mirror of
https://github.com/callumalpass/tasknotes.git
synced 2026-07-22 12:50:26 +00:00
improve dependency cache performance
This commit is contained in:
parent
6f36847464
commit
4d3e351092
4 changed files with 618 additions and 72 deletions
|
|
@ -44,6 +44,7 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l
|
|||
- (#1737) Clarified that changing the task tag does not automatically update existing Base view filters. Thanks to @goldorak00 for reporting this and @Domcoppinger for the first-time setup feedback.
|
||||
- (#2005) Excluded archived task notes and archived project-note references from Task & Project Statistics. Thanks to @Xiarno for suggesting this.
|
||||
- Added a local review-type lint check that catches dependency-resolution warnings from Obsidian's online review before submission.
|
||||
- Improved dependency and project relationship cache updates so ordinary note edits avoid unnecessary relationship recalculation, while blocked and blocking state stays current when dependency status changes.
|
||||
|
||||
## Fixed
|
||||
|
||||
|
|
|
|||
|
|
@ -33,10 +33,14 @@ export class DependencyCache extends Events {
|
|||
// Dependency indexes
|
||||
private dependencySources: Map<string, Set<string>> = new Map(); // task path -> blocking task paths
|
||||
private dependencyTargets: Map<string, Set<string>> = new Map(); // task path -> tasks blocked by this task
|
||||
private activeDependencySources: Map<string, Set<string>> = new Map(); // task path -> incomplete blocking task paths
|
||||
private activeDependencyTargets: Map<string, Set<string>> = new Map(); // task path -> tasks actively blocked by this task
|
||||
|
||||
// Project references index
|
||||
private projectReferences: Map<string, Set<string>> = new Map(); // project path -> Set<task paths that reference it>
|
||||
private projectReferenceSources: Map<string, Set<string>> = new Map(); // task path -> Set<project paths it references>
|
||||
private relationshipFingerprints: Map<string, string> = new Map(); // task path -> normalized dependency/project fields
|
||||
private completedStatusByPath: Map<string, boolean> = new Map(); // file path -> completion state for status-aware dependency lookups
|
||||
|
||||
// Initialization state
|
||||
private initialized = false;
|
||||
|
|
@ -142,15 +146,27 @@ export class DependencyCache extends Events {
|
|||
return;
|
||||
}
|
||||
|
||||
const metadata = this.app.metadataCache.getFileCache(file);
|
||||
if (!metadata?.frontmatter) {
|
||||
this.clearForwardDependencies(file.path);
|
||||
const frontmatter = this.getFrontmatterFromCache(cache) ?? this.getFrontmatterForFile(file);
|
||||
this.updateCompletionState(file.path, frontmatter);
|
||||
|
||||
if (!frontmatter) {
|
||||
if (this.hasForwardRelationships(file.path)) {
|
||||
this.clearForwardDependencies(file.path);
|
||||
}
|
||||
this.triggerIfFileRelationshipsChanged(file.path, before);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isTaskFileCallback(metadata.frontmatter)) {
|
||||
this.clearForwardDependencies(file.path);
|
||||
if (!this.isTaskFileCallback(frontmatter)) {
|
||||
if (this.hasForwardRelationships(file.path)) {
|
||||
this.clearForwardDependencies(file.path);
|
||||
}
|
||||
this.triggerIfFileRelationshipsChanged(file.path, before);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextFingerprint = this.buildRelationshipFingerprint(frontmatter);
|
||||
if (this.relationshipFingerprints.get(file.path) === nextFingerprint) {
|
||||
this.triggerIfFileRelationshipsChanged(file.path, before);
|
||||
return;
|
||||
}
|
||||
|
|
@ -159,7 +175,7 @@ export class DependencyCache extends Events {
|
|||
// Only clear the forward dependencies (tasks this task depends on)
|
||||
// Keep reverse dependencies intact - they'll be updated when other tasks change
|
||||
this.clearForwardDependencies(file.path);
|
||||
this.indexTaskFile(file.path, metadata.frontmatter);
|
||||
this.indexTaskFile(file.path, frontmatter);
|
||||
this.triggerIfFileRelationshipsChanged(file.path, before);
|
||||
}
|
||||
|
||||
|
|
@ -171,7 +187,7 @@ export class DependencyCache extends Events {
|
|||
|
||||
private getFileRelationshipSignature(path: string): string {
|
||||
const blockingTasks = this.sortedSetValues(this.dependencySources.get(path));
|
||||
const blockedTasks = this.sortedSetValues(this.dependencyTargets.get(path));
|
||||
const blockedTasks = this.sortedSetValues(this.activeDependencyTargets.get(path));
|
||||
const referencedProjects = this.sortedSetValues(this.projectReferenceSources.get(path));
|
||||
const projectTasks = this.sortedSetValues(this.projectReferences.get(path));
|
||||
|
||||
|
|
@ -200,22 +216,36 @@ export class DependencyCache extends Events {
|
|||
*/
|
||||
private handleFileRenamed(file: TFile, oldPath: string): void {
|
||||
// Get metadata for new path
|
||||
const metadata = this.app.metadataCache.getFileCache(file);
|
||||
const frontmatter = this.getFrontmatterForFile(file);
|
||||
|
||||
// Clear old path
|
||||
this.clearFileFromIndexes(oldPath);
|
||||
|
||||
// Index new path if it's a task
|
||||
if (
|
||||
this.isValidFile(file.path) &&
|
||||
metadata?.frontmatter &&
|
||||
this.isTaskFileCallback(metadata.frontmatter)
|
||||
) {
|
||||
this.indexTaskFile(file.path, metadata.frontmatter);
|
||||
if (this.isValidFile(file.path) && frontmatter && this.isTaskFileCallback(frontmatter)) {
|
||||
this.indexTaskFile(file.path, frontmatter);
|
||||
}
|
||||
this.trigger(EVENT_DEPENDENCY_CACHE_CHANGED);
|
||||
}
|
||||
|
||||
private getFrontmatterForFile(file: TFile): Record<string, unknown> | null {
|
||||
const metadata = this.app.metadataCache.getFileCache(file);
|
||||
return this.getFrontmatterFromCache(metadata);
|
||||
}
|
||||
|
||||
private getFrontmatterFromCache(cache: unknown): Record<string, unknown> | null {
|
||||
if (!cache || typeof cache !== "object" || !("frontmatter" in cache)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const frontmatter = (cache as { frontmatter?: unknown }).frontmatter;
|
||||
if (!frontmatter || typeof frontmatter !== "object" || Array.isArray(frontmatter)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return frontmatter as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a project reference string to a file path
|
||||
*/
|
||||
|
|
@ -242,6 +272,9 @@ export class DependencyCache extends Events {
|
|||
return;
|
||||
}
|
||||
|
||||
this.relationshipFingerprints.set(path, this.buildRelationshipFingerprint(frontmatter));
|
||||
this.completedStatusByPath.set(path, this.isCompletedFrontmatter(frontmatter));
|
||||
|
||||
const dependenciesField = this.fieldMapper?.toUserField("blockedBy") || "blockedBy";
|
||||
const projectField = this.fieldMapper?.toUserField("projects") || "project";
|
||||
|
||||
|
|
@ -255,13 +288,7 @@ export class DependencyCache extends Events {
|
|||
for (const dep of normalized) {
|
||||
const resolved = resolveDependencyEntry(this.app, path, dep);
|
||||
if (resolved?.path && this.isValidFile(resolved.path)) {
|
||||
blockingTasks.add(resolved.path);
|
||||
|
||||
// Add to targets (reverse mapping)
|
||||
if (!this.dependencyTargets.has(resolved.path)) {
|
||||
this.dependencyTargets.set(resolved.path, new Set());
|
||||
}
|
||||
this.dependencyTargets.get(resolved.path)!.add(path);
|
||||
this.addDependencyLink(path, resolved.path, blockingTasks);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -296,6 +323,166 @@ export class DependencyCache extends Events {
|
|||
}
|
||||
}
|
||||
|
||||
private addDependencyLink(
|
||||
dependentPath: string,
|
||||
blockingPath: string,
|
||||
blockingTasks: Set<string>
|
||||
): void {
|
||||
blockingTasks.add(blockingPath);
|
||||
|
||||
if (!this.dependencyTargets.has(blockingPath)) {
|
||||
this.dependencyTargets.set(blockingPath, new Set());
|
||||
}
|
||||
this.dependencyTargets.get(blockingPath)!.add(dependentPath);
|
||||
|
||||
if (!this.isCompletedPath(blockingPath)) {
|
||||
this.addActiveDependencyLink(dependentPath, blockingPath);
|
||||
}
|
||||
}
|
||||
|
||||
private addActiveDependencyLink(dependentPath: string, blockingPath: string): void {
|
||||
if (!this.activeDependencySources.has(dependentPath)) {
|
||||
this.activeDependencySources.set(dependentPath, new Set());
|
||||
}
|
||||
this.activeDependencySources.get(dependentPath)!.add(blockingPath);
|
||||
|
||||
if (!this.activeDependencyTargets.has(blockingPath)) {
|
||||
this.activeDependencyTargets.set(blockingPath, new Set());
|
||||
}
|
||||
this.activeDependencyTargets.get(blockingPath)!.add(dependentPath);
|
||||
}
|
||||
|
||||
private removeActiveDependencyLink(dependentPath: string, blockingPath: string): void {
|
||||
const activeSources = this.activeDependencySources.get(dependentPath);
|
||||
if (activeSources) {
|
||||
activeSources.delete(blockingPath);
|
||||
if (activeSources.size === 0) {
|
||||
this.activeDependencySources.delete(dependentPath);
|
||||
}
|
||||
}
|
||||
|
||||
const activeTargets = this.activeDependencyTargets.get(blockingPath);
|
||||
if (activeTargets) {
|
||||
activeTargets.delete(dependentPath);
|
||||
if (activeTargets.size === 0) {
|
||||
this.activeDependencyTargets.delete(blockingPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private rebuildActiveLinksForBlocker(blockingPath: string): void {
|
||||
const blockedTasks = this.dependencyTargets.get(blockingPath);
|
||||
this.activeDependencyTargets.delete(blockingPath);
|
||||
|
||||
if (!blockedTasks) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const dependentPath of blockedTasks) {
|
||||
const activeSources = this.activeDependencySources.get(dependentPath);
|
||||
if (activeSources) {
|
||||
activeSources.delete(blockingPath);
|
||||
if (activeSources.size === 0) {
|
||||
this.activeDependencySources.delete(dependentPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isCompletedPath(blockingPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const dependentPath of blockedTasks) {
|
||||
this.addActiveDependencyLink(dependentPath, blockingPath);
|
||||
}
|
||||
}
|
||||
|
||||
private buildRelationshipFingerprint(frontmatter: Record<string, unknown>): string {
|
||||
const dependenciesField = this.fieldMapper?.toUserField("blockedBy") || "blockedBy";
|
||||
const projectField = this.fieldMapper?.toUserField("projects") || "project";
|
||||
|
||||
const dependencies = (normalizeDependencyList(frontmatter[dependenciesField]) ?? [])
|
||||
.map((dependency) => dependency.uid)
|
||||
.filter((uid) => uid.length > 0)
|
||||
.sort();
|
||||
const projects = this.normalizeProjectFingerprintValues(frontmatter[projectField]);
|
||||
|
||||
return JSON.stringify({ dependencies, projects });
|
||||
}
|
||||
|
||||
private normalizeProjectFingerprintValues(value: unknown): string[] {
|
||||
const projects = Array.isArray(value) ? value : value ? [value] : [];
|
||||
const normalized = new Set<string>();
|
||||
|
||||
for (const project of projects) {
|
||||
if (typeof project !== "string") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const trimmed = project.trim();
|
||||
if (trimmed) {
|
||||
normalized.add(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(normalized).sort();
|
||||
}
|
||||
|
||||
private hasForwardRelationships(path: string): boolean {
|
||||
return (
|
||||
this.relationshipFingerprints.has(path) ||
|
||||
this.dependencySources.has(path) ||
|
||||
this.projectReferenceSources.has(path)
|
||||
);
|
||||
}
|
||||
|
||||
private updateCompletionState(path: string, frontmatter: Record<string, unknown> | null): void {
|
||||
const oldCompleted = this.completedStatusByPath.get(path) ?? false;
|
||||
const newCompleted = frontmatter ? this.isCompletedFrontmatter(frontmatter) : false;
|
||||
this.completedStatusByPath.set(path, newCompleted);
|
||||
|
||||
if (oldCompleted !== newCompleted) {
|
||||
this.rebuildActiveLinksForBlocker(path);
|
||||
}
|
||||
}
|
||||
|
||||
private isCompletedPath(path: string): boolean {
|
||||
const cached = this.completedStatusByPath.get(path);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const file = this.app.vault.getAbstractFileByPath(path);
|
||||
if (!(file instanceof TFile)) {
|
||||
this.completedStatusByPath.set(path, false);
|
||||
return false;
|
||||
}
|
||||
|
||||
const frontmatter = this.getFrontmatterForFile(file);
|
||||
const completed = frontmatter ? this.isCompletedFrontmatter(frontmatter) : false;
|
||||
this.completedStatusByPath.set(path, completed);
|
||||
return completed;
|
||||
}
|
||||
|
||||
private isCompletedFrontmatter(frontmatter: Record<string, unknown>): boolean {
|
||||
const statusField = this.fieldMapper?.toUserField("status") || "status";
|
||||
const status = frontmatter[statusField];
|
||||
const statusText = this.stringifyStatusValue(status);
|
||||
return Boolean(statusText && this.statusManager.isCompletedStatus(statusText));
|
||||
}
|
||||
|
||||
private stringifyStatusValue(status: unknown): string | null {
|
||||
if (
|
||||
typeof status === "string" ||
|
||||
typeof status === "number" ||
|
||||
typeof status === "boolean"
|
||||
) {
|
||||
return String(status);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear only forward dependencies (tasks this task depends on)
|
||||
* Used when a task is modified - we rebuild forward deps from frontmatter
|
||||
|
|
@ -314,9 +501,11 @@ export class DependencyCache extends Events {
|
|||
this.dependencyTargets.delete(blockingTask);
|
||||
}
|
||||
}
|
||||
this.removeActiveDependencyLink(path, blockingTask);
|
||||
}
|
||||
this.dependencySources.delete(path);
|
||||
}
|
||||
this.activeDependencySources.delete(path);
|
||||
|
||||
// Also clear project references since those are stored in this task's frontmatter
|
||||
const referencedProjects = this.projectReferenceSources.get(path);
|
||||
|
|
@ -332,6 +521,7 @@ export class DependencyCache extends Events {
|
|||
}
|
||||
this.projectReferenceSources.delete(path);
|
||||
}
|
||||
this.relationshipFingerprints.delete(path);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -351,9 +541,11 @@ export class DependencyCache extends Events {
|
|||
this.dependencyTargets.delete(blockingTask);
|
||||
}
|
||||
}
|
||||
this.removeActiveDependencyLink(path, blockingTask);
|
||||
}
|
||||
this.dependencySources.delete(path);
|
||||
}
|
||||
this.activeDependencySources.delete(path);
|
||||
|
||||
// Clear from dependency targets
|
||||
const blockedTasks = this.dependencyTargets.get(path);
|
||||
|
|
@ -367,9 +559,11 @@ export class DependencyCache extends Events {
|
|||
this.dependencySources.delete(blockedTask);
|
||||
}
|
||||
}
|
||||
this.removeActiveDependencyLink(blockedTask, path);
|
||||
}
|
||||
this.dependencyTargets.delete(path);
|
||||
}
|
||||
this.activeDependencyTargets.delete(path);
|
||||
|
||||
// Clear project references declared by this file
|
||||
const referencedProjects = this.projectReferenceSources.get(path);
|
||||
|
|
@ -400,6 +594,8 @@ export class DependencyCache extends Events {
|
|||
}
|
||||
this.projectReferences.delete(path);
|
||||
}
|
||||
this.relationshipFingerprints.delete(path);
|
||||
this.completedStatusByPath.delete(path);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -438,11 +634,7 @@ export class DependencyCache extends Events {
|
|||
this.buildIndexesSync();
|
||||
}
|
||||
|
||||
if (this.isCompletedTask(taskPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const blocked = this.dependencyTargets.get(taskPath);
|
||||
const blocked = this.activeDependencyTargets.get(taskPath);
|
||||
return blocked ? Array.from(blocked) : [];
|
||||
}
|
||||
|
||||
|
|
@ -451,53 +643,10 @@ export class DependencyCache extends Events {
|
|||
* Only returns true if the task has blocking dependencies that are NOT completed
|
||||
*/
|
||||
isTaskBlocked(taskPath: string): boolean {
|
||||
const blockingPaths = this.getBlockingTaskPaths(taskPath);
|
||||
if (blockingPaths.length === 0) {
|
||||
return false;
|
||||
if (!this.indexesBuilt) {
|
||||
this.buildIndexesSync();
|
||||
}
|
||||
|
||||
const statusField = this.fieldMapper?.toUserField("status") || "status";
|
||||
|
||||
// Check if any blocking task is incomplete
|
||||
for (const blockingPath of blockingPaths) {
|
||||
const file = this.app.vault.getAbstractFileByPath(blockingPath);
|
||||
if (!(file instanceof TFile)) {
|
||||
// If we can't find the blocking task file, consider it as blocking
|
||||
// (conservative approach - better to show blocked than hide it)
|
||||
continue;
|
||||
}
|
||||
|
||||
const metadata = this.app.metadataCache.getFileCache(file);
|
||||
if (!metadata?.frontmatter) {
|
||||
// No metadata means we can't determine status, assume blocking
|
||||
continue;
|
||||
}
|
||||
|
||||
const status = metadata.frontmatter[statusField];
|
||||
if (!status || !this.statusManager.isCompletedStatus(status)) {
|
||||
// Found at least one incomplete blocking task
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// All blocking tasks are completed
|
||||
return false;
|
||||
}
|
||||
|
||||
private isCompletedTask(taskPath: string): boolean {
|
||||
const file = this.app.vault.getAbstractFileByPath(taskPath);
|
||||
if (!(file instanceof TFile)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const metadata = this.app.metadataCache.getFileCache(file);
|
||||
if (!metadata?.frontmatter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const statusField = this.fieldMapper?.toUserField("status") || "status";
|
||||
const status = metadata.frontmatter[statusField];
|
||||
return Boolean(status && this.statusManager.isCompletedStatus(status));
|
||||
return (this.activeDependencySources.get(taskPath)?.size ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -576,8 +725,12 @@ export class DependencyCache extends Events {
|
|||
private clearIndexes(): void {
|
||||
this.dependencySources.clear();
|
||||
this.dependencyTargets.clear();
|
||||
this.activeDependencySources.clear();
|
||||
this.activeDependencyTargets.clear();
|
||||
this.projectReferences.clear();
|
||||
this.projectReferenceSources.clear();
|
||||
this.relationshipFingerprints.clear();
|
||||
this.completedStatusByPath.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
223
tests/performance/dependencyCache.performance.test.ts
Normal file
223
tests/performance/dependencyCache.performance.test.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import { TFile, type App } from "obsidian";
|
||||
import { FieldMapper } from "../../src/services/FieldMapper";
|
||||
import { DEFAULT_FIELD_MAPPING, DEFAULT_SETTINGS } from "../../src/settings/defaults";
|
||||
import { DependencyCache, EVENT_DEPENDENCY_CACHE_CHANGED } from "../../src/utils/DependencyCache";
|
||||
|
||||
jest.mock("obsidian");
|
||||
|
||||
type MetadataChangedHandler = (file: TFile, data: unknown, cache: unknown) => void;
|
||||
|
||||
type MockApp = App & {
|
||||
__files: Map<string, TFile>;
|
||||
__metadata: Map<string, { frontmatter: Record<string, unknown> }>;
|
||||
__metadataChangedHandlers: MetadataChangedHandler[];
|
||||
__linkResolutionCalls: number;
|
||||
};
|
||||
|
||||
interface LargeGraphFixture {
|
||||
app: MockApp;
|
||||
cache: DependencyCache;
|
||||
blockerPaths: string[];
|
||||
dependentPaths: string[];
|
||||
changeHandler: jest.Mock;
|
||||
}
|
||||
|
||||
function createMockApp(): MockApp {
|
||||
const files = new Map<string, TFile>();
|
||||
const metadata = new Map<string, { frontmatter: Record<string, unknown> }>();
|
||||
const metadataChangedHandlers: MetadataChangedHandler[] = [];
|
||||
|
||||
const app = {
|
||||
__files: files,
|
||||
__metadata: metadata,
|
||||
__metadataChangedHandlers: metadataChangedHandlers,
|
||||
__linkResolutionCalls: 0,
|
||||
vault: {
|
||||
getMarkdownFiles: jest.fn(() => Array.from(files.values())),
|
||||
getAbstractFileByPath: jest.fn((path: string) => files.get(path) ?? null),
|
||||
on: jest.fn(() => ({})),
|
||||
},
|
||||
metadataCache: {
|
||||
getFileCache: jest.fn((file: TFile) => metadata.get(file.path) ?? null),
|
||||
getFirstLinkpathDest: jest.fn((linkpath: string) => {
|
||||
app.__linkResolutionCalls++;
|
||||
return files.get(linkpath) ?? files.get(`${linkpath}.md`) ?? null;
|
||||
}),
|
||||
on: jest.fn((eventName: string, handler: MetadataChangedHandler) => {
|
||||
if (eventName === "changed") {
|
||||
metadataChangedHandlers.push(handler);
|
||||
}
|
||||
return {};
|
||||
}),
|
||||
offref: jest.fn(),
|
||||
},
|
||||
} as unknown as MockApp;
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
function createFile(app: MockApp, path: string, frontmatter: Record<string, unknown>): TFile {
|
||||
const file = new TFile(path);
|
||||
app.__files.set(path, file);
|
||||
app.__metadata.set(path, { frontmatter });
|
||||
return file;
|
||||
}
|
||||
|
||||
function createDependencyCache(app: MockApp): DependencyCache {
|
||||
return new DependencyCache(
|
||||
app,
|
||||
{
|
||||
...DEFAULT_SETTINGS,
|
||||
taskIdentificationMethod: "tag",
|
||||
taskTag: "task",
|
||||
},
|
||||
new FieldMapper(DEFAULT_FIELD_MAPPING),
|
||||
{ isCompletedStatus: jest.fn((status: string) => status === "done") } as never,
|
||||
(frontmatter) => Array.isArray((frontmatter as { tags?: unknown }).tags)
|
||||
);
|
||||
}
|
||||
|
||||
function createLargeGraph(blockerCount = 250, dependentCount = 2500): LargeGraphFixture {
|
||||
const app = createMockApp();
|
||||
const blockerPaths: string[] = [];
|
||||
const dependentPaths: string[] = [];
|
||||
|
||||
for (let i = 0; i < blockerCount; i++) {
|
||||
const path = `Tasks/Blocker-${i}.md`;
|
||||
blockerPaths.push(path);
|
||||
createFile(app, path, {
|
||||
title: `Blocker ${i}`,
|
||||
status: "open",
|
||||
tags: ["task"],
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < dependentCount; i++) {
|
||||
const blockerPath = blockerPaths[i % blockerPaths.length];
|
||||
const path = `Tasks/Dependent-${i}.md`;
|
||||
dependentPaths.push(path);
|
||||
createFile(app, path, {
|
||||
title: `Dependent ${i}`,
|
||||
status: "open",
|
||||
tags: ["task"],
|
||||
blockedBy: [{ uid: `[[${blockerPath}]]`, reltype: "FINISHTOSTART" }],
|
||||
projects: [`[[Projects/Project-${i % 20}.md]]`],
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
createFile(app, `Projects/Project-${i}.md`, {
|
||||
title: `Project ${i}`,
|
||||
});
|
||||
}
|
||||
|
||||
const cache = createDependencyCache(app);
|
||||
const changeHandler = jest.fn();
|
||||
cache.on(EVENT_DEPENDENCY_CACHE_CHANGED, changeHandler);
|
||||
cache.initialize();
|
||||
|
||||
return { app, cache, blockerPaths, dependentPaths, changeHandler };
|
||||
}
|
||||
|
||||
function invokeMetadataChanged(app: MockApp, path: string): void {
|
||||
const file = app.__files.get(path);
|
||||
if (!file) {
|
||||
throw new Error(`Missing file ${path}`);
|
||||
}
|
||||
const cache = app.__metadata.get(path) ?? null;
|
||||
for (const handler of app.__metadataChangedHandlers) {
|
||||
handler(file, null, cache);
|
||||
}
|
||||
}
|
||||
|
||||
function logMetric(label: string, metric: Record<string, number>): void {
|
||||
if (process.env.TN_PERF_LOG === "1") {
|
||||
process.stderr.write(`[dependency-cache-perf] ${label} ${JSON.stringify(metric)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
describe("DependencyCache performance smoke", () => {
|
||||
it("measures large-graph build, edit, and read-heavy paths", async () => {
|
||||
const { app, cache, blockerPaths, dependentPaths, changeHandler } = createLargeGraph();
|
||||
|
||||
let start = performance.now();
|
||||
await cache.buildIndexes();
|
||||
const buildDurationMs = performance.now() - start;
|
||||
const buildLinkResolutionCalls = app.__linkResolutionCalls;
|
||||
expect(cache.isTaskBlocked(dependentPaths[0])).toBe(true);
|
||||
expect(cache.getBlockedTaskPaths(blockerPaths[0]).length).toBeGreaterThan(0);
|
||||
|
||||
app.__linkResolutionCalls = 0;
|
||||
changeHandler.mockClear();
|
||||
start = performance.now();
|
||||
for (const path of dependentPaths) {
|
||||
const entry = app.__metadata.get(path);
|
||||
if (!entry) continue;
|
||||
app.__metadata.set(path, {
|
||||
frontmatter: {
|
||||
...entry.frontmatter,
|
||||
details: "body-like metadata churn that does not affect relationships",
|
||||
},
|
||||
});
|
||||
invokeMetadataChanged(app, path);
|
||||
}
|
||||
const nonRelationshipEditDurationMs = performance.now() - start;
|
||||
const nonRelationshipEditLinkResolutionCalls = app.__linkResolutionCalls;
|
||||
const nonRelationshipEditEvents = changeHandler.mock.calls.length;
|
||||
|
||||
app.__linkResolutionCalls = 0;
|
||||
changeHandler.mockClear();
|
||||
start = performance.now();
|
||||
const blockerEntry = app.__metadata.get(blockerPaths[0]);
|
||||
if (!blockerEntry) {
|
||||
throw new Error("Missing blocker metadata");
|
||||
}
|
||||
app.__metadata.set(blockerPaths[0], {
|
||||
frontmatter: {
|
||||
...blockerEntry.frontmatter,
|
||||
status: "done",
|
||||
},
|
||||
});
|
||||
invokeMetadataChanged(app, blockerPaths[0]);
|
||||
const statusChangeDurationMs = performance.now() - start;
|
||||
const statusChangeLinkResolutionCalls = app.__linkResolutionCalls;
|
||||
const statusChangeEvents = changeHandler.mock.calls.length;
|
||||
expect(cache.isTaskBlocked(dependentPaths[0])).toBe(false);
|
||||
|
||||
app.__linkResolutionCalls = 0;
|
||||
(app.metadataCache.getFileCache as jest.Mock).mockClear();
|
||||
start = performance.now();
|
||||
for (const path of dependentPaths) {
|
||||
cache.isTaskBlocked(path);
|
||||
}
|
||||
for (const path of blockerPaths) {
|
||||
cache.getBlockedTaskPaths(path);
|
||||
}
|
||||
const readHeavyDurationMs = performance.now() - start;
|
||||
const readHeavyLinkResolutionCalls = app.__linkResolutionCalls;
|
||||
const readHeavyMetadataReads = (app.metadataCache.getFileCache as jest.Mock).mock.calls
|
||||
.length;
|
||||
|
||||
logMetric("large-graph", {
|
||||
buildDurationMs,
|
||||
buildLinkResolutionCalls,
|
||||
nonRelationshipEditDurationMs,
|
||||
nonRelationshipEditLinkResolutionCalls,
|
||||
nonRelationshipEditEvents,
|
||||
statusChangeDurationMs,
|
||||
statusChangeLinkResolutionCalls,
|
||||
statusChangeEvents,
|
||||
readHeavyDurationMs,
|
||||
readHeavyLinkResolutionCalls,
|
||||
readHeavyMetadataReads,
|
||||
});
|
||||
|
||||
expect(buildLinkResolutionCalls).toBeGreaterThan(0);
|
||||
expect(nonRelationshipEditLinkResolutionCalls).toBe(0);
|
||||
expect(nonRelationshipEditEvents).toBe(0);
|
||||
expect(statusChangeLinkResolutionCalls).toBe(0);
|
||||
expect(statusChangeEvents).toBe(1);
|
||||
expect(readHeavyLinkResolutionCalls).toBe(0);
|
||||
expect(readHeavyMetadataReads).toBe(0);
|
||||
});
|
||||
});
|
||||
169
tests/unit/utils/DependencyCache.optimization.test.ts
Normal file
169
tests/unit/utils/DependencyCache.optimization.test.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { TFile, type App } from "obsidian";
|
||||
import { FieldMapper } from "../../../src/services/FieldMapper";
|
||||
import { DEFAULT_FIELD_MAPPING, DEFAULT_SETTINGS } from "../../../src/settings/defaults";
|
||||
import {
|
||||
DependencyCache,
|
||||
EVENT_DEPENDENCY_CACHE_CHANGED,
|
||||
} from "../../../src/utils/DependencyCache";
|
||||
|
||||
jest.mock("obsidian");
|
||||
|
||||
type MetadataChangedHandler = (file: TFile, data: unknown, cache: unknown) => void;
|
||||
|
||||
type MockApp = App & {
|
||||
__files: Map<string, TFile>;
|
||||
__metadata: Map<string, { frontmatter: Record<string, unknown> }>;
|
||||
__metadataChangedHandlers: MetadataChangedHandler[];
|
||||
__linkResolutionCalls: number;
|
||||
};
|
||||
|
||||
function createMockApp(): MockApp {
|
||||
const files = new Map<string, TFile>();
|
||||
const metadata = new Map<string, { frontmatter: Record<string, unknown> }>();
|
||||
const metadataChangedHandlers: MetadataChangedHandler[] = [];
|
||||
|
||||
const app = {
|
||||
__files: files,
|
||||
__metadata: metadata,
|
||||
__metadataChangedHandlers: metadataChangedHandlers,
|
||||
__linkResolutionCalls: 0,
|
||||
vault: {
|
||||
getMarkdownFiles: jest.fn(() => Array.from(files.values())),
|
||||
getAbstractFileByPath: jest.fn((path: string) => files.get(path) ?? null),
|
||||
on: jest.fn(() => ({})),
|
||||
},
|
||||
metadataCache: {
|
||||
getFileCache: jest.fn((file: TFile) => metadata.get(file.path) ?? null),
|
||||
getFirstLinkpathDest: jest.fn((linkpath: string) => {
|
||||
app.__linkResolutionCalls++;
|
||||
return files.get(linkpath) ?? files.get(`${linkpath}.md`) ?? null;
|
||||
}),
|
||||
on: jest.fn((eventName: string, handler: MetadataChangedHandler) => {
|
||||
if (eventName === "changed") {
|
||||
metadataChangedHandlers.push(handler);
|
||||
}
|
||||
return {};
|
||||
}),
|
||||
offref: jest.fn(),
|
||||
},
|
||||
} as unknown as MockApp;
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
function createFile(app: MockApp, path: string, frontmatter: Record<string, unknown>): TFile {
|
||||
const file = new TFile(path);
|
||||
app.__files.set(path, file);
|
||||
app.__metadata.set(path, { frontmatter });
|
||||
return file;
|
||||
}
|
||||
|
||||
function createDependencyCache(app: MockApp): DependencyCache {
|
||||
return new DependencyCache(
|
||||
app,
|
||||
{
|
||||
...DEFAULT_SETTINGS,
|
||||
taskIdentificationMethod: "tag",
|
||||
taskTag: "task",
|
||||
},
|
||||
new FieldMapper(DEFAULT_FIELD_MAPPING),
|
||||
{ isCompletedStatus: jest.fn((status: string) => status === "done") } as never,
|
||||
(frontmatter) => Array.isArray((frontmatter as { tags?: unknown }).tags)
|
||||
);
|
||||
}
|
||||
|
||||
function invokeMetadataChanged(app: MockApp, path: string): void {
|
||||
const file = app.__files.get(path);
|
||||
if (!file) {
|
||||
throw new Error(`Missing file ${path}`);
|
||||
}
|
||||
const cache = app.__metadata.get(path) ?? null;
|
||||
for (const handler of app.__metadataChangedHandlers) {
|
||||
handler(file, null, cache);
|
||||
}
|
||||
}
|
||||
|
||||
describe("DependencyCache optimized relationship updates", () => {
|
||||
it("skips link resolution when task metadata changes without relationship changes", async () => {
|
||||
const app = createMockApp();
|
||||
createFile(app, "Tasks/Blocker.md", {
|
||||
title: "Blocker",
|
||||
status: "open",
|
||||
tags: ["task"],
|
||||
});
|
||||
createFile(app, "Projects/Alpha.md", {
|
||||
title: "Alpha",
|
||||
});
|
||||
createFile(app, "Tasks/Dependent.md", {
|
||||
title: "Dependent",
|
||||
status: "open",
|
||||
tags: ["task"],
|
||||
blockedBy: [{ uid: "[[Tasks/Blocker.md]]", reltype: "FINISHTOSTART" }],
|
||||
projects: ["[[Projects/Alpha.md]]"],
|
||||
});
|
||||
|
||||
const cache = createDependencyCache(app);
|
||||
const changeHandler = jest.fn();
|
||||
cache.on(EVENT_DEPENDENCY_CACHE_CHANGED, changeHandler);
|
||||
cache.initialize();
|
||||
await cache.buildIndexes();
|
||||
changeHandler.mockClear();
|
||||
app.__linkResolutionCalls = 0;
|
||||
|
||||
app.__metadata.set("Tasks/Dependent.md", {
|
||||
frontmatter: {
|
||||
title: "Dependent renamed",
|
||||
status: "open",
|
||||
tags: ["task"],
|
||||
blockedBy: [{ uid: "[[Tasks/Blocker.md]]", reltype: "FINISHTOSTART" }],
|
||||
projects: ["[[Projects/Alpha.md]]"],
|
||||
},
|
||||
});
|
||||
invokeMetadataChanged(app, "Tasks/Dependent.md");
|
||||
|
||||
expect(app.__linkResolutionCalls).toBe(0);
|
||||
expect(changeHandler).not.toHaveBeenCalled();
|
||||
expect(cache.isTaskBlocked("Tasks/Dependent.md")).toBe(true);
|
||||
expect(cache.isFileUsedAsProject("Projects/Alpha.md")).toBe(true);
|
||||
});
|
||||
|
||||
it("updates status-aware active relationships without re-resolving dependency links", async () => {
|
||||
const app = createMockApp();
|
||||
createFile(app, "Tasks/Blocker.md", {
|
||||
title: "Blocker",
|
||||
status: "open",
|
||||
tags: ["task"],
|
||||
});
|
||||
createFile(app, "Tasks/Dependent.md", {
|
||||
title: "Dependent",
|
||||
status: "open",
|
||||
tags: ["task"],
|
||||
blockedBy: [{ uid: "[[Tasks/Blocker.md]]", reltype: "FINISHTOSTART" }],
|
||||
});
|
||||
|
||||
const cache = createDependencyCache(app);
|
||||
const changeHandler = jest.fn();
|
||||
cache.on(EVENT_DEPENDENCY_CACHE_CHANGED, changeHandler);
|
||||
cache.initialize();
|
||||
await cache.buildIndexes();
|
||||
changeHandler.mockClear();
|
||||
app.__linkResolutionCalls = 0;
|
||||
|
||||
expect(cache.isTaskBlocked("Tasks/Dependent.md")).toBe(true);
|
||||
expect(cache.getBlockedTaskPaths("Tasks/Blocker.md")).toEqual(["Tasks/Dependent.md"]);
|
||||
|
||||
app.__metadata.set("Tasks/Blocker.md", {
|
||||
frontmatter: {
|
||||
title: "Blocker",
|
||||
status: "done",
|
||||
tags: ["task"],
|
||||
},
|
||||
});
|
||||
invokeMetadataChanged(app, "Tasks/Blocker.md");
|
||||
|
||||
expect(app.__linkResolutionCalls).toBe(0);
|
||||
expect(changeHandler).toHaveBeenCalledTimes(1);
|
||||
expect(cache.isTaskBlocked("Tasks/Dependent.md")).toBe(false);
|
||||
expect(cache.getBlockedTaskPaths("Tasks/Blocker.md")).toEqual([]);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue