Skip to content

wait_for_turn polls take a write lock every 250 ms just to refresh last_wait_at #14

Description

@mostlydev

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:

  1. 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.
  2. 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.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions