Skip to content

feat(WBC-1179): probe stale queries to recover a lost terminal event - #76

Draft
sfishel18 wants to merge 2 commits into
mainfrom
simon/stale-query-watchdog
Draft

sfishel18 wants to merge 2 commits into
mainfrom
simon/stale-query-watchdog

Conversation

@sfishel18

Copy link
Copy Markdown
Contributor

Summary

On 2026-09-17 the staging MCP canary waited out its full 900s budget even though the SQL session had finished the query. The session logged success and sent the terminal state_updated, but the driver never received it, on a healthy socket. The driver trusts that one pushed event and never asks again, so the cursor waited until the caller gave up.

This adds a watchdog. When a query goes stale_query_probe_seconds (default 30s) without any message from the session, the driver sends a retrieve_results probe. Repeat probes back off to 8× the interval. The reply recovers the query: results for a normal query, or the result location (result_uri) for a store query. Pass None to disable it.

Behaviour worth knowing:

  • Store queries recover only against sql-session ≥ 1.9.3. The result_uri echo in the probe reply ships in 1.9.3 (wherobots/sql-session#208). Against an older session, a store query's probe reply carries no location, so the watchdog keeps waiting rather than complete the query with no results. That is the same as today, never worse. An empty store result whose terminal event was lost looks identical, so it waits too.
  • A probe never fails a healthy query. sql-session's execution cache holds only 4 entries and can evict a query that is still running. A probe for that query gets Execution not found. The driver treats this as "can't probe this one", not as a failure, because the query's terminal event still arrives.
  • Probes never hold up result delivery. A probe skips a check rather than wait behind a user's in-flight send.
  • Large results aren't re-sent repeatedly. A query already waiting on its results gets at most one probe, no earlier than 4× the interval.
  • The API change is additive: a new optional stale_query_probe_seconds kwarg on connect()/connect_direct(), and ExecutionState.PENDING.

Related Issues

Relates to WBC-1179 (canary run https://github.com/wherobots/studio-backend/actions/runs/35281752422). Builds on #74. Server side: wherobots/sql-session#208.


Requester Checklist

Complete these before marking Ready for Review

  • I have self-reviewed my own code
  • I have added/updated tests that prove my fix/feature works
  • I have included visual proof (screenshot, video, or test output) if applicable
  • All CI checks are passing
  • PR size is S/M, OR I have justified the size and added a walkthrough
  • I have updated documentation if needed

Visual Proof

uv run pytest -q: 225 passed. pre-commit run --all-files: all hooks pass. The watchdog test file passed 5/5 consecutive runs.

The new tests were checked to fail against main, and the key ones were also checked by breaking the behaviour they cover:

  • moving the staleness check back to idle-only → the busy-connection starvation test fails;
  • completing a store query with no result_uri → the keep-waiting test fails;
  • removing the backoff → the backoff test fails;
  • letting a probe block on the send lock → the stalled-send test fails;
  • dropping the eviction guard's normal-request check → the guard test fails in ~4s (it no longer hangs).

Size Justification (if L/XL)

The size comes mostly from tests: source and docs are +308/−30, tests are +608/−2. Nearly all the source change is in wherobots/db/connection.py. The PR is split into two commits that review independently:

  1. the watchdog: staleness tracking, probes, backoff, the new kwarg;
  2. store-query recovery from the probe reply's result_uri.

Reviewer Checklist

If these are not met, close the tab — this PR is not ready for review

  • Requester checklist above is complete
  • All CI checks are passing
  • Tests adequately cover the changes

The driver trusts a single pushed state_updated per query. When that frame
is lost on a healthy socket, the query waits forever (2026-09-17 staging
MCP canary: the session logged success, the driver never saw it).

Add a bounded watchdog on the reader thread. Any inbound event for an
execution, progress included, resets that query's clock. After N seconds
of silence it re-sends retrieve_results as a probe, backing off N, 2N, 4N,
then 8N. Replies to a retrieve reset the clock but not the backoff, so a
long, silent query isn't re-asked every N seconds. The check runs on every
reader-loop iteration (gated to at most once per min(1s, N)), so steady
traffic for one query can't starve another's watchdog. When the watchdog
is on, recv() is capped at that check interval, so a read_timeout of None
or a long one can't park the reader past a due probe.

Probes never wait for the send lock: if a user thread is mid-send, maybe
stalled, the probe is skipped until the next check instead of stopping
recv() for every query on the connection.

Probed states: EXECUTION_REQUESTED, PENDING, RUNNING. A query whose
results were already requested gets one probe, after 4N: the session
re-serializes and re-sends the full result for every retrieve, so
duplicates are costly. Duplicate replies are dropped by the existing claim
in complete_query. Add the session's PENDING state, so a probe reply can
never fail a healthy query as an unknown state.

sql-session's execution cache is a small LRU that can evict a query that
is still running; its terminal state_updated still arrives. So a probe's
"Execution not found" doesn't fail the query: it stops probing it and
keeps waiting. After the normal retrieve, not-found still fails it.

New additive kwarg stale_query_probe_seconds (default 30.0) on connect(),
connect_direct() and Connection. None disables the watchdog; an invalid
value disables it with a warning and never raises.
…-1179)

A store-only execution has no cached Arrow table, so the session answers
retrieve_results for it with succeeded and no results. Sessions with the
probe contract (sql-session step 16) also echo the top-level result_uri and
size, as state_updated carries them.

On an execution_result with a result_uri, complete with that StoreResult,
as the state_updated path does. The normal path never requests results for
a store-configured query, so such a reply can only answer a probe.

Succeeded without a result_uri on a store query is an older session or an
empty store result, which can't be told apart. Log a warning and keep
waiting rather than complete empty, which would silently drop an old
session's real result, and stop probing it. Known limitation: an empty
store result whose terminal event was lost waits as it does today.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant