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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis
added a commit
that referenced
this pull request
Oct 9, 2026
Keep #1168's clean-close check and guarded parent check in the worker, with the killed-build record queued and the queue closed behind it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 9, 2026
…ock-startup # Conflicts: # packages/microcosm-build/src/microcosm/build/telemetry_emitter_service/collector.py
MaxGhenis
added a commit
that referenced
this pull request
Oct 9, 2026
main's #1147 and #1168 both changed the collector's denied-credential warning: keep #1168's never-raising write_warning with #1147's per-reason message. #1147 also adds a blocked run ending, which is a run event and so uses the queue's reserved room; say so where the reserve is described. Give the remaining Hypothesis properties no per-example deadline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
MaxGhenis
added a commit
that referenced
this pull request
Oct 10, 2026
#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>
MaxGhenis
added a commit
that referenced
this pull request
Oct 10, 2026
#1168 now re-checks the retry deadline after each backoff, in case the sleep overran. Read the deadline through the callable there too: the automatic merge compared the clock against the function itself. Its worker also prunes once more before draining. Model a wait for this process's own spool lock in the drain property, state the retry property for a deadline read around every wait, and keep the drain tests to the service's own stderr lines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
Problem
Every concurrent build on a host shares one SQLite spool for local telemetry (
~/.cache/microcosm/telemetry/events.sqlite3, busy timeout 5 s). Before the emitter service binds its socket,main()opens the spool (Alembicupgrade head, thenprune) andEmitterService.run()registers the producer, which is a write.Measured on origin/main 7f23594 (CPython 3.14.7 free-threaded, SQLite 3.53.1), with a second connection holding
BEGIN IMMEDIATE, as another build's service does while it writes:register(new or existing producer),appendOperationalError: database is locked(SQLITE_BUSY) after 5.2 sEventSpool(...)on a spool with rows older than 7 days (its prune deletes)EventSpool(...)on a new spool (migration DDL)pending_runs,has_deliverable, a prune with nothing to deletemain()did not catch the error, so the service exited, the build ran with no hosted telemetry, and a SQLAlchemy traceback, including the SQL and its parameters, landed on the build's stderr.Concurrent first opens raced. Eight services opening a new spool at the same instant failed about half the time:
alembic_version.engine.begin()plusalembic upgradewas neither atomic nor serialized. A statement trace shows the four CREATEs autocommitted.The delivery worker died on any exception. Heartbeat appends, prune-on-append and
flush_once(for exampleint(None)from a collectorexpires_in: null) were unguarded. The thread died with a traceback on the build's stderr. Only the worker notices a dead build, so a build later SIGKILLed left a service serving forever, in its own session (start_new_session=True).Change
--ready-deadline: the Unix time at whichstart()gives up (datetime.now(UTC), the module's existing wall clock).main.open_registered_spoolopens the spool and registers the producer in one retry loop (contention.py). The service waits for another process's lock until 0.5 s before the build stops waiting.PRAGMA busy_timeoutapplied at connection checkout enforces it.--ready-deadline, startup makes one attempt with the normal 5 s wait, exactly the old behaviour. With one, it waits at least as long as the old single 5 s wait could have before the build gave up, minus the 0.5 s margin. For example, with the 3 s budget on main today, a lock held for 1.5 s is waited out, and a test pins this.EventSpool.prune_if_due, once per 60 s, with a failed attempt counting toward the interval.prunedeletes in batches of 500 rows, each its own short transaction, and pauses 25 ms between batches outside the process lock.BEGIN IMMEDIATEtransaction (SQLAlchemy's documented pysqlite recipe for transactional DDL), on a short-lived engine. A second migrator waits, then finds the spool at head, and Alembic does nothing.prune_if_due,flush_once, and the drain.CollectorDelivery.flush_oncebacks off like a collector failure after an unexpected error, so a bug cannot become one request per second. A locked spool is retried on the next tick._servestops if the worker thread ever dies.--ready-deadline=<value>as one token, so a past deadline parses. SIGINT ends the service at once, as SIGTERM does. Every service warning goes throughdiagnostics.write_warning, which never raises. A closed stderr pipe — a build run throughteeand interrupted — no longer kills the worker.describe_errorprints the driver's one-line message, not SQLAlchemy's statement and parameters.Evaluated and not done
Degrade to local-only instead of exiting.
local_onlyis a per-run row state in the same spool that is locked, so it cannot be recorded. The client already continues the build with a disabled handle when the service exits.Becoming ready before the spool is usable (bind first, buffer events in memory, or register lazily on the first append). This would remove SQLite from readiness entirely. But a persistent lock would then stall the build's event sends:
_handleappends synchronously in the accept thread, waiting up to 5 s per event, while the client times out after 0.2 s per send. Exiting cleanly costs the build the startup budget once instead. Doing this properly means decoupling the event path from spool writes; that is a separate change (follow-up below).An index on
created_atto speed the prune's scans. It needs a new migration, and an older checkout sharing the spool would then refuse it (follow-up below).Invariants (each has a test in
test_telemetry_spool_contention.py)EventSpoolopens in well under one busy wait while the lock is held.alembic check.prune_if_dueprunes on the first call, at once after an unfinished prune, and otherwise iff 60 s have passed since the previous attempt, failed or not.sum(query; one over it is summed and trimmed.Mutation check. Hand-written mutants, each removing one guarantee above, applied in a separate worktree and run against their targeted tests. A kill counts only when a test fails on an assertion or the expected exception; each failing test id and its
Eline are recorded.~/reviews/microcosm-telemetry-spool-lock-2026-10-09/mutants/(SUMMARY.md,run_mutants_v7.py,results_v7.json).Known limits
created_at, which needs a migration (see "Evaluated and not done").(created_at, sequence)ties the order is arbitrary in both versions. For payloads whose stored length differs from their re-serialised length, this version counts the stored length, which is what the cap total sums.Reviews
check/in the evidence folder.review-hard-r1.md). Each is addressed:Merge order with #1166 and #1165
This branch is merged with origin/main as of a224dc1. One conflict in
collector.py, with main's settled-rejection warning, is resolved to go throughwrite_warning. With #1166 merged as well, the client waits up to 30 s instead of 3 s, so startup can wait out locks of up to about 29 s; with main's 3 s budget alone, up to about 2.5 s.test_telemetry_emitter.py,test_telemetry_emitter_startup.pyandtest_telemetry_emitter_service_imports.py: all passed. That includestest_subprocess_exchanges_ambient_token_and_delivers_events, which times out on origin/main under load. Keep the telemetry emitter service off the modelling stack #1166 fixes that.datetime.now(UTC)because Keep the telemetry emitter service off the modelling stack #1166's startup tests replace the module'stimewith a fake that has notime().Trade-off to know: with #1166's 30 s budget, a spool that stays locked costs the build up to about 29.5 s at start before it continues without telemetry. With main's current 3 s budget, it costs about 3 s today (the build kills the service at its deadline) and about 2.5 s with this PR, where the service stops 0.5 s early to say why.
Tests run (touched files only,
-p no:cacheprovider --basetemp=<scratch>)test_telemetry_spool_contention.py(new) andtest_telemetry_emitter.py: all passed on this branch. I deselected onlytest_subprocess_exchanges_ambient_token_and_delivers_events; it failed here exactly as on origin/main, from the 3 s budget at load average 90–130, and passes with #1166 merged (above).Follow-ups (not in this PR)
created_at.axiom: n/a: telemetry infrastructure, no policy encoding
🤖 Generated with Claude Code