Skip to main content

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}