Skip to content

Native: carry the next workflow poll on the workflow task completion #1062

Closed

Description

@rmcdaniel

Delivered in Native 3.3.0 and PHP SDK 3.11.0

General reconciled the original acceptance against maintained backend controls and the exact published producer/consumer tuple. Acceptance, publication and scope limits. No benchmark speedup or installed SQL-count measurement is claimed. Earlier NONPASSs remain attributed.

Original requirement and historical evidence

External performance report, topic 11a. The reported timings and proposed fixes have not been independently reproduced. The supplied patches are reference drafts for review and current-source qualification.

Problem

A workflow task can end without scheduling an activity, as one that ran
local activities does. The worker's workflow poll then claims the next
workflow task, in another locked transaction.

Proposed contract

  • POST /api/worker/workflow-tasks/{task_id}/complete also accepts
    workflow_poll, with the same rules as in 10a.
  • The completion's transaction claims a ready workflow task for it, in a
    savepoint, only when the completion carried no activity_poll, or
    that poll's claim leased nothing. So a worker is never handed a workflow
    task and an activity at once.
  • When the claim isn't attempted because the activity poll leased an
    activity, the response has no workflow_poll key. Document this:
    a missing key means nothing was leased for that poll. A worker that reads
    the missing key as "unknown" stops carrying workflow polls (it happened in
    the PHP SDK, see 19).
  • Advertised as workflow_task_completion_workflow_poll.

Reported benchmark evidence

Reporter-supplied cumulative measurements, not an isolated-effect or independently verified performance claim: 200 sequentially started workflows, five sequential 20ms activities or five local activities each, six PHP worker processes, mean of three repetitions, one machine, fresh SQLite/MySQL8.0/PostgreSQL16 databases with default durability. "Five" and "local" below mean last-close wall time in seconds; S/M/P means SQLite/MySQL/PostgreSQL. Preserve regressions and noise in the reported results.

five local
slowest backend 17.56 → 17.87 (noise) 4.95 → 4.38

Locked workflow polls in five-local-steps on MySQL fell from 656 to 75
over three reps.

Acceptance

  • The claim is attempted only under the rule above.
  • The missing-key semantics are documented with the capability, and a test
    covers them.
  • Without the capability, nothing changes.
  • worker-protocol-api.openapi.yaml documents the capability, the field
    and the missing-key rule. The reference commit doesn't yet.

Implementation constraints

Preserve the existing rule: a worker never holds a workflow
task and an activity at once.

Dependencies

Related existing work: #325.

Draft patches and validation boundary

Current main was independently read as b8f4dd13623ce645de77ba5bd89ae8dd8eea2f7c. The supplied draft series targets 3d4d4afaf2b24aaa5443eac154e253b011924738; patches are cumulative reference material, not a current-main application or test result. Review and rebase on maintained source, preserve all existing tests and published compatibility, then establish causal controls and actual conformance before shipping. No patch was applied or executed during intake.

Known draft gap / required control: The draft does not yet include the complete versioned OpenAPI/capability and missing-key documentation. Preserve the rule that one completion cannot grant both an activity and a workflow lease.

The following sanitized unified diffs identify proposed reference material:

  • 0013-t11-Carry-the-next-workflow-poll-on-the-workflow-task-co.patch — 11,218 bytes, SHA-256 f9268e6c434cd7acbf273f1ead961f6657da4220bc3eb8eb11d76b2936b6737e.

Current implementation: PR1073 is normally merged as 54af464004d85759e16e939124059449c8052758 / tree 784e5506945c4a6f2072d5005e806e8d8d68f4dd. Its accepted source has62 successful PR checks/one declared skip and48 successful merge-group jobs, with OpenAPI revision63 and the shared carried-poll vectors. The accepted SDK330 source handoff replaces the withdrawn b631 handoff. The automatic main attempts remain NONPASS/runner-blocked after two locked-archive HTTP504 setup failures; this is not a product assertion or published result. No further same-head retry is selected. Next owner/action: Server continues the ordered P0 source train, then owns the coherent published producer and consumer/runner qualification tuple; SDK-PHP owns its accepted consumer source. This issue remains open for published-artifact acceptance. The reported benchmark measurements remain unverified.

Activity

  1. added
    kind:featureA public product capability or experience is requested
    priority:P0Immediate release or data-integrity risk
    repo:serverOwned by the standalone server repository
    on Nov 6, 2026
  2. rmcdaniel commented on Nov 6, 2026

    @rmcdaniel
    MemberAuthor

    Sanitized reference diff 0013-t11-Carry-the-next-workflow-poll-on-the-workflow-task-co.patch, part 1/1.

    This is an unverified, cumulative draft from the external report. Mail author/date/commit-message metadata was removed; the unified-diff hunks were retained. No patch was applied or executed, and no build/test or compatibility result is implied. The known gaps and acceptance criteria in this issue remain mandatory.

    Complete sanitized patch: 11,218 bytes, SHA-256 f9268e6c434cd7acbf273f1ead961f6657da4220bc3eb8eb11d76b2936b6737e. This part: 11,218 bytes, SHA-256 f9268e6c434cd7acbf273f1ead961f6657da4220bc3eb8eb11d76b2936b6737e. Parts concatenate in order; a part boundary is between complete file diffs.

    diff --git a/rust/src/runtime/execution.rs b/rust/src/runtime/execution.rs
    index bf713fa4..4a40032a 100644
    --- a/rust/src/runtime/execution.rs
    +++ b/rust/src/runtime/execution.rs
    @@ -1566,20 +1566,22 @@ impl Runtime {
             worker: Option<super::runtime_credentials::Principal>,
             protocol: Option<&str>,
         ) -> Result<Value> {
    -        self.complete_or_checkpoint(task_id, body, worker, protocol, None, None).await
    +        self.complete_or_checkpoint(task_id, body, worker, protocol, None, None, None).await
         }
     
         /// A completion that carries the worker's next activity poll
    -    /// (`Store::claim_piggybacked_activity`).
    -    pub(crate) async fn complete_workflow_with_activity_poll(
    +    /// (`Store::claim_piggybacked_activity`) and workflow poll
    +    /// (`Store::claim_carried_workflow`).
    +    pub(crate) async fn complete_workflow_with_carried_polls(
             &self,
             task_id: &str,
             body: Value,
             worker: Option<super::runtime_credentials::Principal>,
             protocol: Option<&str>,
             activity_poll: Option<Value>,
    +        workflow_poll: Option<Value>,
         ) -> Result<Value> {
    -        self.complete_or_checkpoint(task_id, body, worker, protocol, None, activity_poll.as_ref()).await
    +        self.complete_or_checkpoint(task_id, body, worker, protocol, None, activity_poll.as_ref(), workflow_poll.as_ref()).await
         }
     
         /// POST …/cancellation-scopes/checkpoint, after its route checks: the
    @@ -1592,7 +1594,7 @@ impl Runtime {
             protocol: Option<&str>,
             checkpoint: &super::scope_checkpoint::Checkpoint,
         ) -> Result<Value> {
    -        self.complete_or_checkpoint(task_id, body, worker, protocol, Some(checkpoint), None).await
    +        self.complete_or_checkpoint(task_id, body, worker, protocol, Some(checkpoint), None, None).await
         }
     
         async fn complete_or_checkpoint(
    @@ -1603,6 +1605,7 @@ impl Runtime {
             protocol: Option<&str>,
             checkpoint: Option<&super::scope_checkpoint::Checkpoint>,
             activity_poll: Option<&Value>,
    +        workflow_poll: Option<&Value>,
         ) -> Result<Value> {
             // A task the namespace does not hold is answered before any payload
             // is read or fetched, as PHP's guards run before its normalizer.
    @@ -1658,20 +1661,26 @@ impl Runtime {
                     .await?;
                 }
             }
    +        let workflow_poll = workflow_poll.zip(protocol).map(|(poll, protocol)| super::store::CarriedWorkflowPoll {
    +            body: poll,
    +            protocol,
    +            pages: self.history_pages(),
    +            default_wait: self.worker_poll_wait(),
    +        });
             match &*self.storage {
                 Storage::Sqlite(store) => {
                     store
    -                    .complete_or_checkpoint(task_id, body, self.transition_codec.clone(), external, worker, protocol, self.structural, checkpoint, signal.as_ref(), activity_poll)
    +                    .complete_or_checkpoint(task_id, body, self.transition_codec.clone(), external, worker, protocol, self.structural, checkpoint, signal.as_ref(), activity_poll, workflow_poll)
                         .await
                 }
                 Storage::Postgres(store) => {
                     store
    -                    .complete_or_checkpoint(task_id, body, self.transition_codec.clone(), external, worker, protocol, self.structural, checkpoint, signal.as_ref(), activity_poll)
    +                    .complete_or_checkpoint(task_id, body, self.transition_codec.clone(), external, worker, protocol, self.structural, checkpoint, signal.as_ref(), activity_poll, workflow_poll)
                         .await
                 }
                 Storage::MySql(store) => {
                     store
    -                    .complete_or_checkpoint(task_id, body, self.transition_codec.clone(), external, worker, protocol, self.structural, checkpoint, signal.as_ref(), activity_poll)
    +                    .complete_or_checkpoint(task_id, body, self.transition_codec.clone(), external, worker, protocol, self.structural, checkpoint, signal.as_ref(), activity_poll, workflow_poll)
                         .await
                 }
             }
    diff --git a/rust/src/runtime/http.rs b/rust/src/runtime/http.rs
    index 31ed8749..a568bc39 100644
    --- a/rust/src/runtime/http.rs
    +++ b/rust/src/runtime/http.rs
    @@ -2404,10 +2404,13 @@ async fn complete_workflow(
             .as_object_mut()
             .and_then(|body| body.remove("activity_poll"))
             .filter(|poll| poll.is_object() && poll_rules(poll, ACTIVITY_POLL_RULES).is_ok());
    +    // And its next workflow poll, which the transaction claims for when the
    +    // worker will run no activity first.
    +    let workflow_poll = carried_workflow_poll(&mut body);
         let body = completion_checks(&task_id, &headers, body)?;
         let attempt = body["workflow_task_attempt"].clone();
         runtime
    -        .complete_workflow_with_activity_poll(&task_id, body, Some(request.principal().clone()), headers.get("x-durable-workflow-protocol-version").and_then(|value| value.to_str().ok()), activity_poll)
    +        .complete_workflow_with_carried_polls(&task_id, body, Some(request.principal().clone()), headers.get("x-durable-workflow-protocol-version").and_then(|value| value.to_str().ok()), activity_poll, workflow_poll)
             .await
             .map_err(|error| {
                 completion_refusal(
    @@ -2542,6 +2545,19 @@ async fn fail_workflow_task(
             .map(|body| Json(worker_response(body)))
     }
     
    +/// A completion's carried workflow poll. As with the workflow task
    +/// completion's activity poll, a poll the rules refuse, or one for other
    +/// task kinds, is left to the worker's own poll.
    +fn carried_workflow_poll(body: &mut Value) -> Option<Value> {
    +    body.as_object_mut()
    +        .and_then(|body| body.remove("workflow_poll"))
    +        .filter(|poll| {
    +            poll.is_object()
    +                && poll_rules(poll, WORKFLOW_POLL_RULES).is_ok()
    +                && super::update_validation::task_kinds(poll).is_ok_and(|kinds| kinds == ["workflow"])
    +        })
    +}
    +
     async fn complete_activity(
         Extension(runtime): Extension<Runtime>,
         Path(task_id): Path<String>,
    @@ -2549,17 +2565,8 @@ async fn complete_activity(
         Json(mut body): Json<Value>,
     ) -> Result<Json<Value>> {
         // The worker's next workflow poll, carried by the completion so its
    -    // transaction claims the workflow task the result makes ready. As with
    -    // the workflow task completion's activity poll, a poll the rules refuse,
    -    // or one for other task kinds, is left to the worker's own poll.
    -    let workflow_poll = body
    -        .as_object_mut()
    -        .and_then(|body| body.remove("workflow_poll"))
    -        .filter(|poll| {
    -            poll.is_object()
    -                && poll_rules(poll, WORKFLOW_POLL_RULES).is_ok()
    -                && super::update_validation::task_kinds(poll).is_ok_and(|kinds| kinds == ["workflow"])
    -        });
    +    // transaction claims the workflow task the result makes ready.
    +    let workflow_poll = carried_workflow_poll(&mut body);
         let protocol = headers.get("x-durable-workflow-protocol-version").and_then(|value| value.to_str().ok());
         let attempt = body["activity_attempt_id"].clone();
         runtime
    diff --git a/rust/src/runtime/store.rs b/rust/src/runtime/store.rs
    index 5f61260a..c14b5e80 100644
    --- a/rust/src/runtime/store.rs
    +++ b/rust/src/runtime/store.rs
    @@ -2965,6 +2965,7 @@ where
             checkpoint: Option<&super::scope_checkpoint::Checkpoint>,
             signal: Option<&super::signal_payloads::ResolvedSignal>,
             activity_poll: Option<&Value>,
    +        workflow_poll: Option<CarriedWorkflowPoll<'_>>,
         ) -> Result<Value> {
             let transition_codec_for_streams = transition_codec.clone();
             // `commands` is required and non-empty, with no count limit of its
    @@ -3119,10 +3120,15 @@ where
                     }
                 }
             }
    +        let carries_activity_poll = activity_poll.is_some();
             let activity_poll = match (activity_poll, protocol) {
                 (Some(poll), Some(protocol)) => self.admit_piggybacked_activity_poll(poll).await.map(|fence| (poll, fence, protocol)),
                 _ => None,
             };
    +        let workflow_poll = match workflow_poll {
    +            Some(poll) => self.admit_carried_workflow_poll(poll).await,
    +            None => None,
    +        };
             let mut tx = self.begin().await?;
             let task = self.task_row(&mut tx, task_id).await?;
             if DB::string(&task, "task_type")? != "workflow" {
    @@ -3442,12 +3448,22 @@ where
                 Some((poll, fence, protocol)) => self.claim_piggybacked_activity(&mut tx, poll, &fence, protocol).await?,
                 None => None,
             };
    +        // The worker's next workflow poll, claimed for only when the worker
    +        // runs no activity first: the completion carried no activity poll,
    +        // or that poll's claim leased nothing.
    +        let workflow_poll = match &workflow_poll {
    +            Some(poll) if !carries_activity_poll || activity_poll == Some(false) => self.claim_carried_workflow(&mut tx, poll).await?,
    +            _ => None,
    +        };
             tx.commit().await?;
             self.wake.notify_waiters();
             let mut response = json!({"task_id": task_id, "run_id": run_id, "run_status": run_status, "workflow_task_attempt": body["workflow_task_attempt"], "outcome": "completed", "recorded": true, "reason": null, "created_task_ids": created});
             if let Some(leased) = activity_poll {
                 response["activity_poll"] = json!({"leased": leased});
             }
    +        if let Some(leased) = workflow_poll {
    +            response["workflow_poll"] = json!({"leased": leased});
    +        }
             Ok(response)
         }
     
    @@ -4106,8 +4122,8 @@ where
             Some(AdmittedWorkflowPoll { body: poll.body, protocol: poll.protocol, page: poll.pages.poll(requested, encoding), fence, request })
         }
     
    -    /// The carried workflow poll's claim, in the activity completion's
    -    /// transaction, under the poll's request ID: the worker's poll with that
    +    /// The carried workflow poll's claim, in the completion's transaction,
    +    /// under the poll's request ID: the worker's poll with that
         /// ID replays the lease. A claim that fails is rolled back to before it,
         /// and the completion commits without it.
         async fn claim_carried_workflow(&self, tx: &mut Transaction<'_, DB>, poll: &AdmittedWorkflowPoll<'_>) -> Result<Option<bool>> {
    @@ -5487,7 +5503,8 @@ pub(crate) fn capabilities() -> Value {
             "workflow_updates": true, "query_tasks": true, "query_task_authority":true, "query_task_poll_request_idempotency": true,
             "activity_lease_renewal": true, "activity_cancellation_acknowledgement": true,
             "child_workflow_start_turn": true, "condition_wait_idempotent_reopen": true,
    -        "workflow_task_completion_activity_poll": true, "activity_completion_workflow_poll": true});
    +        "workflow_task_completion_activity_poll": true, "activity_completion_workflow_poll": true,
    +        "workflow_task_completion_workflow_poll": true});
         value["runtime_external_payload_transport"] = super::payload_discovery::transport_manifest();
         value["supported_workflow_task_commands"]
             .as_array_mut()
  3. rmcdaniel commented on Nov 6, 2026

    @rmcdaniel
    MemberAuthor

    Security static review found that this cumulative reference change inherits the two carried-poll blockers and has an incomplete dependency graph.

    • Both activity_poll.worker_id and workflow_poll.worker_id must equal the completing task lease_owner and therefore each other. The draft admits each poll independently, so one completion can allocate work or replay state to another registered worker and can evade the one-worker capacity rule. Revalidate the equality inside the transaction and add mixed-worker controls proving no activity/workflow lease, receipt, binding, or result-cache write occurs.
    • The inherited carried-claim helpers catch every claim_in error and turn it into Ok(None). Only a closed set of documented poll refusals may be downgraded. Unexpected backend, decoding, stored-state, budget, and invariant failures must propagate or be explicitly surfaced rather than masquerading as successful completion.
    • Patch 0013 is based on the carried-workflow implementation introduced by topic 10a / server#1061, but this issue currently lists only server#1060 as a dependency. Add server#1061 or make the required workflow-poll implementation and controls self-contained.

    This is a static finding only. The supplied patch was not applied or executed.

  4. rmcdaniel commented on Nov 6, 2026

    @rmcdaniel
    MemberAuthor

    Sanitized rebased reference for report topic 11a: 0013-t11-Carry-the-next-workflow-poll-on-the-workflow-task-co.patch.

    The supplied revision targets Server b8f4dd13623ce645de77ba5bd89ae8dd8eea2f7c. It is cumulative external reference material, not independently qualified against today's maintained source. Mail headers and commit-message/application instructions were removed; the unified diff is retained. The reporter now claims every rebased commit builds and the tip passes SQLite/MySQL/PostgreSQL tests. Those are attributed claims, not a new maintainer test result. Existing acceptance criteria, security findings and completed implementation receipts remain authoritative. Earlier attached patches are retained as historical references.

    Sanitized patch: 12,219 bytes, SHA-256 3fcf748668ea9effca87e85fc3877bc1ae84717ff096e39e8a6aaeb90c7448b5. Original-base sanitized identity: f9268e6c434cd7acbf273f1ead961f6657da4220bc3eb8eb11d76b2936b6737e. No patch was applied or executed during this update.

    diff --git a/rust/src/runtime/execution.rs b/rust/src/runtime/execution.rs
    index bf713fa4..4a40032a 100644
    --- a/rust/src/runtime/execution.rs
    +++ b/rust/src/runtime/execution.rs
    @@ -1566,20 +1566,22 @@ impl Runtime {
             worker: Option<super::runtime_credentials::Principal>,
             protocol: Option<&str>,
         ) -> Result<Value> {
    -        self.complete_or_checkpoint(task_id, body, worker, protocol, None, None).await
    +        self.complete_or_checkpoint(task_id, body, worker, protocol, None, None, None).await
         }
     
         /// A completion that carries the worker's next activity poll
    -    /// (`Store::claim_piggybacked_activity`).
    -    pub(crate) async fn complete_workflow_with_activity_poll(
    +    /// (`Store::claim_piggybacked_activity`) and workflow poll
    +    /// (`Store::claim_carried_workflow`).
    +    pub(crate) async fn complete_workflow_with_carried_polls(
             &self,
             task_id: &str,
             body: Value,
             worker: Option<super::runtime_credentials::Principal>,
             protocol: Option<&str>,
             activity_poll: Option<Value>,
    +        workflow_poll: Option<Value>,
         ) -> Result<Value> {
    -        self.complete_or_checkpoint(task_id, body, worker, protocol, None, activity_poll.as_ref()).await
    +        self.complete_or_checkpoint(task_id, body, worker, protocol, None, activity_poll.as_ref(), workflow_poll.as_ref()).await
         }
     
         /// POST …/cancellation-scopes/checkpoint, after its route checks: the
    @@ -1592,7 +1594,7 @@ impl Runtime {
             protocol: Option<&str>,
             checkpoint: &super::scope_checkpoint::Checkpoint,
         ) -> Result<Value> {
    -        self.complete_or_checkpoint(task_id, body, worker, protocol, Some(checkpoint), None).await
    +        self.complete_or_checkpoint(task_id, body, worker, protocol, Some(checkpoint), None, None).await
         }
     
         async fn complete_or_checkpoint(
    @@ -1603,6 +1605,7 @@ impl Runtime {
             protocol: Option<&str>,
             checkpoint: Option<&super::scope_checkpoint::Checkpoint>,
             activity_poll: Option<&Value>,
    +        workflow_poll: Option<&Value>,
         ) -> Result<Value> {
             // A task the namespace does not hold is answered before any payload
             // is read or fetched, as PHP's guards run before its normalizer.
    @@ -1658,20 +1661,26 @@ impl Runtime {
                     .await?;
                 }
             }
    +        let workflow_poll = workflow_poll.zip(protocol).map(|(poll, protocol)| super::store::CarriedWorkflowPoll {
    +            body: poll,
    +            protocol,
    +            pages: self.history_pages(),
    +            default_wait: self.worker_poll_wait(),
    +        });
             match &*self.storage {
                 Storage::Sqlite(store) => {
                     store
    -                    .complete_or_checkpoint(task_id, body, self.transition_codec.clone(), external, worker, protocol, self.structural, checkpoint, signal.as_ref(), activity_poll)
    +                    .complete_or_checkpoint(task_id, body, self.transition_codec.clone(), external, worker, protocol, self.structural, checkpoint, signal.as_ref(), activity_poll, workflow_poll)
                         .await
                 }
                 Storage::Postgres(store) => {
                     store
    -                    .complete_or_checkpoint(task_id, body, self.transition_codec.clone(), external, worker, protocol, self.structural, checkpoint, signal.as_ref(), activity_poll)
    +                    .complete_or_checkpoint(task_id, body, self.transition_codec.clone(), external, worker, protocol, self.structural, checkpoint, signal.as_ref(), activity_poll, workflow_poll)
                         .await
                 }
                 Storage::MySql(store) => {
                     store
    -                    .complete_or_checkpoint(task_id, body, self.transition_codec.clone(), external, worker, protocol, self.structural, checkpoint, signal.as_ref(), activity_poll)
    +                    .complete_or_checkpoint(task_id, body, self.transition_codec.clone(), external, worker, protocol, self.structural, checkpoint, signal.as_ref(), activity_poll, workflow_poll)
                         .await
                 }
             }
    diff --git a/rust/src/runtime/http.rs b/rust/src/runtime/http.rs
    index 31ed8749..a568bc39 100644
    --- a/rust/src/runtime/http.rs
    +++ b/rust/src/runtime/http.rs
    @@ -2404,10 +2404,13 @@ async fn complete_workflow(
             .as_object_mut()
             .and_then(|body| body.remove("activity_poll"))
             .filter(|poll| poll.is_object() && poll_rules(poll, ACTIVITY_POLL_RULES).is_ok());
    +    // And its next workflow poll, which the transaction claims for when the
    +    // worker will run no activity first.
    +    let workflow_poll = carried_workflow_poll(&mut body);
         let body = completion_checks(&task_id, &headers, body)?;
         let attempt = body["workflow_task_attempt"].clone();
         runtime
    -        .complete_workflow_with_activity_poll(&task_id, body, Some(request.principal().clone()), headers.get("x-durable-workflow-protocol-version").and_then(|value| value.to_str().ok()), activity_poll)
    +        .complete_workflow_with_carried_polls(&task_id, body, Some(request.principal().clone()), headers.get("x-durable-workflow-protocol-version").and_then(|value| value.to_str().ok()), activity_poll, workflow_poll)
             .await
             .map_err(|error| {
                 completion_refusal(
    @@ -2542,6 +2545,19 @@ async fn fail_workflow_task(
             .map(|body| Json(worker_response(body)))
     }
     
    +/// A completion's carried workflow poll. As with the workflow task
    +/// completion's activity poll, a poll the rules refuse, or one for other
    +/// task kinds, is left to the worker's own poll.
    +fn carried_workflow_poll(body: &mut Value) -> Option<Value> {
    +    body.as_object_mut()
    +        .and_then(|body| body.remove("workflow_poll"))
    +        .filter(|poll| {
    +            poll.is_object()
    +                && poll_rules(poll, WORKFLOW_POLL_RULES).is_ok()
    +                && super::update_validation::task_kinds(poll).is_ok_and(|kinds| kinds == ["workflow"])
    +        })
    +}
    +
     async fn complete_activity(
         Extension(runtime): Extension<Runtime>,
         Path(task_id): Path<String>,
    @@ -2549,17 +2565,8 @@ async fn complete_activity(
         Json(mut body): Json<Value>,
     ) -> Result<Json<Value>> {
         // The worker's next workflow poll, carried by the completion so its
    -    // transaction claims the workflow task the result makes ready. As with
    -    // the workflow task completion's activity poll, a poll the rules refuse,
    -    // or one for other task kinds, is left to the worker's own poll.
    -    let workflow_poll = body
    -        .as_object_mut()
    -        .and_then(|body| body.remove("workflow_poll"))
    -        .filter(|poll| {
    -            poll.is_object()
    -                && poll_rules(poll, WORKFLOW_POLL_RULES).is_ok()
    -                && super::update_validation::task_kinds(poll).is_ok_and(|kinds| kinds == ["workflow"])
    -        });
    +    // transaction claims the workflow task the result makes ready.
    +    let workflow_poll = carried_workflow_poll(&mut body);
         let protocol = headers.get("x-durable-workflow-protocol-version").and_then(|value| value.to_str().ok());
         let attempt = body["activity_attempt_id"].clone();
         runtime
    diff --git a/rust/src/runtime/ready_age_tests.rs b/rust/src/runtime/ready_age_tests.rs
    index 50f64437..2b3c3f71 100644
    --- a/rust/src/runtime/ready_age_tests.rs
    +++ b/rust/src/runtime/ready_age_tests.rs
    @@ -20,7 +20,7 @@ store_methods! {
                     "commands":[{"type":"schedule_activity","activity_type":"ready-age-activity",
                         "arguments":{"codec":"avro","blob":"AA=="},
                         "retry_policy":{"max_attempts":2,"backoff_seconds":[0]}}]}),
    -                Arc::new(Semaphore::new(1)), BTreeMap::new(), None, Some("1.20"), Default::default(), None, None, None).await.unwrap();
    +                Arc::new(Semaphore::new(1)), BTreeMap::new(), None, Some("1.20"), Default::default(), None, None, None, None).await.unwrap();
             }
             let row = Self::query("SELECT id FROM workflow_tasks WHERE workflow_run_id=$1 AND task_type=$2 AND status='ready'")
                 .bind(run).bind(kind).fetch_one(&self.pool).await.unwrap();
    diff --git a/rust/src/runtime/store.rs b/rust/src/runtime/store.rs
    index 0624bbb6..66cc0684 100644
    --- a/rust/src/runtime/store.rs
    +++ b/rust/src/runtime/store.rs
    @@ -2978,6 +2978,7 @@ where
             checkpoint: Option<&super::scope_checkpoint::Checkpoint>,
             signal: Option<&super::signal_payloads::ResolvedSignal>,
             activity_poll: Option<&Value>,
    +        workflow_poll: Option<CarriedWorkflowPoll<'_>>,
         ) -> Result<Value> {
             let transition_codec_for_streams = transition_codec.clone();
             // `commands` is required and non-empty, with no count limit of its
    @@ -3132,10 +3133,15 @@ where
                     }
                 }
             }
    +        let carries_activity_poll = activity_poll.is_some();
             let activity_poll = match (activity_poll, protocol) {
                 (Some(poll), Some(protocol)) => self.admit_piggybacked_activity_poll(poll).await.map(|fence| (poll, fence, protocol)),
                 _ => None,
             };
    +        let workflow_poll = match workflow_poll {
    +            Some(poll) => self.admit_carried_workflow_poll(poll).await,
    +            None => None,
    +        };
             let mut tx = self.begin().await?;
             let task = self.task_row(&mut tx, task_id).await?;
             if DB::string(&task, "task_type")? != "workflow" {
    @@ -3455,12 +3461,22 @@ where
                 Some((poll, fence, protocol)) => self.claim_piggybacked_activity(&mut tx, poll, &fence, protocol).await?,
                 None => None,
             };
    +        // The worker's next workflow poll, claimed for only when the worker
    +        // runs no activity first: the completion carried no activity poll,
    +        // or that poll's claim leased nothing.
    +        let workflow_poll = match &workflow_poll {
    +            Some(poll) if !carries_activity_poll || activity_poll == Some(false) => self.claim_carried_workflow(&mut tx, poll).await?,
    +            _ => None,
    +        };
             tx.commit().await?;
             self.wake.notify_waiters();
             let mut response = json!({"task_id": task_id, "run_id": run_id, "run_status": run_status, "workflow_task_attempt": body["workflow_task_attempt"], "outcome": "completed", "recorded": true, "reason": null, "created_task_ids": created});
             if let Some(leased) = activity_poll {
                 response["activity_poll"] = json!({"leased": leased});
             }
    +        if let Some(leased) = workflow_poll {
    +            response["workflow_poll"] = json!({"leased": leased});
    +        }
             Ok(response)
         }
     
    @@ -4119,8 +4135,8 @@ where
             Some(AdmittedWorkflowPoll { body: poll.body, protocol: poll.protocol, page: poll.pages.poll(requested, encoding), fence, request })
         }
     
    -    /// The carried workflow poll's claim, in the activity completion's
    -    /// transaction, under the poll's request ID: the worker's poll with that
    +    /// The carried workflow poll's claim, in the completion's transaction,
    +    /// under the poll's request ID: the worker's poll with that
         /// ID replays the lease. A claim that fails is rolled back to before it,
         /// and the completion commits without it.
         async fn claim_carried_workflow(&self, tx: &mut Transaction<'_, DB>, poll: &AdmittedWorkflowPoll<'_>) -> Result<Option<bool>> {
    @@ -5501,7 +5517,8 @@ pub(crate) fn capabilities() -> Value {
             "workflow_updates": true, "query_tasks": true, "query_task_authority":true, "query_task_poll_request_idempotency": true,
             "claimed_task_ready_age": true, "activity_lease_renewal": true, "activity_cancellation_acknowledgement": true,
             "child_workflow_start_turn": true, "condition_wait_idempotent_reopen": true,
    -        "workflow_task_completion_activity_poll": true, "activity_completion_workflow_poll": true});
    +        "workflow_task_completion_activity_poll": true, "activity_completion_workflow_poll": true,
    +        "workflow_task_completion_workflow_poll": true});
         value["runtime_external_payload_transport"] = super::payload_discovery::transport_manifest();
         value["supported_workflow_task_commands"]
             .as_array_mut()
  5. rmcdaniel commented on Nov 6, 2026

    @rmcdaniel
    MemberAuthor

    Security follow-up on the updated rebased reference 0013-t11-Carry-the-next-workflow-poll-on-the-workflow-task-co.patch (12,219 B / SHA-256 3fcf748668ea9effca87e85fc3877bc1ae84717ff096e39e8a6aaeb90c7448b5).

    The issue now explicitly depends on both #1060 and #1061, so the dependency-graph defect recorded in 6692719982 is closed. The two inherited implementation blockers are not: the cumulative series still admits the activity and workflow polls from their independent worker_id values rather than proving both equal the completing task's lease_owner, and the inherited carried-claim helpers still turn every claim_in error into Ok(None).

    Accordingly the combined completion can still cross worker custody/capacity boundaries and suppress unexpected internal faults. Keep the mixed-worker no-write/no-replay controls and closed error-classification/propagation controls as acceptance requirements. Static review only; no patch was applied or executed, and no reporter-attributed build or performance claim is promoted.

  6. rmcdaniel commented on Nov 7, 2026

    @rmcdaniel
    MemberAuthor

    Accepted producer checkpoint: PR1073, accepted head 511bd6fd, normally merged 54af4640. Its contract is included in the accepted 4fb runtime and now the immutable published native 3.2.0 source/tag 3c6f1eda9c6d2ebc0ee5bec8ccf431900052be9e. The earlier reference-draft same-worker/error findings are superseded by that implementation and its actual SQLite/PostgreSQL/MySQL/MariaDB Source controls. They are not remaining implementation holds on the accepted producer.

    The authoritative OpenAPI schema 63 and closed replay vectors retain protocol 1.20. Schema SHA-256 is d6c726a7f97417494d9897ae1101ac7300ba6414174041dea875ae01e5052391; vectors SHA-256 12ca5db29d96b5e0f03852b698ca1d15bd796c07486a1af85484d4e4dfc45e9b.

    Capability workflow_task_completion_workflow_poll admits optional workflow_poll on workflow completion. Both supplied carriers must name the completing owner. The activity carrier has priority; if it delivers a lease, the workflow carrier is not attempted and its reply member is omitted. A missing member delivers no lease, releases the consumer reservation and permits ordinary polling without asserting an empty queue or disabling support. Retained owner/fence checks, opposite-kind exclusion and visible unexpected faults are inherited from the accepted shared implementation. Duplicate completion never dispatches again; lost answers recover with the original poll request IDs.

    The actual publisher input is now available: owner terminal PASS, independent Security publication PASS, index ghcr.io/durable-workflow/server@sha256:6916f6f3bb1ee58d071f10f888068fd321cd1576fb70dc97ce8e9e61da493d62, exact producer/native runner 3c6. This issue stays open for actual published qualification. Server next owns the existing corpus/four-cell Compose receipt packet 325/6705614909. General next binds the actual published SDK package and SDK runner plus the bounded invocation to this producer; SDK-PHP owns its existing consumer/harness execution and result. The shared consumer handoff is SDK293/6705418888. Installed clock/transit/buffer-age, missing-key capacity, lost-answer/original-ID recovery, expiry/refence and selected load acceptance remain open. No tentative/withdrawn head, Source fixture or older tuple supplies that result.

  7. rmcdaniel commented on Nov 9, 2026

    @rmcdaniel
    MemberAuthor

    General reconciled the carried-poll and workflow-clock delivery against the original acceptance criteria while both repository agents are Off.

    The producer is published Native Server 3.3.0, source 2a15179f29801eec9f2a2d4e6a9c5f93233f104e, index sha256:cb4b11bd6235a551cf51e7dc6342504fbbcef19959618b376fb5648ca00fc388. The consumer is normal published PHP SDK 3.11.0, source fbc64ef98c27be467d75a29a29bfb0ee4e80dba7.

    Feature-specific acceptance is covered by the maintained source controls. The carried-poll runtime tests, HTTP execution tests, workflow replay/history-budget tests and versioned OpenAPI are byte-identical between the qualified integrated source 5f937ec8 (run 42448138571) and the 3.3.0 cut. The source lanes execute the registered controls on SQLite, MySQL, MariaDB and PostgreSQL; the accepted #1073 source also has 62 successful checks and its declared skip.

    Published compatibility is now verified with the unchanged full PHP replay matrix: 42531701411, 13 executed scenarios passed, zero skips, exact Server 3.3.0 and normal Packagist SDK 3.11.0, distinct clients, restarted worker, completed-history replay/query, in-flight signal/restart and nondeterminism refusal. Captured audit. Its runner bootstrap fix has complete ordinary main custody: signed reviewed tree, 71 successful main checks/eight expected skips and topic absence verified. The published product bytes and feature-specific controls are unchanged.

    The source controls establish the transaction/receipt/answer invariants. The published matrix establishes producer/consumer compatibility; it is not an installed SQL-count or benchmark experiment. No speedup is claimed. #1073's earlier archive-acquisition NONPASSs and the first published replay's interpreter-bootstrap NONPASS remain attributed.

    Acceptance: qualify_workflow_completion_carriers covers activity-first, activity-empty, workflow-only and foreign-owner cases. An activity grant omits workflow_poll, does not probe its claim and leaves the workflow task ready at attempt zero; empty/no activity permits the workflow grant and no opposite activity lease. OpenAPI explicitly says omission delivers no workflow lease and permits freeing the reservation without inferring an empty queue or disabling the capability. Published SDK 3.11.0 contains the accepted #330 consumer.

    The separate condition-wait durable-workflow/sdk-php#269 final-audit C2/C5 follow-up remains open; it is not a carried-poll or workflow-clock acceptance claim. The original reporter benchmark remains unverified.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    agent:generalkind:featureA public product capability or experience is requestedpriority:P0Immediate release or data-integrity riskrepo:serverOwned by the standalone server repository

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions