Skip to main content

mkit_server/sql/
kv.rs

1//! `SqlKvStore`: the [`NamespaceStore`] contract over one `kv` table.
2
3use std::collections::HashMap;
4use std::fmt;
5use std::sync::atomic::{AtomicU32, Ordering};
6use std::sync::{Mutex, PoisonError};
7
8use super::{Capacity, Row, SqlConn, SqlError, SqlValue, TxFn, blob, count, schema};
9use crate::store::{
10    Batch, BatchOutcome, Cursor, Key, NamespaceStore, Partition, PartitionStats, Precondition,
11    ScanPage, StoreCapabilities, StoreError, StoreMaintenance, Value, Write, codec, keys,
12};
13
14/// Keys per `get_many` statement: one partition parameter plus this many
15/// keys stays far below [`super::MAX_BOUND_PARAMS`].
16pub const GET_MANY_CHUNK: usize = 64;
17
18/// How long a partition's [`NamespaceStore::stats`] is cached, ms.
19const STATS_TTL_MS: u64 = 60_000;
20/// Most partitions whose stats are cached at once.
21const STATS_CACHE_MAX: usize = 4096;
22
23pub(super) const GET: &str = "SELECT value FROM kv WHERE part = ?1 AND key = ?2";
24pub(super) const PUT: &str = "INSERT INTO kv (part, key, value) VALUES (?1, ?2, ?3) \
25     ON CONFLICT (part, key) DO UPDATE SET value = excluded.value";
26pub(super) const DELETE: &str = "DELETE FROM kv WHERE part = ?1 AND key = ?2";
27pub(super) const SCAN_FROM: &str = "SELECT key, value FROM kv \
28     WHERE part = ?1 AND key >= ?2 AND key < ?3 ORDER BY key LIMIT ?4";
29pub(super) const SCAN_AFTER: &str = "SELECT key, value FROM kv \
30     WHERE part = ?1 AND key > ?2 AND key < ?3 ORDER BY key LIMIT ?4";
31pub(super) const STATS: &str =
32    "SELECT COUNT(*), SUM(length(key) + length(value)) FROM kv WHERE part = ?1";
33/// Bounded first window over the existing timer index, including raw malformed keys.
34pub const TIMER_WINDOW_START: &str = "SELECT part, key FROM kv INDEXED BY kv_timers \
35     WHERE key >= x'7700' AND key < x'7701' ORDER BY key, part LIMIT ?1";
36/// Resume strictly after an index position; no aggregation or implicit wrap.
37// Keep the composite cursor first: SQLite otherwise chooses the fixed prefix
38// lower bound and filters every preceding timer instead of seeking the cursor.
39pub const TIMER_WINDOW_AFTER: &str = "SELECT part, key FROM kv INDEXED BY kv_timers \
40     WHERE (key, part) > (?1, ?2) AND key >= x'7700' AND key < x'7701' \
41     ORDER BY key, part LIMIT ?3";
42
43/// One raw timer-index row and the exclusive position for its successor.
44///
45/// Advancing by the raw key, including unregistered kinds and malformed timer
46/// keys, prevents bounded enumeration from restarting at the same unknown row.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct TimerCursor {
49    /// Raw timer key; parsing belongs to the driver, after accounting the row.
50    pub key: Key,
51    /// Logical partition sharing the physical SQL database.
52    pub partition: Partition,
53}
54
55pub(super) const PROBE: &str = "SELECT 1";
56
57/// The `get_many` statement for `n` keys (`?2` … `?{n+1}`).
58pub(super) fn get_many_sql(n: usize) -> String {
59    let marks: Vec<String> = (2..n + 2).map(|i| format!("?{i}")).collect();
60    format!(
61        "SELECT key, value FROM kv WHERE part = ?1 AND key IN ({})",
62        marks.join(", ")
63    )
64}
65
66/// The [`NamespaceStore`] contract (and [`StoreMaintenance`]) over any
67/// [`SqlConn`]: full capabilities, every batch one [`SqlConn::transaction`].
68///
69/// With a [`Capacity`], an ordinary batch holding a put returns
70/// [`StoreError::Full`] once the database uses [`Capacity::soft_limit`] bytes
71/// or more, checked inside its transaction. Delete-only batches and bounded,
72/// guarded relay-scan checkpoints, relay timer reschedules, and guarded timer
73/// retry moves may use the reserve above that limit. These timer exceptions
74/// preserve immediate relay rescheduling and persisted infrastructure backoff
75/// on a full shard.
76/// An engine `Full` on a delete-only batch is reported as
77/// [`StoreError::Unavailable`], never `Full`.
78///
79/// Every method is synchronous inside: its future completes on first poll.
80/// A host that drives a blocking engine should run each call on a
81/// blocking thread.
82pub struct SqlKvStore<C> {
83    conn: C,
84    schema_version: AtomicU32,
85    stats: Mutex<HashMap<Vec<u8>, (u64, PartitionStats)>>,
86    capacity: Option<Capacity>,
87}
88
89impl<C> fmt::Debug for SqlKvStore<C> {
90    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91        f.debug_struct("SqlKvStore")
92            .field(
93                "schema_version",
94                &self.schema_version.load(Ordering::Relaxed),
95            )
96            .field("capacity", &self.capacity)
97            .finish_non_exhaustive()
98    }
99}
100
101impl<C: SqlConn> SqlKvStore<C> {
102    /// A store over `conn`, after migrating its schema
103    /// ([`schema::migrate`]).
104    ///
105    /// # Errors
106    /// [`StoreError::Unsupported`] for a database with a newer schema than
107    /// this binary's; the engine's error otherwise.
108    pub fn open(conn: C) -> Result<Self, StoreError> {
109        let version = schema::migrate(&conn)?;
110        Ok(Self::from_checked_conn(conn, version))
111    }
112
113    /// Open an existing, matching schema without migrating or writing it.
114    /// Used by native export so a backup operation cannot mutate its source.
115    ///
116    /// # Errors
117    /// The existing schema must exactly match this binary's version.
118    pub fn open_existing(conn: C) -> Result<Self, StoreError> {
119        let version = schema::require_current(&conn)?;
120        Ok(Self::from_checked_conn(conn, version))
121    }
122
123    fn from_checked_conn(conn: C, version: u32) -> Self {
124        Self {
125            conn,
126            schema_version: AtomicU32::new(version),
127            stats: Mutex::default(),
128            capacity: None,
129        }
130    }
131
132    /// [`Self::open`], capped at `capacity`: the connection enforces the
133    /// hard cap ([`SqlConn::set_size_limit`]) and the store the soft limit.
134    ///
135    /// # Errors
136    /// As [`Self::open`].
137    pub fn open_with_capacity(conn: C, capacity: Capacity) -> Result<Self, StoreError> {
138        let mut store = Self::open(conn)?;
139        store.conn.set_size_limit(capacity.cap_bytes())?;
140        store.capacity = Some(capacity);
141        Ok(store)
142    }
143
144    /// Apply with a synchronous target-local extension inside the guarded transaction.
145    /// The extension reads local keys and appends writes only after all input guards hold.
146    ///
147    /// # Errors
148    /// Invalid batches, extension errors, or backend failures roll back every effect.
149    pub fn apply_extended<F>(
150        &self,
151        p: &Partition,
152        batch: Batch,
153        extend: F,
154    ) -> Result<BatchOutcome, StoreError>
155    where
156        F: FnOnce(
157                &dyn Fn(&Key) -> Result<Option<Value>, SqlError>,
158                &mut Batch,
159                u64,
160            ) -> Result<(), SqlError>
161            + 'static,
162    {
163        batch.validate(&self.capabilities())?;
164        let part = part(p)?;
165        let adds = batch.has_put();
166        let soft_limit = self.capacity.map(|c| c.soft_limit());
167        match self.conn.transaction(Box::new(move |conn| {
168            check_and_write_extended(&conn, &part, batch, soft_limit, extend)
169        })) {
170            Ok(outcome) => Ok(outcome),
171            Err(SqlError::Full) if !adds => Err(StoreError::unavailable(
172                "database full during a delete-only batch",
173            )),
174            Err(error) => Err(error.into()),
175        }
176    }
177
178    /// Read at most `limit` raw timer rows in `(key, partition)` index order.
179    ///
180    /// No row is fetched beyond the allowance, and several rows may name the
181    /// same partition. An empty window marks the end; the caller decides when
182    /// to wrap by passing `None`. The cursor need not still exist in storage.
183    ///
184    /// # Errors
185    /// Zero limit, failed queries, corrupt partition encodings or result columns.
186    pub fn timer_window(
187        &self,
188        after: Option<&TimerCursor>,
189        limit: u32,
190    ) -> Result<Vec<TimerCursor>, StoreError> {
191        if limit == 0 {
192            return Err(StoreError::Invalid(
193                "timer window limit must be positive".into(),
194            ));
195        }
196        let (sql, params) = match after {
197            None => (
198                TIMER_WINDOW_START,
199                vec![SqlValue::Integer(i64::from(limit))],
200            ),
201            Some(cursor) => (
202                TIMER_WINDOW_AFTER,
203                vec![
204                    SqlValue::Blob(cursor.key.as_bytes().to_vec()),
205                    SqlValue::Blob(cursor.partition.encode()?.to_vec()),
206                    SqlValue::Integer(i64::from(limit)),
207                ],
208            ),
209        };
210        self.conn
211            .query(sql, &params)?
212            .into_iter()
213            .map(|mut row| {
214                Ok(TimerCursor {
215                    partition: Partition::decode(&blob(&mut row, 0)?)?,
216                    key: Key::new(blob(&mut row, 1)?),
217                })
218            })
219            .collect()
220    }
221
222    /// The cap, if any.
223    #[must_use]
224    pub fn capacity(&self) -> Option<Capacity> {
225        self.capacity
226    }
227
228    /// The connection.
229    #[must_use]
230    pub fn conn(&self) -> &C {
231        &self.conn
232    }
233
234    /// Forget cached [`NamespaceStore::stats`], so the next call reads the
235    /// table.
236    pub fn clear_stats_cache(&self) {
237        self.stats
238            .lock()
239            .unwrap_or_else(PoisonError::into_inner)
240            .clear();
241    }
242}
243
244fn part(p: &Partition) -> Result<SqlValue, StoreError> {
245    Ok(SqlValue::Blob(p.encode()?.to_vec()))
246}
247
248fn key_param(key: &Key) -> SqlValue {
249    SqlValue::Blob(key.as_bytes().to_vec())
250}
251
252fn read<C: SqlConn>(conn: &C, part: &SqlValue, key: &Key) -> Result<Option<Value>, SqlError> {
253    let mut rows = conn.query(GET, &[part.clone(), key_param(key)])?;
254    rows.first_mut()
255        .map(|row| blob(row, 0).map(Value::new))
256        .transpose()
257}
258
259/// The transaction body of `apply`: read the backend clock once, check the
260/// preconditions in order, refuse a batch with a put at the soft limit,
261/// then write.
262fn check_and_write<C: SqlConn>(
263    conn: &C,
264    part: &SqlValue,
265    batch: Batch,
266    soft_limit: Option<u64>,
267) -> Result<BatchOutcome, SqlError> {
268    check_and_write_extended(conn, part, batch, soft_limit, |_, _, _| Ok(()))
269}
270
271fn check_and_write_extended<C: SqlConn, F>(
272    conn: &C,
273    part: &SqlValue,
274    mut batch: Batch,
275    soft_limit: Option<u64>,
276    extend: F,
277) -> Result<BatchOutcome, SqlError>
278where
279    F: FnOnce(
280        &dyn Fn(&Key) -> Result<Option<Value>, SqlError>,
281        &mut Batch,
282        u64,
283    ) -> Result<(), SqlError>,
284{
285    // Rule 8: the backend's own clock, read inside the transaction.
286    let now = conn.now_ms();
287    for (index, pre) in batch.preconditions.iter().enumerate() {
288        let (holds, observed) = match pre {
289            Precondition::NotAfter(deadline) if now > *deadline => {
290                return Ok(BatchOutcome::DeadlinePassed { backend_now: now });
291            }
292            Precondition::NotAfter(_) => continue,
293            Precondition::Absent(key) => {
294                let current = read(conn, part, key)?;
295                (current.is_none(), current)
296            }
297            Precondition::Present(key) => (read(conn, part, key)?.is_some(), None),
298            Precondition::Equals(key, value) => {
299                let current = read(conn, part, key)?;
300                (current.as_ref() == Some(value), current)
301            }
302        };
303        if !holds {
304            return Ok(BatchOutcome::PreconditionFailed { index, observed });
305        }
306    }
307    extend(&|key| read(conn, part, key), &mut batch, now)?;
308    batch
309        .validate(&StoreCapabilities::full())
310        .map_err(|_| SqlError::Corrupt("extended batch exceeds limits"))?;
311    if let Some(limit) = soft_limit
312        && batch.has_put()
313        && !is_relay_scan_checkpoint(&batch)
314        && !is_relay_timer_reschedule(&batch)
315        && !is_timer_retry_move(&batch)
316        && conn.size_bytes()? >= limit
317    {
318        return Err(SqlError::Full);
319    }
320    for write in batch.writes {
321        match write {
322            Write::Put(key, value) => {
323                let value = SqlValue::Blob(value.into_bytes().into());
324                conn.exec(PUT, &[part.clone(), key_param(&key), value])?
325            }
326            Write::Delete(key) => conn.exec(DELETE, &[part.clone(), key_param(&key)])?,
327        };
328    }
329    Ok(BatchOutcome::Committed)
330}
331
332// The SQL reserve also belongs to source cleanup. Relay scan progress is a
333// bounded control row, guarded by its prior value and committed with only
334// relay-row deletions. Exclude exactly that shape from the soft put cutoff;
335// malformed or unrelated writes still receive Full. The hard engine limit
336// remains enforced by SqlConn for every batch.
337fn is_relay_scan_checkpoint(batch: &Batch) -> bool {
338    let scan_key = keys::relay_scan();
339    let guarded = batch.preconditions.iter().any(|pre| {
340        matches!(pre, Precondition::Absent(key) | Precondition::Equals(key, _) if key == &scan_key)
341    });
342    let mut scan_puts = 0;
343    let only_cleanup = batch.writes.iter().all(|write| match write {
344        Write::Put(key, value) if key == &scan_key => {
345            scan_puts += 1;
346            codec::decode_relay_scan(value).is_ok()
347        }
348        Write::Delete(key) => matches!(keys::parse(key), Some(keys::ParsedKey::Relay(_))),
349        Write::Put(..) => false,
350    });
351    guarded && only_cleanup && scan_puts == 1
352}
353
354// Moving a relay timer after progress on a full source keeps its next fire
355// immediate instead of taking the runner's 5-second retry backoff. The
356// guarded old row is deleted before the empty replacement; this exception
357// cannot create another timer or write data.
358pub(super) fn is_relay_timer_reschedule(batch: &Batch) -> bool {
359    let (
360        [
361            Precondition::Equals(old_key, old_value),
362            Precondition::Absent(absent),
363        ],
364        [Write::Delete(deleted), Write::Put(new_key, new_value)],
365    ) = (batch.preconditions.as_slice(), batch.writes.as_slice())
366    else {
367        return false;
368    };
369    if old_key != deleted
370        || absent != new_key
371        || !old_value.as_bytes().is_empty()
372        || !new_value.as_bytes().is_empty()
373    {
374        return false;
375    }
376    let (
377        Some(keys::ParsedKey::Timer {
378            due_at_ms: old_due,
379            kind: old_kind,
380            reference: old_ref,
381        }),
382        Some(keys::ParsedKey::Timer {
383            due_at_ms: new_due,
384            kind: new_kind,
385            reference: new_ref,
386        }),
387    ) = (keys::parse(old_key), keys::parse(new_key))
388    else {
389        return false;
390    };
391    old_kind == crate::timers::registry::kinds::RELAY.get()
392        && new_kind == old_kind
393        && old_ref == new_ref
394        && new_due > old_due
395}
396
397// A guarded infrastructure retry only relocates one opaque timer payload.
398// The destination guard prevents collision, including unknown timer kinds.
399pub(super) fn is_timer_retry_move(batch: &Batch) -> bool {
400    let (
401        [
402            Precondition::Equals(old_key, old_value),
403            Precondition::Absent(absent),
404        ],
405        [Write::Delete(deleted), Write::Put(new_key, new_value)],
406    ) = (batch.preconditions.as_slice(), batch.writes.as_slice())
407    else {
408        return false;
409    };
410    if old_key != deleted || absent != new_key || old_value != new_value {
411        return false;
412    }
413    let (
414        Some(keys::ParsedKey::Timer {
415            due_at_ms: old_due,
416            kind: old_kind,
417            reference: old_ref,
418        }),
419        Some(keys::ParsedKey::Timer {
420            due_at_ms: new_due,
421            kind: new_kind,
422            reference: new_ref,
423        }),
424        Some((old_original, old_attempt)),
425        Some((new_original, new_attempt)),
426    ) = (
427        keys::parse(old_key),
428        keys::parse(new_key),
429        keys::timer_retry_state(old_key),
430        keys::timer_retry_state(new_key),
431    )
432    else {
433        return false;
434    };
435    new_due > old_due
436        && new_kind == old_kind
437        && new_ref == old_ref
438        && new_original == old_original
439        && new_attempt
440            == old_attempt
441                .saturating_add(1)
442                .min(keys::MAX_TIMER_RETRY_ATTEMPT)
443}
444
445fn entry(mut row: Row) -> Result<(Key, Value), SqlError> {
446    Ok((Key::new(blob(&mut row, 0)?), Value::new(blob(&mut row, 1)?)))
447}
448
449impl<C: SqlConn> NamespaceStore for SqlKvStore<C> {
450    fn capabilities(&self) -> StoreCapabilities {
451        StoreCapabilities::full()
452    }
453
454    async fn get(&self, p: &Partition, key: &Key) -> Result<Option<Value>, StoreError> {
455        Ok(read(&self.conn, &part(p)?, key)?)
456    }
457
458    async fn get_many(
459        &self,
460        p: &Partition,
461        keys: &[Key],
462    ) -> Result<Vec<Option<Value>>, StoreError> {
463        let part = part(p)?;
464        let mut found: HashMap<Vec<u8>, Value> = HashMap::new();
465        for chunk in keys.chunks(GET_MANY_CHUNK) {
466            let params: Vec<SqlValue> = core::iter::once(part.clone())
467                .chain(chunk.iter().map(key_param))
468                .collect();
469            for row in self.conn.query(&get_many_sql(chunk.len()), &params)? {
470                let (key, value) = entry(row)?;
471                found.insert(key.into_bytes().into(), value);
472            }
473        }
474        Ok(keys
475            .iter()
476            .map(|key| found.get(key.as_bytes()).cloned())
477            .collect())
478    }
479
480    async fn scan(
481        &self,
482        p: &Partition,
483        start: &Key,
484        end: &Key,
485        after: Option<&Cursor>,
486        limit: u32,
487    ) -> Result<ScanPage, StoreError> {
488        if limit == 0 {
489            return Err(StoreError::Invalid("scan limit must be at least 1".into()));
490        }
491        let (sql, lower) = match after {
492            None => (SCAN_FROM, start.as_bytes()),
493            // Every cursor this range returns is one of its keys.
494            Some(c) if start.as_bytes() <= c.as_bytes() && c.as_bytes() < end.as_bytes() => {
495                (SCAN_AFTER, c.as_bytes())
496            }
497            Some(_) => {
498                return Err(StoreError::Invalid(
499                    "scan cursor outside the scanned range".into(),
500                ));
501            }
502        };
503        if lower >= end.as_bytes() {
504            return Ok(ScanPage::default());
505        }
506        let params = [
507            part(p)?,
508            SqlValue::Blob(lower.to_vec()),
509            key_param(end),
510            SqlValue::Integer(i64::from(limit) + 1),
511        ];
512        let mut entries = self
513            .conn
514            .query(sql, &params)?
515            .into_iter()
516            .map(entry)
517            .collect::<Result<Vec<_>, _>>()?;
518        let want = usize::try_from(limit).unwrap_or(usize::MAX);
519        let next = (entries.len() > want).then(|| {
520            entries.truncate(want);
521            Cursor::new(entries[want - 1].0.clone().into_bytes())
522        });
523        Ok(ScanPage { entries, next })
524    }
525
526    async fn apply(&self, p: &Partition, batch: Batch) -> Result<BatchOutcome, StoreError> {
527        batch.validate(&self.capabilities())?;
528        let part = part(p)?;
529        let adds = batch.has_put();
530        let soft_limit = self.capacity.map(|c| c.soft_limit());
531        // The batch and the partition move into the owned transaction body.
532        let body: TxFn<C, BatchOutcome> =
533            Box::new(move |conn: C| check_and_write(&conn, &part, batch, soft_limit));
534        match self.conn.transaction(body) {
535            Ok(outcome) => Ok(outcome),
536            // Rule 7: a delete-only batch never reports `Full`. Reaching
537            // the engine limit here means the reserve was too small; the
538            // batch rolled back.
539            Err(SqlError::Full) if !adds => Err(StoreError::unavailable(
540                "database full during a delete-only batch",
541            )),
542            Err(e) => Err(e.into()),
543        }
544    }
545
546    async fn stats(&self, p: &Partition) -> Result<PartitionStats, StoreError> {
547        let name = p.encode()?.to_vec();
548        let now = self.conn.now_ms();
549        let fresh = |at: u64| at <= now && now - at < STATS_TTL_MS;
550        {
551            let cache = self.stats.lock().unwrap_or_else(PoisonError::into_inner);
552            if let Some((at, stats)) = cache.get(&name)
553                && fresh(*at)
554            {
555                return Ok(*stats);
556            }
557        }
558        let rows = self.conn.query(STATS, &[SqlValue::Blob(name.clone())])?;
559        let row = rows
560            .first()
561            .ok_or(SqlError::Corrupt("stats returned no row"))?;
562        let stats = PartitionStats {
563            bytes: count(row, 1)?,
564            keys: Some(count(row, 0)?),
565        };
566        let mut cache = self.stats.lock().unwrap_or_else(PoisonError::into_inner);
567        if cache.len() >= STATS_CACHE_MAX {
568            cache.retain(|_, (at, _)| fresh(*at));
569            if cache.len() >= STATS_CACHE_MAX {
570                cache.clear();
571            }
572        }
573        cache.insert(name, (now, stats));
574        Ok(stats)
575    }
576
577    async fn probe(&self) -> Result<(), StoreError> {
578        self.conn.query(PROBE, &[])?;
579        Ok(())
580    }
581}
582
583impl<C: SqlConn> StoreMaintenance for SqlKvStore<C> {
584    fn layout_version(&self) -> u32 {
585        self.schema_version.load(Ordering::Relaxed)
586    }
587
588    async fn migrate(&self) -> Result<u32, StoreError> {
589        let version = schema::migrate(&self.conn)?;
590        self.schema_version.store(version, Ordering::Relaxed);
591        Ok(version)
592    }
593
594    /// The connection's backup ([`SqlConn::backup_to`]):
595    /// [`StoreError::Unsupported`] unless the backend has one.
596    async fn backup_to(&self, dest: &str) -> Result<(), StoreError> {
597        self.conn.backup_to(dest)
598    }
599}