Skip to content

Durable workflows

PAM 0.1.34 and newer provide an embedded durable workflow engine backed by SQLite WAL. Definitions stay in typed PHP while instance state, step history, attempts, results, timers, errors, leases, and compensation progress survive process restarts.

use Pam\Workflow\Context;
use Pam\Workflow\Definition;
use Pam\Workflow\Engine;
use Pam\Workflow\RetryPolicy;
use Pam\Workflow\Scheduler;
use Pam\Workflow\Step;
use Pam\Workflow\Store;
$orders = new Definition('order.fulfill', version: 1, steps: [
new Step(
'charge',
static function (Context $context): string {
return charge(
$context->input['orderId'],
$context->idempotencyKey(),
);
},
retry: new RetryPolicy(
maxAttempts: 5,
initialDelaySeconds: 1,
multiplier: 2,
),
compensation: static function (
Context $context,
mixed $charge,
): void {
refund($charge, $context->idempotencyKey());
},
),
new Step(
'ship',
static fn (Context $context): string => ship(
$context->results['charge'],
$context->idempotencyKey(),
),
),
]);
$store = new Store(storage_path('pam/workflows.sqlite'));
$engine = (new Engine($store))->register($orders);
$instance = $engine->start(
'order.fulfill',
['orderId' => 42],
idempotencyKey: 'order-42',
);

Calling start() again with the same definition and idempotency key returns the existing instance. A key cannot silently cross definition versions.

$scheduler = new Scheduler(
$store,
$engine,
owner: gethostname() . ':' . getmypid(),
leaseSeconds: 30,
);
$tick = $scheduler->tick(limit: 100);

Each tick atomically claims a bounded set of due instances. SQLite BEGIN IMMEDIATE serializes competing claim transactions; an owner and expiry prevent two healthy scheduler processes on the same host from receiving the same instance.

Expired leases make interrupted pending, running, waiting, and compensating instances eligible again. The engine checks ownership before and after every activity or compensation. A worker that lost its lease cannot persist stale success or failure.

For an activity longer than the lease, renew at safe checkpoints:

new Step('archive', static function (Context $context): string {
foreach (archiveChunks($context->input['path']) as $chunk) {
persistChunk($chunk, $context->idempotencyKey());
$context->heartbeat();
}
return 'archived';
});

Instance states are sequential integer enums:

Value State
1 Pending
2 Running
3 Waiting
4 Completed
5 Failed
6 Compensating
7 Compensated

Retries persist an absolute next_run_at. Completed results are reused rather than recomputed. Terminal failures compensate completed steps in reverse order; an interrupted compensation resumes from the stored step states.

The local history and scheduler lease are durable, but arbitrary external side effects cannot be made exactly-once by a local database. Pass $context->idempotencyKey() to payment, mail, storage, queues, and compensations. Receivers must deduplicate it because a process can stop after an external commit and before recording the local result.

SQLite WAL is the supported single-host store. Multiple local processes are safe; unsupported network filesystems and geographically distributed workflow storage are outside this embedded contract.