Observation
TalkingStickService.waitForTurn (src/service.ts:457-478) loops every waitForTurnPollMs (250 ms) and runs each iteration inside withImmediateTransaction — a BEGIN IMMEDIATE (RESERVED-lock) transaction (src/db.ts:192-205). Inside the transaction, waitForTurnOnce calls touchWaitingMember (src/service.ts:1335-1357) which writes last_wait_at and last_seen_at on the caller's room_members row.
In practice this means: every active waiter takes a SQLite write lock 4×/second, purely to refresh its own waiting-presence timestamp, even when the room state hasn't changed and the call will return not_yet.
Why this is a smell (not a bug today)
WAL mode + busy_timeout=5000 + the fact that each transaction is a single-row UPDATE keeps contention bounded. We have no current evidence of waiters seeing SQLITE_BUSY or extending their effective wait beyond max_wait_ms.
But:
- The operation is read-shaped (the decision in
waitForTurnOnce is a read). Only the presence-refresh is a write. Pairing them on every poll forces every waiter through the writer queue.
- Multiple concurrent MCP servers (one per agent) each polling the same SQLite file means the cost scales linearly with active waiters: N waiters × 4 transactions/sec = 4N writer-acquisitions/sec on the same file.
release_stick's reservation logic depends on last_wait_at being recent within waiterGraceMs (10 s) — so we do need waiters to refresh — but not necessarily on every 250 ms tick.
Suggested direction (not prescriptive)
One or more of:
- Split the read-only decision from the write-only touch. Run
waitForTurnOnce as a read (no BEGIN IMMEDIATE); on every Nth iteration (or on entry/exit of the wait loop), do a separate small write to refresh last_wait_at. Refresh cadence governed by waiterGraceMs / 2 to stay safely under the grace window.
- Batch presence refresh. A wait loop might write
last_wait_at once on entry, every waiterGraceMs / 2 while polling, and once on exit. The decision iterations stay read-only.
- Move presence refresh to a separate non-blocking writer. A small queue or deferred-write mechanism so the polling loop never holds the writer lock; a background flusher drains it. Heavier, but defensible if real waiter counts grow.
(1) is the cheapest. (2) is the cleanest. (3) is over-engineering unless we have evidence.
Acceptance criteria for a fix
- A waiter calling
wait_for_turn with max_wait_ms=30000 writes last_wait_at no more than ~10 times during that wait (vs. ~120 today), without changing the externally-observable wait/grant semantics.
release_stick reservation behavior under contention is unchanged: a waiter that started a wait_for_turn long-poll within waiterGraceMs of a release still gets reserved.
- Concurrency test: N waiters polling against a single SQLite file produce no
SQLITE_BUSY errors and no extended waits, with N at least 4.
Severity
Low. Code smell + scaling concern. No observed bug; logged from a session investigating a reported "wait blocks for the full timeout" symptom that turned out to be expected long-poll behavior, not contention.
Context
Discovered while investigating operator-reported "MCP variant locks for the full timeout" symptom in room b94f3d80-eafc-4d30-9198-758db9d786fe on 2026-04-30. Symptom turned out to be a non-owner correctly long-polling; the real finding is the per-iteration write lock pattern documented above.
Observation
TalkingStickService.waitForTurn(src/service.ts:457-478) loops everywaitForTurnPollMs(250 ms) and runs each iteration insidewithImmediateTransaction— aBEGIN IMMEDIATE(RESERVED-lock) transaction (src/db.ts:192-205). Inside the transaction,waitForTurnOncecallstouchWaitingMember(src/service.ts:1335-1357) which writeslast_wait_atandlast_seen_aton the caller'sroom_membersrow.In practice this means: every active waiter takes a SQLite write lock 4×/second, purely to refresh its own waiting-presence timestamp, even when the room state hasn't changed and the call will return
not_yet.Why this is a smell (not a bug today)
WAL mode +
busy_timeout=5000+ the fact that each transaction is a single-row UPDATE keeps contention bounded. We have no current evidence of waiters seeingSQLITE_BUSYor extending their effective wait beyondmax_wait_ms.But:
waitForTurnOnceis a read). Only the presence-refresh is a write. Pairing them on every poll forces every waiter through the writer queue.release_stick's reservation logic depends onlast_wait_atbeing recent withinwaiterGraceMs(10 s) — so we do need waiters to refresh — but not necessarily on every 250 ms tick.Suggested direction (not prescriptive)
One or more of:
waitForTurnOnceas a read (noBEGIN IMMEDIATE); on every Nth iteration (or on entry/exit of the wait loop), do a separate small write to refreshlast_wait_at. Refresh cadence governed bywaiterGraceMs / 2to stay safely under the grace window.last_wait_atonce on entry, everywaiterGraceMs / 2while polling, and once on exit. The decision iterations stay read-only.(1) is the cheapest. (2) is the cleanest. (3) is over-engineering unless we have evidence.
Acceptance criteria for a fix
wait_for_turnwithmax_wait_ms=30000writeslast_wait_atno more than ~10 times during that wait (vs. ~120 today), without changing the externally-observable wait/grant semantics.release_stickreservation behavior under contention is unchanged: a waiter that started await_for_turnlong-poll withinwaiterGraceMsof a release still gets reserved.SQLITE_BUSYerrors and no extended waits, with N at least 4.Severity
Low. Code smell + scaling concern. No observed bug; logged from a session investigating a reported "wait blocks for the full timeout" symptom that turned out to be expected long-poll behavior, not contention.
Context
Discovered while investigating operator-reported "MCP variant locks for the full timeout" symptom in room
b94f3d80-eafc-4d30-9198-758db9d786feon 2026-04-30. Symptom turned out to be a non-owner correctly long-polling; the real finding is the per-iteration write lock pattern documented above.