Skip to content

Submit staged bundles' calibration diagnostics and Orrery inputs to the collector - #27

Open
anth-volk wants to merge 30 commits into
mainfrom
claude/collector-artifact-submission
Open

anth-volk wants to merge 30 commits into
mainfrom
claude/collector-artifact-submission

Conversation

@anth-volk

@anth-volk anth-volk commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #26

Stacked on #24 (stack #25: #21 → #22 → #23 → #24 → this PR). The base is feat/release-writer-releases-json, so the diff shows only this change. It implements the emitter side of the collector's artifact intake: PolicyEngine/microcosm-collector#6 (the contract) and PolicyEngine/microcosm-collector#12 (the endpoints and worker).

Summary

The huggingface module now also sends what a staged dataset bundle names to the collector, after the bundle's Hugging Face commit. Microcosm needs no separate call for it. Off by default.

  • The interface describes the build (CollectorProvenance). A bundle hand-over doesn't say which country or build it is, or whether its data is licensed, and the collector needs all three. StageDatasetBundle therefore takes an optional collector=CollectorProvenance(country_code, build_id, licensed_data, release=None), checked with the collector contract's rules.
    • The message carries it only when given, so existing bundles keep their message and hand-over ID.
    • A bundle without it goes only to Hugging Face, as today.
  • What is submitted (submissions/planning.py, by role only; files are never read or interpreted):
    • A calibration_diagnostics file gives a calibration-diagnostics submission, with any build_manifest, release_manifest and demographics file.
    • orrery_schema plus execution_overlay give an orrery-inputs submission, with any orrery_upstream and orrery_evidence files, mapped to the collector's role names. The interface exports these bundle role names.
    • Dataset files and sidecars are never submitted.
  • After the Hugging Face commit. The provenance names the repo, the commit that staged the bundle and its folder. So the hand-over's collector row in handover_jobs (one row per hand-over and destination, so no new table) waits for its huggingface row (WAITING_FOR_HUGGING_FACE). A bundle Hugging Face refuses is skipped.
  • The steps (submissions/client.py):
    • Exchange the build's Hugging Face token for an artifacts:submit credential. CollectorSession gains an optional scope; telemetry sessions still send {}. A scoped session uses a token only when the answer says "scope": "artifacts:submit".
    • Create the submission, PUT each file the collector still lacks to its signed URL with exactly its headers, including Host (412 means already stored), then complete it. An instruction whose Host differs from its URL's host is refused.
    • Files copied when the bundle was handed over (the Hugging Face module now copies a bundle's small files, because Microcosm rewrites its build manifest right after staging) are submitted as they were then; the other files are re-hashed first.
    • Uploads go only to https://storage.googleapis.com, or to loopback when a development collector is configured.
  • Outcomes (collector#6's contract at 673df3d):
    • pending, retried with the queue's backoff: outages, 408/429/5xx, a 409 at completion, and a 404 submission_not_found at completion (the next attempt creates the submission again).
    • pending for an hour, with no request at all to that collector meanwhile (COLLECTOR_INTAKE_NOT_ENABLED): an exchange answered 200 without the artifacts:submit scope (a collector released before intake) or 404, and any other 404 from a submission path, including one to create. The token is discarded.
    • A 403 without a code (the token's scope does not allow the request) is followed by one fresh exchange and retry; only a second such refusal is final.
    • failed: refusals (including 403 submission_owned_by_another_user), changed bytes, and completion answering rejected or failed (for example stored_object_mismatch), with the rejection's code.
    • skipped: a bundle Hugging Face refused, or whose folder is longer than the collector's 512 characters (COLLECTOR_FOLDER_TOO_LONG).
    • submitted: success, with each submission's ID and collector status, readable through collector_submission_receipt.
  • Off by default. Nothing is submitted unless the module configuration sets collector_submissions: true, which no interface release sends. development_collector_url points at a loopback collector. Switching production builds on is a separate emitter change that flips this default, so this release changes nothing for running builds.
  • A new protocol fixture (huggingface-stage-dataset-bundle-with-collector.json) carries a described bundle. Released fixtures are unchanged.

Not in this PR

  • Releases (publish_release). The same mechanism would submit a release's calibration diagnostics with release provenance, which the calibration-tree worker needs to record each release publication. It is a follow-up, kept out to keep this bounded.
  • The existing Orrery hand-over (hand_over_orrery_inputs, the graph publication queue to microcosm-runs) is unchanged. Its role set has no execution overlay, so the collector gets the Orrery inputs from the bundle instead. O1 (microcosm#1295) already registers graph.execution.json as a build output.
  • The emitter treats acceptance as the end of its job. The collector's worker result stays at GET /v1/submissions/{id}/receipt; the emitter doesn't poll for it.

Commits (red, then green)

  1. Let a bundle hand-over describe its build for the collector: red 11 failed against a stub. The 12th test, that a bundle without provenance keeps its message, already passed. Green 12 passed.
  2. Plan the collector submissions a staged bundle names: red 6 failed; green 6 passed.
  3. Submit a planned submission through the collector's intake: red 23 failed; green 23 passed against a loopback fake collector (tests/support/fake_collector.py).
  4. Submit described bundles to the collector after their Hugging Face commit: red 10 errored (the module took no collector settings). Adding the new protocol fixture then failed the released-digest test (1 failed, 14 passed) until its digest was recorded. Green 10 and 15 passed.

Each commit's own test files were run again after committing.

Testing

  • Every test file this PR adds or changes, together: tests/interface/test_collector_provenance.py, tests/submissions/ and tests/interface/test_protocol_fixtures.py, 66 passed. They use temporary migrated databases, the in-memory Hub, synthetic credentials and loopback servers only.
  • tools/check-quality.sh and tools/check-changelog.sh pass.
  • I didn't run the rest of the suite locally, including the existing tests for the files I changed (core/auth.py, huggingface/service/module.py, requests.py, bundles.py). CI covers them.
  • Cross-check against the real collector code, not committed. A scratch script ran the collector's app from microcosm-collector#12 over loopback, with its bucket and queue replaced by local stand-ins, and drove this client with a bundle naming both kinds. The real endpoints accepted both submissions, and the uploads carried the signed headers. Repeating each submission returned the same ID, with the job enqueued once.

Deployment verification

No workflow, Dockerfile or release script changes. New and reused settings:

Setting Provider Wiring
collector_submissions (module configuration) none; absent means off The interface's HuggingFaceSettings.configuration sends only handover_database_path, so it is off in every build.
development_collector_url (module configuration) none; development and tests only Must be a loopback origin (the existing _development_collector_url check).
Collector origin the existing PRODUCTION_COLLECTOR_URL constant Used only when switched on without a development URL.
HF_TOKEN, HUGGINGFACE_TOKEN or the saved hf auth login token the build machine's environment, as for the existing modules Read by the existing lookup, now public as huggingface_token (#24), and sent only to the collector's token exchange.

Coordination

Restack and collector contract fixes (2026-10-20)

Restacked on the review fixes to #21–#24, then changed to match collector#6 (contract, 673df3d) and collector#12 (intake, 30dbd1b). Each commit red, then green; only the test files this pull request adds or changes were run, with loopback services only.

Commit Change Red (tests first) Green
4598ed7 Use the shared hand-over codes and public credential lookup after the restack #24 now defines the hand-over error codes and file hashing once and renamed the credential lookup; the module did not import at all on the restacked branch. tests/test_boundaries.py, tests/submissions: 2 failed, 10 errors, 38 passed 49 passed (the one remaining failure is the next commit's)
9c5447f Submit the bundle files copied at hand-over to the collector With #23's copying, the collector row still re-hashed every file, so every Microcosm calibration-diagnostics submission (which includes the rewritten build manifest) would fail. Copies are kept until both rows settle. tests/submissions: 1 failed, 39 passed 68 passed with test_bundles.py
b7c7422 Send the described-bundle fixture through a typed call in the tests The CI failure in test_every_fixture_has_a_typed_call_that_produces_it. test_client_messages.py: 1 failed, 28 passed (as in CI) 30 passed
906cd4e Use a submit token only when the exchange grants that scope Item 1: token scope, intake not available, hourly retry. tests/submissions: 21 failed, 23 passed (most from the client's new clock argument) 73 passed with tests/telemetry/test_delivery.py
42b65d2 Create a submission again only for a 404 submission_not_found Item 2. test_client.py: 5 failed, 26 passed tests/submissions: 49 passed
517d1b6 Fail a submission whose completion answers rejected or failed Item 3; the fake answers stored_object_mismatch with 200 rejected, and its status documents carry files_sha256 and job. 3 failed, 50 passed 53 passed
0130888 Exchange again for the submit scope after a 403 without a code Item 4; the fake refuses a token of another scope with 403 and no code. 3 failed, 53 passed 56 passed
62badd7 Report an already-staged bundle only with the commit that added it Item 5: no fallback to the head when last_commit fails or reports nothing. test_bundles.py: 2 failed, 28 passed 86 passed with tests/submissions
af1e35d Skip a collector submission whose folder is longer than 512 characters Item 6. test_delivery.py: 1 failed, 12 passed tests/submissions: 57 passed
c3b6082 Refuse an upload instruction whose Host header names another host Item 7; the fake lists Host. Sending Host as given already worked (that test passed before). test_client.py: 1 failed, 39 passed 59 passed
28dcec2 Describe the collector answers and decision D10's disclosure rules Item 8: only the Orrery outputs of licensed builds stay private until cleared; calibration data may be public; native evidence is never published. Documentation only: no red step —

Item 9 (the fake collector: scope, error codes, files_sha256 and job, Host, stored_object_mismatch as 200 rejected) came with items 1–4 and 7. Not done, as asked: polling the submission's status and fetching its receipt.

Re-run after the last commit: every test file this pull request adds or changes, 148 passed; with tests/test_boundaries.py, tests/huggingface and tests/telemetry/test_delivery.py, 167 passed. tools/check-artifacts.sh exit 0; ruff check, ruff format --check and tools/check-changelog.sh pass. The earlier four commits' hashes changed in the restack.

Review fixes (2026-10-20, first review of this pull request)

Each fix is a commit that adds a test failing because of the bug, then a commit with the code. Only the test files this pull request adds or changes were run, with loopback services only.

Commits (red → green) Finding Red (tests first) Green
2ea1dd6 → 46012a2 Submit to the collector on a worker of its own, with time limits tick ran the collector cycle after the Hugging Face cycle on the module's one worker thread. Uploads had no write timeout (httpx.Timeout(60.0, write=None)), and uncopied files were re-hashed on that thread before every attempt. So a slow or stalled submission delayed the Hugging Face commits a build waits for and stopped the commit at shutdown. tests/submissions/test_delivery.py: 5 failed, 13 passed. With a collector upload held: run files never reached the staging repository; in a host runtime with a 2-second shutdown period, LocalServiceError: Service worker failed (TimeoutError); UPLOAD_TIMEOUT.write was None; the upload limit and the cycle budget did not exist. 18 passed; 153 across this pull request's changed test files
1701b3e → 19cac1b Keep collector errors out of the Hugging Face answers and startup Review code-quality items 4, 8 and part of 1: an error queueing the collector row turned the bundle's answer into an error; an unusable collector setting (a non-loopback development URL) stopped the Hugging Face module from starting; an unexpected exception during a submission left the row leased for an hour. test_delivery.py: 3 failed, 18 passed 21 passed

Design (finding 7). This uses the mechanism from #23's fix:

  • Own worker. Collector submissions run on their own DeliveryWorker, so a slow or stalled submission never delays a Hugging Face commit, the commit at shutdown or a read-back. tick runs the collector cycle only when the module runs without workers (tests driven by hand). At shutdown a submission still running at the deadline is abandoned with the process, and the collector's HTTP client and the queue are released only once its worker has stopped.
  • Write timeout. Uploads get httpx.Timeout(60.0): a stalled upload fails within a minute.
  • Upload limit. Each upload also stops after upload_time_limit(size): five minutes, or longer for a file that needs it at 2 Mbit/s. The bytes are streamed in 1 MiB chunks, and the limit is checked before each one.
  • Cycle budget. A cycle starts no new submission after MAX_CYCLE_SECONDS (ten minutes). With the limits on every request and upload, one cycle's length is bounded.
  • Clock in the runtime tests. The two runtime tests use the real clock, as the host does: a submission waiting for its Hugging Face commit is retried seconds later.

Not done now: #29's collector_origin setting (review finding 2). #27 still chooses the collector origin itself in _collector_delivery (the development URL, else the production constant), so once #29 and this pull request are both merged, a configured collector_origin would be ignored for collector submissions. The fix needs #29 merged to main and carried up the stack first, since the resolver doesn't exist in this branch's history. It will be:

  • call configured_collector_origin(self.configuration), as the telemetry and Orrery modules do, and allow loopback uploads only when the resolved origin is the development URL;
  • an origin the resolver refuses switches collector submissions off with a warning, as an unusable setting already does after 19cac1b, instead of stopping the module.

Restack onto #23's and #24's fixes. 9a0dda7 (submit described bundles) changed while it was rebased: the module keeps its collector wiring next to the new delivery workers, and the test fixtures use handover_database_path and background_workers=False. As before, two of this pull request's intermediate commits don't pass on their own (9a0dda7 imports the old private credential lookup until bdf9410; a submission test is fixed by dc66627); the branch head passes.

Re-run after the last commit, every test file this pull request adds or changes: 156 passed; every test file the four stacked pull requests change, at this head: 480 passed. tools/check-artifacts.sh exit 0; ruff check, ruff format --check and tools/check-changelog.sh pass.

Not changed here: the collector row's hour-long lease is not yet renewed during an attempt (an emitter killed mid-upload still blocks that row for up to an hour; unexpected errors no longer do); Retry-After, the collector's error codes in receipts and the 1 MiB response cap (review items 3, 6, 7). Open decisions, unchanged: polling submission status and receipts, the interface role constants for H2c, and AGENTS.md's publishing rule.

Fix round 2 (2026-10-20)

Restack onto #23's and #24's round-2 fixes. Every commit has a new SHA; the titles are unchanged. The sections above use the earlier SHAs: 9a0dda7 is now c4c46c5, bdf9410 is 4c8c360, dc66627 is 51aec26, 2ea1dd6 is e081c07, 46012a2 is f2c94a7, 1701b3e is f901abd and 19cac1b is cdffe80. One change goes beyond replaying the commits: #23's DeliveryWorker now passes its stop check to each cycle, so f2c94a7 builds the collector worker's cycle as lambda _stop_requested: self.collector_delivery.run_cycle(). Without that, the worker would fail on every cycle. The collector cycle uses the stop check from f735a52 on. As before, two intermediate commits don't pass on their own (c4c46c5 imports the old private credential lookup until 4c8c360; a submission test is fixed by 51aec26).

Commits (red → green) Finding Red (tests first) Green
b30ec7e → f735a52 Finish both shutdown commits before waiting briefly for the collector close waited for every worker, the collector's included, before the second shutdown commit. A collector upload still running at shutdown used up the whole period, and the second commit, for the repository a bundle upload had held, was skipped even when that upload finished in time. On Modal those run files are lost. tests/submissions/test_delivery.py, the review's case: 1 failed, 21 passed (AssertionError: the staging run files missed the second shutdown commit), in two runs 24 passed (two tests added with the fix: the bounded collector wait, and a cycle that claims nothing once stop is requested); 159 across this pull request's changed test files
d838a03 → e463794 Stop only stalled uploads, never ones that keep making progress Every upload stopped after upload_time_limit(size), which assumes at least 2.1 Mbit/s. A large file on a slower link (399 MiB at 1.5 Mbit/s) was stopped on every attempt, started again from zero, and was never submitted. tests/submissions/test_client.py: 1 failed, 41 passed (AssertionError: an upload that kept making progress was stopped; the outcome was ('pending', 'UPLOAD_FAILED'), at the second 1 MiB piece), in three runs. The stalled-upload test passes on the old code too: its write timeout already detected a stall. 42 passed; 160 across this pull request's changed test files

Design: shutdown (finding 6). close stops every worker, and the collector cycle now claims no further hand-over once stop is requested. close then:

  1. makes the first shutdown commit;
  2. waits until the deadline for the Hugging Face workers only (bundles and release changes);
  3. makes the second shutdown commit when they stopped in time;
  4. only then waits for a collector submission in progress, for at most COLLECTOR_SHUTDOWN_WAIT_SECONDS (2 s) and never past the deadline.

Collector work therefore never uses up the time the Hugging Face shutdown commits need, and a busy collector worker no longer holds the process for the whole shutdown period. A submission still running is abandoned with the process, as before, and its row is retried once its lease expires. The HTTP client and the queue stay open while any worker still runs.

Design: stall detection instead of a size-based limit (finding 7). A signed upload is one PUT that stores nothing unless it completes, so a limit on its total time only makes a slow upload start again from zero.

  • upload_time_limit, chunks_until, UploadTimeLimitError, MIN_UPLOAD_SECONDS and MIN_UPLOAD_BYTES_PER_SECOND are gone.
  • A stall is detected by UPLOAD_TIMEOUT's write timeout, which httpcore applies to each send to the connection: a send that has had no byte accepted for 60 seconds fails the attempt (pending, UPLOAD_FAILED). Connecting and waiting for the answer keep their 60-second limits. Files are still sent in 1 MiB pieces with an explicit Content-Length.
  • The cycle's ten-minute budget only stops new submissions from starting, as before; its comment now says it never stops one in progress.
  • Tests. Both ends of a loopback connection get small socket buffers (FakeCollector(receive_buffer_bytes=…) and httpx.HTTPTransport(socket_options=…)), so the client's sends wait for the fake to read. The tests patch UPLOAD_TIMEOUT to keep the stall threshold under a second. The slow-upload test also shortens any size-based limit with monkeypatch.setattr(..., raising=False), which has nothing to shorten after the fix.
  • Known gap, unchanged. The collector row's one-hour lease is still not renewed during an attempt, so an attempt that outlasts it (a very large file on a slow link) can be claimed again by another emitter on the same machine. The lease's comment now says so.

Process note (finding 8; no history rewrite). In the first round of fixes, the red commit 2ea1dd6 (now e081c07) fails at a setup step rather than for its stated reason: its two runtime tests used the manual clock, and the green commit 46012a2 (now f2c94a7) also switched them to the real clock. With the manual clock, a collector row claimed before its bundle's Hugging Face commit is rescheduled 2 seconds ahead on a clock that never moves, so one of those tests fails with AssertionError: no upload started instead. The tests as they stand at 46012a2 do fail for the stated reasons against the unfixed code. Those commits are not rewritten. This round's red commits, b30ec7e and d838a03, were each run before their fix and fail for their stated reasons.

Re-run after the last commit, every test file this pull request adds or changes: 160 passed; every test file #23, #24 and this pull request change, at this head: 430 passed. tools/check-artifacts.sh exit 0; ruff check, ruff format --check and tools/check-changelog.sh pass.

Fix round 3 (2026-10-20)

Restack onto #24's round-3 fixes. Every commit has a new SHA, the same title and the same patch (git range-diff shows = for all 23). The sections above use the round-2 SHAs: c4c46c5 is now 8941474, 4c8c360 is 57c0136, 51aec26 is 4451d7f, e081c07 is c64d661, f2c94a7 is 394c9b5, f901abd is 3380529, cdffe80 is 4296daa, b30ec7e is 24062f1, f735a52 is 3f63781, d838a03 is f1e40f6 and e463794 is dc59b69. As before, 8941474 does not pass on its own until 57c0136.

Commits (red → green) Finding Red (tests first) Green
c489ad7 → 8ecd153 Send uploads in 64 KiB pieces so HTTPS stalls are measured per piece Over HTTPS the 60-second write timeout covered each whole 1 MiB piece, because CPython's ssl does no partial writes. A link slower than about 140 kbit/s failed partway and started again from zero. tests/submissions/test_client.py: 1 failed, 42 passed (an HTTPS upload that kept making progress was stopped, outcome ('pending', 'UPLOAD_FAILED')), in three runs. With the threshold raised to 5 s the same test passes on that code, so the failure is the write timeout on the first piece. 43 passed, three runs
99fa0e3 → 6e8d912 Renew the collector row's lease while an attempt runs The row's one-hour lease was never renewed, so a second emitter on the same machine could claim the row and upload the same file in parallel, and the first attempt's result was dropped. tests/submissions/test_delivery.py: 1 failed, 24 passed (a second emitter claimed the collector row while its upload was running), in two runs. A guard (the row is free again within one lease after shutdown) passes before and after. 25 passed, three runs
a634cfb → 423139b Ask for the collector token again just before completing Uploads longer than the token's life completed with an expired token and got 401, so the receipt said COLLECTOR_UNAVAILABLE until the next attempt. test_client.py: 1 failed, 43 passed (the completion after a long upload used an expired token, outcome ('pending', 'COLLECTOR_UNAVAILABLE')) 44 passed, three runs

Before pushing, I amended the unpushed red commit 99fa0e3 to drop a final attempts == 1 check that raced: the row can first be attempted, and recorded as WAITING_FOR_HUGGING_FACE, before the bundle's Hugging Face commit. The amended commit still fails for the stated reason.

Design: 64 KiB pieces (replaces the round-2 wording "a send that has had no byte accepted for 60 seconds" and "files are still sent in 1 MiB pieces"). UPLOAD_CHUNK_BYTES is now 64 KiB, the size httpx itself reads files in. Each piece is one send under the 60-second write timeout. Over HTTPS, where a piece is written whole under one time limit, the slowest link that never fails averages 65,536 bytes per 60 seconds over each piece: 1,093 bytes a second, about 8.7 kbit/s. Over plain HTTP (a loopback development collector) a send still fails only after 60 seconds in which no byte was accepted. Nothing else changes on the wire. The code comment, the README and the changelog fragment state this rule and rate instead of "never stopped, however slow the link".

  • Test. The fake collector gains tls_uploads: a second server for uploads that speaks TLS, as Cloud Storage does, while the collector stays plain HTTP. Both sides use only anonymous TLS 1.2 cipher suites, so no certificate or key is generated or committed. The fake reads uploads at about 1 MiB/s, the threshold is 0.5 s and the file is 1.5 MiB, so a 64 KiB piece takes about 75 ms and a 1 MiB piece more than a second.
  • Follow-up commit 383a27f (Run the HTTPS slow-upload test over a Unix socket). The first CI run at 423139b failed this test on both macOS jobs (Ubuntu passed). Measured locally: a loopback TCP connection on macOS grows its receive buffer to about 280 KiB whatever SO_RCVBUF it is given, so it absorbs several pieces at once and the next piece waits up to about 0.3 s, close to the 0.5 s threshold. The upload server now listens on a Unix socket, which keeps the buffer it is given; the test client sends https:// requests over it (an httpx mount with uds=), and the upload URLs still name https://127.0.0.1. Each piece then takes a steady 70 to 80 ms. With the test as it stands after this commit, setting UPLOAD_CHUNK_BYTES back to 1 MiB makes it fail three times out of three for the stated reason, and 64 KiB passes three times out of three, also under Homebrew's Python 3.14, which the macOS CI jobs use.

Design: a renewed two-minute lease (replaces the round-2 "Known gap, unchanged" bullet). The collector row's lease is now 120 s (SUBMISSION_LEASE_SECONDS), renewed every 30 s (SUBMISSION_LEASE_RENEWAL_SECONDS) while an attempt runs, through the same HeldLeases the Hugging Face jobs use.

  • HeldLeases gains for_job, which holds a job without a repository lease, and an optional stop check: renewal ends at stop, or once shutdown has begun. The Hugging Face leases are built without the stop check, so they behave as before.
  • _attempt now returns an AttemptResult; run_cycle stops the renewal, then records the result with the renewed lease value, so the attempt's result is recorded.
  • Another emitter on the machine never claims the row while an upload runs, however long it takes. An attempt abandoned at shutdown, or an emitter that was killed, frees the row within two minutes instead of an hour.
  • Tests. The lease is shortened to 1 s and the renewal to 0.2 s; a second collector delivery on the same hand-over database, with a client that records each submission and sends nothing, runs its cycle every 0.1 s for two and a half leases while the first emitter's upload is held.

Design: a fresh token before completion. submit asks the session for the token once more after the uploads. The session returns the same token while it is valid and exchanges for a new one only when it is about to expire, so uploads that outlast the token (an hour) complete in the same attempt. The fake collector now issues tokens with a lifetime the test chooses and answers an expired one with 401.

Re-run after the last commit, every test file this pull request adds or changes: 164 passed; every test file #23, #24 and this pull request change, at this head: 446 passed (five runs at 423139b, once more at 383a27f). tools/check-artifacts.sh exit 0; ruff check, ruff format --check and tools/check-changelog.sh pass. No workflow, Dockerfile, Terraform, packaging or release script changed, and no new code reads an environment variable.

🤖 Generated with Claude Code

@anth-volk
anth-volk added this pull request to stack #25 October 19, 2026 21:42
@anth-volk
anth-volk force-pushed the claude/collector-artifact-submission branch from 8bfcbdd to 14c0240 Compare October 20, 2026 13:00
@anth-volk
anth-volk force-pushed the claude/collector-artifact-submission branch 2 times, most recently from 5948c03 to b7c7422 Compare October 20, 2026 13:33
anth-volk added a commit that referenced this pull request Oct 20, 2026
The Hugging Face module ran the collector cycle inside the host's tick,
after the Hugging Face run files, and uploads had no write timeout. A
slow or stalled submission therefore delayed the run-file commits a
build waits for, and stopped the commit at shutdown.

The fake collector can now hold uploads on an event. The tests cover the
#27 review's two cases: with an upload held, run files and a release
publication still land, and in a host runtime with a 2-second shutdown
period the commit at shutdown still runs. Three more ask for a finite
write timeout, a time limit on each upload, and a time budget per
collector cycle.

Red: 5 failed, 13 passed. Run files never reached the staging
repository; the runtime reported "LocalServiceError: Service worker
failed (TimeoutError)"; UPLOAD_TIMEOUT.write was None; upload_time_limit
and MAX_CYCLE_SECONDS did not exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
- Collector submissions run on their own DeliveryWorker, the class the
  Hugging Face module already uses for bundles and release changes.
  tick no longer runs the collector cycle (except when the module runs
  without workers, for tests driven by hand), so a slow or stalled
  submission never delays a Hugging Face commit, the commit at shutdown
  or a read-back. close releases the collector's HTTP client and the
  queue only once that worker has stopped.
- Uploads get a 60-second write timeout, so a stalled upload fails
  within a minute. Each upload also stops after upload_time_limit(size):
  five minutes, or longer for a file that needs it at 2 Mbit/s. The
  bytes are streamed in 1 MiB chunks and the limit is checked before
  each one (UploadTimeLimitError becomes a pending UPLOAD_FAILED).
- A collector cycle starts no new submission after MAX_CYCLE_SECONDS
  (ten minutes); with the limits on every request and upload, one
  cycle's length is bounded.

The two runtime tests use the real clock, as the host does: a submission
waiting for its Hugging Face commit is retried seconds later.

Green: 18 passed; 153 passed across #27's changed test files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@anth-volk
anth-volk force-pushed the claude/collector-artifact-submission branch from 28dcec2 to 19cac1b Compare October 20, 2026 17:47
anth-volk added a commit that referenced this pull request Oct 20, 2026
From the #27 review's code-quality notes:

- an error queueing the collector row, after the Hugging Face row was
  queued, turned the whole bundle answer into an error;
- an unusable collector setting (a non-loopback development URL)
  stopped the Hugging Face module from starting;
- an unexpected exception during a submission left the collector row
  leased for the full hour.

Red: 3 failed, 18 passed: "RuntimeError: database is locked",
"ValueError: development collector URL must use a loopback address",
"RuntimeError: synthetic".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
The Hugging Face module ran the collector cycle inside the host's tick,
after the Hugging Face run files, and uploads had no write timeout. A
slow or stalled submission therefore delayed the run-file commits a
build waits for, and stopped the commit at shutdown.

The fake collector can now hold uploads on an event. The tests cover the
#27 review's two cases: with an upload held, run files and a release
publication still land, and in a host runtime with a 2-second shutdown
period the commit at shutdown still runs. Three more ask for a finite
write timeout, a time limit on each upload, and a time budget per
collector cycle.

Red: 5 failed, 13 passed. Run files never reached the staging
repository; the runtime reported "LocalServiceError: Service worker
failed (TimeoutError)"; UPLOAD_TIMEOUT.write was None; upload_time_limit
and MAX_CYCLE_SECONDS did not exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
- Collector submissions run on their own DeliveryWorker, the class the
  Hugging Face module already uses for bundles and release changes.
  tick no longer runs the collector cycle (except when the module runs
  without workers, for tests driven by hand), so a slow or stalled
  submission never delays a Hugging Face commit, the commit at shutdown
  or a read-back. close releases the collector's HTTP client and the
  queue only once that worker has stopped.
- Uploads get a 60-second write timeout, so a stalled upload fails
  within a minute. Each upload also stops after upload_time_limit(size):
  five minutes, or longer for a file that needs it at 2 Mbit/s. The
  bytes are streamed in 1 MiB chunks and the limit is checked before
  each one (UploadTimeLimitError becomes a pending UPLOAD_FAILED).
- A collector cycle starts no new submission after MAX_CYCLE_SECONDS
  (ten minutes); with the limits on every request and upload, one
  cycle's length is bounded.

The two runtime tests use the real clock, as the host does: a submission
waiting for its Hugging Face commit is retried seconds later.

Green: 18 passed; 153 passed across #27's changed test files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@anth-volk
anth-volk force-pushed the claude/collector-artifact-submission branch from 19cac1b to e463794 Compare October 20, 2026 18:47
anth-volk added a commit that referenced this pull request Oct 20, 2026
From the #27 review's code-quality notes:

- an error queueing the collector row, after the Hugging Face row was
  queued, turned the whole bundle answer into an error;
- an unusable collector setting (a non-loopback development URL)
  stopped the Hugging Face module from starting;
- an unexpected exception during a submission left the collector row
  leased for the full hour.

Red: 3 failed, 18 passed: "RuntimeError: database is locked",
"ValueError: development collector URL must use a loopback address",
"RuntimeError: synthetic".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
The #27 fix review found that `close` waits for every worker, the
collector worker included, before the second shutdown commit. When a
collector upload is still running at shutdown, that wait lasts until the
deadline and the second commit is skipped, so the run files of the
repository a bundle upload held are not committed although that upload
finished in time. On Modal they are lost with the container.

The test follows the review's case: a described bundle's collector upload
is held, a bundle upload to the staging repository is held for half a
second, run files for the staging repository are handed over, and the
module closes with a three-second deadline. The staging run files must
land.

Red: 1 failed, 21 passed (`AssertionError: the staging run files missed
the second shutdown commit`), in two runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
The #27 fix review found that every upload stops after
`upload_time_limit(size)`, which assumes at least 2.1 Mbit/s: a large file
on a slower link (399 MiB at 1.5 Mbit/s, for example) is stopped on every
attempt, starts again from zero, and is never submitted.

The fake collector can now give each connection a small receive buffer
and read an upload's body slowly, and the client tests use a client with
a small send buffer, so the client's sends wait for the fake to read.
Two tests on a 2.5 MiB file:

- the fake reads 64 KiB every 20 ms (about a second in all), each send
  makes progress well within a 1-second stall threshold, and any limit on
  the upload's total time derived from the file's size is set as short as
  the client allows (`monkeypatch.setattr(..., raising=False)`, so the
  test still runs once such a limit is gone): the upload must complete;
- the fake reads nothing: the upload must fail with `UPLOAD_FAILED` after
  the 0.5-second stall threshold. This one passes today, because the
  per-send write timeout already detects a stall.

Red: 1 failed, 41 passed (`AssertionError: an upload that kept making
progress was stopped`; the outcome was `('pending', 'UPLOAD_FAILED')`
after about 0.4 s, at the second 1 MiB piece), in three runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@anth-volk
anth-volk force-pushed the claude/collector-artifact-submission branch from e463794 to 423139b Compare October 20, 2026 20:01
anth-volk added a commit that referenced this pull request Oct 20, 2026
The Hugging Face module ran the collector cycle inside the host's tick,
after the Hugging Face run files, and uploads had no write timeout. A
slow or stalled submission therefore delayed the run-file commits a
build waits for, and stopped the commit at shutdown.

The fake collector can now hold uploads on an event. The tests cover the
#27 review's two cases: with an upload held, run files and a release
publication still land, and in a host runtime with a 2-second shutdown
period the commit at shutdown still runs. Three more ask for a finite
write timeout, a time limit on each upload, and a time budget per
collector cycle.

Red: 5 failed, 13 passed. Run files never reached the staging
repository; the runtime reported "LocalServiceError: Service worker
failed (TimeoutError)"; UPLOAD_TIMEOUT.write was None; upload_time_limit
and MAX_CYCLE_SECONDS did not exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
- Collector submissions run on their own DeliveryWorker, the class the
  Hugging Face module already uses for bundles and release changes.
  tick no longer runs the collector cycle (except when the module runs
  without workers, for tests driven by hand), so a slow or stalled
  submission never delays a Hugging Face commit, the commit at shutdown
  or a read-back. close releases the collector's HTTP client and the
  queue only once that worker has stopped.
- Uploads get a 60-second write timeout, so a stalled upload fails
  within a minute. Each upload also stops after upload_time_limit(size):
  five minutes, or longer for a file that needs it at 2 Mbit/s. The
  bytes are streamed in 1 MiB chunks and the limit is checked before
  each one (UploadTimeLimitError becomes a pending UPLOAD_FAILED).
- A collector cycle starts no new submission after MAX_CYCLE_SECONDS
  (ten minutes); with the limits on every request and upload, one
  cycle's length is bounded.

The two runtime tests use the real clock, as the host does: a submission
waiting for its Hugging Face commit is retried seconds later.

Green: 18 passed; 153 passed across #27's changed test files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
From the #27 review's code-quality notes:

- an error queueing the collector row, after the Hugging Face row was
  queued, turned the whole bundle answer into an error;
- an unusable collector setting (a non-loopback development URL)
  stopped the Hugging Face module from starting;
- an unexpected exception during a submission left the collector row
  leased for the full hour.

Red: 3 failed, 18 passed: "RuntimeError: database is locked",
"ValueError: development collector URL must use a loopback address",
"RuntimeError: synthetic".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
The #27 fix review found that `close` waits for every worker, the
collector worker included, before the second shutdown commit. When a
collector upload is still running at shutdown, that wait lasts until the
deadline and the second commit is skipped, so the run files of the
repository a bundle upload held are not committed although that upload
finished in time. On Modal they are lost with the container.

The test follows the review's case: a described bundle's collector upload
is held, a bundle upload to the staging repository is held for half a
second, run files for the staging repository are handed over, and the
module closes with a three-second deadline. The staging run files must
land.

Red: 1 failed, 21 passed (`AssertionError: the staging run files missed
the second shutdown commit`), in two runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
The #27 fix review found that every upload stops after
`upload_time_limit(size)`, which assumes at least 2.1 Mbit/s: a large file
on a slower link (399 MiB at 1.5 Mbit/s, for example) is stopped on every
attempt, starts again from zero, and is never submitted.

The fake collector can now give each connection a small receive buffer
and read an upload's body slowly, and the client tests use a client with
a small send buffer, so the client's sends wait for the fake to read.
Two tests on a 2.5 MiB file:

- the fake reads 64 KiB every 20 ms (about a second in all), each send
  makes progress well within a 1-second stall threshold, and any limit on
  the upload's total time derived from the file's size is set as short as
  the client allows (`monkeypatch.setattr(..., raising=False)`, so the
  test still runs once such a limit is gone): the upload must complete;
- the fake reads nothing: the upload must fail with `UPLOAD_FAILED` after
  the 0.5-second stall threshold. This one passes today, because the
  per-send write timeout already detects a stall.

Red: 1 failed, 41 passed (`AssertionError: an upload that kept making
progress was stopped`; the outcome was `('pending', 'UPLOAD_FAILED')`
after about 0.4 s, at the second 1 MiB piece), in three runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
The round-2 review of #27 (NEW BUGS 1) found that over HTTPS the upload's
60-second write timeout does not measure progress. CPython's `ssl` does
no partial writes, and its write timeout covers each whole call, so the
timeout covers each whole 1 MiB piece the client sends. A link slower
than about 140 kbit/s therefore fails partway and starts again from
zero, though the upload keeps making progress.

The fake collector gains `tls_uploads`: it signs upload URLs for a
second loopback server that speaks TLS, as Cloud Storage does, while the
collector stays plain HTTP. The server has no certificate: both sides
use only anonymous TLS 1.2 cipher suites, so no key material is
generated or committed. The new test reads uploads at about 1 MiB/s,
sets the stall threshold to 0.5 s and uploads 1.5 MiB: the link keeps
making progress, but a 1 MiB piece takes about a second to write.

Red: 1 failed, 42 passed, the same in three runs. The failure is the
stated reason: `an HTTPS upload that kept making progress was stopped`
(`('pending', 'UPLOAD_FAILED')`). With the threshold raised to 5 s the
same test passes on this code, so the TLS connection works and the
failure is the write timeout on the first piece.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
The round-2 review of #27 (NEW BUGS 2) found that the collector row's
one-hour lease is never renewed during an attempt. Every emitter of a
user on one machine shares the hand-over database by default, so once an
attempt outlasts the lease, a second emitter claims the row and uploads
the same file in parallel, and the first attempt's result is dropped.

The new tests shorten the lease to 1 s (and a renewal interval, which
the code before the fix does not have, to 0.2 s), and run a second
collector delivery on the same hand-over database with a client that
records each submission and sends nothing:

- while the first emitter's upload is held for two and a half leases,
  the second emitter must claim nothing; once released, the first
  emitter's result must be recorded;
- a guard: after shutdown abandons the held upload, the second emitter
  must be able to claim the row within three leases. It passes before
  the fix (nothing renews the lease) and must keep passing after it,
  so renewal has to stop at shutdown.

`described_bundle_with_a_held_upload` now returns the hand-over it
stages, so a test can read its receipt.

Red: 1 failed, 24 passed. The failure is the stated reason: `a second
emitter claimed the collector row while its upload was running`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk and others added 30 commits October 22, 2026 21:45
Add CollectorProvenance to the interface: the country, the build (the
attempt ID), whether the build used licensed microdata, and the release, or
None for a staged build. It is checked with the same rules the collector's
submission contract applies.

StageDatasetBundle takes it as an optional collector field. The message
carries it only when given, so a bundle without it keeps its message and its
hand-over ID. The interface also names the bundle roles of the Orrery inputs
the collector projects from: orrery_schema, execution_overlay,
orrery_upstream and orrery_evidence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A bundle that describes its build for the collector produces one submission
per kind whose required roles it names. calibration_diagnostics gives a
calibration-diagnostics submission, with any build_manifest,
release_manifest and demographics file. orrery_schema and execution_overlay
together give an orrery-inputs submission, with any orrery_upstream and
orrery_evidence files, under the collector's role names. Dataset files and
the bundle's own sidecars are not submitted.

The provenance is the bundle's run, the described build, country and
release, and the Hugging Face repo, the commit that staged the bundle, and
its folder. Planning selects files by role only; it never reads them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CollectorSubmissionClient makes one attempt at the collector's submission
contract: exchange the build's Hugging Face token for an artifacts:submit
credential, create the submission, PUT each file the collector still lacks
to its signed URL with exactly the headers it gave, then complete it.

- Before contacting the collector it checks every preserved file's size and
  SHA-256: a missing file is retried later, changed bytes fail.
- Files go only to https://storage.googleapis.com, or to a loopback address
  when the caller allows it for development; any other upload URL fails.
- A 412 from storage means the file is already there.
- The 404 a collector answers while intake is off, outages, 408, 429 and
  5xx leave the submission pending; a 401 drops the session token first.
  A rejected Hugging Face credential and other 4xx answers are final, and a
  409 at completion retries the whole attempt.

CollectorSession gains an optional scope; without one the exchange body is
still {}, so telemetry credentials are unchanged. Tests run against a
loopback fake collector.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mmit

When collector submissions are switched on, the huggingface module gives a
bundle hand-over that describes its build a second row in the hand-over
queue, for the collector destination; handover_jobs already has a row per
hand-over and destination, so no table changes. That row waits until the
Hugging Face row has staged the bundle, since the provenance names that
commit, then submits each planned submission until the collector accepts or
refuses it. A bundle Hugging Face refuses is skipped. The module's tick runs
collector delivery after Hugging Face delivery, and
collector_submission_receipt reports the result.

Collector submissions are off unless the module configuration sets
collector_submissions to true, which no interface release does, so this
emitter changes nothing for production builds. development_collector_url
points the module at a loopback collector.

The request parser checks the optional collector description with the
interface's rules and refuses a bundle whose description is invalid. A new
protocol fixture carries a described bundle; the released fixtures are
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… restack

The release writer's pull request now defines `UPLOAD_FAILED`,
`PRESERVED_FILES_MISSING` and `PRESERVED_BYTES_CHANGED` once, in
`huggingface.service.hub`, file hashing once, in the queue, and makes the
credential lookup public (`core.auth.huggingface_token`). The collector
submission client and the module's collector credential now use them; the
module no longer imported at all after the rename.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Hugging Face module now copies a bundle's small files into the queue at
hand-over, because Microcosm rewrites its build manifest, checksum listing
and build record right after staging. The collector submission still
re-hashed every file from its path, so a calibration-diagnostics
submission, which includes the build manifest, would fail with
`PRESERVED_BYTES_CHANGED` for every Microcosm build.

The collector row now uses the same copies: copied files are uploaded from
the queue and not re-hashed, and only the files too large to copy are read
from their paths. The collector row is queued with the copies, so they are
kept until both destinations have settled. The test of a bundle Hugging
Face refuses now changes a file too large to copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI failed `test_every_fixture_has_a_typed_call_that_produces_it`: the
`huggingface-stage-dataset-bundle-with-collector` fixture had no typed
call. The message tests now build that bundle with its
`CollectorProvenance` and check that `stage_dataset_bundle` sends exactly
the recorded message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contract: microcosm-collector#6 at 673df3d, "When intake is not
available". A collector released before intake answers the exchange with
200 and a `telemetry:write` token, and its submission paths with 404, so
the hand-over ended `failed` as `COLLECTOR_REFUSED_404`.

- A scoped `CollectorSession` accepts a token only when the answer's
  `scope` is the one it asked for. Any other 200, or a 404 from the
  exchange (`intake_not_enabled`), is `intake_unavailable`. Telemetry
  sessions, which ask for no scope, are unchanged.
- The submission client then discards the token, stays `pending` with
  `COLLECTOR_INTAKE_NOT_ENABLED`, and sends nothing to that collector,
  not even an exchange, for an hour. The collector row is attempted again
  after that hour (`finish_job` takes a `retry_after`).
- The fake collector answers the exchange with `scope`, and can act as a
  collector with intake `off` or `absent`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contract: microcosm-collector#6 at 673df3d, "Collector answers". A 404
means "create again" only with the code `submission_not_found`, and only
for a request about one submission (completion, status, receipt): the
client stays `pending` with `SUBMISSION_NOT_FOUND`, and its next attempt
creates the submission again. Every other 404, including any 404 to
create, means the collector has no intake: the client discards the token
and waits an hour (`COLLECTOR_INTAKE_NOT_ENABLED`). Before, every 404 was a
final `COLLECTOR_REFUSED_404`.

The fake collector answers an unknown submission's completion with
`submission_not_found`, and tests can choose an answer's `code`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contract: microcosm-collector#6 at 673df3d, "Collector answers" and
"Receipt". Completion answers 200 with the final status document, and
with status `rejected` and the code `stored_object_mismatch` when a stored
object has another size or recorded SHA-256. Every 200 was recorded as
`accepted`. A completion whose status is `rejected` or `failed` now ends
`failed`, with the rejection's code as the error code (or
`COLLECTOR_REJECTED` without one) and the collector's status.

The fake collector answers with the contract's status documents, including
`files_sha256` and `job`, and rejects a stored-object mismatch with 200
instead of 409.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contract: microcosm-collector#6 at 673df3d, "Collector answers". A 403
without a `code` means the token's scope does not allow the request. The
client now discards the token, exchanges again for `artifacts:submit` and
repeats the request once; only a second such refusal is final
(`COLLECTOR_REFUSED_403`). A 403 with `submission_owned_by_another_user`
stays final at once.

The fake collector refuses a token of another scope with a 403 without a
code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contract: microcosm-collector#6 at 673df3d: `provenance.huggingface.commit`
is the commit that added the folder, never a later commit. When the Hub
did not report the last commit of an already-staged bundle's inventory
(an error, or no commit in the answer), `stage_bundle` fell back to the
repository head, and the collector submission named that head. It now
stays `pending` with `REPOSITORY_UNAVAILABLE` and asks again at the next
attempt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contract: microcosm-collector#6 at 673df3d: `provenance.huggingface.folder`
holds at most 512 characters, and a longer one is refused with 422, which
is final. A bundle's `<prefix>/<run_id>` can be longer, so its collector
row is now settled as `skipped` with `COLLECTOR_FOLDER_TOO_LONG` before
anything is sent; the Hugging Face upload is unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contract: microcosm-collector#6 at 673df3d, "Signed uploads": each
instruction's headers now include `Host`, signed with the others, and the
client sends every header unchanged. httpx sends a given `Host` as is, so
the uploads already worked; an instruction whose `Host` differs from its
URL's host is now refused as `UPLOAD_URL_REFUSED` before any upload.

The fake collector lists `Host` in its instructions and records the `Host`
each upload arrives with.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Documentation only:

- `CollectorProvenance` and the README: only the Orrery outputs of a build
  that used licensed microdata stay private until cleared; calibration data
  may be public, licensed or not (decision D10, 2026-10-20); native
  evidence is never published. The docstring said everything derived from
  a licensed build stays private.
- README: how the module treats the collector's answers under
  collector#6's contract at 673df3d, the commit and folder rules, and that
  it does not poll status or fetch receipts yet.
- Changelog fragment updated to match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Hugging Face module ran the collector cycle inside the host's tick,
after the Hugging Face run files, and uploads had no write timeout. A
slow or stalled submission therefore delayed the run-file commits a
build waits for, and stopped the commit at shutdown.

The fake collector can now hold uploads on an event. The tests cover the
#27 review's two cases: with an upload held, run files and a release
publication still land, and in a host runtime with a 2-second shutdown
period the commit at shutdown still runs. Three more ask for a finite
write timeout, a time limit on each upload, and a time budget per
collector cycle.

Red: 5 failed, 13 passed. Run files never reached the staging
repository; the runtime reported "LocalServiceError: Service worker
failed (TimeoutError)"; UPLOAD_TIMEOUT.write was None; upload_time_limit
and MAX_CYCLE_SECONDS did not exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Collector submissions run on their own DeliveryWorker, the class the
  Hugging Face module already uses for bundles and release changes.
  tick no longer runs the collector cycle (except when the module runs
  without workers, for tests driven by hand), so a slow or stalled
  submission never delays a Hugging Face commit, the commit at shutdown
  or a read-back. close releases the collector's HTTP client and the
  queue only once that worker has stopped.
- Uploads get a 60-second write timeout, so a stalled upload fails
  within a minute. Each upload also stops after upload_time_limit(size):
  five minutes, or longer for a file that needs it at 2 Mbit/s. The
  bytes are streamed in 1 MiB chunks and the limit is checked before
  each one (UploadTimeLimitError becomes a pending UPLOAD_FAILED).
- A collector cycle starts no new submission after MAX_CYCLE_SECONDS
  (ten minutes); with the limits on every request and upload, one
  cycle's length is bounded.

The two runtime tests use the real clock, as the host does: a submission
waiting for its Hugging Face commit is retried seconds later.

Green: 18 passed; 153 passed across #27's changed test files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the #27 review's code-quality notes:

- an error queueing the collector row, after the Hugging Face row was
  queued, turned the whole bundle answer into an error;
- an unusable collector setting (a non-loopback development URL)
  stopped the Hugging Face module from starting;
- an unexpected exception during a submission left the collector row
  leased for the full hour.

Red: 3 failed, 18 passed: "RuntimeError: database is locked",
"ValueError: development collector URL must use a loopback address",
"RuntimeError: synthetic".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- An error queueing a bundle's collector row is printed once (its type
  only), and the bundle's answer is its Hugging Face receipt, which is
  already queued.
- Collector settings the module cannot use (a non-loopback development
  URL today) switch collector submissions off with one warning; the
  Hugging Face module starts as usual.
- An unexpected exception during a submission is recorded as a pending
  attempt (LOCAL_ERROR) that keeps earlier submissions' results, so the
  row is retried after the usual backoff instead of staying leased for
  an hour.

Green: 21 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The #27 fix review found that `close` waits for every worker, the
collector worker included, before the second shutdown commit. When a
collector upload is still running at shutdown, that wait lasts until the
deadline and the second commit is skipped, so the run files of the
repository a bundle upload held are not committed although that upload
finished in time. On Modal they are lost with the container.

The test follows the review's case: a described bundle's collector upload
is held, a bundle upload to the staging repository is held for half a
second, run files for the staging repository are handed over, and the
module closes with a three-second deadline. The staging run files must
land.

Red: 1 failed, 21 passed (`AssertionError: the staging run files missed
the second shutdown commit`), in two runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`close` now waits for the collector worker separately:

1. it stops every worker; the collector cycle now receives the stop check
   and claims no further hand-over once shutdown begins;
2. it makes the first shutdown commit, waits until the deadline for the
   Hugging Face workers only (bundles and release changes), and makes the
   second shutdown commit when they stopped in time;
3. only then does it wait for a collector submission in progress, for at
   most `COLLECTOR_SHUTDOWN_WAIT_SECONDS` (2 s) and never past the
   deadline.

Collector work therefore never uses up the time the Hugging Face
shutdown commits need, and a busy collector worker no longer holds the
process for the whole shutdown period. A submission still running is
abandoned with the process, as before; its row is retried once its lease
expires. The HTTP client and the queue stay open while any worker still
runs.

Two tests come with the fix: the bounded collector wait, and a collector
cycle that claims nothing once stop is requested. The README's shutdown
sentence for collector submissions now says this.

Green: 24 passed (`tests/submissions/test_delivery.py`); every test file
this branch adds or changes: 159 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The #27 fix review found that every upload stops after
`upload_time_limit(size)`, which assumes at least 2.1 Mbit/s: a large file
on a slower link (399 MiB at 1.5 Mbit/s, for example) is stopped on every
attempt, starts again from zero, and is never submitted.

The fake collector can now give each connection a small receive buffer
and read an upload's body slowly, and the client tests use a client with
a small send buffer, so the client's sends wait for the fake to read.
Two tests on a 2.5 MiB file:

- the fake reads 64 KiB every 20 ms (about a second in all), each send
  makes progress well within a 1-second stall threshold, and any limit on
  the upload's total time derived from the file's size is set as short as
  the client allows (`monkeypatch.setattr(..., raising=False)`, so the
  test still runs once such a limit is gone): the upload must complete;
- the fake reads nothing: the upload must fail with `UPLOAD_FAILED` after
  the 0.5-second stall threshold. This one passes today, because the
  per-send write timeout already detects a stall.

Red: 1 failed, 41 passed (`AssertionError: an upload that kept making
progress was stopped`; the outcome was `('pending', 'UPLOAD_FAILED')`
after about 0.4 s, at the second 1 MiB piece), in three runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A signed upload is one `PUT` that stores nothing unless it completes, so
a limit on its total time only makes a slow upload start again from zero.
The client no longer has one: `upload_time_limit`, `chunks_until`,
`UploadTimeLimitError`, `MIN_UPLOAD_SECONDS` and
`MIN_UPLOAD_BYTES_PER_SECOND` are gone. Stalls are still detected by
`UPLOAD_TIMEOUT`'s write timeout, which httpcore applies to each send to
the connection: a send that has had no byte accepted for 60 seconds fails,
and the attempt stays `pending` with `UPLOAD_FAILED`. Connecting and
waiting for the answer keep their 60-second limits. Files are still sent
in 1 MiB pieces with an explicit `Content-Length`.

The collector cycle's ten-minute budget already only stops new
submissions from starting; its comment now says it never stops one in
progress. The lease comment now says an attempt can outlast its
one-hour lease, which is not renewed during an attempt.

`test_an_upload_stops_once_it_outlasts_its_time_limit` tested the
removed functions and is removed. The README and the changelog fragment
describe stall detection instead of the size-based limit.

Green: 42 passed (`tests/submissions/test_client.py`); every test file
this branch adds or changes: 160 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The round-2 review of #27 (NEW BUGS 1) found that over HTTPS the upload's
60-second write timeout does not measure progress. CPython's `ssl` does
no partial writes, and its write timeout covers each whole call, so the
timeout covers each whole 1 MiB piece the client sends. A link slower
than about 140 kbit/s therefore fails partway and starts again from
zero, though the upload keeps making progress.

The fake collector gains `tls_uploads`: it signs upload URLs for a
second loopback server that speaks TLS, as Cloud Storage does, while the
collector stays plain HTTP. The server has no certificate: both sides
use only anonymous TLS 1.2 cipher suites, so no key material is
generated or committed. The new test reads uploads at about 1 MiB/s,
sets the stall threshold to 0.5 s and uploads 1.5 MiB: the link keeps
making progress, but a 1 MiB piece takes about a second to write.

Red: 1 failed, 42 passed, the same in three runs. The failure is the
stated reason: `an HTTPS upload that kept making progress was stopped`
(`('pending', 'UPLOAD_FAILED')`). With the threshold raised to 5 s the
same test passes on this code, so the TLS connection works and the
failure is the write timeout on the first piece.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`UPLOAD_CHUNK_BYTES` drops from 1 MiB to 64 KiB, the size httpx itself
reads files in. Each piece is one send under the 60-second write
timeout. Over HTTPS, where CPython's `ssl` writes a piece whole under one
timeout, the slowest link that never fails now averages 65,536 bytes per
60 seconds over each piece: 1,093 bytes a second, about 8.7 kbit/s,
instead of about 140 kbit/s. Over plain HTTP nothing changes: a send
still fails only after 60 seconds in which no byte was accepted.

The comment at `UPLOAD_TIMEOUT`, the cycle budget's comment, the README
and the changelog fragment now state that exact rule and rate instead of
"an upload that keeps making progress is never stopped, however slow
the link". One comment in `test_client.py` that named the old 1 MiB
piece size now describes the file size without it; the new test is
unchanged.

Green: 43 passed, the same in three runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The round-2 review of #27 (NEW BUGS 2) found that the collector row's
one-hour lease is never renewed during an attempt. Every emitter of a
user on one machine shares the hand-over database by default, so once an
attempt outlasts the lease, a second emitter claims the row and uploads
the same file in parallel, and the first attempt's result is dropped.

The new tests shorten the lease to 1 s (and a renewal interval, which
the code before the fix does not have, to 0.2 s), and run a second
collector delivery on the same hand-over database with a client that
records each submission and sends nothing:

- while the first emitter's upload is held for two and a half leases,
  the second emitter must claim nothing; once released, the first
  emitter's result must be recorded;
- a guard: after shutdown abandons the held upload, the second emitter
  must be able to claim the row within three leases. It passes before
  the fix (nothing renews the lease) and must keep passing after it,
  so renewal has to stop at shutdown.

`described_bundle_with_a_held_upload` now returns the hand-over it
stages, so a test can read its receipt.

Red: 1 failed, 24 passed. The failure is the stated reason: `a second
emitter claimed the collector row while its upload was running`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A collector row's lease drops from a fixed hour to 120 seconds
(`SUBMISSION_LEASE_SECONDS`) and is renewed every 30 seconds
(`SUBMISSION_LEASE_RENEWAL_SECONDS`) while an attempt runs, through the
same `HeldLeases` mechanism the Hugging Face jobs use. `HeldLeases` gains
`for_job`, which holds a job without a repository lease, and an optional
stop check: renewal ends at `stop` or once shutdown has begun. So:

- another emitter on the machine, which shares the hand-over database,
  never claims the row while an upload runs, however long it takes;
- the attempt finishes with the renewed lease value, so its result is
  recorded instead of dropped;
- an attempt abandoned at shutdown, or an emitter that was killed, frees
  the row within two minutes instead of an hour.

`_attempt` now returns an `AttemptResult`, and `run_cycle` stops the
renewal before it records that result. The Hugging Face leases are
unchanged: they are built without the stop check.

The lease comment, the README and the changelog fragment describe the
renewed two-minute lease instead of the fixed hour.

Green: 25 passed, the same in three runs; `test_leases.py`,
`test_delivery_workers.py` and `test_client.py`: 54 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The round-2 review of #27 (CODE QUALITY 3) found that `submit` fetches
the collector token once, before the uploads, and completes the
submission with it afterwards. An upload longer than the token's life
(an hour) therefore gets 401 at completion and waits for the next
attempt, with a misleading `COLLECTOR_UNAVAILABLE` receipt meanwhile.

The fake collector now issues tokens with a lifetime the test chooses
(`token_lifetime_seconds`, 900 s by default) and answers an expired one
with 401, as the collector does. The new test gives tokens one second,
holds the upload for a second and a half, and expects the submission to
be accepted, with a second exchange for the completion.

Red: 1 failed, 43 passed. The failure is the stated reason: `the
completion after a long upload used an expired token`
(`('pending', 'COLLECTOR_UNAVAILABLE')`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`submit` now calls `_token()` once more after the uploads and before the
completion request. The session hands back the same token while it is
valid and exchanges for a new one only when it is about to expire, so a
submission whose uploads outlast the token (an hour) completes in the
same attempt instead of getting 401 and a `COLLECTOR_UNAVAILABLE`
receipt until the next one. The README says so.

Green: 44 passed, the same in three runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI failed the new HTTPS slow-upload test on both macOS jobs, though it
passed on Ubuntu and locally. Measuring each piece's write showed why: a
loopback TCP connection on macOS grows its receive buffer to about
280 KiB whatever `SO_RCVBUF` the listening socket or the accepted
connection is given. The buffer then absorbs several 64 KiB pieces at
once, and the next piece waits up to about 0.3 s for the window to open,
close to the test's 0.5 s threshold. That is the kernel's buffering, not
the client's piece size, so the test depended on the machine.

The fake's TLS upload server now listens on a Unix socket, which keeps
the buffer it is given. The test client sends `https://` requests over
that socket (an httpx mount with `uds=`), and the upload URLs still name
`https://127.0.0.1`, so the client's checks on upload URLs are the same.
Each 64 KiB piece now takes a steady 70 to 80 ms against the 0.5 s
threshold.

Checked both ways, with the test as it stands here: with
`UPLOAD_CHUNK_BYTES` temporarily set back to 1 MiB, the test fails
three times out of three with `an HTTPS upload that kept making
progress was stopped`; with 64 KiB it passes three times out of three.
It also passes under Homebrew's Python 3.14 (OpenSSL 3.6.5), which the
macOS CI jobs use for 3.14. `test_client.py`: 44 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Submit handed-over calibration diagnostics and Orrery inputs to the collector

1 participant