Repository navigation
Conversation
Every concurrent build on a host shares one SQLite spool. When another process held its write lock, a starting service's registration gave up after SQLite's 5 s busy wait, the service exited and its SQLAlchemy traceback landed on the build's stderr, so the build ran with no hosted telemetry. - The build passes its readiness deadline (--ready-deadline, Unix time) and the service retries SQLITE_BUSY/SQLITE_LOCKED during spool open and registration with jittered doubling backoff until 1 s before it. If the spool stays locked it prints one line and exits 75 (EX_TEMPFAIL); any other failure prints one line and exits 1. - Opening a spool at head takes no write lock: retention pruning moved from spool open and every append to the delivery worker (prune_if_due, once per interval, failed attempts included). - Migrations run in one BEGIN IMMEDIATE transaction after a read-only head check, so concurrent first opens no longer interleave DDL (23 of 48 such opens failed with "table already exists" or a duplicate alembic_version row) and a failed migration leaves no partial schema. - The delivery worker skips a failed step for one tick instead of dying: lock contention is silent and retried, other errors print one line per type, a failed heartbeat stays due, and the parent check that records a killed build keeps running. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ntained From the design review of the first commit: - Startup statements wait at most 0.25 s each in SQLite (a busy timeout applied per connection checkout), so the retry loop, not a 5 s busy wait, decides when to give up, and the service exits with its one-line reason before the build stops waiting (margin now 2 s). The wait is capped at 60 s whatever the deadline or the wall clock says; an infinite deadline is accepted and capped, NaN is refused. - main opens and registers the spool (open_registered_spool), then restores the normal 5 s wait; EmitterService.run expects a registered producer. - The client computes the deadline with datetime.now(UTC), the module's existing wall clock, so #1166's fake-clock startup tests keep passing once both land. - prune deletes in batches of 500 rows, each in its own transaction, so the first prune in the worker never holds the write lock (or the lock the event path shares) for long. - An unknown spool revision (a later migration from another checkout) is refused from the read-only check, without taking the write lock. - Warnings go through write_warning, which never raises, so a closed stderr pipe cannot kill the worker; serving stops if the worker ever dies; an unexpected delivery error backs off like a collector failure instead of repeating every tick. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The retry and classifier properties only sometimes generated the cases that tell a correct implementation from a broken one: a wait crossing a deadline 0.04 s away, a second retry, an extended SQLITE_BUSY code. Lock errors and short attempts are now weighted up, busy and locked primaries are drawn with every extension, and the boundary cases are explicit examples. Threaded tests stop their service in a finally block and use daemon threads, so a failing run cannot hang pytest at exit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The worker property reached the drain with failures left in its script only some of the time, so an unguarded drain survived one of three fresh Hypothesis runs. A dedicated property drives the drain directly: it never raises, never runs past its deadline, and reports each error type once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the in-session review: - A build that sent close and exited while a worker step was running was recorded as an unexpected exit after its completion. The worker now checks the build only while it has not been closed (pre-existing). - A parent check that raises is reported and treated as a dead build, so the worker never dies on it. - Invalid arguments print one line (exit 2) instead of a usage block. - The client passes --ready-deadline=<value> as one token, so a past or negative deadline parses. - SIGINT ends the service at once, as SIGTERM does, instead of printing a KeyboardInterrupt traceback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d-only From the in-session review: back-to-back prune batches still kept other processes off the spool for the whole prune, because SQLite's busy handler polls rather than queueing and rarely lands in the gap between two transactions; another service's appends waited up to 5.2 s during a 100k-row prune. And the batched DELETEs took the write lock even when nothing was due, where the old prune only read. - prune pauses 25 ms between batches, outside the process lock, and works at most 1 s per worker tick; an unfinished prune resumes on the next tick instead of waiting the 60 s interval. - prune checks read-only for expired events and expired runs before deleting, so an idle prune takes no write lock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every checkout and worktree on a host opens the same spool. Once a newer checkout applied a migration, every older checkout's service refused the spool and its builds lost hosted telemetry until updated. Spool migrations are now additive only, checked by a test over every packaged revision. Each upgrade records the spool's lineage, so an older checkout whose head is in it uses the spool as it is, without the write lock, and any version delivers the events any other version queued. A checkout whose history diverges from the spool's is still refused. The revision is read again under the migration lock, so an opener that waited while a newer checkout migrated no longer fails in Alembic. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…' into fix/telemetry-spool-shared-versions
…ock-startup # Conflicts: # packages/microcosm-build/src/microcosm/build/telemetry_emitter_service/collector.py
Startup, from the in-session review: with main's 3 s client budget the 2 s margin and 0.25 s SQLite waits made the service give up after about 1 s, where the old single 5 s busy wait rode out a 1.5 s hold. Now every SQLite wait during startup ends by the startup deadline (0.5 s before the build stops waiting) and lasts at most the normal 5 s; opening and registering share one retry loop; and without --ready-deadline startup makes one attempt with the normal wait, as before. From the GPT-6.1 Sol review of bf2ec8e: - A retry no longer starts after its deadline when the backoff sleep overruns. - A build that closes within the worker's first tick still gets one bounded prune step, so short builds cannot skip retention forever. - Expired runs are deleted in batches like events, and the size check reads the file's page counts before it sums every payload, so a spool under the cap is not scanned. - The contention tests wait on observed attempts and thread state instead of fixed sleeps. Tests, from the tests-and-claims lens: the classification check is exhaustive with literal codes, the deadline property states behaviour rather than the formula, the prune differential covers more expired rows than one batch with no size pressure and an exact-fit cap, main() runs under a timeout, the opener test waits for BEGIN IMMEDIATE, and socket directories and service processes are cleaned up. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ing lineage Review found that the additivity check passed changes that break older code: a generated column, a DELETE or UPDATE whose WHERE clause spared the check's seeded rows, and a rebuilt table with another collation. The check now traces the statements a migration runs and refuses any insert, update, delete, drop, trigger or non-ADD ALTER on a table that already existed. Such a table's stored definition may only gain plain column definitions, and new indexes on it must be plain and non-unique. Revisions are applied in the production BEGIN IMMEDIATE transaction, and the seeded rows are those the real spool code stores. A checkout from before lineage was recorded can migrate a spool without recording it, after which older checkouts were refused for good. A checkout that finds the spool at its own head without its lineage now records it under the migration lock, except at a base revision, which every history contains. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#1168 now passes the busy timeout as a callable and gives EventSpool a busy deadline. upgrade_spool_database keeps this branch's script_location and lineage handling on that interface, and the README paragraph follows #1168's wording for a spool that stays locked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
Author
Round-1 reviews and what changedTwo independent GPT-6.1 Sol reviews of the first version ( Design review
Code review
|
This branch has not been deployed
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.
Stacked on #1168, which must merge first. CI only runs for pull requests into
main, so this targetsmain; until #1168 merges, the diff also shows #1168's commits. This PR's own changes ared53bd3b9band3cd53f6d1, plus merges of #1168 andmain(the merges resolve conflicts incollector.py,migrations.py,spool.pyand the README).Problem
Every checkout and worktree on a host opens the same spool,
~/.cache/microcosm/telemetry/events.sqlite3(or under$XDG_CACHE_HOME;telemetry_emitter._cache_dir). Build Macs run several worktrees at different commits. With #1168, a service refuses a spool stamped with a revision missing from its checkout's history, so as soon as a newer checkout applies a second revision, every older checkout's builds run without hosted telemetry until they update. The second revision is already in flight: #1151 adds20261008_02, oneop.create_table("graph_publication_jobs", …)(#1148 adds a revision of the same name).Options evaluated
What decides it is delivery. Every service's delivery worker delivers every pending run in its spool, not just its own (
CollectorDelivery._flush_pending_runsiteratesspool.pending_runs()), and event retention is enforced per file by whichever service prunes it.Per-head files have one real advantage: they also isolate checkouts that predate the change and branches whose migrations diverge. That would decide it if arbitrary historical writers had to be supported. For a queue whose point is delivery, stranding the backlog at every migration is worse, and the next migration is already additive.
Change
upgrade_spool_databasemoves a spool to a new revision, it also records the spool's lineage (every revision in the migrating checkout's history) inspool_lineage, in the sameBEGIN IMMEDIATEtransaction as the Alembic upgrade.alembic_version, andenv.pyleaves it out ofalembic check.classify_spool_revision), from the stamp, the recorded lineage and this checkout's history:IncompatibleSpoolRevisionError. That is still one line from the service and exit 1, as in Keep the telemetry emitter service alive through spool lock contention #1168.BEGIN IMMEDIATEholds the lock, before Alembic runs. In Keep the telemetry emitter service alive through spool lock contention #1168, an opener that found the spool behind and then waited while a newer checkout migrated past it ran Alembic on a revision it did not know:CommandError: Can't locate revision identified by 'future'(theno_relock_checkmutant reproduces exactly that).migrations.pyand applied to every packaged revision after the initial one bytest_every_packaged_revision_after_the_initial_one_is_additive. A revision may create tables. To a table that already existed it may only append ordinary columns that are nullable or defaulted, and add plain non-unique indexes. See "The additivity check" below.EventSpool,upgrade_spool_databaseand the history helpers take an optionalscript_location, so tests can open one spool as checkouts with different histories.Which checkouts this covers
Read from the code of each:
3d9b4678b)alembic upgrade headruns on every open, and fails on the unknown revisionEvery invariant below is about the last row. The additive rule is also what keeps a pre-Alembic writer's statements working on a migrated spool, but nothing here tests that code.
Invariants (each has a test in
test_telemetry_spool_versions.py)I1. Classification follows ancestry (Hypothesis, 300 examples over random revision trees, every head, stamp and lineage variant): at head iff stamp = head; upgrade iff the stamp is empty or a strict ancestor of the head; ahead iff the head is a strict ancestor of the stamp and the recorded lineage holds both; refused otherwise.
I2. Real spools match a model (Hypothesis, 40 random trees of up to three revisions on top of the packaged history, real SQLite and Alembic, up to eight opens). Each open is by any checkout, with either a lineage-recording runner or one that upgrades without recording any, as Keep the telemetry emitter service alive through spool lock contention #1168's does. After every open:
PRAGMA data_version);Recorded
events show every runner and state reached. In one run, 24% of examples included a refusal of a lineage-recording checkout and 20% a lineage repair.I3. One line of history never refuses (Hypothesis, 12 chains): lineage-recording checkouts whose heads lie on one chain, opening in any order, are never refused, and the spool ends at the newest head any of them brought.
I4. An older checkout uses a newer spool without the lock. With another writer holding the lock, it opens well inside one busy wait, then registers, appends, batches, acknowledges and prunes. The stamp stays the newer one.
I5. A version difference strands no event.
startedtocompleted, in order, and the run the newer history queued.These vary the migration history under one version of the application code. What older services need from newer rows is written down in
migrations.pyrather than tested:upload_state == 'pending',run_idandproducer_idin the registration,event_idin each event.I6. A live older service across a newer checkout's migration. Its open connection's statements keep working after the schema change. An append that arrives while the migration holds the lock waits and then succeeds.
I7. Divergence is refused without the lock. Two branches each add a different revision to the same parent: the second is refused inside one busy wait and changes nothing, and the common ancestor still uses the spool. A spool stamped with a future revision is used only if its lineage holds both that revision and this head (checked with no lineage, one without the stamp, one without this head). The real service process prints exactly one line naming both revisions and exits 1.
I8. Under the lock (deterministic): an opener that found the spool behind and then waited out a newer checkout's migration returns
ahead, leaves that checkout's lineage as it was, and all three checkouts can then open the spool.I9. Lineage repair. After a runner without lineage migrates a spool (whether it left a stale lineage or none), an older checkout is refused; once a checkout at the new revision opens it, the lineage is that checkout's history, later opens only read (checked with the lock held elsewhere), and the older checkout works. A spool at the initial revision with no lineage is opened without a write or the lock.
I10. Atomicity. An upgrade that fails after writing the lineage leaves the schema, the stamp and the lineage as they were.
Lineage is not part of the compared schema:
alembic checkpasses on a spool that records one.A one-off run at 10× the examples (3,000 / 400 / 120 / 800) found no counterexample. A second one-off run replaced the packaged history, in the test process, with one that already has a second revision modelled on #1151's: the only tests that differed were the two that compare with a real subprocess or with the ORM models, and the packaged-revision test passed on that
create_tablerevision.The additivity check
It is a guard that holds a migration to a short list of operations, not a proof that older code keeps working. It is stricter than compatibility needs: it refuses some harmless changes, such as replacing an index. For each revision and each parent it builds a spool at the parent, fills the telemetry tables with the rows the real spool code stores (a pending run with events, a local-only run, a run with none), applies the revision in the production
BEGIN IMMEDIATEtransaction while tracing every statement SQLite executes, and requires:ALTER TABLE … ADD COLUMNandCREATE INDEX. AnyINSERT,UPDATE,DELETE,DROP, trigger or otherALTERon it is refused for its kind, whatever rows it would touch. A statement of a kind the check does not recognise is flagged for review.ADD COLUMNdoes). So no rebuild, even an identical one, and no changed collation, constraint or table option.CHECK,UNIQUEor expression.test_the_additivity_check_flags_exactly_the_forbidden_changes(Hypothesis, 80 examples over any combination of 7 allowed and 24 forbidden changes) checks that problems are reported iff a forbidden change is present, and that on every accepted combination this checkout's spool code works with the spool stamped as a future revision. Each forbidden change alone is reported for its own reason, and nine real Alembic revisions, including batch-mode rebuilds and one that runsVACUUM, are rejected through the same path. The forbidden list includes the three changes an earlier version of this check passed, found in review: a generated column that overflows on valid JSON,DELETE … WHERE json_valid(payload_json), and a rebuild that changes a column's collation.What it cannot see: what rows mean, and anything a migration does outside SQL on its own connection.
Mutation check
A pytest plugin swapped one behaviour per run (no tracked file edited). The baseline passed, and all 17 mutants were killed by failing assertions, with no collection or setup errors. Mutant, then the tests that killed it in the final run:
Runner behaviour:
unknown_is_refused(the behaviour before this PR): 11 tests.unknown_is_ahead(no lineage needed): 8, including the diverged branch, all three refusal parameters, I1 and I2.ahead_needs_only_stamp(lineage need not hold this head): the diverged branch,lineage-without-this-head, I1, I2.ahead_needs_only_head(lineage need not hold the stamp):lineage-without-the-stamp, the stale-lineage repair test, I1.known_older_is_at_head(no upgrade): 7 tests.no_lineage: 10 tests.lineage_head_only: 9.incompatible_proceeds: the diverged branch, the three refusal parameters and both repair parameters ("DID NOT RAISE").no_lineage_repair: both repair parameters and I2.repair_at_base_too:test_a_spool_at_the_initial_revision_needs_no_lineagein every run, and I2 in some.no_relock_check: I8,CommandError … Can't locate revision identified by 'future'.relock_always_records_lineage: I8.no_lineage_filter: thealembic checktest.The additivity check's own rules, each switched off in turn:
checker_no_statement_rule: 8 cases, among them the conditional delete and update as Alembic revisions, a replaced index, an identical rebuild and a delete through aWITHclause.checker_no_definition_rule: a column with aCHECK, a rebuild that adds a constraint, the collation rebuild, and the property test.checker_arbitrary_seed_rows(the seed rows the first version used): the conditional update, the conditional delete and the constrained generated column.checker_no_row_comparison: rewritten rows, deleted rows and both conditional cases.After merging #1168's newer head, which changed the busy-timeout interface, the baseline and four mutants were run again on the merged tree
2ef0d13cb(no_relock_check,relock_always_records_lineage,no_lineage_repair,unknown_is_refused), with the same results.Reviews
Two independent GPT-6.1 Sol reviews of the first version, a design review and a code review, both asked for changes; everything from "Lineage repair" down to the statement and definition rules came from them. The findings and what was done about each are in the comment below.
Tests run
Touched files only, with
-p no:cacheprovider --basetemp=<scratch>:test_telemetry_spool_versions.py(new), 57 passed. After the first source change I also ran the migration and spool tests of the untouchedtest_telemetry_spool_contention.pyandtest_telemetry_emitter.py, and after mergingmainthe collector tests; CI runs the rest. Ruff check and format are clean on the changed files.Known limits
20261007_01, and no in-place scheme can fix code already released. So the next spool migration (Publish UK Orrery graphs with the existing local build service #1151/Publish UK execution graphs through a general build emitter service #1148) should merge after this has reached build hosts, on a branch rebased onto it. Itscreate_tablepasses the additivity check as it stands.axiom: n/a: telemetry infrastructure, no policy encoding
🤖 Generated with Claude Code