Skip to content

Send heartbeats while the caller consumes fetched rows - #632

Open
hashhar wants to merge 5 commits into
trinodb:masterfrom
hashhar:hashhar/463-query-stuck-finishing
Open

Send heartbeats while the caller consumes fetched rows#632
hashhar wants to merge 5 commits into
trinodb:masterfrom
hashhar:hashhar/463-query-stuck-finishing

Conversation

@hashhar

@hashhar hashhar commented Aug 18, 2026

Copy link
Copy Markdown
Member

Description

Fixes #463.

The client makes no requests between fetch() calls. A caller that holds a batch of rows for longer than query.client.timeout gets the query abandoned by the coordinator and the next fetch fails.

TrinoQuery now runs a heartbeat between fetch() calls. fetch() stops it on entry and starts it again before returning so a heartbeat never overlaps a request on the same HTTP session. execute() starts one as well when the first response already carries rows and no fetch has run yet. The heartbeat stops on completion and on cancel(). Its loop also exits through a weakref check once the TrinoQuery is garbage collected so a caller that abandons a cursor without closing it does not leak the thread.

heartbeat_interval (default 30s) controls the interval and None disables it. Servers older than Trino 475 do not support HEAD on the statement endpoint so they keep the old abandonment behavior.

A heartbeat already ran while a spooled segment was downloading. The query-level one covers that too so the per-download wiring is gone and SegmentIterator no longer takes request and heartbeat_interval. Stopping a heartbeat now joins its thread which closes a pre-existing race where an in-flight HEAD could overlap the request that followed it.

Non-technical explanation

The client pings the coordinator between fetches, so slow consumers no longer fail with "Query was abandoned by the client".

Release notes

( ) This is not user-visible or docs only and no release notes are required.
(x) Release notes are required, please propose a release note for me.
( ) Release notes are required, with the following suggested text:

@cla-bot cla-bot Bot added the cla-signed label Aug 18, 2026
@hashhar
hashhar force-pushed the hashhar/463-query-stuck-finishing branch from 2cd7679 to 904d56e Compare August 19, 2026 21:40
@hashhar hashhar changed the title Send heartbeats during general result iteration, not just spooled downloads Send heartbeats while the caller consumes fetched rows Aug 19, 2026
@hashhar
hashhar force-pushed the hashhar/463-query-stuck-finishing branch from 459267d to 46d44b8 Compare August 20, 2026 13:27
Before this change the client made no requests between `fetch()` calls.
A caller holding a batch of rows for longer than `query.client.timeout`
got the query abandoned by the coordinator. A heartbeat existed but it
ran only while a spooled segment was being downloaded.

`TrinoQuery` now runs the heartbeat between `fetch()` calls. That covers
segment downloads too, so the per-download heartbeat is removed. The
heartbeat stops on completion and on `cancel()`. Its loop also exits
through a weakref check once the `TrinoQuery` is garbage collected - to
handle a caller that abandons an unclosed cursor.

`_RequestHeartbeat` exposes `start()` and `stop()` in place of the
context manager now. `TrinoRequest.heartbeat()` is now the only place
that constructs one so `SegmentIterator` no longer takes `request` and
`heartbeat_interval`.
The tests started a heartbeat, slept for a fixed 0.1s and then asserted
how many HEAD requests a mock had recorded. The counts depend on how the
machine schedules the heartbeat thread within that window so a loaded
machine can fail them.

Serve scripted HEAD responses from a recording request instead. Each
response releases a semaphore so a test waits for the heartbeats it
needs and then joins the thread. This also removes the fixture that
patched MAX_FAILURES, the scripted responses set up each case directly.
The tests exercise `_RequestHeartbeat` directly and touch nothing
related to spooling. They lived in `test_client_spooling.py` because the
heartbeat used to run only during spooled segment downloads.
Before this change `stop()` set the stop event and returned. The loop
reads that event only in its `wait()` call so the following interleaving
was possible:

- the heartbeat thread returns from `_stop_event.wait()`, event unset
- `fetch()` calls `stop()`, which sets the event and returns
- the heartbeat thread sends its HEAD, it does not read the event again
- `fetch()` sends its GET

The HEAD and the GET then run at once on one `requests.Session` which is
not thread safe. `stop()` now joins the heartbeat thread.

The race is older than the query level heartbeat. The context manager
that `_RequestHeartbeat` used to expose did not join either so a spooled
segment download could overlap the `fetch()` that followed it.
@hashhar
hashhar force-pushed the hashhar/463-query-stuck-finishing branch from 46d44b8 to fb4e162 Compare August 20, 2026 21:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Development

Successfully merging this pull request may close these issues.

Queries stuck in FINISHING time

1 participant