Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,38 @@

## Unreleased

## 4.15.0

- All workflow and run routes encode each identifier or name as one path
segment. Empty identifiers and `.` or `..` segments return
`Error::InvalidPathSegment` before any HTTP request, including capability
discovery for cooperative cancellation.

- Cooperative cancellation now reads the optional top-level `cause` of the
portable cancellation context (workflow#863, workflow#1129). A
timeout-cleanup request (`cause: "timeout"`: the run passed its work
deadline and is now in cleanup) arrives through the existing
`Error::CooperativeCancellationRequested`, so existing
`Error::CooperativeCancellationRequested(_)` arms, such as saga
compensation, run for it unchanged. There is no new `Error` variant. Tell
the two apart with the new `#[non_exhaustive]` `CancellationCause`:
`Requested` for an ordinary request (`cause` absent or `"cancellation"`) or
`Timeout`. The request's cleanup deadline is its existing
`cleanup_deadline_at`. `CancellationRequest::cause()`, `CancellationContext::cause()` and `is_timeout()`
read it from the context. A context with any other `cause` is refused as
`Error::InvalidCooperativeCancellation`. The cause is never inherited: a
descendant cancelled under the request, scoped or not, carries no `cause`
and is an ordinary cancellation, even when its scope origin's root context
carries `cause: "timeout"`. `CancellationContext::to_value()` writes `cause`
back exactly as read, including an explicit `"cancellation"`, and an
ordinary context's output is unchanged.

`CancellationRequest::cause()` derives the discriminator from the existing
context, preserving stable 4.x struct literals and exhaustive destructuring.
This parser support is experimental. Service-mode timeout-cleanup admission
and execution require the upstream Core/native implementation and separate
published-artifact qualification.

- A workflow-task completion that native Server 3.6.0 refuses as too large
(HTTP 413, `limit_kind: workflow_task_completion_bytes`, server#1152,
durable-workflow/.github#228) is now a typed, non-retryable outcome.
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "durable-workflow"
version = "4.14.0"
version = "4.15.0"
edition = "2021"
description = "Rust client and worker SDK for Durable Workflow Cloud and self-hosted Server"
license = "MIT"
Expand All @@ -14,7 +14,7 @@ categories = ["api-bindings", "asynchronous"]
include = ["/src/**", "/schema/**", "/examples/**", "/Cargo.toml", "/README.md", "/CHANGELOG.md", "/LICENSE", "/tests/fixtures/replay-regressions/published-php-complete-explicit-backoff.json", "/tests/fixtures/sdk-metrics-contract.json", "/tests/fixtures/auth-configuration/static-credentials-v1.json"]

[package.metadata.durable-workflow]
product-train = "4.14.0"
product-train = "4.15.0"
compatibility-authority = "protocol-manifests"
supported-server-versions = "2.0.0"
supported-server-release-baselines = { "2" = "2.0.0", "3" = "3.0.2" }
Expand Down
21 changes: 21 additions & 0 deletions docs/cooperative-cancellation-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,27 @@ time, original deadline and ordered lineage. Requester fields are limited to
caller type, ID and label. Timestamp helpers return immutable UTC dates.
`to_value()` produces a detached portable snapshot.

## Timeout cleanup cause

This parser support is experimental. Service-mode timeout-cleanup admission
and execution require the upstream Core/native implementation and separate
published-artifact qualification.

A run that opted in to deadline-triggered cleanup receives its timeout-cleanup
interruption as an ordinary cooperative cancellation request whose context has
the top-level `cause: "timeout"`. It arrives as
`Error::CooperativeCancellationRequested`, so cancellation handlers such as saga
compensation run for it unchanged, and the run still ends timed out.
`request.cause()` (and `CancellationContext::cause()`) is the discriminator:
`CancellationCause::Requested` for an ordinary request or
`CancellationCause::Timeout`. The cleanup deadline is the request's existing
`cleanup_deadline_at`; the timeout kind and work deadline are recorded on the
`WorkflowTimeoutCleanupStarted` event, not in the context. A context with any
other `cause` value is refused. The cause is never inherited: descendants
cancelled under the request, scoped or not, carry no `cause`, so they are
ordinary cancellations, even when a scoped child's origin root context carries
`cause: "timeout"`.

A scoped child context preserves its complete immutable `scope_origin` alongside
the local run lineage. `ScopedCancellationContext::root_context()` retains the
original root deadline. Its scoped lineage records each run, instance, scope,
Expand Down
68 changes: 68 additions & 0 deletions src/cancellation_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,38 @@ impl CancellationLineage {
}
}

/// Why a cooperative cancellation request was delivered to this run.
///
/// Read from the optional top-level `cause` of the portable cancellation
/// context. `Requested` is an ordinary cancellation request (`cause` absent or
/// `cancellation`). `Timeout` is a deadline-triggered cleanup request: the run
/// passed its work deadline and has until the request's cleanup deadline to
/// clean up, and it still ends timed out. The cause is never inherited: a
/// descendant cancelled under the request, scoped or not, is an ordinary
/// cancellation.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum CancellationCause {
/// An ordinary cooperative cancellation request.
Requested,
/// A timeout-cleanup request after the run's work deadline passed.
Timeout,
}

impl CancellationCause {
/// Whether this is a timeout-cleanup request.
pub fn is_timeout(self) -> bool {
self == Self::Timeout
}
}

/// The context's `cause` exactly as recorded, so it round-trips unchanged.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum RecordedCause {
Cancellation,
Timeout,
}

/// Original cancellation metadata restored from canonical request history.
///
/// Fields and nested metadata have read-only accessors. Descendants retain
Expand All @@ -49,6 +81,7 @@ pub struct CancellationContext {
requested_at: DateTime<Utc>,
cleanup_deadline_at: DateTime<Utc>,
lineage: Vec<CancellationLineage>,
cause: Option<RecordedCause>,
scope_origin: Option<Box<ScopedCancellationContext>>,
replay: Option<Weak<Mutex<WorkflowState>>>,
}
Expand All @@ -66,6 +99,7 @@ impl PartialEq for CancellationContext {
&& self.requested_at == other.requested_at
&& self.cleanup_deadline_at == other.cleanup_deadline_at
&& self.lineage == other.lineage
&& self.cause == other.cause
&& self.scope_origin == other.scope_origin
}
}
Expand Down Expand Up @@ -93,6 +127,20 @@ fn nullable_text(value: &Value, key: &str, allow_empty: bool) -> Result<Option<S
}
}

/// Read the optional `cause`. Other keys, as in Core, are not interpreted.
fn read_cause(value: &Value) -> Result<Option<RecordedCause>> {
match value.get("cause") {
None | Some(Value::Null) => Ok(None),
Some(Value::String(cause)) if cause == "cancellation" => {
Ok(Some(RecordedCause::Cancellation))
}
Some(Value::String(cause)) if cause == "timeout" => Ok(Some(RecordedCause::Timeout)),
Some(_) => Err(invalid_context(
"cancellation cause must be cancellation or timeout",
)),
}
}

impl CancellationContext {
/// Read legacy v1 and candidate scoped v2 without granting a new cleanup budget.
pub fn from_value(value: &Value) -> Result<Self> {
Expand Down Expand Up @@ -187,6 +235,7 @@ impl CancellationContext {
"cancellation deadline must follow the original request",
));
}
let cause = read_cause(value)?;
let context = Self {
request_id,
root_request_id,
Expand All @@ -199,6 +248,7 @@ impl CancellationContext {
requested_at,
cleanup_deadline_at,
lineage: normalized,
cause,
scope_origin,
replay: None,
};
Expand Down Expand Up @@ -293,6 +343,19 @@ impl CancellationContext {
self.scope_origin.as_deref()
}

/// Why this request was delivered: an ordinary request or timeout cleanup.
pub fn cause(&self) -> CancellationCause {
match self.cause {
Some(RecordedCause::Timeout) => CancellationCause::Timeout,
_ => CancellationCause::Requested,
}
}

/// Whether this is a timeout-cleanup request (`cause: "timeout"`).
pub fn is_timeout(&self) -> bool {
self.cause == Some(RecordedCause::Timeout)
}

/// Remaining cleanup budget at the last blocking result consumed by this replay.
///
/// Never reads host time. Detached metadata, an ended replay, and a missing or
Expand Down Expand Up @@ -337,6 +400,11 @@ impl CancellationContext {
"cleanup_deadline_at": self.cleanup_deadline_at.to_rfc3339_opts(SecondsFormat::Micros, true),
"lineage": self.lineage.iter().map(CancellationLineage::to_value).collect::<Vec<_>>(),
});
match self.cause {
Some(RecordedCause::Cancellation) => value["cause"] = json!("cancellation"),
Some(RecordedCause::Timeout) => value["cause"] = json!("timeout"),
None => {}
}
if let Some(origin) = &self.scope_origin {
value["scope_origin"] = origin.to_value();
value["scope_authority_deadline_at"] = value["cleanup_deadline_at"].clone();
Expand Down
20 changes: 15 additions & 5 deletions src/cooperative_cancellation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,16 @@ fn text<'a>(value: &'a Value, field: &str) -> Result<&'a str> {
}

impl CancellationRequest {
/// Why this request was delivered, derived from its canonical context.
///
/// An observation without a canonical context is an ordinary request.
/// Both causes arrive through [`Error::CooperativeCancellationRequested`].
pub fn cause(&self) -> CancellationCause {
self.context
.as_ref()
.map_or(CancellationCause::Requested, CancellationContext::cause)
}

pub(super) fn preserve_observation(&self, current: &Self) -> Result<Self> {
self.validate_observation()?;
current.validate_observation()?;
Expand Down Expand Up @@ -1769,6 +1779,11 @@ impl Client {
run_id: Option<&str>,
options: CooperativeCancellationOptions,
) -> Result<WorkflowCancellationRequest> {
let mut path = format!("/workflows/{}", path_segment("workflow_id", workflow_id)?);
if let Some(run_id) = run_id {
path.push_str(&format!("/runs/{}", path_segment("run_id", run_id)?));
}
path.push_str("/request-cancellation");
if workflow_id.trim().is_empty() || run_id.is_some_and(|id| id.trim().is_empty()) {
return Err(invalid(
"workflow and selected run identities must be non-empty",
Expand Down Expand Up @@ -1798,11 +1813,6 @@ impl Client {
)
.await?;
require_discovery(&info)?;
let mut path = format!("/workflows/{}", percent_encode_path_segment(workflow_id));
if let Some(run_id) = run_id {
path.push_str(&format!("/runs/{}", percent_encode_path_segment(run_id)));
}
path.push_str("/request-cancellation");
let response: Value = self
.request_json(
reqwest::Method::POST,
Expand Down
Loading
Loading