Repository navigation
Bound workflow stream producers, refuse whole batches at capacity, and expire closed streams (#774) - #785
Conversation
…ion (#774) Security's review of #773 found three gaps in the shared durable stream producer contract, all present in published PHP: - max_pending_items and retention_seconds were caller-unbounded. Both are now bounded at every ingress (HTTP append and close, and the workflow_stream command in workflow-task completion, PHP and native rules) and asserted in WorkflowStreamService: max_pending_items 1..10000 (the default is also the maximum, so a producer may only lower it) and retention_seconds 1..2592000. - A capacity refusal committed the accepted prefix of a batch. append now resolves idempotency duplicates first and refuses the whole batch inside its transaction when the fresh items would pass the cap, so nothing is written and a stream the append would open is not created. Concurrent first appends insert the stream row if missing (insertOrIgnore) and then lock it, so the capacity decision always runs against the committed row. - Closed-stream retention was never enforced. A stream past closed_at plus its retention reads as not found (list, describe, subscribe, close), and WorkflowStreamRetention reclaims its rows in bounded, resumable passes from the heartbeat inline retention pass and history:prune. WorkflowStreamCapacityRaceTest proves the capacity decision under real contention on MySQL and PostgreSQL. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
) A general POST /api/system/retention/pass (no run_ids) also reclaims expired workflow streams, at most limit streams and limit * 100 items, with an unchanged report. Operators get an explicit way to drive the sweep, and the native port can exercise it on every engine. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er-bounds Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The inline stream retention pass keeps one throttle key per namespace, like the history retention pass. Declare it in the bounded-growth cache inventory, its documentation, the soak sampler and both perf workflows' key thresholds. List its owner in the perf workflow's path filters. Hash the namespace with SHA-256 in that key instead of SHA-1. Split the prune pass into a per-stream loop and a per-transaction batch without changing its bounds or order. An append that cannot see the stream row it just opened answers stream_not_found instead of a generic exception. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Security review: hold exact head The follow-up correctly declares the new cache prefix and replaces the Sonar-reported SHA-1 use. Four substantive gaps remain:
No operator action is needed. Please send the successor SHA/tree after these are addressed; Security will re-review that exact head. |
#774) Security's review of fa3ba9f held four gaps. This addresses each. Expiry is irreversible for a stream's identity. From the expiry instant an append answers 404 stream_not_found like every other operation (it answered stream_closed before). The retention pass deletes an expired stream's items but keeps its row, without a deadline, until the run is pruned, so the name is never opened again at offset 0. Discovery is index-bounded. A new reclaim_at column, set at close to closed_at plus the retention and backfilled for terminal streams, is indexed with (namespace, reclaim_at, id). The pass reads one namespace's due streams in deadline order from that index and stops at its limit: handler counts on MySQL and MariaDB, an index scan without a sort on PostgreSQL and an index search on SQLite. Items go only after their external payloads are settled. Each batch first runs ExternalPayloadRetentionCleanup for its items, which counts them as released and applies run retention's shared-reference, backup-hold, blocked-driver and failure handling. A blocked or failed batch keeps its items for the next pass. Run pruning removes a run's streams. PHP run retention settled the payloads of a pruned run's stream items but never deleted the rows; it now deletes them in the prune transaction, and skips a run whose stream gained an item after its payload cleanup. Stream writes serialize on the run row. Appends and closes take the run row's lock before reading any stream row (on SQLite a no-op write takes the database write lock, since PHP 8.3 begins SQLite transactions DEFERRED), so concurrent first appends wait instead of racing. A stream is created through its model again, which restores the namespace stream quota that the previous insertOrIgnore bypassed. References are claimed only by an admitted append. The HTTP append verifies each reference before its transaction and claims the fresh items' references inside it, after the capacity decision, so a refused append claims nothing. The contention proof runs on MySQL, MariaDB, PostgreSQL and file-backed SQLite; CI's feature job gains a MariaDB container for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The feature job's MariaDB container sits between the corpus validator's Composer sink and its server-verify-trust guard, so the guard is now 87 lines from the sink instead of 75. The guard itself is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Security re-review: hold exact head The final The successor materially addresses the earlier expiry-identity, indexed discovery, simple refusal/claim ordering, run-row cleanup, and four-engine contention points. Two payload-lifecycle concurrency blocks remain:
Required before clearance:
Hosted checks are also still active on this head. Merge/release remains held. No operator action is needed. |
…gines (#774) Security's hold on 0bdaee5 (6420503497) asked for one object-serialized decision between stream writers and payload cleanup, fenced run cleanup, the command path in the protocol, one lock order, and controlled interleavings on MySQL, MariaDB, PostgreSQL and SQLite. - Writers claim inside the append's transaction: the HTTP append claims what it verified (claimVerified); the workflow command append claims the transport-resolved registration (claimRegistered, Server #786). Each claim holds the object's lock until the append commits. - Cleanup asks again under the object's lock whether any retained row references the object before deleting it (deleteUnlessReferenced), in canonical lock order (bucket, then URI), as multi-reference claims do. - Reclamation batches and run pruning run READ COMMITTED (ReadCommittedTransaction). Under MySQL and MariaDB's REPEATABLE READ the check under the lock read a snapshot from before the wait and deleted an object a writer had just committed a reference to; the new interleaving tests found it. - Run pruning is one transaction that takes the run lock before payload cleanup and the row deletes; an append to a pruned run answers 404. This replaces the durable_stream_appended_during_prune skip. - On SQLite the payload object lock takes the write lock before reading its row, so a concurrent payload operation waits instead of failing 503. - WorkflowStreamPayloadRaceTest: HTTP and command appends against reclamation and run pruning in both orders, a late append to a run being pruned, and opposite-order multi-object claims, each on four engines with the other side's lock wait observed. The fork-and-pause helper moves to Concerns\ForkedDatabaseActors and reuses one migrated database per engine per process, which takes the race files from 5.5 to 1.5 minutes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
Security Tier-A review of exact The new object-lock decision closes the two durable-stream append paths, but the common cleanup still races with existing non-stream writers. Interleaving: an existing run references content-addressed URI U; a new non-stream writer claims U and releases the lock before its database insert; retention then takes U's lock, performs the new under-lock Required successor:
This is source-only review. No production state or operator action is involved. |
|
Risk-tier correction to my preceding review: the non-stream claim-before-reference gap is pre-existing and outside PR #785's changed durable-stream scope. Under the operator-approved risk-tiered review policy, it is a separate scoped follow-up, not a merge blocker for #785. I withdraw the #785 remains under focused Tier-A review only for regressions or incomplete guarantees in its changed production delta. The non-stream path should be handed to General with a reproducible case, but it does not need to be repaired before this Rust successor can merge. This correction narrows the gate; it does not erase the audit finding or imply that the pre-existing path was tested. |
|
Operator scope correction: #785 is pre-RC Rust development and is not waiting on Security review. The Rust Server owner may merge this exact successor after its own qualification and required CI. Security will focus pre-merge blocking review on changes that can affect existing released/deployed surfaces, credentials/auth boundaries, production data, or CI/supply-chain trust. Findings confined to an unreleased Rust surface are advisory or follow-up work unless the owner explicitly asks for a gate. The earlier non-stream finding concerns an existing PHP surface and remains a separate handoff; it does not gate #785. No further Security acknowledgment is required for this PR. |
Merge receiptMerged through the merge queue at 2026-10-26T18:25:24Z.
Identity. The merged tree equals the qualified head's tree ( Evidence.
Review. Tier A. Security dropped its pre-merge hold under the operator's pre-RC correction (6423216033, #325 6423232503). Closes, by hand: #774 and #786. Follow-ups, owned by General:
🤖 Generated with Claude Code |



Addresses #774 and #786 (both closed by hand after the merge receipt).
Review tier: A. It deletes stored data (payload reclamation, run pruning) and changes write-path locking. Policy: #325 comment 6423175739.
Problem
Published Server 2.11.1 had three gaps in the durable workflow stream producer contract, found in Security's review of #773:
max_pending_itemsandretention_secondswere caller-unbounded.429.Qualifying this PR turned up more gaps in published code, fixed here as well:
DEFERREDon the server's PHP 8.3, whateverDB_SQLITE_TRANSACTION_MODEsays. Laravel applies the configured mode only on PHP ≥ 8.4.workflow_streamappend items were stored without claiming their references (Workflow-task workflow_stream items store payload_reference without verification or claim #786).503) instead of waiting.Design: #774 decisions, revised after Security's holds (6418234760, 6418469554, 6420503497).
Change
max_pending_itemsis 1 to 10000 (also the default); a producer may lower its cap but not raise it.retention_secondsis 1 to 2592000 (30 days).workflow_streamcommand (PHP and native completion rules).WorkflowStreamServiceasserts both bounds for callers that skip request validation.max_pending_items_per_stream_maxandretention_seconds_max.429 stream_fullinside the transaction, so nothing is written and no stream is opened.WorkflowRunStreamLock).max_workflow_streams) applies.404 stream_not_found.payload_referencebefore its transaction, without claiming it. The worker command's append (Workflow-task workflow_stream items store payload_reference without verification or claim #786) relies on the runtime transport, which already resolves each reference to a registration in the namespace. Both claim the references of the items they will write inside the append's transaction, after the capacity decision. The HTTP path usesclaimVerified; the command path usesclaimRegistered, which checks the registration again. Each claim holds the object's lock until the append commits, and a refusal rolls it back.deleteUnlessReferencedtakes its lock and asks again whether any retained row references it before deleting it. Either the writer's reference commits first and the object stays, or the object goes first and the claim fails.ReadCommittedTransaction). That second check must see a reference committed while cleanup waited for the lock. MySQL and MariaDB default to REPEATABLE READ, whose snapshot predates the wait, so reclamation batches and run pruning run READ COMMITTED. PostgreSQL is set to the same, so a server default can't change it. SQLite runs one writer at a time. Inside a caller's transaction the level can't change, and the callback runs as it is.RuntimeExternalPayloadObjectLock::inLockOrdersorts by bucket, then URI. A multi-reference claim and cleanup's deletes both lock in that order, so two transactions never wait on each other in opposite orders.closed_at + retention_seconds, a stream reads as not found for list, describe, subscribe, close and append (404 stream_not_found).WorkflowStreamRetention).reclaim_atis set at close (backfilled for terminal streams) and indexed(namespace, reclaim_at, id).ExternalPayloadRetentionCleanup::deleteForDurableStreamItems. That treats the items as released and applies run retention's shared-reference, backup-hold, blocked-driver and failure handling. The items are deleted only after it succeeds.reclaim_atcleared.history:prune(--limitstreams and--limit × 100items per namespace), and the operator retention pass. It runs in every retention mode.HistoryRetentionEnforcer::pruneRunis one transaction that takes the run lock before anything else. Payload cleanup, the run's row deletes and its stream items and streams follow. No append to the run can commit in between, and a later one finds the details pruned.durable_stream_appended_during_prune).deletedreport gainsdurable_stream_items_deletedanddurable_streams_deleted.Native. Native has no stream write routes on main (
501), and its run retention holds any run with durable streams, so it neither deletes nor orphans stream rows. Native expiry, kept terminal rows and indexed reclamation land with the #773 successor, through a native-owned deadline relation; native's catalog follows the frozen published layout (the #740 precedent). In native, this PR changes only the completion-rule bounds.Not changed here. Two transactions opening new streams on different runs can deadlock on MySQL and MariaDB. That comes from the gap lock of the stream row's locking read, and main has the same code. It is tracked in #787.
Tests
WorkflowStreamsTest.history:prune, and the operator pass.SEARCH … USING INDEX wf_durable_streams_ns_reclaim_idx (namespace=? AND reclaim_at<?), no temp sort).HistoryRetentionTest.RuntimeExternalPayloadTransportTest. A reference stays unclaimed after a429refusal and a409refusal, and is claimed by an admitted append.Concerns\ForkedDatabaseActors).performance_schema.data_lock_waitsjoined todata_locks.OBJECT_NAME.pg_stat_activitylock wait in the locking read.WorkflowStreamCapacityRaceTest.rangescan on the deadline index with no filesort, and read exactly the limit (Handler_read_key1,Handler_read_next9 for 10 rows, among 2,400 rows per namespace that are mostly not due). PostgreSQL uses an index scan on it withactual rows=10and no sort.WorkflowStreamPayloadRaceTest.404 stream_not_foundand claims nothing.Qualification
At
1761ac29(parent0bdaee54, main0ef7c932), on the pinned server image:Interleavings on four engines (MySQL 8.0, MariaDB 10.11, PostgreSQL 17, file-backed SQLite in WAL mode):
WorkflowStreamPayloadRaceTest32/32 andWorkflowStreamCapacityRaceTest15/15, in 1:33 at the exact head.Counterfactuals. Each one undoes a single fix; the tests that should catch it fail.
Full
tests/Featurewith the four engines configured: 2,973 tests. The 8 errors are all inActivityWorkerDeregistrationTest, caused by the local runner leaving the image's on-disk SQLite default in place. WithDB_DATABASE=:memory:, as CI uses, that file passes 10/10 with 2 skipped.MySQL 8 and PostgreSQL 17 as the application database, as CI's prepared-local job runs them:
HistoryRetentionTestandWorkflowStreamsTest. Each engine passed 70 of 71. The 71st was the SQLite-only plan test, which errored onEXPLAIN QUERY PLAN. It is now scoped to SQLite and passes on both engines when run alone.Pint: clean.
Native: unchanged by this successor.
Notes for operators:
binlog_format=STATEMENT. The defaults, ROW (MySQL 8) and MIXED (MariaDB), are fine.🤖 Generated with Claude Code