Skip to main content

khive_db/sql_bridge/
inline_bridge.rs

1use super::{
2    acquire_handle_slot, acquire_unit_lease, async_trait, bind_params, execute_prepared_batch,
3    execute_query, execute_query_page, execute_query_row, map_rusqlite_err, map_write_boundary_err,
4    map_write_err, open_standalone_writer_on_blocking, prepare_batch_statements,
5    prepare_cached_sql_statement, reject_transaction_control_statements, run_manual_atomic_unit,
6    Any, Arc, AtomicEventRows, AtomicUnitOp, ConnectionPool, PageRequest, PoolBackedReader,
7    PoolBackedWriter, SlotTimeoutClass, SqlRow, SqlStatement, SqlValue, SqliteError, SqliteReader,
8    SqliteWriter, StandaloneHandle, StorageCapability, StorageError,
9};
10
11// =============================================================================
12// atomic_unit (ADR-067 Component A, Fork C slice 2)
13// =============================================================================
14
15/// A purely-synchronous `SqlReader`/`SqlWriter` over a borrowed connection,
16/// used to drive an [`AtomicUnitOp`] on the queued or in-memory path, where the
17/// closure body runs inside a `spawn_blocking` (synchronous
18/// `FnOnce(&rusqlite::Connection) -> ...`) rather than a real async context.
19///
20/// Every method here does plain, non-suspending rusqlite work — there is no
21/// real `.await` point anywhere in this impl — so [`block_on_sync`] driving
22/// the resulting future to completion with a single poll is sound, not a
23/// hack: the future can never actually be `Pending`.
24///
25/// `SqlReader`/`SqlWriter` both carry a `'static` supertrait bound (they are
26/// used as `Box<dyn ...>` elsewhere in this module), so this type cannot
27/// hold a real `&'c Connection` borrow — it would tie `InlineWriter` to a
28/// non-`'static` lifetime and, independently, `&Connection` is not `Send`
29/// (`Connection` is `!Sync`), which the `#[async_trait]`-generated futures
30/// require. A raw pointer sidesteps both: `*const Connection` is `Send` and
31/// `'static` on its face, and the safety burden (the pointee outliving
32/// every dereference) is upheld by construction — see `atomic_unit`, the
33/// only call site: it builds an `InlineWriter` from `conn: &Connection`,
34/// drives `op` to completion via `block_on_sync` synchronously, and drops
35/// the `InlineWriter` before that borrow ends, all within one stack frame.
36pub(super) struct InlineWriter {
37    pub(super) event_rows: Option<Arc<AtomicEventRows>>,
38    pub(super) conn: *const rusqlite::Connection,
39}
40
41// SAFETY: `InlineWriter` is never actually shared across a real thread
42// boundary — it is constructed, driven to completion synchronously via
43// `block_on_sync`, and dropped within a single call frame inside the
44// `spawn_blocking` closure (see `atomic_unit`). The `Send`
45// bound `async_trait` imposes on the futures below is a static
46// over-approximation for this restricted, single-threaded usage pattern.
47unsafe impl Send for InlineWriter {}
48
49impl InlineWriter {
50    /// SAFETY: valid for the lifetime of the enclosing synchronous scope in
51    /// `atomic_unit` (see the struct doc comment above) — the pointee is
52    /// never dereferenced after that scope ends.
53    fn conn(&self) -> &rusqlite::Connection {
54        unsafe { &*self.conn }
55    }
56}
57
58#[async_trait]
59impl khive_storage::SqlReader for InlineWriter {
60    async fn query_row(
61        &mut self,
62        statement: SqlStatement,
63    ) -> khive_storage::types::StorageResult<Option<SqlRow>> {
64        execute_query_row(self.conn(), &statement)
65            .map_err(|e| map_rusqlite_err(e, "inline.query_row"))
66    }
67
68    async fn query_all(
69        &mut self,
70        statement: SqlStatement,
71    ) -> khive_storage::types::StorageResult<Vec<SqlRow>> {
72        execute_query(self.conn(), &statement).map_err(|e| map_rusqlite_err(e, "inline.query_all"))
73    }
74
75    async fn query_page(
76        &mut self,
77        statement: SqlStatement,
78        page: PageRequest,
79    ) -> khive_storage::types::StorageResult<Vec<SqlRow>> {
80        execute_query_page(self.conn(), &statement, &page)
81            .map_err(|e| map_rusqlite_err(e, "inline.query_page"))
82    }
83
84    async fn query_scalar(
85        &mut self,
86        statement: SqlStatement,
87    ) -> khive_storage::types::StorageResult<Option<SqlValue>> {
88        let row = khive_storage::SqlReader::query_row(self, statement).await?;
89        Ok(row.and_then(|r| r.columns.into_iter().next().map(|c| c.value)))
90    }
91
92    async fn explain(
93        &mut self,
94        statement: SqlStatement,
95    ) -> khive_storage::types::StorageResult<Vec<SqlRow>> {
96        let explain_stmt = SqlStatement {
97            sql: format!("EXPLAIN QUERY PLAN {}", statement.sql),
98            params: statement.params,
99            label: statement.label,
100        };
101        khive_storage::SqlReader::query_all(self, explain_stmt).await
102    }
103}
104
105#[async_trait]
106impl khive_storage::SqlWriter for InlineWriter {
107    async fn execute(
108        &mut self,
109        statement: SqlStatement,
110    ) -> khive_storage::types::StorageResult<u64> {
111        // Boundary: `execute_batch` owns transaction-control rejection;
112        // `atomic_unit` uses this one-statement primitive for its own boundary.
113        let mut stmt = prepare_cached_sql_statement(self.conn(), &statement.sql)
114            .map_err(|e| map_write_err(e, "inline.execute"))?;
115        bind_params(&mut stmt, &statement.params)
116            .map_err(|e| map_write_err(e, "inline.execute"))?;
117        let affected = stmt
118            .raw_execute()
119            .map_err(|e| map_write_err(e, "inline.execute"))?;
120        if let Some(event_rows) = self.event_rows.as_deref() {
121            event_rows.observe(&statement, affected as u64);
122        }
123        Ok(affected as u64)
124    }
125
126    async fn execute_batch(
127        &mut self,
128        statements: Vec<SqlStatement>,
129    ) -> khive_storage::types::StorageResult<u64> {
130        // Runs inside the writer task's per-request `BEGIN IMMEDIATE`
131        // (atomic_unit flag-on path), so a caller `COMMIT` would close the
132        // task's transaction — reject transaction-control statements up
133        // front, same contract as every other `execute_batch`.
134        reject_transaction_control_statements(&statements, "inline.execute_batch")?;
135        let prepared = prepare_batch_statements(self.conn(), &statements)
136            .map_err(|e| map_write_err(e, "inline.execute_batch"))?;
137        execute_prepared_batch(
138            self.conn(),
139            prepared,
140            &statements,
141            self.event_rows.as_deref(),
142        )
143        .map_err(|e| map_write_err(e, "inline.execute_batch"))
144    }
145
146    async fn execute_script(&mut self, script: String) -> khive_storage::types::StorageResult<()> {
147        // Boundary: this raw script path is internal maintenance only and is
148        // outside the `execute_batch` transaction-control contract.
149        self.conn()
150            .execute_batch(&script)
151            .map_err(|e| map_write_err(e, "inline.execute_script"))
152    }
153}
154
155/// Poll `fut` exactly once with a no-op waker and return its output.
156///
157/// Only sound for futures that never actually suspend — every caller in
158/// this module drives an [`InlineWriter`], whose methods are pure
159/// synchronous rusqlite calls with no real `.await` point.
160///
161/// ADR-067 Component A: this used to
162/// `unreachable!()`-panic on `Poll::Pending`, and a panicking closure
163/// running inside the writer task's `spawn_blocking` (see
164/// `SqlBridge::atomic_unit`'s flag-on branch) would surface as a
165/// `JoinError` in `run_writer_task`, which is treated as fatal — the writer
166/// task exits and every subsequent `WriterTaskHandle::send` on this pool
167/// fails for the rest of the process. A future `atomic_unit` caller whose
168/// closure ever gains a real suspend point (this file's own contract
169/// already forbids it, but the invariant is enforced by convention, not the
170/// type system) would take down the writer task for the whole daemon.
171/// Returning `Err` instead lets `Pending` flow through the SAME error path
172/// as any other `atomic_unit` op failure: `WriteRequest::execute_and_reply`
173/// treats it as an ordinary `Err`, issues `ROLLBACK` on the writer task's
174/// held transaction, replies the error to the caller, and the writer task's
175/// `spawn_blocking` closure returns normally (not via panic) — so the task
176/// keeps draining subsequent requests instead of dying with the whole pool.
177pub(super) fn block_on_sync<F: std::future::Future>(fut: F) -> Result<F::Output, StorageError> {
178    use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
179
180    fn no_op(_: *const ()) {}
181    fn clone_waker(_: *const ()) -> RawWaker {
182        RawWaker::new(std::ptr::null(), &VTABLE)
183    }
184    static VTABLE: RawWakerVTable = RawWakerVTable::new(clone_waker, no_op, no_op, no_op);
185
186    // SAFETY: every `RawWakerVTable` function is a no-op that never
187    // dereferences the data pointer, so a null data pointer is sound.
188    let raw_waker = RawWaker::new(std::ptr::null(), &VTABLE);
189    let waker = unsafe { Waker::from_raw(raw_waker) };
190    let mut cx = Context::from_waker(&waker);
191
192    let mut fut = std::pin::pin!(fut);
193    match fut.as_mut().poll(&mut cx) {
194        Poll::Ready(v) => Ok(v),
195        Poll::Pending => {
196            tracing::error!(target: "khive_db::sql_bridge",
197                "block_on_sync: atomic_unit future suspended on its first poll — \
198                 the closure passed to SqlAccess::atomic_unit must be non-blocking \
199                 (synchronous InlineWriter calls only, no real .await point)"
200            );
201            Err(StorageError::Internal(
202                "atomic_unit future suspended — closure must be non-blocking".to_string(),
203            ))
204        }
205    }
206}
207
208// =============================================================================
209// SqlBridge: the SqlAccess implementor
210// =============================================================================
211
212/// Bridges `ConnectionPool` to `khive_storage::SqlAccess`.
213///
214/// Dispatches based on whether the pool is file-backed or in-memory:
215/// - File-backed: pooled ordinary reader operations and a lazy standalone
216///   connection only for explicitly admitted multi-call read transactions,
217///   plus standalone writer connections capped at one live handle; atomic
218///   units drive a single registered raw transaction span.
219/// - In-memory: pool-backed connections per query (single shared connection).
220pub struct SqlBridge {
221    pool: Arc<ConnectionPool>,
222    pub(super) is_file_backed: bool,
223}
224
225impl SqlBridge {
226    /// Create a new bridge wrapping the given pool.
227    pub fn new(pool: Arc<ConnectionPool>, _is_file_backed: bool) -> Self {
228        // The legacy hint is not an authority for choosing an unguarded
229        // in-memory writer. A caller cannot route a file-backed pool through
230        // PoolBackedWriter by supplying `false`.
231        let is_file_backed = pool.canonical_path().is_some();
232        Self {
233            pool,
234            is_file_backed,
235        }
236    }
237}
238
239#[async_trait]
240impl khive_storage::SqlAccess for SqlBridge {
241    fn database_path(&self) -> Option<std::path::PathBuf> {
242        self.pool.canonical_path().map(std::path::Path::to_path_buf)
243    }
244
245    async fn reader(
246        &self,
247    ) -> khive_storage::types::StorageResult<Box<dyn khive_storage::SqlReader>> {
248        if self.is_file_backed {
249            Ok(Box::new(SqliteReader {
250                handle: None,
251                pool: Arc::clone(&self.pool),
252                poisoned: false,
253            }))
254        } else {
255            Ok(Box::new(PoolBackedReader {
256                pool: Arc::clone(&self.pool),
257                transaction: None,
258            }))
259        }
260    }
261
262    async fn writer(
263        &self,
264    ) -> khive_storage::types::StorageResult<Box<dyn khive_storage::SqlWriter>> {
265        if self.is_file_backed {
266            if self.pool.config().read_only {
267                return Err(StorageError::Pool {
268                    operation: "writer".into(),
269                    message: "backend is read-only".into(),
270                });
271            }
272            let db = crate::timeout_sink::db_label(&self.pool);
273            // ADR-136 D1 gate 1: queue-first. The handle lookup runs BEFORE
274            // any standalone connection is opened, and a lookup failure is
275            // propagated (never silently degraded) when strict routing is
276            // on. Only the flag-off/degraded case still opens a standalone
277            // connection.
278            let writer_task = match self.pool.writer_task_handle() {
279                Ok(handle) => handle,
280                Err(e) => {
281                    if self.pool.config().write_routing_strict {
282                        return Err(e);
283                    }
284                    tracing::warn!(target: "khive_db::sql_bridge",
285                        error = %e,
286                        "KHIVE_WRITE_ROUTING is not strict; writer() degrades to the \
287                         standalone-connection path"
288                    );
289                    None
290                }
291            };
292            if writer_task.is_none() && self.pool.config().write_routing_strict {
293                return Err(StorageError::Pool {
294                    operation: "writer".into(),
295                    message: "KHIVE_WRITE_ROUTING=strict but no writer-task handle is \
296                              available; refusing to fall back to a direct connection"
297                        .into(),
298                });
299            }
300            if writer_task.is_none() && self.pool.write_queue_active() {
301                // The queue is enabled but this call didn't get a handle
302                // (spawn/runtime degrade) — a direct-route violation in the
303                // making once this writer's execute*/query* methods run.
304                // In-memory pools are excluded: they never spawn a writer
305                // task by documented design (explicit `Some(true)` degrades),
306                // so a violation row there would be noise, not signal.
307                crate::timeout_sink::emit_direct_route_violation(
308                    &db,
309                    crate::timeout_sink::Site::DirectRouteSqlBridgeWriter,
310                );
311            }
312            // A standalone read-write connection is opened only when there is
313            // no queue handle to route writes through — `SqliteWriter`'s
314            // `SqlReader` methods (`query_row`/`query_all`/`query_page`)
315            // use pooled readers in the queue-backed case and lazily open the
316            // closed standalone exception only for an explicit deferred read
317            // transaction. Production callers do read through a `writer()`
318            // handle, so both routes are live.
319            // The standalone open acquires the pool-wide one-permit writer
320            // budget first, and the permit travels in the handle for the
321            // handle's whole lifetime — a queue-backed handle holds no
322            // writer permit (its writes route through the writer task), so
323            // this budget caps exactly the standalone read-write
324            // connections.
325            let handle = if writer_task.is_none() {
326                let handle_slot = acquire_handle_slot(
327                    self.pool.sql_bridge_writer_slots(),
328                    self.pool.config().checkout_timeout,
329                    "sql_bridge.writer_handle",
330                    SlotTimeoutClass::Admission,
331                )
332                .await?;
333                let (conn, handle_slot) =
334                    open_standalone_writer_on_blocking(Arc::clone(&self.pool), handle_slot).await?;
335                Some(StandaloneHandle {
336                    conn,
337                    _retained_slot: Some(handle_slot),
338                    read_transaction_slot: None,
339                })
340            } else {
341                None
342            };
343            Ok(Box::new(SqliteWriter {
344                observe_direct_errors: true,
345                event_rows: None,
346                handle,
347                writer_task,
348                origin: self.pool.origin(),
349                db,
350                pool: Arc::clone(&self.pool),
351                held_lease: None,
352            }))
353        } else {
354            Ok(Box::new(PoolBackedWriter {
355                pool: Arc::clone(&self.pool),
356            }))
357        }
358    }
359
360    /// Implements the trait's atomic-unit suspend-free invariant
361    /// (`SqlAccess::atomic_unit`'s doc comment): on the queued and in-memory branches,
362    /// `op` is driven through `block_on_sync` on an `InlineWriter` — a
363    /// single-poll driver that returns `Err` the instant `op`'s future is
364    /// `Pending` instead of ever actually suspending. `op` must therefore
365    /// issue only synchronous DML; see `InlineWriter`'s and
366    /// `block_on_sync`'s doc comments for the full mechanics and why this
367    /// restriction is load-bearing (a suspended poll inside the writer
368    /// task's `spawn_blocking` would otherwise block that task on external
369    /// async work while holding the single write connection).
370    async fn atomic_unit(
371        &self,
372        op: AtomicUnitOp,
373    ) -> khive_storage::types::StorageResult<Box<dyn Any + Send>> {
374        let event_rows = Arc::new(AtomicEventRows::default());
375        let result = async {
376            if self.is_file_backed {
377                if self.pool.config().read_only {
378                    return Err(StorageError::Pool {
379                        operation: "atomic_unit".into(),
380                        message: "backend is read-only".into(),
381                    });
382                }
383                // Best-effort, same guard `writer()` uses: `Ok(None)` on flag-off;
384                // `Err(WriterTaskNoRuntime)` propagates loud rather than silently
385                // falling back to a competing connection from a sync caller. ADR-136
386                // D1 gate 3: `Ok(None)` under strict routing is ALSO a fail-closed
387                // error (queue was requested but unavailable), not just a degrade.
388                let handle = self.pool.writer_task_handle()?;
389                if handle.is_none() && self.pool.config().write_routing_strict {
390                    return Err(StorageError::Pool {
391                        operation: "atomic_unit".into(),
392                        message: "KHIVE_WRITE_ROUTING=strict but no writer-task handle is \
393                              available; refusing to fall back to a direct connection"
394                            .into(),
395                    });
396                }
397                if handle.is_none() && self.pool.write_queue_active() {
398                    crate::timeout_sink::emit_direct_route_violation(
399                        &crate::timeout_sink::db_label(&self.pool),
400                        crate::timeout_sink::Site::DirectRouteAtomicUnit,
401                    );
402                }
403                if let Some(writer_task) = handle {
404                    // Flag-on: ONE queued WriteRequest. `run_writer_task` already
405                    // has an open `BEGIN IMMEDIATE` on its dedicated connection
406                    // before this closure runs and issues `COMMIT`/`ROLLBACK`
407                    // after it returns — `op` must not (and, via `InlineWriter`,
408                    // does not) issue its own transaction control.
409                    let pending_event_rows = Arc::clone(&event_rows);
410                    return writer_task
411                        .send_bounded_at(
412                            move |conn| {
413                                let mut inline = InlineWriter {
414                                    event_rows: Some(Arc::clone(&pending_event_rows)),
415                                    conn: conn as *const rusqlite::Connection,
416                                };
417                                // Flatten: `block_on_sync` now returns `Result<F::Output,
418                                // StorageError>` (outer = "did the future actually
419                                // resolve on first poll", inner = the op's own
420                                // `StorageResult`) instead of panicking on `Pending`
421                                // (ADR-067 Component A). Either
422                                // error flows through this closure's ordinary `Err`
423                                // return, which `WriteRequest::execute_and_reply`
424                                // already turns into a normal ROLLBACK + error reply —
425                                // no panic, so the writer task survives.
426                                match block_on_sync(op(&mut inline)) {
427                                    Ok(inner) => inner,
428                                    Err(e) => Err(e),
429                                }
430                            },
431                            "atomic_unit",
432                        )
433                        .await;
434                }
435                // Flag-off (or no writer task available): manual
436                // BEGIN IMMEDIATE/COMMIT/ROLLBACK on a standalone writer —
437                // byte-for-byte the pre-ADR-067 shape.
438                //
439                // Contract: this acquire waits on the pool-wide one-permit
440                // writer-handle budget — the same permit a live `writer()` handle
441                // holds for its lifetime — so it times out with
442                // `StorageError::AdmissionTimeout` after `checkout_timeout` while a writer
443                // handle is checked out (and a `writer()` call times out while
444                // this unit runs). Callers must not hold a boxed writer handle
445                // across an `atomic_unit()` call on the same pool; drop the
446                // handle first. The `writer_task` branch above never touches this
447                // budget.
448                let handle_slot = acquire_handle_slot(
449                    self.pool.sql_bridge_writer_slots(),
450                    self.pool.config().checkout_timeout,
451                    "sql_bridge.atomic_unit_handle",
452                    SlotTimeoutClass::Admission,
453                )
454                .await?;
455                // The unit's lease spans its BEGIN, statements and COMMIT or
456                // ROLLBACK, as one queued request does on the write-queue path.
457                let unit_lease = acquire_unit_lease(Arc::clone(&self.pool)).await?;
458                let (conn, handle_slot) =
459                    open_standalone_writer_on_blocking(Arc::clone(&self.pool), handle_slot).await?;
460                let mut writer = SqliteWriter {
461                    observe_direct_errors: false,
462                    event_rows: Some(Arc::clone(&event_rows)),
463                    handle: Some(StandaloneHandle {
464                        conn,
465                        _retained_slot: Some(handle_slot),
466                        read_transaction_slot: None,
467                    }),
468                    writer_task: None,
469                    origin: self.pool.origin(),
470                    db: crate::timeout_sink::db_label(&self.pool),
471                    pool: Arc::clone(&self.pool),
472                    held_lease: unit_lease,
473                };
474                run_manual_atomic_unit(&mut writer, op, self.pool.origin())
475                    .await
476                    .inspect_err(|error| self.pool.record_direct_writer_error(error))
477            } else {
478                // Every statement shares one connection. Keep its guard through
479                // commit/rollback so other units and ordinary writes cannot join it.
480                let pool = Arc::clone(&self.pool);
481                let pending_event_rows = Arc::clone(&event_rows);
482                tokio::task::spawn_blocking(move || {
483                    let guard = pool.try_writer().map_err(|error: SqliteError| {
484                        StorageError::driver(StorageCapability::Sql, "atomic_unit", error)
485                    })?;
486                    let conn = guard.conn();
487                    if !conn.is_autocommit() {
488                        pool.retire_pooled_writer(conn);
489                        return Err(StorageError::writer_task_terminated(
490                            khive_storage::WriterTaskRequestState::SideEffectsUnknown,
491                        ));
492                    }
493                    if let Err(error) = conn.execute_batch("BEGIN IMMEDIATE") {
494                        if !conn.is_autocommit() {
495                            pool.retire_pooled_writer(conn);
496                            return Err(SqliteError::settlement_with_cause(
497                                &error,
498                                khive_storage::error::SqliteWriteStage::Begin,
499                            )
500                            .into_storage_error(StorageCapability::Sql, "atomic_unit.begin"));
501                        }
502                        return Err(map_write_boundary_err(
503                            error,
504                            "atomic_unit.begin",
505                            khive_storage::error::SqliteWriteStage::Begin,
506                        ))
507                        .inspect_err(|error| pool.record_direct_writer_error(error));
508                    }
509                    let _tx_handle = khive_storage::tx_registry::register_scoped(
510                        Some("atomic_unit".to_string()),
511                        pool.origin(),
512                    );
513                    let (result, terminal_state) = crate::writer_task::execute_wrapped_transaction(
514                        conn,
515                        "atomic_unit.commit",
516                        |conn| {
517                            let mut inline = InlineWriter {
518                                event_rows: Some(Arc::clone(&pending_event_rows)),
519                                conn: conn as *const rusqlite::Connection,
520                            };
521                            block_on_sync(op(&mut inline)).and_then(|result| result)
522                        },
523                    );
524                    if terminal_state.is_some() {
525                        pool.retire_pooled_writer(conn);
526                    }
527                    result.inspect_err(|error| pool.record_direct_writer_error(error))
528                })
529                .await
530                .map_err(|error| {
531                    StorageError::driver(StorageCapability::Sql, "atomic_unit", error)
532                })?
533            }
534        }
535        .await;
536        khive_storage::usage::account_event_write(
537            result.as_ref().map(|_| event_rows.committed_rows()),
538        );
539        result
540    }
541}