Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
},
"scripts": {
"ecs": "vendor/bin/ecs check --fix",
"stan": "vendor/bin/phpstan analyse src tests",
"stan": "vendor/bin/phpstan analyse src tests examples/business-state-machines",
"feature": [
"Composer\\Config::disableProcessTimeout",
"phpunit --testdox --testsuite feature"
Expand Down
4 changes: 3 additions & 1 deletion docs/business-state-machines.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,9 @@ An application commit adapter must:

An immutable snapshot is not a concurrency fence. The runner checks snapshot correlation and source consistency, but the application activity must implement the transaction, operation admission and provider reconciliation. A database fence cannot recall a request already sent to a provider.

For separate application and DW stores, the application's decision record is the business commit authority. Use an explicit durable handoff and idempotent outcome lookup. No distributed transaction is implied. Shared-store and cross-store persistence race guarantees are not demonstrated by this slice's in-memory runner tests.
For separate application and DW stores, the application's decision record is the business commit authority. Use an explicit durable handoff and idempotent outcome lookup. No distributed transaction is implied.

The [SQL business-decision example](../examples/business-state-machines/README.md) supplies an application-owned reference boundary and real database tests for cancellation/completion, authority revocation, atomic publication intent and lost acknowledgement. It does not replace application authentication, evidence validation, provider fencing or publication delivery. The in-memory runner tests alone do not establish persistence race guarantees.

## Compensation and nested-machine boundary

Expand Down
31 changes: 31 additions & 0 deletions examples/business-state-machines/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# SQL business decisions

This application-side example implements the `TransitionRuntime::commit()` persistence boundary using Laravel's database connection. It uses application tables in `schema.php`; it does not add DW migrations or change native workflow storage. Invoke `SqlBusinessStore` from a recorded application activity.

The subject row is the common fence for completion, cancellation, and authority changes. The store checks the original state and revision, then writes the new state, immutable decision, and publication intent in one transaction. Retrying an identical decision after losing its acknowledgement returns the original result. Reusing its identity with another subject or content refuses. A competing transition gets a revision conflict.

Cancellation takes that same row lock. Cancellation accepted first prevents the completion commit. Completion accepted first makes cancellation against the old revision a conflict. `cancellation_requested` means business forward completion is fenced; it does not mean external effects stopped, compensation succeeded, or the business reached a Cancelled state.

The trusted authorization callback receives the full execution, principal, operation, and proposed transition context. It must authenticate the principal, enforce the exact pinned profile and transition, and verify the assessment against authoritative immutable facts. Returning a version for a caller-supplied `satisfied` value is unsafe. The subject row must still authorize that principal and version under lock. Revocation must update the same subject row transactionally. The callback runs outside SQL locks, and caller-owned transactions refuse.

The example's host-policy fixture uses a closed profile and a fixed immutable assessment. Replace it with the application's own trusted registry and retained facts. A serialized scope, profile fingerprint, principal name, or snapshot ID is not authentication or evidence by itself.

Run the executable SQL example tests from the repository root:

```bash
vendor/bin/phpunit tests/Unit/V2/StateMachine/BusinessCommitTest.php
```

They exercise the actual example implementation on SQLite: lost acknowledgement, cancellation before/after completion, competing revisions, publication failure rollback, decision reuse, authorization revocation, foreign principals, transaction ownership, and profile substitution. The test does not require a provider or a running workflow worker.

For the separate-process race tests, use the repository's configured MySQL/MariaDB or PostgreSQL test database:

```bash
vendor/bin/phpunit tests/Feature/V2/V2BusinessCommitConcurrencyTest.php
```

The six cases observe the competing `SELECT ... FOR UPDATE` before releasing the winner. Both cancellation/completion orderings, both revocation/completion orderings, duplicate completion, and competing completion use separate processes and connections. SQLite and hosts without `pcntl` explicitly skip this race suite.

The publication table stores an intent. A real application still needs an idempotent dispatcher and consumer; this transaction does not atomically publish to an external broker. Separate business and DW stores communicate through the recorded commit activity and exact decision identity, without a distributed transaction. Activity retries must retain the original request; they must not repeat provider effects while recovering a lost business commit acknowledgement.

This example covers business persistence. Provider admission/fencing, uncertain effects, retained child compensation, the complete two-integration application workflow, policy migration, and resource budgets remain separate parts of the business state-machine work. The SQL tests do not establish those guarantees.
204 changes: 204 additions & 0 deletions examples/business-state-machines/SqlBusinessStore.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
<?php

declare(strict_types=1);

namespace DurableWorkflow\Examples\BusinessStateMachines;

use Closure;
use Illuminate\Database\ConnectionInterface;
use LogicException;
use Workflow\V2\StateMachine\Execution\ExecutionReference;
use Workflow\V2\StateMachine\Execution\TransitionStatus;
use Workflow\V2\StateMachine\Persistence\CommitResult;
use Workflow\V2\StateMachine\Transition\SnapshotPhase;
use Workflow\V2\StateMachine\Transition\TransitionContext;

/**
* Application-owned SQL boundary, called from a recorded activity, never workflow code.
* This example owns no DW tables, provider requests, queue, or publication dispatcher.
*/
final readonly class SqlBusinessStore
{
/**
* The trusted host authenticates the principal and authorizes this subject/operation.
* For a commit it must also validate the complete transition against the pinned profile
* and the assessment against authoritative immutable facts; caller-supplied "satisfied"
* is not evidence. It returns the authority version still required under the row lock.
*
* @param Closure(ExecutionReference, string, string, ?TransitionContext): string $authorize
*/
public function __construct(
private ConnectionInterface $db,
private Closure $authorize
) {
}

public function commit(TransitionContext $context, string $principal): CommitResult
{
$execution = $context->execution();
$authority = $this->authorize($execution, $principal, 'commit', $context);
if (! in_array($context->snapshot->phase, [SnapshotPhase::Before, SnapshotPhase::After], true)
|| $context->snapshot->transitionKey !== $context->transition->key
|| ! in_array($execution->sourceState, $context->transition->from, true)
|| $context->snapshot->state !== $execution->sourceState
|| $context->snapshot->revision !== $execution->sourceRevision
|| ! $context->snapshot->target->satisfied()) {
throw new LogicException('The protected commit requires the matching satisfied assessment.');
}
$request = [
'execution' => $execution->toArray(),
'transition' => get_object_vars($context->transition),
'snapshot' => [
'id' => $context->snapshot->snapshotId,
'phase' => $context->snapshot->phase->value,
'target' => $context->snapshot->target->toArray(),
'permission' => $context->snapshot->permission->toArray(),
],
];
$id = self::decisionId($execution, 'transition', $execution->executionId);
return $this->decide($execution, $principal, $authority, $id, $request, false);
}

/**
* Accepted business cancellation closes forward commits; it does not verify compensation.
*/
public function requestCancellation(
ExecutionReference $execution,
string $requestId,
string $principal,
): CommitResult {
$authority = $this->authorize($execution, $principal, 'cancel');
if (trim($requestId) === '') {
throw new LogicException('Cancellation requires an immutable request identity.');
}
return $this->decide(
$execution,
$principal,
$authority,
self::decisionId($execution, 'cancellation', $requestId),
[
'execution' => $execution->toArray(),
'requestId' => $requestId,
],
true,
);
}

public static function subjectId(ExecutionReference $execution): string
{
return hash('sha256', json_encode([$execution->scope, $execution->subject], JSON_THROW_ON_ERROR));
}

public static function decisionId(ExecutionReference $execution, string $kind, string $key): string
{
// A decision ID cannot be reused for another subject inside the same authorized scope.
return hash('sha256', json_encode([$execution->scope, $kind, $key], JSON_THROW_ON_ERROR));
}

private function authorize(
ExecutionReference $execution,
string $principal,
string $operation,
?TransitionContext $context = null,
): string {
if ($this->db->transactionLevel() !== 0) {
throw new LogicException('The business boundary must own its transaction.');
}
$authority = ($this->authorize)($execution, $principal, $operation, $context);
if ($this->db->transactionLevel() !== 0 || trim($authority) === '' || strlen($authority) > 128) {
throw new LogicException('Host authorization must return a bounded version outside SQL locks.');
}
return $authority;
}

/**
* @param array<string, mixed> $request
*/
private function decide(

Check failure on line 117 in examples/business-state-machines/SqlBusinessStore.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 18 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=durable-workflow_workflow&issues=AaHaofs7H6_68wC1zK1s&open=AaHaofs7H6_68wC1zK1s&pullRequest=1148
ExecutionReference $execution,
string $principal,
string $authority,
string $id,
array $request,
bool $cancellation,
): CommitResult {
$fingerprint = hash('sha256', json_encode($request, JSON_THROW_ON_ERROR));
return $this->db->transaction(function () use (
$execution,
$principal,
$authority,
$id,
$request,
$fingerprint,
$cancellation,
): CommitResult {
// Cancellation, authority revocation and completion must all serialize on this row.
$subject = $this->db->table('example_machine_subjects')
->where('id', self::subjectId($execution))->lockForUpdate()->first();
if ($subject === null || $subject->scope !== $execution->scope
|| $subject->subject !== $execution->subject || $subject->principal !== $principal
|| $subject->authority_version !== $authority) {
throw new LogicException('The original subject authority is not current.');
}
$existing = $this->db->table('example_machine_decisions')
->where('id', $id)
->first();
if ($existing !== null) {
if ($existing->subject_id !== $subject->id || $existing->request_sha256 !== $fingerprint) {
throw new LogicException('The decision identity was reused with different content.');
}
// Lost acknowledgement: return the original decision even after later cancellation/state changes.
return new CommitResult($execution, TransitionStatus::from($existing->status), $existing->reason);
}
if ((bool) $subject->cancellation_requested) {
$status = TransitionStatus::CancellationRequested;
$reason = 'business_cancellation_requested';
} elseif ($subject->state !== $execution->sourceState
|| (string) $subject->revision !== $execution->sourceRevision) {
$status = TransitionStatus::Conflict;
$reason = 'source_changed';
} elseif ($cancellation) {
$this->db->table('example_machine_subjects')
->where('id', $subject->id)
->update([
'cancellation_requested' => true,
]);
$status = TransitionStatus::CancellationRequested;
$reason = 'business_cancellation_requested';
} else {
if ((int) $subject->revision >= PHP_INT_MAX) {
throw new LogicException('The subject revision is exhausted.');
}
$revision = (int) $subject->revision + 1;
$this->db->table('example_machine_subjects')
->where('id', $subject->id)
->update([
'state' => $request['transition']['to'],
'revision' => $revision,
]);
$this->db->table('example_machine_publications')
->insert([
'id' => $id,
'subject_id' => $subject->id,
'payload' => json_encode([
'execution' => $execution->toArray(),
'transitionKey' => $request['transition']['key'],
'state' => $request['transition']['to'],
'revision' => (string) $revision,
], JSON_THROW_ON_ERROR),
]);
$status = TransitionStatus::Committed;
$reason = 'business_state_committed';
}
$this->db->table('example_machine_decisions')
->insert([
'id' => $id,
'subject_id' => $subject->id,
'request_sha256' => $fingerprint,
'status' => $status->value,
'reason' => $reason,
]);
return new CommitResult($execution, $status, $reason);
});
}
}
36 changes: 36 additions & 0 deletions examples/business-state-machines/schema.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Schema\Builder;

/** Install only in the example application's disposable database, not in DW migrations. */
return static function (Builder $schema): void {
$schema->create('example_machine_subjects', static function (Blueprint $table): void {
$table->string('id', 64)
->primary();
$table->string('scope');
$table->string('subject');
$table->string('principal');
$table->string('authority_version', 128);
$table->string('state');
$table->bigInteger('revision');
$table->boolean('cancellation_requested')
->default(false);
});
$schema->create('example_machine_decisions', static function (Blueprint $table): void {
$table->string('id', 64)
->primary();
$table->string('subject_id', 64);
$table->string('request_sha256', 64);
$table->string('status');
$table->string('reason');
});
$schema->create('example_machine_publications', static function (Blueprint $table): void {
$table->string('id', 64)
->primary();
$table->string('subject_id', 64);
$table->text('payload');
});
};
2 changes: 2 additions & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
parameters:
level: 0
scanDirectories:
- examples/business-state-machines
scanFiles:
- scripts/ci/check-committed-composer-locks.php
Loading
Loading