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}