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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions .github/workflows/native-image-parity.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@ on:
pull_request:
paths:
- scripts/conformance/server-parity/native-image-corpus.sh
- scripts/conformance/server-parity/update-validation-sqlite.sh
- scripts/conformance/server-parity/php-maintenance-window.sh
- .github/workflows/native-image-parity.yml

permissions:
Expand All @@ -36,7 +34,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 75
env:
# The name the shared PHP maintenance window expects.
# The corpus part's own container and network name.
RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}
LOCAL_IMAGE: dw-native-corpus-${{ github.run_id }}-${{ github.run_attempt }}:corpus
steps:
Expand All @@ -47,7 +45,7 @@ jobs:
run: |
set -euo pipefail
bash scripts/ci/pull-images.sh \
"$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.4.json)" \
"$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.5.json)" \
composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee

- name: Set up Docker Buildx
Expand Down
2 changes: 0 additions & 2 deletions .github/workflows/phpunit-feature.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,6 @@ jobs:
- name: Require safe PHP image build context
run: node --test tests/Unit/PhpImageContextTest.mjs

- name: Restore PHP's SQLite maintenance loop on every path
run: node --test tests/Unit/PhpMaintenanceWindowTest.mjs
- name: Restore the patch cohort's maintenance loop on every path
run: node --test tests/Unit/PatchCohortMaintenanceTest.mjs

Expand Down
80 changes: 30 additions & 50 deletions .github/workflows/server-parity.yml

Large diffs are not rendered by default.

64 changes: 64 additions & 0 deletions docs/rust-server-port-checkpoints/2026-10-27-reference-2115.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# October 27, the parity reference moves to published 2.11.5

- **Before.** The Shared Server fixtures compared native and embedded
against published Server 2.11.4 (Core 2.14.2), with two SQLite
workarounds: `update-validation` and `service-calls` recorded apart from
the main corpus, with PHP's maintenance loop stopped (#723, #796).
- **Reference.** Published Server 2.11.5, Core 2.15.1, as General verified
it (release run 40478372197):
- source `721b2831975cc898cc39d7528c5ba7e8441fe744`;
- index `sha256:f4f2ec64a6522ee18a31542a30554873dbaa2f5d3af3ccb52deb914591699c20`,
identical on Docker Hub and GHCR;
- linux/amd64 `sha256:bcc672859a399d1df234fc304531ab06b7bfa8acad7cb2c9da95f8e7ab5c98b4`;
- linux/arm64 `sha256:3160f4f58ab9b144705c3e4d5d97b0881b0891c1f00e9ab6b99b3245a8c3b26a`.

`tests/Fixtures/ServerParityBaselines/php-2.11.5.json` records it. The SDK,
CLI, Waterline and sample-app pins are unchanged. The takeover cohorts are
not affected: each names its own source release and layout.
- **The SQLite maintenance pause is gone.** 2.11.5 carries the
update-validation lock fixes (#726) and atomic service admission (#796).
Cloud's installed #726 acceptance passed 15 of 15 original SQLite
records with the maintenance loop running, and General's #796 check
passed 10 of 10. So the SQLite job records both fixtures in its main
corpus, with the loop running, as the MySQL and PostgreSQL jobs always
have. `update-validation-sqlite.sh`, `php-maintenance-window.sh` and their
tests are removed. The native image corpus does the same.
- **Expectation changes**, all 2.11.5 behavior:
- `service-calls`: the "replay other caller" step returns. An idempotency
key belongs to the operation (Workflow #931): another caller namespace
reusing it gets `403 service_operation_forbidden`, and no call or audit
row is written. 91 requests.
- `repair-pass`: the report adds Core 2.15's linked service-call counts
(`service_call_linked_candidates`, `service_call_links_reconciled`,
`service_call_linked_failures`).
- `bridge-adapters`: a second bounded answer for the #777 foreign-instance
defect. Core 2.15.1 checks the namespace first, but 2.11.5's bridge
controller still echoes "cannot be reused with a different namespace"
for both foreign starts. Server #811 (merged after 2.11.5) answers the
intended "is unavailable". The intended answer is unchanged.
- **Native.**
- Refuses an idempotency-key replay from anyone but the call's owner,
matching Core 2.15.1's `ServiceCallIdempotency` and 2.11.5's 403.
- The repair report adds the three linked counts as zeros. Native binds no
call to a workflow run: workflow-bound handlers answer 501 (#794).
- Takeover now refuses a PHP database holding an open call bound to a
workflow run (`upgrade_source_service_calls_workflow_bound_open`), which
PHP closes when its run ends and native never would. The service-call
inspection reports the count as `workflow_bound_open`. Closed
workflow-bound calls and open calls of other bindings still take over.
- **Checked** (local SQLite, `native-image-corpus.sh` with a native image
built from this branch, published 2.11.5 and embedded 2.11.5):
- first run: native and embedded passed all 73 cases; 2.11.5 passed 71,
including `update-validation` and `service-calls` with the maintenance
loop running. It failed `repair-pass` (the three new counts) and
`bridge-adapters` (`start_other_type`), the two changes above;
- after them, both fixtures pass against the running 2.11.5 server;
- second full run, native image rebuilt with them (runner revision
`73d43c8c`, the same tree): published 2.11.5, native and embedded each
pass all 73 cases, and the three records compare equal;
- native: `cargo test --lib` for repair_pass, waterline_import and the
upgrade module (60 tests) pass, and the service-calls execution tests
pass 8/8 on SQLite. CI runs them on all four databases.
- **Next.**
- The canonical history-byte unit (#770) lands with the first reference
that carries #812 (`rust-325-wip/canonical-history-bytes`).
31 changes: 23 additions & 8 deletions docs/rust-server-port.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,33 +39,48 @@ Updated October 27, 2026.
- [#803](https://github.com/durable-workflow/server/pull/803): the native
release-candidate image (`Dockerfile.native`), its release workflow and
the takeover runbook's image commands.
- The parity reference moves to published 2.11.4
([checkpoint](rust-server-port-checkpoints/2026-10-27-reference-2114.md)).
- The parity reference moved to published 2.11.4 (#805,
[checkpoint](rust-server-port-checkpoints/2026-10-27-reference-2114.md)),
and moves to published 2.11.5, without the SQLite maintenance pause
([checkpoint](rust-server-port-checkpoints/2026-10-27-reference-2115.md)).
- [#814](https://github.com/durable-workflow/server/pull/814): the shared
fixtures accept published PHP's typed `429 long_poll_capacity_exhausted`
poll refusal, and retry a locked Composer install after a transient
download failure (the two #813 merge-group failures).
- Native's v1 projection byte meter in Core's canonical unit (#770) is
prepared on `rust-325-wip/canonical-history-bytes`. It lands with the
first reference that carries #812, because `waterline-import` records
`history_size_bytes` from the reference.
- **Still refused at takeover:** workflow-to-workflow messages
(`workflow_messages`, written only by embedded PHP workflows) and external
payload references. PHP keeps references in about 30 table and column
locations in their URI form, so taking them over is its own project; the
recommendation is that the release candidate keeps refusing them.
- **Shared Server fixtures.** Native, embedded and published PHP 2.11.4 are
- **Shared Server fixtures.** Native, embedded and published PHP 2.11.5 are
compared on SQLite, MySQL 8.0, MariaDB 10.11 and PostgreSQL 17. Where the
published reference itself misbehaves, an explicit boundary separates it
from native, and the PHP owner has an issue for each case. Recent ones:
- Workflow #931, an idempotency key repeated from another caller namespace
(left out of `service-calls` until a release carries the correction);
- #796, `service-calls` under full-corpus SQLite load;
- #777, a bridge start for an instance another namespace holds: 2.11.1
to 2.11.4 name that namespace's type, 2.11.5 the namespace difference;
#811 (after 2.11.5) answers "unavailable";
- #747, a bridge update to an SDK-worker workflow;
- #794, workflow-bound Nexus handlers.

Closed by 2.11.5: Workflow #931 (`service-calls` repeats a key from
another caller again, now refused 403), and the SQLite maintenance pause
for `update-validation` and `service-calls` (#723, #726, #796).

No expectation is edited to make native pass.
- **Parity surface.** Native answers 174 of the 175 API route methods in
published 2.11.1's route table, which 2.11.4 leaves unchanged. Eight of those are the prepared
published 2.11.1's route table, which 2.11.4 and 2.11.5 leave unchanged. Eight of those are the prepared
local-activity routes, which it answers with PHP's refusal for a bridge
without the prepared role. Nexus workflow-bound handler kinds answer 501
until #794 settles their contract. An execution test sends each route
through the native router, and the ones still answering
`501 rust_capability_not_implemented` must match
[`unserved-php-routes.v2.11.1.txt`](../rust/tests/fixtures/unserved-php-routes.v2.11.1.txt).
- **Remaining for the release candidate:**
- landing the 2.11.4 reference move;
- landing the 2.11.5 reference move;
- the scope question with the operator: prepared local activities,
workflow-bound Nexus handlers and external payload takeover;
- publishing the native image (General runs the release);
Expand Down
9 changes: 9 additions & 0 deletions docs/rust-server-upgrade-takeover.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,15 @@ ones, without naming calls. A pending dispatch makes the inspection
`upgrade_source_service_call_dispatches_pending` before any change. Let PHP
finish them before the stop. Completed dispatches do not block takeover.

The same report counts open service calls bound to a workflow run
(`workflow_bound_open`): calls a Nexus workflow-bound handler accepted that are
still `pending`, `accepted` or `started`. PHP closes such a call when its linked
run ends, and 2.11.5's maintenance pass repairs one it missed. Native serves no
workflow-bound handler yet (Server #794) and closes no linked call, so one
would stay open after takeover. Any makes the inspection `unqualified`, and
takeover refuses with `upgrade_source_service_calls_workflow_bound_open` before
any change. Let the linked runs finish, or cancel the calls, before the stop.

## Exclusive transaction per backend

The marker, the fingerprint check and the schema changes must not interleave
Expand Down
6 changes: 6 additions & 0 deletions rust/src/runtime/repair_pass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ fn empty_report(request: &PassRequest, throttled: bool) -> Map<String, Value> {
// never has a dispatch to enqueue.
"service_call_dispatch_candidates": 0, "service_call_dispatches_enqueued": 0,
"service_call_dispatch_failures": [],
// Published 2.11.5 (Core 2.15) also closes open calls bound to a
// workflow run whose run has ended (LinkedServiceCallCloser). Native
// binds no call to a workflow run: it refuses workflow-bound handlers
// (Server #794).
"service_call_linked_candidates": 0, "service_call_links_reconciled": 0,
"service_call_linked_failures": [],
});
match value {
Value::Object(map) => map,
Expand Down
31 changes: 29 additions & 2 deletions rust/src/runtime/service_calls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1069,6 +1069,26 @@ fn not_implemented() -> RuntimeError {
super::refuse(StatusCode::NOT_IMPLEMENTED, "rust_capability_not_implemented")
}

/// `serviceOperationForbidden`: no ownership or policy detail, no audit row.
fn service_operation_forbidden() -> RuntimeError {
RuntimeError::Protocol {
status: StatusCode::FORBIDDEN,
response: json!({"reason": "service_operation_forbidden",
"message": "The service operation is not authorized for this caller."}),
}
}

/// The replaying request is the call's owner: the same caller namespace and
/// caller workflow instance and run, through the same catalog objects.
fn replay_owner(call: &Call, execution: &Execution, [endpoint, service, operation]: [&String; 3]) -> bool {
call.caller_namespace.as_ref() == Some(&execution.caller_namespace)
&& call.caller_instance == execution.caller_instance
&& call.caller_run == execution.caller_run
&& call.endpoint_id.as_ref() == Some(endpoint)
&& call.service_id.as_ref() == Some(service)
&& call.operation_id.as_ref() == Some(operation)
}

/// `ServiceCallDeadlinePolicy` failures at execute (`assertDeadlinePolicy`).
fn invalid(field: &str, message: &str) -> RuntimeError {
laravel_rules::failure(vec![(field.to_owned(), vec![message.to_owned()])]).expect("a message")
Expand Down Expand Up @@ -1466,12 +1486,19 @@ store_methods! {
mode,
};
let kind = binding_kind(operation.kind());
// `ServiceCallIdempotency::find`: a key belongs to the target
// operation, not to a caller. The first call under it is found
// whoever made it, and only its owner may replay it (Workflow #931).
if let Some(key) = execution.idempotency_key.as_deref().map(str::trim).filter(|key| !key.is_empty())
&& let Some(mut prior) = Self::query("SELECT c.*,d.call_deadline_at FROM workflow_service_calls c LEFT JOIN dw_service_call_deadlines d ON d.service_call_id=c.id WHERE c.namespace=$1 AND c.workflow_service_operation_id=$2 AND c.caller_namespace=$3 AND c.idempotency_key=$4 ORDER BY c.created_at,c.id LIMIT 1")
.bind(self.namespace.as_ref()).bind(operation.row.id.as_str()).bind(execution.caller_namespace.as_str()).bind(key)
&& let Some(mut prior) = Self::query("SELECT c.*,d.call_deadline_at FROM workflow_service_calls c LEFT JOIN dw_service_call_deadlines d ON d.service_call_id=c.id WHERE c.target_namespace=$1 AND c.endpoint_name=$2 AND c.service_name=$3 AND c.operation_name=$4 AND c.idempotency_key=$5 ORDER BY c.created_at,c.id LIMIT 1")
.bind(self.namespace.as_ref()).bind(endpoint.row.name.as_str()).bind(service.row.name.as_str())
.bind(operation.row.name.as_str()).bind(key)
.fetch_optional(&mut *tx).await?
.map(|row| Self::service_call_row(&row)).transpose()?
{
if !replay_owner(&prior, &execution, [&endpoint.row.id, &service.row.id, &operation.row.id]) {
return Err(service_operation_forbidden());
}
// `authorizeReplay`: authorization and the circuit, no limits.
let decision = request.base_decision();
if !decision.allowed() {
Expand Down
2 changes: 1 addition & 1 deletion rust/src/runtime/upgrade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ fn report(
report["status"] = json!("unqualified");
}
report["storage_inspection"] = storage_inspection;
if dispatch_inspection["pending"] != 0 {
if dispatch_inspection["pending"] != 0 || dispatch_inspection["workflow_bound_open"] != 0 {
report["status"] = json!("unqualified");
}
report["service_call_dispatch_inspection"] = dispatch_inspection;
Expand Down
23 changes: 20 additions & 3 deletions rust/src/runtime/upgrade/dispatch_scan.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
//! PHP 2.11.3's service-call dispatch outbox. PHP schedules a call's handler
//! there and a later pass dispatches it; native runs the handler when it
//! accepts the call, so it cannot resume a dispatch that is still scheduled or
//! claimed. A completed dispatch is only PHP's bookkeeping. The report counts
//! rows and never names calls.
//! claimed. A completed dispatch is only PHP's bookkeeping.
//!
//! It also counts open calls bound to a workflow run (Nexus workflow-bound
//! handlers). PHP closes such a call when its linked run ends, and 2.11.5's
//! maintenance pass repairs one it missed (LinkedServiceCallCloser). Native
//! serves no workflow-bound handler (Server #794) and closes no linked call,
//! so one would stay open forever after takeover. The report counts rows and
//! never names calls.
use crate::runtime::{Result, backend::Backend};
use serde_json::{Value, json};
use sqlx::{Executor, IntoArguments};
use std::collections::BTreeMap;

const PENDING: &str = "SELECT COUNT(*) AS n FROM workflow_service_call_dispatches WHERE state<>'completed'";
const WORKFLOW_BOUND_OPEN: &str = "SELECT COUNT(*) AS n FROM workflow_service_calls \
WHERE resolved_binding_kind='workflow_run' AND status IN ('pending','accepted','started')";

pub(super) async fn inspect<DB: Backend>(
connection: &mut DB::Connection,
Expand All @@ -27,5 +35,14 @@ where
.await?;
DB::number(&row, "n")?
};
Ok(json!({"dispatches": rows, "pending": pending}))
let calls = counts.get("workflow_service_calls").copied().unwrap_or(0);
let workflow_bound_open = if calls == 0 {
0
} else {
let row = sqlx::query(sqlx::AssertSqlSafe(DB::statement(WORKFLOW_BOUND_OPEN)))
.fetch_one(&mut *connection)
.await?;
DB::number(&row, "n")?
};
Ok(json!({"dispatches": rows, "pending": pending, "workflow_bound_open": workflow_bound_open}))
}
8 changes: 8 additions & 0 deletions rust/src/runtime/upgrade/takeover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,14 @@ fn qualified(inventory: &Value) -> Result<&str> {
"upgrade_source_service_call_dispatches_pending",
));
}
// An open call bound to a workflow run would never close under native,
// which serves no workflow-bound handler (Server #794).
if inventory["service_call_dispatch_inspection"]["workflow_bound_open"] != 0 {
return Err(refuse(
StatusCode::CONFLICT,
"upgrade_source_service_calls_workflow_bound_open",
));
}
// A schedule native cannot run would stop firing after takeover.
if inventory["schedule_inspection"]["unsupported"] != 0 {
return Err(refuse(
Expand Down
Loading
Loading