Optimize polling query plans - #35
Conversation
Install canonical ordered polling indexes and use adapter-aware row locks for outbox claims. Split mixed recovery paths and scope SQL claim transactions to read committed so concurrent MySQL workers do not serialize behind gap locks.
Greptile SummaryThe PR optimizes effect, reminder, and broadcast polling by adding ordered indexes, selecting category heads without locks, and locking only the chosen row under read-committed transactions.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains. Important Files Changed
Sequence DiagramsequenceDiagram
participant W as Claiming worker
participant DB as Database
W->>DB: Probe available/pending head
DB-->>W: Category candidate
W->>DB: Probe stale head
DB-->>W: Recovery candidate
W->>W: Select globally earliest candidate
W->>DB: Lock candidate by primary key (SKIP LOCKED)
alt Candidate locked elsewhere or changed
DB-->>W: No row
W->>W: Exclude candidate ID
W->>DB: Repeat category probes
else Candidate locked
W->>DB: Conditionally update claim ownership
DB-->>W: Claimed work
end
Reviews (2): Last reviewed commit: "fix: avoid locking unused candidates" | Re-trigger Greptile |
Load actor identity after claiming the outbox row. This prevents MySQL from driving the locking query through the instances join, sorting and locking every due reminder before LIMIT can select one.
|
CI follow-up ( A new red query-shape assertion rejected joins in candidate probes. Effect and reminder probes now lock only their indexed outbox table, then load actor identity by primary key after a successful claim. The MySQL reminder race passed eight consecutive focused runs locally, the complete MySQL 8.4 suite, and both GitHub MySQL 8.0/8.4 jobs. The full replacement CI matrix is green. |
Split polling needs both category heads to preserve global order, but locking both can hide one from another claimant. Peek without locks, lock only the selected row, and retry past candidates locked elsewhere.
Summary
solid-objectsfrom 0.14.5 to 0.14.6FOR UPDATE SKIP LOCKEDfor PostgreSQL and MySQL effect, reminder, and broadcast claimsSKIP LOCKEDwith next-key locksThis is the Node equivalent of cardmagic/solid-objects-ruby#60, adapted to the Node implementation rather than copied mechanically.
Production evidence
LEADx Web App on MySQL reported these Solid Objects polling loads around 2026-09-03 06:00 UTC:
(available_at, id)ORquery ordered by(available_at, id)The Ruby tables already had plausible leading polling indexes, and the Ruby fix removed the broadcast
ORplan. The Node schema had no corresponding effect, reminder, or broadcast polling indexes. Node broadcasts already used a separate recovery statement, but that statement was a broadUPDATE; under MySQL's default repeatable-read isolation it took a next-key lock that blocked another claimant beforeSKIP LOCKEDcould help. The reminder cleanup had the same concurrency failure.Generated SQL and plans
PostgreSQL and MySQL generate one ordered locking probe for effects. Reminders and broadcasts use separate ordered, limited, non-locking probes for their available/pending and stale categories, compare those heads, then reselect only the oldest candidate by primary key with
FOR UPDATE SKIP LOCKED. If that row is locked elsewhere, the claimant excludes it and repeats the indexed peeks. SQLite keeps its serialized transaction path and omits the lock clause.Candidate probes contain only the outbox table so MySQL cannot drive them through the
instancesjoin, materialize the queue, and lock extra rows. Actor identity is loaded by primary key after a successful effect or reminder claim.Plans were measured on dedicated databases with 50,000 production-shaped rows. These are isolated
EXPLAIN ANALYZEtimings, not the original shared-production latency. The after timings below are for the ordered category probes; the final candidate lock is a primary-key lookup.The broadcast revision guard needs a separate
(instance_id, state_revision, status)index; the outer delivery-order probe uses(status, available_at_ms, id).The isolated plans show defective Node query/schema shapes before this change. They do not establish that every second of the LEADx samples came from those plans: shared database contention can add lock wait time, and should be investigated separately in production telemetry.
Correctness and concurrency
The claimant tests pause the first transaction after it locks its selected row and require a second claimant to finish inside a one-second database deadline. Mixed available/stale reminder and pending/stale broadcast tests prove the first transaction does not hide the unselected category head. The second claimant retries past the locked oldest row and receives the other row on MySQL and PostgreSQL. MySQL also retains same-category effect coverage.
Pending and stale candidates are compared by
(available_at_ms, id)for broadcasts and(run_at_ms, id)for reminders. Exact locking queries recheck eligibility before conditional updates. Attempt counts, per-instance broadcast revision ordering, retry/recovery behavior, ownership checks, and at-least-once delivery remain intact.Migration and compatibility
Schema migration 8 adds:
effects_poll(status, available_at_ms, id)reminders_due(status, run_at_ms, id)broadcasts_poll(status, available_at_ms, id)broadcasts_instance_revision(instance_id, state_revision, status)The migration is additive, records its version only after all indexes exist, and is safe to retry: SQLite/PostgreSQL use
IF NOT EXISTS, while MySQL checksinformation_schema.statistics. A version-7 upgrade test runs installation twice and verifies exact index columns. Index construction still takes each adapter's normal DDL locks, so large existing installations should runinstall()as part of a controlled deploy.Database.transactiongains an optionalDatabaseTransactionOptionsargument. Built-in PostgreSQL and MySQL adapters honorread_committed; SQLite keeps its serialized transaction. Custom SQL adapters are documented to honor the option for claim correctness.TDD record
Observed red before each behavior change:
pnpm exec vitest run test/polling-queries.test.ts— expected locking probes; all three original queries ended atLIMIT 1pnpm exec vitest run test/dead-letters.test.ts -t "upgrades an existing version-one database"— expected migrations 1-8, received 1-7DatabaseDeadlineExceededwhile the second claimant waited behind the first transaction's recovery range lockDatabaseDeadlineExceeded; after applying read committed, reminder still returned no second row until broad cleanup was replacedinstances, sorted and locked both due rows beforeLIMIT 1undefinedfor reminders and broadcasts on MySQL and broadcasts on PostgreSQL because the first transaction locked both category headsThe same focused tests passed green after their implementations. New rejection capture narrows non-
Errorvalues before exposing a concreteErrorin the tests.Validation
pnpm run format:checkpnpm run checkpnpm run testpnpm run test:coverage— 51 files, 360 passed, 17 skippedpnpm run buildpnpm run pack:checkpnpm run test:packagepnpm run test:recoverypnpm run test:at-least-oncepnpm run test:browser— 9 passedpnpm audit --audit-level=high— no known vulnerabilitiesSOLID_OBJECTS_DATABASE_URL=postgresql://... pnpm run test:postgresqlon PostgreSQL 18 — 13 passedSOLID_OBJECTS_DATABASE_URL=mysql://... pnpm run test:mysqlon MySQL 8.4 — 9 passedSOLID_OBJECTS_REDIS_URL=redis://... pnpm run test:redison Redis 7 — 3 passed