P2-4: Resumable upload via tus #24
Labels
No labels
data-integrity
engine
platform
procurement
remote
scaffold
ui
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: Cordy/cairn-desktop#24
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
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?
Depends on P2-3.
Goal
Upload large files so an interrupted transfer resumes from where it stopped instead of starting over. Cairn already implements the tus protocol server-side.
Files
internal/remote/tus.go,internal/remote/tus_test.goProtocol flow
POSTto the tus endpoint withUpload-LengthandTus-Resumable: 1.0.0→201with aLocationheader identifying the upload.PATCHto that location withContent-Type: application/offset+octet-stream,Upload-Offset: <n>, and the body chunk →204with the newUpload-Offset.HEADthe location → currentUpload-Offset; continue from there.Produces
Note
io.ReaderAt, notio.Reader— resuming requires seeking to an arbitrary offset.Design decisions
PUTbelow ~8 MiB and tus above it. A tus handshake for a 2 KB file is pure overhead. Make the threshold a constant with a comment, not a magic number.409 Conflicton PATCH means the offset is wrong. Re-HEADand resync rather than retrying blindly.Steps
PUTpathgit commit -s -m "feat(remote): resumable uploads via tus"Acceptance criteria
Done
What was built
internal/remote/tus.go: a tus client — PutResumable, chunked PATCH upload, HEAD-and-resync on 409, an UploadStore interface for persisting the upload URL.Client.Putroutes to tus at/above an 8 MiB threshold when the reader is a seekable ReaderAt of known size and the Client has a tus endpoint; otherwise plain PUT.internal/state: a newuploadstable (path + size + mtime + version) backing UploadStore; invalidated on any change.internal/sync/engine.go: put hashes the file, uploads, then re-Stats and records state only if size/mtime/FileID are unchanged, else Skips ("changed while uploading") per P2-R5./davBaseURLs); F1 made a file changed mid-upload record an unknown-hash row instead of no row, so the next pass re-uploads instead of downloading a torn copy over the user's file; F2 added a content-version token so a same-size/same-mtime replacement during a resume starts a fresh upload instead of resuming into the old bytes.Tests
19257f4, linux/arm64, go1.25.5 — green.go vet/gofmt/go mod tidy -diffclean on host, windows/amd64 and linux/arm64. Coverage: remote 91.0%, state 87.0%, sync 93.4%, vfs 88.1%, total 91.5%.Acceptance criteria
TestPutResumableResumesAfterTheConnectionDropsand through the engine inTestEngineUploadsLargeFilesThroughTus; confirmed live (11,534,213 < 20,971,520).TestPutResumableStartsAfreshForAnEditedFile(mtime and size changes) and, after the F2 fix, a same-size/same-mtime content replacement too (TestPutResumableStartsAfreshForReplacedContent)./api/v1/tus/; live, a 5-byte file went by PUT./dav/home/mount) — met and tested against the fake and live.Rulings
-s, Co-Authored-By.scheme://host/api/v1/tus/; Upload-Metadatapathis BaseURL's path with Cairn's/davprefix stripped, plus the engine path.remote.Uploadgains aVersiontoken from an optional reader interface; a mismatch on resume terminates and restarts, closing the same-size/same-mtime replacement gap.Deferred
resumablefalls through to a plain PUT from the end position instead of erroring.uploadURLchecks only scheme/host, not that the path lies under the tus mount, before use by terminate/HEAD/PATCH.uploadsrow and cairnd spool forever when the local file is later deleted, renamed, or conflict-copied.ContentHash ""as unchanged.Implemented and reviewed by Claude (subagent-driven), landed on main after review and green CI.