fix(datastores): complete v2 shard-first migration for write-side sync paths (#1052) #97
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "fix/1052-v2-shard-first-write-paths"
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
_meta.jsoninstead of 13.9MB monolith on v2 repos. Drops from ~38s to ~3ms on the reporter's setup.commitSeqinstead of re-uploading the full monolith.dirtyPaths,bulkInvalidated) before writing the sidecar — prevents stale paths leaking across service instances.v1 vs v2 benchmark results (MinIO, 1000 files / 50 models)
v2 slow paths are slightly slower on local MinIO (more S3 round trips for shard assembly) but massively faster on slow links where the monolith transfer dominates.
Test plan
deno check,deno lint,deno fmt --check,deno install --frozenall cleanCloses swamp-club#1052
Adversarial Review
Critical / High
s3_cache_sync.ts:2178-2196—commitPushv2 no-op marks sidecar clean without verifying local cache completenessThe v1 no-op path (line 2200-2207) calls
localHasAllRemoteEntries()before marking the sidecar clean — this is a correctness guard against the swamp-club#1225 data-loss scenario where a fresh reader with an incomplete cache gets marked clean, causing the nextpullChangedto fast-path past unfetched files.The new v2 no-op path (line 2184-2191) unconditionally clears
dirtyPaths, setslocalDirty: false, and writes the sidecar — without anylocalHasAllRemoteEntries()check.Breaking scenario: A fresh reader runs
pullChanged(metadata-only or interrupted), then does a two-phase push with nothing dirty.preparePushreturnspushed=0, deleted=0.commitPushenters the v2 no-op branch, writes a clean sidecar withcommitSeq. The nextpullChangedseescommitSeqmatches remote_meta.json, fast-paths to 0, and the reader never downloads the missing files.This is the exact same data-loss class that swamp-club#1225 addressed for v1 — the v2 path skips the safety check.
Severity: CRITICAL — silent data loss on the pull path for fresh readers on v2 repos.
Suggested fix: Add the
localHasAllRemoteEntries()guard before writing the sidecar in the v2 no-opcommitPushpath, mirroring what thepushChangedv2 paths already do (lines 1844 and 1895).Medium
s3_cache_sync.ts:1818-1835— v2 writeback partition list derived fromgroupEntriesByPartitionmay diverge from shards on diskWhen building
newMeta.partitions(line 1828-1831), the code derives the partition list from the in-memory index viagroupEntriesByPartition(this.index.entries), not from the shards that were actually written. IfdirtyPartitionKeysis non-empty, only those shards are re-written (viawritePartitionedIndexwith the dirty filter). But the _meta.json partition list reflects ALL in-memory entries — including partitions whose shards predate this push.This is consistent with the existing
commitPushShardFirstbehavior (which also rebuilds fromsurvivingPartitions), so it's likely intentional. However, if a shard was deleted out-of-band (e.g., bucket lifecycle policy),_meta.jsonwill reference a shard that no longer exists, causingassembleIndexFromShardsto fall back to monolith on the next read. This is a degraded-performance scenario, not data loss, since the fallback is safe.Severity: MEDIUM — degraded performance (fallback to monolith) if shards are externally deleted, but no data loss.
s3_cache_sync.ts:1590-1610—pushChangedshard assembly populates index butpreparePushduplicates the logic without sharingv2CommitSeqBoth
pushChanged(line 1590-1610) andpreparePush(line 1953-1970) have nearly identical shard-assembly blocks, butpreparePushdoesn't trackv2CommitSeq— theInternalPushManifestdoesn't carry it. This meanscommitPushhas to re-read_meta.json(line 2223) even thoughpreparePushalready read it. Not a correctness issue sincecommitPushreads the authoritative remote value, but it's a missed optimization and a divergence point where future changes to one path could forget the other.Severity: MEDIUM — not a bug, but a maintenance risk from code duplication.
s3_cache_sync_test.ts:5655-5707— Test#1052: commitPush no-opdoes not exercise the fresh-reader scenario that the Critical finding above describesThe test seeds the file locally (
seedFile) and runspullChangedto populate the sidecar before callingpreparePush/commitPush. This meanslocalHasAllRemoteEntries()would pass even if it were checked, so the test wouldn't catch the missing guard. A test with an incomplete local cache (e.g., metadata-only pull, or missing files) would be needed to verify the safety check.Severity: MEDIUM — test gap directly related to the Critical finding.
Low
s3_bench.ts:508—catchblock in scenario 14 setup swallows all errors including permission/IO failuresIf
Deno.readDirfails for a reason other than an empty/nonexistent directory (e.g., permission denied), the benchmark silently proceeds with stale data from a prior run, producing misleading results. In a benchmark context this is low-risk since it runs in temp dirs, butcatch (e) { if (!(e instanceof Deno.errors.NotFound)) throw e; }would be more precise.Severity: LOW — benchmark-only, not production code.
s3_cache_sync.ts— repeatedbuildV2State+ manualsidecar.commitSeq = Xpattern (lines 1848-1851, 1899-1901, 2188-2190)The
buildV2Statehelper doesn't acceptcommitSeqin its overrides, so every call site that needs to set it doessidecar.commitSeq = Xafter construction. If a future caller forgets this step, the sidecar will havecommitSeq: undefinedand the fast path will miss. This is a footgun but not a current bug.Severity: LOW — maintenance concern, no current breakage.
Verdict
FAIL — The
commitPushv2 no-op path unconditionally marks the sidecar clean without verifying that the local cache actually has all remote entries. This is a regression of the swamp-club#1225 data-loss fix for v2 repos. The fix is a one-line guard (if (await this.localHasAllRemoteEntries())) wrapping the sidecar write, matching the pattern already used inpushChanged's v2 paths.Code Review
Blocking Issues
pushChangedv2 writeback path: shard write errors silently swallowed after retries (datastore/s3/extensions/datastores/_lib/s3_cache_sync.ts, lines ~1821-1836)In the new v2 writeback branch of
pushChanged(if (v2CommitSeq !== null)), the code callswritePartitionedIndexto write dirty shards. The JSDoc ofwritePartitionedIndexexplicitly states:Concretely, inside
writePartitionedIndex, shard PUTs are wrapped as:and the results are collected via
Promise.allSettled(which never rejects).In the v2 path, shards are the source of truth — the code comment on line ~1820 even says "shards are the source of truth (same as commitPushShardFirst)." If a shard write fails after 3 retries,
writePartitionedIndexreturns silently,writePartitionMetastill writes a new_meta.jsonwithcommitSeq + 1, and the sidecar records the newcommitSeq. Every subsequent sync takes the fast path (commitSeq matches), permanently masking the stale shard.The parallel code path
commitPushShardFirstavoids this by callingwriteSharddirectly, which propagates errors:The fix is to use
writeSharddirectly in the v2pushChangedwriteback path (mirroringcommitPushShardFirst) rather than reusingwritePartitionedIndex, which was designed for the legacy v1 path where the monolith is the source of truth and shard writes are optional optimizations.No existing or new test covers the failure-after-retries case for this code path.
Suggestions
Redundant
_meta.jsonGET insidewritePartitionedIndexfor v2 pushChanged — WhenpushChangedenters the v2 writeback path,assembleIndexFromShardshas already read_meta.json. ThenwritePartitionedIndexreads it a second time internally (to checkif (existing && existing.version === 2) return;before skipping the v1 meta write). Since the repo is confirmed v2 at this point, this is an extra S3 GET per writeback that could be avoided by not reusingwritePartitionedIndexfor the v2 path (which aligns with the fix for blocking issue #1 above).Benchmark env vars set but not restored (
datastore/benchmarks/s3_bench.ts, lines 72–80) —AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY, andAWS_REGIONare set inmain()but not saved/restored in thefinallyblock. ThewithProgrammableServerhelper in the test file properly saves and restores env vars; the benchmark could adopt the same pattern for consistency, especially if it is ever invoked within a broader test harness.Code Review: V2 Shard-First Write-Path Migration (swamp-club#1052)
Verdict: PASS
Summary
This PR completes the v2 shard-first migration for the write side of both S3 and GCS datastore sync. When
assembleIndexFromShards()detects a v2 repo (one with_meta.jsoncontainingcommitSeq), the write paths (pushChanged,preparePush,commitPush) now skip the monolithic index fetch/writeback and instead operate exclusively on partition shards. This eliminates redundant full-index round-trips that were the source of significant overhead (swamp-club#1034).The changes are well-structured, symmetric across S3 and GCS, and the new test coverage targets the critical safety invariant (incomplete-cache guard on sidecar marking).
Findings
MEDIUM — Orphaned shard files when all entries in a partition are deleted (
pushChangedv2 path)S3:
s3_cache_sync.ts:1826-1831GCS:
gcs_cache_sync.ts:1689-1694In the v2
pushChangedwriteback, dirty partitions are written only whenentries && Object.keys(entries).length > 0. If all entries in a partition are deleted, the code skips thewriteShard()call but never deletes the now-empty remote shard file.The
_meta.jsonpartitions list is correctly rebuilt fromallPartitions.keys()(line 1834/1697), so the empty partition disappears from the metadata — but the orphaned shard blob remains on S3/GCS storage indefinitely.Compare with
commitPushShardFirst()(S3 line 2283-2296) which properly handles this case:Impact: Storage leak — orphaned shard files accumulate over the lifetime of the repo. Not a correctness issue since
_meta.jsonwon't reference them andassembleIndexFromShards()only reads partitions listed in metadata. However, this creates unbounded storage waste proportional to model/workflow churn.Suggested fix: Add an
elsebranch in thepushChangedv2 path that mirrorscommitPushShardFirst's behavior — delete the remote shard when entries are empty.LOW —
_meta.jsonpartitions list correctly reflects index state (reviewed, no issue)S3:
s3_cache_sync.ts:1834GCS:
gcs_cache_sync.ts:1698The new
_meta.jsonis built fromallPartitions.keys()whereallPartitions = groupEntriesByPartition(this.index.entries). Sincethis.indexhas already been mutated by the preceding put/delete operations inpushChanged, this is sound — the partitions list will accurately reflect which shards have entries.No action needed — noting this was reviewed and found correct.
LOW — Test coverage is well-targeted (reviewed, no issue)
S3:
s3_cache_sync_test.ts(newseedV2Repofunction and 4 test cases)The
seedV2Repo()helper correctly seeds the mock S3 with_meta.json(commitSeq, partitions list) and corresponding shard files. The tests properly verify:commitPushv2 no-op uses_meta.jsoncommitSeq, not monolith (avoids monolith GET)pushChangedslow path on v2 uses shard assemblypreparePushslow path on v2 uses shard assemblycommitPushv2 no-op does NOT mark sidecar clean when cache is incompleteThe incomplete-cache safety test (#4) is particularly valuable — it guards against a subtle regression where
localHasAllRemoteEntries()returning false correctly prevents the sidecar from being marked clean.No action needed — noting the tests are well-targeted.
Dimensions Reviewed
assembleIndexFromShards()returning a non-nullcommitSeqis the right gate. The fallback to v1 monolith paths whenv2CommitSeq === nullpreserves backward compatibility.assertSafePath) are unchanged. No user-controlled input flows into S3/GCS keys without validation.localHasAllRemoteEntries()guard before marking sidecar clean (S3 line 1846, GCS line 1709) correctly prevents premature clean marking on incomplete caches. commitSeq is incremented before write, ensuring monotonic progression.pushChanged,preparePush,commitPushbehavior changes are transparent to callers.s3.deleteObjectvsgcs.deleteObject).s3_bench.tscovers all 15 scenarios including v2-specific paths. Well-structured with proper cleanup.Conclusion
The PR is well-executed with one medium-severity storage hygiene issue (orphaned shards in
pushChangedv2 path). This is not a correctness or data integrity problem — orphaned shards are invisible to index assembly since_meta.jsonwon't reference them — but it should be addressed to prevent unbounded storage accumulation. The fix is straightforward and can be done as a follow-up.All other dimensions are sound. The code is clean, the S3/GCS implementations are properly symmetric, and the test coverage is well-targeted at the critical safety invariants.
Code Review
Blocking Issues
Missing tests for GCS
pushChangedv2 shard-first write path (datastore/gcs/extensions/datastores/_lib/gcs_cache_sync.ts)CLAUDE.md requires: "New functionality in vault/ or datastore/ extensions should have corresponding tests." This PR adds a v2 shard-first write path to GCS's
pushChangedandpreparePush(structurally identical to the S3 changes). The S3 extension received four explicit tests covering these paths (#1052: pushChanged slow path on v2 uses shard assembly, not monolith,#1052: preparePush slow path on v2 uses shard assembly, not monolith, etc.). The GCS extension'sgcs_cache_sync_test.tshas existing tests forcommitPush/preparePushin v2 mode, but no test verifies thatpushChangedon a v2 repo (a) uses shard assembly instead ofpullIndex(forceRemote)and (b) skips the monolithic index PUT in the writeback. Without this test, a regression that causespushChangedto fall back to the monolith path on v2 repos would go undetected in GCS.Required: add a GCS test equivalent to the S3
#1052: pushChanged slow path on v2 uses shard assembly, not monolithtest.Suggestions
Orphaned shard files when a partition becomes empty via
pushChangedv2 (s3_cache_sync.tslines ~1826–1840,gcs_cache_sync.tslines ~1682–1700)In the v2 writeback path of
pushChanged, when all entries in a partition are deleted, the shard file (e.g._index/data--t--m--1.json) is left as an orphan in the bucket._meta.jsonis correctly updated to omit the empty partition, so correctness is unaffected, but the shard object accumulates as garbage.commitPushShardFirsthandles this consistently by explicitly callings3.deleteObject/gcs.deleteObjecton the shard whenObject.keys(existing).length === 0. Consider applying the same cleanup inpushChanged's v2 writeback, or add a comment documenting the intentional divergence.No test coverage for v2 deletions via
pushChanged(s3_cache_sync_test.ts)The four new
#1052tests cover v2 push (file modification) and no-op paths, but none verify that deleting a file from a v2 repo viapushChangedcorrectly updates the shards and_meta.json. The orphaned-shard behavior described above would not be caught by any existing test.Code Review
Blocking Issues
None.
Suggestions
S3 test file is missing an explicit
preparePushv2 slow-path test. The GCS test file has#1052: GCS preparePush slow path on v2 uses shard assembly, not monolith(gcs_cache_sync_test.ts line 5382), which directly asserts thatpreparePushon a v2 repo callsassembleIndexFromShardsand never fetches the monolithic index. The S3 equivalent was also modified in this PR (s3_cache_sync.ts, the sameassembleIndexFromShardscall was added topreparePush), but there is no corresponding#1052: preparePush slow path on v2test ins3_cache_sync_test.ts. The S3 path is exercised indirectly through thecommitPushtests (which callpreparePushfirst), but a direct mirror of the GCS test would close the coverage gap and follow the existing test symmetry pattern the two files maintain.v2
pushChangedbumpscommitSeqeven when onlyindexMutatedis set. Whenpushed === 0,deleted === 0, and onlyindexMutatedis true (i.e.,scrubIndexremoved zombie entries from the in-memory index but no S3/GCS objects changed), the v2 writeback path inpushChangedstill iterates overdirtyPartitionKeys(empty), then writes a new_meta.jsonwithcommitSeq + 1and the unchanged partition list. No shard content changes, but the_meta.jsonPUT fires anyway. This was the same behavior as the v1 index writeback for theindexMutated-only case, so it's not a regression — it just means a zombie-scrub produces a gratuitous_meta.jsonwrite and a spurious commitSeq bump that will invalidate any peer's cached sidecar. Low priority; could be gated ondirtyPartitionKeys.size > 0in the future.Adversarial Code Review — Verdict: PASS ✅
No CRITICAL or HIGH severity issues found.
Changed Files Reviewed
datastore/gcs/extensions/datastores/_lib/gcs_cache_sync.tsdatastore/gcs/extensions/datastores/_lib/gcs_cache_sync_test.tsdatastore/gcs/manifest.yamldatastore/s3/extensions/datastores/_lib/s3_cache_sync.tsdatastore/s3/extensions/datastores/_lib/s3_cache_sync_test.tsdatastore/s3/manifest.yamldatastore/benchmarks/s3_bench.tsdatastore/benchmarks/README.mdDimension Analysis
1. Logic
No issues found.
Both S3 and GCS implementations correctly mirror each other with appropriate cloud-specific adaptations. The two-phase push (
preparePush/commitPush) correctly narrows the lock critical section. The shard-first v2 migration path is logically sound —migrateMonolithToShardscorrectly parses monolithic indexes and splits into per-model shards. The fast-path sidecar optimization correctly gates on sidecar completeness before trusting cached fingerprints, preventing stale reads.2. Error Handling
No issues found.
isRetryableErrorcorrectly classifies transient vs permanent failures.retryWithBackoffimplements exponential backoff with jitter and respects abort signals between retries. All async paths propagateAbortSignalcorrectly viathrowIfAbortedchecks. Thefinallyblocks in test fixtures properly restore environment variables and close resources.3. Security
No issues found.
Path traversal is prevented by
assertSafePathwhich rejects..components, absolute paths, and null bytes in all local filesystem write paths.fetchForeignContentindependently validates its paths.isInternalCacheFileprevents leaking internal state files (.swamp-*) to cloud storage during push. No credential material is hardcoded — both implementations delegate to their respective cloud credential chains (AWS default chain, GCS ADC).4. Concurrency
No issues found.
The TOCTOU race condition identified in swamp-club #168 is correctly addressed — fingerprints are recorded from the GET response headers during download, not from a separate metadata call after the write. The
localHasAllRemoteEntries()guard (#1225) prevents marking the sidecar as clean when the local cache is incomplete, avoiding a race where a partial pull would incorrectly arm the fast path for a subsequent sync.5. Data Integrity
No issues found.
SHA-256 content hashing prevents redundant uploads by comparing local and remote content digests.
commitSeq-based fast path correctly short-circuits when the remote sequence matches the local cached value. The shard merge logic inpullChangedcorrectly handles concurrent writes by re-reading shards if the merged index differs from expectations. Atomic file writes viaDeno.writeFilewith rename semantics protect local index persistence.6. Resource Management
No issues found.
abortableSleepcorrectly cleans up its timer on abort. Test HTTP servers useDeno.serve({ port: 0 })for automatic port allocation and are properly shut down infinallyblocks. Tests that create SDK clients with connection pools correctly usesanitizeResources: falsewith explanatory comments, as required by project conventions.7. API Contracts
No issues found.
Both implementations correctly satisfy the
DatastoreSyncServiceinterface. S3'sdeleteObject(key, signal?)and GCS'sdeleteObject(key, options?, signal?)signatures correctly match their respective client SDK conventions.normalizeETag(S3-only) correctly strips surrounding quotes from ETags.isMultipartETag(S3-only) correctly rejects multipart ETags (containing-Nsuffix) which have non-deterministic values.8. Codegen Safety
Not applicable. No files under
model/are in the changed file set. The manifest version bumps are data-only changes.Minor Observations (LOW severity)
s3_cache_sync.tsisRetryableErrordoes not explicitly check forPreconditionFailedErrorby name, unlike GCS which does. S3 handles this implicitly via HTTP 412 status code. This is functionally correct since S3 SDK surfaces conditional-write failures differently than the GCS client, but the asymmetry could cause confusion during future maintenance.s3_cache_sync.ts,gcs_cache_sync.tsDIRTY_PATH_CAP(200 paths) overflow fallback to full directory walk is a reasonable safeguard, but the cap value is not configurable. For repositories with many small files, this threshold could be hit frequently, negating the benefit of per-path dirty tracking. Not a bug — a design trade-off worth monitoring.gcs_cache_sync.tsgenerationvalues are int64 integers compared as strings. This is correct for equality checks (the only comparison performed), but worth noting that lexicographic ordering would not match numeric ordering if ever used for range comparisons in the future.Summary
The codebase is well-engineered with comprehensive defensive programming practices. The S3 and GCS implementations are structurally parallel with appropriate cloud-specific adaptations. Test coverage is thorough, including race condition regression tests, error injection via programmable HTTP servers, and integration tests with real SDK clients against local emulators. The two-phase sync design, shard-first indexing, and fingerprint-based fast path are all correctly implemented with appropriate guards against known failure modes.
No changes are required for merge.