1use std::any::{Any, TypeId};
8use std::cell::RefCell;
9use std::cmp::Ordering as CmpOrdering;
10use std::collections::{BTreeMap, BTreeSet, HashMap};
11use std::error::Error;
12use std::fmt;
13use std::rc::Rc;
14use std::sync::{Arc, Mutex, OnceLock};
15
16use serde::{Deserialize, Serialize};
17use serde_json::{Number, Value};
18
19use crate::ctx::Ctx;
20use crate::dispatcher::NodeFn;
21use crate::graph::{Graph, GraphNodeOpts, GraphOptions, RestoreFactoryMeta};
22use crate::json::validate_strict_json_value;
23use crate::node::{Core, NodeRestoreRuntime, Status};
24use crate::protocol::AnyValue;
25use crate::versioning::{
26 node_version_to_json, validate_node_version_json, verify_restored_node_version,
27};
28
29pub const GRAPH_CHECKPOINT_VERSION: &str = "graphrefly.checkpoint.v1";
31
32pub type GraphCheckpointJson = Value;
34
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
36#[serde(tag = "kind")]
37pub enum GraphCheckpointValue {
39 #[serde(rename = "SENTINEL")]
40 Sentinel,
42 #[serde(rename = "DATA")]
43 Data {
45 data: GraphCheckpointJson,
47 },
48}
49
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51#[serde(tag = "kind")]
52pub enum GraphCheckpointTerminal {
54 #[serde(rename = "none")]
55 None,
57 #[serde(rename = "COMPLETE")]
58 Complete,
60 #[serde(rename = "ERROR")]
61 Error {
63 error: GraphCheckpointJson,
65 },
66}
67
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
69#[serde(tag = "kind")]
70pub enum GraphCheckpointFactory {
72 #[serde(rename = "registry-ref")]
73 RegistryRef {
75 #[serde(rename = "ref")]
76 ref_: String,
78 #[serde(skip_serializing_if = "Option::is_none")]
79 config: Option<GraphCheckpointJson>,
81 #[serde(rename = "configVersion", skip_serializing_if = "Option::is_none")]
82 config_version: Option<GraphCheckpointJson>,
84 },
85 #[serde(rename = "local-only")]
86 LocalOnly {
88 name: String,
90 reason: String,
92 },
93}
94
95#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
96pub struct GraphCheckpointNode {
98 pub id: String,
100 #[serde(skip_serializing_if = "Option::is_none")]
101 pub name: Option<String>,
103 pub factory: GraphCheckpointFactory,
105 pub status: String,
107 pub deps: Vec<String>,
109 pub value: GraphCheckpointValue,
111 #[serde(rename = "backendState", skip_serializing_if = "Option::is_none")]
112 pub backend_state: Option<GraphCheckpointJson>,
114 #[serde(skip_serializing_if = "Option::is_none")]
115 pub version: Option<GraphCheckpointJson>,
117 pub terminal: GraphCheckpointTerminal,
119 pub lifecycle: GraphCheckpointLifecycle,
121 #[serde(rename = "ctxState")]
122 pub ctx_state: GraphCheckpointCtxState,
124 #[serde(skip_serializing_if = "Option::is_none")]
125 pub meta: Option<BTreeMap<String, GraphCheckpointJson>>,
127}
128
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
130pub struct GraphCheckpointLifecycle {
132 pub activated: bool,
134 #[serde(rename = "hasCalledFnOnce")]
135 pub has_called_fn_once: bool,
137}
138
139#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
140pub struct GraphCheckpointCtxState {
142 pub persist: bool,
144 pub value: GraphCheckpointValue,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149pub struct GraphCheckpointEdge {
151 pub from: String,
153 pub to: String,
155}
156
157#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
158pub struct GraphCheckpointMount {
160 pub at: String,
162 pub checkpoint: GraphCheckpoint,
164}
165
166#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
167pub struct GraphCheckpoint {
169 pub version: String,
171 #[serde(skip_serializing_if = "Option::is_none")]
172 pub name: Option<String>,
174 pub nodes: Vec<GraphCheckpointNode>,
176 pub edges: Vec<GraphCheckpointEdge>,
178 #[serde(skip_serializing_if = "Option::is_none")]
179 pub mounts: Option<Vec<GraphCheckpointMount>>,
181}
182
183#[derive(Clone)]
184pub(crate) struct CheckpointEntry {
185 pub id: String,
186 pub name: Option<String>,
187 pub factory: String,
188 pub meta: BTreeMap<String, String>,
189 pub restore: Option<RestoreFactoryMeta>,
190 pub core: Core,
191 pub unregistered: bool,
192}
193
194#[derive(Debug, Clone)]
195pub struct GraphRestoreError(String);
197
198impl GraphRestoreError {
199 pub fn new(msg: impl Into<String>) -> Self {
201 Self(msg.into())
202 }
203}
204
205impl fmt::Display for GraphRestoreError {
206 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
207 f.write_str(&self.0)
208 }
209}
210
211impl Error for GraphRestoreError {}
212
213pub type GraphRestoreResult<T> = Result<T, GraphRestoreError>;
215type RestoreResult<T> = GraphRestoreResult<T>;
216type JsonDefinitionFn = dyn Fn(&GraphCheckpointJson) -> RestoreResult<GraphCheckpointJson>;
217type BackendStateContributor = dyn Fn(&str) -> Result<GraphCheckpointJson, String>;
218type CustomRestoreFn = dyn Fn(&Graph) -> RestoreResult<Core>;
219type CheckpointJsonEncoder =
220 dyn Fn(&dyn Any, &str) -> RestoreResult<GraphCheckpointJson> + Send + Sync;
221
222static CHECKPOINT_JSON_ENCODERS: OnceLock<Mutex<HashMap<TypeId, Arc<CheckpointJsonEncoder>>>> =
223 OnceLock::new();
224
225thread_local! {
226 static BACKEND_STATE_CONTRIBUTORS: RefCell<HashMap<(usize, usize, u64), Rc<BackendStateContributor>>> =
227 RefCell::new(HashMap::new());
228}
229
230#[doc(hidden)]
235pub fn register_checkpoint_json_encoder<T: 'static>(
236 encoder: impl Fn(&T, &str) -> RestoreResult<GraphCheckpointJson> + Send + Sync + 'static,
237) {
238 let encoders = CHECKPOINT_JSON_ENCODERS.get_or_init(|| Mutex::new(HashMap::new()));
239 encoders
240 .lock()
241 .expect("checkpoint JSON encoder registry poisoned")
242 .insert(
243 TypeId::of::<T>(),
244 Arc::new(move |value, path| {
245 let typed = value.downcast_ref::<T>().ok_or_else(|| {
246 GraphRestoreError::new(format!(
247 "checkpoint: value at {path} did not match registered checkpoint encoder type"
248 ))
249 })?;
250 encoder(typed, path)
251 }),
252 );
253}
254
255pub(crate) fn register_backend_state_contributor(
257 core: &Core,
258 contributor: Rc<BackendStateContributor>,
259) {
260 BACKEND_STATE_CONTRIBUTORS.with(|contributors| {
261 contributors
262 .borrow_mut()
263 .insert(core.identity_key(), contributor);
264 });
265}
266
267pub(crate) fn unregister_backend_state_contributor(core: &Core) {
268 unregister_backend_state_contributor_key(core.identity_key());
269}
270
271pub(crate) fn unregister_backend_state_contributor_key(key: (usize, usize, u64)) {
272 BACKEND_STATE_CONTRIBUTORS.with(|contributors| {
273 contributors.borrow_mut().remove(&key);
274 });
275}
276
277fn checkpoint_backend_state(core: &Core, path: &str) -> RestoreResult<Option<GraphCheckpointJson>> {
278 let contributor = BACKEND_STATE_CONTRIBUTORS
279 .with(|contributors| contributors.borrow().get(&core.identity_key()).cloned());
280 contributor
281 .map(|contributor| {
282 let value = contributor(path).map_err(|err| {
283 GraphRestoreError::new(format!("checkpoint: backendState at {path}: {err}"))
284 })?;
285 validate_checkpoint_json(&value, path)?;
286 Ok(value)
287 })
288 .transpose()
289}
290
291#[derive(Clone)]
292pub struct GraphRestoreDefinition {
294 pub ref_: String,
296 f: Rc<JsonDefinitionFn>,
297}
298
299impl GraphRestoreDefinition {
300 pub fn json(
302 ref_: impl Into<String>,
303 f: impl Fn(&GraphCheckpointJson) -> RestoreResult<GraphCheckpointJson> + 'static,
304 ) -> Self {
305 Self {
306 ref_: ref_.into(),
307 f: Rc::new(f),
308 }
309 }
310
311 fn call(&self, value: &GraphCheckpointJson) -> RestoreResult<GraphCheckpointJson> {
312 (self.f)(value)
313 }
314}
315
316pub trait GraphRestoreDescriptor {
318 fn ref_(&self) -> &str;
320 fn define(&self, ctx: RestoreDefineCtx<'_>) -> RestoreResult<RestoreNodeDefinition>;
322}
323
324#[derive(Clone)]
325pub enum GraphRestoreEntry {
327 Descriptor(Rc<dyn GraphRestoreDescriptor>),
329 Definition(GraphRestoreDefinition),
331}
332
333impl GraphRestoreEntry {
334 pub fn descriptor(d: impl GraphRestoreDescriptor + 'static) -> Self {
336 Self::Descriptor(Rc::new(d))
337 }
338
339 pub fn definition(d: GraphRestoreDefinition) -> Self {
341 Self::Definition(d)
342 }
343}
344
345#[derive(Clone, Default)]
346pub struct GraphRestoreRegistry {
348 entries: BTreeMap<String, GraphRestoreEntry>,
349}
350
351impl GraphRestoreRegistry {
352 pub fn try_new(entries: impl IntoIterator<Item = GraphRestoreEntry>) -> RestoreResult<Self> {
354 let mut out = Self::default();
355 for entry in entries {
356 let key = match &entry {
357 GraphRestoreEntry::Descriptor(d) => d.ref_().to_owned(),
358 GraphRestoreEntry::Definition(d) => d.ref_.clone(),
359 };
360 if out.entries.insert(key.clone(), entry).is_some() {
361 return Err(GraphRestoreError::new(format!(
362 "duplicate restore registry ref '{key}'"
363 )));
364 }
365 }
366 Ok(out)
367 }
368
369 pub fn new(entries: impl IntoIterator<Item = GraphRestoreEntry>) -> Self {
371 Self::try_new(entries).expect("restore registry must not contain duplicate refs")
372 }
373
374 fn descriptor(&self, ref_: &str) -> Option<Rc<dyn GraphRestoreDescriptor>> {
375 match self.entries.get(ref_) {
376 Some(GraphRestoreEntry::Descriptor(d)) => Some(d.clone()),
377 _ => None,
378 }
379 }
380
381 fn definition(&self, ref_: &str) -> Option<GraphRestoreDefinition> {
382 match self.entries.get(ref_) {
383 Some(GraphRestoreEntry::Definition(d)) => Some(d.clone()),
384 _ => None,
385 }
386 }
387}
388
389pub fn restore_registry(
391 entries: impl IntoIterator<Item = GraphRestoreEntry>,
392) -> GraphRestoreRegistry {
393 GraphRestoreRegistry::new(entries)
394}
395
396pub fn default_restore_registry() -> GraphRestoreRegistry {
398 GraphRestoreRegistry::new([
399 GraphRestoreEntry::descriptor(StateRestoreDescriptor),
400 GraphRestoreEntry::descriptor(MapJsonRestoreDescriptor),
401 GraphRestoreEntry::descriptor(ReactiveListDeltaRestoreDescriptor),
402 GraphRestoreEntry::descriptor(ReactiveListSnapshotRestoreDescriptor),
403 GraphRestoreEntry::descriptor(ReactiveLogDeltaRestoreDescriptor),
404 GraphRestoreEntry::descriptor(ReactiveLogSnapshotRestoreDescriptor),
405 GraphRestoreEntry::descriptor(ReactiveMapDeltaRestoreDescriptor),
406 GraphRestoreEntry::descriptor(ReactiveMapSnapshotRestoreDescriptor),
407 GraphRestoreEntry::descriptor(ReactiveIndexDeltaRestoreDescriptor),
408 GraphRestoreEntry::descriptor(ReactiveIndexSnapshotRestoreDescriptor),
409 ])
410}
411
412pub struct RestoreDefineCtx<'a> {
414 pub id: &'a str,
416 pub deps: &'a [String],
418 pub config: Option<&'a GraphCheckpointJson>,
420 pub config_version: Option<&'a GraphCheckpointJson>,
422 pub checkpoint: &'a GraphCheckpointNode,
424 registry: &'a GraphRestoreRegistry,
425}
426
427impl RestoreDefineCtx<'_> {
428 pub fn resolve_definition(&self, ref_: &str) -> RestoreResult<GraphRestoreDefinition> {
430 self.registry.definition(ref_).ok_or_else(|| {
431 GraphRestoreError::new(format!(
432 "restore_graph: missing function definition for '{ref_}' (node '{}')",
433 self.id
434 ))
435 })
436 }
437}
438
439pub enum RestoreNodeKind {
441 StateJson,
443 NodeJson(NodeFn),
445 Custom(Rc<CustomRestoreFn>),
447}
448
449pub struct RestoreNodeDefinition {
451 pub factory: String,
453 pub kind: RestoreNodeKind,
455 pub opts: GraphNodeOpts,
457}
458
459pub struct StateRestoreDescriptor;
461
462impl GraphRestoreDescriptor for StateRestoreDescriptor {
463 fn ref_(&self) -> &str {
464 "state"
465 }
466
467 fn define(&self, ctx: RestoreDefineCtx<'_>) -> RestoreResult<RestoreNodeDefinition> {
468 if !ctx.deps.is_empty() {
469 return Err(GraphRestoreError::new(format!(
470 "restore_graph: state node '{}' cannot restore deps",
471 ctx.id
472 )));
473 }
474 if ctx.config.is_some() {
475 return Err(GraphRestoreError::new(
476 "restore_graph: built-in state descriptor does not accept config",
477 ));
478 }
479 if ctx.config_version.is_some() {
480 return Err(GraphRestoreError::new(
481 "restore_graph: built-in state descriptor does not accept configVersion",
482 ));
483 }
484 Ok(RestoreNodeDefinition {
485 factory: "state".to_owned(),
486 kind: RestoreNodeKind::StateJson,
487 opts: restored_opts(ctx.checkpoint)?,
488 })
489 }
490}
491
492pub struct MapJsonRestoreDescriptor;
494
495impl GraphRestoreDescriptor for MapJsonRestoreDescriptor {
496 fn ref_(&self) -> &str {
497 "map"
498 }
499
500 fn define(&self, ctx: RestoreDefineCtx<'_>) -> RestoreResult<RestoreNodeDefinition> {
501 if ctx.deps.len() != 1 {
502 return Err(GraphRestoreError::new(format!(
503 "restore_graph: map node '{}' requires exactly one dep",
504 ctx.id
505 )));
506 }
507 let config = ctx.config.and_then(Value::as_object).ok_or_else(|| {
508 GraphRestoreError::new("restore_graph: 'map' descriptor requires object config")
509 })?;
510 if ctx.config_version.is_some() {
511 return Err(GraphRestoreError::new(
512 "restore_graph: built-in map descriptor does not accept configVersion",
513 ));
514 }
515 let fn_ref = config.get("fn").and_then(Value::as_str).ok_or_else(|| {
516 GraphRestoreError::new("restore_graph: 'map' config.fn must be a string definition ref")
517 })?;
518 let definition = ctx.resolve_definition(fn_ref)?;
519 let body: NodeFn = Rc::new(move |ctx: &Ctx| {
520 for value in ctx.batch::<GraphCheckpointJson>(0) {
521 match definition.call(value.as_ref()) {
522 Ok(out) => ctx.emit(out),
523 Err(err) => panic!("{err}"),
524 }
525 }
526 });
527 Ok(RestoreNodeDefinition {
528 factory: "map".to_owned(),
529 kind: RestoreNodeKind::NodeJson(body),
530 opts: restored_opts(ctx.checkpoint)?,
531 })
532 }
533}
534
535#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
536#[serde(transparent)]
537struct CheckpointJsonOrd(GraphCheckpointJson);
538
539impl Ord for CheckpointJsonOrd {
540 fn cmp(&self, other: &Self) -> CmpOrdering {
541 let left = strict_canonical_json_bytes_for_ord(&self.0);
542 let right = strict_canonical_json_bytes_for_ord(&other.0);
543 left.cmp(&right)
544 }
545}
546
547impl PartialOrd for CheckpointJsonOrd {
548 fn partial_cmp(&self, other: &Self) -> Option<CmpOrdering> {
549 Some(self.cmp(other))
550 }
551}
552
553fn strict_canonical_json_bytes_for_ord(value: &GraphCheckpointJson) -> Vec<u8> {
554 crate::json::strict_canonical_json_bytes(value)
555 .expect("CheckpointJsonOrd is constructed only after strict JSON validation")
556}
557
558fn collection_base_id(id: &str, suffix: &str, ref_: &str) -> RestoreResult<String> {
559 let Some(base) = id.strip_suffix(suffix) else {
560 return Err(GraphRestoreError::new(format!(
561 "restore_graph: '{ref_}' node '{id}' must end with '{suffix}'"
562 )));
563 };
564 if base.is_empty() {
565 return Err(GraphRestoreError::new(format!(
566 "restore_graph: '{ref_}' node '{id}' has no collection name"
567 )));
568 }
569 Ok(base.to_owned())
570}
571
572fn reject_collection_config(
573 config: Option<&GraphCheckpointJson>,
574 config_version: Option<&GraphCheckpointJson>,
575 ref_: &str,
576) -> RestoreResult<()> {
577 if config.is_some() || config_version.is_some() {
578 return Err(GraphRestoreError::new(format!(
579 "restore_graph: '{ref_}' descriptor does not accept config"
580 )));
581 }
582 Ok(())
583}
584
585fn collection_log_max_size_config(
586 config: Option<&GraphCheckpointJson>,
587 config_version: Option<&GraphCheckpointJson>,
588 ref_: &str,
589) -> RestoreResult<Option<usize>> {
590 if config_version.is_some() {
591 return Err(GraphRestoreError::new(format!(
592 "restore_graph: '{ref_}' descriptor does not accept configVersion"
593 )));
594 }
595 let Some(config) = config else {
596 return Ok(None);
597 };
598 let GraphCheckpointJson::Object(config) = config else {
599 return Err(GraphRestoreError::new(format!(
600 "restore_graph: '{ref_}' descriptor requires object config"
601 )));
602 };
603 if config.is_empty() {
604 return Ok(None);
605 }
606 if config.len() != 1 || !config.contains_key("maxSize") {
607 return Err(GraphRestoreError::new(format!(
608 "restore_graph: '{ref_}' config only accepts maxSize"
609 )));
610 }
611 let max_size = config
612 .get("maxSize")
613 .expect("checked")
614 .as_u64()
615 .ok_or_else(|| {
616 GraphRestoreError::new(format!(
617 "restore_graph: '{ref_}' config.maxSize must be a positive integer"
618 ))
619 })?;
620 if max_size == 0 {
621 return Err(GraphRestoreError::new(format!(
622 "restore_graph: '{ref_}' config.maxSize must be a positive integer"
623 )));
624 }
625 usize::try_from(max_size).map(Some).map_err(|_| {
626 GraphRestoreError::new(format!(
627 "restore_graph: '{ref_}' config.maxSize exceeds usize"
628 ))
629 })
630}
631
632fn backend_array(
633 node: &GraphCheckpointNode,
634 ref_: &str,
635) -> RestoreResult<Vec<GraphCheckpointJson>> {
636 let Some(GraphCheckpointJson::Array(items)) = &node.backend_state else {
637 return Err(GraphRestoreError::new(format!(
638 "restore_graph: '{ref_}' node '{}' requires array backendState",
639 node.id
640 )));
641 };
642 Ok(items.clone())
643}
644
645fn map_entries(
646 node: &GraphCheckpointNode,
647 ref_: &str,
648) -> RestoreResult<Vec<(CheckpointJsonOrd, GraphCheckpointJson)>> {
649 let mut out = Vec::new();
650 let mut seen = BTreeSet::new();
651 for (i, entry) in backend_array(node, ref_)?.into_iter().enumerate() {
652 let GraphCheckpointJson::Array(pair) = entry else {
653 return Err(GraphRestoreError::new(format!(
654 "restore_graph: {}.backendState[{i}] must be a [key,value] map entry",
655 node.id
656 )));
657 };
658 if pair.len() != 2 {
659 return Err(GraphRestoreError::new(format!(
660 "restore_graph: {}.backendState[{i}] must be a [key,value] map entry",
661 node.id
662 )));
663 }
664 let key = CheckpointJsonOrd(pair[0].clone());
665 if !seen.insert(key.clone()) {
666 return Err(GraphRestoreError::new(format!(
667 "restore_graph: {}.backendState[{i}][0] duplicates an earlier map key",
668 node.id
669 )));
670 }
671 out.push((key, pair[1].clone()));
672 }
673 Ok(out)
674}
675
676fn index_rows(
677 node: &GraphCheckpointNode,
678 ref_: &str,
679) -> RestoreResult<
680 Vec<
681 crate::data_structures::IndexRow<CheckpointJsonOrd, CheckpointJsonOrd, GraphCheckpointJson>,
682 >,
683> {
684 let mut out = Vec::new();
685 let mut seen = BTreeSet::new();
686 for (i, row) in backend_array(node, ref_)?.into_iter().enumerate() {
687 let GraphCheckpointJson::Object(obj) = row else {
688 return Err(GraphRestoreError::new(format!(
689 "restore_graph: {}.backendState[{i}] must be an index row object",
690 node.id
691 )));
692 };
693 if obj.len() != 3
694 || !obj.contains_key("primary")
695 || !obj.contains_key("secondary")
696 || !obj.contains_key("value")
697 {
698 return Err(GraphRestoreError::new(format!(
699 "restore_graph: {}.backendState[{i}] must contain primary, secondary, and value",
700 node.id
701 )));
702 }
703 let primary = CheckpointJsonOrd(obj.get("primary").expect("checked").clone());
704 if !seen.insert(primary.clone()) {
705 return Err(GraphRestoreError::new(format!(
706 "restore_graph: {}.backendState[{i}].primary duplicates an earlier index row",
707 node.id
708 )));
709 }
710 out.push(crate::data_structures::IndexRow {
711 primary,
712 secondary: CheckpointJsonOrd(obj.get("secondary").expect("checked").clone()),
713 value: obj.get("value").expect("checked").clone(),
714 });
715 }
716 Ok(out)
717}
718
719pub struct ReactiveListDeltaRestoreDescriptor;
721pub struct ReactiveListSnapshotRestoreDescriptor;
723pub struct ReactiveLogDeltaRestoreDescriptor;
725pub struct ReactiveLogSnapshotRestoreDescriptor;
727pub struct ReactiveMapDeltaRestoreDescriptor;
729pub struct ReactiveMapSnapshotRestoreDescriptor;
731pub struct ReactiveIndexDeltaRestoreDescriptor;
733pub struct ReactiveIndexSnapshotRestoreDescriptor;
735
736impl GraphRestoreDescriptor for ReactiveListDeltaRestoreDescriptor {
737 fn ref_(&self) -> &str {
738 "reactiveList.delta"
739 }
740
741 fn define(&self, ctx: RestoreDefineCtx<'_>) -> RestoreResult<RestoreNodeDefinition> {
742 collection_delta_definition(
743 ctx,
744 "reactiveList.delta",
745 ".delta",
746 false,
747 |graph: &Graph, base: String, node: &GraphCheckpointNode| {
748 let collection = crate::data_structures::reactive_list::<GraphCheckpointJson>(
749 backend_array(node, "reactiveList.delta")?,
750 crate::data_structures::ReactiveListOptions::named(base).graph(graph.clone()),
751 );
752 Ok(collection.delta.erased())
753 },
754 )
755 }
756}
757
758impl GraphRestoreDescriptor for ReactiveListSnapshotRestoreDescriptor {
759 fn ref_(&self) -> &str {
760 "reactiveList.snapshot"
761 }
762
763 fn define(&self, ctx: RestoreDefineCtx<'_>) -> RestoreResult<RestoreNodeDefinition> {
764 collection_existing_definition(ctx, "reactiveList.snapshot", ".snapshot")
765 }
766}
767
768impl GraphRestoreDescriptor for ReactiveLogDeltaRestoreDescriptor {
769 fn ref_(&self) -> &str {
770 "reactiveLog.delta"
771 }
772
773 fn define(&self, ctx: RestoreDefineCtx<'_>) -> RestoreResult<RestoreNodeDefinition> {
774 let max_size = collection_log_max_size_config(ctx.config, ctx.config_version, self.ref_())?;
775 collection_delta_definition(
776 ctx,
777 "reactiveLog.delta",
778 ".delta",
779 true,
780 move |graph: &Graph, base: String, node: &GraphCheckpointNode| {
781 let state = backend_array(node, "reactiveLog.delta")?;
782 if let Some(max_size) = max_size {
783 if state.len() > max_size {
784 return Err(GraphRestoreError::new(format!(
785 "restore_graph: 'reactiveLog.delta' node '{}' backendState exceeds config.maxSize",
786 node.id
787 )));
788 }
789 }
790 let mut options =
791 crate::data_structures::ReactiveLogOptions::named(base).graph(graph.clone());
792 if let Some(max_size) = max_size {
793 options = options.max_size(max_size);
794 }
795 let collection =
796 crate::data_structures::reactive_log::<GraphCheckpointJson>(state, options);
797 Ok(collection.delta.erased())
798 },
799 )
800 }
801}
802
803impl GraphRestoreDescriptor for ReactiveLogSnapshotRestoreDescriptor {
804 fn ref_(&self) -> &str {
805 "reactiveLog.snapshot"
806 }
807
808 fn define(&self, ctx: RestoreDefineCtx<'_>) -> RestoreResult<RestoreNodeDefinition> {
809 collection_existing_definition(ctx, "reactiveLog.snapshot", ".snapshot")
810 }
811}
812
813impl GraphRestoreDescriptor for ReactiveMapDeltaRestoreDescriptor {
814 fn ref_(&self) -> &str {
815 "reactiveMap.delta"
816 }
817
818 fn define(&self, ctx: RestoreDefineCtx<'_>) -> RestoreResult<RestoreNodeDefinition> {
819 collection_delta_definition(
820 ctx,
821 "reactiveMap.delta",
822 ".delta",
823 false,
824 |graph: &Graph, base: String, node: &GraphCheckpointNode| {
825 let collection =
826 crate::data_structures::reactive_map::<CheckpointJsonOrd, GraphCheckpointJson>(
827 map_entries(node, "reactiveMap.delta")?,
828 crate::data_structures::ReactiveMapOptions::named(base)
829 .graph(graph.clone()),
830 );
831 Ok(collection.delta.erased())
832 },
833 )
834 }
835}
836
837impl GraphRestoreDescriptor for ReactiveMapSnapshotRestoreDescriptor {
838 fn ref_(&self) -> &str {
839 "reactiveMap.snapshot"
840 }
841
842 fn define(&self, ctx: RestoreDefineCtx<'_>) -> RestoreResult<RestoreNodeDefinition> {
843 collection_existing_definition(ctx, "reactiveMap.snapshot", ".snapshot")
844 }
845}
846
847impl GraphRestoreDescriptor for ReactiveIndexDeltaRestoreDescriptor {
848 fn ref_(&self) -> &str {
849 "reactiveIndex.delta"
850 }
851
852 fn define(&self, ctx: RestoreDefineCtx<'_>) -> RestoreResult<RestoreNodeDefinition> {
853 collection_delta_definition(
854 ctx,
855 "reactiveIndex.delta",
856 ".delta",
857 false,
858 |graph: &Graph, base: String, node: &GraphCheckpointNode| {
859 let collection = crate::data_structures::reactive_index::<
860 CheckpointJsonOrd,
861 CheckpointJsonOrd,
862 GraphCheckpointJson,
863 >(
864 index_rows(node, "reactiveIndex.delta")?,
865 crate::data_structures::ReactiveIndexOptions::named(base).graph(graph.clone()),
866 );
867 Ok(collection.delta.erased())
868 },
869 )
870 }
871}
872
873impl GraphRestoreDescriptor for ReactiveIndexSnapshotRestoreDescriptor {
874 fn ref_(&self) -> &str {
875 "reactiveIndex.snapshot"
876 }
877
878 fn define(&self, ctx: RestoreDefineCtx<'_>) -> RestoreResult<RestoreNodeDefinition> {
879 collection_existing_definition(ctx, "reactiveIndex.snapshot", ".snapshot")
880 }
881}
882
883fn collection_delta_definition(
884 ctx: RestoreDefineCtx<'_>,
885 ref_: &'static str,
886 suffix: &str,
887 allow_config: bool,
888 restore: impl Fn(&Graph, String, &GraphCheckpointNode) -> RestoreResult<Core> + 'static,
889) -> RestoreResult<RestoreNodeDefinition> {
890 if !ctx.deps.is_empty() {
891 return Err(GraphRestoreError::new(format!(
892 "restore_graph: '{ref_}' node '{}' cannot restore deps",
893 ctx.id
894 )));
895 }
896 if !allow_config {
897 reject_collection_config(ctx.config, ctx.config_version, ref_)?;
898 }
899 let base = collection_base_id(ctx.id, suffix, ref_)?;
900 let checkpoint = ctx.checkpoint.clone();
901 Ok(RestoreNodeDefinition {
902 factory: ref_.to_owned(),
903 kind: RestoreNodeKind::Custom(Rc::new(move |graph| {
904 restore(graph, base.clone(), &checkpoint)
905 })),
906 opts: restored_opts(ctx.checkpoint)?,
907 })
908}
909
910fn collection_existing_definition(
911 ctx: RestoreDefineCtx<'_>,
912 ref_: &'static str,
913 suffix: &str,
914) -> RestoreResult<RestoreNodeDefinition> {
915 reject_collection_config(ctx.config, ctx.config_version, ref_)?;
916 let id = ctx.id.to_owned();
917 let _base = collection_base_id(ctx.id, suffix, ref_)?;
918 Ok(RestoreNodeDefinition {
919 factory: ref_.to_owned(),
920 kind: RestoreNodeKind::Custom(Rc::new(move |graph| {
921 graph.find(&id).map(|node| node.core()).ok_or_else(|| {
922 GraphRestoreError::new(format!(
923 "restore_graph: '{ref_}' node '{id}' was not restored with its collection"
924 ))
925 })
926 })),
927 opts: restored_opts(ctx.checkpoint)?,
928 })
929}
930
931#[derive(Clone)]
932pub struct RestoreGraphOptions {
934 pub registry: GraphRestoreRegistry,
936 pub graph: GraphOptions,
938}
939
940impl RestoreGraphOptions {
941 pub fn new(registry: GraphRestoreRegistry) -> Self {
943 Self {
944 registry,
945 graph: GraphOptions::default(),
946 }
947 }
948}
949
950impl Graph {
951 pub fn checkpoint(&self) -> RestoreResult<GraphCheckpoint> {
953 checkpoint_graph(self)
954 }
955}
956
957pub fn restore_graph(
959 checkpoint: GraphCheckpoint,
960 options: RestoreGraphOptions,
961) -> RestoreResult<Graph> {
962 let prepared = prepare_checkpoint(&checkpoint, &options.registry, "checkpoint", None)?;
963 construct_prepared(&prepared, &options)
964}
965
966fn checkpoint_graph(graph: &Graph) -> RestoreResult<GraphCheckpoint> {
967 let mut entries = graph.checkpoint_entries();
968 let mut i = 0;
969 while i < entries.len() {
970 let deps = entries[i].core.deps();
971 for dep in deps {
972 if entries.iter().any(|candidate| candidate.core.ptr_eq(&dep)) {
973 continue;
974 }
975 entries.push(CheckpointEntry {
976 id: graph.checkpoint_synthetic_id_for_core(&dep),
977 name: None,
978 factory: dep.factory().unwrap_or_else(|| "?".to_owned()),
979 meta: BTreeMap::new(),
980 restore: None,
981 core: dep,
982 unregistered: true,
983 });
984 }
985 i += 1;
986 }
987 let mut nodes = Vec::with_capacity(entries.len());
988 let mut edges = Vec::new();
989 for entry in &entries {
990 let runtime = entry.core.checkpoint_runtime();
991 ensure_quiescent_status(runtime.status, &entry.id, "checkpoint")?;
992 let deps = entry.core.deps();
993 let dep_ids = deps
994 .iter()
995 .map(|dep| {
996 entries
997 .iter()
998 .find(|candidate| candidate.core.ptr_eq(dep))
999 .map(|candidate| candidate.id.clone())
1000 .ok_or_else(|| {
1001 GraphRestoreError::new(format!(
1002 "checkpoint: node '{}' has an unregistered dep; local-only auto-discovery restore is not implemented",
1003 entry.id
1004 ))
1005 })
1006 })
1007 .collect::<RestoreResult<Vec<_>>>()?;
1008 edges.extend(dep_ids.iter().map(|dep| GraphCheckpointEdge {
1009 from: dep.clone(),
1010 to: entry.id.clone(),
1011 }));
1012 let backend_state =
1013 checkpoint_backend_state(&entry.core, &format!("{}.backendState", entry.id))?;
1014 let non_authoritative_collection_helper =
1015 is_non_authoritative_collection_helper(entry.meta.get("kind").map(String::as_str));
1016 nodes.push(GraphCheckpointNode {
1017 id: entry.id.clone(),
1018 name: entry.name.clone(),
1019 factory: checkpoint_factory(entry),
1020 status: status_to_str(runtime.status).to_owned(),
1021 deps: dep_ids,
1022 value: if non_authoritative_collection_helper {
1023 GraphCheckpointValue::Sentinel
1024 } else {
1025 checkpoint_value(runtime.cache.as_ref(), runtime.has_data, &entry.id)?
1026 },
1027 backend_state,
1028 version: runtime.version.as_ref().map(node_version_to_json),
1029 terminal: if runtime.terminal {
1030 match runtime.status {
1031 Status::Completed => GraphCheckpointTerminal::Complete,
1032 Status::Errored => {
1033 return Err(GraphRestoreError::new(format!(
1034 "checkpoint: ERROR payload for node '{}' is unavailable; cannot serialize a placeholder",
1035 entry.id
1036 )));
1037 }
1038 _ => GraphCheckpointTerminal::None,
1039 }
1040 } else {
1041 GraphCheckpointTerminal::None
1042 },
1043 lifecycle: GraphCheckpointLifecycle {
1044 activated: runtime.activated,
1045 has_called_fn_once: runtime.has_called_fn_once,
1046 },
1047 ctx_state: GraphCheckpointCtxState {
1048 persist: runtime.ctx_state_persist,
1049 value: if non_authoritative_collection_helper {
1050 GraphCheckpointValue::Sentinel
1051 } else {
1052 checkpoint_value(
1053 runtime.ctx_state.as_ref(),
1054 runtime.ctx_state.is_some(),
1055 &format!("{}.ctxState", entry.id),
1056 )?
1057 },
1058 },
1059 meta: if entry.meta.is_empty() {
1060 None
1061 } else {
1062 Some(
1063 entry
1064 .meta
1065 .iter()
1066 .map(|(k, v)| (k.clone(), GraphCheckpointJson::String(v.clone())))
1067 .collect(),
1068 )
1069 },
1070 });
1071 }
1072 let mounts = graph
1073 .checkpoint_mounts()
1074 .into_iter()
1075 .map(|(at, child)| {
1076 Ok(GraphCheckpointMount {
1077 at,
1078 checkpoint: checkpoint_graph(&child)?,
1079 })
1080 })
1081 .collect::<RestoreResult<Vec<_>>>()?;
1082 Ok(GraphCheckpoint {
1083 version: GRAPH_CHECKPOINT_VERSION.to_owned(),
1084 name: graph.name().map(str::to_owned),
1085 nodes,
1086 edges,
1087 mounts: if mounts.is_empty() {
1088 None
1089 } else {
1090 Some(mounts)
1091 },
1092 })
1093}
1094
1095fn is_non_authoritative_collection_helper(kind: Option<&str>) -> bool {
1096 matches!(
1097 kind,
1098 Some(
1099 "collection_delta"
1100 | "collection_snapshot"
1101 | "collection_view_delta"
1102 | "collection_view_snapshot"
1103 )
1104 )
1105}
1106
1107fn checkpoint_factory(entry: &CheckpointEntry) -> GraphCheckpointFactory {
1108 if entry.unregistered {
1109 return GraphCheckpointFactory::LocalOnly {
1110 name: entry.factory.clone(),
1111 reason: "node is an unregistered live dependency auto-discovered from topology"
1112 .to_owned(),
1113 };
1114 }
1115 if let Some(restore) = &entry.restore {
1116 return GraphCheckpointFactory::RegistryRef {
1117 ref_: restore.ref_.clone(),
1118 config: restore.config.clone(),
1119 config_version: restore.config_version.clone(),
1120 };
1121 }
1122 if entry.factory == "state" {
1123 return GraphCheckpointFactory::RegistryRef {
1124 ref_: "state".to_owned(),
1125 config: None,
1126 config_version: None,
1127 };
1128 }
1129 GraphCheckpointFactory::LocalOnly {
1130 name: entry.factory.clone(),
1131 reason: "node was not constructed with restore metadata".to_owned(),
1132 }
1133}
1134
1135fn checkpoint_value(
1136 value: Option<&AnyValue>,
1137 has_data: bool,
1138 path: &str,
1139) -> RestoreResult<GraphCheckpointValue> {
1140 if !has_data {
1141 return Ok(GraphCheckpointValue::Sentinel);
1142 }
1143 let value = value.ok_or_else(|| {
1144 GraphRestoreError::new(format!(
1145 "checkpoint: value at {path} is marked DATA but absent"
1146 ))
1147 })?;
1148 Ok(GraphCheckpointValue::Data {
1149 data: any_to_json(value, path)?,
1150 })
1151}
1152
1153fn any_to_json(value: &AnyValue, path: &str) -> RestoreResult<GraphCheckpointJson> {
1154 let encoder = CHECKPOINT_JSON_ENCODERS
1155 .get_or_init(|| Mutex::new(HashMap::new()))
1156 .lock()
1157 .map_err(|_| GraphRestoreError::new("checkpoint JSON encoder registry poisoned"))?
1158 .get(&value.as_ref().type_id())
1159 .cloned();
1160 if let Some(encoder) = encoder {
1161 let out = encoder(value.as_ref(), path)?;
1162 validate_checkpoint_json(&out, path)?;
1163 return Ok(out);
1164 }
1165 if let Some(v) = value.downcast_ref::<GraphCheckpointJson>() {
1166 validate_checkpoint_json(v, path)?;
1167 return Ok(v.clone());
1168 }
1169 if let Some(v) = value.downcast_ref::<String>() {
1170 return Ok(Value::String(v.clone()));
1171 }
1172 if let Some(v) = value.downcast_ref::<bool>() {
1173 return Ok(Value::Bool(*v));
1174 }
1175 if let Some(v) = value.downcast_ref::<i32>() {
1176 return Ok(Value::Number(Number::from(*v)));
1177 }
1178 if let Some(v) = value.downcast_ref::<i64>() {
1179 return Ok(Value::Number(Number::from(*v)));
1180 }
1181 if let Some(v) = value.downcast_ref::<u32>() {
1182 return Ok(Value::Number(Number::from(*v)));
1183 }
1184 if let Some(v) = value.downcast_ref::<u64>() {
1185 return Ok(Value::Number(Number::from(*v)));
1186 }
1187 if let Some(v) = value.downcast_ref::<usize>() {
1188 return Ok(Value::Number(Number::from(*v as u64)));
1189 }
1190 if let Some(v) = value.downcast_ref::<f64>() {
1191 if let Some(n) = Number::from_f64(*v) {
1192 let out = Value::Number(n);
1193 validate_checkpoint_json(&out, path)?;
1194 return Ok(out);
1195 }
1196 }
1197 Err(GraphRestoreError::new(format!(
1198 "checkpoint: value at {path} is not strict JSON compatible"
1199 )))
1200}
1201
1202fn validate_checkpoint_json(value: &GraphCheckpointJson, path: &str) -> RestoreResult<()> {
1203 validate_strict_json_value(value, path)
1204 .map_err(|err| GraphRestoreError::new(format!("checkpoint: {err}")))
1205}
1206
1207pub fn restored_opts(node: &GraphCheckpointNode) -> RestoreResult<GraphNodeOpts> {
1209 let meta = node
1210 .meta
1211 .clone()
1212 .unwrap_or_default()
1213 .into_iter()
1214 .map(|(k, v)| {
1215 let value = v.as_str().ok_or_else(|| {
1216 GraphRestoreError::new(format!(
1217 "restore_graph: node '{}' meta field '{}' must be a string in Rust",
1218 node.id, k
1219 ))
1220 })?;
1221 Ok((k, value.to_owned()))
1222 })
1223 .collect::<RestoreResult<BTreeMap<_, _>>>()?;
1224 let restore = match &node.factory {
1225 GraphCheckpointFactory::RegistryRef {
1226 ref_,
1227 config,
1228 config_version,
1229 } => Some(RestoreFactoryMeta {
1230 ref_: ref_.clone(),
1231 config: config.clone(),
1232 config_version: config_version.clone(),
1233 }),
1234 GraphCheckpointFactory::LocalOnly { .. } => None,
1235 };
1236 Ok(GraphNodeOpts {
1237 name: node.name.clone(),
1238 meta,
1239 restore,
1240 ..GraphNodeOpts::default()
1241 })
1242}
1243
1244struct PreparedNode {
1245 checkpoint: GraphCheckpointNode,
1246 descriptor: Rc<dyn GraphRestoreDescriptor>,
1247 deps: Vec<String>,
1248}
1249
1250struct PreparedCheckpoint {
1251 checkpoint: GraphCheckpoint,
1252 nodes: BTreeMap<String, PreparedNode>,
1253 mounts: Vec<(String, PreparedCheckpoint)>,
1254}
1255
1256fn prepare_checkpoint(
1257 checkpoint: &GraphCheckpoint,
1258 registry: &GraphRestoreRegistry,
1259 path: &str,
1260 mount_at: Option<&str>,
1261) -> RestoreResult<PreparedCheckpoint> {
1262 if checkpoint.version != GRAPH_CHECKPOINT_VERSION {
1263 return Err(GraphRestoreError::new(format!(
1264 "restore_graph: unsupported checkpoint version at {path}"
1265 )));
1266 }
1267 let mut nodes = BTreeMap::new();
1268 for node in &checkpoint.nodes {
1269 if nodes.contains_key(&node.id) {
1270 return Err(GraphRestoreError::new(format!(
1271 "restore_graph: duplicate node id '{}'",
1272 node.id
1273 )));
1274 }
1275 if let Some(at) = mount_at {
1276 let prefix = format!("{at}::");
1277 if node.id.starts_with(&prefix) {
1278 return Err(GraphRestoreError::new(format!(
1279 "restore_graph: mounted checkpoint at '{at}' must use child-local node id '{}', not '{}'",
1280 node.id.trim_start_matches(&prefix),
1281 node.id
1282 )));
1283 }
1284 }
1285 let descriptor = match &node.factory {
1286 GraphCheckpointFactory::LocalOnly { name, reason } => {
1287 return Err(GraphRestoreError::new(format!(
1288 "restore_graph: node '{}' uses local-only factory '{}' ({reason})",
1289 node.id, name
1290 )));
1291 }
1292 GraphCheckpointFactory::RegistryRef { ref_, .. } => {
1293 registry.descriptor(ref_).ok_or_else(|| {
1294 GraphRestoreError::new(format!(
1295 "restore_graph: missing registry descriptor for '{ref_}' (node '{}')",
1296 node.id
1297 ))
1298 })?
1299 }
1300 };
1301 let status = status_from_str(&node.status, &node.id)?;
1302 ensure_quiescent_status(status, &node.id, "restore_graph")?;
1303 validate_checkpoint_node_json(node)?;
1304 if let Some(version) = &node.version {
1305 validate_node_version_json(version, &format!("{}.version", node.id))
1306 .map_err(|err| GraphRestoreError::new(err.to_string()))?;
1307 }
1308 nodes.insert(
1309 node.id.clone(),
1310 PreparedNode {
1311 checkpoint: node.clone(),
1312 descriptor,
1313 deps: node.deps.clone(),
1314 },
1315 );
1316 }
1317 for node in nodes.values() {
1318 for dep in &node.deps {
1319 if !nodes.contains_key(dep) {
1320 return Err(GraphRestoreError::new(format!(
1321 "restore_graph: node '{}' has missing dep '{dep}'",
1322 node.checkpoint.id
1323 )));
1324 }
1325 }
1326 }
1327 let mut edge_keys = BTreeSet::new();
1328 for edge in &checkpoint.edges {
1329 if !edge_keys.insert((edge.from.clone(), edge.to.clone())) {
1330 return Err(GraphRestoreError::new(format!(
1331 "restore_graph: duplicate edge '{}' -> '{}'",
1332 edge.from, edge.to
1333 )));
1334 }
1335 let Some(target) = nodes.get(&edge.to) else {
1336 return Err(GraphRestoreError::new(format!(
1337 "restore_graph: edge '{}' -> '{}' references a missing node",
1338 edge.from, edge.to
1339 )));
1340 };
1341 if !nodes.contains_key(&edge.from) || !target.deps.contains(&edge.from) {
1342 return Err(GraphRestoreError::new(format!(
1343 "restore_graph: edge '{}' -> '{}' is not present in target deps",
1344 edge.from, edge.to
1345 )));
1346 }
1347 }
1348 for node in nodes.values() {
1349 for dep in &node.deps {
1350 if !edge_keys.contains(&(dep.clone(), node.checkpoint.id.clone())) {
1351 return Err(GraphRestoreError::new(format!(
1352 "restore_graph: node '{}' dep '{dep}' is missing its edge",
1353 node.checkpoint.id
1354 )));
1355 }
1356 }
1357 }
1358 let mut mount_paths = BTreeSet::new();
1359 let mut mounts = Vec::new();
1360 for mount in checkpoint.mounts.clone().unwrap_or_default() {
1361 if mount.at.is_empty() {
1362 return Err(GraphRestoreError::new(format!(
1363 "restore_graph: mount path at {path}.mounts[] must not be empty"
1364 )));
1365 }
1366 if !mount_paths.insert(mount.at.clone()) {
1367 return Err(GraphRestoreError::new(format!(
1368 "restore_graph: duplicate mount path '{}'",
1369 mount.at
1370 )));
1371 }
1372 mounts.push((
1373 mount.at.clone(),
1374 prepare_checkpoint(
1375 &mount.checkpoint,
1376 registry,
1377 &format!("{path}.mounts.{}", mount.at),
1378 Some(&mount.at),
1379 )?,
1380 ));
1381 }
1382 Ok(PreparedCheckpoint {
1383 checkpoint: checkpoint.clone(),
1384 nodes,
1385 mounts,
1386 })
1387}
1388
1389fn validate_checkpoint_node_json(node: &GraphCheckpointNode) -> RestoreResult<()> {
1390 if let GraphCheckpointFactory::RegistryRef {
1391 config,
1392 config_version,
1393 ..
1394 } = &node.factory
1395 {
1396 if let Some(config) = config {
1397 validate_checkpoint_json(config, &format!("{}.factory.config", node.id))?;
1398 }
1399 if let Some(config_version) = config_version {
1400 validate_checkpoint_json(
1401 config_version,
1402 &format!("{}.factory.configVersion", node.id),
1403 )?;
1404 }
1405 }
1406 if let GraphCheckpointValue::Data { data } = &node.value {
1407 validate_checkpoint_json(data, &format!("{}.value", node.id))?;
1408 }
1409 if let GraphCheckpointValue::Data { data } = &node.ctx_state.value {
1410 validate_checkpoint_json(data, &format!("{}.ctxState", node.id))?;
1411 }
1412 if let Some(backend_state) = &node.backend_state {
1413 validate_checkpoint_json(backend_state, &format!("{}.backendState", node.id))?;
1414 }
1415 if let GraphCheckpointTerminal::Error { error } = &node.terminal {
1416 validate_checkpoint_json(error, &format!("{}.terminal.error", node.id))?;
1417 }
1418 if let Some(meta) = &node.meta {
1419 for (key, value) in meta {
1420 validate_checkpoint_json(value, &format!("{}.meta.{key}", node.id))?;
1421 }
1422 }
1423 Ok(())
1424}
1425
1426fn construct_prepared(
1427 prepared: &PreparedCheckpoint,
1428 options: &RestoreGraphOptions,
1429) -> RestoreResult<Graph> {
1430 let mut opts = options.graph.clone();
1431 opts.name = prepared.checkpoint.name.clone();
1432 let graph = crate::graph::graph_opts(opts);
1433 let mut built = BTreeMap::<String, Core>::new();
1434 let mut visiting = BTreeSet::<String>::new();
1435 for id in prepared.nodes.keys() {
1436 build_node(
1437 id,
1438 prepared,
1439 &options.registry,
1440 &graph,
1441 &mut built,
1442 &mut visiting,
1443 )?;
1444 }
1445 for (at, child) in &prepared.mounts {
1446 graph.mount_restored(construct_prepared(child, options)?, at.clone());
1447 }
1448 for (id, core) in &built {
1449 let node = &prepared
1450 .nodes
1451 .get(id)
1452 .expect("built map only contains prepared nodes")
1453 .checkpoint;
1454 let (cache, has_data) = restore_value(&node.value);
1455 let (terminal, status) = restore_terminal(node)?;
1456 let (ctx_state, ctx_state_persist) = restore_ctx_state(&node.ctx_state);
1457 let restored_version = verify_restored_node_version(
1458 &core.versioning_policy(),
1459 node.version.as_ref(),
1460 has_data,
1461 cache.as_ref(),
1462 &format!("{}.version", node.id),
1463 )
1464 .map_err(|err| GraphRestoreError::new(err.to_string()))?;
1465 core.restore_runtime(NodeRestoreRuntime {
1466 cache,
1467 has_data,
1468 version: restored_version,
1469 status,
1470 terminal,
1471 activated: node.lifecycle.activated,
1472 has_called_fn_once: node.lifecycle.has_called_fn_once,
1473 ctx_state,
1474 ctx_state_persist,
1475 });
1476 }
1477 Ok(graph)
1478}
1479
1480fn build_node(
1481 id: &str,
1482 prepared: &PreparedCheckpoint,
1483 registry: &GraphRestoreRegistry,
1484 graph: &Graph,
1485 built: &mut BTreeMap<String, Core>,
1486 visiting: &mut BTreeSet<String>,
1487) -> RestoreResult<Core> {
1488 if let Some(core) = built.get(id) {
1489 return Ok(core.clone());
1490 }
1491 if !visiting.insert(id.to_owned()) {
1492 return Err(GraphRestoreError::new(format!(
1493 "restore_graph: dependency cycle at '{id}'"
1494 )));
1495 }
1496 let item = prepared.nodes.get(id).ok_or_else(|| {
1497 GraphRestoreError::new(format!("restore_graph: missing prepared node '{id}'"))
1498 })?;
1499 let deps = item
1500 .deps
1501 .iter()
1502 .map(|dep| build_node(dep, prepared, registry, graph, built, visiting))
1503 .collect::<RestoreResult<Vec<_>>>()?;
1504 let (config, config_version) = match &item.checkpoint.factory {
1505 GraphCheckpointFactory::RegistryRef {
1506 config,
1507 config_version,
1508 ..
1509 } => (config.as_ref(), config_version.as_ref()),
1510 GraphCheckpointFactory::LocalOnly { .. } => (None, None),
1511 };
1512 let definition = item.descriptor.define(RestoreDefineCtx {
1513 id,
1514 deps: &item.deps,
1515 config,
1516 config_version,
1517 checkpoint: &item.checkpoint,
1518 registry,
1519 })?;
1520 let core = match definition.kind {
1521 RestoreNodeKind::StateJson => graph
1522 .restore_state_json_with_id(id.to_owned(), definition.opts)
1523 .erased(),
1524 RestoreNodeKind::NodeJson(body) => graph
1525 .restore_node_with_id(
1526 id.to_owned(),
1527 definition.factory,
1528 deps.clone(),
1529 body,
1530 definition.opts,
1531 )
1532 .erased(),
1533 RestoreNodeKind::Custom(restore) => restore(graph)?,
1534 };
1535 let restored_deps = core.deps();
1536 if restored_deps.len() != deps.len()
1537 || restored_deps
1538 .iter()
1539 .zip(deps.iter())
1540 .any(|(restored, expected)| !restored.ptr_eq(expected))
1541 {
1542 return Err(GraphRestoreError::new(format!(
1543 "restore_graph: descriptor '{}' registered node '{}' with deps that do not match the checkpoint",
1544 item.descriptor.ref_(),
1545 id
1546 )));
1547 }
1548 built.insert(id.to_owned(), core.clone());
1549 visiting.remove(id);
1550 Ok(core)
1551}
1552
1553fn restore_value(value: &GraphCheckpointValue) -> (Option<AnyValue>, bool) {
1554 match value {
1555 GraphCheckpointValue::Sentinel => (None, false),
1556 GraphCheckpointValue::Data { data } => (Some(Rc::new(data.clone()) as AnyValue), true),
1557 }
1558}
1559
1560fn restore_ctx_state(state: &GraphCheckpointCtxState) -> (Option<AnyValue>, bool) {
1561 let (value, has_data) = restore_value(&state.value);
1562 (if has_data { value } else { None }, state.persist)
1563}
1564
1565fn restore_terminal(node: &GraphCheckpointNode) -> RestoreResult<(bool, Status)> {
1566 let status = status_from_str(&node.status, &node.id)?;
1567 match &node.terminal {
1568 GraphCheckpointTerminal::None => {
1569 if status.is_terminal() {
1570 return Err(GraphRestoreError::new(format!(
1571 "restore_graph: node '{}' terminal status requires terminal state",
1572 node.id
1573 )));
1574 }
1575 Ok((false, status))
1576 }
1577 GraphCheckpointTerminal::Complete => {
1578 if status != Status::Completed {
1579 return Err(GraphRestoreError::new(format!(
1580 "restore_graph: node '{}' COMPLETE terminal requires completed status",
1581 node.id
1582 )));
1583 }
1584 Ok((true, status))
1585 }
1586 GraphCheckpointTerminal::Error { .. } => Err(GraphRestoreError::new(format!(
1587 "restore_graph: node '{}' carries ERROR terminal payload, which Rust restore cannot preserve yet",
1588 node.id
1589 ))),
1590 }
1591}
1592
1593fn status_to_str(status: Status) -> &'static str {
1594 match status {
1595 Status::Sentinel => "sentinel",
1596 Status::Pending => "pending",
1597 Status::Dirty => "dirty",
1598 Status::Settled => "settled",
1599 Status::Resolved => "resolved",
1600 Status::Completed => "completed",
1601 Status::Errored => "errored",
1602 }
1603}
1604
1605fn status_from_str(status: &str, id: &str) -> RestoreResult<Status> {
1606 match status {
1607 "sentinel" => Ok(Status::Sentinel),
1608 "pending" => Ok(Status::Pending),
1609 "dirty" => Ok(Status::Dirty),
1610 "settled" => Ok(Status::Settled),
1611 "resolved" => Ok(Status::Resolved),
1612 "completed" => Ok(Status::Completed),
1613 "errored" => Ok(Status::Errored),
1614 _ => Err(GraphRestoreError::new(format!(
1615 "restore_graph: node '{id}' has invalid status '{status}'"
1616 ))),
1617 }
1618}
1619
1620fn ensure_quiescent_status(status: Status, id: &str, op: &str) -> RestoreResult<()> {
1621 if matches!(status, Status::Pending | Status::Dirty) {
1622 return Err(GraphRestoreError::new(format!(
1623 "{op}: node '{id}' has non-quiescent status '{status}' that cannot be checkpoint-restored yet",
1624 status = status_to_str(status)
1625 )));
1626 }
1627 Ok(())
1628}
1629
1630#[cfg(test)]
1631mod tests {
1632 use super::*;
1633 use crate::graph::RestoreFactoryMeta;
1634 use crate::node::Node;
1635 use crate::protocol::Message;
1636 use crate::versioning::{NodeVersion, NodeVersioningPolicy};
1637 use serde_json::json;
1638
1639 struct StatefulJsonDescriptor;
1640
1641 impl GraphRestoreDescriptor for StatefulJsonDescriptor {
1642 fn ref_(&self) -> &str {
1643 "stateful-json"
1644 }
1645
1646 fn define(&self, ctx: RestoreDefineCtx<'_>) -> RestoreResult<RestoreNodeDefinition> {
1647 assert_eq!(ctx.deps.len(), 1);
1648 Ok(RestoreNodeDefinition {
1649 factory: "stateful-json".to_owned(),
1650 kind: RestoreNodeKind::NodeJson(Rc::new(|ctx: &Ctx| {
1651 let mut acc = ctx
1652 .state_get::<GraphCheckpointJson>()
1653 .and_then(|v| v.as_i64())
1654 .unwrap_or(40);
1655 for _ in ctx.batch::<GraphCheckpointJson>(0) {
1656 acc += 1;
1657 ctx.emit(json!(acc));
1658 }
1659 ctx.state_set(json!(acc));
1660 })),
1661 opts: restored_opts(ctx.checkpoint)?,
1662 })
1663 }
1664 }
1665
1666 #[test]
1667 fn restore_preserves_ctx_state_for_later_runs() {
1668 let g = crate::graph::graph();
1669 let source = g.state_opts(json!(1), GraphNodeOpts::named("source"));
1670 let mut opts = GraphNodeOpts::named("memo");
1671 opts.restore = Some(RestoreFactoryMeta::registry_ref("stateful-json"));
1672 let memo = g.node_opts::<GraphCheckpointJson, _>(
1673 vec![source.erased()],
1674 |ctx| {
1675 let mut acc = ctx
1676 .state_get::<GraphCheckpointJson>()
1677 .and_then(|v| v.as_i64())
1678 .unwrap_or(40);
1679 for _ in ctx.batch::<GraphCheckpointJson>(0) {
1680 acc += 1;
1681 ctx.emit(json!(acc));
1682 }
1683 ctx.state_set(json!(acc));
1684 },
1685 opts,
1686 );
1687 let _keep_active = memo.subscribe(|_| {});
1688 assert_eq!(memo.cache(), Some(json!(41)));
1689
1690 let checkpoint = g.checkpoint().expect("checkpoint succeeds");
1691 let registry = restore_registry([
1692 GraphRestoreEntry::descriptor(StateRestoreDescriptor),
1693 GraphRestoreEntry::descriptor(StatefulJsonDescriptor),
1694 ]);
1695 let restored = restore_graph(checkpoint, RestoreGraphOptions::new(registry))
1696 .expect("restore succeeds");
1697 let restored_memo = Node::<GraphCheckpointJson>::from_core(
1698 restored.find("memo").expect("memo restored").core(),
1699 );
1700 let restored_source = Node::<GraphCheckpointJson>::from_core(
1701 restored.find("source").expect("source restored").core(),
1702 );
1703
1704 assert_eq!(restored_memo.cache(), Some(json!(41)));
1705 let restored_checkpoint = restored.checkpoint().expect("re-checkpoint succeeds");
1706 let memo_checkpoint = restored_checkpoint
1707 .nodes
1708 .iter()
1709 .find(|node| node.id == "memo")
1710 .expect("memo checkpoint exists");
1711 assert_eq!(
1712 memo_checkpoint.lifecycle,
1713 GraphCheckpointLifecycle {
1714 activated: true,
1715 has_called_fn_once: true
1716 }
1717 );
1718 let runtime = restored_memo.erased().checkpoint_runtime();
1719 assert_eq!(
1720 runtime
1721 .ctx_state
1722 .and_then(|v| v.downcast::<GraphCheckpointJson>().ok())
1723 .as_deref(),
1724 Some(&json!(41))
1725 );
1726 restored_source.set(json!(2));
1727 assert_eq!(restored_memo.cache(), Some(json!(42)));
1728 let runtime = restored_memo.erased().checkpoint_runtime();
1729 assert_eq!(
1730 runtime
1731 .ctx_state
1732 .and_then(|v| v.downcast::<GraphCheckpointJson>().ok())
1733 .as_deref(),
1734 Some(&json!(42))
1735 );
1736 }
1737
1738 #[test]
1739 fn checkpoints_and_restores_node_runtime_versions() {
1740 let g = crate::graph::graph();
1741 let source = g.state_opts(json!(1), GraphNodeOpts::named("source"));
1742 source.set(json!(2));
1743 let checkpoint = g.checkpoint().expect("checkpoint succeeds");
1744 assert_eq!(
1745 checkpoint.nodes[0].version,
1746 Some(json!({ "level": 0, "counter": 1 }))
1747 );
1748
1749 let restored = restore_graph(
1750 checkpoint,
1751 RestoreGraphOptions::new(default_restore_registry()),
1752 )
1753 .expect("V0 restore succeeds");
1754 let restored_source = restored.find("source").expect("source restored");
1755 assert_eq!(
1756 restored_source.version(),
1757 Some(NodeVersion::V0 { counter: 1 })
1758 );
1759
1760 restored_source.down(vec![Message::Data(Rc::new(json!(3)))]);
1761 assert_eq!(
1762 restored_source.version(),
1763 Some(NodeVersion::V0 { counter: 2 })
1764 );
1765 }
1766
1767 #[test]
1768 fn restore_rejects_v0_metadata_when_selected_policy_is_not_v0() {
1769 let g = crate::graph::graph();
1770 let source = g.state_opts(json!(1), GraphNodeOpts::named("source"));
1771 source.set(json!(2));
1772 let checkpoint = g.checkpoint().expect("checkpoint succeeds");
1773
1774 let disabled_err = match restore_graph(
1775 checkpoint.clone(),
1776 RestoreGraphOptions {
1777 registry: default_restore_registry(),
1778 graph: GraphOptions {
1779 versioning: Some(NodeVersioningPolicy::Disabled),
1780 ..GraphOptions::default()
1781 },
1782 },
1783 ) {
1784 Ok(_) => panic!("V0 metadata must not restore into disabled versioning"),
1785 Err(err) => err,
1786 };
1787 assert!(disabled_err.to_string().contains("versioning is disabled"));
1788
1789 let level1_err = match restore_graph(
1790 checkpoint,
1791 RestoreGraphOptions {
1792 registry: default_restore_registry(),
1793 graph: GraphOptions {
1794 versioning: Some(NodeVersioningPolicy::Level1 {
1795 hash: Some(Rc::new(|bytes: &[u8]| {
1796 format!(
1797 "h:{}",
1798 std::str::from_utf8(bytes).expect("canonical JSON is UTF-8")
1799 )
1800 })),
1801 }),
1802 ..GraphOptions::default()
1803 },
1804 },
1805 ) {
1806 Ok(_) => panic!("V0 metadata must not restore into a V1 lane"),
1807 Err(err) => err,
1808 };
1809 assert!(level1_err
1810 .to_string()
1811 .contains("level 0 requires matching node versioning policy"));
1812 }
1813
1814 #[test]
1815 fn restores_v1_only_with_matching_hash_lane() {
1816 let hash = Rc::new(|bytes: &[u8]| {
1817 format!(
1818 "h:{}",
1819 std::str::from_utf8(bytes).expect("canonical JSON is UTF-8")
1820 )
1821 });
1822 let graph = crate::graph::graph_opts(GraphOptions {
1823 versioning: Some(NodeVersioningPolicy::Level1 {
1824 hash: Some(hash.clone()),
1825 }),
1826 ..GraphOptions::default()
1827 });
1828 let source = graph.state_opts(json!(1), GraphNodeOpts::named("source"));
1829 source.set(json!(2));
1830 let checkpoint = graph.checkpoint().expect("checkpoint succeeds");
1831 assert_eq!(
1832 checkpoint.nodes[0].version,
1833 Some(json!({ "level": 1, "counter": 1, "cid": "h:2", "prev": "h:1" }))
1834 );
1835
1836 let err = match restore_graph(
1837 checkpoint.clone(),
1838 RestoreGraphOptions::new(default_restore_registry()),
1839 ) {
1840 Ok(_) => panic!("V1 restore without a matching lane must fail"),
1841 Err(err) => err,
1842 };
1843 assert!(err.to_string().contains("matching node versioning policy"));
1844
1845 let err = match restore_graph(
1846 checkpoint.clone(),
1847 RestoreGraphOptions {
1848 registry: default_restore_registry(),
1849 graph: GraphOptions {
1850 versioning: Some(NodeVersioningPolicy::Level1 {
1851 hash: Some(Rc::new(|bytes: &[u8]| {
1852 format!(
1853 "other:{}",
1854 std::str::from_utf8(bytes).expect("canonical JSON is UTF-8")
1855 )
1856 })),
1857 }),
1858 ..GraphOptions::default()
1859 },
1860 },
1861 ) {
1862 Ok(_) => panic!("wrong hash lane must fail"),
1863 Err(err) => err,
1864 };
1865 assert!(err.to_string().contains("hash policy"));
1866
1867 let restored = restore_graph(
1868 checkpoint,
1869 RestoreGraphOptions {
1870 registry: default_restore_registry(),
1871 graph: GraphOptions {
1872 versioning: Some(NodeVersioningPolicy::Level1 { hash: Some(hash) }),
1873 ..GraphOptions::default()
1874 },
1875 },
1876 )
1877 .expect("matching V1 lane restores");
1878 let restored_source = restored.find("source").expect("source restored");
1879 assert_eq!(
1880 restored_source.version(),
1881 Some(NodeVersion::V1 {
1882 counter: 1,
1883 cid: "h:2".to_owned(),
1884 prev: Some("h:1".to_owned()),
1885 })
1886 );
1887 restored_source.down(vec![Message::Data(Rc::new(json!(3)))]);
1888 assert_eq!(
1889 restored_source.version(),
1890 Some(NodeVersion::V1 {
1891 counter: 2,
1892 cid: "h:3".to_owned(),
1893 prev: Some("h:2".to_owned()),
1894 })
1895 );
1896 }
1897
1898 #[test]
1899 fn restore_requires_version_metadata_when_versioning_is_enabled() {
1900 let graph = crate::graph::graph();
1901 graph.state_opts(json!(1), GraphNodeOpts::named("source"));
1902 let mut checkpoint = graph.checkpoint().expect("checkpoint succeeds");
1903 checkpoint.nodes[0].version = None;
1904
1905 let err = match restore_graph(
1906 checkpoint,
1907 RestoreGraphOptions::new(default_restore_registry()),
1908 ) {
1909 Ok(_) => panic!("missing version metadata must fail under default V0 versioning"),
1910 Err(err) => err,
1911 };
1912 assert!(err.to_string().contains("version metadata is required"));
1913 }
1914
1915 #[test]
1916 fn restore_rejects_invalid_node_version_metadata_shape() {
1917 let graph = crate::graph::graph();
1918 graph.state_opts(json!(1), GraphNodeOpts::named("source"));
1919 let checkpoint = graph.checkpoint().expect("checkpoint succeeds");
1920
1921 let mut extra = checkpoint.clone();
1922 extra.nodes[0].version = Some(json!({ "level": 0, "counter": 1, "extra": true }));
1923 let err = match restore_graph(extra, RestoreGraphOptions::new(default_restore_registry())) {
1924 Ok(_) => panic!("extra node version fields must fail honestly"),
1925 Err(err) => err,
1926 };
1927 assert!(err.to_string().contains("unexpected node version fields"));
1928
1929 let mut unsafe_counter = checkpoint;
1930 unsafe_counter.nodes[0].version =
1931 Some(json!({ "level": 0, "counter": 9_007_199_254_740_992u64 }));
1932 let err = match restore_graph(
1933 unsafe_counter,
1934 RestoreGraphOptions::new(default_restore_registry()),
1935 ) {
1936 Ok(_) => panic!("unsafe node version counters must fail honestly"),
1937 Err(err) => err,
1938 };
1939 assert!(err.to_string().contains("safe integer"));
1940 }
1941
1942 #[test]
1943 fn checkpoint_and_restore_reject_noncanonical_json_numbers() {
1944 let g = crate::graph::graph();
1945 let source = g.state_opts(-0.0_f64, GraphNodeOpts::named("source"));
1946 let _keep_active = source.subscribe(|_| {});
1947 let err = g
1948 .checkpoint()
1949 .expect_err("negative zero is not strict canonical JSON");
1950 assert!(err.to_string().contains("strict canonical JSON"));
1951
1952 let g = crate::graph::graph();
1953 g.state_opts(json!(1), GraphNodeOpts::named("source"));
1954 let mut checkpoint = g.checkpoint().expect("checkpoint succeeds");
1955 checkpoint.nodes[0].value = GraphCheckpointValue::Data {
1956 data: Value::Number(Number::from_f64(f64::MIN_POSITIVE / 2.0).unwrap()),
1957 };
1958 let err = match restore_graph(
1959 checkpoint,
1960 RestoreGraphOptions::new(default_restore_registry()),
1961 ) {
1962 Ok(_) => panic!("subnormal checkpoint number must fail restore validation"),
1963 Err(err) => err,
1964 };
1965 assert!(err.to_string().contains("subnormal"));
1966 }
1967
1968 #[test]
1969 fn restore_rejects_error_terminal_payload_until_preservation_lands() {
1970 let g = crate::graph::graph();
1971 g.state_opts(json!(1), GraphNodeOpts::named("source"));
1972 let mut checkpoint = g.checkpoint().expect("checkpoint succeeds");
1973 checkpoint.nodes[0].status = "errored".to_owned();
1974 checkpoint.nodes[0].terminal = GraphCheckpointTerminal::Error {
1975 error: json!("boom"),
1976 };
1977
1978 let err = match restore_graph(
1979 checkpoint,
1980 RestoreGraphOptions::new(default_restore_registry()),
1981 ) {
1982 Ok(_) => panic!("ERROR terminal payload restore must fail honestly"),
1983 Err(err) => err,
1984 };
1985 assert!(err.to_string().contains("cannot preserve yet"));
1986 }
1987
1988 #[test]
1989 fn restore_descriptors_reject_unsupported_config_version() {
1990 let g = crate::graph::graph();
1991 g.state_opts(json!(1), GraphNodeOpts::named("source"));
1992 let mut checkpoint = g.checkpoint().expect("checkpoint succeeds");
1993 if let GraphCheckpointFactory::RegistryRef { config_version, .. } =
1994 &mut checkpoint.nodes[0].factory
1995 {
1996 *config_version = Some(json!("v2"));
1997 }
1998
1999 let err = match restore_graph(
2000 checkpoint,
2001 RestoreGraphOptions::new(default_restore_registry()),
2002 ) {
2003 Ok(_) => panic!("built-in state rejects configVersion"),
2004 Err(err) => err,
2005 };
2006 assert!(err.to_string().contains("configVersion"));
2007 }
2008
2009 #[test]
2010 fn restore_preserves_registry_metadata_for_later_checkpoints() {
2011 let g = crate::graph::graph();
2012 let source = g.state_opts(json!(3), GraphNodeOpts::named("source"));
2013 let mut opts = GraphNodeOpts::named("double");
2014 opts.restore = Some(
2015 RestoreFactoryMeta::registry_ref("map").with_config(json!({ "fn": "double-json" })),
2016 );
2017 let doubled = g.node_opts::<GraphCheckpointJson, _>(
2018 vec![source.erased()],
2019 |ctx| {
2020 for value in ctx.batch::<GraphCheckpointJson>(0) {
2021 ctx.emit(json!(value.as_i64().expect("number input") * 2));
2022 }
2023 },
2024 opts,
2025 );
2026 let _keep_active = doubled.subscribe(|_| {});
2027 let checkpoint = g.checkpoint().expect("checkpoint succeeds");
2028 let registry = restore_registry([
2029 GraphRestoreEntry::descriptor(StateRestoreDescriptor),
2030 GraphRestoreEntry::descriptor(MapJsonRestoreDescriptor),
2031 GraphRestoreEntry::definition(GraphRestoreDefinition::json("double-json", |value| {
2032 Ok(json!(value.as_i64().expect("number input") * 2))
2033 })),
2034 ]);
2035
2036 let restored = restore_graph(checkpoint, RestoreGraphOptions::new(registry))
2037 .expect("restore succeeds");
2038 let restored_checkpoint = restored.checkpoint().expect("re-checkpoint succeeds");
2039 let restored_node = restored_checkpoint
2040 .nodes
2041 .iter()
2042 .find(|node| node.id == "double")
2043 .expect("double checkpoint exists");
2044
2045 assert_eq!(
2046 restored_node.factory,
2047 GraphCheckpointFactory::RegistryRef {
2048 ref_: "map".to_owned(),
2049 config: Some(json!({ "fn": "double-json" })),
2050 config_version: None,
2051 }
2052 );
2053 }
2054
2055 #[test]
2056 fn restore_rejects_rust_unsupported_non_string_meta() {
2057 let g = crate::graph::graph();
2058 g.state_opts(json!(1), GraphNodeOpts::named("source"));
2059 let mut checkpoint = g.checkpoint().expect("checkpoint succeeds");
2060 checkpoint.nodes[0].meta = Some(BTreeMap::from([("json".to_owned(), json!({ "x": 1 }))]));
2061
2062 let err = match restore_graph(
2063 checkpoint,
2064 RestoreGraphOptions::new(default_restore_registry()),
2065 ) {
2066 Ok(_) => panic!("Rust graph metadata is string-only"),
2067 Err(err) => err,
2068 };
2069 assert!(err.to_string().contains("meta field 'json'"));
2070 }
2071
2072 #[test]
2073 fn restore_rejects_duplicate_edges() {
2074 let g = crate::graph::graph();
2075 let source = g.state_opts(json!(1), GraphNodeOpts::named("source"));
2076 let mut opts = GraphNodeOpts::named("memo");
2077 opts.restore = Some(RestoreFactoryMeta::registry_ref("stateful-json"));
2078 let memo = g.node_opts::<GraphCheckpointJson, _>(
2079 vec![source.erased()],
2080 |ctx| {
2081 for value in ctx.batch::<GraphCheckpointJson>(0) {
2082 ctx.emit(value.as_ref().clone());
2083 }
2084 },
2085 opts,
2086 );
2087 let _keep_active = memo.subscribe(|_| {});
2088 let mut checkpoint = g.checkpoint().expect("checkpoint succeeds");
2089 let edge = checkpoint.edges[0].clone();
2090 checkpoint.edges.push(edge);
2091
2092 let registry = restore_registry([
2093 GraphRestoreEntry::descriptor(StateRestoreDescriptor),
2094 GraphRestoreEntry::descriptor(StatefulJsonDescriptor),
2095 ]);
2096 let err = match restore_graph(checkpoint, RestoreGraphOptions::new(registry)) {
2097 Ok(_) => panic!("duplicate checkpoint edges must fail"),
2098 Err(err) => err,
2099 };
2100 assert!(err.to_string().contains("duplicate edge"));
2101 }
2102
2103 #[test]
2104 fn checkpoint_and_restore_reject_non_quiescent_statuses() {
2105 let g = crate::graph::graph();
2106 let source = g.state_opts(json!(1), GraphNodeOpts::named("source"));
2107 source.down(vec![Message::Dirty]);
2108
2109 let err = g
2110 .checkpoint()
2111 .expect_err("checkpoint cannot preserve in-flight wave bookkeeping yet");
2112 assert!(err.to_string().contains("non-quiescent status 'dirty'"));
2113
2114 let g = crate::graph::graph();
2115 g.state_opts(json!(1), GraphNodeOpts::named("source"));
2116 let mut checkpoint = g.checkpoint().expect("checkpoint succeeds");
2117 checkpoint.nodes[0].status = "pending".to_owned();
2118
2119 let err = match restore_graph(
2120 checkpoint,
2121 RestoreGraphOptions::new(default_restore_registry()),
2122 ) {
2123 Ok(_) => panic!("restore must reject non-quiescent checkpoint status"),
2124 Err(err) => err,
2125 };
2126 assert!(err.to_string().contains("non-quiescent status 'pending'"));
2127 }
2128
2129 #[test]
2130 fn restore_rejects_empty_mount_path_without_panicking() {
2131 let parent = crate::graph::graph();
2132 let child = crate::graph::graph();
2133 child.state_opts(json!(1), GraphNodeOpts::named("leaf"));
2134 parent.mount(child, "child");
2135 let mut checkpoint = parent.checkpoint().expect("checkpoint succeeds");
2136 checkpoint.mounts.as_mut().expect("mount exists")[0]
2137 .at
2138 .clear();
2139
2140 let err = match restore_graph(
2141 checkpoint,
2142 RestoreGraphOptions::new(default_restore_registry()),
2143 ) {
2144 Ok(_) => panic!("empty mount path must fail validation"),
2145 Err(err) => err,
2146 };
2147 assert!(err.to_string().contains("must not be empty"));
2148 }
2149
2150 #[test]
2151 fn checkpoint_rejects_unavailable_error_payload_instead_of_inventing_one() {
2152 let g = crate::graph::graph();
2153 let source = g.state_opts(json!(1), GraphNodeOpts::named("source"));
2154 source.down(vec![Message::Error(Box::new(std::io::Error::other(
2155 "boom",
2156 )))]);
2157
2158 let err = g
2159 .checkpoint()
2160 .expect_err("Rust cannot serialize the original GraphError payload yet");
2161 assert!(err.to_string().contains("ERROR payload"));
2162 }
2163}