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}