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 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#[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 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}