Skip to main content

vecdb_core/storage/
metadata.rs

1use std::path::{Path, PathBuf};
2use std::time::{SystemTime, UNIX_EPOCH};
3
4use chrono::DateTime;
5use r2d2::Pool;
6use r2d2_sqlite::SqliteConnectionManager;
7use rusqlite::params;
8
9use crate::errors::{Result, VecDbError};
10use crate::types::{CollectionConfig, VectorRecord};
11
12// Schema: DDL only — PRAGMAs are applied per-connection via the pool's `with_init`.
13const SCHEMA: &str = r#"
14CREATE TABLE IF NOT EXISTS vectors (
15    id          TEXT PRIMARY KEY NOT NULL,
16    mmap_index  INTEGER NOT NULL,
17    payload     TEXT NOT NULL DEFAULT '{}',
18    text        TEXT,
19    created_at  INTEGER NOT NULL,
20    updated_at  INTEGER NOT NULL,
21    deleted     INTEGER NOT NULL DEFAULT 0
22);
23
24CREATE INDEX IF NOT EXISTS idx_vectors_deleted ON vectors(deleted);
25CREATE INDEX IF NOT EXISTS idx_vectors_created_at ON vectors(created_at);
26
27CREATE TABLE IF NOT EXISTS collections (
28    name        TEXT PRIMARY KEY NOT NULL,
29    config      TEXT NOT NULL,
30    created_at  INTEGER NOT NULL
31);
32"#;
33
34fn now_unix() -> i64 {
35    SystemTime::now()
36        .duration_since(UNIX_EPOCH)
37        .unwrap_or_default()
38        .as_secs() as i64
39}
40
41fn ts_to_dt(secs: i64) -> DateTime<chrono::Utc> {
42    DateTime::from_timestamp(secs, 0).unwrap_or_else(chrono::Utc::now)
43}
44
45fn build_pool(path: &Path, max_size: u32) -> Result<Pool<SqliteConnectionManager>> {
46    let manager = SqliteConnectionManager::file(path).with_init(|conn| {
47        conn.execute_batch(
48            "PRAGMA journal_mode=WAL; \
49             PRAGMA busy_timeout=5000; \
50             PRAGMA synchronous=NORMAL; \
51             PRAGMA foreign_keys=ON;",
52        )
53    });
54    r2d2::Pool::builder()
55        .max_size(max_size)
56        .build(manager)
57        .map_err(|e| VecDbError::StorageError(format!("connection pool: {e}")))
58}
59
60#[derive(Clone)]
61pub struct MetadataStore {
62    pool: Pool<SqliteConnectionManager>,
63    pub path: PathBuf,
64}
65
66impl MetadataStore {
67    pub fn open(path: &Path) -> Result<Self> {
68        Self::open_with_pool_size(path, 8)
69    }
70
71    pub fn open_with_pool_size(path: &Path, max_size: u32) -> Result<Self> {
72        let pool = build_pool(path, max_size)?;
73        let conn = pool
74            .get()
75            .map_err(|e| VecDbError::StorageError(format!("pool init: {e}")))?;
76        conn.execute_batch(SCHEMA)?;
77        drop(conn);
78        Ok(Self {
79            pool,
80            path: path.to_path_buf(),
81        })
82    }
83
84    fn conn(&self) -> Result<r2d2::PooledConnection<SqliteConnectionManager>> {
85        self.pool
86            .get()
87            .map_err(|e| VecDbError::StorageError(format!("pool get: {e}")))
88    }
89
90    pub fn upsert(&self, id: &str, mmap_index: usize, record: &VectorRecord) -> Result<()> {
91        let payload = serde_json::to_string(&record.payload)
92            .map_err(|e| VecDbError::SerializationError(e.to_string()))?;
93        let now = now_unix();
94        self.conn()?.execute(
95            "INSERT OR REPLACE INTO vectors \
96             (id, mmap_index, payload, text, created_at, updated_at, deleted) \
97             VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0)",
98            params![id, mmap_index as i64, payload, record.text, now, now],
99        )?;
100        Ok(())
101    }
102
103    /// Insert/replace many records in a single transaction (bulk load).
104    pub fn upsert_batch(&self, items: &[(String, usize, &VectorRecord)]) -> Result<()> {
105        let mut conn = self.conn()?;
106        let now = now_unix();
107        let tx = conn.transaction()?;
108        {
109            let mut stmt = tx.prepare(
110                "INSERT OR REPLACE INTO vectors \
111                 (id, mmap_index, payload, text, created_at, updated_at, deleted) \
112                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0)",
113            )?;
114            for (id, mmap_index, record) in items {
115                let payload = serde_json::to_string(&record.payload)
116                    .map_err(|e| VecDbError::SerializationError(e.to_string()))?;
117                stmt.execute(params![
118                    id,
119                    *mmap_index as i64,
120                    payload,
121                    record.text,
122                    now,
123                    now
124                ])?;
125            }
126        }
127        tx.commit()?;
128        Ok(())
129    }
130
131    pub fn get(&self, id: &str) -> Result<(usize, VectorRecord)> {
132        let conn = self.conn()?;
133        let row = conn.query_row(
134            "SELECT id, mmap_index, payload, text, created_at, updated_at \
135             FROM vectors WHERE id = ?1 AND deleted = 0",
136            params![id],
137            |row| {
138                Ok((
139                    row.get::<_, String>(0)?,
140                    row.get::<_, i64>(1)?,
141                    row.get::<_, String>(2)?,
142                    row.get::<_, Option<String>>(3)?,
143                    row.get::<_, i64>(4)?,
144                    row.get::<_, i64>(5)?,
145                ))
146            },
147        );
148
149        match row {
150            Ok((rid, mmap_idx, payload_str, text, created_ts, updated_ts)) => {
151                let payload = serde_json::from_str(&payload_str)
152                    .map_err(|e| VecDbError::SerializationError(e.to_string()))?;
153                let record = VectorRecord {
154                    id: rid,
155                    vector: vec![],
156                    payload,
157                    text,
158                    created_at: ts_to_dt(created_ts),
159                    updated_at: ts_to_dt(updated_ts),
160                };
161                Ok((mmap_idx as usize, record))
162            }
163            Err(rusqlite::Error::QueryReturnedNoRows) => {
164                Err(VecDbError::NotFound { id: id.to_string() })
165            }
166            Err(e) => Err(VecDbError::SqliteError(e)),
167        }
168    }
169
170    pub fn delete(&self, id: &str) -> Result<()> {
171        let now = now_unix();
172        let rows = self.conn()?.execute(
173            "UPDATE vectors SET deleted = 1, updated_at = ?1 WHERE id = ?2 AND deleted = 0",
174            params![now, id],
175        )?;
176        if rows == 0 {
177            return Err(VecDbError::NotFound { id: id.to_string() });
178        }
179        Ok(())
180    }
181
182    pub fn list_active(&self) -> Result<Vec<(String, usize)>> {
183        let conn = self.conn()?;
184        let mut stmt = conn.prepare(
185            "SELECT id, mmap_index FROM vectors WHERE deleted = 0 ORDER BY mmap_index ASC",
186        )?;
187        let rows = stmt
188            .query_map([], |row| {
189                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize))
190            })?
191            .collect::<std::result::Result<Vec<_>, _>>()?;
192        Ok(rows)
193    }
194
195    pub fn count_active(&self) -> Result<usize> {
196        let n: i64 = self.conn()?.query_row(
197            "SELECT COUNT(*) FROM vectors WHERE deleted = 0",
198            [],
199            |row| row.get(0),
200        )?;
201        Ok(n as usize)
202    }
203
204    pub fn save_collection(&self, config: &CollectionConfig) -> Result<()> {
205        let json = serde_json::to_string(config)
206            .map_err(|e| VecDbError::SerializationError(e.to_string()))?;
207        let now = now_unix();
208        self.conn()?.execute(
209            "INSERT OR REPLACE INTO collections (name, config, created_at) VALUES (?1, ?2, ?3)",
210            params![config.name, json, now],
211        )?;
212        Ok(())
213    }
214
215    pub fn load_collection(&self, name: &str) -> Result<CollectionConfig> {
216        let conn = self.conn()?;
217        match conn.query_row(
218            "SELECT config FROM collections WHERE name = ?1",
219            params![name],
220            |row| row.get::<_, String>(0),
221        ) {
222            Ok(json) => serde_json::from_str(&json)
223                .map_err(|e| VecDbError::SerializationError(e.to_string())),
224            Err(rusqlite::Error::QueryReturnedNoRows) => {
225                Err(VecDbError::CollectionNotFound(name.to_string()))
226            }
227            Err(e) => Err(VecDbError::SqliteError(e)),
228        }
229    }
230
231    /// Ensure a SQLite expression index exists for each declared payload field,
232    /// so selective filters on them are served from the index. Idempotent.
233    pub fn ensure_payload_indexes(&self, fields: &[String]) -> Result<()> {
234        let conn = self.conn()?;
235        for f in fields {
236            if !is_safe_json_path(f) {
237                continue;
238            }
239            let idx_name = format!("idx_pf_{}", f.replace('.', "_"));
240            let sql = format!(
241                "CREATE INDEX IF NOT EXISTS {idx_name} ON vectors (json_extract(payload, '$.{f}'))"
242            );
243            conn.execute(&sql, [])?;
244        }
245        Ok(())
246    }
247
248    /// Return ids of all active records whose payload matches `filter`.
249    pub fn filter_ids(
250        &self,
251        filter: &serde_json::Value,
252        indexed: &[String],
253    ) -> Result<Vec<crate::types::VectorId>> {
254        Ok(self
255            .filter_ids_indexed(filter, indexed)?
256            .into_iter()
257            .map(|(id, _)| id)
258            .collect())
259    }
260
261    /// Like [`filter_ids`], but also returns each match's `mmap_index` so the
262    /// caller can fetch its vector directly for a filtered brute-force scan
263    /// (predicate pushdown: candidates come from the filter, not the ANN index).
264    ///
265    /// Fast path: the predicate is pushed into SQLite via `json_extract`, so the
266    /// engine returns only referenced scalars and we never full-parse the 95%+
267    /// of payloads a selective filter excludes. Falls back to a full in-process
268    /// scan for filter shapes the pushdown can't represent identically.
269    pub fn filter_ids_indexed(
270        &self,
271        filter: &serde_json::Value,
272        indexed: &[String],
273    ) -> Result<Vec<(crate::types::VectorId, usize)>> {
274        if let Some(rows) = self.filter_pushdown(filter, indexed)? {
275            return Ok(rows);
276        }
277        self.filter_scan(filter)
278    }
279
280    /// SQL-pushdown fast path. Returns `None` (caller falls back) when the
281    /// filter shape can't be translated with identical semantics.
282    fn filter_pushdown(
283        &self,
284        filter: &serde_json::Value,
285        indexed: &[String],
286    ) -> Result<Option<Vec<(crate::types::VectorId, usize)>>> {
287        use crate::planner::executor::{apply_filter_with, collect_filter_fields};
288
289        let indexed_set: std::collections::HashSet<&str> =
290            indexed.iter().map(|s| s.as_str()).collect();
291
292        let mut fields = std::collections::BTreeSet::new();
293        if !collect_filter_fields(filter, &mut fields) || filter_contains_bool(filter) {
294            return Ok(None);
295        }
296        // Fields extracted via json_extract for the in-process re-eval, in a
297        // stable order (excluding "id", which comes from its own column). Bail
298        // if any field name can't be safely embedded in a JSON path.
299        let mut extracted: Vec<String> = Vec::new();
300        for f in &fields {
301            if f == "id" {
302                continue;
303            }
304            if !is_safe_json_path(f) {
305                return Ok(None);
306            }
307            extracted.push(f.clone());
308        }
309
310        // Build a SQL predicate that is a *superset* of the exact filter (it may
311        // over-match, e.g. non-numeric coerced by CAST, case-insensitive LIKE),
312        // so SQLite returns only the candidate rows and the Rust re-eval below
313        // trims to the exact set. If we can't build one, fall back.
314        let mut params: Vec<rusqlite::types::Value> = Vec::new();
315        let Some(where_super) = filter_to_sql_superset(filter, &mut params, &indexed_set) else {
316            return Ok(None);
317        };
318
319        let mut select = String::from("SELECT id, mmap_index");
320        for f in &extracted {
321            select.push_str(&format!(", json_extract(payload, '$.{f}')"));
322        }
323        select.push_str(" FROM vectors WHERE deleted = 0 AND ");
324        select.push_str(&where_super);
325
326        let conn = self.conn()?;
327        let mut stmt = match conn.prepare(&select) {
328            Ok(s) => s,
329            Err(_) => return Ok(None), // e.g. JSON1 unavailable → fall back
330        };
331        let ncols = extracted.len();
332        let rows_res = stmt.query_map(rusqlite::params_from_iter(params.iter()), |row| {
333            let id: String = row.get(0)?;
334            let idx: i64 = row.get(1)?;
335            let mut vals: Vec<rusqlite::types::Value> = Vec::with_capacity(ncols);
336            for c in 0..ncols {
337                vals.push(row.get::<usize, rusqlite::types::Value>(2 + c)?);
338            }
339            Ok((id, idx as usize, vals))
340        });
341        let rows: Vec<(String, usize, Vec<rusqlite::types::Value>)> = match rows_res {
342            Ok(iter) => iter.collect::<std::result::Result<_, _>>()?,
343            Err(_) => return Ok(None),
344        };
345
346        let mut out = Vec::new();
347        for (id, idx, vals) in rows {
348            let resolve = |field: &str| -> Option<serde_json::Value> {
349                if field == "id" {
350                    return Some(serde_json::Value::String(id.clone()));
351                }
352                let pos = extracted.iter().position(|f| f == field)?;
353                sqlite_to_json(&vals[pos])
354            };
355            if apply_filter_with(filter, &resolve) {
356                out.push((id, idx));
357            }
358        }
359        Ok(Some(out))
360    }
361
362    /// Fallback: load active rows and evaluate the filter in-process.
363    fn filter_scan(
364        &self,
365        filter: &serde_json::Value,
366    ) -> Result<Vec<(crate::types::VectorId, usize)>> {
367        use crate::planner::executor::apply_json_filter;
368        use crate::types::SearchResult;
369
370        let conn = self.conn()?;
371        let mut stmt =
372            conn.prepare("SELECT id, mmap_index, payload, text FROM vectors WHERE deleted = 0")?;
373        let rows: Vec<(String, i64, String, Option<String>)> = stmt
374            .query_map([], |row| {
375                Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
376            })?
377            .collect::<std::result::Result<_, _>>()?;
378
379        let mut out = Vec::new();
380        for (id, idx, payload_str, text) in rows {
381            let payload: serde_json::Value = serde_json::from_str(&payload_str)
382                .unwrap_or_else(|_| serde_json::Value::Object(Default::default()));
383            let fake = SearchResult {
384                id: id.clone(),
385                score: 0.0,
386                dense_score: None,
387                sparse_score: None,
388                payload,
389                text,
390            };
391            if apply_json_filter(&fake, filter) {
392                out.push((id, idx as usize));
393            }
394        }
395        Ok(out)
396    }
397
398    pub fn list_collections(&self) -> Result<Vec<CollectionConfig>> {
399        let conn = self.conn()?;
400        let mut stmt = conn.prepare("SELECT config FROM collections ORDER BY created_at ASC")?;
401        let rows = stmt
402            .query_map([], |row| row.get::<_, String>(0))?
403            .collect::<std::result::Result<Vec<_>, _>>()?;
404        rows.iter()
405            .map(|json| {
406                serde_json::from_str(json)
407                    .map_err(|e| VecDbError::SerializationError(e.to_string()))
408            })
409            .collect()
410    }
411}
412
413/// A field name is safe to embed in a `'$.<field>'` JSON path when it contains
414/// only characters that can't break out of the quoted path literal.
415fn is_safe_json_path(field: &str) -> bool {
416    !field.is_empty()
417        && field
418            .chars()
419            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
420}
421
422/// `json_extract` maps a JSON boolean to SQLite integer 0/1, which would not
423/// compare equal to a `Bool` filter value the way the in-process path does — so
424/// any bool in the filter forces the fallback.
425fn filter_contains_bool(filter: &serde_json::Value) -> bool {
426    match filter {
427        serde_json::Value::Bool(_) => true,
428        serde_json::Value::Array(a) => a.iter().any(filter_contains_bool),
429        serde_json::Value::Object(o) => o.values().any(filter_contains_bool),
430        _ => false,
431    }
432}
433
434/// SQL expression that reads a field: the `id` column, or `json_extract` of a
435/// (safe) payload path. `None` if the path is unsafe.
436fn field_sql_expr(field: &str) -> Option<String> {
437    if field == "id" {
438        return Some("id".to_string());
439    }
440    if !is_safe_json_path(field) {
441        return None;
442    }
443    Some(format!("json_extract(payload, '$.{field}')"))
444}
445
446/// Text and numeric forms of a scalar filter value, for the equality superset
447/// (`CAST(... AS TEXT) = text OR CAST(... AS REAL) = real`). Numeric form is
448/// `Null` when the value isn't numeric.
449fn value_forms(v: &serde_json::Value) -> Option<(rusqlite::types::Value, rusqlite::types::Value)> {
450    use rusqlite::types::Value as V;
451    match v {
452        serde_json::Value::String(s) => {
453            let real = s.parse::<f64>().ok().map(V::Real).unwrap_or(V::Null);
454            Some((V::Text(s.clone()), real))
455        }
456        serde_json::Value::Number(n) => {
457            let f = n.as_f64()?;
458            Some((V::Text(n.to_string()), V::Real(f)))
459        }
460        _ => None,
461    }
462}
463
464fn value_as_real(v: &serde_json::Value) -> Option<f64> {
465    match v {
466        serde_json::Value::Number(n) => n.as_f64(),
467        serde_json::Value::String(s) => s.parse::<f64>().ok(),
468        _ => None,
469    }
470}
471
472/// Natural-typed SQLite param for a JSON number (integer stays integer so it
473/// matches an integer-valued expression index).
474fn natural_number_param(n: &serde_json::Number) -> Option<rusqlite::types::Value> {
475    use rusqlite::types::Value as V;
476    if let Some(i) = n.as_i64() {
477        Some(V::Integer(i))
478    } else {
479        n.as_f64().map(V::Real)
480    }
481}
482
483/// One leaf condition → SQL predicate. For an **indexed** field the predicate
484/// references the bare `json_extract(...)` expression (no `CAST`) so SQLite can
485/// use the expression index; correctness for that field then assumes a
486/// consistent scalar type (the normal contract for an indexed column), and the
487/// Rust re-eval still runs. For non-indexed fields it emits a `CAST` superset
488/// that never excludes a true match. `None` forces the caller to fall back.
489fn leaf_sql_superset(
490    field: &str,
491    op: &str,
492    value: &serde_json::Value,
493    params: &mut Vec<rusqlite::types::Value>,
494    indexed: &std::collections::HashSet<&str>,
495) -> Option<String> {
496    use rusqlite::types::Value as V;
497    let expr = field_sql_expr(field)?;
498    let is_indexed = indexed.contains(field);
499
500    if is_indexed {
501        // Index-friendly: bare expression compared to a natural-typed param.
502        match op {
503            "=" => {
504                let p = match value {
505                    serde_json::Value::Number(n) => natural_number_param(n)?,
506                    serde_json::Value::String(s) => V::Text(s.clone()),
507                    _ => return None,
508                };
509                params.push(p);
510                return Some(format!("{expr} = ?"));
511            }
512            "<" | "<=" | ">" | ">=" => {
513                let r = value_as_real(value)?;
514                params.push(V::Real(r));
515                return Some(format!("{expr} {op} ?"));
516            }
517            "LIKE" => {
518                let s = value.as_str()?;
519                params.push(V::Text(s.to_string()));
520                return Some(format!("{expr} LIKE ?"));
521            }
522            _ => return None,
523        }
524    }
525
526    // Non-indexed: CAST superset (over-matches, trimmed by the Rust re-eval).
527    match op {
528        "=" => {
529            let (text, real) = value_forms(value)?;
530            params.push(text);
531            params.push(real);
532            Some(format!(
533                "(CAST({expr} AS TEXT) = ? OR CAST({expr} AS REAL) = ?)"
534            ))
535        }
536        "<" | "<=" | ">" | ">=" => {
537            let r = value_as_real(value)?;
538            params.push(V::Real(r));
539            Some(format!("CAST({expr} AS REAL) {op} ?"))
540        }
541        "LIKE" => {
542            let s = value.as_str()?;
543            params.push(V::Text(s.to_string()));
544            Some(format!("CAST({expr} AS TEXT) LIKE ?"))
545        }
546        _ => None, // e.g. "!=" — rarely selective; let the caller fall back
547    }
548}
549
550/// Translate a filter into a superset SQL predicate (never excludes a true
551/// match; the Rust re-eval trims over-matches). `None` if any part is
552/// unsupported.
553fn filter_to_sql_superset(
554    filter: &serde_json::Value,
555    params: &mut Vec<rusqlite::types::Value>,
556    indexed: &std::collections::HashSet<&str>,
557) -> Option<String> {
558    match filter {
559        serde_json::Value::Array(cs) => {
560            let mut parts = Vec::with_capacity(cs.len());
561            for c in cs {
562                parts.push(filter_to_sql_superset(c, params, indexed)?);
563            }
564            if parts.is_empty() {
565                return None;
566            }
567            Some(format!("({})", parts.join(" AND ")))
568        }
569        serde_json::Value::Object(obj) => {
570            if obj.contains_key("field") && obj.contains_key("op") && obj.contains_key("value") {
571                let field = obj.get("field")?.as_str()?;
572                let op = obj.get("op")?.as_str()?;
573                let value = obj.get("value")?;
574                leaf_sql_superset(field, op, value, params, indexed)
575            } else {
576                if obj.is_empty() {
577                    return None;
578                }
579                let mut parts = Vec::with_capacity(obj.len());
580                for (k, v) in obj {
581                    parts.push(leaf_sql_superset(k, "=", v, params, indexed)?);
582                }
583                Some(format!("({})", parts.join(" AND ")))
584            }
585        }
586        _ => None,
587    }
588}
589
590/// Convert a scalar returned by `json_extract` back to a JSON value, matching
591/// what the in-process resolver would produce (nulls → absent field).
592fn sqlite_to_json(v: &rusqlite::types::Value) -> Option<serde_json::Value> {
593    use rusqlite::types::Value as V;
594    match v {
595        V::Null => None,
596        V::Integer(i) => Some(serde_json::Value::Number((*i).into())),
597        V::Real(f) => serde_json::Number::from_f64(*f).map(serde_json::Value::Number),
598        V::Text(s) => Some(serde_json::Value::String(s.clone())),
599        V::Blob(_) => None,
600    }
601}
602
603#[cfg(test)]
604mod pool_tests {
605    use super::*;
606    use chrono::Utc;
607    use serde_json::json;
608    use std::sync::Arc;
609    use tempfile::tempdir;
610
611    use crate::types::VectorRecord;
612
613    fn dummy_record(id: &str) -> VectorRecord {
614        let now = Utc::now();
615        VectorRecord {
616            id: id.to_string(),
617            vector: vec![],
618            payload: json!({ "id": id }),
619            text: None,
620            created_at: now,
621            updated_at: now,
622        }
623    }
624
625    // Test 1 — four threads read concurrently; all must succeed and see 5 records.
626    #[test]
627    fn test_pool_concurrent_reads() {
628        let dir = tempdir().unwrap();
629        let path = dir.path().join("conc_reads.db");
630
631        let store = MetadataStore::open(&path).unwrap();
632        for i in 0..5usize {
633            store
634                .upsert(&format!("r{i}"), i, &dummy_record(&format!("r{i}")))
635                .unwrap();
636        }
637
638        let store = Arc::new(store);
639        let mut handles = Vec::new();
640        for _ in 0..4 {
641            let s = Arc::clone(&store);
642            handles.push(std::thread::spawn(move || {
643                let active = s.list_active().expect("list_active failed");
644                assert_eq!(active.len(), 5, "expected 5 active records");
645            }));
646        }
647        for h in handles {
648            h.join().expect("thread panicked");
649        }
650    }
651
652    // Test 2 — two writers and two readers run concurrently; no errors expected.
653    #[test]
654    fn test_pool_concurrent_upsert_and_read() {
655        let dir = tempdir().unwrap();
656        let path = dir.path().join("conc_rw.db");
657
658        let store = Arc::new(MetadataStore::open(&path).unwrap());
659        let mut handles = Vec::new();
660
661        // Two writer threads — each upserts 10 records with unique ids.
662        for t in 0..2usize {
663            let s = Arc::clone(&store);
664            handles.push(std::thread::spawn(move || {
665                for i in 0..10usize {
666                    let id = format!("t{t}r{i}");
667                    s.upsert(&id, t * 10 + i, &dummy_record(&id))
668                        .expect("upsert failed");
669                }
670            }));
671        }
672
673        // Two reader threads — each calls count_active 10 times.
674        for _ in 0..2usize {
675            let s = Arc::clone(&store);
676            handles.push(std::thread::spawn(move || {
677                for _ in 0..10 {
678                    let _ = s.count_active().expect("count_active failed");
679                }
680            }));
681        }
682
683        for h in handles {
684            h.join().expect("thread panicked");
685        }
686
687        // All 20 records must be present after the writers finish.
688        assert_eq!(store.count_active().unwrap(), 20);
689    }
690
691    // Test 3 — pool with max_size(2) correctly recycles connections over 20 ops.
692    #[test]
693    fn test_metadata_survives_pool_exhaustion() {
694        let dir = tempdir().unwrap();
695        let path = dir.path().join("pool2.db");
696
697        let store = MetadataStore::open_with_pool_size(&path, 2).unwrap();
698
699        // 20 sequential operations — each acquires and releases a connection.
700        for i in 0..20usize {
701            let id = format!("p{i}");
702            store
703                .upsert(&id, i, &dummy_record(&id))
704                .unwrap_or_else(|e| panic!("op {i} failed: {e}"));
705        }
706
707        assert_eq!(store.count_active().unwrap(), 20);
708    }
709}