Skip to content

Rust Server train: command batches, named namespaces, completion checks, external inputs, operator surface, leases, deployments (#325) - #696

Merged
rmcdaniel merged 43 commits into
mainfrom
rust-325-train-oct23
Oct 23, 2026
Merged

rmcdaniel merged 43 commits into
mainfrom
rust-325-train-oct23

Conversation

@rmcdaniel

@rmcdaniel rmcdaniel commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

Rust Server (#325) train. Each change below was compared live with published Server 2.11.1 (ghcr.io/durable-workflow/server@sha256:bf8bed13…). Each has an integration test that passes on SQLite, PostgreSQL and MySQL. Each has a checkpoint under docs/rust-server-port-checkpoints/. The full native suite passes locally on SQLite, PostgreSQL and MySQL (449 tests each) at the same Rust tree.

Operator surface

Leases and limits

  • Expired activity leases settle through the retry policy, as PHP's activity timeout pass does.
  • A deregistered worker's activity leases settle as worker_deregistered.
  • Native enforces the Workflow package's pending signal, pending update and payload size limits (DW_V2_LIMIT_*). Native's old 64-update lifetime cap and 64 KiB update budget are removed; PHP has neither.
  • Update responses carry PHP's principal and rejected_at. A null request_id is omitted.

Run operations

  • Standalone activities: POST/GET /api/activities and GET /api/activities/{id}.
  • Archive of a closed run.
  • Redrive of a failed run at its failed activity.

Routing

  • Runs are pinned to their start cohort's build, and polls give a pinned task only to that build's workers. Unpinned activities go to any worker; that contract was agreed with General on Workflow #908.

  • Build-id rollouts: promote, drain, resume and rollback on the task-queue routes and /api/deployments, with:

    • draining poll refusals;
    • task_queue_draining start refusals;
    • promoted start pins.

    Takeover now accepts rollout rows.

  • An accepted update goes only to a worker that advertises workflow_updates and declares the update. Published PHP 2.11.1 gives it to any worker of the type, so a worker without the handler can fail it mid-rollout (Server #690). Main's PHP now routes the same way (Route accepted updates to workers with the matching handler #691). A new test, update_routing, covers the native rule.

  • GET /api/task-queues/{queue}/priority-fairness.

  • Cluster info advertises the service catalog read contract, so Waterline 2.12.3's Services pages load.

Request and response contracts

  • Workflow task completions whose command payloads are not complete Avro values are refused with PHP's field-scoped 422, before anything is recorded.

  • Workflow task completions run PHP's completion checks, before ownership as in PHP:

    • trimming;
    • the transport rules;
    • digit-string integers;
    • command scopes;
    • normalizer messages.

    Malformed commands get PHP's Laravel field errors instead of native reasons. A run that a completed task leaves open is waiting, even when the task recorded only markers. Digit strings, bare payload strings and plain continue-as-new arguments are accepted as PHP accepts them. A command field PHP neither reads nor scope-checks for that command type is ignored, as PHP ignores it; native still refuses a field PHP reads for a feature native cannot run, such as worker_session. Eleven older native test expectations that the published image contradicts were corrected against it.

  • Unknown routes and wrong methods get PHP's 404 and 405 bodies, using an embedded copy of PHP's route table. Routes PHP serves and native does not yet still answer 501.

  • An empty request body is read as empty input, as PHP reads it.

  • Admission refusals on start, signal, update and query name the workflow, as PHP's do.

  • An activity heartbeat on a closed run gets PHP's 200 with cancel_requested: true; native used to answer 409.

  • Workflow task command batches are applied as PHP's parseCommands applies them:

    • one terminal command anywhere, applied after the rest; a second one gets PHP's 409 invalid_commands;
    • children start alongside activities, timers, waits and other children;
    • complete_update and fail_update follow PHP's validateUpdateCommands, so an update completes alongside other commands and an update task may leave its update for a later task;
    • continue-as-new leaves open activities, timers, children and waits with the closed run, a child that continues as new stays its parent's child, and the command's workflow_type and queue apply to the new run.

    Native refused each of these before. Successful completions now carry PHP's created_task_ids, protocol_version and server_capabilities. Workflow task failures and heartbeats, and activity results and failures, also carry the worker envelope. A workflow task heartbeat also reports the run's and the task's status.

  • Named namespaces now serve every workflow command, plus update, repair, redrive and cooperative cancellation requests, as PHP does. Native used to refuse them with development_namespace_command_unqualified or development_namespace_operation_unqualified. Update admission and cancellation requests no longer look the instance up in the default namespace.

  • An upsert_search_attributes command needs the worker's typed_search_attributes capability even without attribute_types, as PHP requires.

  • Signal, update and query input accept a runtime external payload reference, as start input does. A signal keeps the reference; an update or query carries the bytes inline. Refused references get PHP's typed runtime-external-payload-error body, which SDKs read to decide whether to retry.

CLI and Waterline

  • The published dw 2.3.0 CLI matches PHP's exit code on 58 of its 60 sweep commands. The other two are not gaps: a lock refusal on the SQLite reference, and a query timing race.
  • Every Waterline 2.12.3 service-mode route answers native as it answers PHP.

Sonar maintainability findings (General's request on #325)

  • The 21 redundant 'static lifetimes in backend.rs, the wildcard import in query_poll_requests.rs and a closure in activity_metadata.rs are fixed.
  • Two complex functions are split:
    • the named-namespace route check is now a route table with per-family helpers. A one-off test compared it with the old check on 1,949,072 method and path pairs, with identical answers;
    • Laravel's rule loop is split into helpers named after Laravel's own steps.
  • The other six complexity findings and the two eight-argument methods are left as they are. The reasons are on Replace PHP Server with a compatible Rust runtime and safe in-place upgrade #325.

CI

  • The parity jobs compile the library tests with two jobs, which now peaks near 4.2 GB. Under the 4 GB container limit, rustc was killed in execution-and-codec. The three test-compile steps now get 6 GB. A local run with the CI image and limits reproduced the kill at 4 GB and passed at 6 GB.

Not in this train: #673 (under Security review).

GET|POST /api/system/metrics reports the namespace's workflow task
failures, server-wide projection drift, and the namespace's request
admission, durable-state and external payload usage, each with PHP's
bounded label policy. Native enforces none of those quotas, so limits are
null and rejection counters zero, as on an unconfigured PHP server.

The new system-metrics fixture fails a workflow task in a named namespace,
which native refused. That failure, and its retry, are scoped to the
task's namespace, so named namespaces now accept it.
…d claim

The published PHP SQLite reference shares its database with a one-second maintenance loop. A zero-wait poll could return no task, so the scenario recorded no claim and the failure request answered 422. The probe now long-polls for five seconds and retries the documented 503 backend_lock_pressure refusal, as the other probes do.
GET /api/system/prometheus-metrics reports the namespace's workflow,
activity and task-queue series with latency histograms, task state and
worker capacity, each family capped at 100 series with its suppression
disclosed, as PrometheusMetricsSummary does.

Latency buckets compare durations with numeric bounds on every backend.
Published PHP on SQLite counts every duration in every bucket
(server#683); the fixture records that reference defect without adopting
it.

As in PHP, an activity task scheduled by a workflow task is no longer
stamped dispatched: PHP's completion publishes only timer tasks.
GET /api/system/external-payload-cleanup reports the namespace's cleanup
metrics. POST .../pass renews expired completion-upload allowances whose
activity can still complete and drops the rest, then reclaims the
namespace's expired, unclaimed references under their object locks: a
shared object keeps its file, a sole reference deletes it. Each pass is
recorded in the namespace's totals; a blocked reference answers 207.

Native deletes through the local driver only, honours a backup hold that
PHP left active, and treats a missing hold row as no hold.
Locally, published PHP on SQLite refused a namespace delete with its
retryable 503 backend_lock_pressure in two of four parity runs, and a
storage diagnostic with a retryable 503 in two. The namespace-delete and
payload-cleanup probes now retry lock pressure through the shared
middleware, and the storage diagnostic retries a retryable 503 up to ten
times.
POST /api/system/repair/pass recovers overdue ready tasks and expired leases, creates lost workflow tasks and deadline tasks, and enforces expired activity deadlines, with PHP's request rules, scope-fair selection and loop throttle. Recoveries native does not perform yet (an expired running activity lease, a missing task for a run waiting on an activity, timer, update, signal or child) are reported as candidate failures with 207. Repair status now shares the pass's candidate query, with PHP 2.11's availability rule and microsecond cutoff.
…d PHP 2.11

Published PHP claims only ready activity tasks; its repair loop settles an expired activity lease by closing the attempt as expired and either scheduling a retry (retry_reason lease_expired) or recording a terminal ActivityFailed with ActivityLeaseExpiredException. Native re-claimed the expired task as the next attempt, skipping the retry policy and its backoff. Native's claim queries now take only ready activity tasks, and its scheduler and the repair pass settle expired activity leases as PHP's enforcer does. The native-only restart probe expected re-claim of an activity without retries; it now declares two attempts and expects the lease-expired retry, matching PHP.
Both deregistration routes in published PHP revoke each activity lease the departing worker still holds (ActivityTaskLeaseRecovery::recoverForStoppedWorker): the attempt closes as expired and the activity's retry policy records ActivityRetryScheduled or ActivityFailed with failure_reason worker_deregistered and lease_revoked_at. Native released only workflow-task leases, leaving activity leases until they expired. The lease enforcer now has a transactional core shared by expiry and revocation, and both native deregistration routes call it.
…blished PHP 2.11

Native now honors DW_V2_LIMIT_PENDING_SIGNALS, _PENDING_UPDATES and
_PAYLOAD_SIZE_BYTES (and their WORKFLOW_V2_LIMIT_* names) with the memo
limit's casts; zero or less disables a check. Like StructuralLimits:

- an admissible signal past the received-signal count or the payload size
  is rejected durably with structural_limit_exceeded, a null outcome and
  no history; an oversized one keeps no arguments;
- an update past the accepted-update count or the payload size is a
  durable rejection with the limit's message and no arguments, and its
  answer (and a retried request's) carries the limit fields;
- an activity result over the payload size fails the activity without a
  retry, with PHP's structural-limit exception and failure category.

The 64 KiB update request budget is removed: PHP rejects oversized
updates durably through the payload limit instead. Operator metrics now
report the configured ceilings and memo limit.
…request id

The structural-limit comparison showed three older update differences.
PHP's update answer names the requester recorded on the command and
carries rejected_at, and UpdateAccepted records request_id only when the
request named one.
POST /api/activities starts an activity as a top-level job inside a host
run of type dw.standalone_activity, as StandaloneActivityStartService does:
StartAccepted, WorkflowStarted and ActivityScheduled, a pending activity
with a retry snapshot, and a dispatched activity task; no workflow task.
A closed id starts the next run; a running one, a workflow's id or another
namespace's id is refused with PHP's answers.

The activity's terminal outcome closes the host run instead of resuming
workflow code: a result completes it with the result as output, a failure
fails it with the activity's failure, and a timeout fails it as timed_out
(failed for a lost lease).

GET /api/activities/{id} describes the current host run with its attempts
from the run's activity view; GET /api/activities lists host runs newest
first by status bucket, with offset page tokens. Without input an activity
has no arguments, and its claim omits them.

The run list now reports a run's own business key before its instance's.
…ages load

The PHP SDK reads the service catalog only when cluster info advertises
durable-workflow.v2.service-catalog-read.contract version 1. Native served
the eight catalog reads but did not advertise them, so Waterline 2.12.3 in
service mode showed its Services pages as unavailable. Cluster info now
carries Server 2.11.1's contract unchanged. Against the published Waterline
image, every read route and the query, signal, repair and cancel actions now
answer native as they answer published PHP.
POST /api/workflows/{id}/archive and /runs/{run}/archive follow
WorkflowStub::attemptArchive: an open run is refused durably with
rejected_run_not_closed; a closed run records an archive command and
ArchiveRequested, and its first archive also sets the run's archive
columns and records WorkflowArchived; a repeat answers archive_not_needed.
The reason and request_id are validated with Laravel's rules and
messages. A dw 2.3.0 sweep of 60 commands now differs from published
PHP only in redrive and the task-queue build-id lifecycle.
The build-ids test started a workflow on a queue whose only worker is
build nv1 and expected its pending task in an unversioned row. Published
2.11.1 pins that start to nv1, and native now does too, so the pending
task belongs to nv1.
Serve the task-queue build-id routes and /api/deployments over the PHP
rollout rows. A promoted build pins new starts, a draining build's
workers are refused polls, and a queue whose live workers all drain
refuses starts. The build-ids snapshot reads the rollouts, and takeover
no longer refuses a database that has them.
… like PHP 2.11; route updates only to workers that declare them

A signal keeps the runtime reference; an update or query carries the
referenced bytes inline; each claims the object. Refused references get
PHP's typed runtime-external-payload-error body with the route envelope,
and checks follow incomingEnvelope's order. An input object naming a
reference stays an object so a malformed envelope is refused.

Native keeps delivering an accepted update only to a worker that
advertises workflow_updates and declares it; PHP's delivery to any
worker of the type is recorded as Server #690.
Native now runs PHP's completion stages: Laravel's TrimStrings (Str::trim
set) and ConvertEmptyStringsToNull, WorkflowTaskCompletionValidation's
transport rules in 25-command chunks with Laravel's ordering and names,
digit-string integer normalization, validateWorkflowTaskCommandScopes
with the activity and child timeout envelopes, payload codec
canonicalization, and the normalizer's field scopes, timer, wait and
version marker messages. An out-of-range marker version is the bridge's
409 invalid_commands. Bare payload strings are Avro blobs and plain
continue-as-new arguments are no arguments, as PHP's normalizer reads
them.

Eleven older test expectations that published 2.11.1 contradicts were
corrected against live probes (listed in the checkpoint).
…ike published PHP 2.11

PHP's executor leaves every run a completed task did not close waiting,
even when the task recorded only version markers. Native left such a run
pending.
PHP's normalizer reads a fixed set of fields per command type and ignores
the rest unless a scope check refuses them, and ignores unknown retry
policy keys. Native now drops a field before its own checks only when PHP
neither reads nor scope-checks it for that command type: unknown fields,
null options, queue on a timer, priority on an activity. Fields PHP reads
stay, so native still refuses a feature it cannot run (worker_session)
instead of silently ignoring it.
…e route check becomes a route table, and Laravel's rule loop gets helpers
…inal command anywhere, children alongside other work, update commands in any task, continue-as-new over open work and from a child
…workflow task completions like PHP 2.11; refuse two terminal commands with PHP's 409
… cancellation request in named namespaces like published PHP 2.11
…and on activity results; report the heartbeat's run and task status
Compiling the library tests with two jobs now peaks near 4.2 GB. Under the
4 GB container limit, rustc was OOM-killed in execution-and-codec's codec
step (exit 101), which stopped the later steps. A local run with the CI
image and limits reproduces the kill at 4 GB and passes at 6 GB. The two
database jobs compile the same library tests under the same limit, so they
get the same headroom. GitHub's runners have 16 GB.
@rmcdaniel

rmcdaniel commented Oct 23, 2026 •

Copy link
Copy Markdown
Member Author

codec-deadline-health classification for this PR's runs. Actions log downloads time out from my workspace, so I read the logs through a disposable VM.

What failed

  • Run 39962030032 (a5e2b3a8f):
    • execution-and-codec: rustc was OOM-killed at the 4 GB compile limit. Fixed in b87618f23 (6 GB).
    • mysql-8.0: a canonical event 1.229 s after its deadline, against a 1 s budget.
  • Run 39969602684 (b87618f23): mariadb-10.11 and mysql-8.0, both "Busy CPU after the original expiry observation".

This is mostly a pre-existing flake. Five of the last ~35 completed runs before this PR had one failing codec cell, all on MySQL-family databases. Four of the five were this same busy-CPU check; the fifth was an operation deadline overrun.

A/B on one 4-vCPU VM. Main 44fd778b and this PR's release builds ran the same mariadb-10.11 and mysql-8.0 cells, interleaved, in two batches:

Batch Build Failed Timer lateness, mean Cleanup lateness, mean
1 main 2 of 12 386 ms 355 ms
1 this PR 3 of 12 656 ms 597 ms
2 main 7 of 12 696 ms 394 ms
2 this PR, lease sweep moved 8 of 12 641 ms 843 ms
  • Main fails the same checks in the same environment.
  • In one main cell, the server's CPU fell from about 110% to 35% for about 3 s right after the original's expiry, and the load's reads failed. That stall predates this PR and is what the busy-CPU check catches.
  • Read throughput per wave is the same: 112 against 118, then 98 against 103.
  • The cancellation-cleanup close is consistently slower on this PR: about 1.7 to 2.1 times main's lateness, in both batches. I haven't found the cause yet. Moving the new lease sweep out of the timer tick did not change it.

Decision. I'll merge on the required checks and the Rust suites. The cleanup-lateness regression and the pre-existing stall are both tracked for the next Rust Server PR. Every failure in both runs and on the VM is a timing or load check. A cell stops at its first failed check, so I can only say that the event and outcome checks it reached passed.

MariaDB types COALESCE(MAX(unsigned int),0) as DECIMAL in a prepared
statement, which the integer decoder refuses, so GET /api/system/metrics
answered 503 on MariaDB 10.11. Read the plain MAX as an optional integer
and default to 0, as schedule history already does.
Published PHP 2.11.1 stamps ActivityStarted a few milliseconds after the
attempt start its deadline runs from. Its timeout enforcer can retry in that
gap, which failed main's SQLite parity job once. The contract now requires
the retry at or after the attempt start plus the timeout, keeps the event
within a second of that start, and names the transport and margin when it
fails. Decision recorded in ACTIVITY-RETRY-RECEIPTS.md.
@rmcdaniel

Copy link
Copy Markdown
Member Author

New head 69485a2f4: two fixes plus main (#697, PHP-only).

MariaDB mysql-execution (run 39969602684). Two system-metrics tests got 503 storage_unavailable with a column_decode error. In a prepared statement, MariaDB 10.11 types COALESCE(MAX(unsigned int),0) as DECIMAL; MySQL 8 and PostgreSQL return an integer. The failed-task maximum now reads the plain MAX as an optional integer and defaults to 0, as schedule history already does (965612737). Local MariaDB: full suite with --all-targets passes, 699 tests and 0 failures; the system-metrics tests also pass on SQLite, PostgreSQL and MySQL. The mariadb job stopped at that step, so its parity steps run for the first time on this head.

Main is red after #675 (run 39961767541, execution-and-codec). activity-deadline-receipts saw a retry earlier than the ActivityStarted event time plus the 2-second timeout. That step records the PHP target first and stops at the first failure, so the early retry came from published PHP 2.11.1. Its claimer takes one clock reading for the attempt start and close deadline; WorkflowHistoryEvent::record takes a later one for the event. The PHP timeout enforcer can run between them, which is correct against its own deadline. Native stamps both from the same millisecond.

The contract now measures from the attempt start carried in the event snapshot (payload.activity.started_at). It still refuses any retry before that start plus the timeout, and requires the event to be stamped 0 to under 1 second after the start. On failure it names the transport and the margin (53bd5783f). The decision is recorded in ACTIVITY-RETRY-RECEIPTS.md.

codec-deadline-health. It failed on both MySQL-family cells again. Same classification as comment 6368856135; it is not a required check.

@rmcdaniel

Copy link
Copy Markdown
Member Author

New head 08367e7ec: main merged (#701, PHP-only: ScheduleVisibilityQuery and its test). The Rust tree is identical to 69485a2f4.

On 69485a2f4 (run 39984112058), every Rust suite passed:

  • execution-and-codec;
  • mysql-execution on MySQL 8.0, MariaDB 10.11 and the existing-PHP matrix;
  • postgres-storage on Postgres 17 and the existing-PHP matrix.

Everything else passed too, except the classified MySQL-family codec-deadline-health cell (#130). I cancelled the duplicate parity run on 08367e7ec (39999712438) to free shared runners. This merges on the required PHPUnit and Sonar checks for the new head, as #675 did.

@sonarqubecloud

Copy link
Copy Markdown

@rmcdaniel
rmcdaniel merged commit dd51ea9 into main Oct 23, 2026
14 of 19 checks passed
@rmcdaniel
rmcdaniel deleted the rust-325-train-oct23 branch October 23, 2026 18:18
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