fix(platform): make BYO storage switches and blob backfill trustworthy - #3239
Merged
Conversation
…YO switch Saving an org's own object-storage connection made every blob the org had stored before that moment unreadable: `resolveOrgObjectStore` is `own ?? default`, every serve/read/delete lane used it, and a blob ref carries no store identity — so presigned GETs, the sandbox stage, TTS audio, knowledge (re)indexing and WebDAV reads all 404ed until the admin discovered and finished the blob backfill, while the docs, the settings UI and the service header all promised mixed references. `resolveOrgObjectStoresForRead` lists the org's store and — only when the org has its own bucket and the default tree names a different one — the deployment default behind it; `locateOrgObjectStore` HEADs them in turn (no round-trip for a single-store org) and `deleteOrgObject` clears every store that may hold a key, so erasure/retention deletes no longer leave a default-store copy behind. Mint lanes keep resolving the org's current store. `s3GetObject` returns the stored Content-Type with the bytes for the backfill's copy. Finding: files-1.
…table The backfill copied every object as application/octet-stream, counted a copy as migrated without checking it landed, never deleted the source (so a residency tenant's history stayed in the shared default store, and later deletes missed that copy), walked only documents + file_metadata (TTS audio and video-link blobs were left behind while the run reported completed), and stamped progress/completion without a status fence — a run the watchdog failed kept copying beside a fresh one and finally flipped its own row back to completed. handleRef now HEADs source and target, GETs the source with its stored Content-Type, PUTs it as-is, HEADs the copy to verify the size (a short copy is deleted and counted failed), and only then deletes the source; a target copy that already matches finishes a cut-off move, one of the wrong size is re-copied. Two more phases walk app.tts_audio_chunks and app.video_link_jobs (migration 0077 widens the phase CHECK; the TTS sample entry carries no name because chunk text is message content). Source and target naming one physical bucket is refused (SAME_STORE). Every stamp and the completion UPDATE are fenced on status = 'running' and RETURNING; nothing matched stops the engine without touching the terminal row, and a stamp is due at least every 60 s inside a batch so the watchdog window measures real liveness. Findings: files-2, files-v1, files-3, files-9.
The data-residency page promised a byte-for-byte round-trip check and row rewrites the engine never did. Say what it does now: the copy lands with its content type, is verified against the source's size, and only then is the source deleted; nothing is rewritten because keys stay identical and reads locate the blob; a re-run finishes cut-off moves; every ref-holding table is walked; the same-store case is refused. English, German and French in the same change. Findings: files-2, files-v1, files-3.
POST /files/upload stores the bytes first and records the upload intent second; the intent row is the only record that the blob exists, and recordUploadIntent also ran the lazy ledger sweep unguarded on the request path. A DB error between the PUT and the INSERT (or inside the sweep) answered 500 with an object in the bucket that no reclaim lane could ever find — a permanent orphan, re-created on every client retry. The route now deletes the blob it just minted (best-effort, via the shared reclaim helper) before rethrowing when the intent write fails, and the sweep inside recordUploadIntent is bookkeeping: it logs and never fails the mint. Finding: files-8.
The third session presign door — POST /files/upload-handoff with its own size rule checked at presign — had no client: the app uploads through /files/upload and /files/blob-upload, the REST door presigns on its own, and the only sender was the backend's own integration check. Every upload-policy change had to be replicated to it for nobody. Delete the route, its schema and createUploadHandoff; the nine integration-check journeys mint through /blob-upload instead (same intent + register semantics). The 0067 migration comment and the service header name the doors that remain. Finding: files-4.
MAX_FILE_SIZE_BYTES (a 64 KB cap no reader of the object-storage config files enforced) and parseObjectStorageSecretsJson (the credentials are read through decryptSecretsFile + inline validation) had no consumer anywhere in the repo; knip cannot flag them because backend/** are entry files. A live-looking guard nothing enforces invites a reader to rely on it. Finding: files-5.
…ssing
chunkCompressedAudio removed its /tmp chunk directory with
`.catch(() => {})` on both the cleanup closure and the error path, so a
directory left behind by ENOTEMPTY/EBUSY accumulated across
transcription jobs with no line to diagnose the eventual disk-full.
Both sites now warn with the directory path, like cleanupTmp beside
them.
Finding: files-6.
The data-residency lane now serves a file stored before the org saved its bucket connection, both before the backfill (from the default store) and after it (from the BYO bucket), asserts the served Content-Type survived the move, and that the source object is gone from the default store once the run completed. Findings: files-1, files-2, files-v1.
0077 is taken by the conversation-messages unique-external-id migration (PR #3225), which pushed first; two files sharing a number would fail the boot of whoever merges second. Renamed before the branch was ever pushed, so no deployment has recorded the old name. Findings: files-3 (the phase CHECK widening).
The lane picked the org's oldest file_metadata row as the blob stored before the BYO switch, but earlier lanes seed rows whose refs never had a blob (stuck.mp3 → s3:stuck-audio-1, created 40 min in the past), so the probe served a ghost and failed before and after the move alike. Mint the pre-switch blob through the app's own door (blob-upload → PUT → register, text/markdown) while the org still resolves to the default store, probe that file, and assert that this exact blob is in the BYO bucket and gone from the default store once the run completed. Findings: files-1, files-2, files-v1 (integration proof).
The blob backfill became a destructive move (PUT, HEAD-verify, DELETE source) guarded by two checks that could not carry that weight: - `sameObjectStore` was a raw string compare of bucket/region/endpoint, so `http://minio:9000/` against `http://minio:9000`, a host spelled in another case, a DNS alias, or a proxy in front of the same bucket passed it; handleRef then found the "target" copy present at the source's size and deleted the only copy of every blob the org owned. - The resume branch took any size-matching target copy as a finished move. Every org that ran the pre-move engine holds exactly such copies, written as application/octet-stream beside the correctly typed source; the first run after this change would have deleted the typed source for each of them and made the untyped copy permanent. Now `sameObjectStore` compares an endpoint identity (URL-parsed, host case-folded, trailing slash dropped; the same bucket with no endpoint is the same AWS bucket whatever region string names it), and runBackfill additionally proves physical identity with `sharesPhysicalStore`: a marker written through the target under a fresh org-namespaced key must stay invisible through the source, deleted again on every path. Dry runs probe too, so a preview cannot report "nothing to move" for a run that would delete everything. `s3HeadObject` returns the stored Content-Type next to the size (`S3ObjectHead`), and handleRef finishes a cut-off move only when size AND type agree (a source with no type compares on size); any other target copy is re-copied over and the source retired after. `locateOrgObject` returns the located store together with that HEAD for readers that need it anyway. Tests: aliased endpoints by config and by probe (dry and real, marker gone, failure UPDATE fenced), the octet-stream re-copy counted migrated, the typeless-source resume, the 60 s liveness stamp inside one batch, and the new helpers. Addresses PR #3239 review: blocking (a) and (b), stampIfDue test.
The abandoned-upload sweep and the replacement-upload cleanup resolved the org's CURRENT store and deleted there. An intent minted before the org connected its own bucket, whose grace window expires after the switch, was "deleted" from the org bucket (S3 answers 204 for a missing key), its ledger row dropped, and the blob stayed orphaned in the deployment default store forever. Both lanes now go through `deleteOrgObject`, which clears every store that may hold the key — the concept the mixed-store read path already uses — and the sweep no longer resolves a store it does not need. Addresses PR #3239 review (non-blocking: upload-intents.ts:246/267, replacement.ts:933-944).
`getWebdavBlobUrl` located the store holding the key (one HEAD per candidate store) and then HEADed the located store again before presigning — two round-trips per WebDAV GET for a BYO org. It now uses `locateOrgObject`, which returns the located store together with that HEAD, and presigns against it. Addresses PR #3239 review (non-blocking: webdav/handlers.ts:914-920).
This was referenced Sep 5, 2026
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.
Summary
Saving a BYO object-storage connection made every blob the org had stored before that moment unreadable, and the blob backfill that was supposed to move them copied without a content type, without verification, without deleting the source, over only two of the four ref-holding tables, and without a status fence. This PR makes the switch and the move trustworthy:
resolveOrgObjectStoresForReadlists own-then-default (only when the default tree names a different physical bucket);locateOrgObjectStore/locateOrgObjectHEAD them in turn (no round-trip for a single-store org; the WebDAV GET reuses that HEAD instead of issuing a second one);deleteOrgObjectclears every store that may hold a key. Every serve/read/delete lane (files, sandbox stage, TTS, project text, knowledge, WebDAV, erasure,blob_access) and every reclaim lane (abandoned upload intents, replacement-upload cleanup) now goes through them; mint lanes keep resolving the org's current store.handleRefHEADs source and target, GETs the source with its storedContent-Type, PUTs it as-is, HEADs the copy to verify the size (a short copy is deleted and counted failed), and only then deletes the source. A target copy finishes a cut-off move only when size AND stored content type agree with the source (a source with no type compares on size); any other target copy — including theapplication/octet-streamcopies the pre-move engine left behind — is re-copied over and the source retired after.sameObjectStorecompares an endpoint identity (URL-parsed, host case-folded, trailing slash dropped; the same bucket with no endpoint is the same AWS bucket whatever region string names it). On top of that, every run — dry runs too — probes physical identity withsharesPhysicalStore: a marker written through the target under a fresh org-namespaced key must stay invisible through the source; the marker is deleted again on every path. Either check failing the run asSAME_STOREbefore any source delete.app.tts_audio_chunksandapp.video_link_jobs; migration 0078 widens the phase CHECK (renumbered from 0077 — re-check at merge time, 0077 belongs to fix(platform): close conversation send, dedupe and scoping gaps #3225).status = 'running'withRETURNING; nothing matched stops the engine without touching the terminal row; a stamp is due at least every 60 s inside a batch so the watchdog measures real liveness.POST /files/uploadreclaims the blob it just minted when the intent write fails; the lazy sweep insiderecordUploadIntentnever fails the mint./files/upload-handoffdoor,createUploadHandoff, and the deadfile_utilsexports are gone; the two empty.catch(() => {})inaudio_preprocess.tsnow warn.Findings fixed
locateOrgObjectStore/locateOrgObject/deleteOrgObject).octet-streamcopy is re-copied, not trusted.SAME_STORE.tts_audio_chunksandvideo_link_jobstoo (migration 0078 widens the phase CHECK).status = 'running'; a fenced engine stops; time-based stamps inside a batch./files/uploadreclaims the minted blob whenrecordUploadIntentfails; the inline sweep is guarded./files/upload-handoff+createUploadHandoff+ schema removed; integration-check journeys mint through/blob-upload.MAX_FILE_SIZE_BYTES+parseObjectStorageSecretsJsonremoved.audio_preprocess.tsare logged.Review round 1 (adversarial) — addressed
sameObjectStorenormalises the endpoint (endpointIdentity), andrunBackfillproves physical identity withsharesPhysicalStorebefore anything else happens (dry runs too, so a preview cannot say "nothing to move" for a run that would delete everything). Tests: same bucket spelled with a trailing slash / other host case (config), a DNS alias the config cannot see (probe, dry and real), the marker gone on every path, the failure UPDATE fenced onstatus = 'running', the seeded source object surviving. Commit84a801555.octet-streamcopy.s3HeadObjectreturns{ size, contentType }(S3ObjectHead);handleReffinishes a cut-off move only when size AND type agree, else re-copies. Tests:application/pdfsource beside a same-sizeapplication/octet-streamtarget → target re-copied asapplication/pdf, source deleted,migrated=1notskipped=1; a typeless source resumes on size alone. Commit84a801555.sweepUploadIntentsandrunReplacementCleanupdelete throughdeleteOrgObject(every store that may hold the key) instead of the mint store (5e74b6f28);getWebdavBlobUrlreuses the HEADlocateOrgObjectalready issued (57cac5339);stampIfDuehas a fake-timer test that advances pastSTAMP_INTERVAL_MSinside one batch; the SAME_STORE tests assert the failure UPDATE'sAND status = 'running'fence.skipped: an honest "pending source delete" number needs a new counter column + contract + UI (a migration for a status-line nuance), and counting it as a 0-byte candidate would make the dry run'scandidatesdisagree with the real run'smigrated. The docs describe the real run as deleting the source after verification, which covers the matching copies too.Skipped
Tests & gates observed (final tree,
57cac5339)bunx tsc --noEmit(services/platform):TSC_OK.bunx oxlint --type-aware(services/platform): exit 0, no findings.bunx vitest --run --project server:Test Files 521 passed (521)/Tests 6140 passed (6140).bun run check(worktree root):Tasks: 38 successful, 40 total— the one red task is@tale/platform#testwith exactly one failure,lib/engine/core/execute/execute.test.ts > subautomation > resolves name@version explicitly(Test Files 1 failed | 528 passed (529)/Tests 1 failed | 73378 passed (73379)); this branch touches nothing underlib/engine, and the file passes 3/3 reruns in isolation (Tests 29 passed (29)). turbo listedFailed: @tale/platform#testas the only failed task (38 successful of 40; the 40th is the task downstream of it).bun run --filter @tale/platform test:uialone (run while the integration proof was also running):Test Files 1 failed | 455 passed (456)/Tests 1 failed | 3511 passed (3512), the one failure aTimed out in waitForinsettings-rail.test.tsx(untouched); rerun of that file alone:Test Files 1 passed (1)/Tests 2 passed (2). The branch's only app-side change is theBackfillPhaseunion inapp/lib/backend/contract/object-storage.ts.bun run knip:check: exit 0 (one pre-existing configuration hint,cron-parserinservices/platform/knip.config.ts, untouched).run-itest.sh, branch contains fix(platform): pass the corpus scope rows as one jsonb parameter #3222 so no cherry-pick): first run (alongsidetest:ui):RUN TRUNCATED at checkTasksCollabIntegrity — 121/134 lanes ranon a Postgres40001serialization failure inaudit_logs lockChainHead(tasks/collab lane,426/428before it). Rerun alone on a quiet machine:[itest] 474/476 checks passed across 134/134 lanes, no truncation. This theme's lane both times:PASS data residency: object storage connection + blob backfill to BYO — … dry=completed/69c/0m, real=completed/39m/208253B, landed=true, sourceRetired=true, preMoved=true, servedBeforeMove=true, servedAfterMove=true, type=text/markdown→text/markdown (want kept). The two reds are outside this theme:webdav re-home(red on main before the campaign) andmessages: concurrent appends each take their own slot — appends=12, failed=1 (want 0)(conversations lane, fix(platform): close conversation send, dedupe and scoping gaps #3225; it PASSED on this same tree in the first run — a concurrency flake, flagged to the coordinator).Notes for the reviewer
0078_object_storage_backfill_phases.sql), assigned by the campaign coordinator; 0077 is fix(platform): close conversation send, dedupe and scoping gaps #3225's (merged). Please re-check the number at merge time.SAME_STORE) when source and target are one physical bucket by config or by probe. The probe writes and deletes one marker object under<prefix>/<org>/<uuid>in the target on every run, dry runs included.webdav re-homeintegration lane is red on main before this campaign (known, not this theme).origin/main(f02b00412, no conflicts);git merge-tree --write-tree origin/main HEADis CLEAN at3997f6a2a.