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.
Define a workflow
Section titled “Define a workflow”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.
Run due work
Section titled “Run due work”$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';});State contract
Section titled “State contract”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.
Delivery semantics
Section titled “Delivery semantics”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.