waffle-commons / async
Fiber-based finish-request deferred task runner for Waffle Commons: short post-response work lifted out of the user-perceived latency path, under a bounded per-request budget.
Requires
- php: ^8.5
- psr/log: ^3.0
- waffle-commons/contracts: 0.1.0-beta6
Requires (Dev)
- carthage-software/mago: ^1.29
- cyclonedx/cyclonedx-php-composer: ^6.2
- igor-php/igor-php: ^0.7.0
- php-mock/php-mock-phpunit: ^2.15
- phpunit/phpunit: ^12.5
- vimeo/psalm: ^6.16
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-08 20:26:49 UTC
README
Waffle Async Component
Release:
0.1.0-beta6|CHANGELOG.mdRFC: RFC-015 (ASYNC-01) — Fiber-based finish-request task deferral
A bounded, worker-safe runner that lifts short post-response work — mail delivery, webhook fan-out, audit
writes — out of the user-perceived latency path. Tasks deferred during a request run after the response is
flushed to FrankenPHP but before the worker accepts its next request, each inside its own native Fiber for
failure isolation. This is finish-request deferral, not background processing: the work runs on the same
single worker thread, sequentially, under a hard per-request budget.
The concurrent half of RFC-015 — outbound HTTP fan-out via
ConcurrentClientInterface— ships inwaffle-commons/http-client, joined to this package only throughwaffle-commons/contracts.
📦 Installation
composer require waffle-commons/async
🧱 Surface
| Class | Role |
|---|---|
Waffle\Commons\Async\DeferredTaskRunner |
final runner implementing TaskRunnerInterface and ResettableInterface (declared directly, as igor-php requires). Holds the request-scoped pending queue. |
Waffle\Commons\Async\Exception\DeferralBudgetExceededException |
Thrown by defer() once the per-request budget is exhausted. Implements the contract DeferralBudgetExceededExceptionInterface; exposes budget(): int. |
Waffle\Commons\Async\Exception\InvalidBudgetException |
Thrown at construction when $budget < 1. Implements the contract AsyncExceptionInterface; exposes budget(): int. |
The contracts — Waffle\Commons\Contracts\Async\TaskRunnerInterface, DeferredTaskInterface,
Exception\AsyncExceptionInterface, Exception\DeferralBudgetExceededExceptionInterface — live in
waffle-commons/contracts. Consumers (controllers, the kernel) depend on those interfaces, never on this
concrete package.
🚀 Usage
use Psr\Log\LoggerInterface; use Waffle\Commons\Async\DeferredTaskRunner; use Waffle\Commons\Contracts\Async\DeferredTaskInterface; use Waffle\Commons\Contracts\Async\TaskRunnerInterface; // 1. A self-contained, worker-safe task. final readonly class SendReceiptTask implements DeferredTaskInterface { public function __construct( private LoggerInterface $logger, private string $orderId, ) {} #[\Override] public function run(): void { // short post-response work: mail / webhook / audit write $this->logger->info("receipt sent for {$this->orderId}"); } #[\Override] public function name(): string { return 'order.receipt'; } } // 2. Registered once per worker iteration — the kernel does this (see "Wiring" below). $runner = new DeferredTaskRunner(budget: DeferredTaskRunner::DEFAULT_BUDGET, logger: $logger); // 3. In a handler — defer, return immediately, the kernel drains on TerminateEvent. function placeOrder(TaskRunnerInterface $runner): ResponseInterface { $runner->defer(new SendReceiptTask($logger, $orderId)); // queued, not run yet return jsonResponse(['pending' => $runner->pending()]); // ── response flushed ── then run() executes SendReceiptTask in an isolated Fiber. }
⏱️ The runner
public const int DEFAULT_BUDGET = 64; public function __construct( int $budget = self::DEFAULT_BUDGET, // hard ceiling on tasks deferred per request (>= 1) LoggerInterface $logger = new NullLogger(), // PSR-3; receives per-task failure/abandon logs ); public function defer(DeferredTaskInterface $task): void; // throws DeferralBudgetExceededException at the ceiling public function run(): void; // snapshot, clear, then drain each task in its own Fiber public function pending(): int; // tasks currently queued this request public function reset(): void; // worker reset: empty the pending queue
- Budget guard. A
$budgetbelow1throwsInvalidBudgetExceptionat construction — a budget that rejects every deferral is a programming error, refused eagerly.DEFAULT_BUDGETis64. defer()appends to a request-scopedlist<DeferredTaskInterface>; oncepending() >= $budgetit throwsDeferralBudgetExceededExceptioninstead of enqueuing — the explicit signal to move the workload to a real queue or broker.run()snapshots the pending queue and clears it before draining, so a task that itself defers another task cannot grow the queue being drained, and the queue is empty even if the drain throws.- Fiber lifecycle, per task. The runner
start()s the task'sFiber; if it is not terminated it isresume()d exactly once — the single cooperative resume, there is no scheduler loop. If it is still not terminated it is abandoned with awarninglog. The Fiber is then force-destroyed (unset) inside the sametry, so a throwing destructor/finallyis caught and logged rather than detonating as a fatal that would abort the sibling tasks. - Failure isolation. Any
\Throwablefrom a task is caught and logged aterrorlevel usingDeferredTaskInterface::name()as the label; siblings continue running. reset()empties the pending queue — the only request-scoped state — so deferred work never bleeds across the FrankenPHP worker boundary.
🧵 Worker safety
DeferredTaskRunner's pending queue is the only request-scoped state. It implements ResettableInterface
directly (igor-php requires the direct declaration, not one inherited transitively via
TaskRunnerInterface), and the kernel calls reset() after every request so no deferred work ever crosses
the worker boundary (wfl igor 0 KO). Fibers here are a failure-isolation boundary, not a thread pool —
there is exactly one OS thread; two deferred tasks never run simultaneously, they run one after another
during finish-request.
Finish-request deferral is deliberately not background processing: it trades a slice of worker throughput for perceived latency, bounded by the hard per-request budget rather than a wall-clock timeout. Routinely wanting to defer dozens of tasks is the tripwire that the work belongs on a real queue/broker instead, not in this budget.
🔌 Wiring (kernel package)
The finish-request drain itself is not in waffle-commons/async — the package stays contracts-only. The
kernel package wires:
Waffle\Event\Listener\DeferredTaskFlushListener(final readonly) — subscribed to the kernel'sTerminateEvent(fires after the response is flushed); its__invoke()calls$runner->run()inside a catch-all, so a throwable escaping the drain cannot reach the client.- The template
AppKernelFactoryregistersDeferredTaskRunnerunderTaskRunnerInterface::classwithDeferredTaskRunner::DEFAULT_BUDGET, and adds the flush listener onTerminateEvent.
🐘 PHP 8.5 features used
final class DeferredTaskRunnerwith promoted,readonlyconstructor properties ($budget,$logger).- Native
Fiberas a per-task isolation boundary (start()/resume()/isTerminated()). public const int DEFAULT_BUDGET— typed class constant.#[\Override]on every interface implementation.
🧭 Architectural boundary (mago guard)
An active dependency perimeter is enforced on every CI run by vendor/bin/mago guard (bundled into
composer mago; zero baselines). The rules live in mago.toml under [guard.perimeter] — a
forbidden use statement fails the build, not a reviewer.
Production code under Waffle\Commons\Async may depend only on:
Waffle\Commons\Async\**— itselfWaffle\Commons\Contracts\**— the shared contracts package, the only Waffle dependency permittedPsr\**— PSR interfaces (psr/log)@global+Psl\**— PHP core (including nativeFiber) and the PHP Standard Library
Test code under WaffleTests\Commons\Async is unrestricted (@all). Structural rules are guarded too:
interfaces must be named *Interface, Exception\** classes must end in *Exception, and any Enum\**
namespace may hold only enum declarations.
Contract-first, component-agnostic by construction: components compose through waffle-commons/contracts,
never directly through one another.
🧪 Testing
docker exec -w /waffle-commons/async waffle-dev composer tests
📚 Documentation
Central framework docs (Diátaxis) for this component:
- Reference:
reference/async.md - Explanation:
explanation/async-finish-request-deferral.md - Full documentation tree: waffle-commons/documentation
📄 License
MIT — see LICENSE.md.