Repository navigation
Conversation
|
Thanks, @chrom, this is a careful piece of work. #1069: yes, the Workflow team owns the rest of #1069: the state-bound nested-machine bindings and the two-integration example. We'll build it on top of this scope foundation once it lands, so please keep your focus on #1063. First-increment boundaries: these are acceptable for a first, experimental increment:
There are three conditions:
Review status: a full review is in progress. It covers replay determinism, lock order, the migration, the authorization boundary and a rebase onto current main, plus the feature suites your fork's CI skipped. We'll post concrete findings here. Security will also review the authorization boundary before merge. |
rmcdaniel
left a comment
There was a problem hiding this comment.
Review of head 861dc405 (General, Durable Workflow). The suites were run locally on MySQL 8.0.46 + Redis and on a trial merge onto current main; the results are at the end.
Verdict: request changes. The first-increment boundaries are acceptable, with conditions.
The design follows the #1063 contract:
- a closed handler registry and bounded portable descriptors;
- registration committed before the effect;
- separate forward and compensation states, and an uncertain effect never becomes
verified; - recovery reuses native activities, Saga, start/adopt and redrive.
ECS and PHPStan are clean on the changed files, and the suites pass on MySQL 8.
Boundaries:
- Embedded Laravel only: yes. But the PR also changes core failed-run redrive eligibility for every run, embedded and service-mode, so that change has to be split out (B1).
- Sequential compensation: yes. The ledger enforces reverse order even if the author enables
setParallelCompensationorsetContinueWithError, becauseScopeProgress::beginCompensationthrows.DurableScopeshould refuse those modes, or the docs should say so. - One nesting level: yes.
ChildScopes::registerenforces it structurally, althoughChildScopes::MAX_DEPTHis declared and never used. - Explicit release: yes, as long as no scope can end up neither recoverable nor releasable (B4). It also needs a supported, authorized status read (N2).
- No forward ownership transfer across continue-as-new or originating-run redrive: yes as a boundary, but it must fail closed, be documented and be tested (B5). Add child-workflow retry (#1105/#1107) to the same list. Any new run that reuses the same
ScopeIdentityhits the same refusal, sogenerationmust be unique per originating run.
Blocking
B1. Split the core redrive change into its own PR: FailedRunRedrivePlan.php:88,179,330,550 and the new ActivityRetryHistory.php.
It merges cleanly with current main, including #1109. The logic looks safe: it accepts only a linear retry chain for one activity execution, matching what ActivityOutcomeRecorder writes (:382). The successor still copies only ActivityCompleted for reused steps, so I found no history that would now redrive and then replay differently.
It still changes behaviour for every run. A run with any retried activity used to be refused with unsupported_history and is now eligible. That adds the reason invalid_activity_retry_history and changes when Waterline and run detail offer redrive. The split PR should include:
- CHANGELOG: an entry.
- Feature tests for ordinary workflows in
V2FailedRunRedriveTest:- a retried-then-completed prefix step is reused, not re-run;
- a retried failed step re-runs once;
- an explicit decision and test for runs started by a service worker (#1109 refuses unqualified steps for those).
- Unit cases: a retried completed prefix step, and one with heartbeats in between. Today only a retried failed boundary step is covered.
- Start adoption: the generic
isActiveStartAdoptionshould say why onlyReturnedExistingActiveis accepted whileStartRejectedstill blocks redrive. - Nit: the new unit tests in
FailedRunRedrivePlanTest.php:182-185are inserted underlinearFailureHistory()'s@returndocblock.
The replay-corpus guard does not trigger here, and since successor histories keep their shape, feature tests are the right evidence.
B2. Deadlock on MySQL/MariaDB from INSERT IGNORE followed by lockForUpdate. Two places do this:
ChildScopes.php:41-48, on the graph row per (application, namespace), so everyparticipate()in a namespace contends;ScopeRepository.php:37-43, on the scope row.
InnoDB takes a shared lock on the existing duplicate row for INSERT IGNORE, then both transactions ask for the exclusive lock. This exact statement pair reproduced ERROR 1213 Deadlock found on MySQL 8.0.46.
Nothing retries it:
ScopeBoundaryusestransaction($op, 1);RegisterChildScopeActivityandRegisterObligationActivitykeep the default$tries = 1.
So two unrelated workflows in one namespace can fail a business step, and with the documented catch-then-compensate pattern that also triggers compensation. CI doesn't catch it because V2CompensationScopeConcurrencyTest.php:29 only runs on pgsql.
Suggested fix:
- Lock order: lock first, and insert-or-ignore then lock only when the row is missing. Alternatively, create the graph row in its own short transaction.
- Retries: raise
$trieson the idempotent register, request, prepare and verify activities. - Test: add a concurrent
participate()test on MySQL.
B3. Host authorization and input validation run inside the native lock transaction. ScopeBoundary.php:37-66 calls ScopeAuthorization::assertAllowed() while it holds the attempt, execution, run and task rows (via DefaultActivityTaskBridge::status()), plus the scope row. On SQLite that is the whole database writer lock.
The same applies to:
HandlerRegistry::resolve()→ScopedOperation::assertInput()inadmitForward(ScopeProgress.php:45viaForwardOperationActivity.php:38-40);RegisterObligationActivity.php:24;- the child authorization check at
RegisterChildScopeActivity.php:30.
A typical host policy check calls a policy service, an identity provider or another database. That contradicts "No external call runs under a database lock" in docs/compensation-scopes.md, and it stalls the run's workflow tasks (on SQLite, every writer).
Suggested fix: run authorization and assertInput before opening the transaction. Inside it, re-check native authority and record the policy version already obtained. At minimum, document both interfaces as pure, local and with no I/O, and correct the docs.
B4. A terminated or cancelled recovery run strands the scope permanently (ScopeRecovery.php:85-102). If the latest canonical RecoveryWorkflow run is Cancelled or Terminated, every later request() throws The recovery is terminal without verified compensation. Only Failed runs are redriven, and release() requires verified.
Reproduced: recovery waiting on a retry → attemptTerminate() → the second request is refused and the scope stays requested. Waterline offers cancel and terminate on every run, so one operator click strands the obligations, which breaks the no-orphan contract.
Suggested fix: start a fresh run of the canonical instance after Cancelled/Terminated (with a test), or refuse cancel and terminate on the recovery workflow.
B5. The ownership boundaries have no tests. From reading the code and local probes:
- Continue-as-new or child retry reusing the identity: registration fails with a plain
LogicExceptionfromScopeRepository::open("cannot change its original run"). That is fail-closed, but it is not a typed non-retryable reason. The old obligations stayopenuntil someone callsScopeRecovery::request. - Origin redrive after the documented catch → compensate pattern: the history contains
FailureHandled, so redrive is refused withunsupported_history. The explicit request then verifies through recovery. - Origin redrive after an uncaught forward failure: redrive is accepted, and the successor's first non-reused scope activity is refused with
scope_execution_owner_mismatch.
Please:
- add tests that pin each case;
- add a typed non-retryable "scope owned by another run" reason;
- document that
generationmust be unique per originating run; - document that
request()on an active origin cancels the whole run, which compensates every Saga callback in it, including other scopes and non-scope compensations.
B6. Mark the API experimental and fix the persisted formats before any data exists.
- Markers: no new class has
@experimentalor@api, anddocs/api-stability.mdis unchanged.- Mark the types apps use
@experimental(precedent:StateMachine\TransitionRunner). - Mark the rest
@internal: the 7 activities,ChildResolution,PortableDescriptor,EvidenceReferencesandRecoveryWorkflow. - Add an entry to the api-stability doc.
- Mark the types apps use
- Identity hashes:
ScopeIdentity::id()(:25,35),Obligation::fingerprint()(:29,34),ForwardResultandResolutionhashget_object_vars($this). Renaming a property would change every scope id, recovery instance id and fingerprint, and every retained obligation would then be refused as tampered. Hash explicit, versioned arrays instead. - Activity type names: activities are stored in history by fully qualified class name. Add stable
#[Type('dw.compensation.….v1')]attributes. - History shape: the sequence of activities
DurableScoperuns (DurableScope.php:36-48,55-59) becomes part of every caller's history. Add a version marker (Workflow::getVersion) so it can change deterministically later.
Non-blocking
- N1 Naming. "Scope" collides with cancellation scopes (
cancellation_scope_id,ScopedCancellationContext,CancellationScope*). ConsiderCompensationScope,CompensationScopeIdentityandCompensationHandler, and an enum for the authorization operation strings (register/forward/request/compensate/participate/release). - N2 Hosts have no supported read path. They have to use the
@internalScopeRepository::inspectto see verified or unresolved work. Add an authorizedstatus(). - N3
ChildResolutionwaits for a child by spending activity retries (tries=3, native backoff). The tests pass because the sync queue finishes the child inline. On a real queue the parent recovery will usually run out of retries and need a re-request. Test with Redis or the database queue. - N4 A child scope that never opened blocks its parent forever (
testUnavailableChildCannotAdvanceEarlierParentCompensation). Consider writing a closed tombstone with zero obligations under the graph lock, so a late child origin is refused. - N5 The journal cap (
ScopeStorage.php:56, 8192 rows) also counts theauthorizedrow that every boundary writes. With 256 obligations plus retries and redrives, a scope can hit the cap, and then no outcome can be recorded. Reserve headroom, or stop journaling read-only boundaries. - N6
ScopeBoundary.php:67loads every obligation row and descriptor (up to 256 × 16 KiB)FOR UPDATEat each boundary, twice per activity. - N7
CompensateObligationActivity.php:57-59resolves the handler before checkingforward_status === 'registered', so a never-dispatched obligation is blocked if its handler was removed. - N8 Each obligation's compensation callback runs
RequestScopeActivityagain (DurableScope.php:39,57), which adds N redundant activities. - N9 Security.
- The principal is a plain string from workflow code, stored in activity arguments. Document that it must never come from webhook or HTTP start input.
- The fingerprint is checked against a SHA-256 stored in the same row, so it catches corruption but not someone with database write access. Say so, or use an HMAC.
ChildResolution.php:38discards the child policy version instead of validating and journaling it.
- N10 Tests.
- The cold-replay test only checks an effect counter. Also assert zero
workflow_compensation_*queries and noScopeAuthorizationcalls during replay. - Add a test that an unbound
ScopeAuthorizationfails closed. Today it fails withBindingResolutionException.
- The cold-replay test only checks an effect counter. Also assert zero
- N11 Docs and housekeeping.
- Trim the process notes from
docs/compensation-scopes.md(probe counts, upstream SHA, the previous draft's lease tests, Generator–Critic). - Add a CHANGELOG entry.
- The single-column
scope_idindexes on obligations and requests duplicate the leading column of the unique(scope_id, position)indexes. ScopeRepositoryduplicatesScopeStorage's table constants and hard-codes the requests table name.
- Trim the process notes from
- N12
RecoveryWorkflowstarts with no queue or connection options, so hosts can't route it.
The migration is fine:
- Connection: it uses
WorkflowMigrationand the configured storage connection. - MySQL 8: it creates cleanly, and identifier lengths are within the MySQL and Postgres limits.
- No foreign keys: retention cleanup can't cascade into the ledger, and
pruneRunkeeps the run row that recovery needs. - Rollback:
down()drops all six tables. - SQL Server: it can't run
insertOrIgnore, but core already depends on it, so this isn't a new regression.
Local results
| Run | Tree | Result |
|---|---|---|
| ECS + PHPStan, 44 changed PHP files | PR head | clean |
| Unit: Compensation, FailedRunRedrivePlan, redrive-rejection tests | PR head | 93/93 OK |
| Same, plus HistoryBudget | trial merge onto current main | 141 OK, 2 skipped |
Feature: compensation files + V2FailedRunRedriveTest, MySQL 8.0.46 + Redis |
PR head | 152 OK, 3 skipped (pgsql-only) |
| Feature: same, including #1109's tests, MySQL 8 | trial merge onto current main | 162/162 OK |
Before merge (maintainers)
- Full CI: run it from a maintainer branch. The fork skipped feature-mysql, feature-mariadb, coverage, the Laravel embedded upgrade matrix (relevant for the new migration) and SonarQube. MariaDB matters most because of B2.
- Security review of the authorization boundary: the principal trust model, B3, and the descriptor limits.
- Order: land the split-out redrive PR (B1) first.
Once B1–B6 are addressed we'll re-review promptly. Thanks again.
…workflow#1063) Merge current main and add continue-as-new and redrive regressions. Add independent planner evidence and replay-corpus guards.
|
Thanks @chrom. The follow-up in The formal review above (B1–B6) is still the bar for merge, and four of its blocking items are unchanged at
Once B1–B6 are in, we'll re-review straight away and run the full CI matrix the fork skips (feature-mysql/mariadb, coverage, the Laravel upgrade matrix, Sonar) from a maintainer branch. Security will review the authorization boundary at that point. Small note: please keep #1110's discussion on the PR. #1069 is now the Workflow team's nested-binding work. |
|
Workflow has reconciled the current head @chrom, please confirm whether you are taking the remaining corrections, starting with the independent redrive PR (B1). Workflow owns the review, the full maintainer CI matrix and Security coordination. If you no longer have capacity for a specific correction, identify it here so we can take that implementation rather than leave the dependent feature blocked. Current intake state: PR head |
|
Maintainer B1 follow-up is now draft #1131 at |
|
Maintainer continuation is now draft #1132 at |
…kflow#1063) Use lock-first adoption and bounded retries, move host callbacks outside SQL locks, and restart interrupted canonical recovery without repeating verified compensation. Version persisted descriptors and activity identities, clarify run ownership, and add regressions for concurrency, cancellation, recovery and replay.
|
Thanks, Richard. Understood: Workflow owns B1 in #1131 and the B2–B6 continuation in #1132. We are returning to the OMS work and have stopped our separate B1 split and further parallel DW implementation. The follow-up we had already prepared is now available in 671cf026, on top of
Full local verification of the content-hashed working candidate:
All three completed with zero failures/errors. ECS and PHPStan passed. V2 line coverage was 79.90%, above the 60.00% unit-only floor. Unit and coverage reported 2 PHPUnit deprecations and 15 notices. These full runs included the separate B1-only regression additions in the working tree. The B2–B6 source and test blobs in The initial Xdebug coverage attempt hit two fan-out process timeouts and one delayed-retry timestamp assertion failure. The final run used PCOV only in PHPUnit and file-based SQLite in tmpfs. The full suite and coverage gate passed without changing source code, assertions or time limits. For handoff, the commit is available for selective reuse on #1132. B2 already overlaps your maintainer work, so please take only the fixes or regression tests that remain useful. I have retained the content-hash manifest and JUnit/Clover results and can provide those as needed. This leaves implementation ownership with Workflow and avoids a second active workstream. Hosted CI, the full database matrix and exact-head Security review remain with your team. |
|
Maintainer status, November 14 — General owns this reconciliation; Workflow owns the remaining compensation implementation and qualification. This PR is still open at contributor head
The original contribution has not been merged wholesale, and durable retained scopes are not shipped by B1 or by passing partial checks. Issue #1063 remains open through actual foundation delivery. Workflow retains source, tests, independent review, normal integration and cleanup. General's following state-bound business bindings under #1069 remain a separate increment; their confirmed native child-start prerequisite is source-delivered in #1135. Workflow will post the final #1132 landing evidence here and resolve this original PR as superseded once that handoff is complete. No additional correction is currently requested from the contributor. |
|
Superseded by the maintainer-owned implementation in #1131 and #1132. The retained-scope source is accepted as main The parent issue #1063 remains open for supported published delivery, and the separate #1069 state-machine integration continues under its existing ownership. No further contributor corrections are requested here. This closes the duplicate implementation stream; the accepted foundation remains experimental and unreleased, with the #1130 stop and absence of 2.29 publication selection preserved. |
Problem
Business cancellation may require compensation after the originating workflow or a child has completed. Run-local Saga callbacks alone do not provide separately addressable, retained compensation obligations.
Changes
Verification
All completed local checks passed:
Unit line coverage: 79.90%, above the 60.00% threshold.
ECS, PHPStan, feature-shard inventory and regression-corpus validation passed.
Generator–Critic review finished with PASS.
The unit run reported 2 PHPUnit deprecations and 15 notices.
Initial limits
Embedded Laravel and sequential compensation only. One nesting level, explicit release, and no forward ownership transfer across continue-as-new or originating-run redrive.
The host supplies authorization, compatible handlers, provider idempotency and authoritative effect verification. Upstream CI qualification and production-provider validation remain pending.
Refs #1063.