Skip to content

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.