Skip to content

Ctrl-C stops a statement, and so does another thread - #8

Merged
tamnd merged 1 commit into
mainfrom
interrupt
Aug 18, 2026
Merged

tamnd merged 1 commit into
mainfrom
interrupt

Conversation

@tamnd

@tamnd tamnd commented Aug 18, 2026

Copy link
Copy Markdown
Owner

A press during a long statement did nothing until the statement ended. Python answers a signal by setting a flag in the handler and raising the exception at the next bytecode on the main thread, and a thread sitting inside the engine with the GIL released runs no bytecode, so a ten second query swallowed the press and gave it back ten seconds later. That is the shape of a hang, and a notebook that looks hung gets its kernel killed.

A statement called from the main thread now runs on a thread the connection keeps for it, while the calling thread waits two milliseconds at a time and asks Python for signals in between. A press raises KeyboardInterrupt out of execute at five milliseconds measured on this machine, against the fifty the milestone asks for. A statement called from any other thread runs inline exactly as before, because Python delivers a signal to the main thread and to no other, and watching for one anywhere else is a cost that buys nothing.

The thread is kept rather than made per statement, and it is only started at the first statement that needs it. Starting a thread costs thirty microseconds against a small statement that costs ten, and handing work to a parked one costs six, so the runner looks for work for two hundred microseconds before it parks and the waiting side looks for the answer for as long before it sleeps. test_a_statement_that_finishes_is_not_slowed_by_being_watched is the gate on that: a small statement on the main thread has to stay inside three times what it costs on a worker thread.

interrupt() is the same stop asked for from another thread, and it raises zudb.Interrupted where the statement is. It takes no lock on the connection: the engine handle it sets is held beside the lock rather than under it, so an ask cannot queue behind the statement it means to stop. closed and rows_read answer while a statement runs for the same reason, and rows_read is what a progress bar is drawn from.

Eleven tests, all against a database of twelve thousand people whose pairwise statement runs about ten seconds: the press inside the budget, the ask inside the budget, a statement on a worker thread stopped too, the connection reading twelve thousand rows again afterwards, an ask with nothing running not ending the next statement, a press with nothing running still Python's to deliver, an interrupt on a closed connection saying so, a connection answering while it is busy, and a press during a statement that finished first still being raised.

Local gates: ruff check, ruff format --check, cargo fmt --all --check, cargo clippy --all-targets -- -D warnings, and pytest, 246 passed.

Part of DX2, tamnd/zu#168: Ctrl-C raises KeyboardInterrupt in Python within 50 ms.

A press during a long statement did nothing until the statement
ended, because Python delivers a signal by setting a flag and
raising it at the next bytecode on the main thread, and a thread
inside the engine with the GIL released runs no bytecode. Ten
seconds of that reads as a hang, and a notebook that looks hung
gets its kernel killed.

So a statement called from the main thread now runs on a thread
the connection keeps, and the calling thread waits for it two
milliseconds at a time, asking Python for signals in between. A
press raises KeyboardInterrupt from execute at five milliseconds
measured against the fifty the milestone asks for. Everywhere
else the statement runs inline as it always did, because Python
delivers a signal to the main thread and to no other, and a
watch there would be a cost buying nothing.

The thread is kept rather than made per statement, and made at
all only at the first statement that needs it. Starting one costs
thirty microseconds against a small statement that costs ten, and
handing work to a parked thread costs six, so the thread looks
for work for two hundred microseconds before it parks and the
waiting side looks for the answer for as long before it sleeps.
A statement that finishes on its own is not measurably slower for
being watched, which is a test rather than a claim.

interrupt() is the same stop from another thread, and raises
Interrupted where the statement is. It takes no lock on the
connection, because the engine handle it sets lives beside the
lock rather than under it and an ask that queued behind the
statement could only arrive after it. closed and rows_read are
answerable while a statement runs for the same reason, which is
what a progress bar is drawn from.
@tamnd
tamnd merged commit dabfc94 into main Aug 18, 2026
5 of 7 checks passed
@tamnd
tamnd deleted the interrupt branch August 18, 2026 04:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant