net/adapter/net/cortex/workflow/fold.rs
1//! `WorkflowFold` — decodes `EventMeta` + payload, routes on dispatch,
2//! and applies the deterministic task-lifecycle state transition.
3//!
4//! The chain is single-writer (the task-lease holder), so the fold does
5//! not arbitrate contention between writers — it replays the writer's
6//! cursor advances. It does enforce one structural invariant, though: a
7//! terminal task (`Done`/`Failed`) is never moved by a later transition
8//! or retry, so a duplicate / replayed / buggy-writer event can't
9//! resurrect a settled task. Same chain → same state.
10
11use super::super::super::redex::{RedexError, RedexEvent, RedexFold};
12use super::super::meta::{
13 compute_checksum, compute_checksum_with_meta, EventMeta, EVENT_META_SIZE,
14};
15use super::dispatch::{
16 DISPATCH_TASK_ADVANCED, DISPATCH_TASK_CANCEL_REQUESTED, DISPATCH_TASK_DELETED,
17 DISPATCH_TASK_LINKED, DISPATCH_TASK_RETRIED, DISPATCH_TASK_SUBMITTED,
18 DISPATCH_TASK_TRANSITIONED,
19};
20use super::state::WorkflowState;
21use super::types::{
22 AdvancedPayload, CancelRequestedPayload, DeletedPayload, LinkedPayload, RetriedPayload,
23 SubmittedPayload, TaskState, TaskStatus, TransitionedPayload,
24};
25
26/// Fold implementation for the task-lifecycle model.
27pub struct WorkflowFold;
28
29impl RedexFold<WorkflowState> for WorkflowFold {
30 fn apply(&mut self, ev: &RedexEvent, state: &mut WorkflowState) -> Result<(), RedexError> {
31 // Decode failures use `RedexError::Decode` (recoverable —
32 // skip-and-continue even under the `Stop` policy) so one
33 // corrupt event can't wedge the fold task forever; same
34 // rationale as `TasksFold`.
35 if ev.payload.len() < EVENT_META_SIZE {
36 return Err(RedexError::Decode(format!(
37 "workflow payload too short: {} bytes (need >= {})",
38 ev.payload.len(),
39 EVENT_META_SIZE
40 )));
41 }
42 let meta = EventMeta::from_bytes(&ev.payload[..EVENT_META_SIZE])
43 .ok_or_else(|| RedexError::Decode("bad EventMeta prefix".into()))?;
44 let tail = &ev.payload[EVENT_META_SIZE..];
45
46 // Verify the ingest-time checksum over (header-with-zeroed-
47 // checksum ++ tail); fall back to the legacy tail-only hash
48 // for records written by pre-fix adapters.
49 let v2_expected = compute_checksum_with_meta(&meta, tail);
50 let valid = meta.checksum == v2_expected || meta.checksum == compute_checksum(tail);
51 if !valid {
52 return Err(RedexError::Decode(format!(
53 "workflow fold: EventMeta checksum mismatch at seq {} (got {:#010x}, v2 expected {:#010x})",
54 ev.entry.seq, meta.checksum, v2_expected
55 )));
56 }
57
58 match meta.dispatch {
59 DISPATCH_TASK_SUBMITTED => {
60 let p: SubmittedPayload =
61 postcard::from_bytes(tail).map_err(|e| RedexError::Decode(e.to_string()))?;
62 // Submit is the baseline; a re-submit of a live id
63 // resets it to the fresh state (the log is the source
64 // of truth) and clears any stale cancel signal.
65 state.tasks.insert(p.id, TaskState::submitted());
66 state.cancelled.remove(&p.id);
67 }
68 DISPATCH_TASK_TRANSITIONED => {
69 let p: TransitionedPayload =
70 postcard::from_bytes(tail).map_err(|e| RedexError::Decode(e.to_string()))?;
71 if let Some(t) = state.tasks.get_mut(&p.id) {
72 // Terminal is terminal: a `Done`/`Failed` task is
73 // never moved by a plain transition. The sanctioned
74 // way out of `Failed` is `retry`; out of `Done`
75 // there is none (a fresh `submit` resets instead).
76 // This guards replay / duplicate / buggy-writer
77 // events from resurrecting a settled task (review #2).
78 if !t.status.is_terminal() {
79 t.status = p.status;
80 }
81 }
82 // A transition for an unknown id is a no-op: the submit
83 // we never observed simply isn't in our view.
84 }
85 DISPATCH_TASK_ADVANCED => {
86 let p: AdvancedPayload =
87 postcard::from_bytes(tail).map_err(|e| RedexError::Decode(e.to_string()))?;
88 if let Some(t) = state.tasks.get_mut(&p.id) {
89 t.step = t.step.saturating_add(1);
90 // A new step starts with a clean attempt counter.
91 t.attempts = 0;
92 }
93 }
94 DISPATCH_TASK_RETRIED => {
95 let p: RetriedPayload =
96 postcard::from_bytes(tail).map_err(|e| RedexError::Decode(e.to_string()))?;
97 if let Some(t) = state.tasks.get_mut(&p.id) {
98 // Retry re-runs the current step — the sanctioned
99 // `Failed → Running` exit. It must not resurrect a
100 // `Done` task, which is terminal success (review #2).
101 if t.status != TaskStatus::Done {
102 t.attempts = t.attempts.saturating_add(1);
103 t.status = TaskStatus::Running;
104 }
105 }
106 }
107 DISPATCH_TASK_DELETED => {
108 let p: DeletedPayload =
109 postcard::from_bytes(tail).map_err(|e| RedexError::Decode(e.to_string()))?;
110 // Cascade: delete reclaims the WHOLE subtree (shards /
111 // spawned children), not just the named task — an
112 // orphaned shard would keep running and keep holding its
113 // claim (corrections #4). The subtree is computed from
114 // the folded lineage, so it's deterministic / replayable.
115 let subtree = state.subtree(p.id);
116 // Detach the root from its parent's child list.
117 if let Some(parent) = state.parents.get(&p.id).copied() {
118 if let Some(sibs) = state.children.get_mut(&parent) {
119 sibs.retain(|c| *c != p.id);
120 }
121 }
122 for t in subtree {
123 state.tasks.remove(&t);
124 state.cancelled.remove(&t);
125 state.children.remove(&t);
126 state.parents.remove(&t);
127 }
128 }
129 DISPATCH_TASK_CANCEL_REQUESTED => {
130 let p: CancelRequestedPayload =
131 postcard::from_bytes(tail).map_err(|e| RedexError::Decode(e.to_string()))?;
132 // Record the signal for the worker to observe; the
133 // status transition itself is the worker's to make.
134 state.cancelled.insert(p.id);
135 }
136 DISPATCH_TASK_LINKED => {
137 let p: LinkedPayload =
138 postcard::from_bytes(tail).map_err(|e| RedexError::Decode(e.to_string()))?;
139 // Record the lineage edge (idempotent — a duplicate link
140 // doesn't double-insert the child).
141 let kids = state.children.entry(p.parent).or_default();
142 if !kids.contains(&p.child) {
143 kids.push(p.child);
144 }
145 state.parents.insert(p.child, p.parent);
146 }
147 other => {
148 // Unknown dispatches in the CortEX-internal range are
149 // forward-compatibility — log and skip.
150 tracing::debug!(
151 dispatch = other,
152 seq = ev.entry.seq,
153 "workflow fold: ignoring unknown dispatch"
154 );
155 }
156 }
157 Ok(())
158 }
159}