Skip to main content

haematite_cli/serve/storage/
embed_pending.rs

1//! V3 (ruled 5 Oct 08:15, 08:27 and 08:31): the next page of the embedding
2//! job's walk for one declared field of one branch.
3//!
4//! A walk is the difference over the field's collection's revision keys
5//! (`m\0{collection}\0`) from the position's `through` (the empty tree before
6//! the first walk ends) to its target, `toward`: the head when the walk
7//! begins, kept while the head moves. Its cost is in proportion to the
8//! changes, never to the store: the engine's diff skips equal subtrees, and
9//! each change costs one read of its document at `toward`.
10//!
11//! The shards are walked together, in one id order. Each gives at most one
12//! change more than a page after the walk's `after` (JT1b): the engine stops a
13//! shard the moment it holds its budget, so a budget of exactly a page would
14//! stop a shard holding exactly a page's changes as if it had more, and the
15//! walk that has read its last record would not say so. One that has more
16//! stops at its `next_after`, and the page keeps only the changes at or
17//! before the smallest such key, so no record another shard still holds can
18//! fall before the page's `Upto`; a shard that gave the change past the page
19//! is cut back to a page, which then reaches `Upto` its last change. The
20//! page claims `End` only when no shard has more and all it holds fits in
21//! one page.
22//!
23//! A shard's stop can be a key that is no change, or no record at all (an
24//! internal node's separator, settled by the engine's `settle_boundary`), so
25//! `Upto` is always the last change the page holds. A page with no change and
26//! a stop goes on from that stop within the same call: the engine settles a
27//! stop only strictly past the key it was given (`diff_page.rs` `settle`) and
28//! refuses by name when it cannot, so this always moves and never spins.
29//!
30//! EMB-E2 (j): while a field's rewalk marker is in its companion, its open
31//! walk runs from the empty tree to `through`, and lists only the texts with
32//! no vector and no windows there: the ones passed as longer than the model.
33//! Nothing else is named, so no record is counted twice.
34
35use std::num::NonZeroUsize;
36use std::sync::Arc;
37
38use haem_frames::embedding::{ItemCode, input_refusal};
39use haematite::db::ObservedChange;
40use haematite::tree::{DiffBudget, DiffInterval, Hash, empty_root_hash};
41use serde_json::Value;
42
43use super::embed_job::Reach;
44use super::embedded::{self, Declaration};
45use super::{
46    Captured, Catalogue, DocumentRevision, Failure, Held, Storage, document_key, identifier, reads,
47    revision_key,
48};
49use crate::serve::protocol::{Selection, Version};
50
51/// Most tree nodes one shard's diff page may load.
52const DIFF_NODES: usize = 4096;
53
54/// One change the job must make a vector for, or remove.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum Waiting {
57    /// The record's text at `toward`, and the revision it is at.
58    Text {
59        id: String,
60        made_from: Version,
61        text: String,
62    },
63    Removed {
64        id: String,
65    },
66}
67
68/// The next page of a walk.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct Pending {
71    pub toward: Version,
72    pub waiting: Vec<Waiting>,
73    /// Records with no text in the source field, or text that is not a
74    /// string: no vector is made (Q10).
75    pub no_text: Vec<String>,
76    /// Records whose text is longer than a worker takes: never cut, no
77    /// vector is made (Q10).
78    pub too_long: Vec<String>,
79    /// EMB-E1: records whose text is empty or only whitespace, which the
80    /// worker refuses as `embed_input_empty`: passed with no vector, never
81    /// sent, so the walk goes on past them.
82    pub empty_input: Vec<String>,
83    pub reach: Reach,
84}
85
86impl Storage {
87    pub(in crate::serve) fn pending_reader(&self) -> PendingReader {
88        PendingReader {
89            reader: Arc::clone(&self.reader),
90            catalogue: self.catalogue(),
91        }
92    }
93
94    /// The next page of `branch`'s walk for `collection.field`, at most
95    /// `page_records` changes; `None` when the position is at the head and
96    /// no walk is open.
97    ///
98    /// # Errors
99    /// `branch_missing`; `embed_field_not_declared`; `embed_walk_released`
100    /// when a version the walk needs is no longer kept;
101    /// `embed_record_id_corrupt`; whatever a read or the diff answers.
102    pub fn embed_pending(
103        &self,
104        branch: &str,
105        collection: &str,
106        field: &str,
107        page_records: NonZeroUsize,
108        input_bytes: NonZeroUsize,
109    ) -> Result<Option<Pending>, Failure> {
110        self.pending_reader()
111            .read(branch, collection, field, page_records, input_bytes)
112            .map(|page| page.map(|(pending, _)| pending))
113    }
114}
115
116/// Immutable authority for a pending page; no writer admission is held.
117pub(in crate::serve) struct PendingReader {
118    reader: Arc<haematite::db::ReadOnlyDatabase>,
119    catalogue: Catalogue,
120}
121
122pub(in crate::serve) struct PendingPage {
123    pub pending: Pending,
124    pub declaration: Declaration,
125    pub position: Option<embedded::Position>,
126}
127
128impl PendingReader {
129    pub fn read(
130        &self,
131        branch: &str,
132        collection: &str,
133        field: &str,
134        page_records: NonZeroUsize,
135        input_bytes: NonZeroUsize,
136    ) -> Result<Option<(Pending, Declaration)>, Failure> {
137        self.read_page(branch, collection, field, page_records, input_bytes)
138            .map(|page| page.map(|page| (page.pending, page.declaration)))
139    }
140
141    pub fn read_page(
142        &self,
143        branch: &str,
144        collection: &str,
145        field: &str,
146        page_records: NonZeroUsize,
147        input_bytes: NonZeroUsize,
148    ) -> Result<Option<PendingPage>, Failure> {
149        identifier(branch)?;
150        identifier(collection)?;
151        identifier(field)?;
152        let head = self.catalogue.capture_held(&Selection {
153            branch: branch.to_owned(),
154            version: None,
155        })?;
156        let declaration = self.declared(&head, collection, field)?;
157        let companion = self.catalogue.capture_companion_held(branch)?;
158        let position = self.position(companion.as_ref(), collection, field)?;
159        let (from_branch, through) = position
160            .as_ref()
161            .map_or((branch, None), |at| (at.branch.as_str(), at.through));
162        let open = position.as_ref().and_then(|at| at.toward.as_ref());
163        let toward = open.map_or(head.version, |open| open.version);
164        if open.is_none() && from_branch == branch && through == Some(head.version) {
165            return Ok(None);
166        }
167        // A rewalk is an open walk toward the position's own `through`.
168        let rewalk = open.is_some_and(|open| through == Some(open.version))
169            && self.rewalking(companion.as_ref(), collection, field)?;
170        let from_hold = through
171            .filter(|_| !rewalk)
172            .map(|through| self.kept(from_branch, through))
173            .transpose()?;
174        let from: Vec<Hash> = from_hold.as_ref().map_or_else(
175            || vec![empty_root_hash(); self.reader.shard_count()],
176            |held| held.shards.iter().map(|shard| shard.head).collect(),
177        );
178        let to = self.kept(branch, toward)?;
179        let after = open.map(|open| revision_key(collection, &open.after));
180        let (changes, more) =
181            self.walk_page(collection, &from, &to, after.as_deref(), page_records)?;
182        let mut pending = Pending {
183            toward,
184            waiting: Vec::new(),
185            no_text: Vec::new(),
186            too_long: Vec::new(),
187            empty_input: Vec::new(),
188            reach: Reach::End,
189        };
190        let sort = Sort {
191            to: &to,
192            declaration: &declaration,
193            collection,
194            field,
195            limit: u64::try_from(input_bytes.get()).unwrap_or(u64::MAX),
196            rewalk: companion.as_deref().filter(|_| rewalk),
197        };
198        for change in &changes {
199            self.sort(&mut pending, change, &sort)?;
200        }
201        if more {
202            let last = changes.last().ok_or_else(|| {
203                Failure::new(
204                    "embed_walk_stalled",
205                    "a page with more after it holds no change",
206                )
207            })?;
208            pending.reach = Reach::Upto(record_id(collection, last.key())?);
209        }
210        Ok(Some(PendingPage {
211            pending,
212            declaration,
213            position,
214        }))
215    }
216
217    fn declared(
218        &self,
219        at: &Captured,
220        collection: &str,
221        field: &str,
222    ) -> Result<Declaration, Failure> {
223        let undeclared = || {
224            Failure::new(
225                "embed_field_not_declared",
226                format!("{collection}.{field} is not declared embedded"),
227            )
228        };
229        let bytes = reads::get_bytes(
230            &self.reader,
231            at,
232            &super::embed_job::field_key(collection, field),
233        )?
234        .ok_or_else(undeclared)?;
235        match embedded::read_field(&bytes)? {
236            embedded::Field::Embedded(declaration) => Ok(declaration),
237            embedded::Field::Supplied { .. } => Err(undeclared()),
238        }
239    }
240
241    /// Puts one change of the walk in its place on `pending`. EMB-E1: each
242    /// text is checked as the worker checks a request, so the walk never
243    /// lists one the worker would refuse. EMB-E2 (j): a rewalk lists only a
244    /// text with no vector and no windows, and names nothing else.
245    ///
246    /// # Errors
247    /// `embed_record_id_corrupt`; whatever a read answers.
248    fn sort(
249        &self,
250        pending: &mut Pending,
251        change: &ObservedChange,
252        sort: &Sort<'_>,
253    ) -> Result<(), Failure> {
254        let id = record_id(sort.collection, change.key())?;
255        let revision = match change {
256            ObservedChange::Removed { .. } => {
257                pending.waiting.push(Waiting::Removed { id });
258                return Ok(());
259            }
260            ObservedChange::Added { value, .. }
261            | ObservedChange::Modified {
262                new_value: value, ..
263            } => value,
264        };
265        if let Some(companion) = sort.rewalk
266            && self.has_vectors(companion, sort, &id)?
267        {
268            return Ok(());
269        }
270        let made_from = serde_json::from_slice::<DocumentRevision>(revision)
271            .map_err(Failure::storage)?
272            .written_at;
273        let named = sort.rewalk.is_none();
274        match text(
275            &self.reader,
276            sort.to,
277            sort.declaration,
278            sort.collection,
279            &id,
280        )? {
281            None if named => pending.no_text.push(id),
282            Some(text) => match input_refusal(&text, sort.limit) {
283                None => pending.waiting.push(Waiting::Text {
284                    id,
285                    made_from,
286                    text,
287                }),
288                Some(ItemCode::EmbedInputEmpty) if named => pending.empty_input.push(id),
289                Some(_) if named => pending.too_long.push(id),
290                Some(_) => {}
291            },
292            None => {}
293        }
294        Ok(())
295    }
296
297    /// EMB-E2 (j): whether `collection.field`'s rewalk marker is in its
298    /// companion.
299    fn rewalking(
300        &self,
301        companion: Option<&Held>,
302        collection: &str,
303        field: &str,
304    ) -> Result<bool, Failure> {
305        let Some(companion) = companion else {
306            return Ok(false);
307        };
308        let key = embedded::rewalk_key(collection, field);
309        Ok(reads::get_bytes(&self.reader, companion, &key)?.is_some())
310    }
311
312    /// EMB-E2 (j): whether `id` has a vector or windows in `companion`: two
313    /// point reads, the vector key and its first window's.
314    fn has_vectors(
315        &self,
316        companion: &Captured,
317        sort: &Sort<'_>,
318        id: &str,
319    ) -> Result<bool, Failure> {
320        let vector = embedded::vector_key(sort.collection, sort.field, id);
321        let window = embedded::window_key(sort.collection, sort.field, id, 0);
322        Ok(
323            reads::get_bytes(&self.reader, companion, &vector)?.is_some()
324                || reads::get_bytes(&self.reader, companion, &window)?.is_some(),
325        )
326    }
327
328    fn position(
329        &self,
330        companion: Option<&Held>,
331        collection: &str,
332        field: &str,
333    ) -> Result<Option<embedded::Position>, Failure> {
334        let Some(companion) = companion else {
335            return Ok(None);
336        };
337        reads::get_bytes(
338            &self.reader,
339            companion,
340            &embedded::position_key(collection, field),
341        )?
342        .map(|bytes| embedded::read_position(&bytes))
343        .transpose()
344    }
345
346    /// One page of the difference, in key order across every shard, and
347    /// whether the walk has more after it; never empty when it has.
348    fn walk_page(
349        &self,
350        collection: &str,
351        from: &[Hash],
352        to: &Captured,
353        after: Option<&[u8]>,
354        page_records: NonZeroUsize,
355    ) -> Result<(Vec<ObservedChange>, bool), Failure> {
356        let mut after = after.map(<[u8]>::to_vec);
357        loop {
358            let (changes, stop) =
359                self.walk_once(collection, from, to, after.as_deref(), page_records)?;
360            match stop {
361                Some(stop) if changes.is_empty() => after = Some(stop),
362                stop => return Ok((changes, stop.is_some())),
363            }
364        }
365    }
366
367    /// One page of the difference after `after`, and the key it stops at
368    /// when the walk has more after it.
369    fn walk_once(
370        &self,
371        collection: &str,
372        from: &[Hash],
373        to: &Captured,
374        after: Option<&[u8]>,
375        page_records: NonZeroUsize,
376    ) -> Result<(Vec<ObservedChange>, Option<Vec<u8>>), Failure> {
377        let lower = format!("m\0{collection}\0").into_bytes();
378        let upper = format!("m\0{collection}\u{1}").into_bytes();
379        let budget = DiffBudget {
380            // One past the page, so a shard holding exactly a page finishes
381            // with no stop; one that gives the change past it is cut below.
382            max_changes: page_records.checked_add(1).ok_or_else(|| {
383                Failure::new(
384                    "embed_page_records_overflow",
385                    "the page setting leaves no room to look one change past it",
386                )
387            })?,
388            max_nodes: NonZeroUsize::new(DIFF_NODES)
389                .ok_or_else(|| Failure::new("diff_budget", "node budget must be nonzero"))?,
390        };
391        let mut changes = Vec::new();
392        // The smallest key at which a shard stopped with more to give.
393        let mut bound: Option<Vec<u8>> = None;
394        for shard in &to.shards {
395            let from_root = from.get(shard.shard_id).copied().ok_or_else(|| {
396                Failure::new(
397                    "incomplete_roots",
398                    "the walk's start does not name every shard",
399                )
400            })?;
401            let page = self
402                .reader
403                .changes_page_in_range(
404                    shard.shard_id,
405                    from_root,
406                    shard.head,
407                    DiffInterval {
408                        lower: Some(&lower),
409                        upper: Some(&upper),
410                    },
411                    after,
412                    budget,
413                )
414                .map_err(Failure::observer)?;
415            if let Some(next) = page.next_after
416                && bound.as_ref().is_none_or(|bound| next < *bound)
417            {
418                bound = Some(next);
419            }
420            changes.extend(page.entries);
421        }
422        if let Some(bound) = &bound {
423            changes.retain(|change| change.key() <= bound.as_slice());
424        }
425        changes.sort_by(|left, right| left.key().cmp(right.key()));
426        if changes.len() > page_records.get() {
427            changes.truncate(page_records.get());
428            let last = changes.last().map(|change| change.key().to_vec());
429            return Ok((changes, last));
430        }
431        Ok((changes, bound))
432    }
433
434    /// The roots of `branch` at `at`: its head, or, through the catalogue's
435    /// view, a version its record keeps or a kept history row (STOR-003 MD4).
436    fn kept(&self, branch: &str, at: Version) -> Result<Held, Failure> {
437        self.catalogue
438            .capture_held(&Selection {
439                branch: branch.to_owned(),
440                version: Some(at),
441            })
442            .map_err(|failure| walk_unreadable(branch, at, failure))
443    }
444}
445
446/// What a capture of the walk's target answered, as the walk sees it: a
447/// version missing, released or of an ended generation cannot be read, and
448/// each is `embed_walk_released` with the capture's own code kept in the
449/// message (STOR-003 MD4-b); anything else is answered as itself.
450fn walk_unreadable(branch: &str, at: Version, failure: Failure) -> Failure {
451    const UNREADABLE: [&str; 3] = [
452        "version_missing",
453        "version_released",
454        "version_generation_ended",
455    ];
456    if UNREADABLE.contains(&failure.code.as_str()) {
457        Failure::new(
458            "embed_walk_released",
459            format!(
460                "{branch} at {}.{} is no longer kept, so the walk cannot be read ({}: {})",
461                at.generation, at.sequence, failure.code, failure.message
462            ),
463        )
464    } else {
465        failure
466    }
467}
468
469/// The record id of a revision key in `collection`.
470/// What a page's changes are sorted against.
471struct Sort<'a> {
472    to: &'a Captured,
473    declaration: &'a Declaration,
474    collection: &'a str,
475    field: &'a str,
476    /// The most bytes of text a worker takes.
477    limit: u64,
478    /// EMB-E2 (j): the companion, when the walk is a rewalk.
479    rewalk: Option<&'a Captured>,
480}
481
482fn record_id(collection: &str, key: &[u8]) -> Result<String, Failure> {
483    let corrupt = || Failure::new("embed_record_id_corrupt", "a revision key names no record");
484    let prefix = format!("m\0{collection}\0");
485    let id = key.strip_prefix(prefix.as_bytes()).ok_or_else(corrupt)?;
486    String::from_utf8(id.to_vec()).map_err(|_not_utf8| corrupt())
487}
488
489/// The declared source field's text in a record at `at`, if it has one.
490fn text(
491    reader: &haematite::db::ReadOnlyDatabase,
492    at: &Captured,
493    declaration: &Declaration,
494    collection: &str,
495    id: &str,
496) -> Result<Option<String>, Failure> {
497    let Some(bytes) = reads::get_bytes(reader, at, &document_key(collection, id))? else {
498        return Ok(None);
499    };
500    let document: Value = serde_json::from_slice(&bytes).map_err(Failure::storage)?;
501    Ok(document
502        .get(&declaration.source)
503        .and_then(Value::as_str)
504        .map(str::to_owned))
505}
506
507#[cfg(test)]
508#[path = "embed_pending_tests.rs"]
509mod tests;