fix(datastore): bulk-diff hydrate, arm commitSeq fast path, skip root migration #270

Open
stack72 wants to merge 3 commits from fix/datastore-pull-push-perf-2033-1931-2091 into main
Owner

Summary

Three fixes applied symmetrically to both @swamp/s3-datastore and @swamp/gcs-datastore:

  • #1931 — commitSeq fast path never arms after pull: On the v2 shard-first path with no 404 cleanup and no monolithic index ETag, neither existing branch wrote commitSeq to the sidecar. Added a new else if branch that persists commitSeq after a clean v2 pull so the fast path arms on the next call. Eliminates P50 4.5s pull overhead on every method call.

  • #2091 — preparePush reads root .datastore-index.json under managedConfig+namespace: migrateRootDataToNamespace unconditionally read the root monolithic index, hitting AccessDenied on scoped IAM policies. When v2 shard assembly succeeds, the migration is now skipped entirely and dataKeyMigrated=true is set — the v2 shard index is authoritative. Eliminates 5–15s retry backoff per push on namespace-scoped IAM setups.

  • #2033 — Cold hydrate issues one round-trip per file: Added a bulk listAllObjects call before the pullChanged walk loop. Index entries absent from the listing are pruned during the walk instead of discovering 404s per-file during download — O(files/1000) list calls instead of O(files) getObject calls. On the reporter's 45,713-file namespace, this cuts ~45,512 wasted round-trips to ~46 list pages. The listing is wrapped in try-catch: on failure it falls back to the pre-change per-file behavior.

Bumps both manifests to 2026.09.10.2.

Test plan

  • S3 datastore: 281 tests pass (4 new regression tests)
  • GCS datastore: 271 tests pass (4 new regression tests)
  • Type-checking clean on both datastores
  • Lint clean, frozen lockfiles valid
  • UAT tests (swamp-uat) reviewed — all black-box/outcome-based, none affected

New regression tests

Issue Test Verifies
#1931 pullChanged on v2 shard-first writes commitSeq to sidecar, next pull is fast-pathed Clean v2 pull → sidecar has commitSeq → second pull returns 0 via fast path
#2091 preparePush skips root migration when v2 shard index exists Root .datastore-index.json never read, dataKeyMigrated set
#2033 pullChanged falls back to per-file behavior when listing fails Pull succeeds when listing throws 403
#2033 pullChanged prunes stale entries via listing without per-file 404 Stale entry pruned by listing, no getObject issued for it

Closes #2033, #1931, #2091

🤖 Generated with Claude Code

## Summary Three fixes applied symmetrically to both `@swamp/s3-datastore` and `@swamp/gcs-datastore`: - **#1931 — commitSeq fast path never arms after pull:** On the v2 shard-first path with no 404 cleanup and no monolithic index ETag, neither existing branch wrote `commitSeq` to the sidecar. Added a new `else if` branch that persists `commitSeq` after a clean v2 pull so the fast path arms on the next call. Eliminates P50 4.5s pull overhead on every method call. - **#2091 — preparePush reads root `.datastore-index.json` under managedConfig+namespace:** `migrateRootDataToNamespace` unconditionally read the root monolithic index, hitting AccessDenied on scoped IAM policies. When v2 shard assembly succeeds, the migration is now skipped entirely and `dataKeyMigrated=true` is set — the v2 shard index is authoritative. Eliminates 5–15s retry backoff per push on namespace-scoped IAM setups. - **#2033 — Cold hydrate issues one round-trip per file:** Added a bulk `listAllObjects` call before the `pullChanged` walk loop. Index entries absent from the listing are pruned during the walk instead of discovering 404s per-file during download — O(files/1000) list calls instead of O(files) getObject calls. On the reporter's 45,713-file namespace, this cuts ~45,512 wasted round-trips to ~46 list pages. The listing is wrapped in try-catch: on failure it falls back to the pre-change per-file behavior. Bumps both manifests to `2026.09.10.2`. ## Test plan - [x] S3 datastore: 281 tests pass (4 new regression tests) - [x] GCS datastore: 271 tests pass (4 new regression tests) - [x] Type-checking clean on both datastores - [x] Lint clean, frozen lockfiles valid - [x] UAT tests (swamp-uat) reviewed — all black-box/outcome-based, none affected ### New regression tests | Issue | Test | Verifies | |-------|------|----------| | #1931 | `pullChanged on v2 shard-first writes commitSeq to sidecar, next pull is fast-pathed` | Clean v2 pull → sidecar has commitSeq → second pull returns 0 via fast path | | #2091 | `preparePush skips root migration when v2 shard index exists` | Root `.datastore-index.json` never read, `dataKeyMigrated` set | | #2033 | `pullChanged falls back to per-file behavior when listing fails` | Pull succeeds when listing throws 403 | | #2033 | `pullChanged prunes stale entries via listing without per-file 404` | Stale entry pruned by listing, no getObject issued for it | Closes #2033, #1931, #2091 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(datastore): bulk-diff hydrate, arm commitSeq fast path, skip root migration (#2033, #1931, #2091)
Some checks failed
CI: Datastores / datastore/gcs - check (pull_request) Successful in 40s
CI: Datastores / datastore/gcs - fmt (pull_request) Failing after 28s
CI: Datastores / datastore/gcs - lint (pull_request) Successful in 29s
CI: Datastores / datastore/gcs - test (pull_request) Successful in 54s
CI: Datastores / datastore/s3 - check (pull_request) Successful in 38s
CI: Datastores / datastore/s3 - fmt (pull_request) Failing after 28s
CI: Datastores / datastore/s3 - lint (pull_request) Successful in 32s
CI: Datastores / datastore/gcs - lockfile up to date (pull_request) Successful in 31s
CI: Datastores / datastore/s3 - test (pull_request) Successful in 1m22s
CI: Datastores / datastore/s3 - lockfile up to date (pull_request) Successful in 27s
CI: Reviews / Detect Changes (pull_request) Successful in 28s
CI: Reviews / CI Security Review (pull_request) Has been skipped
CI / Actions Audit (pull_request) Successful in 30s
CI: Datastores / Gate: Datastores (pull_request) Failing after 0s
CI / Dependency Audit (pull_request) Successful in 2m46s
CI: Reviews / Claude Code Review (pull_request) Successful in 5m0s
CI / Gate: Audit (pull_request) Successful in 0s
CI: Reviews / Adversarial Code Review (pull_request) Successful in 4m26s
CI: Reviews / Gate: Reviews (pull_request) Successful in 0s
45c976565f
Three fixes applied symmetrically to both s3-datastore and gcs-datastore:

1. pullChanged commitSeq fast path never armed after clean v2 shard pull
   (#1931): on the v2 shard-first path with no 404 cleanup and no
   monolithic index ETag, neither existing branch wrote commitSeq to the
   sidecar. Added a new else-if branch that persists commitSeq after a
   clean v2 pull so the fast path arms on the next call.

2. preparePush/pushChanged read root .datastore-index.json under
   managedConfig+namespace (#2091): migrateRootDataToNamespace
   unconditionally read the root monolithic index, hitting AccessDenied
   on scoped IAM. When v2 shard assembly succeeds, skip migration
   entirely and mark dataKeyMigrated=true — the v2 shard index is
   authoritative.

3. Cold hydrate issued one S3/GCS round-trip per file (#2033): added a
   bulk listAllObjects call before the pullChanged walk loop. Entries
   absent from the listing are pruned during the walk instead of
   discovering 404s per-file during download — O(files/1000) list calls
   instead of O(files) getObject calls. The listing is wrapped in
   try-catch: on failure it falls back to the pre-change per-file
   behavior.

Bumps both manifests to 2026.09.10.2.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author
Owner

Code Review

Blocking Issues

None.

Suggestions

  1. Documentation misplacement in gcs_cache_sync.ts (lines 74–108): The large JSDoc block describing isInternalCacheFile's exclusion criteria is orphaned — it appears before isInsideNamespaceDir rather than directly before isInternalCacheFile at line 153. Two consecutive JSDoc comments appear with no function between them (the isInsideNamespaceDir JSDoc at lines 109–115 immediately follows), leaving isInternalCacheFile undocumented at its definition site. This is likely an ordering side-effect of placing isInsideNamespaceDir before isInternalCacheFile in the GCS file (S3 has the reverse ordering). Consider moving isInternalCacheFile (and its JSDoc) to directly precede isInsideNamespaceDir, matching the S3 layout, or placing the big JSDoc immediately before the function it describes.

  2. Dead code in full-walk orphan detection (gcs pushChanged line ~2417, preparePush line ~2850; same in S3 counterparts): Inside the guard if (this.dirtyPathsOverflowed && !this.lazyPullActive && this.index), there is an unreachable check if (this.lazyPullActive && isLazySkippable(key)) continue;. Since !this.lazyPullActive is already required by the outer condition, the inner this.lazyPullActive is always false and the continue never executes. This is harmless but adds noise; removing the inner check would clarify intent.

  3. Test gap: scoped walk + lazyPullActive = true: The existing lazy-hydration deletion guard test (del3) exercises the bulk invalidation path (markDirty() with no relPath, which sets bulkInvalidated = true, bypassing the scoped walk entirely). There is no test verifying that the scoped walk directory-deletion path (markDirty({ relPath: "data/..." }) pointing to a directory that still exists) correctly skips orphan deletion when lazyPullActive = true. The code guard if (!this.lazyPullActive && this.index) is correct, but a complementary test would protect against future regression in this specific combination.

## Code Review ### Blocking Issues None. ### Suggestions 1. **Documentation misplacement in `gcs_cache_sync.ts`** (lines 74–108): The large JSDoc block describing `isInternalCacheFile`'s exclusion criteria is orphaned — it appears before `isInsideNamespaceDir` rather than directly before `isInternalCacheFile` at line 153. Two consecutive JSDoc comments appear with no function between them (the `isInsideNamespaceDir` JSDoc at lines 109–115 immediately follows), leaving `isInternalCacheFile` undocumented at its definition site. This is likely an ordering side-effect of placing `isInsideNamespaceDir` before `isInternalCacheFile` in the GCS file (S3 has the reverse ordering). Consider moving `isInternalCacheFile` (and its JSDoc) to directly precede `isInsideNamespaceDir`, matching the S3 layout, or placing the big JSDoc immediately before the function it describes. 2. **Dead code in full-walk orphan detection** (gcs `pushChanged` line ~2417, `preparePush` line ~2850; same in S3 counterparts): Inside the guard `if (this.dirtyPathsOverflowed && !this.lazyPullActive && this.index)`, there is an unreachable check `if (this.lazyPullActive && isLazySkippable(key)) continue;`. Since `!this.lazyPullActive` is already required by the outer condition, the inner `this.lazyPullActive` is always `false` and the `continue` never executes. This is harmless but adds noise; removing the inner check would clarify intent. 3. **Test gap: scoped walk + `lazyPullActive = true`**: The existing lazy-hydration deletion guard test (del3) exercises the *bulk invalidation* path (`markDirty()` with no `relPath`, which sets `bulkInvalidated = true`, bypassing the scoped walk entirely). There is no test verifying that the *scoped walk* directory-deletion path (`markDirty({ relPath: "data/..." })` pointing to a directory that still exists) correctly skips orphan deletion when `lazyPullActive = true`. The code guard `if (!this.lazyPullActive && this.index)` is correct, but a complementary test would protect against future regression in this specific combination.
Author
Owner

Adversarial Review

Critical / High

None found. The production code changes are logically correct.

Medium

  1. Listing-failure fallback tests override the wrong method — tests don't test what they claim

    S3 s3_cache_sync_test.ts:8501-8502 and GCS gcs_cache_sync_test.ts:7783:

    Both "swamp-club#2033: pullChanged falls back to per-file behavior when listing fails" tests override listObjects:

    (mock as unknown as Record<string, unknown>).listObjects = () => {
      return Promise.reject(/* ... */);
    };
    

    But the production code calls listAllObjects (S3 at line 1901, GCS at line 1808). The mock's listAllObjects method is defined separately and is NOT affected by this override. The listing succeeds normally, remoteKeys is populated, and the test passes coincidentally — NOT because the fallback path was exercised.

    Breaking example: If someone introduced a bug in the catch {} fallback block (e.g., accidentally re-throwing, or setting remoteKeys to an empty set instead of leaving it null), this test would not catch it.

    Suggested fix: Change both tests to override listAllObjects instead of listObjects:

    (mock as unknown as Record<string, unknown>).listAllObjects = () => {
      return Promise.reject(/* ... */);
    };
    

Low

  1. Existing test fix is correct but subtly coupled: The mock.storage.set("data/file/v1/raw", ...) additions at s3_cache_sync_test.ts:4867 and gcs_cache_sync_test.ts:4532 are necessary — without them, the new bulk-diff listing would prune the entry before the download phase ever sees it, preventing the injected auth error from firing. The fix is correct but worth noting: this test now exercises both the listing path (which must NOT prune the file) and the download error path (which must throw). If the listing logic had a bug that incorrectly pruned an existing file, this test would no longer reach the auth-error assertion and would pass for the wrong reason. Consider adding a comment explaining why the storage seed is needed.

Verdict

PASS — The production code is correct across all three fixes (#2033 bulk-diff listing, #1931 commitSeq fast-path arming, #2091 root-migration skip for v2 shards). The bulk-diff optimization handles failure gracefully (null remoteKeys → no pruning), namespace stripping is correct, and the v2 sidecar writes place commitSeq correctly. The test for listing-failure fallback overrides the wrong mock method (Medium), but the production fallback code itself is sound. The manifest version bumps are appropriate.

## Adversarial Review ### Critical / High None found. The production code changes are logically correct. ### Medium 1. **Listing-failure fallback tests override the wrong method — tests don't test what they claim** **S3** `s3_cache_sync_test.ts:8501-8502` and **GCS** `gcs_cache_sync_test.ts:7783`: Both "swamp-club#2033: pullChanged falls back to per-file behavior when listing fails" tests override `listObjects`: ```typescript (mock as unknown as Record<string, unknown>).listObjects = () => { return Promise.reject(/* ... */); }; ``` But the production code calls `listAllObjects` (S3 at line 1901, GCS at line 1808). The mock's `listAllObjects` method is defined separately and is NOT affected by this override. The listing succeeds normally, `remoteKeys` is populated, and the test passes coincidentally — NOT because the fallback path was exercised. **Breaking example**: If someone introduced a bug in the `catch {}` fallback block (e.g., accidentally re-throwing, or setting `remoteKeys` to an empty set instead of leaving it `null`), this test would not catch it. **Suggested fix**: Change both tests to override `listAllObjects` instead of `listObjects`: ```typescript (mock as unknown as Record<string, unknown>).listAllObjects = () => { return Promise.reject(/* ... */); }; ``` ### Low 1. **Existing test fix is correct but subtly coupled**: The `mock.storage.set("data/file/v1/raw", ...)` additions at `s3_cache_sync_test.ts:4867` and `gcs_cache_sync_test.ts:4532` are necessary — without them, the new bulk-diff listing would prune the entry before the download phase ever sees it, preventing the injected auth error from firing. The fix is correct but worth noting: this test now exercises both the listing path (which must NOT prune the file) and the download error path (which must throw). If the listing logic had a bug that incorrectly pruned an existing file, this test would no longer reach the auth-error assertion and would pass for the wrong reason. Consider adding a comment explaining why the storage seed is needed. ### Verdict **PASS** — The production code is correct across all three fixes (#2033 bulk-diff listing, #1931 commitSeq fast-path arming, #2091 root-migration skip for v2 shards). The bulk-diff optimization handles failure gracefully (null remoteKeys → no pruning), namespace stripping is correct, and the v2 sidecar writes place commitSeq correctly. The test for listing-failure fallback overrides the wrong mock method (Medium), but the production fallback code itself is sound. The manifest version bumps are appropriate.
style(datastore): fix deno fmt in new test assertions
Some checks failed
CI: Datastores / datastore/gcs - fmt (pull_request) Successful in 32s
CI: Datastores / datastore/gcs - check (pull_request) Successful in 35s
CI: Datastores / datastore/gcs - lint (pull_request) Successful in 37s
CI: Datastores / datastore/gcs - test (pull_request) Successful in 1m8s
CI: Datastores / datastore/s3 - check (pull_request) Successful in 31s
CI: Datastores / datastore/s3 - fmt (pull_request) Successful in 33s
CI: Datastores / datastore/s3 - lint (pull_request) Successful in 29s
CI: Datastores / datastore/gcs - lockfile up to date (pull_request) Successful in 33s
CI: Datastores / datastore/s3 - lockfile up to date (pull_request) Successful in 30s
CI: Datastores / datastore/s3 - test (pull_request) Successful in 1m28s
CI: Reviews / Detect Changes (pull_request) Successful in 25s
CI: Reviews / CI Security Review (pull_request) Has been skipped
CI / Actions Audit (pull_request) Successful in 27s
CI: Datastores / Gate: Datastores (pull_request) Successful in 1s
CI / Dependency Audit (pull_request) Successful in 2m41s
CI: Reviews / Claude Code Review (pull_request) Failing after 4m29s
CI / Gate: Audit (pull_request) Successful in 0s
CI: Reviews / Adversarial Code Review (pull_request) Successful in 8m22s
CI: Reviews / Gate: Reviews (pull_request) Failing after 0s
060bd7a38e
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author
Owner

Code Review

Blocking Issues

  1. swamp-club#2033 listing-fallback tests override the wrong method name in both test files.

    gcs_cache_sync_test.ts (new test "swamp-club#2033: GCS pullChanged falls back to per-file behavior when listing fails") and its S3 mirror override listObjects:

    (mock as unknown as Record<string, unknown>).listObjects = () => {
      return Promise.reject(new GcsOperationError(...));
    };
    

    But the production code calls this.gcs.listAllObjects(...), not listObjects. The mock has listAllObjects defined; overriding listObjects adds an unreachable property. The listAllObjects call in pullChanged still succeeds, remoteKeys is populated normally, and the test asserts pulled === 1 — which passes, but because the file was pulled via the normal path, not the intended fallback path. The fallback code under catch is never exercised. The test name is wrong and the scenario it claims to cover is not actually tested.

    Fix: override listAllObjects instead of listObjects on the mock:

    (mock as unknown as Record<string, unknown>).listAllObjects = () => {
      return Promise.reject(new GcsOperationError(...));
    };
    

    (Same correction applies to the S3 mirror test using S3OperationError.)

Suggestions

  1. Minor: formatBatchFailure ternary style differs between GCS and S3. GCS uses a three-way ternary (op === "pull" ? "from" : op === "delete" ? "from" : "to") while S3 uses a simpler two-way form (op === "push" ? "to" : "from"). Both produce identical results, but consistency between the two mirrored implementations would make future diffs easier to read.
## Code Review ### Blocking Issues 1. **`swamp-club#2033` listing-fallback tests override the wrong method name in both test files.** `gcs_cache_sync_test.ts` (new test "swamp-club#2033: GCS pullChanged falls back to per-file behavior when listing fails") and its S3 mirror override `listObjects`: ```typescript (mock as unknown as Record<string, unknown>).listObjects = () => { return Promise.reject(new GcsOperationError(...)); }; ``` But the production code calls `this.gcs.listAllObjects(...)`, not `listObjects`. The mock has `listAllObjects` defined; overriding `listObjects` adds an unreachable property. The `listAllObjects` call in `pullChanged` still succeeds, `remoteKeys` is populated normally, and the test asserts `pulled === 1` — which passes, but because the file was pulled via the normal path, not the intended fallback path. The fallback code under `catch` is never exercised. The test name is wrong and the scenario it claims to cover is not actually tested. Fix: override `listAllObjects` instead of `listObjects` on the mock: ```typescript (mock as unknown as Record<string, unknown>).listAllObjects = () => { return Promise.reject(new GcsOperationError(...)); }; ``` (Same correction applies to the S3 mirror test using `S3OperationError`.) ### Suggestions 1. **Minor: `formatBatchFailure` ternary style differs between GCS and S3.** GCS uses a three-way ternary (`op === "pull" ? "from" : op === "delete" ? "from" : "to"`) while S3 uses a simpler two-way form (`op === "push" ? "to" : "from"`). Both produce identical results, but consistency between the two mirrored implementations would make future diffs easier to read.
Author
Owner

Code Review: bulk-diff hydrate, arm commitSeq fast path, skip root migration (#2033, #1931, #2091)

Summary

This PR adds three features to both GCS and S3 datastore sync services:

  1. Bulk-diff remote listing (#2033): pullChanged now lists all remote objects upfront to prune stale index entries in bulk, avoiding O(files) per-file 404 round-trips.
  2. commitSeq fast-path arming (#1931): After a clean v2 shard-first pull with no 404 cleanup, the sidecar now records commitSeq so the next pull can short-circuit via the fast path.
  3. Skip root migration for v2 repos (#2091): pushChanged/preparePush skip the root-to-namespace data key migration when v2 shard-first indexing is already in place.

Test coverage includes new tests for all three features plus a fix to an existing error-propagation test that would silently pass under the new bulk-diff listing.

617 insertions, 47 deletions across 6 files.


Findings

1. Pull-side shard writeback without distributed lock — amplified blast radius

Attribute Value
Severity Medium
Dimension Data Integrity / Concurrency
Files gcs_cache_sync.ts:1978–2041, s3_cache_sync.ts:2113–2175

The 404 shard writeback block (introduced by #2063) calls writeShard() and writePartitionMeta() during pullChanged(), which runs without the distributed lock. Both methods are unconditional PUTs (gcs_cache_sync.ts:880–881, s3_cache_sync.ts:947–948) — no generation/ETag preconditions.

This PR amplifies the existing race by routing all listing-pruned entries (potentially many) through pull404PartitionKeys into the same unlocked writeback path. Previously, only per-file download-time 404s fed this set (a handful at most). With the bulk-diff listing, a pull against a remote that had a recent GC or compaction could prune hundreds of entries and trigger shard rewrites for many partitions.

Race scenario:

  1. Pull lists remote objects, sees entries X are gone.
  2. Concurrent push (on another machine, holding the lock) writes shard S with new entries Y and bumps commitSeq to N+1.
  3. Push releases lock.
  4. Pull's shard writeback overwrites shard S with entries that exclude both X (pruned) AND Y (not known to pull), then writes _meta.json with commitSeq = M+1 where M < N+1.
  5. Entries Y become orphaned — data files exist remotely but are not indexed. Recovery requires a full discoverIndexFromBucket (new machine or empty cache).

The shard writeback path itself is pre-existing (#2063), but the increased volume of entries flowing through it materially increases the probability and blast radius of this race.

Recommendation: Consider gating the shard writeback on a short lock acquisition (with a fast timeout — if the lock is held, skip the writeback like the existing "non-fatal" fallback), or at minimum use conditional writes (GCS generation-match / S3 If-Match) to detect concurrent modifications and abort the writeback if the shard was modified since it was read.


2. Full-bucket listing without sub-prefix scoping

Attribute Value
Severity Medium
Dimension Resource Management / Performance
Files gcs_cache_sync.ts:1808–1811, s3_cache_sync.ts:1901–1904

The bulk-diff calls listAllObjects(undefined, signal), which lists all objects under the bucket's configured prefix. Other listing calls in the codebase scope to a sub-prefix (e.g., discoverIndexFromBucket scopes to ${namespace}/ at line 1552, migrateMonolithToShards scopes to _index/ at line 1124).

The comment explains the rationale: root-level files from pre-namespace pushes must be captured. However, for large shared-prefix deployments (multi-namespace datastores with hundreds of models), this unscoped listing could:

  • Return millions of objects, creating a large Set<string> in memory.
  • Cost significant API egress (S3 ListObjectsV2 / GCS Objects.list are paginated at 1000).
  • Add latency to every pullChanged call, even when most listed objects are irrelevant.

The graceful fallback on listing failure mitigates hard failures but not the performance degradation when the listing succeeds but is large.

Recommendation: Consider scoping to the namespace prefix (falling back to unscoped only for the non-namespaced case), or listing with namespace prefix first and falling back to an unscoped listing only when the index contains entries that don't match the namespace listing. Alternatively, add a configurable size threshold: if the listing returns more than N entries, discard it and fall back to per-file behavior.


3. Partial failure in shard writeback leaves inconsistent local state

Attribute Value
Severity Low
Dimension Error Handling
Files gcs_cache_sync.ts:1986–2041, s3_cache_sync.ts:2121–2175

The shard writeback block is wrapped in a single try/catch that covers multiple sequential operations: writeShard() (per partition), writePartitionMeta(), atomicWriteTextFile(), and writeSyncState(). If an early operation succeeds but a later one fails:

  • writePartitionMeta succeeds (remote commitSeq bumped) but atomicWriteTextFile fails → remote and local index diverge. Next boot reads stale local index, but the remote shards have the updated commitSeq.
  • atomicWriteTextFile succeeds but writeSyncState fails → local index is updated but sidecar doesn't record the new commitSeq. Next boot takes the slow path but finds consistent state.

The first case is self-healing: the next pullChanged will re-read remote shards (commitSeq mismatch forces slow path) and re-prune the stale entries. But the recovery costs an extra slow-path cycle and the intermediate state could confuse concurrent operations.

Recommendation: Consider writing the sidecar and local index before the remote shard writeback, so local state is always at least as up-to-date as remote state. Alternatively, break the try/catch into separate blocks so partial remote writes can be logged distinctly.


4. Test fix for error-propagation test is correct but subtle

Attribute Value
Severity Low
Dimension Logic / Correctness
Files gcs_cache_sync_test.ts:4532, s3_cache_sync_test.ts:4867

The existing "still throws on non-NotFound errors" test is updated to add mock.storage.set("data/file/v1/raw", ...). This is necessary because the bulk-diff listing now prunes entries not found in remote storage before the download loop runs. Without seeding the file, the listing would prune the entry and the overridden getObject (which throws an auth error) would never be reached — the test would pass vacuously without exercising the error-propagation path.

The fix is correct. The subtlety is that this test's validity now depends on the bulk-diff listing being exhaustive — if a future change makes the listing skip certain entries, this test could regress silently. A brief inline comment explaining why the file is seeded would aid future maintainers.


5. Empty-string sidecar fields are consistent but semantically ambiguous

Attribute Value
Severity Low
Dimension Logic / Correctness
Files gcs_cache_sync.ts:2048–2049, s3_cache_sync.ts:2182–2183

The new else if (v2CommitSeq !== null) block writes remoteIndexGeneration: "" (GCS) / remoteIndexETag: "" (S3) to the sidecar. An empty string is truthy in JavaScript, which could matter if any fast-path check uses if (sidecar.remoteIndexGeneration) rather than strict comparison.

This matches the existing pattern in the #2063 shard writeback block directly above, so the fast-path code must already handle empty strings correctly. Consistent but worth noting for future readers.


Not Flagged (Examined and Clean)

  • Path traversal: assertSafePath is correctly applied to all local file paths derived from index entries.
  • Iteration safety: Object.entries() creates a snapshot before the loop, so delete this.index.entries[rel] during iteration is safe.
  • Abort signal propagation: throwIfAborted(signal) is called at batch boundaries; signal is forwarded to all remote operations.
  • S3/GCS parity: The bulk-diff, commitSeq arming, and migration-skip logic are structurally identical across both providers, differing only in provider-specific types (generation vs ETag, NotFoundError class vs error name checks).
  • Test coverage: New tests cover all three features (commitSeq fast-path arming, v2 migration skip, listing fallback, listing-based pruning) for both providers. Tests correctly use in-memory mocks and clean up temp directories in finally blocks.
  • Manifest versions: Both bumped to 2026.09.10.2, consistent.
  • Fresh v2 init: Silent _meta.json write failure on empty buckets is documented and self-healing (retries on next run). No data loss risk since the bucket is empty.
  • formatBatchFailure S3 preposition: S3 uses "from" for both pull and delete (vs GCS which distinguishes all three ops). Pre-existing difference, not introduced by this PR.

Verdict

No critical or high-severity findings. Two medium findings (unlocked shard writeback amplification, full-bucket listing scope) and three low findings. The medium findings represent defense-in-depth improvements rather than likely-to-hit bugs in normal operation — the existing self-healing mechanisms (slow-path fallback, re-discovery) provide recovery paths for the identified races.

## Code Review: bulk-diff hydrate, arm commitSeq fast path, skip root migration (#2033, #1931, #2091) ### Summary This PR adds three features to both GCS and S3 datastore sync services: 1. **Bulk-diff remote listing** (#2033): `pullChanged` now lists all remote objects upfront to prune stale index entries in bulk, avoiding O(files) per-file 404 round-trips. 2. **commitSeq fast-path arming** (#1931): After a clean v2 shard-first pull with no 404 cleanup, the sidecar now records `commitSeq` so the next pull can short-circuit via the fast path. 3. **Skip root migration for v2 repos** (#2091): `pushChanged`/`preparePush` skip the root-to-namespace data key migration when v2 shard-first indexing is already in place. Test coverage includes new tests for all three features plus a fix to an existing error-propagation test that would silently pass under the new bulk-diff listing. 617 insertions, 47 deletions across 6 files. --- ### Findings #### 1. Pull-side shard writeback without distributed lock — amplified blast radius | Attribute | Value | |-----------|-------| | Severity | **Medium** | | Dimension | Data Integrity / Concurrency | | Files | `gcs_cache_sync.ts:1978–2041`, `s3_cache_sync.ts:2113–2175` | The 404 shard writeback block (introduced by #2063) calls `writeShard()` and `writePartitionMeta()` during `pullChanged()`, which runs **without** the distributed lock. Both methods are unconditional PUTs (`gcs_cache_sync.ts:880–881`, `s3_cache_sync.ts:947–948`) — no generation/ETag preconditions. This PR amplifies the existing race by routing **all** listing-pruned entries (potentially many) through `pull404PartitionKeys` into the same unlocked writeback path. Previously, only per-file download-time 404s fed this set (a handful at most). With the bulk-diff listing, a pull against a remote that had a recent GC or compaction could prune hundreds of entries and trigger shard rewrites for many partitions. **Race scenario:** 1. Pull lists remote objects, sees entries X are gone. 2. Concurrent push (on another machine, holding the lock) writes shard S with new entries Y and bumps `commitSeq` to N+1. 3. Push releases lock. 4. Pull's shard writeback overwrites shard S with entries that exclude both X (pruned) AND Y (not known to pull), then writes `_meta.json` with `commitSeq = M+1` where M < N+1. 5. Entries Y become orphaned — data files exist remotely but are not indexed. Recovery requires a full `discoverIndexFromBucket` (new machine or empty cache). The shard writeback path itself is pre-existing (#2063), but the increased volume of entries flowing through it materially increases the probability and blast radius of this race. **Recommendation:** Consider gating the shard writeback on a short lock acquisition (with a fast timeout — if the lock is held, skip the writeback like the existing "non-fatal" fallback), or at minimum use conditional writes (GCS generation-match / S3 If-Match) to detect concurrent modifications and abort the writeback if the shard was modified since it was read. --- #### 2. Full-bucket listing without sub-prefix scoping | Attribute | Value | |-----------|-------| | Severity | **Medium** | | Dimension | Resource Management / Performance | | Files | `gcs_cache_sync.ts:1808–1811`, `s3_cache_sync.ts:1901–1904` | The bulk-diff calls `listAllObjects(undefined, signal)`, which lists **all** objects under the bucket's configured prefix. Other listing calls in the codebase scope to a sub-prefix (e.g., `discoverIndexFromBucket` scopes to `${namespace}/` at line 1552, `migrateMonolithToShards` scopes to `_index/` at line 1124). The comment explains the rationale: root-level files from pre-namespace pushes must be captured. However, for large shared-prefix deployments (multi-namespace datastores with hundreds of models), this unscoped listing could: - Return millions of objects, creating a large `Set<string>` in memory. - Cost significant API egress (S3 ListObjectsV2 / GCS Objects.list are paginated at 1000). - Add latency to every `pullChanged` call, even when most listed objects are irrelevant. The graceful fallback on listing failure mitigates hard failures but not the performance degradation when the listing succeeds but is large. **Recommendation:** Consider scoping to the namespace prefix (falling back to unscoped only for the non-namespaced case), or listing with namespace prefix first and falling back to an unscoped listing only when the index contains entries that don't match the namespace listing. Alternatively, add a configurable size threshold: if the listing returns more than N entries, discard it and fall back to per-file behavior. --- #### 3. Partial failure in shard writeback leaves inconsistent local state | Attribute | Value | |-----------|-------| | Severity | **Low** | | Dimension | Error Handling | | Files | `gcs_cache_sync.ts:1986–2041`, `s3_cache_sync.ts:2121–2175` | The shard writeback block is wrapped in a single `try/catch` that covers multiple sequential operations: `writeShard()` (per partition), `writePartitionMeta()`, `atomicWriteTextFile()`, and `writeSyncState()`. If an early operation succeeds but a later one fails: - `writePartitionMeta` succeeds (remote commitSeq bumped) but `atomicWriteTextFile` fails → remote and local index diverge. Next boot reads stale local index, but the remote shards have the updated commitSeq. - `atomicWriteTextFile` succeeds but `writeSyncState` fails → local index is updated but sidecar doesn't record the new commitSeq. Next boot takes the slow path but finds consistent state. The first case is self-healing: the next `pullChanged` will re-read remote shards (commitSeq mismatch forces slow path) and re-prune the stale entries. But the recovery costs an extra slow-path cycle and the intermediate state could confuse concurrent operations. **Recommendation:** Consider writing the sidecar and local index **before** the remote shard writeback, so local state is always at least as up-to-date as remote state. Alternatively, break the try/catch into separate blocks so partial remote writes can be logged distinctly. --- #### 4. Test fix for error-propagation test is correct but subtle | Attribute | Value | |-----------|-------| | Severity | **Low** | | Dimension | Logic / Correctness | | Files | `gcs_cache_sync_test.ts:4532`, `s3_cache_sync_test.ts:4867` | The existing "still throws on non-NotFound errors" test is updated to add `mock.storage.set("data/file/v1/raw", ...)`. This is necessary because the bulk-diff listing now prunes entries not found in remote storage **before** the download loop runs. Without seeding the file, the listing would prune the entry and the overridden `getObject` (which throws an auth error) would never be reached — the test would pass vacuously without exercising the error-propagation path. The fix is correct. The subtlety is that this test's validity now depends on the bulk-diff listing being exhaustive — if a future change makes the listing skip certain entries, this test could regress silently. A brief inline comment explaining why the file is seeded would aid future maintainers. --- #### 5. Empty-string sidecar fields are consistent but semantically ambiguous | Attribute | Value | |-----------|-------| | Severity | **Low** | | Dimension | Logic / Correctness | | Files | `gcs_cache_sync.ts:2048–2049`, `s3_cache_sync.ts:2182–2183` | The new `else if (v2CommitSeq !== null)` block writes `remoteIndexGeneration: ""` (GCS) / `remoteIndexETag: ""` (S3) to the sidecar. An empty string is truthy in JavaScript, which could matter if any fast-path check uses `if (sidecar.remoteIndexGeneration)` rather than strict comparison. This matches the existing pattern in the #2063 shard writeback block directly above, so the fast-path code must already handle empty strings correctly. Consistent but worth noting for future readers. --- ### Not Flagged (Examined and Clean) - **Path traversal:** `assertSafePath` is correctly applied to all local file paths derived from index entries. - **Iteration safety:** `Object.entries()` creates a snapshot before the loop, so `delete this.index.entries[rel]` during iteration is safe. - **Abort signal propagation:** `throwIfAborted(signal)` is called at batch boundaries; `signal` is forwarded to all remote operations. - **S3/GCS parity:** The bulk-diff, commitSeq arming, and migration-skip logic are structurally identical across both providers, differing only in provider-specific types (`generation` vs `ETag`, `NotFoundError` class vs error name checks). - **Test coverage:** New tests cover all three features (commitSeq fast-path arming, v2 migration skip, listing fallback, listing-based pruning) for both providers. Tests correctly use in-memory mocks and clean up temp directories in `finally` blocks. - **Manifest versions:** Both bumped to `2026.09.10.2`, consistent. - **Fresh v2 init:** Silent `_meta.json` write failure on empty buckets is documented and self-healing (retries on next run). No data loss risk since the bucket is empty. - **`formatBatchFailure` S3 preposition:** S3 uses `"from"` for both pull and delete (vs GCS which distinguishes all three ops). Pre-existing difference, not introduced by this PR. ### Verdict **No critical or high-severity findings.** Two medium findings (unlocked shard writeback amplification, full-bucket listing scope) and three low findings. The medium findings represent defense-in-depth improvements rather than likely-to-hit bugs in normal operation — the existing self-healing mechanisms (slow-path fallback, re-discovery) provide recovery paths for the identified races.
fix(datastore): override listAllObjects not listObjects in fallback tests
Some checks failed
CI: Datastores / datastore/gcs - check (pull_request) Successful in 33s
CI: Datastores / datastore/gcs - fmt (pull_request) Successful in 33s
CI: Datastores / datastore/gcs - lint (pull_request) Successful in 34s
CI: Datastores / datastore/s3 - fmt (pull_request) Successful in 34s
CI: Datastores / datastore/s3 - check (pull_request) Successful in 38s
CI: Datastores / datastore/gcs - test (pull_request) Successful in 56s
CI: Datastores / datastore/s3 - lint (pull_request) Successful in 41s
CI: Datastores / datastore/gcs - lockfile up to date (pull_request) Successful in 42s
CI: Datastores / datastore/s3 - lockfile up to date (pull_request) Successful in 32s
CI: Datastores / datastore/s3 - test (pull_request) Successful in 1m32s
CI: Reviews / Detect Changes (pull_request) Successful in 31s
CI: Reviews / CI Security Review (pull_request) Has been skipped
CI: Datastores / Gate: Datastores (pull_request) Successful in 0s
CI / Actions Audit (pull_request) Successful in 33s
CI / Dependency Audit (pull_request) Successful in 2m55s
CI / Gate: Audit (pull_request) Successful in 1s
CI: Reviews / Claude Code Review (pull_request) Failing after 5m17s
CI: Reviews / Adversarial Code Review (pull_request) Successful in 6m46s
CI: Reviews / Gate: Reviews (pull_request) Failing after 0s
efedec9fe4
The listing-fallback tests were overriding listObjects, but
pullChanged calls listAllObjects. The mock's real listAllObjects
still succeeded, so the catch path was never exercised.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author
Owner

Code Review

Blocking Issues

  1. Missing GCS tests for swamp-club#2033 (bulk-diff listing): The bulk-diff remote listing feature was added to gcs_cache_sync.ts (new listAllObjects call in pullChanged, pull404PartitionKeys pre-population from the listing, listing fallback on error). The S3 test file has two corresponding tests for this exact feature:

    • "swamp-club#2033: pullChanged falls back to per-file behavior when listing fails" — verifies the graceful degradation path when listAllObjects throws
    • "swamp-club#2033: pullChanged prunes stale entries via listing without per-file 404" — verifies that index entries absent from the listing are pruned without issuing a getObject per file

    Neither test has a GCS counterpart in gcs_cache_sync_test.ts. The project's testing rules require "new functionality in vault/ or datastore/ extensions should have corresponding tests." The feature is present in the GCS implementation (identical logic to S3) but lacks coverage.

Suggestions

  1. Test fix for "pullChanged: still throws on non-NotFound errors" (both S3 and GCS, already applied correctly): Seeding data/file/v1/raw into mock.storage ensures the new listing code doesn't prune it before the overridden getObject can fire the auth error. The fix is correct; just noting it's load-bearing for the test's intended behavior.

  2. No-op pruning when listing fails: The listing call inside pullChanged uses no prefix (undefined), which for a namespaced repo results in a full-bucket listing. If another namespace shares the same bucket and has a root-level object with the same relative path as a namespaced object (data/file.yaml at root vs my-ns/data/file.yaml), the root entry would add data/file.yaml to remoteKeys, preventing a legitimate prune of a missing namespaced object. The inline comment explains this is intentional to preserve the pre-namespace fallback behavior, and pullFile handles the 404 correctly. This is fine as-is, but worth noting for future reviewers.

## Code Review ### Blocking Issues 1. **Missing GCS tests for swamp-club#2033 (bulk-diff listing)**: The bulk-diff remote listing feature was added to `gcs_cache_sync.ts` (new `listAllObjects` call in `pullChanged`, `pull404PartitionKeys` pre-population from the listing, listing fallback on error). The S3 test file has two corresponding tests for this exact feature: - `"swamp-club#2033: pullChanged falls back to per-file behavior when listing fails"` — verifies the graceful degradation path when `listAllObjects` throws - `"swamp-club#2033: pullChanged prunes stale entries via listing without per-file 404"` — verifies that index entries absent from the listing are pruned without issuing a `getObject` per file Neither test has a GCS counterpart in `gcs_cache_sync_test.ts`. The project's testing rules require "new functionality in vault/ or datastore/ extensions should have corresponding tests." The feature is present in the GCS implementation (identical logic to S3) but lacks coverage. ### Suggestions 1. **Test fix for `"pullChanged: still throws on non-NotFound errors"`** (both S3 and GCS, already applied correctly): Seeding `data/file/v1/raw` into `mock.storage` ensures the new listing code doesn't prune it before the overridden `getObject` can fire the auth error. The fix is correct; just noting it's load-bearing for the test's intended behavior. 2. **No-op pruning when listing fails**: The listing call inside `pullChanged` uses no prefix (`undefined`), which for a namespaced repo results in a full-bucket listing. If another namespace shares the same bucket and has a root-level object with the same relative path as a namespaced object (`data/file.yaml` at root vs `my-ns/data/file.yaml`), the root entry would add `data/file.yaml` to `remoteKeys`, preventing a legitimate prune of a missing namespaced object. The inline comment explains this is intentional to preserve the pre-namespace fallback behavior, and `pullFile` handles the 404 correctly. This is fine as-is, but worth noting for future reviewers.
Author
Owner

Adversarial Code Review

PR Summary

This PR implements three improvements to both GCS and S3 datastore sync backends:

  1. Bulk-diff hydration (#2033): Pre-fetches a full remote listing via listAllObjects to prune stale index entries before the download loop, reducing per-file 404 round-trips from O(files) to O(files/1000) list calls.
  2. commitSeq fast-path arming (#1931): After a clean v2 shard-first pull with no 404s, writes commitSeq to the sidecar so subsequent pulls can short-circuit via the fast path.
  3. Skip root migration for v2 (#2091): When a v2 shard-first index is assembled, skips the migrateRootDataToNamespace call entirely (data is already namespaced) and marks dataKeyMigrated = true in the sidecar.

Additionally:

  • Fixes an existing test ("still throws on non-NotFound errors") by seeding mock storage with the data file, so bulk-diff doesn't prune it before the download attempt.
  • Adds 4 new tests per backend covering #1931, #2091, and #2033 (fallback + pruning).
  • Version bumps both manifests to 2026.09.10.2.

Findings

MEDIUM-1: Unbounded remote listing in bulk-diff may spike memory on large buckets

File: gcs_cache_sync.ts (lines 1808-1824), s3_cache_sync.ts (lines 1901-1917)
Dimension: Resource Management

The bulk-diff listing calls listAllObjects(undefined, signal) which fetches every object under the configured bucket prefix. For repositories with millions of objects (e.g., long-lived multi-model datastores), this will:

  • Load all keys into a Set<string> — potentially hundreds of MB of string data.
  • Block the download phase until all paginated list calls complete.
  • Only bail out on hard errors (network, auth) — not on high key counts.

The catch-all fallback is correctly wired: on listing failure, remoteKeys stays null and the code falls back to per-file 404 behavior. The AbortSignal is passed through, providing external timeout cancellation.

Mitigating factors: The listing is bounded by the datastore's configured prefix, and extremely large datastores already perform full listings in other code paths (e.g., repairNamespaceContamination, migrateRootDataToNamespace). This is a conscious trade-off per the comment.

Recommendation: Consider adding a high-water mark — if the listing exceeds N entries (e.g., 500K), abort and fall back to per-file 404 behavior. This would bound memory without changing the happy path.


MEDIUM-2: Bulk-diff listing includes all namespaces, not just the active one

File: gcs_cache_sync.ts (lines 1808-1824), s3_cache_sync.ts (lines 1901-1917)
Dimension: Resource Management / Data Integrity

The listing passes undefined as the sub-prefix, meaning it returns objects from all namespaces in the bucket. The code strips the current namespace prefix from matching keys and adds non-matching keys as-is. For multi-namespace shared datastores, this means:

  • The Set contains keys from all namespaces (unnecessary extra memory).
  • Non-current-namespace keys won't match any local index entries, so they're inert — no false pruning occurs.

This is documented as intentional ("root-level files from pre-namespace pushes are captured"), so the correctness impact is nil. The only cost is the larger-than-necessary listing.

Recommendation: No action needed for correctness. If performance matters, a namespace-scoped listing with a root-fallback listing would be more efficient, but adds complexity.


LOW-1: S3 mock listAllObjects does not accept signal parameter

File: s3_cache_sync_test.ts (line 162)
Dimension: API Contract Violations (test-only)

The S3 mock's listAllObjects(subPrefix?: string) omits the signal parameter that the real S3Client.listAllObjects(subPrefix?, signal?) accepts. JavaScript silently ignores extra arguments, so this doesn't cause test failures, but the mock doesn't honor abort signals. The GCS mock correctly includes signal in its signature (line 180).

Recommendation: Add signal?: AbortSignal to the S3 mock's listAllObjects for parity with the GCS mock and the real client.


LOW-2: pull404PartitionKeys set now accumulates from two sources — comment could clarify

File: gcs_cache_sync.ts (line 1833), s3_cache_sync.ts (line 1927)
Dimension: Logic & Correctness

The pull404PartitionKeys set was moved from after the download loop to before the index walk. It now collects partition keys from two sources: (1) entries pruned by the bulk-diff listing, and (2) entries that 404 during download. The later shard-cleanup code consumes this set identically regardless of source. This is correct behavior — both sources represent entries whose remote object is gone — but the moved declaration and dual-source accumulation could surprise a future reader.

Recommendation: A one-line comment at the declaration explaining the dual-source accumulation would help.


INFO-1: Test fix for "non-NotFound errors" is correct and necessary

File: gcs_cache_sync_test.ts (line 4532), s3_cache_sync_test.ts (line 4867)
Dimension: Logic & Correctness

Both test files add mock.storage.set("data/file/v1/raw", ...) to seed the file in mock storage. Without this, the new bulk-diff listing would see the file is absent from the remote and prune it from the index, preventing the getObject override (which throws a 403) from ever firing. The fix ensures the listing includes the file so the download path is exercised. Correct and well-motivated.


INFO-2: GCS and S3 implementations are structurally identical — consistent changes

Dimension: Logic & Correctness

All three features (#2033, #1931, #2091) are applied symmetrically to both backends with appropriate backend-specific differences:

  • GCS uses remoteIndexGeneration: "" vs S3 uses remoteIndexETag: ""
  • GCS calls this.gcs.listAllObjects() vs S3 calls this.s3.listAllObjects()
  • Error types are backend-appropriate (GcsOperationError vs S3OperationError)

The structural symmetry is well maintained. No divergence found.


INFO-3: Migration skip logic in pushChanged and preparePush is correct

Dimension: Logic & Correctness

The assembled/prepAssembled variables are assigned from assembleIndexFromShards or assembleDirtyShardsOnly earlier in each method. When truthy, the v2 shard-first index was successfully assembled, meaning data is already namespace-scoped. Skipping migrateRootDataToNamespace and marking dataKeyMigrated = true is the correct behavior — migration is a v1-to-v2 concern, not a v2-to-v2 concern. The else branches preserve the original migration logic unchanged.


Dimension Summary

Dimension Findings
Logic & Correctness No issues. Pruning logic, fast-path arming, and migration skip are all correct.
Error Handling Bulk-diff listing failure is correctly caught and triggers graceful fallback. All new sidecar writes are wrapped in non-fatal try/catch.
Security No new attack surface. No user-controlled paths processed. assertSafePath usage is unchanged.
Concurrency & State Index mutation during Object.entries() iteration is safe (snapshot). pull404PartitionKeys dual-source accumulation is correct. No new concurrency hazards.
Data Integrity Pruning only removes entries from in-memory index — remote and local files are not deleted. TOCTOU window between listing and index walk is bounded by the sync lock.
Resource Management MEDIUM: Unbounded listAllObjects may spike memory on very large buckets. Mitigated by prefix scoping and existing precedent.
API Contract Violations LOW: S3 mock omits signal param on listAllObjects.
Codegen Safety N/A — no codegen changes.

Verdict

PASS — No critical or high-severity findings. The two medium findings are resource-management concerns with clear mitigating factors and no correctness impact. The changes are well-structured, symmetrically applied, and thoroughly tested.

# Adversarial Code Review ## PR Summary This PR implements three improvements to both GCS and S3 datastore sync backends: 1. **Bulk-diff hydration (#2033)**: Pre-fetches a full remote listing via `listAllObjects` to prune stale index entries *before* the download loop, reducing per-file 404 round-trips from O(files) to O(files/1000) list calls. 2. **commitSeq fast-path arming (#1931)**: After a clean v2 shard-first pull with no 404s, writes `commitSeq` to the sidecar so subsequent pulls can short-circuit via the fast path. 3. **Skip root migration for v2 (#2091)**: When a v2 shard-first index is assembled, skips the `migrateRootDataToNamespace` call entirely (data is already namespaced) and marks `dataKeyMigrated = true` in the sidecar. Additionally: - Fixes an existing test ("still throws on non-NotFound errors") by seeding mock storage with the data file, so bulk-diff doesn't prune it before the download attempt. - Adds 4 new tests per backend covering #1931, #2091, and #2033 (fallback + pruning). - Version bumps both manifests to `2026.09.10.2`. --- ## Findings ### MEDIUM-1: Unbounded remote listing in bulk-diff may spike memory on large buckets **File:** `gcs_cache_sync.ts` (lines 1808-1824), `s3_cache_sync.ts` (lines 1901-1917) **Dimension:** Resource Management The bulk-diff listing calls `listAllObjects(undefined, signal)` which fetches *every* object under the configured bucket prefix. For repositories with millions of objects (e.g., long-lived multi-model datastores), this will: - Load all keys into a `Set<string>` — potentially hundreds of MB of string data. - Block the download phase until all paginated list calls complete. - Only bail out on hard errors (network, auth) — not on high key counts. The catch-all fallback is correctly wired: on listing failure, `remoteKeys` stays `null` and the code falls back to per-file 404 behavior. The `AbortSignal` is passed through, providing external timeout cancellation. **Mitigating factors:** The listing is bounded by the datastore's configured prefix, and extremely large datastores already perform full listings in other code paths (e.g., `repairNamespaceContamination`, `migrateRootDataToNamespace`). This is a conscious trade-off per the comment. **Recommendation:** Consider adding a high-water mark — if the listing exceeds N entries (e.g., 500K), abort and fall back to per-file 404 behavior. This would bound memory without changing the happy path. --- ### MEDIUM-2: Bulk-diff listing includes all namespaces, not just the active one **File:** `gcs_cache_sync.ts` (lines 1808-1824), `s3_cache_sync.ts` (lines 1901-1917) **Dimension:** Resource Management / Data Integrity The listing passes `undefined` as the sub-prefix, meaning it returns objects from *all* namespaces in the bucket. The code strips the current namespace prefix from matching keys and adds non-matching keys as-is. For multi-namespace shared datastores, this means: - The `Set` contains keys from all namespaces (unnecessary extra memory). - Non-current-namespace keys won't match any local index entries, so they're inert — no false pruning occurs. This is documented as intentional ("root-level files from pre-namespace pushes are captured"), so the correctness impact is nil. The only cost is the larger-than-necessary listing. **Recommendation:** No action needed for correctness. If performance matters, a namespace-scoped listing with a root-fallback listing would be more efficient, but adds complexity. --- ### LOW-1: S3 mock `listAllObjects` does not accept `signal` parameter **File:** `s3_cache_sync_test.ts` (line 162) **Dimension:** API Contract Violations (test-only) The S3 mock's `listAllObjects(subPrefix?: string)` omits the `signal` parameter that the real `S3Client.listAllObjects(subPrefix?, signal?)` accepts. JavaScript silently ignores extra arguments, so this doesn't cause test failures, but the mock doesn't honor abort signals. The GCS mock correctly includes `signal` in its signature (line 180). **Recommendation:** Add `signal?: AbortSignal` to the S3 mock's `listAllObjects` for parity with the GCS mock and the real client. --- ### LOW-2: `pull404PartitionKeys` set now accumulates from two sources — comment could clarify **File:** `gcs_cache_sync.ts` (line 1833), `s3_cache_sync.ts` (line 1927) **Dimension:** Logic & Correctness The `pull404PartitionKeys` set was moved from after the download loop to before the index walk. It now collects partition keys from two sources: (1) entries pruned by the bulk-diff listing, and (2) entries that 404 during download. The later shard-cleanup code consumes this set identically regardless of source. This is correct behavior — both sources represent entries whose remote object is gone — but the moved declaration and dual-source accumulation could surprise a future reader. **Recommendation:** A one-line comment at the declaration explaining the dual-source accumulation would help. --- ### INFO-1: Test fix for "non-NotFound errors" is correct and necessary **File:** `gcs_cache_sync_test.ts` (line 4532), `s3_cache_sync_test.ts` (line 4867) **Dimension:** Logic & Correctness Both test files add `mock.storage.set("data/file/v1/raw", ...)` to seed the file in mock storage. Without this, the new bulk-diff listing would see the file is absent from the remote and prune it from the index, preventing the `getObject` override (which throws a 403) from ever firing. The fix ensures the listing includes the file so the download path is exercised. Correct and well-motivated. --- ### INFO-2: GCS and S3 implementations are structurally identical — consistent changes **Dimension:** Logic & Correctness All three features (#2033, #1931, #2091) are applied symmetrically to both backends with appropriate backend-specific differences: - GCS uses `remoteIndexGeneration: ""` vs S3 uses `remoteIndexETag: ""` - GCS calls `this.gcs.listAllObjects()` vs S3 calls `this.s3.listAllObjects()` - Error types are backend-appropriate (`GcsOperationError` vs `S3OperationError`) The structural symmetry is well maintained. No divergence found. --- ### INFO-3: Migration skip logic in `pushChanged` and `preparePush` is correct **Dimension:** Logic & Correctness The `assembled`/`prepAssembled` variables are assigned from `assembleIndexFromShards` or `assembleDirtyShardsOnly` earlier in each method. When truthy, the v2 shard-first index was successfully assembled, meaning data is already namespace-scoped. Skipping `migrateRootDataToNamespace` and marking `dataKeyMigrated = true` is the correct behavior — migration is a v1-to-v2 concern, not a v2-to-v2 concern. The else branches preserve the original migration logic unchanged. --- ## Dimension Summary | Dimension | Findings | |---|---| | Logic & Correctness | No issues. Pruning logic, fast-path arming, and migration skip are all correct. | | Error Handling | Bulk-diff listing failure is correctly caught and triggers graceful fallback. All new sidecar writes are wrapped in non-fatal try/catch. | | Security | No new attack surface. No user-controlled paths processed. `assertSafePath` usage is unchanged. | | Concurrency & State | Index mutation during `Object.entries()` iteration is safe (snapshot). `pull404PartitionKeys` dual-source accumulation is correct. No new concurrency hazards. | | Data Integrity | Pruning only removes entries from in-memory index — remote and local files are not deleted. TOCTOU window between listing and index walk is bounded by the sync lock. | | Resource Management | MEDIUM: Unbounded `listAllObjects` may spike memory on very large buckets. Mitigated by prefix scoping and existing precedent. | | API Contract Violations | LOW: S3 mock omits `signal` param on `listAllObjects`. | | Codegen Safety | N/A — no codegen changes. | --- ## Verdict **PASS** — No critical or high-severity findings. The two medium findings are resource-management concerns with clear mitigating factors and no correctness impact. The changes are well-structured, symmetrically applied, and thoroughly tested.
Some checks failed
CI: Datastores / datastore/gcs - check (pull_request) Successful in 33s
CI: Datastores / datastore/gcs - fmt (pull_request) Successful in 33s
CI: Datastores / datastore/gcs - lint (pull_request) Successful in 34s
CI: Datastores / datastore/s3 - fmt (pull_request) Successful in 34s
CI: Datastores / datastore/s3 - check (pull_request) Successful in 38s
CI: Datastores / datastore/gcs - test (pull_request) Successful in 56s
CI: Datastores / datastore/s3 - lint (pull_request) Successful in 41s
CI: Datastores / datastore/gcs - lockfile up to date (pull_request) Successful in 42s
CI: Datastores / datastore/s3 - lockfile up to date (pull_request) Successful in 32s
CI: Datastores / datastore/s3 - test (pull_request) Successful in 1m32s
CI: Reviews / Detect Changes (pull_request) Successful in 31s
CI: Reviews / CI Security Review (pull_request) Has been skipped
CI: Datastores / Gate: Datastores (pull_request) Successful in 0s
CI / Actions Audit (pull_request) Successful in 33s
CI / Dependency Audit (pull_request) Successful in 2m55s
CI / Gate: Audit (pull_request) Successful in 1s
CI: Reviews / Claude Code Review (pull_request) Failing after 5m17s
CI: Reviews / Adversarial Code Review (pull_request) Successful in 6m46s
CI: Reviews / Gate: Reviews (pull_request) Failing after 0s
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin fix/datastore-pull-push-perf-2033-1931-2091:fix/datastore-pull-push-perf-2033-1931-2091
git switch fix/datastore-pull-push-perf-2033-1931-2091

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch main
git merge --no-ff fix/datastore-pull-push-perf-2033-1931-2091
git switch fix/datastore-pull-push-perf-2033-1931-2091
git rebase main
git switch main
git merge --ff-only fix/datastore-pull-push-perf-2033-1931-2091
git switch fix/datastore-pull-push-perf-2033-1931-2091
git rebase main
git switch main
git merge --no-ff fix/datastore-pull-push-perf-2033-1931-2091
git switch main
git merge --squash fix/datastore-pull-push-perf-2033-1931-2091
git switch main
git merge --ff-only fix/datastore-pull-push-perf-2033-1931-2091
git switch main
git merge fix/datastore-pull-push-perf-2033-1931-2091
git push origin main
Sign in to join this conversation.
No reviewers
No labels
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
swamp-club/swamp-extensions!270
No description provided.