Cancellation¶
A long-running query blocks the actor that issued it. If another actor — a timer, supervisor, user-facing controller — decides the query has run long enough, it needs a way to say so.
CancelToken is a sendable handle that fires SQLCancel on a statement from outside the owning actor.
class val CancelToken
fun cancel()
val, so sendable. It holds a copy of the SQLHSTMT pointer and nothing else.
Where tokens come from¶
Both Statement and Cursor expose:
fun cancel_token(): CancelToken
Call it, send the token to a supervisor, continue with your operation.
The pattern¶
use "lib:odbc"
use "odbc"
use "time"
// A supervisor actor that holds a CancelToken and fires SQLCancel on
// demand. The token is val, so it can safely cross actor boundaries.
actor Canceller
let _token: CancelToken
let _env: Env
new create(env: Env, token: CancelToken) =>
_env = env
_token = token
be fire() =>
_env.out.print("canceller: firing cancel")
_token.cancel()
// A Notify that wakes the canceller after a short delay.
class iso _TimerNotify is TimerNotify
let _canceller: Canceller
new iso create(canceller: Canceller) =>
_canceller = canceller
fun ref apply(timer: Timer, count: U64): Bool =>
_canceller.fire()
false
actor Main
new create(env: Env) =>
let dsn_name =
try env.args(1)?
else "psqlred"
end
match Odbc.connect(Dsn("DSN=" + dsn_name))
| let conn: Connection =>
// A query that sleeps for 10 seconds server-side (Postgres).
// Other backends: substitute the equivalent long-running statement.
match \exhaustive\ conn.prepare("SELECT pg_sleep(10)")
| let stmt: Statement =>
// Hand the supervisor a token. Token is val — safe to send.
let canceller = Canceller(env, stmt.cancel_token())
// Schedule cancellation for 1 second from now.
let timers = Timers
let timer =
Timer(_TimerNotify(canceller), 1_000_000_000)
timers(consume timer)
env.out.print("main: starting long query")
match \exhaustive\ stmt.execute()
| Executed =>
// In practice a cancelled execute returns an ExecError
// with SQLSTATE HY008. If we got Executed, the cancel
// didn't land in time.
env.out.print("main: query completed before cancel")
stmt.close_cursor()
| let e: ExecError =>
env.out.print("main: execute returned: " + e.string())
let diag = e.unsafe_diag()
try
let rec = diag(0)?
env.out.print(
" SQLSTATE " + rec.sqlstate
+ " (Postgres reports 57014; ODBC defines HY008)")
end
end
stmt.close()
| let e: PrepareError =>
env.err.print("prepare: " + e.string())
end
conn.close()
| let e: ConnectError =>
env.err.print("connect: " + e.string())
end
Postgres-specific (uses pg_sleep):
./build/12-cancellation
main: starting long query
canceller: firing cancel
main: execute returned: ExecError: query error [57014]
SQLSTATE 57014 (Postgres reports 57014; ODBC defines HY008)
HY008 is the ODBC-standard SQLSTATE for “operation canceled”. Postgres reports its own 57014 (“query_canceled”) which the library surfaces as-is — check for either for portable recognition.
Lifetime contract¶
One sharp edge: the token holds a raw copy of the SQLHSTMT. It doesn’t know whether the owning Statement/Cursor has closed. If the token outlives the statement and someone calls token.cancel() after stmt.close(), you’re calling SQLCancel on a freed handle — undefined behaviour, typically a crash.
Contract: the caller ensures no outstanding token is used after close().
Practical patterns:
- Close the statement after every token-holding actor has been told to drop it
- Treat the token as one-shot: fire it and forget it
- Use a supervising actor that explicitly discards the token before the query completes
There’s no lifetime guard in the API. Guarding would require either actor-coordinated refcounting (expensive and awkward) or actively invalidating the token pointer (which defeats the thread-safety of SQLCancel). The trade: cheap and fast normally, fragile if you misuse it.
When cancellation doesn’t land¶
Cancellation is driver+database cooperation. SQLCancel sends an asynchronous request; the database notices and aborts.
Some databases don’t cancel mid-statement — a SELECT without blocking I/O can run to completion before the database checks. Some drivers have quirks. If the cancel fires but the query completes normally, that’s the reason; the sample handles it in the | Executed branch.
Not a timeout primitive¶
CancelToken is an action, not a policy. For a statement timeout, build one out of CancelToken plus time.Timer (sample 12 does exactly that). The library doesn’t provide a timeout method because the policy choices (behaviour on ambiguous commits, retry semantics, cleanup of partial work) vary too much for a single built-in to stay out of the way.