Skip to main content

sequel_mcp/sql/
cancel.rs

1//! Server-side statement cancellation (D2).
2//!
3//! `MAX_EXECUTION_TIME`/`max_statement_time` only cover SELECTs and are
4//! not guaranteed immediate, so this module provides active cancellation:
5//! when a deadline expires, `KILL QUERY <id>` is issued from a separate
6//! same-user control connection against the executing connection's id
7//! (captured via `CONNECTION_ID()` before the statement runs). The
8//! executing connection is only reused after its transaction state is
9//! verified clean; anything inconclusive discards it. A cancelled
10//! mutation is never retried automatically.
11
12use mysql_async::Pool;
13use mysql_async::prelude::Queryable;
14use thiserror::Error;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum CancelOutcome {
18    /// Statement finished before the deadline (no cancellation).
19    Completed,
20    /// Deadline fired; KILL QUERY succeeded; the executing future
21    /// resolved; the connection verified clean and stays in the pool.
22    Interrupted,
23    /// Deadline fired and the outcome could not be established — the
24    /// executing connection was discarded. The caller must surface an
25    /// uncertain result and must not retry.
26    Uncertain,
27}
28
29#[derive(Debug, Error)]
30pub enum CancellationError {
31    #[error("statement deadline exceeded; interrupted via KILL QUERY ({0}ms)")]
32    Interrupted(u64),
33    #[error(
34        "statement deadline exceeded; cancellation inconclusive — connection discarded ({0}ms)"
35    )]
36    Uncertain(u64),
37}
38
39/// Issue `KILL QUERY <connection_id>` on a separate connection from the
40/// same pool (same user may kill its own threads).
41pub async fn kill_query(pool: &Pool, connection_id: u64) -> Result<(), String> {
42    let mut control = pool
43        .get_conn()
44        .await
45        .map_err(|e| format!("control connection failed: {e}"))?;
46    // The identifier originates from the server (CONNECTION_ID), never
47    // user input, and is a u64 — formatting cannot inject. KILL is not
48    // universally preparable, so it is sent as literal SQL.
49    let sql = format!("KILL QUERY {connection_id}");
50    control
51        .query_drop(sql.as_str())
52        .await
53        .map_err(|e| format!("KILL QUERY failed: {e}"))
54}
55
56/// Verify a post-cancellation connection is clean: no open transaction,
57/// autocommit restored, and it answers a health query. `true` = reusable.
58pub async fn verify_connection_clean(conn: &mut mysql_async::Conn) -> Result<bool, String> {
59    let row: Option<(i8, i8)> = conn
60        .exec_first::<(i8, i8), _, _>("SELECT @@autocommit, @@in_transaction", ())
61        .await
62        .map_err(|e| format!("verify query failed: {e}"))?;
63    match row {
64        // in_transaction must be 0; autocommit is informational (we set
65        // session state per operation anyway).
66        Some((_, in_txn)) => Ok(in_txn == 0),
67        None => Ok(false),
68    }
69}