fix(datastores): pullChanged skips and prunes dangling index entries (#867) #83
Loading…
Reference in a new issue
No description provided.
Delete branch "worktree-867"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
gc,sync,model method run,compact) were blocked because the pre-sync pull aborted on 9,956 dangling index entries with no built-in recoveryChanges
datastore/s3/extensions/datastores/_lib/s3_cache_sync.ts— detect NotFound/NoSuchKey in download batch loop (matchinghydrateFile()precedent), prune entry, setindexMutated, expand local rewrite condition to(pulled > 0 || indexMutated), push cleaned index back to S3datastore/gcs/extensions/datastores/_lib/gcs_cache_sync.ts— identical fix usingNotFoundErrorclassTest plan
swamp datastore syncpulled 2 real files with 0 errors, remote index self-healed, follow-up sync cleanswamp extension source addpointing to the modified local extensionAdversarial Review
Medium
Stale fingerprint passed to
markSyncedafter remote index writeback defeats fast path (both files, identical bug)GCS —
gcs_cache_sync.ts:1079-1086: WhenindexMutatedis true from pruning dangling entries, the new code writes the cleaned index to GCS viaputObject, but discards the PUT response. It then callsmarkSynced(indexGeneration)at line 1086 with the old generation captured from the originalpullIndexGET. TheputObjectchanged the remote index's generation — so the sidecar now records a stale fingerprint. The nexttryFastPullChangedHEADs the remote, sees the new generation, mismatches against the sidecar's old generation, and falls through to the slow path every time.S3 —
s3_cache_sync.ts:1198-1205: Same bug. The PUT response's ETag is discarded andmarkSynced(indexETag)at line 1205 records the pre-PUT ETag.Contrast with
pushChanged: The existingpushChangedwriteback (GCS line 1352, S3 line 1485) correctly capturesputResult.generation/putResult.etagfrom the PUT response and passes it tomarkSynced. The newpullChangedwriteback doesn't follow this pattern.Breaking example: A repo with 1 dangling index entry out of 4000.
pullChangedprunes it and writes back the cleaned index.markSyncedrecords the old generation. Every subsequentpullChangedmisses the fast path and does a full 1+ MB index GET + 4000-stat walk — exactly the performance bug the fast path was built to avoid (lab/164). This repeats on every sync until something else (e.g.,pushChanged) writes a new sidecar with the correct fingerprint.Existing code documents this exact invariant —
markSynced's comment (GCS line 587-594, S3 line 647-649) says: "remoteIndexGenerationMUST come from the same GET response that delivered the bytes we verified the local cache against, OR from our own putObject response for the bytes we just wrote." The new code violates the second clause.Suggested fix (GCS):
(And the same for S3, capturing
putResult?.etag.)Low
Broad
catchblock now silently swallows remote index PUT failures, not just sidecar writesGCS —
gcs_cache_sync.ts:1087-1089, S3 —s3_cache_sync.ts:1206-1211: Thecatchblock was originally documented as "Non-fatal: sidecar update is opportunistic" — protecting only themarkSyncedsidecar write. The new code adds aputObject(remote index writeback) inside the same try block, so a persistent PUT failure (e.g., credential revocation mid-sync) is silently swallowed. The cleaned index is written locally but the remote retains the dangling entry. Self-heals on the nextpullChanged(re-prune, re-try PUT), so this is non-blocking. The comment is now slightly misleading about what it protects.indexMutatedflag leaks acrosspullIndexboundariesNeither
pullIndexnor its callers resetindexMutatedtofalsewhen a fresh remote index replacesthis.index. If apullChangedwriteback fails (caught by the broadcatch),indexMutatedstaystrue. A subsequentpushChangedthen callspullIndex(forceRemote: true), which replacesthis.indexentirely, butindexMutatedis stilltruefrom the prior operation. This triggers a redundant writeback of the just-fetched-and-unmodified remote index. No data loss — just a wasted PUT. Pre-existing design; the PR doesn't introduce it but does add a new path that can trigger it.Verdict
PASS — The core logic (prune dangling NotFound entries, write back cleaned index) is correct and well-tested. The stale-fingerprint issue (Medium #1) defeats the fast-path optimization after a prune but doesn't cause data loss or incorrect behavior — the slow path always produces correct results. The fix is a one-line capture of the PUT response. Tests are thorough and cover both the happy path and the error-propagation path.
Code Review
Blocking Issues
GCS bulk-walk deletion gate uses
bulkInvalidatedinstead of an overflow-specific flag, causing a data-loss divergence from S3 (gcs_cache_sync.ts:1267)In
pushChanged's full-walk branch, GCS gates remote-object deletion onthis.bulkInvalidated:bulkInvalidatedis set totruebymarkDirty()called with norelPath— andpushFilecalls exactly that:This creates a silent data-loss scenario across process boundaries:
service.pushFile("data/foo.yaml")→ sidecar written withbulkInvalidated: true.GcsCacheSyncService) callspushChanged()→ loads sidecar →bulkInvalidated = true→ full walk → all remote-only GCS objects not present locally are deleted.The S3 sibling implementation explicitly avoids this by using a separate
dirtyPathsOverflowedfield (only set when the 200-path cap overflows) as the deletion gate, and its code comments state directly:GCS has no
dirtyPathsOverflowedfield. The GCS test"pushChanged: bulk walk deletes orphaned index entries after markDirty()"explicitly validates deletions triggered by a no-relPathmarkDirty(), which confirms the divergence is present but the cross-processpushFile → pushChangeddanger is untested (the test uses the same service instance, sotoPush/toDeleteare computed beforebulkInvalidatedis set by the upload batch).Fix: mirror S3's design — add a
dirtyPathsOverflowedfield toDatastoreSyncStateV2in GCS, set it only on cap overflow inmarkDirty, and gate bulk-walk deletions ondirtyPathsOverflowed(notbulkInvalidated). UpdatemarkSyncedto clear it. This brings GCS semantics in line with S3's documented intent.Suggestions
writePartitionedIndexin GCS usesArray<Promise<unknown>>instead ofPromise<void>[](gcs_cache_sync.ts:1502): The S3 sibling chains.then(() => {})before.catch(() => {})to producePromise<void>[]. GCS only chains.catch(() => {})on aPromise<GcsWriteResult>, givingArray<Promise<GcsWriteResult | undefined>>. The runtime behaviour (all errors swallowed,Promise.allSettledused) is identical, but the type annotation is less precise than S3's.Combined try-catch in
pullChangedswallowsatomicWriteTextFileerrors (gcs_cache_sync.ts:1069–1090): The single try block wraps both the local index rewrite andmarkSynced. A disk-full or permission error duringatomicWriteTextFile(this.indexPath, ...)is silently swallowed with the comment "Non-fatal: sidecar update is opportunistic." The local index rewrite is more than just the sidecar — losing it means the next process's slow-path walk pulls incorrectlocalMtimevalues, causing unnecessary re-uploads (swamp-club #222 scenario). The same pattern exists in S3, so this isn't new to this PR, but it's worth splitting the try blocks in both implementations: let theatomicWriteTextFilepropagate (it's load-bearing), and only wrapmarkSyncedin the opportunistic catch.Code Review
Three fixes bundled in this PR:
dirtyPathsOverflowedfield added to GCS sidecar state + orphan-scan gate changed frombulkInvalidatedtodirtyPathsOverflowed && !lazyPullActivepullChangednow skips and prunes dangling index entries (404 on pull → delete from index, not abort)pullChangedcaptures the PUT response fingerprint after a dangling-entry writeback instead of using the stale pre-scrub generation/ETagAll three fixes are correct and well-motivated. Testing rules followed throughout (in-memory mocks, local HTTP servers via
Deno.serve({ port: 0 }); env vars restored infinally;sanitizeResources: falseon TCP-connection tests with an explanatory comment). Noanytypes in hand-written code; named exports only; no model files touched; version bumps inmanifest.yamlare consistent with the changes.Blocking Issues
None.
Suggestions
Dead inner guard in
gcs_cache_sync.tsline 1281 — The PR changed the outer condition fromthis.bulkInvalidatedtothis.dirtyPathsOverflowed && !this.lazyPullActive, making the pre-existing inner guard unreachable:Because the outer condition already requires
!this.lazyPullActive, the innerthis.lazyPullActive && …branch can never execute. The comment above it ("Without this check, a bulk push after a lazy pull would delete every un-hydrated raw file from the remote") is now misleading — the outer!this.lazyPullActiveis what enforces that invariant. The S3 sibling (which already had the correct outer condition) has no inner guard. Consider removing the innerifand its comment to match S3.del3GCS test comment could explain why the test actually works — The test"pushChanged: lazy hydration guard prevents deletion of un-hydrated files"callsmarkDirty()(no relPath), which setsbulkInvalidated = truebut leavesdirtyPathsOverflowed = false. So the orphan-scan block (dirtyPathsOverflowed && !lazyPullActive) never executes — the test passes becausedirtyPathsOverflowedis false, not becauselazyPullActiveguards the inner check. A short inline note would prevent a future reader from concluding the inner guard is load-bearing.Adversarial Review
Medium
Dead code:
isLazySkippableguard is unreachable —gcs_cache_sync.ts:1281The PR changed the outer condition at line 1274 from
this.bulkInvalidated && this.indextothis.dirtyPathsOverflowed && !this.lazyPullActive && this.index. The inner guard at line 1281:is now unreachable because the outer
!this.lazyPullActiveguaranteesthis.lazyPullActiveisfalseinside the block. Before the PR this guard was live (the old outer condition didn't checklazyPullActive).Impact: No behavioral bug — the outer guard is strictly more conservative (skips ALL orphan deletion when lazy pull is active, vs. the old code which only skipped
isLazySkippablekeys). But the dead branch is misleading to future readers who may think theisLazySkippablefilter is the intended behavior boundary.Suggested fix: Remove the dead
if (this.lazyPullActive && isLazySkippable(key)) continue;line at 1281, or add a comment that the outer guard supersedes it.Low
Scoped/partitioned pull doesn't persist dangling-entry cleanup —
gcs_cache_sync.ts:1075,s3_cache_sync.ts:1187When
pullChangeduses the partitioned index path (models filter),indexGeneration/indexETagisnull. The writeback block (if (indexGeneration)/if (indexETag)) is skipped entirely, so dangling entries pruned from the in-memory index are never written back to local disk or remote. The same dangling entry will be re-encountered on every scoped pull without self-healing — only a full (non-scoped) pull persists the cleanup.This follows the pre-existing pattern (the
pulled > 0writeback also gated on the same condition before the PR), so it's consistent. Self-heals on the next full pull.Double serialization in index writeback —
gcs_cache_sync.ts:1080+1084,s3_cache_sync.ts:1191+1196JSON.stringify(this.index, null, 2)is called twice in quick succession — once foratomicWriteTextFile(local) and again forTextEncoder.encode(remote upload). Both produce identical output from the same in-memory object. For large indexes (the code comments reference 1.37 MB), serializing twice is wasteful. A singleconst indexJson = JSON.stringify(...)withnew TextEncoder().encode(indexJson)for the upload andindexJsonfor the file write would halve the serialization cost. Not a correctness issue.Verdict
PASS — The changes are logically sound.
The behavioral fix (gating bulk-walk orphan deletion on
dirtyPathsOverflowedinstead ofbulkInvalidated) correctly distinguishes between "too many dirty paths to track individually" (overflow → orphan deletion appropriate) and "baremarkDirty()frompushFileor explicit bulk signal" (modification intent → orphan deletion inappropriate). This closes the data-loss vector where a reader-side repo with a sparse local cache would have its remote files deleted by apushChangedtriggered viapushFile.The dangling-entry pruning in
pullChangedcorrectly identifiesNotFoundError(GCS) /NoSuchKey/NotFound(S3) vs. other errors, prunes the in-memory index, writes the cleaned index back to both local and remote, and updates the sidecar generation/ETag from the PUT response. Error-path tests cover the non-404 case. TheretryWithBackoffcorrectly does not retry NotFound errors (non-retryable), so the error surfaces without masking.Test coverage is good: happy-path dangling pruning, non-NotFound error propagation, and the behavioral change for bare
markDirty()are all tested for both GCS and S3.