Skip to main content

Async platforms — when to use which

What this covers / who it's for. The three ways background work runs in this app — plain queued jobs on Horizon, the task-runner execution ledger, and the async-requests poll platform — and the decision guide for picking (or combining) them. Read this before adding any job, sweep, or "kick off and poll" feature.

The decision guide (start here)

Your work is…UseWhy
A fire-and-forget side effect of one action (forward a document, persist an audit row, process a webhook)Plain queued job on a dedicated Horizon queueNo durable ledger needed; queue-native retries are the recovery story
Multi-unit tracked work — "run something for every tenant / every file / every work item" — or a single job that must be durably trackedTask-runner (D93)One batch per run, one job per unit, a durable task_runs/task_run_units ledger, per-unit failure containment, operator retry/cancel/stuck tooling
A user-visible long-running request the SPA kicks off and polls (exports, statements, computed answers)Async-requests (D83)The tenant-scoped pollable request store with a typed link | confirmation | answer result
Fleet work whose per-tenant outcome is user-visibleBoth — task-runner fans out, each unit job feeds an async requestMonthly statements are the worked example (below)

The two platforms are deliberately decoupled siblings: task-runner tracks internal execution (central ledger, operator web-guard surface), async-requests tracks client-visible request state (tenant-RLS store, client-guard poll). Neither imports the other; a consumer job may feed both.

(a) Plain queued jobs on Horizon

Six supervisors, one queue each — config/horizon.php carries the authoritative values and the per-queue rationale comments. Per-queue isolation exists so one workload's backlog or serialization never head-of-line-blocks another:

QueueSupervisortries / timeoutCarriesIsolation rationale
defaultsupervisor-11 / 60severything unroutedcatch-all
webhookssupervisor-webhooks5 / 120sProcessInboundWebhookJobupstream is already 200-acked — transient failures must retry, never drop
documentssupervisor-documents5 / 120sForwardDocumentToProviderJob, ScanDocumentJobstore-before-forward: retry rather than strand a document in stored
auditsupervisor-audit5 / 120sPersistAuditEventJob (async success path)a dropped append leaves no detectable gap — retry then alert
dlpsupervisor-dlp5 / 120sPersistDataAccessLogJobevery Pii/Secret read logs here; no consumer = silent data loss
taskssupervisor-tasks1 / 300stask-runner unit jobsthe deliberately low worker count is the DB-load throttle; tries=1 because units contain their own failures

waits alerting is 60s everywhere except tasks (900s — sweeps are batch work). Local-dev caveat: the docker worker service runs plain queue:work on default only; run Horizon (make artisan ARGS=horizon) to drain the five specialized queues locally.

(b) Task-runner — the durable execution ledger (D93)

app-modules/task-runner/README.md has the specifics; the task-runner skill teaches adoption. The shape: any multi-unit background task becomes a Laravel batch with one job per unit, ledgered on the central task_runs / task_run_units tables (unit tenant_id is an opaque no-FK bigint — per-unit tenancy is the consumer job's responsibility, initialized in try/ended in finally).

  • Define + register: a TaskDefinition (type(), units(), makeJob(), options()) registered via TaskTypeRegistry::register(...) in the consumer's provider boot() — the same registry inversion as webhooks; task-runner never imports a consumer.
  • Start: app(TaskRunner::class)->start($definition, $context, initiator: ...) — persists the run + unit rows (chunked), dispatches a Bus::batch() with allowFailures().
  • Failure containment: a fleet unit's throwable is recorded on its ledger row (redacted last_error — the dlp module provides the ScrubsLedgerText masking seam) and not rethrown: one tenant's failure never aborts the sweep. Recovery is tasks:retry <run> [--failed-only] or the next scheduled run — never a queue re-attempt.
  • Throttling: per-type named rate limiter task-runner.<type> driven by TaskOptions->ratePerMinute; throttled tails are held (not failed) up to the retry_horizon_hours drain horizon.
  • Operator surface: console tasks:status / tasks:retry / tasks:cancel / tasks:prune / tasks:detect-stuck, plus the read-only backoffice API GET /api/v1/backoffice/task-runs (+ /{uuid}), web guard, permission task-runner.runs.view.
  • Stuck detection: tasks:detect-stuck (hourly) surfaces units sitting running past stuck_after_minutes (default 120, per-type override) — it never auto-fails them.
  • Retention: tasks:prune (daily) force-deletes terminal runs past retention_days (default 90); queue:prune-batches (daily) trims the underlying job_batches.

Adopters today: payments.monthly_statements, provider-mirror.reconcile_stale, documents.redrive_forward_failed, features.roll_periods (the four converted scheduled sweeps), plus documents' ForwardDocumentToProviderJob as the tracked single-job case.

Run and unit state machines

(c) Async-requests — the client-facing poll platform (D83)

app-modules/async-requests/README.md has the specifics; the async-requests-platform skill teaches adoption. One tenant-scoped, forced-RLS table (async_requests) behind the published Integration\Contracts\AsyncRequestPlatform seam — create / markProcessing / complete / fail, every transition type-checked against the caller's own $expectedType (a foreign type reads as 404 ASYNC_REQUEST_NOT_FOUND; a terminal row rejects transitions with 409 ASYNC_REQUEST_ALREADY_TERMINAL). The platform runs no jobs and knows no request types — the consumer owns its job and queue choice.

  • Typed result: link ({url, expires_at}), confirmation ({confirmed, message}), or answer (consumer-defined payload).
  • Client surface: GET /api/v1/async-requests/{uuid} (poll) and GET /api/v1/async-requests (filtered, cursor-paginated list), both client guard + permission async-requests.view.
  • Retention: async-requests:prune (daily) force-deletes terminal rows whose completed_at is past retention_days (default 30 — distinct from task-runner's 90); it never touches object-storage artifacts.
  • Live consumers today: payments only — payments.transactions_export and payments.monthly_statement, both completing with a link to a signed, permission-gated download. (Notification CTAs can point at a poll uuid as plain data, but notification is not a platform consumer.)

Combining them: monthly statements, the worked example

payments:generate-monthly-statements (scheduled monthly) starts the task-runner type payments.monthly_statements — one unit per eligible tenant. Each unit job (GenerateTenantMonthlyStatementJob) is tracked on the task-runner ledger and creates, processes, and completes that tenant's payments.monthly_statement async request with a link result. Operators watch the fleet on the task-runs ledger; each tenant polls their own request.

Mind the two nearly-identical type keys: payments.monthly_statements (plural — the task-runner type) vs payments.monthly_statement (singular — the async-request type).


← Engineering wiki index