reddb_server/runtime/vector_turbo_kind.rs
1//! Foundation for `KIND vector.turbo` collections (PRD #668, issue #693).
2//!
3//! Mirrors `blockchain_kind` for the marker side of the contract:
4//! `red.collection.{name}.kind = "turbo"` is the durable signal that
5//! distinguishes a TurboQuant-backed vector collection from the legacy
6//! `vector` kind. Routing in `impl_ddl`, `create_vector`, and the
7//! vector-search executor reads `is_turbo` to branch.
8//!
9//! The per-collection runtime state (`TurboCollectionState`) owns the
10//! in-memory `TurboQuantIndex` and a `TurboExtent` when a pager is
11//! available, plus the codec dimension / metric inherited from the
12//! collection contract.
13
14use std::path::PathBuf;
15use std::sync::Arc;
16use std::time::Duration;
17
18use parking_lot::{Condvar, Mutex};
19
20use crate::storage::engine::distance::DistanceMetric;
21use crate::storage::engine::turboquant::extent::TurboExtent;
22use crate::storage::engine::turboquant::index::TurboQuantIndex;
23use crate::storage::engine::Pager;
24use crate::storage::unified::{EntityData, UnifiedStore};
25use crate::storage::EntityId;
26use reddb_file::{
27 read_turboquant_snapshot as read_snapshot, write_turboquant_snapshot as write_snapshot,
28 TurboQuantSnapshotError as SnapshotError,
29};
30use reddb_types::Value;
31
32/// Value stored under `red.collection.{name}.kind` for vector.turbo
33/// collections. Must be distinct from `blockchain_kind::CHAIN_KIND_TAG`
34/// and from any future kind marker.
35pub const TURBO_KIND_TAG: &str = "turbo";
36
37/// Deterministic codec seed shared by every turbo collection in this
38/// process. The seed feeds the rotation matrix + codebook; a fixed seed
39/// guarantees that re-encoding the same vector across restarts produces
40/// the same code, which is what makes lazy re-population from the
41/// persisted vector entities safe.
42pub const TURBO_CODEC_SEED: u64 = 0x7155_8807_FED4_2913;
43
44fn kind_key(collection: &str) -> String {
45 format!("red.collection.{collection}.kind")
46}
47
48/// Persist the `turbo` kind marker. Called once at collection creation.
49/// Idempotent against `IF NOT EXISTS`: re-stamping the same value is a
50/// no-op at the config-tree layer.
51pub fn mark_as_turbo(store: &UnifiedStore, collection: &str) {
52 store.set_config_tree(
53 &kind_key(collection),
54 &crate::serde_json::Value::String(TURBO_KIND_TAG.to_string()),
55 );
56}
57
58/// True if `mark_as_turbo` was ever called for this collection. Cheap
59/// (one config-tree read) and safe to call on every INSERT/SEARCH —
60/// the legacy `vector` path takes the `false` branch with no extra
61/// cost beyond the lookup.
62pub fn is_turbo(store: &UnifiedStore, collection: &str) -> bool {
63 match store.get_config(&kind_key(collection)) {
64 Some(Value::Text(s)) => s.as_ref() == TURBO_KIND_TAG,
65 _ => false,
66 }
67}
68
69/// Per-collection runtime state for a `vector.turbo` collection.
70///
71/// `index` is the in-memory TurboQuant index that owns the encoded
72/// codes + raw vectors; `extent` is the per-collection page-backed
73/// payload buffer when the store is in paged mode (None for in-memory
74/// runtimes). Both are wrapped in `Mutex` because INSERTs serialize on
75/// the per-collection state — the contention point lives here rather
76/// than on the global store lock.
77pub struct TurboCollectionState {
78 pub dim: usize,
79 pub metric: DistanceMetric,
80 pub index: Mutex<TurboQuantIndex>,
81 pub extent: Mutex<Option<TurboExtent>>,
82 /// Set once the lazy rebuild from persisted vector entities has
83 /// happened. Subsequent INSERT/SEARCH calls skip the scan.
84 populated: std::sync::atomic::AtomicBool,
85 /// Issue #673 — per-collection readiness flag. Flips to `true`
86 /// only after the background rebuild completes. SEARCH against a
87 /// not-yet-ready collection waits (with bounded timeout) or
88 /// returns a structured NOT_READY response.
89 ready: std::sync::atomic::AtomicBool,
90 /// Condvar-paired wait surface for SEARCH callers that want to
91 /// block (bounded) until ready instead of immediately failing.
92 ready_signal: Arc<(Mutex<bool>, Condvar)>,
93 /// `.tv` snapshot file path for this collection (#674). `None` when
94 /// the active `StorageLayout` opts out of snapshot files
95 /// (`Minimal` / embedded). When `Some`, the checkpoint cycle dumps
96 /// here and boot prefers loading from here over rebuilding from
97 /// the extent.
98 snapshot_path: Mutex<Option<PathBuf>>,
99 /// Async-barrier handle for the most-recently-spawned snapshot
100 /// dump worker (#674). The next checkpoint cycle joins the
101 /// previous worker before starting a new one — bounds backpressure
102 /// to at most one in-flight dump per collection.
103 prev_snapshot_join: Mutex<Option<std::thread::JoinHandle<()>>>,
104}
105
106impl TurboCollectionState {
107 pub fn new(dim: usize, metric: DistanceMetric, pager: Option<&Arc<Pager>>) -> Self {
108 let index = TurboQuantIndex::new(dim, TURBO_CODEC_SEED);
109 let extent = pager
110 .and_then(|p| TurboExtent::new(Arc::clone(p)).ok())
111 .map(Some)
112 .unwrap_or(None);
113 Self {
114 dim,
115 metric,
116 index: Mutex::new(index),
117 extent: Mutex::new(extent),
118 populated: std::sync::atomic::AtomicBool::new(false),
119 ready: std::sync::atomic::AtomicBool::new(false),
120 ready_signal: Arc::new((Mutex::new(false), Condvar::new())),
121 snapshot_path: Mutex::new(None),
122 prev_snapshot_join: Mutex::new(None),
123 }
124 }
125
126 /// Set (or clear) the `.tv` snapshot path for this collection.
127 /// Called by `RedDB::turbo_state` once the resolved
128 /// `TieredLayoutPaths` is known.
129 pub fn set_snapshot_path(&self, path: Option<PathBuf>) {
130 *self.snapshot_path.lock() = path;
131 }
132
133 /// Current `.tv` snapshot path, if any.
134 pub fn snapshot_path(&self) -> Option<PathBuf> {
135 self.snapshot_path.lock().clone()
136 }
137
138 /// Returns the current readiness flag. `true` means the
139 /// background rebuild (or lazy populate) has completed and the
140 /// in-memory index reflects every WAL-acked INSERT.
141 pub fn is_ready(&self) -> bool {
142 self.ready.load(std::sync::atomic::Ordering::Acquire)
143 }
144
145 /// Mark the collection as ready. Called at the end of
146 /// `ensure_populated` and from the background rebuild worker.
147 /// Wakes any SEARCH callers parked on `wait_until_ready`.
148 fn mark_ready(&self) {
149 self.ready.store(true, std::sync::atomic::Ordering::Release);
150 let (lock, cv) = &*self.ready_signal;
151 let mut flag = lock.lock();
152 *flag = true;
153 cv.notify_all();
154 }
155
156 /// Block the caller (with a bounded timeout) until the
157 /// collection becomes ready. Returns `true` if ready within the
158 /// timeout, `false` if the timeout fired. A zero-or-negative
159 /// timeout still does one fast-path check.
160 pub fn wait_until_ready(&self, timeout: Duration) -> bool {
161 if self.is_ready() {
162 return true;
163 }
164 if timeout.is_zero() {
165 return self.is_ready();
166 }
167 let (lock, cv) = &*self.ready_signal;
168 let mut flag = lock.lock();
169 if *flag {
170 return true;
171 }
172 let _ = cv.wait_for(&mut flag, timeout);
173 *flag
174 }
175
176 /// Lazily populate the in-memory index from any vector entities
177 /// already persisted in the collection, then drain any WAL-replayed
178 /// `VectorInsert` records captured at store-open time (issue #694).
179 ///
180 /// Boot-time recovery: the WAL `VectorInsert` records are the
181 /// authoritative source for vectors that may not have made it into
182 /// the entity manager's persisted state (e.g. a crash between WAL
183 /// fsync and the next paged flush). Replaying them in WAL order
184 /// under a fixed codec seed reconstructs the in-memory
185 /// `TurboQuantIndex` deterministically — including the
186 /// partial-block tail introduced by ADR 0024.
187 ///
188 /// Non-vector traffic does not block on this rebuild: the runtime
189 /// only takes this path on the first turbo INSERT/SEARCH after
190 /// boot. `#673` wires the per-collection `ready: bool` flag on
191 /// top of this hook to keep vector traffic from observing a
192 /// half-built index while the rebuild is in flight.
193 pub fn ensure_populated(
194 &self,
195 store: &UnifiedStore,
196 collection: &str,
197 ) -> Result<(), crate::storage::StoreError> {
198 use std::sync::atomic::Ordering;
199 if self.populated.load(Ordering::Acquire) {
200 return Ok(());
201 }
202 let mut index = self.index.lock();
203 // Double-checked: another writer may have populated while we
204 // were waiting for the lock.
205 if self.populated.load(Ordering::Acquire) {
206 return Ok(());
207 }
208 // Snapshot-first boot (#674). When a valid `.tv` exists at
209 // the layout-derived path, replay its `(id, vector)` pairs in
210 // stored order. The deterministic codec seed reproduces
211 // byte-identical block/lane placement, so the index ends up
212 // consistent with a from-scratch entity scan. Snapshots are purely a
213 // cache: any failure (missing, truncated, crc-bad, dim/seed
214 // drift) falls through to the legacy entity-scan path so
215 // boot still succeeds.
216 if let Some(path) = self.snapshot_path.lock().clone() {
217 if path.exists() {
218 match read_snapshot(&path, self.dim as u32, TURBO_CODEC_SEED) {
219 Ok(payload) => {
220 for (raw_id, vector) in payload.vectors {
221 index.insert(EntityId::new(raw_id), vector);
222 }
223 }
224 Err(err) => {
225 tracing::warn!(
226 target: "reddb::turbo::snapshot",
227 collection,
228 path = %path.display(),
229 error = %err,
230 "vector.turbo snapshot unusable; falling back to extent/WAL rebuild",
231 );
232 }
233 }
234 }
235 }
236
237 // A snapshot can lag a successfully checkpointed entity. Complete the
238 // projection from the durable base before discarding its WAL records.
239 if let Some(manager) = store.get_collection(collection) {
240 manager.try_for_each_entity(usize::MAX, |entity| {
241 if let EntityData::Vector(data) = &entity.data {
242 if data.dense.len() == self.dim {
243 index.insert(entity.id, data.dense.clone());
244 }
245 }
246 true
247 })?;
248 }
249 // Drain WAL-replayed VectorInsert records (#694). Apply in WAL
250 // order so the resulting block/lane placement matches the
251 // pre-restart state byte-for-byte. Duplicate ids (same vector
252 // also present in the entity manager) overwrite via
253 // `TurboQuantIndex::insert`'s id-replace branch, which is safe
254 // and idempotent because the codec seed is constant.
255 if let Some(records) = store.take_replayed_turbo_inserts(collection) {
256 for (raw_id, vector) in records {
257 if vector.len() == self.dim {
258 index.insert(EntityId::new(raw_id), vector);
259 }
260 }
261 }
262 self.populated.store(true, Ordering::Release);
263 self.mark_ready();
264 Ok(())
265 }
266
267 /// Capture the current in-memory index state and dump it to the
268 /// configured `.tv` path on a worker thread (#674). The next
269 /// caller (typically the next WAL checkpoint cycle) blocks on the
270 /// previous worker before starting a new one — bounded
271 /// backpressure of at most one dump in flight per collection. The
272 /// WAL checkpoint itself never waits for the snapshot fsync.
273 ///
274 /// No-op when `snapshot_path` is `None` (`StorageLayout::Minimal`
275 /// or embedded mode) — preserves the single-file portability
276 /// story.
277 pub fn dump_snapshot_async(self: &Arc<Self>, lsn: u64) {
278 let Some(path) = self.snapshot_path.lock().clone() else {
279 return;
280 };
281
282 // Async barrier: wait for any previous dump to finish before
283 // taking a fresh snapshot. Joining here (and not inside the
284 // new worker) keeps the work serialized per collection and
285 // ensures the on-disk file moves monotonically forward.
286 if let Some(prev) = self.prev_snapshot_join.lock().take() {
287 let _ = prev.join();
288 }
289
290 // Capture state under the index lock — the encode/decode
291 // path uses the same lock — then drop the lock and serialize
292 // off-thread so the checkpoint completion latency is not
293 // blocked on snapshot fsync (acceptance criterion #674.5).
294 let dim = self.dim as u32;
295 let captured: Vec<(u64, Vec<f32>)> = {
296 let guard = self.index.lock();
297 guard
298 .iter_persisted()
299 .map(|(id, v)| (id.raw(), v.to_vec()))
300 .collect()
301 };
302
303 let path_for_worker = path.clone();
304 let handle = std::thread::Builder::new()
305 .name("turbo-snapshot-dump".to_string())
306 .spawn(move || {
307 if let Some(parent) = path_for_worker.parent() {
308 let _ = std::fs::create_dir_all(parent);
309 }
310 if let Err(err) =
311 write_snapshot(&path_for_worker, dim, TURBO_CODEC_SEED, lsn, &captured)
312 {
313 tracing::warn!(
314 target: "reddb::turbo::snapshot",
315 path = %path_for_worker.display(),
316 error = %err,
317 "vector.turbo snapshot dump failed; cache will be rebuilt on next checkpoint",
318 );
319 }
320 })
321 .ok();
322
323 *self.prev_snapshot_join.lock() = handle;
324 }
325
326 /// Join the in-flight snapshot worker, if any. Used by `RedDB::Drop`
327 /// to make sure no `.tv` write outlives the runtime, and by tests
328 /// that want to assert the on-disk state synchronously.
329 pub fn wait_snapshot(&self) {
330 if let Some(prev) = self.prev_snapshot_join.lock().take() {
331 let _ = prev.join();
332 }
333 }
334
335 /// Surface `SnapshotError::Io` to callers that need it. Currently
336 /// unused outside of tests but kept on the public path so the
337 /// snapshot module stays an implementation detail of this state.
338 #[allow(dead_code)]
339 pub(crate) fn snapshot_error_is_fatal(err: &SnapshotError) -> bool {
340 matches!(err, SnapshotError::Io(_))
341 }
342
343 /// Background-rebuild hook (#694 / #673). Drives the same code
344 /// path as `ensure_populated`; safe to call from a worker thread.
345 /// On completion the per-collection readiness flag flips and any
346 /// SEARCH callers parked on `wait_until_ready` are woken.
347 pub fn background_rebuild(
348 &self,
349 store: &UnifiedStore,
350 collection: &str,
351 ) -> Result<(), crate::storage::StoreError> {
352 self.ensure_populated(store, collection)
353 }
354}
355
356/// Public hook (#673) — issued by `RedDB::turbo_state` the first
357/// time a turbo collection's state is materialised. Spawns a worker
358/// thread that drains the WAL replay buffer + persisted vector
359/// entities and flips the readiness flag. Non-vector traffic never
360/// blocks on this; vector SEARCH/INSERT against a not-yet-ready
361/// collection observes the flag via `is_ready` / `wait_until_ready`.
362pub fn spawn_background_rebuild(
363 store: Arc<UnifiedStore>,
364 collection: String,
365 state: Arc<TurboCollectionState>,
366) -> std::thread::JoinHandle<()> {
367 // Worker holds a *weak* handle to the store so it can detect
368 // shutdown when the upgrade fails. The runtime registers the
369 // returned `JoinHandle` and joins it in `RedDB::Drop` before
370 // releasing its own `Arc<UnifiedStore>`, which is what
371 // guarantees a clean restart on the same database path.
372 let store_weak = Arc::downgrade(&store);
373 drop(store);
374 std::thread::Builder::new()
375 .name(format!("turbo-rebuild-{collection}"))
376 .spawn(move || {
377 let Some(store) = store_weak.upgrade() else {
378 return;
379 };
380 if let Err(error) = state.background_rebuild(&store, &collection) {
381 tracing::warn!(collection, %error, "vector.turbo rebuild failed");
382 return;
383 }
384 store.set_config_tree(
385 &format!("red.collection.{collection}.vector.turbo.ready"),
386 &crate::serde_json::Value::Bool(true),
387 );
388 })
389 .expect("spawn turbo background rebuild thread")
390}
391
392/// Read the persisted readiness flag from the catalog. Used by
393/// admin/introspection paths that don't hold the runtime
394/// `TurboCollectionState` (e.g. SHOW COLLECTION metadata, cross-
395/// process tooling). The in-memory `TurboCollectionState::is_ready`
396/// is the authoritative live signal; this is the persisted shadow.
397pub fn ready_flag_from_catalog(store: &UnifiedStore, collection: &str) -> bool {
398 let key = format!("red.collection.{collection}.vector.turbo.ready");
399 match store.get_config(&key) {
400 Some(Value::Boolean(b)) => b,
401 _ => false,
402 }
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408 use crate::storage::unified::UnifiedStore;
409
410 #[test]
411 fn mark_and_detect_turbo_kind() {
412 let store = UnifiedStore::new();
413 assert!(!is_turbo(&store, "v"));
414 mark_as_turbo(&store, "v");
415 assert!(is_turbo(&store, "v"));
416 }
417}