durable_workflow/
cancellation_scope.rs

1use super::*;
2
3const OPEN_BUDGET: Duration = Duration::from_secs(5);
4const MAX_SCOPE_SEQUENCE: u64 = i64::MAX as u64;
5const MAX_HISTORY_PAGES: usize = 128;
6
7fn invalid() -> Error {
8    Error::InvalidCooperativeCancellation("invalid canonical cancellation scope opening".into())
9}
10
11fn identity(value: &str) -> bool {
12    !value.trim().is_empty() && value.len() <= 255
13}
14
15fn text<'a>(value: &'a Value, field: &str) -> Result<&'a str> {
16    value[field]
17        .as_str()
18        .filter(|value| identity(value))
19        .ok_or_else(invalid)
20}
21
22#[derive(Clone, Debug)]
23pub(super) struct CanonicalScopeOpening {
24    pub scope_id: String,
25    pub parent_scope_id: String,
26    pub shield_parent: bool,
27}
28
29#[derive(Clone, Default, Debug)]
30pub(super) struct CancellationScopeHistory {
31    pub openings: BTreeMap<u64, CanonicalScopeOpening>,
32    pub memberships: BTreeMap<u64, String>,
33}
34
35fn invalid_history(detail: &str) -> Error {
36    invalid_recorded_history(
37        "invalid_cancellation_scope_history",
38        0,
39        "canonical scope tree and original membership",
40        "invalid history",
41        detail,
42    )
43}
44
45fn starts_with_workflow_start(events: &[HistoryEvent]) -> bool {
46    events
47        .first()
48        .is_some_and(|event| event.event_type == "WorkflowStarted")
49        || (events
50            .first()
51            .is_some_and(|event| event.event_type == "StartAccepted")
52            && events
53                .get(1)
54                .is_some_and(|event| event.event_type == "WorkflowStarted"))
55}
56
57impl CancellationScopeHistory {
58    pub fn read(events: &[HistoryEvent], run_id: &str) -> Result<Self> {
59        let has_scopes = events
60            .iter()
61            .any(|event| event.event_type == "CancellationScopeOpened");
62        if has_scopes && !starts_with_workflow_start(events) {
63            return Err(invalid_history(
64                "scope history lacks its original workflow start",
65            ));
66        }
67        let mut history = Self::default();
68        let mut event_ids = BTreeSet::new();
69        let mut scopes = BTreeMap::new();
70        let mut namespace: Option<&str> = None;
71        let mut last_event_sequence = 0;
72        let mut last_opening = 0;
73        for event in events {
74            if has_scopes {
75                let event_id = event
76                    .raw
77                    .get("id")
78                    .and_then(Value::as_str)
79                    .filter(|value| identity(value))
80                    .ok_or_else(|| invalid_history("missing canonical event identity"))?;
81                let sequence = event
82                    .raw
83                    .get("sequence")
84                    .and_then(Value::as_u64)
85                    .filter(|value| *value > last_event_sequence && *value <= MAX_SCOPE_SEQUENCE)
86                    .ok_or_else(|| invalid_history("canonical event order changed"))?;
87                let incoming_namespace = event
88                    .raw
89                    .get("namespace")
90                    .and_then(Value::as_str)
91                    .filter(|value| identity(value))
92                    .ok_or_else(|| invalid_history("missing canonical namespace"))?;
93                if !event_ids.insert(event_id)
94                    || namespace.is_some_and(|previous| previous != incoming_namespace)
95                    || !event.payload.is_object()
96                    || event.event_type.trim().is_empty()
97                {
98                    return Err(invalid_history(
99                        "canonical event identity, namespace or payload changed",
100                    ));
101                }
102                namespace = Some(incoming_namespace);
103                last_event_sequence = sequence;
104            }
105            let payload = &event.payload;
106            let sequence = payload["sequence"]
107                .as_u64()
108                .filter(|value| *value > 0 && *value <= MAX_SCOPE_SEQUENCE);
109            if event.event_type == "CancellationScopeOpened" {
110                let scope_id = text(payload, "scope_id")
111                    .map_err(|_| invalid_history("invalid scope identity"))?;
112                let parent = text(payload, "parent_scope_id")
113                    .map_err(|_| invalid_history("invalid parent identity"))?;
114                let sequence =
115                    sequence.ok_or_else(|| invalid_history("invalid authored opening sequence"))?;
116                let shield = payload["shield_parent"]
117                    .as_bool()
118                    .ok_or_else(|| invalid_history("invalid shielding"))?;
119                if payload["schema"] != "durable-workflow.cancellation-scope/v1"
120                    || run_id.is_empty()
121                    || payload["workflow_run_id"].as_str() != Some(run_id)
122                    || scope_id == "root"
123                    || scopes.contains_key(scope_id)
124                    || (parent != "root" && !scopes.contains_key(parent))
125                    || sequence <= last_opening
126                    || history.memberships.contains_key(&sequence)
127                {
128                    return Err(invalid_history("invalid canonical opening tree"));
129                }
130                scopes.insert(scope_id, sequence);
131                last_opening = sequence;
132                history.openings.insert(
133                    sequence,
134                    CanonicalScopeOpening {
135                        scope_id: scope_id.into(),
136                        parent_scope_id: parent.into(),
137                        shield_parent: shield,
138                    },
139                );
140                continue;
141            }
142            let admission = matches!(
143                event.event_type.as_str(),
144                "ActivityScheduled"
145                    | "TimerScheduled"
146                    | "ChildWorkflowScheduled"
147                    | "ConditionWaitOpened"
148                    | "SignalWaitOpened"
149            );
150            let operation = admission
151                || matches!(
152                    event.event_type.as_str(),
153                    "ActivityStarted"
154                        | "ActivityCompleted"
155                        | "ActivityFailed"
156                        | "ActivityTimedOut"
157                        | "ActivityCancelled"
158                        | "ActivityRetryScheduled"
159                        | "TimerFired"
160                        | "TimerCancelled"
161                        | "ChildRunStarted"
162                        | "ChildRunCompleted"
163                        | "ChildRunFailed"
164                        | "ChildRunCancelled"
165                        | "ChildRunTerminated"
166                        | "ConditionWaitSatisfied"
167                        | "ConditionWaitTimedOut"
168                        | "ConditionWaitCancelled"
169                        | "SignalWaitReceived"
170                        | "SignalWaitTimedOut"
171                        | "SignalWaitCancelled"
172                );
173            if !operation {
174                continue;
175            }
176            let mut membership: Option<&str> = None;
177            for snapshot in std::iter::once(payload).chain(
178                ["activity", "timer", "child_workflow"]
179                    .iter()
180                    .filter_map(|name| payload.get(name)),
181            ) {
182                let Some(value) = snapshot.get("cancellation_scope_id") else {
183                    continue;
184                };
185                let incoming = value
186                    .as_str()
187                    .filter(|value| identity(value))
188                    .ok_or_else(|| invalid_history("invalid operation scope membership"))?;
189                if membership.is_some_and(|previous| previous != incoming) {
190                    return Err(invalid_history("contradictory operation scope membership"));
191                }
192                membership = Some(incoming);
193            }
194            if membership.is_none() && !admission {
195                continue;
196            }
197            let membership = membership.unwrap_or("root");
198            if membership != "root"
199                && !sequence.is_some_and(|sequence| {
200                    scopes
201                        .get(membership)
202                        .is_some_and(|opening| *opening < sequence)
203                })
204            {
205                return Err(invalid_history(
206                    "operation scope was not opened before original admission",
207                ));
208            }
209            let Some(sequence) = sequence else {
210                continue;
211            };
212            if history.openings.contains_key(&sequence)
213                || history
214                    .memberships
215                    .get(&sequence)
216                    .is_some_and(|previous| previous != membership)
217            {
218                return Err(invalid_history(
219                    "operation changed its original scope membership",
220                ));
221            }
222            history.memberships.insert(sequence, membership.into());
223        }
224        Ok(history)
225    }
226}
227
228#[derive(Clone, Debug)]
229pub(super) struct CancellationScopeOpening {
230    pub sequence: u64,
231    pub parent_scope_id: String,
232    pub shield_parent: bool,
233    pub command_count: usize,
234}
235
236impl WorkflowContext {
237    pub(super) fn validate_scope_membership(
238        &self,
239        state: &mut WorkflowState,
240        cursor: usize,
241    ) -> Result<()> {
242        if !state.allow_cancellation_scope_authoring {
243            return Ok(());
244        }
245        if let Some(replay) = &mut state.scope_delivery {
246            replay.active_scope = self.cancellation_scope_id.clone();
247        }
248        if let Some(recorded) = state.recorded_commands.get(cursor) {
249            let sequence = recorded.sequence();
250            let original = state
251                .cancellation_scope_memberships
252                .get(&sequence)
253                .map(String::as_str)
254                .or_else(|| {
255                    state
256                        .scope_delivery
257                        .as_ref()
258                        .and_then(|replay| replay.canonical.deliveries.get(&sequence))
259                        .map(|delivered| delivered.context.scope_id())
260                })
261                .unwrap_or("root");
262            if original != self.cancellation_scope_id {
263                return Err(invalid_recorded_history(
264                    "cancellation_scope_membership_changed",
265                    sequence,
266                    original,
267                    &self.cancellation_scope_id,
268                    "operation changed the scope where it was created",
269                ));
270            }
271        }
272        Ok(())
273    }
274
275    pub(super) fn apply_scope_membership(&self, command: &mut serde_json::Map<String, Value>) {
276        if self.cancellation_scope_id != "root" {
277            command.insert(
278                "cancellation_scope_id".into(),
279                json!(self.cancellation_scope_id),
280            );
281        }
282    }
283
284    /// Await the original durable opening, then run a body with its own context.
285    /// Operations created from that context retain their scope when awaited later.
286    /// Candidate authoring remains disabled on ordinary workers.
287    pub async fn cancellation_scope<F, Fut, T>(&self, shield_parent: bool, body: F) -> Result<T>
288    where
289        F: FnOnce(WorkflowContext) -> Fut,
290        Fut: Future<Output = Result<T>>,
291    {
292        let scope_id = ScopeOpeningCall {
293            ctx: self.clone(),
294            shield_parent,
295        }
296        .await?;
297        let mut scoped = self.clone();
298        scoped.cancellation_scope_id = scope_id;
299        body(scoped).await
300    }
301}
302
303struct ScopeOpeningCall {
304    ctx: WorkflowContext,
305    shield_parent: bool,
306}
307
308impl Future for ScopeOpeningCall {
309    type Output = Result<String>;
310
311    fn poll(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll<Self::Output> {
312        let mut state = match self.ctx.state.lock() {
313            Ok(state) => state,
314            Err(_) => return Poll::Ready(Err(Error::WorkflowStatePoisoned)),
315        };
316        if !state.allow_cancellation_scope_authoring {
317            return Poll::Ready(Err(Error::CancellationScopeExecutionUnavailable));
318        }
319        if state.cancellation_consumed {
320            return Poll::Ready(Err(Error::CancellationScopeExecutionUnavailable));
321        }
322        if state
323            .cancellation_scope_opening
324            .as_ref()
325            .is_some_and(|opening| opening.command_count != state.commands.len())
326        {
327            return Poll::Ready(Err(invalid_history(
328                "workflow authored commands after an uncommitted opening",
329            )));
330        }
331        let sequence = (state.command_cursor as u64)
332            .saturating_add(state.commands.len() as u64)
333            .saturating_add(1);
334        if let Some(recorded) = state.recorded_commands.get(state.command_cursor).cloned() {
335            return match recorded {
336                RecordedCommand::CancellationScope {
337                    sequence: original,
338                    scope_id,
339                    parent_scope_id,
340                    shield_parent,
341                } if original == sequence
342                    && parent_scope_id == self.ctx.cancellation_scope_id
343                    && shield_parent == self.shield_parent =>
344                {
345                    state.command_cursor += 1;
346                    Poll::Ready(Ok(scope_id))
347                }
348                recorded => Poll::Ready(Err(invalid_recorded_history(
349                    "cancellation_scope_opening_changed",
350                    sequence,
351                    "original scope opening, parent and shielding",
352                    recorded.shape(),
353                    "authored cancellation scope differs from committed history",
354                ))),
355            };
356        }
357        if state.history_events.iter().any(|event| {
358            matches!(
359                event.event_type.as_str(),
360                "WorkflowCompleted"
361                    | "WorkflowFailed"
362                    | "WorkflowCancelled"
363                    | "WorkflowTerminated"
364                    | "WorkflowContinuedAsNew"
365            )
366        }) {
367            return Poll::Ready(Err(invalid_recorded_history(
368                "cancellation_scope_opening_changed",
369                sequence,
370                "original scope opening",
371                "closed history",
372                "closed history cannot admit an unrecorded scope",
373            )));
374        }
375        state.cancellation_scope_opening = Some(CancellationScopeOpening {
376            sequence,
377            parent_scope_id: self.ctx.cancellation_scope_id.clone(),
378            shield_parent: self.shield_parent,
379            command_count: state.commands.len(),
380        });
381        Poll::Pending
382    }
383}
384
385/// A scope opening proved against complete history on its original claim.
386/// This proof does not advertise scoped workflow execution support.
387#[derive(Clone, Debug)]
388pub struct CancellationScopeOpenReceipt {
389    scope_id: String,
390    history_event_id: String,
391    sequence: u64,
392    parent_scope_id: String,
393    shield_parent: bool,
394    duplicate: bool,
395    history: Vec<HistoryEvent>,
396}
397
398impl CancellationScopeOpenReceipt {
399    pub fn scope_id(&self) -> &str {
400        &self.scope_id
401    }
402    pub fn history_event_id(&self) -> &str {
403        &self.history_event_id
404    }
405    pub fn sequence(&self) -> u64 {
406        self.sequence
407    }
408    pub fn parent_scope_id(&self) -> &str {
409        &self.parent_scope_id
410    }
411    pub fn shield_parent(&self) -> bool {
412        self.shield_parent
413    }
414    pub fn duplicate(&self) -> bool {
415        self.duplicate
416    }
417    pub fn history(&self) -> &[HistoryEvent] {
418        &self.history
419    }
420
421    fn acknowledge<'a>(receipt: &'a Value, expected: &Value) -> Result<&'a str> {
422        if expected
423            .as_object()
424            .ok_or_else(invalid)?
425            .iter()
426            .any(|(key, value)| key != "namespace" && receipt.get(key) != Some(value))
427            || receipt["opened"].as_bool() != Some(true)
428            || receipt["duplicate"].as_bool().is_none()
429            || receipt["claim_released"].as_bool() != Some(false)
430            || receipt.get("created_task_ids") != Some(&json!([]))
431            || receipt.get("reason") != Some(&Value::Null)
432            || text(receipt, "scope_id")? == "root"
433        {
434            return Err(invalid());
435        }
436        text(receipt, "history_event_id")?;
437        receipt["history_refresh_page_token"]
438            .as_str()
439            .filter(|value| !value.trim().is_empty())
440            .ok_or_else(invalid)
441    }
442
443    fn from_history(receipt: &Value, events: &[Value], expected: &Value) -> Result<Self> {
444        Self::acknowledge(receipt, expected)?;
445        let kind = |index: usize| {
446            events
447                .get(index)
448                .and_then(|event| event.get("event_type").or_else(|| event.get("type")))
449                .and_then(Value::as_str)
450        };
451        if kind(0) != Some("WorkflowStarted")
452            && !(kind(0) == Some("StartAccepted") && kind(1) == Some("WorkflowStarted"))
453        {
454            return Err(invalid());
455        }
456        let mut event_ids = BTreeSet::new();
457        let mut scopes = BTreeSet::new();
458        let mut last_event_sequence = 0;
459        let mut last_scope_sequence = 0;
460        let mut found = false;
461        let mut history = Vec::with_capacity(events.len());
462        for event in events {
463            let event_id = text(event, "id")?;
464            let event_sequence = event["sequence"].as_u64().ok_or_else(invalid)?;
465            let payload = &event["payload"];
466            let event_kind = event
467                .get("event_type")
468                .or_else(|| event.get("type"))
469                .and_then(Value::as_str)
470                .filter(|value| !value.trim().is_empty())
471                .ok_or_else(invalid)?;
472            if !event_ids.insert(event_id)
473                || event_sequence <= last_event_sequence
474                || event_sequence > MAX_SCOPE_SEQUENCE
475                || event.get("namespace") != expected.get("namespace")
476                || !payload.is_object()
477            {
478                return Err(invalid());
479            }
480            last_event_sequence = event_sequence;
481            if event_kind == "CancellationScopeOpened" {
482                let scope_id = text(payload, "scope_id")?;
483                let parent = text(payload, "parent_scope_id")?;
484                let sequence = payload["sequence"].as_u64().ok_or_else(invalid)?;
485                if payload["schema"] != "durable-workflow.cancellation-scope/v1"
486                    || payload.get("workflow_run_id") != expected.get("workflow_run_id")
487                    || scope_id == "root"
488                    || scopes.contains(scope_id)
489                    || (parent != "root" && !scopes.contains(parent))
490                    || payload["shield_parent"].as_bool().is_none()
491                    || sequence <= last_scope_sequence
492                    || sequence > MAX_SCOPE_SEQUENCE
493                {
494                    return Err(invalid());
495                }
496                scopes.insert(scope_id);
497                last_scope_sequence = sequence;
498                if Some(event_id) == receipt["history_event_id"].as_str() {
499                    if ["scope_id", "sequence", "parent_scope_id", "shield_parent"]
500                        .iter()
501                        .any(|field| payload.get(*field) != receipt.get(*field))
502                    {
503                        return Err(invalid());
504                    }
505                    found = true;
506                }
507            }
508            history.push(serde_json::from_value(event.clone()).map_err(|_| invalid())?);
509        }
510        if !found {
511            return Err(invalid());
512        }
513        CancellationScopeHistory::read(&history, text(expected, "workflow_run_id")?)
514            .map_err(|_| invalid())?;
515        Ok(Self {
516            scope_id: text(receipt, "scope_id")?.into(),
517            history_event_id: text(receipt, "history_event_id")?.into(),
518            sequence: receipt["sequence"].as_u64().ok_or_else(invalid)?,
519            parent_scope_id: text(receipt, "parent_scope_id")?.into(),
520            shield_parent: receipt["shield_parent"].as_bool().ok_or_else(invalid)?,
521            duplicate: receipt["duplicate"].as_bool().ok_or_else(invalid)?,
522            history,
523        })
524    }
525}
526
527impl Client {
528    /// Open a scope and prove its canonical identity on the original claim.
529    ///
530    /// This explicit protocol 1.20 operation keeps one five-second budget for
531    /// lost acknowledgement recovery and all history pages. No lease, worker
532    /// capability or cancellation execution authority is added by this proof.
533    pub async fn open_cancellation_scope_on_claim(
534        &self,
535        task: &WorkflowTask,
536        sequence: u64,
537        parent_scope_id: &str,
538        shield_parent: bool,
539    ) -> Result<CancellationScopeOpenReceipt> {
540        let (owner, run_id) = cooperative_cancellation::cancellation_claim(task)?;
541        if [
542            task.task_id.as_str(),
543            owner,
544            run_id,
545            parent_scope_id,
546            self.namespace.as_str(),
547        ]
548        .iter()
549        .any(|value| !identity(value))
550            || sequence == 0
551            || sequence > MAX_SCOPE_SEQUENCE
552        {
553            return Err(invalid());
554        }
555        let expected = json!({"task_id":task.task_id, "workflow_run_id":run_id,
556            "lease_owner":owner, "workflow_task_attempt":task.workflow_task_attempt,
557            "sequence":sequence, "parent_scope_id":parent_scope_id,
558            "shield_parent":shield_parent, "namespace":self.namespace});
559        let path = format!(
560            "/worker/workflow-tasks/{}",
561            percent_encode_path_segment(&task.task_id)
562        );
563        let body = json!({"lease_owner":owner, "workflow_task_attempt":task.workflow_task_attempt,
564            "sequence":sequence, "parent_scope_id":parent_scope_id,"shield_parent":shield_parent});
565        tokio::time::timeout(OPEN_BUDGET, async {
566            let open_path = format!("{path}/cancellation-scopes/open");
567            let request = || self.request_json::<Value, _>(reqwest::Method::POST,
568                &open_path, RequestProtocol::Worker("1.20"), Some(&body));
569            let receipt = match request().await {
570                Err(error) if worker_operation_is_retryable(&error) => request().await?,
571                result => result?,
572            };
573            let mut token = Some(CancellationScopeOpenReceipt::acknowledge(&receipt, &expected)?.to_owned());
574            let mut seen = BTreeSet::new();
575            let mut events = Vec::new();
576            while let Some(current) = token.take() {
577                if seen.len() >= MAX_HISTORY_PAGES || !seen.insert(current.clone()) { return Err(invalid()); }
578                let page: Value = self.request_json(reqwest::Method::POST, &format!("{path}/history"),
579                    RequestProtocol::Worker("1.20"), Some(&json!({"lease_owner":owner,
580                        "workflow_task_attempt":task.workflow_task_attempt, "next_history_page_token":current,
581                        "history_page_size":WORKFLOW_HISTORY_PAGE_SIZE}))).await?;
582                if page["task_id"].as_str() != Some(task.task_id.as_str())
583                    || page["workflow_task_attempt"].as_u64() != Some(task.workflow_task_attempt)
584                { return Err(invalid()); }
585                let batch = page["history_events"].as_array().ok_or_else(invalid)?;
586                if batch.len() > WORKFLOW_HISTORY_PAGE_SIZE as usize || batch.iter().any(|event| !event.is_object()) {
587                    return Err(invalid());
588                }
589                token = match page.get("next_history_page_token") {
590                    Some(Value::Null) => None,
591                    Some(Value::String(next)) if !next.trim().is_empty() && !batch.is_empty() => Some(next.clone()),
592                    _ => return Err(invalid()),
593                };
594                events.extend(batch.iter().cloned());
595            }
596            CancellationScopeOpenReceipt::from_history(&receipt, &events, &expected)
597        }).await.map_err(|_| Error::Timeout)?
598    }
599}