fix(datastores): SHA-256 fallback in fileNeedsPush when mtime matches (#1307) #132
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/1307-mtime-sha256-fallback"
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
fileNeedsPushin both S3 and GCS datastores returnedfalse(skip push) on the mtime-match fast path without checking the SHA-256 hash. On filesystems with coarse mtime granularity (e.g. Linux tmpfs at 1s resolution), rapid same-size writes within the same second are invisible to the stat-only fast path, silently skipping the push.sha256hash exists in the index entry, compute the local file's SHA-256 and compare before returningfalse. Legacy index entries withoutsha256preserve the old fast-path behavior.@swamp/s3-datastoreand@swamp/gcs-datastore.Changed files
datastore/s3/extensions/datastores/_lib/s3_cache_sync.ts— hash check in mtime-match branch + updated doc commentdatastore/gcs/extensions/datastores/_lib/gcs_cache_sync.ts— identical hash check in mtime-match branchdatastore/s3/extensions/datastores/_lib/s3_cache_sync_test.ts— 2 new tests (detects change / skips unchanged when mtime matches)datastore/gcs/extensions/datastores/_lib/gcs_cache_sync_test.ts— 2 new tests (same pattern)Performance note
The mtime-match branch was previously a pure stat-only fast path (zero file I/O). Now, files with a matching mtime+size that have a
sha256in the index get areadFile+ SHA-256 computation. This only affects the slow path (aftermarkDirty), not thetryFastPushChangedshortcut. The tradeoff is correctness over speed — silent data loss on coarse-grained filesystems is worse than extra reads.Test plan
deno checkpasses for both extensionsCloses swamp-club#1307
🤖 Generated with Claude Code
Adversarial Review
Medium
Performance regression on push walk hot path —
s3_cache_sync.ts:2775/gcs_cache_sync.ts:2644Before this PR, the "same size + same mtime" branch in
fileNeedsPushwas a stat-only fast path (return false— O(1) per file). After this PR, whenexisting.sha256is present (which it is for every file that was ever pushed viapushFile, sincepushFilealways computes and stores sha256), the code reads the entire file and computes SHA-256 — upgrading the fast path to O(file_size) per file.Breaking example: A production datastore with 4,000 previously-pushed files calls
pushChanged()when nothing has changed. Before: ~4,000stat()calls (~50ms). After: ~4,000Deno.readFile()+crypto.subtle.digest()calls. If files average 50 KB, that's ~200 MB of reads + hashing per sync — significant on network-attached or containerized filesystems. Fordata/*/rawcontent files (potentially MB-sized), the cost is worse.The sha256 propagates through the remote index to all clients (pull downloads the index containing sha256 from prior pushes), so every client pays this cost, not just the original pusher.
This is a correctness/performance trade-off, not a bug — the fix addresses a real data-loss scenario (coarse mtime on tmpfs). But the performance impact on the common "no changes" path should be measured against production workloads before shipping, since the DEF-2 tracing that motivated the fast-path sidecar identified this exact walk as the bottleneck.
Suggested mitigation: Consider gating the SHA-256 fallback behind a condition that only fires when the sidecar indicates actual dirty state (e.g.,
bulkInvalidated || dirtyPaths.size > 0), rather than unconditionally for every file on every walk. Unchanged files on a non-dirty walk can safely trust mtime.Double read+hash for files that need pushing —
s3_cache_sync.ts:2776-2781/gcs_cache_sync.ts:2645-2650When
fileNeedsPushreturnstruebecause the SHA-256 doesn't match, the file is subsequently pushed viapushFile, which reads the same file again (Deno.readFile) and recomputes SHA-256 (crypto.subtle.digest). The file content is read and hashed twice for every file that actually changed.Breaking example: A 100 MB raw content file with a coarse-mtime change:
fileNeedsPushreads 100 MB + hashes → returns true →pushFilereads 100 MB + hashes again → uploads. That's 200 MB of reads and two SHA-256 passes over the same data.This isn't a correctness issue, but it's avoidable waste.
fileNeedsPushcould cache the computed hash or the read data for reuse bypushFile.Low
Asymmetric doc comment update —
s3_cache_sync.ts:2750-2756vsgcs_cache_sync.ts:2626The S3 version updates the JSDoc for
fileNeedsPushto document the new 4-branch detection logic. The GCS version has no JSDoc onfileNeedsPushat all. While neither file is wrong, the asymmetry between two files that are otherwise kept in lockstep could confuse future maintainers.Verdict
PASS — The core logic change is correct: when the index carries a SHA-256 hash and mtime matches, comparing the hash catches same-size writes that coarse mtime granularity would otherwise miss. Both the "hash mismatch → push" and "hash match → skip" paths are tested. The code is consistent between S3 and GCS implementations. The performance trade-off (stat-only fast path → full file read + hash for every previously-pushed file on every push walk) is worth measuring against production workloads, but is not a correctness or safety blocker.
Code Review
Blocking Issues
None.
Suggestions
gcs_cache_sync.ts: Orphaned JSDoc block (lines 69–103)The large JSDoc block starting at line 69 ("Returns true for files that live inside the cache directory but must NOT cross the sync boundary…") describes
isInternalCacheFilesemantics. In the S3 sibling file that comment sits directly aboveisInternalCacheFile. In the GCS file,isInsideNamespaceDirwas moved aboveisInternalCacheFile, but its comment (lines 104–110) was correctly placed — while the bigisInternalCacheFilecomment was left orphaned between the two functions. The result is thatisInternalCacheFile(line 148) has no JSDoc, and the long comment appears to documentisInsideNamespaceDirinstead. Not a runtime issue, but could confuse future readers. Consider moving the large block to sit immediately aboveisInternalCacheFile.gcs_cache_sync.ts/s3_cache_sync.ts:preparePushdirtyPartitionKeysdeduplication uses linear scanIn
preparePush(both files), dirty partition keys are deduplicated with!dirtyPartitionKeys.includes(key)against a plain array (O(n)per insert).pushChangedin the same files uses aSet<string>for this, which isO(1). On large deploys with thousands of files, the array approach could be measurably slower. Low priority, but worth aligning for consistency.