Skip to main content

haematite_cli/serve/storage/
merge_coverage.rs

1//! Affected owners are differences and incident edges at changed endpoint presence.
2//! Unchanged orphan edges belong to the full candidate graph validation.
3
4use std::collections::BTreeSet;
5
6use super::{
7    Captured, Deserialize, Failure, HeldInputs, MergeChoice, MergeChoiceRow, MergeOwner, Node,
8    NonZeroUsize, Serialize, Storage, Triple, cursor, edges, identifier, validate_token,
9};
10use crate::serve::protocol::Edge;
11use crate::serve::storage::{
12    reads,
13    review::{NODE_BUDGET, choice, compare, dangling},
14};
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(deny_unknown_fields)]
18pub struct MissingChoice {
19    pub owner: MergeOwner,
20    pub token: String,
21}
22
23#[derive(Debug, Serialize)]
24pub struct MergeCoverage {
25    pub missing: Vec<MissingChoice>,
26    pub next_after: Option<String>,
27    pub end: bool,
28}
29
30#[derive(Serialize, Deserialize)]
31#[serde(deny_unknown_fields)]
32pub(in crate::serve::storage) struct Position {
33    merge_id: String,
34    revision: u64,
35    comparison: Option<String>,
36    diff_done: bool,
37    incident: Option<Incident>,
38    pending: Vec<MissingChoice>,
39}
40
41#[derive(Serialize, Deserialize)]
42#[serde(deny_unknown_fields)]
43struct Incident {
44    node: Node,
45    incoming: bool,
46    after: Option<String>,
47}
48
49pub(in crate::serve::storage) struct Step {
50    pub(in crate::serve::storage) missing: Vec<MissingChoice>,
51    pub(in crate::serve::storage) next: Option<Position>,
52    pub(in crate::serve::storage) keys: Vec<Vec<u8>>,
53}
54
55pub(super) const PAGE_OWNER_LIMIT: usize = 128;
56
57struct Work {
58    missing: Vec<MissingChoice>,
59    keys: Vec<Vec<u8>>,
60    source_left: usize,
61    target_left: usize,
62    nodes_left: usize,
63    incident_left: usize,
64}
65
66impl Work {
67    const fn new() -> Self {
68        Self {
69            missing: Vec::new(),
70            keys: Vec::new(),
71            source_left: dangling::SCAN_ALLOWANCE,
72            target_left: dangling::SCAN_ALLOWANCE,
73            nodes_left: NODE_BUDGET,
74            incident_left: dangling::SCAN_ALLOWANCE,
75        }
76    }
77
78    const fn full(&self, limit: NonZeroUsize) -> bool {
79        self.missing.len() == limit.get() || self.keys.len() == PAGE_OWNER_LIMIT
80    }
81
82    const fn can_compare(&self) -> bool {
83        self.source_left > 0 && self.target_left > 0 && self.nodes_left >= 2
84    }
85}
86
87struct Context<'a> {
88    storage: &'a Storage,
89    inputs: HeldInputs,
90    service: Captured,
91    merge_id: &'a str,
92}
93
94struct IncidentOwner {
95    key: Vec<u8>,
96    missing: Vec<MissingChoice>,
97}
98
99struct Inspection {
100    missing: Vec<MissingChoice>,
101    incident: Option<Node>,
102}
103
104impl Storage {
105    /// List uncovered affected owners; caller-held pages cannot authorize publication.
106    ///
107    /// # Errors
108    /// Refuses invalid positions, moved inputs or journal revisions and corrupt rows.
109    pub fn merge_coverage(
110        &self,
111        merge_id: &str,
112        after: Option<&str>,
113        limit: NonZeroUsize,
114    ) -> Result<MergeCoverage, Failure> {
115        let position = after.map(cursor::decode::<Position>).transpose()?;
116        let step = self.coverage_step(merge_id, position, limit)?;
117        let end = step.next.is_none();
118        let next_after = step.next.as_ref().map(cursor::encode).transpose()?;
119        Ok(MergeCoverage {
120            missing: step.missing,
121            next_after,
122            end,
123        })
124    }
125
126    /// Drive a server-owned position from the start; no caller page is a certificate.
127    pub(in crate::serve::storage) fn coverage_step(
128        &self,
129        merge_id: &str,
130        position: Option<Position>,
131        limit: NonZeroUsize,
132    ) -> Result<Step, Failure> {
133        let plan = self.open_merge_plan(merge_id)?;
134        let inputs = self.choice_inputs(&plan)?;
135        let service = self.merge_service_selection()?;
136        let revision = self.choice_revision(&service, merge_id)?;
137        let position = match position {
138            Some(position) => {
139                validate_position(&position, merge_id, revision)?;
140                position
141            }
142            None => Position {
143                merge_id: merge_id.to_owned(),
144                revision,
145                comparison: None,
146                diff_done: false,
147                incident: None,
148                pending: Vec::new(),
149            },
150        };
151        walk(
152            &Context {
153                storage: self,
154                inputs,
155                service,
156                merge_id,
157            },
158            position,
159            limit,
160        )
161    }
162}
163
164fn validate_position(position: &Position, id: &str, revision: u64) -> Result<(), Failure> {
165    if position.merge_id != id || position.pending.len() > 3 {
166        return Err(Failure::new(
167            "invalid_continuation",
168            "coverage position belongs to another walk",
169        ));
170    }
171    if position.revision != revision {
172        return Err(Failure::new(
173            "choice_journal_moved",
174            "journal changed during coverage listing",
175        ));
176    }
177    for missing in &position.pending {
178        missing.owner.key()?;
179        validate_token(&missing.token)?;
180    }
181    if let Some(incident) = &position.incident {
182        identifier(&incident.node.collection)?;
183        identifier(&incident.node.document_id)?;
184        if incident
185            .after
186            .as_ref()
187            .is_some_and(|after| after.len() > 5 * 513)
188        {
189            return Err(Failure::new(
190                "invalid_continuation",
191                "incident cursor exceeds its key bound",
192            ));
193        }
194    }
195    Ok(())
196}
197
198fn walk(
199    context: &Context<'_>,
200    mut position: Position,
201    limit: NonZeroUsize,
202) -> Result<Step, Failure> {
203    let mut work = Work::new();
204    if !position.pending.is_empty() {
205        let take = limit.get().min(position.pending.len());
206        work.missing.extend(position.pending.drain(..take));
207    }
208    drain_incident(context, &mut position, &mut work, limit)?;
209    if !work.full(limit)
210        && position.pending.is_empty()
211        && position.incident.is_none()
212        && !position.diff_done
213    {
214        difference_step(context, &mut position, &mut work, limit)?;
215    }
216    let ended = position.diff_done && position.incident.is_none() && position.pending.is_empty();
217    Ok(Step {
218        missing: work.missing,
219        next: if ended { None } else { Some(position) },
220        keys: work.keys,
221    })
222}
223
224fn drain_incident(
225    context: &Context<'_>,
226    position: &mut Position,
227    work: &mut Work,
228    limit: NonZeroUsize,
229) -> Result<(), Failure> {
230    while position.incident.is_some()
231        && position.pending.is_empty()
232        && !work.full(limit)
233        && work.incident_left > 0
234    {
235        incident_step(context, position, work, limit)?;
236    }
237    Ok(())
238}
239
240fn comparison_page(
241    context: &Context<'_>,
242    position: &Position,
243    work: &mut Work,
244) -> Result<Option<compare::Page>, Failure> {
245    let scan = NonZeroUsize::new(work.source_left.min(work.target_left))
246        .ok_or_else(|| Failure::new("coverage_budget", "raw scan allowance is exhausted"))?;
247    let page = match compare::held_page(
248        &context.storage.reader,
249        &context.inputs.origin,
250        &context.inputs.target,
251        &context.inputs.source,
252        position.comparison.as_deref(),
253        scan,
254        work.nodes_left,
255    ) {
256        Ok(page) => page,
257        Err(error) if error.code == "node_budget_too_small" && work.nodes_left < NODE_BUDGET => {
258            return Ok(None);
259        }
260        Err(error) => return Err(error),
261    };
262    work.source_left = work
263        .source_left
264        .checked_sub(page.source_scanned)
265        .ok_or_else(|| Failure::new("coverage_budget", "source scan exceeded its allowance"))?;
266    work.target_left = work
267        .target_left
268        .checked_sub(page.target_scanned)
269        .ok_or_else(|| Failure::new("coverage_budget", "target scan exceeded its allowance"))?;
270    work.nodes_left = work
271        .nodes_left
272        .checked_sub(page.nodes_loaded)
273        .ok_or_else(|| Failure::new("coverage_budget", "comparison exceeded its node allowance"))?;
274    Ok(Some(page))
275}
276
277fn difference_step(
278    context: &Context<'_>,
279    position: &mut Position,
280    work: &mut Work,
281    limit: NonZeroUsize,
282) -> Result<(), Failure> {
283    while work.can_compare() && !work.full(limit) {
284        let Some(page) = comparison_page(context, position, work)? else {
285            return Ok(());
286        };
287        let keys: BTreeSet<_> = page
288            .source
289            .iter()
290            .chain(&page.target)
291            .map(haematite::ObservedChange::key)
292            .filter(|key| !key.starts_with(b"k\0"))
293            .collect();
294        for key in keys {
295            if let Some(owner) = owner_key(key)? {
296                let inspected = inspect(context, owner)?;
297                work.keys.push(key.to_vec());
298                append_missing(position, &mut work.missing, inspected.missing, limit);
299                if let Some(node) = inspected.incident {
300                    position.incident = Some(Incident {
301                        node,
302                        incoming: false,
303                        after: None,
304                    });
305                }
306                position.comparison = Some(compare::after_key(&page, key)?);
307                drain_incident(context, position, work, limit)?;
308            } else {
309                unsupported_conflict(context, key)?;
310                if key.starts_with(b"c\0")
311                    || key.starts_with(b"n\0")
312                    || key.starts_with(b"v\0")
313                    || key.starts_with(b"w\0")
314                {
315                    work.keys.push(key.to_vec());
316                }
317                position.comparison = Some(compare::after_key(&page, key)?);
318            }
319            if work.full(limit) || position.incident.is_some() || !position.pending.is_empty() {
320                return Ok(());
321            }
322        }
323        position.comparison = page.next_after;
324        if position.comparison.is_none() {
325            position.diff_done = true;
326            return Ok(());
327        }
328    }
329    Ok(())
330}
331
332fn append_missing(
333    position: &mut Position,
334    missing: &mut Vec<MissingChoice>,
335    found: Vec<MissingChoice>,
336    limit: NonZeroUsize,
337) {
338    for owner in found {
339        if missing.len() < limit.get() {
340            missing.push(owner);
341        } else {
342            position.pending.push(owner);
343        }
344    }
345}
346
347fn incident_step(
348    context: &Context<'_>,
349    position: &mut Position,
350    work: &mut Work,
351    limit: NonZeroUsize,
352) -> Result<(), Failure> {
353    let incident = position
354        .incident
355        .as_ref()
356        .ok_or_else(|| Failure::new("invalid_continuation", "incident work disappeared"))?;
357    let page = incident_page(context, incident, work, limit)?;
358    let incoming = incident.incoming;
359    let scanned = page.entries.len();
360    let charged = if scanned == 0 && page.next_after.is_some() {
361        1
362    } else {
363        scanned
364    };
365    work.incident_left = work
366        .incident_left
367        .checked_sub(charged)
368        .ok_or_else(|| Failure::new("coverage_budget", "incident scan exceeded its allowance"))?;
369    for edge in page.entries {
370        let last = incident_suffix(&edge, incoming);
371        if incoming && edge.from == edge.to {
372            continue;
373        }
374        let Some(owner) = inspect_incident_edge(context, edge)? else {
375            continue;
376        };
377        work.keys.push(owner.key);
378        append_missing(position, &mut work.missing, owner.missing, limit);
379        if work.full(limit) || !position.pending.is_empty() {
380            let incident = position
381                .incident
382                .as_mut()
383                .ok_or_else(|| Failure::new("invalid_continuation", "incident work disappeared"))?;
384            incident.after = Some(last);
385            return Ok(());
386        }
387    }
388    if let Some(after) = page.next_after {
389        let incident = position
390            .incident
391            .as_mut()
392            .ok_or_else(|| Failure::new("invalid_continuation", "incident work disappeared"))?;
393        incident.after = Some(after);
394    } else if incoming {
395        position.incident = None;
396    } else {
397        let incident = position
398            .incident
399            .as_mut()
400            .ok_or_else(|| Failure::new("invalid_continuation", "incident work disappeared"))?;
401        incident.incoming = true;
402        incident.after = None;
403    }
404    Ok(())
405}
406
407fn incident_page(
408    context: &Context<'_>,
409    incident: &Incident,
410    work: &Work,
411    limit: NonZeroUsize,
412) -> Result<reads::Page<Edge>, Failure> {
413    let remaining = PAGE_OWNER_LIMIT
414        .checked_sub(work.keys.len())
415        .ok_or_else(|| Failure::new("coverage_budget", "owner batch exceeded its allowance"))?;
416    let rows = NonZeroUsize::new(limit.get().min(remaining).min(work.incident_left))
417        .ok_or_else(|| Failure::new("coverage_budget", "incident allowance is zero"))?;
418    let page = if incident.incoming {
419        edges::incoming(
420            &context.storage.reader,
421            &context.inputs.target,
422            &incident.node,
423            None,
424            incident.after.as_deref(),
425            rows,
426            None,
427        )?
428    } else {
429        edges::out(
430            &context.storage.reader,
431            &context.inputs.target,
432            &incident.node,
433            None,
434            incident.after.as_deref(),
435            rows,
436            None,
437        )?
438    };
439    Ok(page)
440}
441
442fn inspect_incident_edge(
443    context: &Context<'_>,
444    edge: Edge,
445) -> Result<Option<IncidentOwner>, Failure> {
446    let owner = MergeOwner::Edge {
447        relation: edge.relation,
448        from: edge.from,
449        to: edge.to,
450    };
451    let key = owner.key()?;
452    let values = context.storage.choice_values(&context.inputs, &key)?;
453    let target = values.target.as_deref().ok_or_else(|| {
454        Failure::new(
455            "edge_index_inconsistent",
456            "incident index names an absent owner",
457        )
458    })?;
459    let properties: serde_json::Value = serde_json::from_slice(target).map_err(Failure::storage)?;
460    if properties != edge.properties {
461        return Err(Failure::new(
462            "edge_index_inconsistent",
463            "incident and owner properties differ",
464        ));
465    }
466    // Changed owners are handled by the difference scan, so this phase skips them.
467    if !values.unchanged() {
468        return Ok(None);
469    }
470    Ok(Some(IncidentOwner {
471        key,
472        missing: inspect(context, owner)?.missing,
473    }))
474}
475
476fn incident_suffix(edge: &Edge, incoming: bool) -> String {
477    let other = if incoming { &edge.from } else { &edge.to };
478    format!(
479        "{}\0{}\0{}",
480        edge.relation, other.collection, other.document_id
481    )
482}
483
484fn inspect(context: &Context<'_>, owner: MergeOwner) -> Result<Inspection, Failure> {
485    let key = owner.key()?;
486    let values = context.storage.choice_values(&context.inputs, &key)?;
487    let token = values.token(&context.inputs.pins, &key)?;
488    let row = context
489        .storage
490        .read_choice_row(&context.service, context.merge_id, &token)?;
491    if row.as_ref().is_some_and(|row| row.owner != owner) {
492        return Err(Failure::new(
493            "choice_row_corrupt",
494            "coverage row names another owner",
495        ));
496    }
497    let conflict = values.ancestor != values.target
498        && values.ancestor != values.source
499        && values.target != values.source;
500    let mut missing = Vec::new();
501    let incident = match &owner {
502        MergeOwner::Document {
503            collection,
504            document_id,
505        } => {
506            let result = document_presence(&values, row.as_ref())?;
507            if result.is_none_or(|present| present != values.ancestor.is_some()) {
508                Some(Node {
509                    collection: collection.clone(),
510                    document_id: document_id.clone(),
511                })
512            } else {
513                None
514            }
515        }
516        MergeOwner::Edge { from, to, .. } => {
517            if edge_settled_absent(&values, row.as_ref()) {
518                return Ok(Inspection {
519                    missing,
520                    incident: None,
521                });
522            }
523            let from = endpoint(context, from, &mut missing)?;
524            let to = endpoint(context, to, &mut missing)?;
525            if from == Some(false) || to == Some(false) {
526                if row
527                    .as_ref()
528                    .is_some_and(|row| row.choice != MergeChoice::RemoveEdge)
529                {
530                    return Err(Failure::new(
531                        "choice_row_corrupt",
532                        "settled dangling edge has a keeping choice",
533                    ));
534                }
535                if row.is_none() {
536                    missing.push(MissingChoice {
537                        owner: owner.clone(),
538                        token: token.clone(),
539                    });
540                }
541            } else if row
542                .as_ref()
543                .is_some_and(|row| row.choice == MergeChoice::RemoveEdge)
544                && from == Some(true)
545                && to == Some(true)
546            {
547                return Err(Failure::new(
548                    "choice_row_corrupt",
549                    "edge removal has no absent endpoint",
550                ));
551            }
552            None
553        }
554    };
555    if conflict && row.is_none() && !missing.iter().any(|missing| missing.token == token) {
556        missing.push(MissingChoice { owner, token });
557    }
558    Ok(Inspection { missing, incident })
559}
560
561fn edge_settled_absent(values: &Triple, row: Option<&MergeChoiceRow>) -> bool {
562    match row.map(|row| row.choice) {
563        Some(MergeChoice::TakeSource) => values.source.is_none(),
564        Some(MergeChoice::TakeTarget) => values.target.is_none(),
565        None if values.ancestor == values.target => values.source.is_none(),
566        None if values.ancestor == values.source || values.target == values.source => {
567            values.target.is_none()
568        }
569        Some(MergeChoice::RemoveEdge) | None => false,
570    }
571}
572
573fn endpoint(
574    context: &Context<'_>,
575    node: &Node,
576    missing: &mut Vec<MissingChoice>,
577) -> Result<Option<bool>, Failure> {
578    let owner = MergeOwner::Document {
579        collection: node.collection.clone(),
580        document_id: node.document_id.clone(),
581    };
582    let key = owner.key()?;
583    let values = context.storage.choice_values(&context.inputs, &key)?;
584    let token = values.token(&context.inputs.pins, &key)?;
585    let row = context
586        .storage
587        .read_choice_row(&context.service, context.merge_id, &token)?;
588    if row.as_ref().is_some_and(|row| row.owner != owner) {
589        return Err(Failure::new(
590            "choice_row_corrupt",
591            "endpoint row names another owner",
592        ));
593    }
594    let presence = document_presence(&values, row.as_ref())?;
595    if presence.is_none() && !missing.iter().any(|missing| missing.token == token) {
596        missing.push(MissingChoice { owner, token });
597    }
598    Ok(presence)
599}
600
601fn document_presence(
602    values: &Triple,
603    row: Option<&MergeChoiceRow>,
604) -> Result<Option<bool>, Failure> {
605    if let Some(row) = row {
606        return match row.choice {
607            MergeChoice::TakeSource => Ok(Some(values.source.is_some())),
608            MergeChoice::TakeTarget => Ok(Some(values.target.is_some())),
609            MergeChoice::RemoveEdge => Err(Failure::new(
610                "choice_row_corrupt",
611                "document row removes an edge",
612            )),
613        };
614    }
615    if values.target.is_some() == values.source.is_some() {
616        Ok(Some(values.target.is_some()))
617    } else if values.ancestor.is_none() {
618        Ok(Some(true))
619    } else {
620        Ok(None)
621    }
622}
623
624fn unsupported_conflict(context: &Context<'_>, key: &[u8]) -> Result<(), Failure> {
625    if key.starts_with(b"w\0") {
626        let values = context.storage.choice_values(&context.inputs, key)?;
627        choice::embedded_field(
628            key,
629            values.ancestor.as_deref(),
630            values.target.as_deref(),
631            values.source.as_deref(),
632        )?;
633        return Ok(());
634    }
635    let parts_count = if key.starts_with(b"c\0") || key.starts_with(b"n\0") {
636        2
637    } else if key.starts_with(b"v\0") {
638        4
639    } else {
640        return Ok(());
641    };
642    let text = std::str::from_utf8(key).map_err(Failure::storage)?;
643    let parts: Vec<_> = text.split('\0').collect();
644    if parts.len() != parts_count {
645        return Err(Failure::new(
646            "corrupt_key",
647            "unsupported owner key has invalid components",
648        ));
649    }
650    for part in &parts[1..] {
651        identifier(part)?;
652    }
653    let values = context.storage.choice_values(&context.inputs, key)?;
654    if values.ancestor != values.target
655        && values.ancestor != values.source
656        && values.target != values.source
657    {
658        return Err(Failure::new(
659            "choice_owner_unsupported",
660            format!(
661                "family {} owner {} has conflicting values and no choice kind",
662                parts[0],
663                parts[1..].join("/"),
664            ),
665        ));
666    }
667    Ok(())
668}
669
670pub(super) fn owner_key(key: &[u8]) -> Result<Option<MergeOwner>, Failure> {
671    if key.starts_with(b"d\0") {
672        let text = std::str::from_utf8(key).map_err(Failure::storage)?;
673        let parts: Vec<_> = text.split('\0').collect();
674        let ["d", collection, document_id] = parts.as_slice() else {
675            return Err(Failure::new(
676                "corrupt_key",
677                "document key has invalid components",
678            ));
679        };
680        let owner = MergeOwner::Document {
681            collection: (*collection).to_owned(),
682            document_id: (*document_id).to_owned(),
683        };
684        owner.key()?;
685        Ok(Some(owner))
686    } else if key.starts_with(b"e\0") {
687        let text = std::str::from_utf8(key).map_err(Failure::storage)?;
688        let edge = edges::decode_edge(b"", text, b"{}")?;
689        Ok(Some(MergeOwner::Edge {
690            relation: edge.relation,
691            from: edge.from,
692            to: edge.to,
693        }))
694    } else {
695        Ok(None)
696    }
697}