Skip to content

Serve synchronous update validation like Server 2.11.1 - #712

Open
rmcdaniel wants to merge 13 commits into
mainfrom
rust-325-update-validation
Open

rmcdaniel wants to merge 13 commits into
mainfrom
rust-325-update-validation

Conversation

@rmcdaniel

@rmcdaniel rmcdaniel commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

Part of #325.

Problem

The Python SDK lets a workflow declare update validators. A worker with validators registers only when the server advertises update_validation_tasks and its synchronous_update_validation contract. Native advertised neither, and refused a validated update with 422 update_validation_not_implemented. So a Python worker with validators could not run against native.

Change

Native now serves synchronous update validation as published Server 2.11.1 does (ValidatedExternalWorkflowUpdateAdmission, WorkflowUpdateValidationTaskBroker):

  • Admission. For a live run with a complete durable contract and a declared update, native checks, in PHP's order: a recorded receipt for the request_id; whether the contract states validators at all (409 update_validator_contract_missing if not); the arguments; and a request_id. None of these refusals is recorded.

  • Waiting for the validator. The caller then waits for a validator without holding a transaction, up to DW_UPDATE_VALIDATION_TIMEOUT (10 s).

    • The validation task is keyed by run, update name and request_id, with a hash of the arguments.
    • Routing refusals answer 409.
    • Approval admits the update with its id equal to the task id.
    • A validator rejection records a rejected command and update with UpdateRejected and answers 422.
    • Any other worker failure answers 409. A timeout answers 504 with its reason: not claimed, worker lost, or execution timeout.
  • Worker routes.

    • POST /api/worker/update-validation-tasks/poll;
    • POST /api/worker/update-validation-tasks/{task}/approve;
    • POST /api/worker/update-validation-tasks/{task}/reject.

    They use Laravel's request rules, and completions are fenced like PHP's (owner, attempt, lease, worker freshness, terminal duplicates). A claim lasts DW_UPDATE_VALIDATION_LEASE_TIMEOUT (5 s). An expired lease can be claimed again with the next attempt. The task carries the run's replay history and its unsigned history export.

  • Multiplexed workflow poll. task_kinds is validated. With validation tasks requested, the poll refuses a worker without the capability, answers no_workflow_capability for a worker without workflow types, and alternates kinds through workflow_task_poll_cursors. Every workflow task names task_kind: "workflow". Workflow poll refusals now carry the worker protocol envelope, as PHP's do.

  • Poll request IDs (the contract Security asked for on Rust Server train: MySQL statement closes (#714), update validation, policy-map JSON, worker history presentation #723). A trimmed, non-empty poll_request_id on a workflow-task poll keeps published PHP's WorkflowTaskPollRequestStore contract:

    • Binding:
      • The ID binds to its normalized task-kind set; either order of the two kinds is one set.
      • Another set answers 409 poll_request_task_kinds_conflict with requested_task_kinds, bound_task_kinds and the ID. Nothing is replayed or claimed, and the existing lease stays held.
      • Keys cover namespace, queue, registered build, worker and ID.
    • Replay:
      • An empty or queue-changed answer replays for the pending window.
      • A workflow lease replays while its task stays leased to the worker at the same attempt, showing the current (heartbeat-renewed) lease.
      • A validation lease replays while its task stays leased at the delivered attempt.
      • An answer that is no longer deliverable polls afresh, so an expired validation lease is re-claimed at the next attempt under the same ID. poll_cached_task_expired is gone.
      • Answers and bindings expire on PHP's resultTtl schedule.
    • Each probe is one transaction:
      • It replays a concurrent duplicate's answer.
      • It creates the queue's workflow_task_poll_cursors row at update_validation if missing, and locks it.
      • It claims the kind the cursor names first, then the other, turns the cursor after a lease, and records the answer under the request ID.
      • Native already serializes write transactions on every backend, so these locks add defense in depth on top of that.
  • Advertisement. The capability, its contract and both timeouts are advertised, and the two DW_UPDATE_VALIDATION_* settings are now honored.

  • Found on the way.

    • An accepted update's receipt no longer carries accepted: true; PHP's result mapper sends accepted only when it is false.
    • A worker contract without update_validators now makes remote updates refuse, as on PHP. Current SDKs all send the field.

The decision record is docs/rust-server-port-checkpoints/2026-10-23-update-validation.md.

Fixture

update-validation records 49 steps against published PHP. They cover:

  • the refusals before validation;
  • a rejection through the multiplexed poll, and an approval through the dedicated poll with its fences;
  • retries;
  • three timeouts run side by side;
  • lease expiry and re-claim, and a worker failure;
  • multiplexed ordering and every routing refusal.

Waiting update requests run in child PHP processes. The contract normalizes identifiers (as labels, so reuse stays checked), timestamps and the probe's workflow prefix. It also checks that each timeout really waited.

Checks (poll request IDs)

  • Fixtures, published 2.11.1:
    • workflow-poll-request-replay records empty replay, lease replay after a heartbeat, a padded ID, and polling afresh after completion and after drain.
    • update-validation adds 8 steps: a multiplexed ID, its reordered retry, both other sets, and a default ID retried with both kinds.
    • Both pass 3/3 against native locally.
  • Native execution tests on SQLite, PostgreSQL and MySQL cover:
    • the reordered and padded retry, both conflict sets, and the default ID retried with both kinds;
    • an expired validation lease re-claimed at attempt 2 under its ID, and a completed validation delivery polling afresh;
    • concurrent duplicates sharing one delivery and one cursor turn, and concurrent probes leasing one task of each kind.
  • Counterfactuals on PostgreSQL. Each was run with a 50 ms pause that widens the race window:
    • reading the cursor before the probe's transaction fails the concurrent-probe test;
    • dropping the in-transaction replay gives a duplicate a second, different delivery.
  • Full native suites: lib 259 and execution 461 pass on all three databases.

Checks

  • Two published PHP 2.11.1 runs normalize identically, and native matches all 49 steps locally on SQLite.
  • ServerParityUpdateValidationContractTest: 22 tests, including 19 refused changes.
  • Native integration tests (update_validation, 3 tests) pass on SQLite, PostgreSQL and MySQL, with the other update test modules (28 tests per database).
  • Server-parity Node tests: 5,508 tests, 5,506 pass, 1 intended skip; the one failure (PhpImageContextTest) needs Docker inside the Node container.

Not done yet

An update whose durable contract declares a validator now waits for a
validator-capable worker to approve it, as published PHP does: admission
refusals in PHP's order, validation tasks keyed by run, update and request id,
the dedicated and multiplexed worker polls, fenced approve and reject routes,
lease expiry and re-claim, and 504 timeouts with their reasons. An accepted
update's receipt no longer carries accepted: true, as PHP's result mapper
drops it. The update-validation fixture records 49 steps against published
PHP; native matches all of them.
@rmcdaniel

Copy link
Copy Markdown
Member Author

End-to-end check with the published Python SDK (durable-workflow==2.10.0, the validator-capable SDK) against this branch's native binary, and the same script against published PHP 2.11.1:

  • A workflow declares @workflow.update("add") with @add.validator, which refuses negative values. The worker registers before the start, so the run records declared_update_validators.
  • update("add", [5], wait_for="completed", request_id="a1") is approved by the validator, accepted and completed on both servers.
  • update("add", [-1], request_id="a2") raises UpdateRejected: value must not be negative on both servers.
  • After a finish signal, both runs return {'values': [5]}.

Before this branch, the same worker could not register against native, because native advertised no update validation contract.

@rmcdaniel

Copy link
Copy Markdown
Member Author

Security review of exact 8b0a8795d8488ea0414acc3350c6adff6191db76: no security merge blocker found in the new synchronous validation boundary.

  • The three worker endpoints remain behind the worker-role route family and the request namespace is authorized before the namespace-scoped Runtime is selected.
  • Polling verifies the namespace registration, queue, freshness/capability and compatibility/fingerprint before a task is leased. Completion rechecks task namespace, terminal state, lease owner, attempt, expiry and current worker freshness.
  • The apparent read/update race is covered by the existing durable-transition serialization used by every Store::begin(): SQLite BEGIN IMMEDIATE, PostgreSQL's transaction advisory lock, and MySQL's locked ownership row. Claim, timeout and completion all use it.
  • Replay history/arguments are returned only after a task is claimed within that namespace. The externally requested history export remains signed; the internal worker replay bundle is intentionally unsigned, matching the PHP task contract.
  • Failure values are JSON-encoded and request bodies remain under the existing payload-limit middleware; stack traces are not persisted or returned by this implementation.

This is source acceptance only. The exact-head database/parity jobs are still running, and #712 does not qualify or replace #708/current-main integration, protected publication, multi-architecture, or credential gates.

@rmcdaniel

Copy link
Copy Markdown
Member Author

Security terminal hosted-evidence review at exact 8b0a8795d8488ea0414acc3350c6adff6191db76: all required checks now pass. That includes the published adapters job, all six codec/deadline database cells, all three MySQL/MariaDB execution cells, both PostgreSQL storage cells, execution-and-codec, the feature/source suites, repository contracts, CodeQL, Sonar, and both public-boundary scans. The candidate-only job is the intended skip. No failed or pending check remains on this head.

This is not yet a merge acceptance because the branch is eight commits behind current main (0667ba79f5). The intervening #711/#716 changes touch the PHP image/SQLite bootstrap/Apache keepalive paths and do not textually overlap #712's Rust/update-validation files, but the final candidate must integrate current main and repeat its required checks at the returned exact head. Preserve this fully green run as the pre-integration control. No source takeover, tag, image publication, or #673 disposition is implied.

@rmcdaniel

Copy link
Copy Markdown
Member Author

Current-main integration at exact head 3961dc2bf36cc640fe21aa81e928d4d15450a606: a clean merge of main 0667ba79f into the green pre-integration head 8b0a8795d. Main brings eight files (Dockerfile, Apache prefork config, SQLite bootstrap script, two CI checkers, first-run smoke, server reference and one Feature test) and no Rust or fixture changes. cargo check --all-targets passes locally. The 8b0a8795d run stays as the pre-integration control. Fresh hosted checks are queued.

@rmcdaniel

Copy link
Copy Markdown
Member Author

Included in train #723, at exact head 069270b4e6a920720243ff7779c483d2f51a67ba on main 960aa3051. This PR's earlier hosted results are preserved above. Its remaining runs on the pre-#707 head are cancelled to free runners, and it closes as merged through #723.

…blished 2.11.1

Fixture first for the poll_request_id contract of workflow-task polls.
One worker records an empty poll and its replay after work arrived, a
fresh id's lease and its replay, the replay after a workflow-task
heartbeat renewed the lease, a padded copy of the id, the same id after
its task completed while another was ready, and after nothing was left.

Published 2.11.1: the empty id replays empty; the lease replays with the
renewed lease; the padded id is the same id; a completed delivery polls
afresh and leases the next task; with nothing ready the id answers
empty. Native currently leases the empty id's retry, gives the fresh id
nothing, and refuses the drained id with 409 poll_cached_task_expired,
which PHP never sends. The unit test runs the contract on the recorded
observation and on six mutations, including those native answers.

Published 2.11.1 passes 3/3 locally with its maintenance loop running.
Native's workflow-poll poll_request_id differed from PHP's
WorkflowTaskPollRequestStore: an empty answer was not kept, so a retry
leased work that arrived meanwhile; a lease replayed its stale lease
time; the key ignored queue and build; and an ID whose task had closed
was refused with 409 poll_cached_task_expired, which PHP never sends.

A workflow poll with a trimmed, non-empty request ID now:
- binds the ID to its task kinds and keeps its answer in
  dw_query_cache, keyed by namespace, queue, registered build, worker
  and ID, as PHP's cache keys are;
- replays that answer while it is deliverable: an empty or
  queue-changed answer for the pending window (poll timeout + 5 s,
  capped at a minute), a lease while its task stays leased to the
  worker at the same attempt, with the task's current lease;
- polls afresh when the lease is no longer deliverable, so it leases
  the next ready task or answers empty;
- writes the lease's answer in the claim transaction, so a concurrent
  duplicate replays it instead of leasing another task, and records
  _server_poll_request_id in the task payload, as PHP's
  PollRequestLeaseBinding does.

Activity polls keep their receipts. The workflow poll, its heartbeat and
the activity status route now report times with microseconds, as PHP
does.

The workflow-poll-request-replay fixture passes 3/3 against native
locally, and a native execution test pins the same rules. Full native
SQLite suite: 253 library and 454 execution tests pass.
… rust-325-update-validation-poll

# Conflicts:
#	.github/workflows/server-parity.yml
The probe waits through the 10-second validation timeout, a 6-second lease
expiry, a 2-second completion wait and several short polls before PHP does
any work. On the published PHP SQLite runtime it exceeded the recorder's
generic 45-second probe limit while making its last calls (train #723,
job 120776618912: spawnSync php ETIMEDOUT). Expected results are
unchanged.
…perseded PHPUnit runs cancel

The update-validation probe's waiting child requests now retry PHP's
documented backend_lock_pressure refusal (nothing committed) with the same
rule as every other probe request. seconds stays the answered attempt's
own wait; retries are reported on stderr.

PHPUnit feature suite and Feature source qualification used a job-level
always(), which GitHub never cancels, so #724's superseded-run cancellation
left the old run holding the concurrency group. !cancelled() still runs
both jobs when a needed job failed, so the required checks stay fail-closed.
…or its PHP leg

The SQLite job's main corpus now excludes update-validation.json. A separate
script records that fixture for php, embedded and rust and compares the three.
Its published PHP record runs with PHP's maintenance loop stopped: under that
loop's SQLite writer contention, PHP 2.11.1 loses its own validation-timeout
and lease-reclaim writes to "database is locked" and its validation poll
answers without the typed lock-pressure refusal (10 of 15 local runs failed
with the loop running, 5 of 5 and 3 of 3 passed with it stopped). Expected
results are unchanged. MySQL and PostgreSQL keep the fixture in their main
corpus with their loops running.

The runner gains --exclude-fixture, which must name a selected fixture and
cannot be combined with --fixture. The maintenance window helper is #722's,
moved to a shared path. Tests cover the selection rules, the workflow wiring
and every restore path of the helper.

PHPUnit Feature returns to main's job conditions and drops its concurrency
group. The central action policy requires always() on the PHPUnit feature
suite gate, and GitHub never cancels an always() job, so cancel-in-progress
would only queue a newer run behind it. Shared Server fixtures keeps #724's
cancellation.
Sonar reported 11 style findings in the two new scripts. Conditions use
[[ ]], the state reader ends with an explicit return, and positional
parameters are read into named locals. Behavior is unchanged: a missing
COMMAND still fails under set -u, and each assertion still fails the
caller under set -e. PhpMaintenanceWindowTest passes 5/5, and one local
end-to-end record stopped and restored the real maintenance container.
Fixture first for the poll_request_id contract #712 advertises
(normalized_task_kind_set binding, poll_request_task_kinds_conflict).
On its own queue the probe now records: a multiplexed poll with an ID;
its retry with the kinds reordered; the same ID with the workflow kind
alone and with the validation kind alone; and a default (workflow-only)
ID's lease, its retry with both kinds, and its plain replay.

Published 2.11.1: the reordered retry replays the same validation task
at attempt 1; both other sets answer 409 poll_request_task_kinds_conflict
with requested_task_kinds, bound_task_kinds and the ID; the default ID
refuses the multiplexed retry (bound ["workflow"]) and replays its own
lease.

The 49 existing expected steps are unchanged; only their identifier
labels renumber around the 8 new steps. The fixture's int64 input was
kept byte-exact by splicing the expected block as text. The recorded
observation vector is the new 2.11.1 recording; its unit test passes
22/22, and published 2.11.1 passes 2/2 locally.
…lidation deliveries

Native's update-validation poll advertised PHP's poll_request_id
contract (normalized_task_kind_set binding, poll_request_task_kinds_conflict)
without keeping it. A multiplexed retry re-polled: the cursor had already
turned, so it could lease a second task, and a retry with another kind set
was never refused. Native's cursor was read outside any transaction and
turned in a separate one, so concurrent probes could both read the same
first kind.

A trimmed, non-empty poll_request_id now binds to its normalized kind set
(either order of the two kinds is one set), as WorkflowTaskPollRequestStore
does:
- a live binding to another set answers 409 poll_request_task_kinds_conflict
  with requested_task_kinds, bound_task_kinds and the ID, before anything
  is replayed or claimed, and the existing lease stays held;
- a validation delivery replays while its task stays leased to the worker
  at the delivered attempt; once it is not deliverable, the ID polls
  afresh, so an expired lease is claimed again at the next attempt;
- the binding and answer expire as PHP's resultTtl sets them: five seconds
  after a live lease ends, capped at an hour, or the pending window.

Each multiplexed probe is now one transaction, WorkflowTaskKindSelector's:
replay a concurrent duplicate's answer, create the queue's cursor at
update_validation if missing and lock it, claim the kind it names first
and then the other, turn the cursor after a lease, and record the answer
under the request ID. The workflow claim and the validation claim gain
transaction-taking variants for this.

Native execution tests on SQLite, PostgreSQL and MySQL cover the reordered
retry, both conflict sets, the default ID retried with both kinds, the
expired validation lease re-claimed at attempt 2 under its ID, a completed
validation delivery polling afresh, concurrent duplicates sharing one
delivery and one cursor turn, and concurrent probes leasing one task of
each kind. Reading the cursor before the probe's transaction, or dropping
the in-transaction replay, fails those concurrency tests on PostgreSQL.
The update-validation and workflow-poll-request-replay fixtures pass 2/2
against native.
@sonarqubecloud

Copy link
Copy Markdown

@rmcdaniel

Copy link
Copy Markdown
Member Author

Security R2 blocker on current exact head 320c1e8b4647b89d56ca3d630151df02d94bcc30: a duplicate poll that reaches its timeout can overwrite a concurrently committed live lease with an empty result for the same poll_request_id.

Claims correctly serialize on the binding row and replay a result found inside the claim transaction (replay/lock). The timeout path has a gap after its last probe, though. Both the workflow loop (single-kind) and multiplexed loop (multiplexed) can:

  1. finish a probe with no work;
  2. lose the transition lock;
  3. have another duplicate claim and commit a workflow/update-validation lease plus its cached answer; then
  4. observe their deadline and call remember_empty_workflow_poll.

That method locks the binding but never rechecks the now-deliverable result; it unconditionally updates the result row to {task:null,poll_status:"empty"} (write). The timing duplicate therefore returns empty, subsequent retries replay empty, and a lost response from the claimant cannot recover its still-live lease by retrying the same ID. The same race exists for the single-kind task_queue_changed terminal write. This violates the stated same-ID single-delivery/replay contract and can strand work until lease expiry. It is native-specific: the PHP coordinated path elects one pending leader, while followers only wait/read its result.

The concurrent regression starts with both task kinds already ready, so every duplicate encounters the cached lease during its probe and does not exercise the probe-to-terminal-write window (current test).

Please make the terminal no-task write conditional in the same transaction: after locking/binding, re-read and return any deliverable answer committed by a duplicate; write empty/task_queue_changed only if none exists. The caller must return that replay rather than its prebuilt terminal answer. Add deterministic barrier-based regressions for workflow and update-validation leases committed between the final empty probe and the terminal write, on SQLite, PostgreSQL, and MySQL, including a lost-response retry. I changed no source and started no CI.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant