fix(atomicmarket): bound the stats-market recompute so a backlog drains - #210
Merged
Conversation
update_atomicmarket_stats_market() claimed every due row of its queue and resolved all four listing types in one statement, on the runtime pool. That pool sets a 30s statement_timeout, and under PgBouncer transaction pooling a connection-level setting is the only one that applies, so a backlog larger than 30s of work was cancelled with 57014. The cancellation rolled back the statement's own DELETE-claim, the next tick re-read the same backlog, and the queue grew instead of draining. Nothing recovers from that state on its own: atomicmarket_stats_markets stops advancing and the operator sees the same cancellation every two minutes. A reader that falls behind is enough to build the backlog, because the reader-priority gate skips the job entirely while it catches up, and the queue had no dedup, so a hot listing added a row per write. The recompute now claims a bounded batch, resolves it, and releases the claimed rows guarded on a captured sequence token, which is the protocol the sales-filter and template-prices drains already use. The resolution carries the 1.3.23 CTE chain unchanged, so a written stats row is byte-identical to what the full recompute produced for the same state; an integration test asserts that against the reference extracted from its own migration. Bounding is what makes a timeout ceiling meaningful, and it also lets the drain yield to the reader between batches rather than after a run it cannot interrupt. Migration 2.0.10 recreates the queue with the dedup key, the token and absolute autovacuum thresholds, which also compacts whatever backlog it holds. Rebuilding rather than altering is what deduplicates a table that has no key to join on. The rebuild takes its exclusive lock before the copy rather than at the drop, so an enqueue committed between the two cannot be dropped with the old heap; a filler still writing the queue therefore fails the version on its lock timeout instead, and it retries on the next boot. The dedup key carries refresh_at so an auction's end-time row stays distinct from its immediate one. The function returns queue rows released where it returned stats rows written, so a batch of already-current listings still reports progress and the burn-down is not capped at one batch per tick. Nothing read the old value. Signed-off-by: Rob Konsdorf <rob@facings.io>
robrigo
force-pushed
the
fix/stats-market-bounded-drain
branch
3 times, most recently
from
September 10, 2026 20:21
a2b86d4 to
8cd2146
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
The transactional queue rebuild and concurrency-sensitive claim/release protocol warrant final human database review.
Pull request overview
Bounds AtomicMarket stats recomputation so queued work can drain without repeatedly timing out.
Changes:
- Adds a deduplicated, sequence-guarded queue migration.
- Moves recomputation to bounded batches on the long-running pool.
- Adds unit, integration, parity, and concurrency coverage.
File summaries
| File | Description |
|---|---|
UPGRADING.md |
Documents migration and deployment requirements. |
stats-market-drain.test.ts |
Tests batch-loop orchestration. |
stats-market-drain.integration.test.ts |
Tests queue and recomputation behavior. |
index.ts |
Implements and schedules the bounded drain. |
2.0.10/database.sql |
Advances the schema version. |
2.0.10/atomicmarket.sql |
Rebuilds the queue and bounded function. |
CHANGELOG.md |
Adds 2.3.4 release notes. |
Review details
- Files reviewed: 7/7 changed files
- Comments generated: 0
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The rate limiter keyed an IPv6 address ending in a dotted quad into the bucket of the unrelated IPv4 client it appeared to name, and collapsed every IPv4-mapped address written without one into a single range key. The qs lift clears two advisories in the parser every read endpoint's query string passes through. Both are the reason to take these rather than routine currency. The rest stay in the toolchain and reach no image. Signed-off-by: Rob Konsdorf <rob@facings.io>
robrigo
force-pushed
the
fix/stats-market-bounded-drain
branch
from
September 10, 2026 20:44
8cd2146 to
8beef89
Compare
Merged
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.
update_atomicmarket_stats_market()claimed every due row ofatomicmarket_stats_markets_updatesand resolved sales, auctions, buyoffers and template buyoffers in one statement, called every two minutes on the runtime pool. That pool sets a 30 secondstatement_timeout, and under PgBouncer transaction pooling a connection-level setting is the only one that applies, so a backlog larger than 30 seconds of work is cancelled with 57014. The cancellation rolls back the statement's own DELETE-claim, so the next tick reads the same backlog and fails the same way. The queue grows,atomicmarket_stats_marketsstops advancing, and nothing recovers on its own. A reader that falls behind is enough to build the backlog, because the reader-priority gate skips the job entirely while it catches up, and the queue had no dedup key, so a hot listing added one row per write.The recompute now claims a bounded batch, resolves it, and releases the claimed rows guarded on a captured sequence token. That is the protocol
update_atomicmarket_sales_filtershas used since 1.7.11 andupdate_atomicmarket_template_pricessince 2.0.6, and this queue is the sibling 2.0.6's header names as the one still lacking it. The job moves to the max-1 long-running pool, behind the same reader-priority gate and EXISTS probe as the other two, and yields between batches rather than after a run it cannot interrupt.The resolution carries the 1.3.23 CTE chain unchanged, with the claimed set in place of the DELETE-claim it replaces. Parity is the contract and the integration test enforces it: the reference is extracted from
definitions/migrations/1.3.23/atomicmarket.sqlat run time and created under a second name, so a drift in either implementation fails the comparison rather than passing silently.Migration 2.0.10 recreates the queue with the dedup key, the sequence token and absolute autovacuum thresholds, which also deduplicates and compacts whatever backlog it holds. The dedup key carries
refresh_at, which is what keeps an auction's end-time row distinct from its immediate one so the auction still resolves when its end time passes. The rebuild takes itsACCESS EXCLUSIVElock before the copy reads a row rather than letting theDROPacquire it: a copy running underACCESS SHAREreads its own snapshot, and an enqueue another backend commits between that snapshot and the drop would be lost with the old heap, which is a listing whose stats row never recomputes, with no error. Holding one lock across the copy and the drop closes that window and makes an overlapping filler fail the version on the 5 secondlock_timeoutinstead, retrying on the next boot.The function returns queue rows released where it returned stats rows written, so a batch of already-current listings still reports progress and the burn-down is not capped at one batch per tick. Nothing read the old value.
Risk
The migration does not drain the backlog; it compacts it, and the filler drains it in bounded batches once the reader is near the chain head. The deploy precondition is that the previous filler is stopped first, recorded in the migration header,
UPGRADING.mdand the changelog entry. A rollback needs no schema change: an earlier image calls the function with no argument, which resolves through the parameter default.The lock-order note in the header is load-bearing. The recompute writes
atomicmarket_stats_markets, which fires 2.0.6's template-prices enqueue trigger before the release touches the stats queue. That is the reverse of the orderAtomicMarketHandler.deleteDBtakes the two queues in, and the header records why the two never interleave within a filler.Validation
The test database was rebuilt from scratch and the full suite run on Postgres 14 and 18, both ends of the CI matrix.
d4e53ab5pnpm test:e2e:ci, Postgres 14pnpm test:e2e:ci, Postgres 18The 25 added tests are 13 unit and 12 integration. The parity assertion was mutation-tested: altering one arm of the new recompute fails it, so the comparison bites rather than comparing two empty results. The migration was applied twice more over an already-migrated database to confirm replay, and the lock fix was verified by racing an uncommitted enqueue against the rebuild, which aborts on the lock timeout and leaves the row intact.
pnpm lintandpnpm check-typespass.pnpm knipexits 1 with findings identical to those ond4e53ab5, so that failure is pre-existing and is not a CI gate.