fix: retry attachment uploads and heal content missing from storage - #129
Closed
petergaultney wants to merge 1 commit into
Closed
fix: retry attachment uploads and heal content missing from storage#129petergaultney wants to merge 1 commit into
petergaultney wants to merge 1 commit into
Conversation
An attachment syncs in two decoupled halves: the metadata (path + sha256) rides the folder doc over CRDT sync and always converges, while the content is a client PUT to storage. When that PUT died with no HTTP response, the classifier had no code or status to match, fell back to the generic message, and treated the failure as terminal - the background queue only re-drives retryable failures, so the upload was abandoned permanently. The metadata landed anyway, leaving every other client and any git-sync chasing a hash that exists nowhere. We found six such attachments in one vault, the oldest three weeks old and never retried across many restarts. - CAS.writeFile gains the withTransientRetry wrapper readFile already had. - Any non-abort transfer failure with no HTTP response is retryable (s3TransferFailureFromUnknown); DOMException AbortError and abort-like messages stay terminal. - Existing holes heal: each attachment version is HEAD-verified against storage once per machine, bounded by a durable guid -> hash ledger (casVerified), and a machine whose local content IS the missing version re-uploads it. A machine holding a different version surfaces an error instead of clobbering the claim - the bytes may still exist on the author's machine. Successful pushes and pulls record for free. - sweepUnverifiedCasContent enqueues the verification after each syncFileTree. Without it the verify never runs for files at rest: the tree sync noops any file whose local state matches committed metadata, and SyncFile.sync() otherwise only fires on local modify events and in-memory retry queues. - 404 now reads 'Attachment content not found in storage' rather than the generic 'Attachment storage request failed.' enableVerifyUploads is deleted rather than defaulted on. The plugin materializes every flag into data.json on save, so an existing install never consults the schema default - the flag reads false on every machine that has saved settings, and a fix gated on it does nothing for them. The verify path it guarded also called push() without force, whose hash-unchanged guard skipped the upload, so the feature was a no-op for exactly the author-machine case it existed for; that is now push(true).
Contributor
Author
|
🍋: Closing to reopen from the TrilliantHealth org fork instead of a personal one; the branch and commit are identical. New PR linked shortly. |
Contributor
Author
|
🍋: Reopened as #131. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🍋:
An attachment whose upload PUT dies without an HTTP response is abandoned permanently, while its
metadata syncs to everyone — so every client and any git-sync ends up chasing a content hash that
exists nowhere in storage. We found six of these in one vault; the oldest had been broken for three
weeks and had never been retried across many restarts.
Why it is permanent
An attachment syncs in two decoupled halves. The metadata (path + sha256) rides the folder doc over
CRDT sync and always converges. The content is a client PUT to storage. When that PUT dies with no
response at all (network drop, CORS, connection reset),
S3Errorhas no code or status to match, soit falls back to the generic "Attachment storage request failed." and the failure is classified
terminal. The background queue only re-drives retryable failures, so nothing ever tries again —
not on the next sync, not after a restart. The metadata is already published, so the reference is
fleet-wide and permanent.
Changes
CAS.writeFilegains thewithTransientRetrywrapperreadFilealready had.(
s3TransferFailureFromUnknown).DOMException AbortErrorand abort-like messages stay terminal.per machine, bounded by a durable
guid -> hashledger (casVerified); successful pushes andpulls record for free. A machine whose local content is the missing version re-uploads it; a
machine holding a different version surfaces an error instead of clobbering the claim, since the
real bytes may still exist on the author's machine.
sweepUnverifiedCasContentenqueues that verification after eachsyncFileTree. Without it theverify never runs for files at rest: the tree sync noops any file whose local state matches
committed metadata, and
SyncFile.sync()otherwise only fires on local modify events andin-memory retry queues.
On deleting
enableVerifyUploadsWorth a maintainer's judgement, so stating the reasoning rather than burying it. The flag is deleted
rather than defaulted on, because a flag default cannot ship a fix to existing installs: the plugin
materializes every flag into
data.jsonon save, so an install that has ever saved settings neverconsults the schema default. The flag reads
falseon every such machine (it defaults falseupstream), and gating the heal on it makes the fix inert for exactly the people who need it. We
learned this the hard way — our first build gated on a default-true flag and did nothing.
Separately, the path that flag guarded called
push()withoutforce, whose hash-unchanged guardskipped the upload. So the verify feature was a no-op precisely for the author-machine case it
exists for. That is now
push(true).Verification
Manufactured a hole against real storage (uploaded an attachment, deleted the object, left the
metadata), then reloaded: the sweep found and re-uploaded it within ~30s with no user action. Then
ran it for real — the two authoring machines of the six broken attachments upgraded, and all six
missing versions reappeared in storage within minutes; every client can download them again.
11 tests in
tests-th/. They pass on our fork but cannot run in a clean upstream checkout:jest loads
__tests__/mocks/globalStubs.ts, which is git-crypt ciphertext without the key(
TS1490: File appears to be binary), so every suite fails to start regardless of content. That isalso why they are not under
__tests__/— a file there would be unreadable in the diff. Please movethem wherever you prefer.
Running as
0.8.9-th.5/th.6on our internal fork across ~35 machines.