Repository navigation
Description
Current owner and release gate — November 9, 2026, 23:49 UTC
General owns this P0 while Server and SDK-PHP are Off. Producer #1116 and SDK consumer sdk-php#373 are merged source. The accepted v2 contract remains authoritative.
The release audit reproduced partial activity-result commit on invalid commands and lost exact-retry decisions. Atomic repair #1118, final head e0a22d863007c03812d736641666c0bb5e783dc3, now also binds receipt identities to the completed activity/run and accepted workflow task. A new contradictory-identity counterfactual failed before this final refinement.
Eleven real-HTTP controls pass on SQLite, MySQL 8.0.46 and PostgreSQL 17.11, plus four continuation library controls including deterministic read/signal atomicity. Full final-head repository CI has 62 successful checks and one intended optional skip. The earlier OpenAPI-v65 and receipt-identity nonpasses remain historical evidence. Current source declares document v66.
Next: General follows #1118 through the protected merge queue (position 1, awaiting checks at 23:48), then qualifies Native 3.4 release source and the matching published SDK pair, installed acceptance and measured A/B. Keep opt-in/default-off. Source checks do not prove published delivery or a speedup.
Original requirement and evidence
External report topic 24-server, accepted for P0 intake at the user's direction. The proposal, timings and supplied test results are unverified reference evidence. No patch was applied or executed during intake.
Implementation owner: general. The owning agent drives source, release and applicable published-artifact verification. Existing release, operating and credential gates remain in force.
Problem and proposed contract
The proposal lets a worker submit workflow_continuation with an activity completion, so the result and the next eligible workflow commands can commit in one transaction. It also proposes eager activity delivery and bounded history_tail from history_after_sequence. This is a new optional producer contract, separate from ordinary carried-poll responses.
Acceptance
- Publish versioned request/reply/capability, decline/error, history-tail and byte/event/command-budget contracts before SDK consumer implementation. Coordinate existing server#1060, Native: claim the woken workflow task for a poll carried on the activity completion #1061, Native: carry the next workflow poll on the workflow task completion #1062 and Native: return a carried poll's answer in its completion's response #1067; they are dependencies/context, not proof of this new capability.
- Accept a continuation only when the exact authoritative history equals the worker's admitted prefix plus the recorded result. Define canonical result/codec/timestamp identity; a worker's guessed event must not become authority.
- Preserve original worker admission, owner, lease, fence, attempt and deadlines. Reject stale/foreign workers, unknown history, pending workflow/message tasks, parallel groups, cancellation, and unsupported commands. Initially limit commands to schedule_activity, start_timer, complete_workflow and record_version_marker.
- An ordinary continuation decline must preserve an otherwise valid activity completion and normal later workflow polling. Only documented eligibility declines may downgrade; backend/decoder/budget/invariant failures need an explicit truthful contract.
- Scope speculative claim/commands to a savepoint and restore all cached state on rollback. Retried/lost completion replies must not duplicate results, history or commands. Preserve capacity and one-slot grant rules, eager fallback and response budgets.
- Keep the feature opt-in with old clients/servers unchanged. Add real HTTP and SQLite/MySQL/PostgreSQL controls for concurrency, foreign identity, expiry/transit, rollback, signals/updates, cancellation, replay, missing tail and large/external payloads.
- Qualify the matching published producer/SDK tuple before claiming delivered behavior or a speedup. No default enablement is selected here.
Reporter measurement: one Server binary with alternating SDK behavior, 14.59→10.76 seconds; lock turns 11→6 per workflow. These are unverified measurements, not acceptance evidence.
SDK counterpart: durable-workflow/sdk-php#341. Topic 25 depends on this producer's accepted transaction invariants.
Reported workload and evidence limits
The report describes 200 workflows, six PHP worker processes, five sequential 20 ms activities or five local activities, last-close wall time, and three or four interleaved A/B rounds. MySQL session-to-session drift is reported as high as 11%. Stock database durability, network placement and the absence of deferred projections are reporter conditions, not independently verified facts. The headline run covers topics 1–24; topic 25 has only a MySQL five-steps A/B. No speedup, production readiness or suite pass is accepted from this intake.
Sanitized reference data
The supplied Server series targets reported base d4c6de8d; the SDK reference targets 96dc061. Maintained heads were independently read as Server c3bbfa5 and SDK c87eae37. These cumulative references are not current-main patches or maintained qualification.
0007-Commit-an-activity-s-result-and-the-workflow-task-it.patch— 89,131 bytes / SHA-256341084feeb6700b8098f1771c540e8c0ca9eea549080f41dd7971b81f7647bd9.
Bench-only Server0001 is retained as diagnostic context, not a separate production change. Complete sanitized diff references, shared bundle identity and source-review findings are linked in the intake index and the issue comments. Original mail headers, author/date metadata, application commands and runner instructions are omitted.
Activity
rmcdaniel commentedon Nov 6, 2026
Sanitized external reference for topic 24-server:
0007-Commit-an-activity-s-result-and-the-workflow-task-it.patch, part 1/2. Complete sanitized diff: 89,131 bytes / SHA-256341084feeb6700b8098f1771c540e8c0ca9eea549080f41dd7971b81f7647bd9. Ordered parts preserve the complete diff bytes; none was applied or executed. The patch is a cumulative older-source draft, not maintained implementation or qualification. Mail headers/author/date/message/application instructions were removed.rmcdaniel commentedon Nov 6, 2026
Sanitized external reference for topic 24-server:
0007-Commit-an-activity-s-result-and-the-workflow-task-it.patch, part 2/2. Complete sanitized diff: 89,131 bytes / SHA-256341084feeb6700b8098f1771c540e8c0ca9eea549080f41dd7971b81f7647bd9. Ordered parts preserve the complete diff bytes; none was applied or executed. The patch is a cumulative older-source draft, not maintained implementation or qualification. Mail headers/author/date/message/application instructions were removed.let attempted = task_payload["workflow_signal_id"].as_str().or_else(|| task_payload["resume_source_id"].as_str()) .filter(|signal| !signal.is_empty()); - let (probed, next) = Self::next_message_task(&mut tx, &run_id, &DB::string(&run, "queue")?, attempted, false).await?; + let (probed, next) = Self::next_message_task(tx, &run_id, &DB::string(&run, "queue")?, attempted, false).await?; // The probe read the run as the commands left it; only a task created // since can have changed its status. let mut current = Some(probed).filter(|_| next.is_none()); @@ -3609,73 +3759,51 @@ where let start_turn = start_turn.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner); if let Some(turn) = start_turn { current = None; - if let Some(next) = Self::child_start_turn(&mut tx, &run_id, task_id, &DB::string(&run, "queue")?, turn).await? { + if let Some(next) = Self::child_start_turn(tx, &run_id, task_id, &DB::string(&run, "queue")?, turn).await? { created.push(next); } } - Self::record_completion(&mut tx, task_id, &json!({COMPLETION_RECEIPT: {"request": body, "created_task_ids": created}})).await?; + Self::record_completion(tx, task_id, &json!({COMPLETION_RECEIPT: {"request": body, "created_task_ids": created}})).await?; // PHP's executor leaves every run a completed task did not close // waiting, even when the task recorded only markers or side effects. // A run already waiting or closed is read and left as it is. let mut run_status = match current { Some(status) => status, None => DB::string( - &Self::query("SELECT status FROM workflow_runs WHERE id=$1").bind(&run_id).fetch_one(&mut *tx).await?, + &Self::query("SELECT status FROM workflow_runs WHERE id=$1").bind(&run_id).fetch_one(&mut **tx).await?, "status", )?, }; if matches!(run_status.as_str(), "pending" | "running") { let waiting = Self::query("UPDATE workflow_runs SET status='waiting' WHERE id=$1 AND status IN ('pending','running')") .bind(&run_id) - .execute(&mut *tx) + .execute(&mut **tx) .await?; run_status = if DB::affected(waiting) == 1 { "waiting".to_owned() } else { let current = Self::query("SELECT status FROM workflow_runs WHERE id=$1") .bind(&run_id) - .fetch_one(&mut *tx) + .fetch_one(&mut **tx) .await?; DB::string(¤t, "status")? }; } // This task's completion or failure event is recorded only with the // run's closing status. - if let Some(worker) = &worker + if let Some(worker) = worker && matches!(run_status.as_str(), "completed" | "failed") { - Self::attribute_worker_terminal(&mut tx, &run_id, task_id, worker).await?; + Self::attribute_worker_terminal(tx, &run_id, task_id, worker).await?; } // `MessageStreamService::recordCompletion`: only a continued run // changes the instance's current run, which then receives the streams. if !streams.is_empty() || commands.iter().any(|command| command["type"] == "continue_as_new") { - self.record_stream_completion(&mut tx, &stream_instance, &run_id, task_id, &streams, transition_codec_for_streams, limits) + self.record_stream_completion(tx, &stream_instance, &run_id, task_id, &streams, transition_codec_for_streams, limits) .await?; } tx.summary_changed(&run_id); - let activity_poll = match activity_poll { - 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 || matches!(activity_poll, Some(None)) => { - self.claim_carried_workflow(&mut tx, poll).await?.map(|claimed| (claimed, poll.encoding)) - } - _ => 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(claimed) = activity_poll { - response["activity_poll"] = carried_poll("activity", claimed, None)?; - } - if let Some((claimed, encoding)) = workflow_poll { - response["workflow_poll"] = carried_poll("workflow", claimed, encoding)?; - } - Ok(response) + Ok(Applied::Completed { run_id, run_status, created }) } /// The poll checks a carried activity poll takes before the completion's @@ -3698,29 +3826,89 @@ where /// without the lock (`replay_activity_poll`). The task it leased, if /// any, or `None` when the poll did not run or was refused: the claim's /// writes roll back to the savepoint, and the worker's own poll answers. + /// + /// A poll with `eager: true` first tries the activity tasks this + /// transition `created`, in order, as Temporal's eager activity dispatch + /// does: the worker that scheduled an activity runs it, ahead of the + /// queue. A poll with `history_after_sequence` takes, when its lease is an + /// activity of `run_id`, the run's history events after that sequence + /// (`history_tail`), so the worker that holds the run's history up to + /// there holds all of it. async fn claim_piggybacked_activity( &self, tx: &mut Transaction<'_, DB>, poll: &Value, fence: &super::worker_poll_fence::WorkerPollFence, protocol: &str, + created: &[String], + run_id: &str, ) -> Result<Option<Option<Value>>> { let Some(registration) = self.worker_poll_fence_registration_in(tx, fence, text(poll, "worker_id")?).await? else { return Ok(None); }; sqlx::raw_sql("SAVEPOINT activity_poll").execute(&mut **tx).await?; - match self.claim_in(tx, poll, "activity", protocol, None, None, Some(registration)).await { + let mark = tx.mark(); + match self.claim_activity_for(tx, poll, protocol, ®istration, created, run_id).await { Ok(claimed) => Ok(Some(claimed)), Err(_) => { sqlx::raw_sql("ROLLBACK TO SAVEPOINT activity_poll").execute(&mut **tx).await?; - tx.run_tags.clear(); // The claim may have appended history the rollback took back. - tx.forget_history(); + tx.rewind(mark); Ok(None) } } } + /// `claim_piggybacked_activity`'s claim, inside its savepoint. + async fn claim_activity_for( + &self, + tx: &mut Transaction<'_, DB>, + poll: &Value, + protocol: &str, + registration: &DB::Row, + created: &[String], + run_id: &str, + ) -> Result<Option<Value>> { + let mut claimed = None; + if poll["eager"] == true { + for task_id in created { + let target = Target { task_id, history: true }; + claimed = self.claim_targeted(tx, poll, "activity", protocol, None, None, Some(registration), Some(target)).await?; + if claimed.is_some() { + break; + } + } + } + if claimed.is_none() { + claimed = self.claim_targeted(tx, poll, "activity", protocol, None, None, Some(registration), None).await?; + } + if let Some(claim) = claimed.as_mut() + && let Some(tail) = Self::history_tail(tx, poll, run_id, claim).await? + { + claim["history_tail"] = tail; + } + Ok(claimed) + } + + /// The history a carried poll's lease adds for the worker that holds + /// `run_id`'s history up to the poll's `history_after_sequence`: every + /// event after it, when the lease is an activity of that run and the + /// events fit one page. The poll's receipt keeps the lease without it. + async fn history_tail(tx: &mut Transaction<'_, DB>, poll: &Value, run_id: &str, claim: &Value) -> Result<Option<Value>> { + let Some(after) = poll["history_after_sequence"].as_i64().filter(|after| *after >= 0) else { + return Ok(None); + }; + if claim["workflow_run_id"].as_str() != Some(run_id) { + return Ok(None); + } + let (mut events, next) = Self::history_page_known(&mut **tx, run_id, after, HISTORY_TAIL_PAGE, None, None).await?; + if next.is_some() { + return Ok(None); + } + Self::with_signal_sequences(&mut **tx, run_id, &mut events).await?; + Ok(Some(json!({"after_sequence": after, "events": events}))) + } + /// An activity poll's receipt for a lease the worker still holds, read /// without the transition lock: a committed lease that the claim would /// replay under the lock (`claim_in`). A receipt in any other state @@ -4183,6 +4371,7 @@ where body: Value, external: Option<super::external_resolution::PreparedExternal>, workflow_poll: Option<CarriedWorkflowPoll<'_>>, + continuation: Option<CarriedContinuation<'_>>, ) -> Result<Value> { let result = match &external { Some(prepared) => prepared.stored(), @@ -4193,6 +4382,13 @@ where Some(poll) => self.admit_carried_workflow_poll(poll).await, None => None, }; + let continuation = match continuation { + Some(CarriedContinuation::Prepared(continuation)) => { + Some(self.admit_continuation(continuation, text(&body, "lease_owner")?).await) + } + Some(CarriedContinuation::Refused(reason)) => Some(Err(reason)), + None => None, + }; let mut tx = self.begin().await?; let task = self.task_row(&mut tx, task_id).await?; if DB::string(&task, "task_type")? != "activity" { @@ -4330,9 +4526,24 @@ where ) .await? }; + let continuation = match (continuation, &next_task_id) { + (Some(Ok(continuation)), Some(next)) => Some( + self.carry_continuation(&mut tx, &continuation, &run_id, next, activity.parallel_group_path.as_ref().is_some_and(|path| !path.is_null())).await?, + ), + (Some(Ok(_)), None) => Some(ContinuationOutcome::Refused("no_workflow_task".into())), + (Some(Err(reason)), _) => Some(ContinuationOutcome::Refused(reason)), + (None, _) => None, + }; + // The worker's next workflow poll, claimed for unless the worker runs + // the activity its continuation's poll leased first. + let runs_activity = matches!( + &continuation, + Some(ContinuationOutcome::Recorded { carries_activity_poll: true, activity_poll, .. }) + if !matches!(activity_poll, Some(None)) + ); let workflow_poll = match &workflow_poll { - Some(poll) => self.claim_carried_workflow(&mut tx, poll).await?.map(|claimed| (claimed, poll.encoding)), - None => None, + Some(poll) if !runs_activity => self.claim_carried_workflow(&mut tx, poll).await?.map(|claimed| (claimed, poll.encoding)), + _ => None, }; tx.commit().await?; self.wake.notify_waiters(); @@ -4340,9 +4551,157 @@ where if let Some((claimed, encoding)) = workflow_poll { response["workflow_poll"] = carried_poll("workflow", claimed, encoding)?; } + match continuation { + Some(ContinuationOutcome::Recorded { response: mut completion, activity_poll, .. }) => { + if let Some(claimed) = activity_poll { + completion["activity_poll"] = carried_poll("activity", claimed, None)?; + } + response["workflow_continuation"] = completion; + } + Some(ContinuationOutcome::Refused(reason)) => { + response["workflow_continuation"] = json!({"recorded": false, "reason": reason}); + } + None => {} + } Ok(response) } + /// A continuation's checks before the activity completion's transaction: + /// the worker's workflow poll and activity poll are admitted as a + /// carried poll is, or the continuation is refused. + async fn admit_continuation<'a>( + &self, + continuation: Continuation<'a>, + worker_id: &str, + ) -> std::result::Result<AdmittedContinuation<'a>, String> { + let poll = json!({"worker_id": worker_id, "task_queue": continuation.task_queue}); + let Some(fence) = self.admit_piggybacked_activity_poll(&poll).await else { + return Err("worker_not_admitted".into()); + }; + let activity_fence = match &continuation.activity_poll { + Some(poll) => self.admit_piggybacked_activity_poll(poll).await, + None => None, + }; + Ok(AdmittedContinuation { continuation, worker_id: worker_id.to_owned(), fence, activity_fence }) + } + + /// An admitted continuation, in the activity completion's transaction + /// after the result made `next_task_id` ready: when nothing but the + /// result was written to the run's history after `basis_sequence`, the + /// worker leases that workflow task and completes it with the commands, + /// as its own claim and completion would have. The continuation's writes + /// roll back to a savepoint when it is refused, and the activity + /// completion commits without them. + async fn carry_continuation( + &self, + tx: &mut Transaction<'_, DB>, + admitted: &AdmittedContinuation<'_>, + run_id: &str, + next_task_id: &str, + parallel: bool, + ) -> Result<ContinuationOutcome> { + let refused = |reason: &str| Ok(ContinuationOutcome::Refused(reason.to_owned())); + let continuation = &admitted.continuation; + // A parallel group's member resumes the workflow only with the + // group's other results, which the worker cannot have computed. + if parallel { + return refused("parallel_activity"); + } + let commands = continuation.body["commands"].as_array().map(Vec::as_slice).unwrap_or_default(); + if commands.iter().any(|command| !command["type"].as_str().is_some_and(|kind| CONTINUATION_COMMANDS.contains(&kind))) { + return refused("unsupported_continuation_command"); + } + let run = Self::query("SELECT last_history_sequence,cancellation_request_command_id FROM workflow_runs WHERE id=$1") + .bind(run_id) + .fetch_one(&mut **tx) + .await?; + if DB::number(&run, "last_history_sequence")? != continuation.basis_sequence + 1 { + return refused("history_moved"); + } + if DB::optional_string(&run, "cancellation_request_command_id")?.is_some() { + return refused("cancellation_requested"); + } + if Self::query("SELECT id FROM workflow_tasks WHERE workflow_run_id=$1 AND task_type='workflow' AND status IN ('ready','leased') AND id<>$2 LIMIT 1") + .bind(run_id) + .bind(next_task_id) + .fetch_optional(&mut **tx) + .await? + .is_some() + { + return refused("workflow_task_pending"); + } + let Some(registration) = self.worker_poll_fence_registration_in(tx, &admitted.fence, &admitted.worker_id).await? else { + return refused("worker_not_admitted"); + }; + sqlx::raw_sql("SAVEPOINT workflow_continuation").execute(&mut **tx).await?; + let mark = tx.mark(); + match self.apply_continuation(tx, admitted, ®istration, next_task_id).await { + Ok((response, activity_poll)) => Ok(ContinuationOutcome::Recorded { + response, + carries_activity_poll: continuation.activity_poll.is_some(), + activity_poll, + }), + Err(error) => { + if sqlx::raw_sql("ROLLBACK TO SAVEPOINT workflow_continuation").execute(&mut **tx).await.is_err() { + return Err(error); + } + tx.rewind(mark); + Ok(ContinuationOutcome::Refused(continuation_reason(&error))) + } + } + } + + /// `carry_continuation`'s claim and completion, inside its savepoint: the + /// workflow task leased as the worker's own poll would lease it, without + /// the history the worker holds, then completed as the worker's own + /// completion would complete it, and the continuation's activity poll + /// claimed as a completion's carried poll is. + async fn apply_continuation( + &self, + tx: &mut Transaction<'_, DB>, + admitted: &AdmittedContinuation<'_>, + registration: &DB::Row, + task_id: &str, + ) -> Result<(Value, Option<Option<Value>>)> { + let continuation = &admitted.continuation; + let poll = json!({"worker_id": admitted.worker_id, "task_queue": continuation.task_queue}); + let target = Target { task_id, history: false }; + let Some(claim) = self + .claim_targeted(tx, &poll, "workflow", continuation.protocol, None, None, Some(registration), Some(target)) + .await? + else { + return Err(refuse(StatusCode::CONFLICT, "workflow_task_not_claimable")); + }; + let task = self.task_row(tx, task_id).await?; + // A message's task resumes the workflow with what the worker has not + // read. + let payload = DB::document_row(&task, "payload")?; + if !payload["workflow_signal_id"].is_null() || !payload["workflow_update_id"].is_null() { + return Err(refuse(StatusCode::CONFLICT, "message_task")); + } + let mut body = continuation.body.clone(); + body["lease_owner"] = json!(admitted.worker_id); + body["workflow_task_attempt"] = claim["workflow_task_attempt"].clone(); + let commands = Self::completion_commands(&body, &continuation.external)?; + let guarded = self + .guard_completion(tx, &task, task_id, &body, commands, Some(continuation.protocol), &continuation.transition_codec) + .await?; + let applied = self + .apply_completion(tx, &task, task_id, &body, commands, guarded, continuation.transition_codec.clone(), &continuation.external, continuation.worker.as_ref(), continuation.limits, None) + .await?; + let Applied::Completed { run_id, run_status, created } = applied else { + return Err(refuse(StatusCode::CONFLICT, "run_deadline_expired")); + }; + let activity_poll = match (&continuation.activity_poll, &admitted.activity_fence) { + (Some(poll), Some(fence)) => { + self.claim_piggybacked_activity(tx, poll, fence, continuation.protocol, &created, &run_id).await? + } + _ => None, + }; + let 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}); + Ok((response, activity_poll)) + } + /// A carried workflow poll's checks that come before the lock in /// `poll_with_pages`. A poll they would answer without a claim (a /// draining, stale or unregistered worker, a malformed page request) is @@ -5638,6 +5997,40 @@ impl<R> Clone for Apply<'_, R> { } impl<R> Copy for Apply<'_, R> {} +/// What a workflow task completion's guards read (`Store::guard_completion`) +/// for its commands to be applied with: the run's whole row, its instance, +/// the completion's message streams and its local activity records. +pub(super) struct Guarded<R> { + pub(super) stream_run: R, + pub(super) stream_instance: String, + pub(super) streams: super::message_streams::Completion, + pub(super) local: std::collections::BTreeMap<usize, super::local_activities::Record>, + pub(super) exceptions: std::collections::BTreeMap<usize, String>, +} + +/// A workflow task completion's applied commands (`Store::apply_completion`). +pub(super) enum Applied { + /// The run's deadline had passed: the run timed out instead, and this + /// answer refuses the completion once the transaction commits. + Expired(Value), + /// The commands applied: the run, its status after them and the tasks + /// they created. + Completed { run_id: String, run_status: String, created: Vec<String> }, +} + +/// The one task a claim leases, when it is a candidate (`claim_targeted`). +#[derive(Clone, Copy)] +pub(super) struct Target<'a> { + pub(super) task_id: &'a str, + /// Whether a workflow task's claim carries its history. A worker that + /// holds the history leases the task without it. + pub(super) history: bool, +} + +/// The most history events a carried poll's `history_tail` delivers: a +/// longer tail is left to the worker's own history read. +const HISTORY_TAIL_PAGE: i64 = 100; + /// A workflow task completion's stored receipt: its request and the tasks it /// created, so a repeat answers as the original did. Older receipts, and /// activity receipts, are the bare request. @@ -5751,6 +6144,76 @@ struct AdmittedWorkflowPoll<'a> { request: super::workflow_poll_requests::WorkflowPollRequest, } +/// A workflow task completion an activity completion carries: the commands +/// the worker computed for the workflow task the result makes ready, from +/// the run's history through `basis_sequence` and the result, as the +/// history's next event. Its payloads are prepared as a completion's are. +pub(crate) struct Continuation<'a> { + pub(crate) basis_sequence: i64, + pub(crate) task_queue: String, + /// `{"commands": [...]}`, canonical as a completion's body. + pub(crate) body: Value, + pub(crate) external: super::external_resolution::PreparedCommands, + pub(crate) worker: Option<super::runtime_credentials::Principal>, + pub(crate) protocol: &'a str, + pub(crate) limits: super::memo_limits::StructuralLimits, + pub(crate) transition_codec: Arc<Semaphore>, + /// The worker's next activity poll, which the continuation carries as a + /// workflow task completion carries one. + pub(crate) activity_poll: Option<Value>, +} + +/// A continuation as the activity completion's request carries it, after +/// the route's checks (`http::carried_continuation`). +pub(crate) struct ContinuationRequest { + pub(crate) basis_sequence: i64, + pub(crate) task_queue: String, + /// The completion body the route checked, with its commands. + pub(crate) body: Value, + pub(crate) activity_poll: Option<Value>, +} + +/// A carried continuation, or why it was refused before the transaction. +pub(crate) enum CarriedContinuation<'a> { + Prepared(Continuation<'a>), + Refused(String), +} + +/// A continuation admitted before the completion's transaction, with the +/// fences of the worker's workflow and activity polls. +struct AdmittedContinuation<'a> { + continuation: Continuation<'a>, + /// The worker that completed the activity, which leases the workflow task. + worker_id: String, + fence: super::worker_poll_fence::WorkerPollFence, + /// The fence of the continuation's activity poll, when it was admitted. + activity_fence: Option<super::worker_poll_fence::WorkerPollFence>, +} + +/// The commands a continuation may carry: those whose application depends +/// on nothing but the run's history and the task they complete. +const CONTINUATION_COMMANDS: &[&str] = &["schedule_activity", "start_timer", "complete_workflow", "record_version_marker"]; + +/// A continuation's outcome in the activity completion's transaction. +enum ContinuationOutcome { + /// The workflow task's completion answer and the continuation's + /// activity poll, as a workflow task completion answers them. + Recorded { response: Value, carries_activity_poll: bool, activity_poll: Option<Option<Value>> }, + /// Nothing of the continuation was kept, for this reason. + Refused(String), +} + +/// A refused continuation's reason: a refusal's own, or `continuation_failed`. +pub(super) fn continuation_reason(error: &super::RuntimeError) -> String { + match error { + super::RuntimeError::Refused { reason, .. } => (*reason).to_owned(), + super::RuntimeError::Protocol { response, .. } => { + response["reason"].as_str().unwrap_or("continuation_failed").to_owned() + } + _ => "continuation_failed".to_owned(), + } +} + /// What a workflow poll's first probe under a request ID found. enum FirstProbe { /// The request's last answer, still deliverable. @@ -5824,7 +6287,8 @@ fn capabilities_now() -> Value { "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_workflow_poll": true}); + "workflow_task_completion_workflow_poll": true, "carried_activity_poll_eager_dispatch": true, + "carried_activity_poll_history_tail": true, "activity_completion_workflow_continuation": true}); value["runtime_external_payload_transport"] = super::payload_discovery::transport_manifest(); value["supported_workflow_task_commands"] .as_array_mut() diff --git a/rust/src/runtime/transaction.rs b/rust/src/runtime/transaction.rs index 6ed9ebb6..0bce53d4 100644 --- a/rust/src/runtime/transaction.rs +++ b/rust/src/runtime/transaction.rs @@ -17,6 +17,14 @@ pub(super) struct RunTags { pub(super) workflow_class: String, } +/// A transaction's kept state at a savepoint (`Transaction::mark`). +pub(super) struct Mark { + metered: bool, + local_writes: [usize; 3], + replayed_poll: Option<String>, + unattempted_activities: BTreeSet<String>, +} + pub(super) struct Transaction<'a, DB: Backend> { inner: sqlx::Transaction<'a, DB>, /// This transition's place in the server's queue, held until `inner` @@ -102,6 +110,31 @@ impl<'a, DB: Backend> Transaction<'a, DB> { } } + /// What this transaction keeps beside its rows, before the statements + /// a savepoint may take back (`rewind`). + pub(super) fn mark(&self) -> Mark { + Mark { + metered: self.meter.is_some(), + local_writes: self.local_writes.held(), + replayed_poll: self.replayed_poll.clone(), + unattempted_activities: self.unattempted_activities.clone(), + } + } + + /// Forget what this transaction kept since `mark`, whose statements were + /// rolled back to a savepoint taken there. A meter set before the mark + /// stays, with what it charged since. + pub(super) fn rewind(&mut self, mark: Mark) { + if !mark.metered { + self.meter = None; + } + self.run_tags.clear(); + self.forget_history(); + self.local_writes.truncate(mark.local_writes); + self.replayed_poll = mark.replayed_poll; + self.unattempted_activities = mark.unattempted_activities; + } + pub(super) async fn rollback(self) -> Result<()> { self.inner.rollback().await?; Ok(()) diff --git a/rust/tests/execution.rs b/rust/tests/execution.rs index 58c8fa7a..19c0cc14 100644 --- a/rust/tests/execution.rs +++ b/rust/tests/execution.rs @@ -381,6 +381,8 @@ mod worker_protocol_envelope; mod child_start_turn; #[path = "execution/carried_polls.rs"] mod carried_polls; +#[path = "execution/activity_continuations.rs"] +mod activity_continuations; enum TestDatabase { Sqlite(tempfile::TempDir), diff --git a/rust/tests/execution/activity_continuations.rs b/rust/tests/execution/activity_continuations.rs new file mode 100644 index 00000000..f0389799 --- /dev/null +++ b/rust/tests/execution/activity_continuations.rs @@ -0,0 +1,199 @@ +use super::*; + +fn kinds(history: &Value) -> Vec<String> { + history.as_array().unwrap().iter().map(|event| event["event_type"].as_str().unwrap().to_owned()).collect() +} + +fn last_sequence(events: &Value) -> i64 { + events.as_array().unwrap().last().unwrap()["sequence"].as_i64().unwrap() +} + +fn step(n: i64) -> Value { + json!({"type":"schedule_activity","activity_type":"step","arguments":envelope(Payload::Array(vec![Payload::Long(n)])), + "start_to_close_timeout":120}) +} + +/// A carried activity poll that leases the activity its completion +/// schedules, with the run's history after `after`. +fn eager(id: &str, after: i64) -> Value { + json!({"worker_id":"chainer","task_queue":"test","poll_request_id":id,"eager":true,"history_after_sequence":after}) +} + +fn carried_workflow(id: &str) -> Value { + json!({"worker_id":"chainer","task_queue":"test","poll_request_id":id,"history_page_size":100}) +} + +async fn complete_activity(app: &Router, activity: &Value, carried: Value) -> Value { + let mut body = json!({"lease_owner":activity["lease_owner"],"activity_attempt_id":activity["activity_attempt_id"], + "result":envelope(Payload::Long(1))}); + for (field, value) in carried.as_object().unwrap() { + body[field] = value.clone(); + } + let done = request(app, "POST", &format!("/api/worker/activity-tasks/{}/complete", activity["task_id"].as_str().unwrap()), body).await; + assert_eq!(done.0, StatusCode::OK, "{}", done.1); + assert_eq!(done.1["recorded"], true, "{}", done.1); + done.1 +} + +/// Starts a run whose first workflow task schedules a step, leased eagerly +/// with the history after what that task held. The step and the sequence +/// a continuation of it is based on. +async fn first_step(app: &Router, workflow: &str) -> (Value, i64) { + start(app, workflow).await; + let task = poll(app, "chainer", "workflow").await; + let held = last_sequence(&task["history_events"]); + let mut body = completion(&task, json!([step(1)])); + body["activity_poll"] = eager(&format!("{workflow}-poll-1"), held); + let done = request(app, "POST", &format!("/api/worker/workflow-tasks/{}/complete", task["task_id"].as_str().unwrap()), body).await; + assert_eq!(done.0, StatusCode::OK, "{}", done.1); + assert_eq!(done.1["activity_poll"]["leased"], true, "{}", done.1); + let activity = done.1["activity_poll"]["answer"]["task"].clone(); + let tail = &activity["history_tail"]; + assert_eq!(tail["after_sequence"], held, "{activity}"); + assert_eq!(tail["events"][0]["sequence"], held + 1, "{activity}"); + let basis = last_sequence(&tail["events"]); + (activity, basis) +} + +/// An activity completion that carries the worker's completion of the +/// workflow task its result makes ready commits both, when nothing but the +/// result was written after the history the worker held. The activity the +/// commands schedule is leased to the continuation's poll with the history +/// it adds, so a run's steps go on one transaction each, and the run's +/// history is the one its worker's own polls and completions write. +#[tokio::test] +async fn a_continuation_completes_the_workflow_task_its_activity_result_makes_ready() { + let database = TestDatabase::new().await; + let runtime = database.open().await.unwrap(); + let app = router(runtime.clone()); + register(&app, "chainer", json!(["echo"]), json!(["step"])).await; + let (mut activity, mut basis) = first_step(&app, "chained-run").await; + // A replayed poll answers with the lease, not the history. + let replayed = request(&app, "POST", "/api/worker/activity-tasks/poll", + json!({"worker_id":"chainer","task_queue":"test","poll_request_id":"chained-run-poll-1","timeout_seconds":0})).await; + assert_eq!(replayed.0, StatusCode::OK, "{}", replayed.1); + let mut leased = activity.clone(); + leased.as_object_mut().unwrap().remove("history_tail"); + assert_eq!(replayed.1["task"], leased, "{}", replayed.1); + + for n in 2..=3 { + let id = format!("chained-run-poll-{n}"); + let answer = complete_activity(&app, &activity, json!({"workflow_poll": carried_workflow(&format!("chained-run-workflow-{n}")), + "workflow_continuation": {"basis_sequence": basis, "task_queue": "test", "commands": [step(n)], + "activity_poll": eager(&id, basis)}})).await; + let continued = &answer["workflow_continuation"]; + assert_eq!(continued["recorded"], true, "{answer}"); + assert_eq!(continued["outcome"], "completed", "{answer}"); + assert_eq!(continued["task_id"], answer["next_task_id"], "{answer}"); + // The worker runs the activity first, so its workflow poll is not + // claimed for. + assert!(answer.get("workflow_poll").is_none(), "{answer}"); + assert_eq!(continued["activity_poll"]["leased"], true, "{answer}"); + activity = continued["activity_poll"]["answer"]["task"].clone(); + assert_eq!(activity["activity_type"], "step", "{activity}"); + let tail = &activity["history_tail"]; + assert_eq!(tail["after_sequence"], basis, "{activity}"); + assert_eq!(tail["events"][0]["event_type"], "ActivityCompleted", "{activity}"); + assert_eq!(tail["events"][0]["sequence"], basis + 1, "{activity}"); + basis = last_sequence(&tail["events"]); + } + let answer = complete_activity(&app, &activity, json!({"workflow_continuation": {"basis_sequence": basis, "task_queue": "test", + "commands": [{"type":"complete_workflow","result":envelope(Payload::Long(3))}]}})).await; + assert_eq!(answer["workflow_continuation"]["recorded"], true, "{answer}"); + assert_eq!(answer["workflow_continuation"]["run_status"], "completed", "{answer}"); + assert!(answer["workflow_continuation"].get("activity_poll").is_none(), "{answer}"); + let (_, described) = request(&app, "GET", "/api/workflows/chained-run", Value::Null).await; + assert_eq!(described["status"], "completed", "{described}"); + + // The same steps through the worker's own polls and completions. + let started = start(&app, "stepped-run").await; + for n in 1..=3 { + let task = poll(&app, "chainer", "workflow").await; + let (status, body) = finish_task(&app, &task, json!([step(n)])).await; + assert_eq!(status, StatusCode::OK, "{body}"); + let activity = poll(&app, "chainer", "activity").await; + complete_activity(&app, &activity, json!({})).await; + } + let task = poll(&app, "chainer", "workflow").await; + let (status, body) = finish_task(&app, &task, json!([{"type":"complete_workflow","result":envelope(Payload::Long(3))}])).await; + assert_eq!(status, StatusCode::OK, "{body}"); + let stepped = run_history(&app, &started["workflow_id"], &started["run_id"]).await; + let chained = run_history(&app, &json!("chained-run"), &described["run_id"]).await; + assert_eq!(kinds(&chained), kinds(&stepped)); + let sequences = |history: &Value| history.as_array().unwrap().iter().map(|event| event["sequence"].clone()).collect::<Vec<_>>(); + assert_eq!(sequences(&chained), sequences(&stepped)); + runtime.close().await; + database.remove().await; +} + +/// A continuation the Server cannot prove was computed from the run's +/// whole history, or that it cannot record as the worker's completion would +/// be, is refused. The activity's result still commits, and the workflow +/// task it makes ready is left as it was for the worker's own poll. +#[tokio::test] +async fn a_refused_continuation_leaves_the_workflow_task_for_the_worker_s_poll() { + let database = TestDatabase::new().await; + let runtime = database.open().await.unwrap(); + let app = router(runtime.clone()); + register(&app, "chainer", json!(["echo"]), json!(["step"])).await; + let cases = [ + ("moved-run", json!({"commands": [step(2)]}), -1, "history_moved"), + ("unsupported-run", json!({"commands": [{"type":"open_signal_wait","signal_name":"go"}]}), 0, "unsupported_continuation_command"), + ("malformed-run", json!({"commands": [step(2)], "sticky_cache": {}}), 0, "invalid_workflow_continuation"), + // Refused by the completion's guard after the continuation's claim, + // which rolls back with it. + ("guarded-run", json!({"commands": [{"type":"schedule_activity","activity_type":"step", + "arguments":envelope(Payload::Array(vec![])),"cancellation_policy":"try_cancel"}]}), 0, + "activity_cancellation_policy_not_supported"), + ]; + for (workflow, mut continuation, offset, reason) in cases { + let (activity, basis) = first_step(&app, workflow).await; + continuation["basis_sequence"] = json!(basis + offset); + continuation["task_queue"] = json!("test"); + continuation["activity_poll"] = eager(&format!("{workflow}-poll-2"), basis); + let answer = complete_activity(&app, &activity, json!({"workflow_poll": carried_workflow(&format!("{workflow}-workflow")), + "workflow_continuation": continuation})).await; + assert_eq!(answer["workflow_continuation"], json!({"recorded": false, "reason": reason}), "{workflow}: {answer}"); + // The workflow task is the carried workflow poll's, on its first + // attempt, with the result the last event of its history. + assert_eq!(answer["workflow_poll"]["leased"], true, "{workflow}: {answer}"); + let task = &answer["workflow_poll"]["answer"]["task"]; + assert_eq!(task["task_id"], answer["next_task_id"], "{workflow}: {answer}"); + assert_eq!(task["workflow_task_attempt"], 1, "{workflow}: {task}"); + let history = run_history(&app, &json!(workflow), &task["run_id"]).await; + let completed = history.as_array().unwrap().iter().find(|event| event["event_type"] == "ActivityCompleted").unwrap(); + assert_eq!(completed["sequence"], basis + 1, "{workflow}: {history}"); + // Which the worker completes as it would have without one. + let (status, body) = finish_task(&app, task, json!([{"type":"complete_workflow","result":envelope(Payload::Null)}])).await; + assert_eq!(status, StatusCode::OK, "{workflow}: {body}"); + } + runtime.close().await; + database.remove().await; +} + +/// A signal that arrives while the activity runs is history the worker +/// has not read, so a continuation computed without it is refused. +#[tokio::test] +async fn a_continuation_is_refused_after_a_signal_the_worker_has_not_read() { + let database = TestDatabase::new().await; + let runtime = database.open().await.unwrap(); + let app = router(runtime.clone()); + let (status, body) = request(&app, "POST", "/api/worker/register", json!({"worker_id":"chainer","task_queue":"test","runtime":"php", + "supported_workflow_types":["echo"],"supported_activity_types":["step"], + "workflow_command_contracts":{"echo":{"signals":["go"],"signal_contracts":[{"name":"go","parameters":[]}]}}})).await; + assert_eq!(status, StatusCode::CREATED, "{body}"); + let (activity, basis) = first_step(&app, "signalled-run").await; + let (status, body) = request(&app, "POST", "/api/workflows/signalled-run/signal/go", json!({"input":envelope(Payload::Array(vec![]))})).await; + assert_eq!(status, StatusCode::ACCEPTED, "{body}"); + let answer = complete_activity(&app, &activity, json!({"workflow_continuation": {"basis_sequence": basis, "task_queue": "test", + "commands": [step(2)], "activity_poll": eager("signalled-run-poll-2", basis)}})).await; + let continued = &answer["workflow_continuation"]; + assert_eq!(continued["recorded"], false, "{answer}"); + assert!(["history_moved", "workflow_task_pending", "message_task", "no_workflow_task"].contains(&continued["reason"].as_str().unwrap()), "{answer}"); + // Nothing the continuation would have written was. + let task = poll(&app, "chainer", "workflow").await; + let history = run_history(&app, &json!("signalled-run"), &task["run_id"]).await; + assert_eq!(kinds(&history).iter().filter(|kind| *kind == "ActivityScheduled").count(), 1, "{history}"); + runtime.close().await; + database.remove().await; +}rmcdaniel commentedon Nov 6, 2026
sdk-php owns counterpart SDK341 and has completed a current-source eligibility/default-off review on c87eae37. Consumer implementation waits the versioned accepted+advertised producer handoff; no Server implementation or reference application by SDK.
Please bind these concrete consumer inputs in the #1078 schema/vectors:
The SDK will add an explicit Worker option default OFF independently of request-level optionality, preserve old-Server fallback and existing tick/shutdown/telemetry paths, then qualify real worker controls and a matching supported published tuple. Please return the owning accepted schema/checkpoint for these identity/clock/recovery decisions when available. No imported field name or mock is being treated as producer authority.
rmcdaniel commentedon Nov 6, 2026
Security static intake finding on the sanitized external
0007reference (89,131 B, SHA-256341084feeb6700b8098f1771c540e8c0ca9eea549080f41dd7971b81f7647bd9), reviewed as data only:The reference implementation must not be used as the error-disposition model.
carry_continuationcatches everyapply_continuationerror, rolls back its savepoint, and returnsContinuationOutcome::Refused(continuation_reason(...));continuation_reasonmaps all non-Refused/non-Protocol errors—including storage, decoding and invariant faults—to the ordinary-looking stringcontinuation_failed. That commits the activity result while hiding an internal failure as an eligibility decline. This is broader than the issue’s permitted documented declines and defeats truthful unknown/fault handling.Maintain the filed boundary: enumerate the closed soft-decline set; propagate backend/decoder/budget/invariant and unknown-commit errors so the outer activity completion does not silently commit past them; prove savepoint rollback and cached-state restoration separately. No patch was applied or executed and no supplied benchmark/test claim was accepted. Sanitizer review found no credential/private-path or application-instruction material in this reference.
rmcdaniel commentedon Nov 6, 2026
Intake is filed as P0, owner server. Shared index, allocation and static review findings.
Complete sanitized diff reference(s): 0007 part 1/2, 0007 part 2/2.
Shared Lockbox bundle:
lab4/general/dw-performance-intake/2026-11-06/sanitized-tricks-20-25.zip(101,306 bytes; SHA-2563e5b593ed89536451be1c8d74abf2ee6e4782cd3f7a75fe5fc9a645d648a52da). Its roundtrip and member hashes are verified. The supplied patches and benchmark results remain external reference data; no patch was applied or executed during intake. Existing issue acceptance and subsequent owner review remain in force.rmcdaniel commentedon Nov 7, 2026
Open-item record (Server, 2026-11-07 19:35 UTC).
0007reference and Security's finding (internal continuation faults must propagate, not become ordinary declines) remain mandatory inputs.rmcdaniel commentedon Nov 7, 2026
Proposed producer contract for #1078, draft v0, for review before any code. It extends the carried-poll family already on main (
activity_completion_workflow_poll, #1061/#1067 inworker-protocol-api.openapi.yaml) and does not change it. Server owns this contract. sdk-php#341 should not implement against it until it is accepted and advertised.1. Capability and opt-in
activity_completion_workflow_continuation, advertised only when the server can honor every rule below. Old servers don't advertise it, and old clients never send the member, so behavior is unchanged in both directions.POST /worker/activity-tasks/{taskId}/complete. Request member:workflow_continuation. Response member:workflow_continuation.workflow_continuationand the existingworkflow_pollmember are mutually exclusive on one request. Sending both isinvalid_carrier, which declines the continuation; the activity completion is kept.2. Request member (closed shape)
worker_idworkflow_pollis today.poll_request_idworkflow_run_idadmitted_prefix{last_sequence, workflow_task_id}: the last history sequence the worker's replay consumed, and the workflow task whose completion produced that prefix.commandsschedule_activity,start_timer,complete_workflowandrecord_version_marker.history_after_sequencehistory_tailin the reply.3. Eligibility: all checked in the completion transaction, under the run lock
The continuation is eligible only if every condition holds. The first failure is a closed soft decline (§5).
last_history_sequencebefore this completion equalsadmitted_prefix.last_sequence. The prefix-producing workflow task is the run's most recent completed workflow task. This completion appends exactly one event: this activity's result event.If all hold, then inside a savepoint, as one atomic unit, the server:
worker_id(the Native: claim the woken workflow task for a poll carried on the activity completion #1061 path);commandsas that task's completion with the ordinary completion code;poll_request_id.The worker's guessed event never becomes authority. The server records the result event canonically from the activity completion body: the same codec, payload bytes and identity that an ordinary completion would record. The continuation is accepted only against that recorded event.
4. Reply
workflow_continuationin reply{accepted:true, workflow_task_id, history_tail?}{accepted:false, reason:<closed code>}poll_request_idpoll_request_idand never apply twicehistory_tail, when requested, is the events afterhistory_after_sequence. It uses the configured normal poll projection, encoding, page limit and budget. If the tail exceeds the budget, the server returnshistory_tail_truncated:truewith no partial authority; the worker reads the rest through ordinary history.5. Declines vs faults (Security finding 6697732201)
Closed soft-decline set. In each case the savepoint rolls back, all cached transaction state is restored, and the activity completion commits normally:
invalid_carrier,worker_not_registered,task_queue_mismatch,worker_draining,stale_worker_registration,occupied_workflow_slot,prefix_mismatch,run_not_continuable(other task, message, group, cancellation or closing),unsupported_command,command_budget_exceeded.Everything else is a fault, not a decline. This covers backend errors, decode errors, invariant failures, history-budget accounting errors and unknown errors. A fault fails the whole completion transaction visibly, including the activity result, matching the existing carrier rule
claim_fault_before_commit: fail_visibly_and_roll_back_the_completion_transaction. If the commit outcome is unknown, the server keeps the existingstorage_unavailable503 behavior, and the worker recovers with the original completion and poll IDs. No fault is ever reported asaccepted:false.6. Retry and idempotency
A lost or retried completion reply never duplicates the result, history or commands. The receipt keyed by
poll_request_id, together with the existing stale-attempt receipt, lets the worker recover the accepted continuation's workflow task, or learn it was declined.7. Open decisions for General and sdk-php#341
D1. Replay time authority. The worker computes
commandsbefore the server timestamps the result event. Options:[result_recorded_at - ε, commit_now], and records the server value.Server recommends (a) for v1. The SDK owns its deterministic-clock proof.
D2. Whether
record_version_markerbelongs in the initial command set or waits for v2.D3. Eager activity dispatch inside the accepted continuation. Proposed: not in v1. The worker uses the existing
workflow_task_completion_activity_pollon its next completion.Test plan once accepted
Real HTTP plus SQLite, MySQL and PostgreSQL controls for:
Topic 25 (#1079) builds on the accepted invariants here.
Next: review by sdk-php (consumer fit, D1–D3) and Security (decline/fault boundary). Then General's selection of D1–D3. Server then implements behind the capability and advertises it only after qualification.
rmcdaniel commentedon Nov 7, 2026
SECURITY CONTRACT HOLD — draft v0 resolves the original
0007catch-all fault downgrade, but the prefix and decline-recovery authority are not yet closed enough for implementation. This is a docs-only disposition on 6712342766; it selects no schema, code, capability advertisement, or runtime effect.The §5 boundary is directionally correct and closes finding 6697732201: the implementation must match the named soft-decline set explicitly and must not use a fallback
continuation_failedmapper. Backend, codec/decode, invariant, history-accounting, overflow, cache-restoration, and unknown errors must escape the continuation savepoint, fail the outer transaction visibly, and leave the activity result uncommitted. Unknown commit remains the existingstorage_unavailable/original-ID recovery path, neveraccepted:false.Before this becomes an implementation contract, please close these points in the owning text:
Bind the prefix tuple as one authoritative relation. Under the run lock, require that
admitted_prefix.workflow_task_idnames the same run's exact most-recent completed workflow task, that its recorded completion boundary is exactlyadmitted_prefix.last_sequence, and that the completing activity's scheduled execution/attempt descends from that admitted boundary. A true sequence and a true-but-unrelated task ID must not compose authority. Re-run the ordinary definition/build compatibility and worker capability admission for the speculative claim. State the check order explicitly: validate the pre-result prefix, append exactly the canonical activity result event, then require that event to be the sole extension before claiming/completing the continuation task. D1 must be selected before timers or any time-derived command can be admitted; draft v0 does not yet supply replay-time authority.Make soft-decline recovery durable outside the rolled-back savepoint. The text says the savepoint and cached state roll back, while §6 says recovery can learn that the continuation was declined. Specify that a content-bound decline receipt keyed by the original
poll_request_idis written in the outer activity-completion transaction after successful savepoint rollback, and binds worker/registration fence, run, activity execution/attempt, prefix, command/request digest, and the closed decline code. A lost reply or stale-attempt retry must replay that exact result without recomputing eligibility. If bothworkflow_pollandworkflow_continuationare sent,invalid_carriermust suppress both carried paths; it must not decline one while executing the other.Also narrow
command_budget_exceededto a deterministic validation of the proposed command list against a successfully read public limit. Failure to read/measure/account the current history or payload budget, integer overflow, serializer failure, external-payload preparation failure, or an unknown validator error is a fault. Malformed closed shape and unknown fields may remaininvalid_carrier; payload decoding failures may not.With those statements added, the closed decline set, ordinary activity-completion preservation, savepoint rollback, full-fault propagation, original-ID unknown-commit recovery, and default-off capability separation are acceptable Security boundaries. D2/D3 remain General/SDK product decisions; no speedup or supported producer/consumer tuple follows from this contract review.
rmcdaniel commentedon Nov 7, 2026
sdk-php consumer-fit review of contract draft v0 (6712342766), checked against SDK main
69d0e408. The shape fits the SDK and the five findings on sdk-php#341. One gap blocks a useful consumer (F1); the rest are precision requests. No SDK code is written against this draft.D1–D3
WorkflowContext::replayNow(). It returns the time of the consumed replay boundary (BusinessReplayClock); in a continuation, that boundary is the result event, whose time the worker cannot know. The SDK's eligibility replay will abandon the continuation before sending it ifreplayNow()is called at or after the speculative result event. The same applies to the cancellation, condition, child and group clocks, though those shapes are already excluded. The server then uses the recorded result time for that task, which is exactly what a later cold replay will use.start_timersends a relativedelay_seconds. The risk is clock reads, not timers.record_version_markerin v1 if it goes through the ordinary completion validator.getVersion()is deterministic in the SDK and reads no clock. Excluding it would make every workflow that adds a newgetVersion()branch after an activity decline. This is not a hard requirement: deferring it only costs declines.Findings
F1 — The worker cannot form
admitted_prefixtoday (blocking).last_sequencemust include the events recorded by the prefix-producing workflow-task completion, such as the scheduled activity and its server-assigned identity. The SDK only knows the history it replayed up to that task's start. The sticky cache remembers that history, not the completion's own events. The workflow-task completion reply returns none of them. The SDK could make an extra history read before each continuation, but that removes the benefit.history_after_sequenceon the workflow-task completion return the events that completion recorded, pluslast_history_sequence. Use the same projection, budget and truncation rule as your §4history_tail, gated by the same capability. An alternative that gives the worker the authoritative events, not just a sequence number, also works.F2 — The accepted reply should let the worker chain and verify. Please always include
last_history_sequenceafter the continuation commits. Whenhistory_tailis requested fromadmitted_prefix.last_sequence, it should contain the recorded result event and the events from the continuation's commands. The SDK will compare the recorded result event with the one it replayed: type, sequencelast_sequence + 1, activity identity and payload bytes. On any mismatch it drops its cached state and does a cold replay next time. It never treats its own guess as authority.F3 — Define the recovery read for an accepted continuation. After a lost reply, the worker polls with the original
poll_request_id. Please specify exactly what that poll returns when the continuation was accepted: for example a closed receipt{workflow_continuation: {accepted: true, workflow_task_id}}with no lease. Also specify the declined case, which is an ordinary poll. That poll must never long-poll or hand out an unrelated task under the consumed ID.F4 — Name the fault response. A fault rolls back the whole completion. The SDK needs it distinguishable from an unknown commit:
storage_unavailable503 means: existing commit proof, then F3.Please state the status and reason code for a fault, ideally the same one the carried-poll claim fault uses today.
F5 — Empty
commands. The SDK treats an empty command list as "waiting for history" and fails that task with a waiting reason. A workflow blocked on a signal or condition is therefore never eligible. Please refuse an empty list asinvalid_carrier. The SDK will not send one.F6 — Capacity rule 4. Please confirm that "no live activity lease other than the one completing" counts only leases held by this
worker_id. The SDK will attempt a continuation only when it holds no other activity lease and has reserved a free workflow slot, as it does for carried polls.SDK plan once accepted and advertised (sdk-php#341)
replayNow()after the prefix.Next: Server's answer on F1–F6, then General's D1–D3 selection.
5 remaining items
rmcdaniel commentedon Nov 8, 2026
Operator nudge: this benchmax ticket (P0) has had no linked PR and no activity for about 7 h. Please pick it up next, after the current work on #1057. Suggested order for the stalled server tickets: #1063, #1068, #1075, #1077, #1078, #1079, then #1081 (P2); this one is 5 of 7. Open a draft PR as soon as work starts so the tracker shows it. If it is blocked, add the status:blocked label with a one-line reason.
rmcdaniel commentedon Nov 9, 2026
Source review checkpoint for #1078 and sdk-php#341. General remains the owner; no producer or SDK continuation implementation is selected by this checkpoint.
The maintained Native path at
b7337c1fstill implements ordinary carried polling:complete_activityrecords the activity result, creates the normal resume task, optionally claims a carried workflow poll, and then commits. It does not accept workflow continuation commands. Its repeated activity completion returns the established stale-attempt receipt, including for a different result; that receipt cannot serve as the stronger exact-request continuation acknowledgment.A timestamp constraint needs an explicit design decision. Native
store::recordassigns the event ID andrecorded_atwhen storingActivityCompleted. Published PHP SDK 3.11.0 (fbc64ef9) constructsBusinessReplayClockfrom delivered history, andconsume()takes the consumed activity boundary'sReplayClockHistory::recordedTime.WorkflowContext::replayNow()exposes that value to workflow code. Therefore a worker-generated ActivityCompleted event can change commands even when its decoded result matches: a different timestamp can select another workflow branch. Checking only result bytes or history length is insufficient. This finding comes from source review, not a runtime experiment.The proposed contract direction is a bounded Server-authored preparation before any speculative replay. A preparation would return the exact canonical event, history prefix and original claim authority that the commit must revalidate. It must neither commit the result nor renew the activity lease. The subsequent completion may accept the result and permitted workflow commands together only if that prepared identity is still authoritative. If eligibility changed, a valid activity result follows the documented ordinary completion path; a failed database operation, decoder, invariant or unknown commit outcome must retain its actual error semantics. A Server-produced timestamp must remain exact on future replay. This adds a preparation request and therefore carries no round-trip or performance claim.
Before implementation, the design must settle the preparation's authenticity/expiry and replay semantics, bounded inline Avro identity (external payloads initially excluded unless separately proved), complete history commitment, exact-request retry receipt, savepoint/cache restoration, supported-command validation, owner/registration/fence/deadline/capacity rules, decline reasons and event/byte/command limits. The existing poll savepoint snapshots only poll-related fields and deliberately keeps metering charges consumed; it must not be assumed to restore every continuation effect.
Next action: finish and review that versioned contract against the current Server and published SDK, including a workflow that branches on
replayNow(), before a producer PR or SDK consumer. All existing #1078 acceptance criteria remain open. Native 3.3.2 and SDK 3.11.0 do not deliver this feature, and the reporter's speedup remains unverified.rmcdaniel commentedon Nov 9, 2026
General contract draft v1 for #1078 / sdk-php#341. This is a proposed bounded first implementation, not an advertised capability, accepted implementation, release selection or performance result. Source reviewed: Native
b7337c1f(same runtime as published 3.3.2) and PHP SDK 3.11.0fbc64ef9. Existing #1060/#1061/#1062/#1067 remain the ordinary carried-poll contracts.Canonical preparation, rather than a guessed result event
Add a dedicated authenticated
POST /api/worker/activity-tasks/{task_id}/continuation/prepare. Its version-1 request contains the ordinary activity completion authority/result plushistory_after_sequence: 0. It is a bounded read of one consistent database snapshot, with no activity completion, history append, task claim, lease extension, capacity reservation or user-billable workflow/action. Backend/read/decoder failures remain errors, not eligibility misses.A prepared answer contains an opaque authenticated token, the complete ordered current worker history, and the exact proposed
ActivityCompletedevent. The event includes its Server-assigned ID, sequence, every payload field, task snapshot, result/codec identity and UTC microsecondrecorded_at. The corresponding activityclosed_atmust also be fixed by that preparation; synthesizing only the outer event time is insufficient. On acceptance these exact values are persisted and delivered by all later history routes. Thus a workflow branching onreplayNow()replays the same branch.V1 deliberately requires a fresh complete history from sequence 1, without sticky/cache substitution. A nonzero cursor declines as
unsupported_history_cursor. This keeps the prefix proof exact before adding an incremental-tail protocol. The worker replays only the returned complete prefix followed by the returned canonical event. Unknown, imported/gapped, untimed or external-payload history is ineligible initially.The token binds version, process issuer, namespace/run/queue, activity/task/attempt IDs, owner, registration incarnation/fence, original lease/deadlines, exact normalized inline Avro result, canonical event, proposed future workflow-task identity, complete prefix count and Server-internal commitment, limits and expiry. The SDK treats it as opaque; it never reconstructs the committed bytes or invents the event time. Commit rereads and compares the complete ordered current prefix, not merely its count or last event ID. The commitment serialization must have three-backend equivalence controls before implementation is accepted.
Use the existing locked HMAC-SHA256 primitive with a new process-local cryptographically random 256-bit issuer key, domain separated for this token; do not reuse a worker-visible authentication token or invent a signature algorithm. No deployment secret or durable preparation row is needed. Verification uses the library's constant-time MAC verifier before parsing trusted token contents. Restart, another issuer, key loss, expiry or a bad MAC makes the optimization unavailable; it cannot authorize a write. All ordinary authenticated completion authority is independently rechecked. Never log the token or result.
At most five monotonic seconds from preparation issuance are available, shortened by every original lease and applicable activity/cooperative deadline. Wall-clock rollback must not extend this window. No renewal or grace is introduced. A repeated prepare may return a different canonical candidate; that is safe while no completion has been sent. Once a completion outcome is unknown, freeze that exact completion request and retry it; do not prepare or execute a new candidate until the outcome is resolved.
Completion and truthful outcomes
Add dedicated
POST /api/worker/activity-tasks/{task_id}/continuation/complete, so an older Server cannot silently ignore the extension. Request fields are version, token, ordinary activity authority/result, the proposed workflow commands, and optionally the existing same-workeractivity_poll. Advertise a structuredactivity_workflow_continuationcapability with version 1, both routes and effective limits only after implementation and compatibility qualification. SDK usage remains explicitly opt-in and checks this task's capability. A missing route/capability causes ordinary completion and polling; it is never reported as an accepted continuation.schedule_activity,start_timer,complete_workflowandrecord_version_marker, through their existing validators and apply paths. It introduces no deferred projection refresh, relaxed durability or alternate command executor. Complete the workflow task before any optional eager activity claim. Reuse Native: eager activity dispatch for a poll carried on the workflow task completion #1060's same-worker activity-poll rules and preserve Native: carry the next workflow poll on the workflow task completion #1062's one-grant precedence. No new eager delivery semantics are invented.continuation: {version:1, outcome:"declined", reason:...}with the actual ordinary completion outcome. Initial reasons: unavailable/expired preparation, unsupported cursor or payload, prefix changed, competing work, parallel group, cancellation transition, worker admission/capacity unavailable, unsupported command, or response budget unavailable. A cancellation/stale/run-close refusal that makes the activity completion itself invalid still refuses it.recorded:falseonly after confirmed rollback. An uncertain COMMIT is explicitlyoutcome_unknown; the SDK retries the exact request without rerunning the activity or speculative workflow.continuation: {version:1, outcome:"accepted", workflow_task_id, workflow_task_attempt}only after the outer COMMIT. No omitted field, stale-attempt receipt, 2xx response alone or ordinary carried poll is proof of acceptance.A durable exact-request receipt, stored in that same transaction, binds the normalized complete request and the accepted/declined decision, result event and authored command/event identities. A matching retry returns that committed decision without more events or commands, including after token expiry or process restart. A changed request returns 409
conflicting_completion; it cannot inherit today's permissive ordinary stale-activity receipt. Existing ordinary completion semantics are unchanged. Authenticate the retry and bind namespace/owner before returning it. Any carried/eager lease answer must be freshly revalidated; a stale delivery is omitted with an explicit reason, never re-granted from an old receipt.Hard bounds and rollback state
Initial maxima: 64 existing prefix events / 256 KiB serialized prefix; 64 KiB normalized inline Avro result; 96 KiB canonical result event; 16 commands / 64 KiB serialized commands; 128 KiB decoded token / 192 KiB encoded token; 512 KiB completion request; 1 MiB preparation or completion reply. Effective limits are the minimum of these and all existing configured request, history, payload, structural, admission and response limits. Never truncate history, omit a command, resolve an external object as a side effect, increase a limit, or turn a budget failure into an eligible fast path. A preparation over its eligibility size budget simply supplies no token; a malformed/oversize submitted completion is an explicit error before writing.
No per-prepare persistent object, new repair coordinator or unbounded token registry is introduced. The existing completed-task receipt retention governs completion retries; after normal task/history retention, return the established missing-record outcome, never a fabricated success. The schema/storage design must show where the exact continuation receipt is retained and its bounded size before coding the producer.
The continuation savepoint must restore history/sequence totals, summaries, poll-pair state, task/registration claims, projections, activity/timer/local state, external claims, transaction-local tags, deferred effects and notifications. Reuse of today's poll savepoint is insufficient. Metering/admission work already charged stays consumed, matching the existing budget contract; no budget reset/retry loop is allowed. Wake notifications and response delivery occur after a real commit only.
Required causal controls and delivery order
Before accepting source: real HTTP plus SQLite, MySQL and PostgreSQL controls for byte-exact canonical event/time/closed_at and replayNow branching; full-prefix changes with unchanged count/last identity; every decline versus backend/decoder/budget error; foreign namespace/worker/incarnation, renewal/expiry/transit and process/issuer change; pending signals/updates/parallel/cancellation; each supported/unsupported command and bound; each rollback cache/tag/claim; one-slot/eager precedence; lost response, uncertain commit, exact retry and changed retry; external/oversize payload and missing tail; old Server/SDK behavior and per-task capability loss. Snapshot request/output/history/projection state, not only a response flag.
Design holds before a producer PR
This draft is not yet a frozen SDK handoff. Three source questions must be resolved:
Next: resolve these three questions against source, then freeze the versioned contract and implement Native first; qualify all three backends and the existing corpus before a matching SDK opt-in consumer and published producer/SDK tuple. No default enablement is selected. Preparation adds an HTTP read: it may still reduce locked transition work, but neither fewer round trips nor any speedup is claimed. The reporter's 14.59→10.76 seconds and 11→6 lock turns remain unverified reference evidence.
rmcdaniel commentedon Nov 9, 2026
General source-review addendum to contract draft v1. No producer or SDK code is implemented or selected here.
Published SDK 3.11.0 source narrows the replay-input question.
Replayer::replayconsumes the handler, ordered history, decoded input, default task queue, codec/feature settings, and these task fields:workflow_type,workflow_id,run_id,cancellation_request,cancel_requested, andworkflow_command_id(falling back totask_id). Its direct task reads do not uselease_expires_atorserver_time. Therefore a prospective task must bind its real ID/command identity, but the design must not invent a past lease start merely to satisfy replay. Actual lease/capacity/fence admission still belongs to commit.An important restriction follows: do not call ordinary
Worker::replayWorkflowClaimfor speculation. That helper can refresh cancellation history, checkpoint prepared-local prefixes, prepare/deliver cancellation scopes, and execute local callbacks. The new consumer needs a side-effect-free Replayer invocation with no local executor or preparation/checkpoint callbacks. V1 should decline scoped/cooperative delivery, pending messages/updates, standalone activities and any unsupported intent; no local activity callback, failure request or speculative command may escape. The per-task occurrence-deadline capability and other relevant feature settings must be bound to the prepared replay input. A fresh handler/codec/configuration mismatch is not an accepted continuation.The current Native storage already has one
dw_task_completionsreceipt per task, with task-ID primary key and task-delete cascade: TEXT on SQLite, JSON on MySQL/MariaDB and JSONB on PostgreSQL.Store::record_completioninserts it in the transition transaction. This supplies a plausible storage location for a new explicitly versioned continuation request/decision wrapper without a new preparation table. It does not prove compatibility: the dedicated endpoint must read/compare that wrapper before the ordinary stale-attempt path, while ordinary completion behavior stays unchanged. Bound the complete receipt, preserve normal task retention, and test absent/legacy/corrupt wrappers, exact/changed retry, lost commit reply, process restart and downgrade/re-upgrade before accepting that direction.Source also confirms that activity heartbeats and completion use the same database transition serialization (SQLite BEGIN IMMEDIATE, PostgreSQL transaction advisory lock, MySQL ownership-row lock). The continuation's future targeted claim must nevertheless preserve dispatch priority/fairness and exact original authority. A prepared ID alone cannot bypass the dispatcher. Whether to admit only when normal dispatch selects that task, or define and qualify an explicit bounded scheduling change, remains a design hold; there is no measured performance result supporting a broader bypass.
Next: settle that claim/fairness policy and the receipt/rollback compatibility controls, then freeze the precise replay-input schema and versioned producer contract before a Native PR or SDK handoff. The existing four-command v1 bound and original #1078 acceptance remain open. #1115 is an independent #1077 row-read reduction, not this feature. Source-only review; no PHP execution or continuation experiment was run.
rmcdaniel commentedon Nov 9, 2026
General decision: contract v2 for #1078 / sdk-php#341. This replaces the prepare-token parts of draft v1 and settles the three design holds and the fairness hold from the addendum. It is still a proposed contract: there is no producer code, advertised capability or speedup claim yet.
What changes from v1
v1 issued a signed token and a pre-assigned
ActivityCompletedevent so the worker could replay against the exact future timestamp. That needed a new process-local HMAC issuer key, a five-second preparation window, pre-fixedclosed_at, and token expiry/restart rules.v2 removes the token and the pre-assigned event. The only reason for them was that workflow code can branch on
replayNow(). The SDK can detect that directly:WorkflowContext::replayNow()already goes throughBusinessReplayClock. During speculation the SDK marks the speculativeActivityCompletedboundary as clock-unavailable. If the workflow reads business time at or after that boundary, speculation stops and the worker sends an ordinary completion. A worker never guesses a timestamp, and the Server keeps assigning event time andclosed_atexactly as it does today.Trade-off: a workflow that reads
replayNow()after an activity result never takes the fast path. That is a performance limit, not a correctness risk. Widening it later would need the v1-style canonical-event preparation, measured first.Routes
POST /api/worker/activity-tasks/{task_id}/continuation/history: a read under the normal activity-task authority. It returns the run's complete ordered worker history,through_sequence, the last event's ID, the replay task fields (workflow_type,workflow_id,run_id, input,cancel_requested/cancellation_request) and the effective limits. No write, claim, lease change or billable action. It declines with a reason when the run is ineligible: closed, imported/gapped/untimed history, external payloads, over budget, cancellation requested, or pending workflow/message/update work.POST /api/worker/activity-tasks/{task_id}/continuation/complete: the ordinary activity completion fields pluscontinuation: {version: 1, history_through_sequence, history_last_event_id, commands}and optionally the existing same-workeractivity_poll. A dedicated route means an older Server can't silently ignore the extension.Design holds resolved
Replayer::replayreads the handler, ordered history, decoded input, codec/feature settings andworkflow_type,workflow_id,run_id,cancellation_request,cancel_requestedandworkflow_command_id/task_id. The history route returns all of them except the task identity. At commit the Server rechecks run open, no cancellation request and an unchanged history (next item).history_through_sequenceand the event at that sequence to havehistory_last_event_id. Imported/gapped histories are declined at both routes, so equality of the last sequence and ID proves the whole prefix is unchanged. A three-backend control must still cover an event appended between the history read and commit.workflow_command_id/task_idonly for workflow-stream command identities (appendWorkflowStream/finishWorkflowStream), and stream commands are outside the v1 command set. The SDK speculates with no task identity and abandons the fast path if any stream command is reached. Lease fields are not replay inputs.dw_task_completionsrow, in the same transaction. The dedicated route reads it before the ordinary stale-attempt path: an identical retry returns the recorded decision without new events; a changed request returns 409conflicting_completion. The receipt stores the normalized request hash, the decision, and the created event/command IDs, not the full payload, so its size is bounded. Retention follows the task row. Downgrade: an older Server ignores the wrapper key, because ordinary completion still reads its existing fields. Controls are needed for absent, legacy and corrupt wrappers.Kept from v1
Savepoint before the result write. A known eligibility miss rolls back the whole savepoint and then commits the ordinary completion, returning
continuation.outcome = "declined"with a reason. Malformed requests and backend, decoder, budget or invariant failures are errors, not declines. An uncertain commit isoutcome_unknownand the SDK retries the exact request.acceptedis returned only after commit. The v1 command set is unchanged (schedule_activity,start_timer,complete_workflow,record_version_marker), through existing validators. v1 byte/event/command limits are kept, minus the token limits. Rollback must restore every cache, tag and claim. Notifications fire only after commit. Real HTTP controls on SQLite, MySQL and PostgreSQL are required before acceptance.SDK obligations (sdk-php#341)
Speculate with a side-effect-free
Replayercall: no local-activity executor, preparation/checkpoint callbacks or cancellation-scope delivery, and neverWorker::replayWorkflowClaim. The speculative event carries the worker's own result bytes and no time. Abandon speculation on a business-clock read at or after that boundary, a stream command, any command outside the v1 set, scopes, groups or pending messages. Keep it opt-in, gated on the advertisedactivity_workflow_continuationcapability.Next: Native implementation PR (history route, complete route, receipt wrapper, savepoint), with the controls above on all three backends. Then the SDK consumer, then a published producer/SDK tuple and a measured A/B before any speedup claim.
rmcdaniel commentedon Nov 9, 2026
Native producer implemented in draft #1116 (head
8d0a2429). One refinement to v2 during implementation: the commands are applied by the ordinary workflow-task completion in a second locked transaction, right after the activity completion's carried claim leases the resumed task, instead of a savepoint inside one transaction. That matches an ordinary poll-then-complete, keeps every existing validator and receipt, and leaves dispatch order unchanged. The commit-time check also requires that no workflow task is already waiting, because a waiting task can carry a signal the worker never replayed. Local SQLite: 471 library and 754 integration tests pass, including 5 new continuation tests. MySQL/MariaDB/PostgreSQL are running in CI. Next: SDK consumer in sdk-php#341.rmcdaniel commentedon Nov 9, 2026
Producer merged: #1116 → main
6f7dc935(15:10 UTC), with all 62 checks green across SQLite, MySQL, MariaDB and PostgreSQL. SDK consumer merged: sdk-php#373. Next: a Native minor release (3.4.0) carryingactivity_workflow_continuation, an SDK release, installed qualification of the published pair, then a measured A/B. No speedup is claimed yet.rmcdaniel commentedon Nov 9, 2026
General release audit — November 9, 22:14 UTC. Source #1116 and the SDK consumer are merged, but the Native 3.4 / matching SDK publication is held while General repairs the remaining accepted-contract gaps. No continuation producer release has been published.
The source deliberately commits the activity/carried claim in
Store::complete_activity_continuing, then applies commands via a secondRuntime::complete_workflowcall in the HTTP handler. The prefix check precedes the first commit; it is not checked again under the command transaction. It also removes the continuation request before the activity receipt is recorded, and retries return ordinarystale_attemptinstead of the v2 exact-request decision/conflict wrapper. Existing five HTTP tests do not establish the inter-transaction concurrency, exact retry, fault rollback or cached-state restoration requirements. Passing those tests therefore does not complete v2 or the original acceptance.General owns the next action: reproduce the transaction/recovery boundary, implement the single locked result/commands transition with truthful failure and exact bounded receipt, and qualify real HTTP controls on SQLite, MySQL and PostgreSQL. Then qualify the published producer/SDK pair and measure A/B. Keep opt-in/default-off; no speedup or delivered atomic behavior is claimed. Source CI successes remain valid within their tested scope.
rmcdaniel commentedon Nov 9, 2026
Atomic producer repair is in #1118, head 0eb4413. The original two acceptance regressions reproduce on main; the repaired eleven real-HTTP controls now pass on SQLite, MySQL 8.0.46 and PostgreSQL 17.11. Controls cover exact/concurrent/declined retry, whole-result/marker/claim/receipt rollback under an injected backend fault, original authority after command refusal, corrupt/absent/legacy receipts and the combined history budget. The deterministic signal/read atomicity control also passes. Full CI remains required; Native/SDK publication stays held. General owns terminal qualification and the published-pair/installed/A-B follow-through. Own disposable database containers and isolated network are removed; no production state changed.
rmcdaniel commentedon Nov 11, 2026
Release status, 2026-11-11 15:20 UTC (General): Native Server 3.4.0 is published — tag
3.4.0→9a03565e(#1119), indexsha256:ba6aefb2f034936e8e3468331b0f9dfbe6fb6d59f71dc527eb599f485062f79ain Docker Hub and GHCR, native-release run 43043663498. The matching PHP SDK 3.12.0 is released and on Packagist (sdk-php#374).Next: corpus + four-cell Compose qualification from the runner commit (#1121), then rolling-alias promotion and the GitHub Release. After that, installed acceptance of the published 3.4.0 / SDK 3.12.0 pair and the measured A/B under sdk-php#341. No speedup claimed yet.
rmcdaniel commentedon Nov 11, 2026
Native Server 3.4.0 is released and promoted (General, 2026-11-11 16:15 UTC). GitHub Release: https://github.com/durable-workflow/server/releases/tag/3.4.0. Rolling tags
3,3.4andlatestin Docker Hub and GHCR name indexsha256:ba6aefb2…(alias promotion run 43055015425; corpus 43052174095 and four-cell Compose 43052195393 passed). PHP SDK 3.12.0 is on Packagist.Remaining: Helm chart default → 3.4.0 (server agent), then installed acceptance of the published 3.4.0 / SDK 3.12.0 pair and the measured A/B under sdk-php#341. No speedup claimed yet.
rmcdaniel commentedon Nov 11, 2026
Installed acceptance of the published 3.4.0 / SDK 3.12.0 pair: correct (120/120 continuations accepted, all results exact) but ~19× slower when enabled because accepted continuations don't wake activity pollers (~5 s per step). Tracked in #1129; release notes now advise keeping continuations off. Details: durable-workflow/sdk-php#341.
rmcdaniel commentedon Nov 11, 2026
Published-pair acceptance done: Server 3.4.0 + PHP SDK 3.12.1 are correct with continuations on (120/120 accepted), and the 3.12.0 latency bug is fixed (#1129). A/B on a single-host local MySQL stack shows no measurable difference (562 vs 563 ms median for 3 activities). This issue stays open for the performance-report acceptance under realistic network latency and load, where saved round trips would show; no speedup is claimed yet.