-
Notifications
You must be signed in to change notification settings - Fork 235
fix(task-history): atomic per-task merge and drop shared index file (#1231) #1261
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
edelauna
wants to merge
5
commits into
main
Choose a base branch
from
issue/1231
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
aee7d64
fix(task-history): prevent concurrent index clobbering
edelauna 2fe1189
test(task-history): add regression coverage for self-healing index re…
edelauna 4e6fa84
fix(task-history): atomic read-modify-write
edelauna cb04fd4
fix(task-history): atomic read-modify-write
edelauna 98cd2bc
fix(task-history): drop _index.json, scan task dirs on read
edelauna File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -33,15 +33,6 @@ export function assertValidTransition(from: HistoryItemStatus | undefined, to: H | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * Index file format for fast startup reads. | ||
| */ | ||
| interface HistoryIndex { | ||
| version: number | ||
| updatedAt: number | ||
| entries: HistoryItem[] | ||
| } | ||
|
|
||
| /** | ||
| * Durable intent for the one repair that spans an active delegated child and | ||
| * its parent. Task files remain authoritative; this file only records the | ||
|
|
@@ -75,12 +66,14 @@ interface DelegationRepairIntent { | |
| * | ||
| * Each task's HistoryItem is stored as an individual JSON file in its | ||
| * existing task directory (`globalStorage/tasks/<taskId>/history_item.json`). | ||
| * A single index file (`globalStorage/tasks/_index.json`) is maintained | ||
| * as a cache for fast list reads at startup. | ||
| * There is no shared index file. Reads scan the task directories. | ||
| * | ||
| * Cross-process safety comes from `safeWriteJson`'s `proper-lockfile` | ||
| * on per-task file writes. Within a single extension host process, | ||
| * an in-process write lock serializes mutations. | ||
| * Cross-process safety for per-task files comes from `safeWriteJson`'s | ||
| * `proper-lockfile` with a `merge` callback: each write reads the | ||
| * current file under the advisory lock and merges incoming fields, so | ||
| * a concurrent writer's changes are preserved rather than silently | ||
| * dropped. Within a single extension host process, an in-process write | ||
| * lock serializes mutations. | ||
| */ | ||
| /** | ||
| * Options for TaskHistoryStore constructor. | ||
|
|
@@ -100,7 +93,6 @@ export class TaskHistoryStore { | |
| private cache: Map<string, HistoryItem> = new Map() | ||
| private taskFileMtimes: Map<string, number> = new Map() | ||
| private writeLock: Promise<void> = Promise.resolve() | ||
| private indexWriteTimer: ReturnType<typeof setTimeout> | null = null | ||
| private fsWatcher: fsSync.FSWatcher | null = null | ||
| private reconcileTimer: ReturnType<typeof setTimeout> | null = null | ||
| private disposed = false | ||
|
|
@@ -112,9 +104,6 @@ export class TaskHistoryStore { | |
| public readonly initialized: Promise<void> | ||
| private resolveInitialized!: () => void | ||
|
|
||
| /** Debounce window for index writes in milliseconds. */ | ||
| private static readonly INDEX_WRITE_DEBOUNCE_MS = 2000 | ||
|
|
||
| /** Periodic reconciliation interval in milliseconds. */ | ||
| private static readonly RECONCILE_INTERVAL_MS = 5 * 60 * 1000 | ||
|
|
||
|
|
@@ -136,30 +125,27 @@ export class TaskHistoryStore { | |
| const tasksDir = await this.getTasksDir() | ||
| await fs.mkdir(tasksDir, { recursive: true }) | ||
|
|
||
| // 1. Load existing index into the cache | ||
| await this.loadIndex() | ||
|
|
||
| // 2. Reconcile cache against actual task directories on disk | ||
| // 1. Scan task directories to populate the cache | ||
| await this.reconcile({ forceRefresh: true }) | ||
| // Capture which active tasks were present in persisted state before replay can | ||
| // change any statuses. Reconciliation must not treat a replay-repaired parent | ||
| // as an orphaned active child in the same startup pass. | ||
| const persistedActiveIds = this.getPersistedActiveIds() | ||
|
|
||
| // 3. Complete any two-record repair interrupted after its intent was durable. | ||
| // 2. Complete any two-record repair interrupted after its intent was durable. | ||
| try { | ||
| await this.replayDelegationRepairIntent() | ||
| } catch (error) { | ||
| console.error("[TaskHistoryStore] Failed to replay delegation repair intent:", error) | ||
| } | ||
|
|
||
| // 4. Repair delegation inconsistencies left by a previous crash | ||
| // 3. Repair delegation inconsistencies left by a previous crash | ||
| await this.reconcileDelegationState(persistedActiveIds) | ||
|
|
||
| // 5. Start fs.watch for cross-instance reactivity | ||
| // 4. Start fs.watch for cross-instance reactivity | ||
| this.startWatcher() | ||
|
|
||
| // 6. Start periodic reconciliation as a defensive fallback | ||
| // 5. Start periodic reconciliation as a defensive fallback | ||
| this.startPeriodicReconciliation() | ||
| } finally { | ||
| // Mark initialization as complete so callers awaiting `initialized` can proceed | ||
|
|
@@ -173,11 +159,6 @@ export class TaskHistoryStore { | |
| dispose(): void { | ||
| this.disposed = true | ||
|
|
||
| if (this.indexWriteTimer) { | ||
| clearTimeout(this.indexWriteTimer) | ||
| this.indexWriteTimer = null | ||
| } | ||
|
|
||
| if (this.reconcileTimer) { | ||
| clearTimeout(this.reconcileTimer) | ||
| this.reconcileTimer = null | ||
|
|
@@ -187,11 +168,6 @@ export class TaskHistoryStore { | |
| this.fsWatcher.close() | ||
| this.fsWatcher = null | ||
| } | ||
|
|
||
| // Synchronously flush the index (best-effort) | ||
| this.flushIndex().catch((err) => { | ||
| console.error("[TaskHistoryStore] Error flushing index on dispose:", err) | ||
| }) | ||
| } | ||
|
|
||
| // ────────────────────────────── Reads ────────────────────────────── | ||
|
|
@@ -257,13 +233,18 @@ export class TaskHistoryStore { | |
| // Merge: preserve existing metadata unless explicitly overwritten | ||
| const merged = existing ? { ...existing, ...item } : item | ||
|
|
||
| // Write per-task file (source of truth) | ||
| await this.writeTaskFile(merged) | ||
| // Compute the actual changed fields relative to the cached state. | ||
| // Only these are applied to the disk version, so fields updated by | ||
| // another process are preserved rather than reverted from a stale cache. | ||
| const delta = existing | ||
| ? Object.fromEntries( | ||
| Object.entries(item).filter(([k, v]) => !deepEqual(v, (existing as Record<string, unknown>)[k])), | ||
| ) | ||
| : undefined | ||
| await this.writeTaskFile(merged, delta ? ({ id: item.id, ...delta } as HistoryItem) : undefined) | ||
|
|
||
| // Update in-memory cache | ||
| this.cache.set(merged.id, merged) | ||
| // Schedule debounced index write | ||
| this.scheduleIndexWrite() | ||
|
|
||
| const all = this.getAll() | ||
|
|
||
|
|
@@ -291,8 +272,6 @@ export class TaskHistoryStore { | |
| // File may already be deleted | ||
| } | ||
|
|
||
| this.scheduleIndexWrite() | ||
|
|
||
| // Call onWrite callback inside the lock for serialized write-through | ||
| if (this.onWrite) { | ||
| await this.onWrite(this.getAll()) | ||
|
|
@@ -317,8 +296,6 @@ export class TaskHistoryStore { | |
| } | ||
| } | ||
|
|
||
| this.scheduleIndexWrite() | ||
|
|
||
| // Call onWrite callback inside the lock for serialized write-through | ||
| if (this.onWrite) { | ||
| await this.onWrite(this.getAll()) | ||
|
|
@@ -346,20 +323,18 @@ export class TaskHistoryStore { | |
| return // tasks dir doesn't exist yet | ||
| } | ||
|
|
||
| // Filter out the index file and hidden files | ||
| // Filter out hidden and reserved names | ||
| const taskDirNames = dirEntries.filter((name) => !name.startsWith("_") && !name.startsWith(".")) | ||
|
|
||
| const onDiskIds = new Set(taskDirNames) | ||
| const cacheIds = new Set(this.cache.keys()) | ||
| let changed = false | ||
| const liveIds = new Set<string>() | ||
|
|
||
| // Task files are authoritative during startup. Later watcher and periodic | ||
| // reconciliations use mtime change detection to avoid rewriting the index when | ||
| // nothing changed on disk. | ||
| for (const taskId of onDiskIds) { | ||
| try { | ||
| const taskFilePath = await this.getTaskFilePath(taskId) | ||
| const { mtimeMs } = await fs.stat(taskFilePath) | ||
| liveIds.add(taskId) | ||
| if ( | ||
| !options.forceRefresh && | ||
| this.cache.has(taskId) && | ||
|
|
@@ -374,26 +349,20 @@ export class TaskHistoryStore { | |
| this.taskFileMtimes.set(taskId, mtimeMs) | ||
| if (!deepEqual(previous, item)) { | ||
| this.cache.set(taskId, item) | ||
| changed = true | ||
| } | ||
| } | ||
| } catch { | ||
| // Corrupted or missing file, skip | ||
| // history_item.json missing or corrupt — not live | ||
| } | ||
| } | ||
|
|
||
| // Tasks in cache but not on disk: remove from cache | ||
| // Evict tasks whose history_item.json no longer exists | ||
| for (const taskId of cacheIds) { | ||
| if (!onDiskIds.has(taskId)) { | ||
| if (!liveIds.has(taskId)) { | ||
| this.cache.delete(taskId) | ||
| this.taskFileMtimes.delete(taskId) | ||
| changed = true | ||
| } | ||
| } | ||
|
|
||
| if (changed) { | ||
| this.scheduleIndexWrite() | ||
| } | ||
| }) | ||
| } | ||
|
|
||
|
|
@@ -584,12 +553,6 @@ export class TaskHistoryStore { | |
| await this.onWrite(this.getAll()) | ||
| } | ||
| await this.removeDelegationRepairIntent() | ||
| // Task files are authoritative and the intent is the recovery journal. | ||
| // Clean up the journal before scheduling the derived index: a crash after | ||
| // cleanup but before the index write is safe because startup rebuilds the | ||
| // index from task files, while the reverse ordering could make the index | ||
| // appear durable before recovery metadata is settled. | ||
| this.scheduleIndexWrite() | ||
| }) | ||
| } | ||
|
|
||
|
|
@@ -645,9 +608,6 @@ export class TaskHistoryStore { | |
| await this.onWrite(this.getAll()) | ||
| } | ||
| await this.removeDelegationRepairIntent() | ||
| // The index is derived state; keep the intent until authoritative task-file | ||
| // writes and write-through have completed, then schedule the index update. | ||
| this.scheduleIndexWrite() | ||
| } | ||
|
|
||
| private matchesDelegationRepairParentPreconditions(intent: DelegationRepairIntent, parent: HistoryItem): boolean { | ||
|
|
@@ -845,96 +805,36 @@ export class TaskHistoryStore { | |
| } | ||
| } | ||
|
|
||
| // Write the index | ||
| await this.writeIndex() | ||
|
|
||
| // Repair any delegation inconsistencies introduced by the migrated entries. | ||
| // Run the lock-free core because migration already holds the store lock. | ||
| await this.reconcileDelegationStateCore(this.getPersistedActiveIds()) | ||
| }) | ||
| } | ||
|
|
||
| // ────────────────────────────── Private: Index management ────────────────────────────── | ||
|
|
||
| /** | ||
| * Load the `_index.json` file into the in-memory cache. | ||
| */ | ||
| private async loadIndex(): Promise<void> { | ||
| const indexPath = await this.getIndexPath() | ||
|
|
||
| try { | ||
| const raw = await fs.readFile(indexPath, "utf8") | ||
| const index: HistoryIndex = JSON.parse(raw) | ||
|
|
||
| if (index.version === 1 && Array.isArray(index.entries)) { | ||
| for (const entry of index.entries) { | ||
| if (entry.id) { | ||
| this.cache.set(entry.id, entry) | ||
| } | ||
| } | ||
| } | ||
| } catch { | ||
| // Index doesn't exist or is corrupted; cache stays empty. | ||
| // Reconciliation will rebuild it from per-task files. | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Write the full index to disk. | ||
| */ | ||
| private async writeIndex(): Promise<void> { | ||
| const indexPath = await this.getIndexPath() | ||
| const index: HistoryIndex = { | ||
| version: 1, | ||
| updatedAt: Date.now(), | ||
| entries: this.getAll(), | ||
| } | ||
|
|
||
| await safeWriteJson(indexPath, index) | ||
| } | ||
|
|
||
| /** | ||
| * Schedule a debounced index write. | ||
| */ | ||
| private scheduleIndexWrite(): void { | ||
| if (this.disposed) { | ||
| return | ||
| } | ||
|
|
||
| if (this.indexWriteTimer) { | ||
| clearTimeout(this.indexWriteTimer) | ||
| } | ||
|
|
||
| this.indexWriteTimer = setTimeout(async () => { | ||
| this.indexWriteTimer = null | ||
| try { | ||
| await this.writeIndex() | ||
| } catch (err) { | ||
| console.error("[TaskHistoryStore] Failed to write index:", err) | ||
| } | ||
| }, TaskHistoryStore.INDEX_WRITE_DEBOUNCE_MS) | ||
| } | ||
|
|
||
| /** | ||
| * Force an immediate index write (called on dispose/shutdown). | ||
| */ | ||
| async flushIndex(): Promise<void> { | ||
| if (this.indexWriteTimer) { | ||
| clearTimeout(this.indexWriteTimer) | ||
| this.indexWriteTimer = null | ||
| } | ||
|
|
||
| await this.writeIndex() | ||
| } | ||
|
|
||
| // ────────────────────────────── Private: Per-task file I/O ────────────────────────────── | ||
|
|
||
| /** | ||
| * Write a HistoryItem to its per-task `history_item.json` file. | ||
| * | ||
| * When `delta` is provided, the merge callback applies only the | ||
| * delta to the current disk state, so fields written by another | ||
| * process are preserved. Without a delta the full item is written | ||
| * as-is (used by administrative repair paths that are authoritative). | ||
| */ | ||
| private async writeTaskFile(item: HistoryItem): Promise<void> { | ||
| private async writeTaskFile(item: HistoryItem, delta?: Partial<HistoryItem>): Promise<void> { | ||
| const filePath = await this.getTaskFilePath(item.id) | ||
| await safeWriteJson(filePath, item) | ||
| if (delta) { | ||
| await safeWriteJson(filePath, item, { | ||
| merge: (existing, incoming) => { | ||
| if (!existing || typeof existing !== "object" || !("id" in existing)) { | ||
| return incoming | ||
| } | ||
| return { ...existing, ...delta } | ||
| }, | ||
| }) | ||
| } else { | ||
| await safeWriteJson(filePath, item) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -1105,16 +1005,23 @@ export class TaskHistoryStore { | |
| const mergedFirst = { ...first, ...updatedFirst } | ||
| const mergedSecond = { ...second, ...updatedSecond } | ||
|
|
||
| // Compute actual diffs against cached state, mirroring upsertCore. | ||
| const deltaFirst = Object.fromEntries( | ||
| Object.entries(updatedFirst).filter(([k, v]) => !deepEqual(v, (first as Record<string, unknown>)[k])), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. to have a single point of truth, AND make it testable in isolation, would it not be good to extract the three delta calculations into one local private method? |
||
| ) | ||
| const deltaSecond = Object.fromEntries( | ||
| Object.entries(updatedSecond).filter(([k, v]) => !deepEqual(v, (second as Record<string, unknown>)[k])), | ||
| ) | ||
|
|
||
| // Write both files before touching the cache so readers never observe a | ||
| // half-updated in-memory state between the two await points. | ||
| await this.writeTaskFile(mergedFirst) | ||
| await this.writeTaskFile(mergedSecond) | ||
| await this.writeTaskFile(mergedFirst, { id: firstId, ...deltaFirst } as HistoryItem) | ||
| await this.writeTaskFile(mergedSecond, { id: secondId, ...deltaSecond } as HistoryItem) | ||
|
|
||
| // Both disk writes succeeded — now update the cache atomically. | ||
| this.cache.set(firstId, mergedFirst) | ||
| this.cache.set(secondId, mergedSecond) | ||
|
|
||
| this.scheduleIndexWrite() | ||
| const all = this.getAll() | ||
| if (this.onWrite) { | ||
| await this.onWrite(all) | ||
|
|
@@ -1155,12 +1062,4 @@ export class TaskHistoryStore { | |
| const tasksDir = await this.getTasksDir() | ||
| return path.join(tasksDir, taskId, GlobalFileNames.historyItem) | ||
| } | ||
|
|
||
| /** | ||
| * Get the path to the `_index.json` file. | ||
| */ | ||
| private async getIndexPath(): Promise<string> { | ||
| const tasksDir = await this.getTasksDir() | ||
| return path.join(tasksDir, GlobalFileNames.historyIndex) | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if it would be good to extract the merge functionality into a local function of it's own to make it testable in isolation