1use 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 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 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 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}