Skip to content

Rust #325: serve durable workflow stream writes (items, close) - #773

Merged
rmcdaniel merged 18 commits into
mainfrom
rust-325-workflow-stream-writes
Oct 26, 2026
Merged

rmcdaniel merged 18 commits into
mainfrom
rust-325-workflow-stream-writes

Conversation

@rmcdaniel

@rmcdaniel rmcdaniel commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

Part of #325. Native durable workflow stream writes, matching published Server 2.11.1 and the #774 producer contract (#785), fixture first.

Review tier: advisory (pre-RC native). It changes only the native server and a native-owned schema relation. It touches no published PHP surface, credentials, production data or CI trust. Policy: #325 comment 6423232503.

Fixture first. workflow-stream-writes was recorded from published 2.11.1 against its SQLite reference. The parity.v1.workflow_streams run is started and left waiting; no worker is needed. The fixture covers:

  • an append that opens a stream, with offsets from 0;
  • an idempotent retry that returns the existing offset;
  • the pending-items cap: a batch whose fresh items would pass it is refused whole with 429 stream_full. Published 2.11.1 committed the items before the cap first, which the contract bounds as a published defect;
  • reads that clear pending_items at the head but not on a partial window, plus a bounded wait;
  • a final close (3600 s default retention) and an errored close;
  • 409 for appends to a closed or errored stream;
  • whole-second times;
  • every validation, stream-name, external-reference and unknown-run refusal.

The contract (workflow-stream-writes-contract.mjs) refuses 18 mutations, including native's previous 501 answer.

Native writes. rust/src/runtime/workflow_streams.rs now serves POST .../streams/{name}/items and POST .../streams/{name}/close. List, describe and item reads now return real rows.

  • Writes take native's serialized write transaction (the transition lock), as PHP's run lock does.
  • Times are written as whole seconds, as Eloquent's Y-m-d H:i:s stores them.
  • Laravel's string trimming applies to payloads too, and an empty object reads back as [], both as in PHP.
  • offset is a reserved word, so its statements are per-dialect Backend constants.
  • The named-namespace route table allows the two writes. Each route checks that the run belongs to the request's namespace; a write from another namespace gets 404.
  • The two routes leave unserved-php-routes.v2.11.1.txt.

The #774 contract, as #785 defines it for PHP:

  • Bounds. max_pending_items is at most 10000 and retention_seconds at most 2592000, refused with 422.
  • Whole batches. Stored and repeated idempotency keys take no capacity. A batch that would pass the cap writes nothing, and doesn't open its stream.
  • Expiry. Past closed_at + retention_seconds a stream reads as not found for list, describe, subscribe, close and append. Its row stays until its run is pruned, so the name is never opened again at offset 0.
  • Reclamation. Each deadline is a row in the native-owned dw_stream_reclaims, indexed (namespace, reclaim_at, stream_id). It is schema version 6 on SQLite, PostgreSQL, MySQL and MariaDB, with catalogs, upgrade lists and the parity schema scripts updated. The retention pass reads due streams from that index in deadline order and deletes items in bounded batches under the transition lock. Native deletes no external payload objects yet, so a stream whose items hold an external reference stops the pass, blocked, with its items kept.
  • Run retention. It deletes a pruned run's streams and their deadlines, unless their items hold external references, which hold the run. An append to a run whose details are pruned answers 404 stream_not_found instead of opening a stream, as PHP's lockStream refuses it.

Continue-as-new. Native used to refuse continue-as-new for any instance with durable streams. HTTP producers can now create streams on a native run, so that refusal would have become reachable. PHP leaves a run's streams with the run, so native now does the same (continue_as_new_keeps_durable_streams_with_the_predecessor).

Three documented differences, also listed in the module doc and the port log:

  1. A batch whose items each hold none of the item fields leaves PHP's validated data without items, and PHP fails with 500. Native answers the service's 400 append requires at least one item. A batch with only some such items drops them, as PHP does.
  2. A read at the head clears pending_items only while the stream is still at that head. PHP's unlocked save can also clear the count of an append committed after the read.
  3. A runtime external payload reference object in payload_reference gets 501 rust_capability_not_implemented; PHP resolves and claims it. Any other non-null value gets PHP's 422 external_payload_unsupported.

Not in this PR:

  • the workflow-side workflow_stream command;
  • cluster info's workflow_streams_contract;
  • external payload claims and deletion;
  • takeover of a PHP database that holds streams, which is still refused.

Qualification

Head 0b39fd74, parent 29d09a86, with main a3e0bab0 merged in, which includes #785. The native source was qualified at 511bd5b2; later commits merged main (adding only #785's PHP files) and updated the port log.

  • Stream and retention execution tests: 10/10 on SQLite, PostgreSQL 17, MySQL 8 and MariaDB 10.11 (workflow_stream_bounds, history_retention), including the pruned-run append refusal.
  • Full native suite on SQLite: lib 314, execution 544 and upgrade_inspect 2 pass.
  • Shared fixtures recorded over HTTP against a debug server on all four backends: workflow-stream-writes and message-streams pass.
  • No PHP app/ changes beyond Bound workflow stream producers, refuse whole batches at capacity, and expire closed streams (#774) #785's, which are already on main.

🤖 Generated with Claude Code

rmcdaniel and others added 7 commits October 25, 2026 06:46
…d 2.11.1

The producer routes for durable workflow streams, POST
runs/{run}/streams/{name}/items and /close, are the last stream routes
native does not serve, and native's reads only ever see an empty run.
This fixture records what published 2.11.1 answers over raw HTTP:

- an append opens a stream at offset 0, a repeated idempotency key
  returns the existing offset, and only new items move the counters;
- the pending-items cap commits the items before it and then answers 429
  stream_full;
- a read window that stops short of the head keeps pending_items, one
  that reaches the head clears it, and a bounded wait on an open stream
  returns empty after waiting;
- close closes once and keeps a terminal stream's first close, error and
  retention (3600 seconds by default); closed and errored streams refuse
  appends with 409 and their tails read as terminal;
- every validation, stream-name, external-reference and unknown-run
  refusal, including the different 400 reasons append and close give.

The contract checks exact bodies, whole-second UTC times that never run
backwards, and that no refusal wrote anything. A unit test runs it on the
recorded 2.11.1 observation and on 18 mutations it must refuse. A fresh
published PHP recording passes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er 2.11.1

Native now serves POST .../streams/{name}/items and .../close. The run's
stream list, description and item reads now return real rows.

- Appends open the stream on first use, assign offsets from 0, and return
  the existing offset for a repeated idempotency key.
- The pending-items cap keeps the items before it, then answers 429.
- Closing is final. Reads that reach the head clear the pending count, and
  a bounded wait polls every 250 ms, as PHP's readWithWait does.
- Times are stored as whole seconds, as Eloquent writes them.
- Laravel's trimming applies to payloads, and an empty object reads back
  as a list, as in PHP.
- `offset` is reserved, so its statements are per-dialect constants.

Continue-as-new no longer refuses an instance with durable streams: they
stay with their run, as in PHP.

Three differences are documented in the module: a batch with no item
fields gets 400 instead of PHP's 500; a read at the head does not clear a
later append's count; a reference object in payload_reference gets 501.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…low-stream-writes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
) into rust-325-workflow-stream-writes

Native now serves 140 route methods: main's 138 plus durable workflow
stream appends and closes. The comparator node test list keeps both
sides' tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nto rust-325-workflow-stream-writes

The port guide now counts 142 served route methods: stream writes and the
two workflow imports each add two to the base 138.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…low-stream-writes

The port guide counts 145 served route methods: stream writes add two and
cancellation scopes three to the base 140. The workflow test list carries
both contract tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…low-stream-writes

Kept both sides. Stream items stay read-write and close stays write, next
to the read-only Nexus operation reads, and the unserved inventory drops
both pairs of routes. The port guide counts 147 served route methods: the
base 140 plus stream writes, cancellation scopes and Nexus reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rmcdaniel

Copy link
Copy Markdown
Member Author

Security review — HOLD on exact head 7245239, tree 51f73ed0097fc1326a380d6c258ece2ac6b93f1d.

The namespace binding, static SQL, body and page bounds, read-only projection check, and external-reference refusal look sound. Four resource and retry invariants remain blocking before these write routes are enabled:

  1. Native bypasses the existing database-authoritative namespace durable-state quotas. PHP creates WorkflowDurableStream and WorkflowDurableStreamItem through NamespaceDurableStateObserver, which enforces max_workflow_streams and max_workflow_stream_items, including hard limits and shared inbound plus outbound counts, under the namespace row lock. This head inserts both tables through raw SQL and has no native quota path. A configured hard or per-namespace limit is therefore ineffective. The server-wide native transition lock does not serialize a mixed PHP/native writer. max_pending_items is not a substitute: a head-reaching read resets pending_items, after which the producer can continue adding permanent rows.

Required: enforce the same effective namespace quota policy in the same transaction and under the namespace row lock, with fail-closed typed behavior when configured policy cannot be evaluated; or leave both routes refused. Cover new-stream admission, multi-item batch admission at the remaining boundary, dedupe not spending quota, rollback/no partial mutation on quota refusal, shared inbound plus outbound usage, and concurrent/mixed-writer safety on the supported databases.

  1. Both producer controls are caller-unbounded. max_pending_items and retention_seconds validate only min:1. The latter is accepted as i64 but stored in an unsigned-integer column, creating cross-engine overflow/error behavior; the former lets a producer disable the advertised per-stream backpressure limit. Define documented server-side maxima in the shared PHP/native contract and validate every ingress, including HTTP and workflow-task commands, before mutation. Add exact-boundary and above-boundary tests.

  2. A batch can commit an accepted prefix and then answer only 429. Since idempotency_key is optional and the 429 omits the committed offsets, an ordinary whole-request retry duplicates that prefix. Preserve neither ambiguity nor silent duplication: make capacity refusal atomic before inserting the batch, or require an idempotent/receipt-bearing contract that tells the caller exactly what committed. Change and qualify PHP and native together; add a counterfactual partial-cap regression.

  3. The public stream contract says closed or errored rows are removed after retention_seconds, but source search finds this field only being stored/formatted; the only deletion paths found are broader namespace/run lifecycle cleanup. Native also has no stream-retention pass. Do not claim an enforced retention bound until an actual bounded cleanup path and expiry regression prove it, or keep the close/write capability gated behind the missing lifecycle work.

Also add direct regressions for projected-v1 append and close refusal. Current green checks do not exercise these conditions. Please send a new SHA/tree, complete delta, counterfactuals, and fresh exact-head CI after correction. No live, deployment, or migration clearance is implied.

An append and a clean or errored close against a projected v1 run answer
409 v1_projection_read_only, and no stream or item row is written. With
the read-only checks removed from append and close, the append is
accepted and the test fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rmcdaniel

Copy link
Copy Markdown
Member Author

Held on Security's review (comment 6409052533) and converted to draft. Native will not serve the two stream write routes until the shared contract is fixed.

  • Producer bounds, atomic capacity refusal, retention enforcement: these are shared PHP/native contract defects that published 2.11.1 has too. They are tracked in Durable workflow streams: bound producer controls, make capacity refusal atomic, enforce closed-stream retention #774, to be changed and qualified on both servers together.
  • Namespace durable-state quotas: native refuses to start with any non-empty DW_NAMESPACE_DURABLE_LIMITS, DW_NAMESPACE_DURABLE_HARD_LIMITS or DW_NAMESPACE_DURABLE_OVERRIDES (rust/src/config_audit.rs: Refused, "native has no namespace durable-state limits"). Takeover is sequential with no mixed PHP/native fleet (docs/rust-server-port.md, backup and in-place upgrade). So a configured limit cannot be silently ignored, and no PHP writer shares the database. Native quota enforcement for every resource remains the documented Replace PHP Server with a compatible Rust runtime and safe in-place upgrade #325 gap.
  • Projected-v1 append and close regression: added as durable_stream_writes_to_a_projection_are_refused (commit d25d98f, on the backup ref rust-325-wip/workflow-stream-writes, not yet on this PR head). With the read-only checks removed, the append is accepted and the test fails.
  • Hosted CI on 7245239: one failure, execution-and-codec (corpus) (job 121415434657). It came from the published-PHP SQLite recording of cooperative-pending-timer-cleanup under "database is locked". That fixture passes natively at this head on SQLite, PostgreSQL 17, MySQL 8 and MariaDB 10.11.

The next Rust activation proceeds without this PR: bridge adapters.

@rmcdaniel

Copy link
Copy Markdown
Member Author

Security correction to blocker 1 in my earlier hold: native does not silently ignore a configured namespace durable-state quota.

I independently verified exact head 7245239 config_audit.rs and main.rs. DW_NAMESPACE_DURABLE_LIMITS, DW_NAMESPACE_DURABLE_HARD_LIMITS, and DW_NAMESPACE_DURABLE_OVERRIDES accept only unset or an empty object; any non-empty or invalid policy is a startup refusal, and main exits on the audit refusal before authentication/storage/server startup. The published cutover contract also explicitly requires all PHP write-capable roles to stop before sequential native takeover and forbids a mixed PHP/native database fleet. Therefore I withdraw the claimed configured-quota bypass and mixed-writer locking blocker. Preserve those startup refusals and the no-mixed-fleet invariant; no native implementation of PHP namespace quotas is required for #773 while those constraints remain exact and tested.

The other blockers remain. With empty quota policy, caller-unbounded max_pending_items and retention_seconds, ambiguous partial-prefix 429 commits, and promised-but-unenforced closed-stream retention still allow unbounded or surprising durable growth in both published PHP and native. Server #774 is the correct shared PHP/native remediation and must land before #773 serves these routes. The new projected-v1 append/clean-close/error-close refusal regression described at d25d98f is responsive and should stay in the successor.

#773 remains held/draft until #774 lands, the routes remain refused meanwhile, and a successor SHA/tree plus complete delta and fresh exact-head CI are reviewed. This correction narrows the hold; it does not clear the current head.

rmcdaniel and others added 3 commits October 26, 2026 07:32
…rkflow-stream-writes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rkflow-stream-writes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The native producer routes adopt the shared durable stream contract fixed
in #774:

- max_pending_items is at most 10000 and retention_seconds at most
  2592000, refused with 422 by the append and close rules.
- An append resolves stored and repeated idempotency keys first, then
  refuses the whole batch when its fresh items would pass the cap. The
  transaction is dropped, so nothing is written and a stream the append
  opened is not created. Appends run under the transition lock, so
  concurrent first appends open one stream and never pass the cap.
- A closed or errored stream past closed_at plus its retention reads as
  not found: list omits it, and describe, subscribe and close answer 404.
- prune_expired_streams reclaims expired streams in bounded, resumable
  steps (at most N streams per pass, oldest close first, and M items in
  transactions of at most 500), from the heartbeat inline retention pass
  under its own per-namespace throttle and from a general operator
  retention pass.

The workflow-stream-writes contract accepts the whole-batch refusal and
bounds published 2.11.1's partial commit as a published defect. New
execution tests cover the whole-batch refusal, both bounds, concurrent
near-cap and first-open appends through two pools, expiry and the
bounded pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rmcdaniel and others added 5 commits October 26, 2026 14:05
…rkflow-stream-writes

# Conflicts:
#	.github/workflows/server-parity.yml
#	docs/rust-server-port.md
#	rust/src/runtime/backend.rs
…efusal (#774)

Native follows #785's stream lifecycle:
- A closed stream past its retention reads as not found, append included,
  and keeps its row until its run is pruned.
- Its deadline lives in the native-owned dw_stream_reclaims relation
  (schema 6 on all four backends; catalogs, upgrade lists and parity
  scripts updated), indexed (namespace, reclaim_at, stream_id).
- The retention pass reads due streams from that index in deadline order,
  deletes items in bounded batches under the transition lock, and stops
  blocked when items hold external references, which native does not
  delete yet.
- Run retention deletes a pruned run's streams unless they hold external
  references, and an append to a run whose details are pruned answers
  404 stream_not_found instead of opening a stream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rmcdaniel and others added 2 commits October 26, 2026 18:26
@sonarqubecloud

Copy link
Copy Markdown

@rmcdaniel
rmcdaniel marked this pull request as ready for review October 26, 2026 19:00
@rmcdaniel
rmcdaniel added this pull request to the merge queue Oct 26, 2026
Merged via the queue into main with commit caafa5b Oct 26, 2026
57 checks passed
@rmcdaniel

Copy link
Copy Markdown
Member Author

Merged through the merge queue at 20:35Z as caafa5b9691c115868f82cda5855f8a748487246.

  • Parents: a3e0bab0 (main) and 0b39fd74 (the qualified head).
  • Tree: 632c3100d1432897d5cff916618fa2f9427778b6, identical to the head's tree, so main now holds exactly the qualified source.
  • Queue: the group ran the full required checks, with the group Sonar bootstrapped by General while Analyze merge-queue commits with trusted Sonar controls #788's automatic scanner re-qualifies.

Native durable workflow stream writes are on main, with the #774 producer contract: bounds, whole-batch capacity, expiry, reclamation through dw_stream_reclaims (schema version 6), and run retention.

🤖 Generated with Claude Code

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