Problem
upload_files can already report each file's resumable tus URL via on_url, and send_to_sink can already consume one via resume_url. But nothing connects them across runs, so an interrupted transfer always restarts from byte 0. From the docstring (operations/workunit/upload.py:185):
Persisting these lets a later run resume via send_to_sink's resume_url; upload_files itself never resumes, since each run mints fresh resources.
For instrument data that matters: a multi-GB Bruker .d bundle that dies at 90% re-sends everything.
Every consumer that wants resume must currently drop below upload_files to send_to_sink, which means giving up the workunit/resource orchestration that is the reason to use upload_files at all. examples/instrument_folder_uploader.py already demonstrates sidecar state files, but as an example — so each caller (that uploader, bfabric-tus-client, BioBeamer) reimplements the same persistence and the same invalidation rules.
Why this belongs in bfabricpy rather than in callers
Deciding whether to resume needs: is there a saved URL, is it same-origin with the current endpoint (_tus_mover._same_origin), does a HEAD still resolve, what offset came back, has tusd expired the upload. All of that lives inside _tus_mover / send_to_sink. A caller implementing it reaches into internals from outside.
There is also a precedent for bfabricpy persisting client-side state across process restarts: oauth/_token_cache.py (TokenCache, ~/.bfabric/tokens/<hash>.json, 0600).
Note the offset needs no new server-side machinery — uploader.get_offset() is a plain tus HEAD against the upload URL (_tus_mover.py:128). Only the URL has to survive between runs. Equally, this must not be pushed into create-resources: that endpoint has no knowledge of the tus layer, and giving it any would put transport state behind a REST API that should stay ignorant of it.
Suggested shape
An opt-in cache path on upload_files, defaulting to today's behaviour:
upload_files(client, params, resume_cache=Path(...) or None)
- when set, write
(file identity) -> upload_url as on_url fires (including for a file whose transfer then fails — that is the case worth keeping)
- on a later run, look up a saved URL and pass it to
send_to_sink(resume_url=...); the HEAD yields the offset, and a stale URL already raises TransferError and can fall back to a fresh upload
None (default) keeps current semantics exactly, so no existing caller changes behaviour
Two things worth deciding when implementing:
- Key: MD5 is the honest identity (it is already computed, and detects a file rewritten in place) rather than path+size+mtime.
- Invalidation: drop entries whose origin no longer matches
tus_endpoint (_same_origin already refuses to send the bearer token cross-origin), plus a TTL matching tusd's configured upload expiry.
Related: #612 — a resume path that reuses the existing pending resource would also make that failure mode self-correcting, since the orphaned resource becomes the checkpoint rather than a blocker.
Problem
upload_filescan already report each file's resumable tus URL viaon_url, andsend_to_sinkcan already consume one viaresume_url. But nothing connects them across runs, so an interrupted transfer always restarts from byte 0. From the docstring (operations/workunit/upload.py:185):For instrument data that matters: a multi-GB Bruker
.dbundle that dies at 90% re-sends everything.Every consumer that wants resume must currently drop below
upload_filestosend_to_sink, which means giving up the workunit/resource orchestration that is the reason to useupload_filesat all.examples/instrument_folder_uploader.pyalready demonstrates sidecar state files, but as an example — so each caller (that uploader,bfabric-tus-client, BioBeamer) reimplements the same persistence and the same invalidation rules.Why this belongs in bfabricpy rather than in callers
Deciding whether to resume needs: is there a saved URL, is it same-origin with the current endpoint (
_tus_mover._same_origin), does aHEADstill resolve, what offset came back, has tusd expired the upload. All of that lives inside_tus_mover/send_to_sink. A caller implementing it reaches into internals from outside.There is also a precedent for bfabricpy persisting client-side state across process restarts:
oauth/_token_cache.py(TokenCache,~/.bfabric/tokens/<hash>.json, 0600).Note the offset needs no new server-side machinery —
uploader.get_offset()is a plain tusHEADagainst the upload URL (_tus_mover.py:128). Only the URL has to survive between runs. Equally, this must not be pushed intocreate-resources: that endpoint has no knowledge of the tus layer, and giving it any would put transport state behind a REST API that should stay ignorant of it.Suggested shape
An opt-in cache path on
upload_files, defaulting to today's behaviour:(file identity) -> upload_urlason_urlfires (including for a file whose transfer then fails — that is the case worth keeping)send_to_sink(resume_url=...); theHEADyields the offset, and a stale URL already raisesTransferErrorand can fall back to a fresh uploadNone(default) keeps current semantics exactly, so no existing caller changes behaviourTwo things worth deciding when implementing:
tus_endpoint(_same_originalready refuses to send the bearer token cross-origin), plus a TTL matching tusd's configured upload expiry.Related: #612 — a resume path that reuses the existing pending resource would also make that failure mode self-correcting, since the orphaned resource becomes the checkpoint rather than a blocker.