Skip to main content

velesdb_memory/migration/
enumeration.rs

1use serde_json::Value;
2
3/// Collections `AgentMemory` opens, all at the same dimension — so any one of
4/// them refusing the new dimension makes the whole store unopenable, and an
5/// inventory that skipped the empty ones would under-report the work.
6pub const AGENT_COLLECTIONS: &[&str] =
7    &["_semantic_memory", "_episodic_memory", "_procedural_memory"];
8
9/// One fact as the rebuild will need to re-create it.
10///
11/// # Why the source vector is here, having once been left out deliberately
12///
13/// This type used to carry `id` and `payload` only, on the stated grounds that
14/// "the old vector is recomputed by the new embedder, and carrying it would
15/// only invite writing it back". That was written when a rebuild had one
16/// regime. #1815 arbitrated two: reuse the source vectors where compatibility
17/// is *proven*, re-embed everywhere else. A type that cannot carry the source
18/// vector cannot express the first, so the reasoning no longer held and the
19/// comment asserting it was describing a decision that had been reversed.
20///
21/// The field is named `source_vector` rather than `vector` for the reason the
22/// old comment was right about: it is the vector of the model being migrated
23/// *from*, and writing it back is only ever legitimate against a
24/// [`Compatibility::Match`](crate::migration::Compatibility::Match). What
25/// [`reinsert`] writes is always the vector its caller passed, never this one
26/// by default.
27#[derive(Debug, Clone, PartialEq)]
28pub struct RawFact {
29    /// The id the fact must keep. Not a suggestion.
30    pub id: u64,
31    /// The whole stored payload, reserved keys included — `content` and every
32    /// `_veles_*` key travel here verbatim.
33    pub payload: String,
34    /// The vector as the SOURCE store holds it, at the source's width.
35    ///
36    /// Read out on every enumeration because the cost is a clone of something
37    /// the engine already materialised, and a second read path taken only under
38    /// `reuse` would be a path the `reembed` tests never exercise.
39    pub source_vector: Vec<f32>,
40}
41
42/// Read every fact of `collection` out of `db`, in pages of `page`.
43///
44/// `ORDER BY id` is load-bearing, not decoration. Measured on 2026-08-03: the
45/// natural scan order is NOT id order — a seeded store answered
46/// `[1, 2, 3, 100, 4, 5, 6, 7]` — so an `OFFSET` walk over the unordered scan
47/// pages by *position* in a layout the engine is free to rearrange
48/// (`reorder_for_locality` does exactly that). Ordering first makes each page
49/// boundary a value.
50///
51/// This is only sound because the walk runs under the exclusive store lock with
52/// nothing else writing.
53///
54/// Prefer [`enumerate_by_cursor`] for a rebuild. This walk is kept as the
55/// independent second path the cursor is checked against — two routes through
56/// the engine agreeing is evidence, where a cursor compared against a list this
57/// module built would only prove self-consistency — and it is bounded at
58/// `100_000` facts, so it is not the one to migrate a real store with.
59///
60/// # Errors
61/// Returns [`crate::MemoryError`] if the scan cannot be parsed or executed.
62pub fn enumerate_collection(
63    db: &velesdb_core::Database,
64    collection: &str,
65    page: usize,
66) -> Result<Vec<RawFact>, crate::MemoryError> {
67    let mut out: Vec<RawFact> = Vec::new();
68    let mut offset = 0usize;
69    loop {
70        let batch = enumerate_page(db, collection, page, offset)?;
71        if batch.is_empty() {
72            break;
73        }
74        let returned = batch.len();
75        out.extend(batch);
76        if returned < page {
77            break;
78        }
79        offset += page;
80    }
81    Ok(out)
82}
83
84/// One page of `collection`, starting at `offset` — the unit a checkpoint
85/// resumes from, and what makes the walk above interruptible rather than
86/// all-or-nothing.
87///
88/// # Errors
89/// Returns [`crate::MemoryError`] if the scan cannot be parsed or executed.
90pub fn enumerate_page(
91    db: &velesdb_core::Database,
92    collection: &str,
93    page: usize,
94    offset: usize,
95) -> Result<Vec<RawFact>, crate::MemoryError> {
96    let sql = format!("SELECT * FROM {collection} ORDER BY id LIMIT {page} OFFSET {offset}");
97    let query = velesdb_core::velesql::Parser::parse(&sql)
98        .map_err(|e| velesdb_core::Error::Query(e.to_string()))?;
99    let hits = db.execute_query(&query, &std::collections::HashMap::new())?;
100    Ok(hits
101        .into_iter()
102        .map(|hit| RawFact {
103            id: hit.point.id,
104            payload: hit
105                .point
106                .payload
107                .as_ref()
108                .map_or_else(|| Value::Null.to_string(), std::string::ToString::to_string),
109            source_vector: hit.point.vector,
110        })
111        .collect())
112}
113
114/// One batch of `collection`, starting strictly after `cursor`, with the cursor
115/// to resume from.
116///
117/// This is the enumeration the rebuild should use, and it is NOT the `VelesQL`
118/// walk above. The engine already carries a cursor primitive — `scroll_batch`
119/// on the collection itself, keyed on the point id, exclusive, ascending — and
120/// it bypasses the query pipeline entirely. That matters twice over:
121///
122/// * The query pipeline asks the collection for `limit + offset` rows and
123///   clamps the total to `MAX_LIMIT` (`100_000`), so an `OFFSET` walk goes blind
124///   past that mark. A cursor never accumulates an offset, so it has no such
125///   bound.
126/// * `WHERE id > n` really is unavailable — filters read the payload and the id
127///   is not in it — but that only ever ruled out expressing the cursor *in
128///   `VelesQL`*. It never ruled out the cursor.
129///
130/// Returns the batch and the cursor to pass next; `None` means the collection
131/// is exhausted.
132///
133/// # Errors
134/// Returns [`crate::MemoryError`] if the collection is absent, is of a kind
135/// that does not scroll, or if the scroll itself fails.
136pub fn scroll_page(
137    db: &velesdb_core::Database,
138    collection: &str,
139    cursor: Option<u64>,
140    batch: usize,
141) -> Result<(Vec<RawFact>, Option<u64>), crate::MemoryError> {
142    let any = db.get_any_collection(collection).ok_or_else(|| {
143        velesdb_core::Error::Query(format!("collection `{collection}` not found"))
144    })?;
145    let scrolled = match &any {
146        velesdb_core::AnyCollection::Vector(c) => c.scroll_batch(cursor, batch, None),
147        velesdb_core::AnyCollection::Graph(c) => c.scroll_batch(cursor, batch, None),
148        velesdb_core::AnyCollection::Metadata(c) => c.scroll_batch(cursor, batch, None),
149        _ => {
150            return Err(velesdb_core::Error::Query(format!(
151                "collection `{collection}` is of a kind that does not scroll"
152            ))
153            .into())
154        }
155    }?;
156    let facts = scrolled
157        .points
158        .into_iter()
159        .map(|point| RawFact {
160            id: point.id,
161            payload: point
162                .payload
163                .as_ref()
164                .map_or_else(|| Value::Null.to_string(), std::string::ToString::to_string),
165            source_vector: point.vector,
166        })
167        .collect();
168    Ok((facts, scrolled.next_cursor))
169}
170
171/// Read every fact of `collection` out of `db` by cursor, in batches of `batch`.
172///
173/// Termination is on an exhausted cursor or an empty batch — never on a SHORT
174/// one. `scroll_batch` keeps scanning candidate ids until it has `batch_size`
175/// live points or the ids run out, and it skips TTL-expired points on the way,
176/// so a short batch does mean the end; but the two stop conditions are cheap and
177/// the walk should not depend on that internal detail holding.
178///
179/// Skipping the expired is the behaviour this rebuild wants, not a quirk to work
180/// around: the contract says an export must exclude already-expired facts so the
181/// rebuild cannot resurrect them.
182///
183/// # Errors
184/// Returns [`crate::MemoryError`] if any batch fails.
185pub fn enumerate_by_cursor(
186    db: &velesdb_core::Database,
187    collection: &str,
188    batch: usize,
189) -> Result<Vec<RawFact>, crate::MemoryError> {
190    let mut out: Vec<RawFact> = Vec::new();
191    let mut cursor: Option<u64> = None;
192    loop {
193        let (facts, next) = scroll_page(db, collection, cursor, batch)?;
194        if facts.is_empty() {
195            break;
196        }
197        out.extend(facts);
198        match next {
199            Some(c) => cursor = Some(c),
200            None => break,
201        }
202    }
203    Ok(out)
204}
205
206// ---------------------------------------------------------------------------
207// PUTTING A FACT BACK
208//
209// Reading every fact out proves half of the feasibility question. The other
210// half is whether it goes back UNCHANGED — same id, same payload, same absolute
211// expiry — into a destination the new embedder sized. That is proven here, on a
212// destination, and never on the source.
213// ---------------------------------------------------------------------------
214
215/// What putting a fact back produced.
216///
217/// A collision is a RESULT, not an error and emphatically not a silent
218/// overwrite: `upsert` would replace whatever sat under that id without a
219/// word, and a rebuild that did so would destroy the very fact it was
220/// preserving. The caller decides; this reports.
221#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
222#[serde(tag = "outcome", rename_all = "snake_case")]
223pub enum Reinsertion {
224    /// The id was free, and the fact now occupies it.
225    Inserted,
226    /// The id was taken. NOTHING was written.
227    Collision {
228        /// The payload already stored under that id, left exactly as it was.
229        existing: String,
230    },
231}
232
233/// Put `fact` back into `collection` under its ORIGINAL id, with `vector` as
234/// the caller decided it.
235///
236/// `vector` is the target embedder's output under `reembed`, or
237/// `fact.source_vector` under `reuse`. This function does not choose between
238/// them and must not: passing the source vector is legitimate exactly when the
239/// source records the target model at the target width, and the only thing that
240/// establishes it is [`resolve`](crate::migration::resolve) — one place, tested
241/// as one rule. Writing back a source vector on any other footing produces a
242/// store whose vectors and whose recorded model disagree, which recall answers
243/// from without ever failing.
244///
245/// # Errors
246/// Returns [`crate::MemoryError`] if the collection is absent, if the stored
247/// payload is not readable, or if the write fails.
248pub fn reinsert(
249    db: &velesdb_core::Database,
250    collection: &str,
251    fact: &RawFact,
252    vector: &[f32],
253) -> Result<Reinsertion, crate::MemoryError> {
254    let any = db.get_any_collection(collection).ok_or_else(|| {
255        velesdb_core::Error::Query(format!("collection `{collection}` not found"))
256    })?;
257    if let Some(Some(existing)) = any.get(&[fact.id]).into_iter().next() {
258        return Ok(Reinsertion::Collision {
259            existing: existing
260                .payload
261                .as_ref()
262                .map_or_else(|| Value::Null.to_string(), std::string::ToString::to_string),
263        });
264    }
265    let payload: Value = serde_json::from_str(&fact.payload).map_err(|e| {
266        velesdb_core::Error::Query(format!("fact {} carries unreadable payload: {e}", fact.id))
267    })?;
268    any.upsert(vec![velesdb_core::Point::new(
269        fact.id,
270        vector.to_vec(),
271        Some(payload),
272    )])?;
273    Ok(Reinsertion::Inserted)
274}
275
276/// What a batch re-insertion produced.
277///
278/// Aligned by id rather than by position, because the caller's question is
279/// "which facts did NOT land", and an index into a slice it may have built by
280/// filtering is not an answer it can act on.
281#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
282pub struct BatchReinsertion {
283    /// Facts written.
284    pub inserted: u64,
285    /// Ids already occupied. Nothing was written over any of them.
286    pub collisions: Vec<u64>,
287}
288
289/// Put a whole batch back, in one write.
290///
291/// The reason a batch exists at all is throughput — a per-fact write costs an
292/// fsync each, which is what made a rebuild of a real store look impossible
293/// before #1797. The reason it is dangerous is that batching is exactly where
294/// an id, a reserved key or an expiry gets dropped without anyone noticing,
295/// since nothing fails.
296///
297/// Occupied ids are collected FIRST and excluded from the write, so a batch
298/// containing one collision still lands the rest and still overwrites nothing.
299///
300/// # Errors
301/// Returns [`crate::MemoryError`] if the collection is absent, a payload is
302/// unreadable, or the write fails.
303pub fn reinsert_batch(
304    db: &velesdb_core::Database,
305    collection: &str,
306    batch: &[(RawFact, Vec<f32>)],
307) -> Result<BatchReinsertion, crate::MemoryError> {
308    let any = db.get_any_collection(collection).ok_or_else(|| {
309        velesdb_core::Error::Query(format!("collection `{collection}` not found"))
310    })?;
311    let ids: Vec<u64> = batch.iter().map(|(fact, _)| fact.id).collect();
312    let occupied: std::collections::HashSet<u64> = any
313        .get(&ids)
314        .into_iter()
315        .flatten()
316        .map(|point| point.id)
317        .collect();
318
319    let mut points = Vec::with_capacity(batch.len());
320    for (fact, vector) in batch {
321        if occupied.contains(&fact.id) {
322            continue;
323        }
324        let payload: Value = serde_json::from_str(&fact.payload).map_err(|e| {
325            velesdb_core::Error::Query(format!("fact {} carries unreadable payload: {e}", fact.id))
326        })?;
327        points.push(velesdb_core::Point::new(
328            fact.id,
329            vector.clone(),
330            Some(payload),
331        ));
332    }
333    let inserted = u64::try_from(points.len()).unwrap_or(u64::MAX);
334    if !points.is_empty() {
335        any.upsert(points)?;
336    }
337    let mut collisions: Vec<u64> = occupied.into_iter().collect();
338    collisions.sort_unstable();
339    Ok(BatchReinsertion {
340        inserted,
341        collisions,
342    })
343}