Appearance
Deferred Task Runner
Flow ID: SY-27 | Module(s): core (cross-cutting) | Complexity: Medium Last Updated: 2026-05-07
Business Context
Every storefront page view generates secondary work -- tracking pixels, analytics events, CRM syncs, recommendation-engine signals. If these outbound HTTP calls execute inline, they add hundreds of milliseconds to TTFB, directly hurting conversion rates and SEO rankings.
The Deferred Task Runner solves this by decoupling secondary work from the user-facing response. Controllers register closures during normal request processing. After CodeIgniter finishes and the response bytes are flushed to the client (via fastcgi_finish_request()), the PHP-FPM worker continues executing those closures within a configurable time budget. From the user's perspective the page is already loaded; from the server's perspective the worker is still busy for a few more seconds cleaning up fire-and-forget calls.
This is a lightweight, single-process alternative to a message queue. It trades durability (tasks are lost if the worker crashes mid-execution) for zero operational overhead (no broker, no consumer, no retry infrastructure). The trade-off is acceptable because every deferred task in the platform today is a non-critical tracking call -- the source-of-truth (e.g., the order itself) is already committed to the database before deferral happens.
API Reference
Public API
The runner is accessed through the DI container as a public service:
php
/** @var DeferredTaskRunner $runner */
$runner = di()->get('deferred_task');| Method | Signature | Description |
|---|---|---|
defer() | defer(Closure $task, int $maxCostSeconds, int $priority = 50, string $name = '') | Enqueue a task for post-response execution |
run() | run(): void | Execute all queued tasks (called automatically by post_system hook) |
hasTasks() | hasTasks(): bool | Whether any tasks are currently queued |
Priority Constants
| Constant | Value | Semantics |
|---|---|---|
PRIORITY_CRITICAL | 100 | Revenue-affecting events -- executes first, least likely to be budget-skipped |
PRIORITY_NORMAL | 50 | Standard tracking (page views, add-to-cart, bookmarks) |
PRIORITY_LOW | 10 | Best-effort analytics (Matomo bulk flush) -- first to be dropped under budget pressure |
defer() Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
task | \Closure | Yes | Zero-argument closure containing the work. Capture variables by value via arrow function or use. |
maxCostSeconds | int | Yes | Declared worst-case duration (seconds). Used for skip decisions -- the task is not started if this exceeds remaining budget. |
priority | int | No | Execution order weight (higher = sooner). Defaults to PRIORITY_NORMAL (50). |
name | string | No | Identifier for logging. Convention: service.action (e.g., meta.purchase, advisable_ai.view). |
Code Flow
1. Task Registration (During Request)
Controllers queue deferred work at various points during request processing. Each call to defer() inserts a DeferredTask value object into an internal SplPriorityQueue:
Controller action (e.g., Adv_checkout::thank_you_page)
-> Build event payload ($customData, $userData)
-> Capture service reference: $fb = $this->facebookConversionService
-> di()->get('deferred_task')->defer(
task: fn() => $fb->dispatchEvent(EventType::PURCHASE, $customData, $userData),
maxCostSeconds: $fb->getMaxCostSeconds(),
priority: DeferredTaskRunner::PRIORITY_CRITICAL,
name: 'meta.purchase',
)Multiple tasks accumulate across the request. A checkout completion page may queue 3-4 tasks (Meta purchase, Manago CRM event, Advisable AI purchase, ProjectAgora order).
2. Hook Trigger (After Response)
CodeIgniter's post_system hook fires after the response is assembled:
php
// application/config/hooks.php
$hook['post_system'] = function () {
if (function_exists('di') && di()->has('deferred_task')) {
di()->get('deferred_task')->run();
}
};The guard clause ensures safety during CLI execution or when the DI container is unavailable.
3. Response Flush & Detach
When run() is called, it first detaches from the client:
run()
-> tasks empty? return early
-> disabled mode? runInline() (no flush, no budget -- legacy path)
-> flushResponse():
1. session_write_close() // Release session lock
2. fastcgi_finish_request() // Flush response, detach from SAPI
OR litespeed_finish_request() // LiteSpeed equivalent
OR no-op // mod_php / CLI -- tasks block response
-> applyTimeLimit() // Adjust PHP max_execution_timeFrom this point forward, the client has received the full HTTP response. The PHP-FPM worker continues executing in the background.
4. Priority Queue Drain with Budget Enforcement
Budget: 10s (configurable)
Remaining = 10
while (queue not empty):
task = queue.extract() // Highest priority first (SplPriorityQueue)
if task.maxCostSeconds > remaining:
skip task, log name // Declared cost exceeds budget
continue
start = microtime(true)
try:
task.callback() // Execute the closure
catch Throwable:
log warning // Isolate failure, continue
elapsed = ceil(microtime(true) - start)
remaining -= elapsed // Track ACTUAL time spent (not declared cost)
if remaining <= 0:
drain remaining tasks as skipped
break
if skipped tasks:
log notice with skipped namesKey design decisions in the drain loop:
- Skip decision uses
maxCostSeconds(declared/pessimistic) to avoid starting tasks that could overrun. - Budget tracking uses actual elapsed time to accurately reflect reality. A task declaring 5s but finishing in 1s frees 4s for subsequent tasks.
- Exception isolation: A failing task does not prevent subsequent tasks from executing. Errors are logged at
warninglevel. - Budget exhaustion: When remaining time drops to zero or below, all remaining tasks in the queue are drained and logged as skipped at
noticelevel.
5. Time Limit Adjustment
The runner negotiates with PHP's max_execution_time to set a safe time limit:
max_execution_time (ini) | timeBudgetSeconds (config) | Outcome |
|---|---|---|
| 30 | 10 | set_time_limit(10) -- tighten below ini |
| 30 | 40 | No call -- ini is already tighter |
| 0 (unlimited) | 10 | set_time_limit(10) -- impose budget |
| 30 | 0 (unlimited budget) | No call -- ini governs |
| 0 | 0 | No call -- fully unconstrained |
The runner estimates consumed time via REQUEST_TIME_FLOAT (wall-clock), which conservatively overestimates CPU time on Linux.
Visual Overview
[HTTP Request arrives]
|
v
[Controller logic]
defer(meta.purchase, CRITICAL, 3s)
defer(advisable_ai.purchase, CRITICAL, 8s)
defer(manago.purchase, CRITICAL, 5s)
defer(matomo.flush, LOW, 3s)
|
v
[CI sends response]
|
v
[post_system hook fires]
|
v
[session_write_close()]
[fastcgi_finish_request()] <-- client receives response here
|
v
[Budget: 10s]
[1] meta.purchase (CRITICAL/100, cost<=3s) -> runs, elapsed 1.2s, remaining 8.8s
[2] advisable_ai.purchase (CRITICAL/100, cost<=8s) -> runs, elapsed 0.8s, remaining 8.0s
[3] manago.purchase (CRITICAL/100, cost<=5s) -> runs, elapsed 1.5s, remaining 6.5s
[4] matomo.flush (LOW/10, cost<=3s) -> runs, elapsed 0.5s, remaining 6.0s
|
v
[PHP-FPM worker free]Domain Layer
Value Object
DeferredTask (src/DeferredTask/DeferredTask.php) is a readonly value object with four promoted constructor properties:
| Property | Type | Description |
|---|---|---|
callback | \Closure | The work to execute |
maxCostSeconds | int | Declared worst-case duration |
priority | int | Priority weight for queue ordering |
name | string | Logging identifier |
Runner
DeferredTaskRunner (src/DeferredTask/DeferredTaskRunner.php, 162 LOC) manages the internal SplPriorityQueue, the flush/detach lifecycle, the budget enforcement loop, and two alternative execution modes (inline when disabled, unlimited when budget is 0).
Architecture
| Component | Path | Purpose |
|---|---|---|
DeferredTask | src/DeferredTask/DeferredTask.php | Task value object (closure, cost, priority, name) |
DeferredTaskRunner | src/DeferredTask/DeferredTaskRunner.php | Core runner -- queue, budget, flush, execution (162 LOC) |
| Config | application/config/deferred_tasks.php | Enabled flag and budget seconds |
| DI registration | application/config/container/deferred_task.php | Registers deferred_task as public DI service with logger |
| Hook | application/config/hooks.php | post_system hook that triggers run() |
| Log file | application/logs/deferred_task_log.php | Rotated log output (15 files) |
| Tests | tests/Unit/DeferredTask/DeferredTaskRunnerTest.php | Full unit test suite (233 LOC) |
Dependencies
- SplPriorityQueue (PHP SPL): Priority-ordered task storage.
EXTR_DATAmode extractsDeferredTaskobjects directly. - Advisable\Logger\NamedLoggerInterface: PSR-3 logger (extends
Psr\Log\LoggerInterface) injected by DI. The constructor calls$logger?->withName('deferred-task')so all records carry%channel% = deferred-task. Registered in the DI container viaapplication/config/container/deferred_task.php. Used for warnings (task failures) and notices (budget skips). - Symfony DI Container: Service registration and autowiring.
SAPI Support
| SAPI | Finish function | Behavior |
|---|---|---|
| PHP-FPM (production) | fastcgi_finish_request() | Response sent immediately, worker continues |
| LiteSpeed | litespeed_finish_request() | Same semantics |
| mod_php / CLI | No-op | Tasks still run but block the response |
Configuration
Environment Variables
| Variable | Type | Default | Description |
|---|---|---|---|
APP_DEFERRED_TASK_ENABLED | bool | true | Enable post-response deferral. When false, tasks run inline (legacy behavior). |
APP_DEFERRED_TASK_BUDGET_SECONDS | int | 10 | Total time budget for all deferred tasks. 0 = unlimited (no skip logic). |
DEFERRED_TASK_LOG_THRESHOLD | string | WARNING | Monolog log level threshold |
DEFERRED_TASK_LOG_MAX_FILES | int | 15 | Log rotation file count |
DI Container Registration
application/config/container/deferred_task.php registers the runner with constructor arguments read from config at container build time:
php
$services->set('deferred_task', DeferredTaskRunner::class)
->arg('$timeBudgetSeconds', $ci->config->item('deferred_task_budget_seconds'))
->arg('$enabled', $ci->config->item('deferred_task_enabled'))
->arg('$logger', service(\Advisable\Logger\NamedLoggerInterface::class))
->public();Registered in application/config/container/modules.php as a ContainerModule.
Registered Callers
Every defer() call site in the codebase, grouped by integration:
Meta Conversions API (Facebook Server-Side Events)
| Controller | Module | Event Type | Task Name | Priority |
|---|---|---|---|---|
Adv_products | eshop | VIEW_CONTENT | meta.view_content | NORMAL |
Adv_vendors | eshop | VIEW_CONTENT | meta.view_content | NORMAL |
Adv_search | search | SEARCH | meta.search | NORMAL |
AdvApiCartController | api | ADD_TO_CART | meta.add_to_cart | NORMAL |
Adv_customer | eshop | COMPLETE_REGISTRATION | meta.complete_registration | NORMAL |
Adv_customer | eshop | ADD_TO_WISHLIST | meta.add_to_wishlist | NORMAL |
Adv_order | eshop | INITIATE_CHECKOUT | meta.initiate_checkout | CRITICAL |
Adv_checkout | checkout | PURCHASE | meta.purchase | CRITICAL |
Cost derived from $fb->getMaxCostSeconds() = $timeout + $connectTimeout (configured per-client).
Advisable AI (Recommendation Engine Signals)
| Controller | Module | AI Method | Task Name | Priority |
|---|---|---|---|---|
Adv_front_controller | core | bookmark() | advisable_ai.bookmark | NORMAL |
Adv_front_controller | core | removeBookmark() | advisable_ai.remove_bookmark | NORMAL |
Adv_front_controller | core | rating() | advisable_ai.rating | NORMAL |
Adv_front_controller | core | view() | advisable_ai.view | NORMAL |
Adv_front_controller | core | purchaseOrder() | advisable_ai.purchase | CRITICAL |
Adv_front_controller | core | sessionToCustomerSwitch() | advisable_ai.session_switch | NORMAL |
AdvApiCartController | api | addToCart() | advisable_ai.add_to_cart | NORMAL |
AdvApiCartController | api | removeFromCart() | advisable_ai.remove_from_cart | NORMAL |
Cost derived from getAdvisableAIMaxCostSeconds() = registry('ADVISABLE_AI', 'TIMEOUT') + registry('ADVISABLE_AI', 'CONNECT_TIMEOUT') (defaults: 5+3 = 8s).
Manago CRM
| Controller | Module | Action | Task Name | Priority |
|---|---|---|---|---|
Adv_checkout | checkout | addEvent() (purchase) | manago.purchase | CRITICAL |
Cost derived from $manago->getMaxCostSeconds() = registry-configured timeout.
ProjectAgora (Ad Serving)
| Controller | Module | Action | Task Name | Priority |
|---|---|---|---|---|
Adv_checkout | checkout | sendOrder() | project_agora.order | CRITICAL |
Cost derived from $projectAgora->getMaxCostSeconds() = config['timeout'] + config['connectTimeout'].
Matomo Analytics
| Controller | Module | Action | Task Name | Priority |
|---|---|---|---|---|
Adv_front_controller | core | flush() (bulk tracking) | matomo.flush | LOW |
Cost derived from $matomo->getMaxCostSeconds() = $timeout + $connectionTimeout.
Execution Modes
| Mode | Condition | Response Flush | Budget Enforcement | Use Case |
|---|---|---|---|---|
| Normal | enabled=true, budget>0 | Yes | Yes | Production default |
| Unlimited | enabled=true, budget=0 | Yes | No (all tasks run) | High-resource environments, debugging |
| Disabled (Inline) | enabled=false | No (tasks block response) | No | Debugging, local development |
In disabled mode, runInline() executes all tasks synchronously before the response is sent. Priority ordering and exception isolation are preserved. This matches the legacy behavior from before the deferred runner was introduced.
Relationship with Circuit Breaker
The deferred task runner and the circuit breaker address complementary failure modes:
| Concern | Solution |
|---|---|
| Latency isolation -- tracking calls must not slow TTFB | Deferred Task Runner (post-response execution) |
| Failure isolation -- a down third-party must not waste budget | Circuit Breaker (fail-fast when service is known-down) |
Inside each deferred closure, the underlying service (e.g., FacebookConversionService) checks $circuitBreaker->isOpen() before making the HTTP call. If the circuit is open, the closure returns immediately without a network request, freeing budget time for other tasks.
[Controller] -> defer(task) -> [Response sent] -> [Runner executes task]
|
CB open? -> return immediately (fail fast)
|
CB closed -> HTTP call
|
success -> recordSuccess()
failure -> recordFailure() -> may trip circuitBusiness Rules
- Post-response execution: All deferred tasks run after the HTTP response is sent to the client. The user never waits for them.
- Priority ordering: Tasks with higher priority values execute first. Within the same priority level, insertion order is preserved by
SplPriorityQueue. - Budget enforcement via declared cost: A task is skipped (never started) if its
maxCostSecondsexceeds the remaining budget. Actual elapsed time is tracked for accurate budget accounting. - Exception isolation: A thrown exception in one task is caught, logged, and does not prevent subsequent tasks from executing.
- Session closure: The PHP session is closed before flushing the response to avoid holding session locks during deferred execution.
- Cost derivation convention: All services expose
getMaxCostSeconds()returningtimeout + connectTimeout, ensuring the declared cost matches the HTTP client's configured worst case. - No persistence: Tasks exist only in memory during the request. If the PHP worker is killed (e.g., FPM restart, OOM), queued tasks are lost. This is acceptable because deferred tasks are tracking/analytics calls -- the primary data (orders, registrations) is already committed.
- No retry: Failed tasks are logged but not retried. Retry logic belongs in the service layer (e.g., circuit breaker re-close) or the integration's own queue.
- Guard clause safety: The
post_systemhook checks bothfunction_exists('di')anddi()->has('deferred_task')to handle CLI contexts and bootstrap failures gracefully. - Budget skip logging: All skipped task names are collected and logged in a single
notice-level message at the end of execution for operational visibility.
Testing
Unit tests in tests/Unit/DeferredTask/DeferredTaskRunnerTest.php (233 LOC) cover:
| Test Group | Cases |
|---|---|
| Basic Execution | Empty queue no-op, priority ordering, hasTasks() state tracking |
| Exception Isolation | Failing task does not block subsequent tasks, warning logged |
| Budget Enforcement | Skip over-budget tasks, skip all when budget too small, drain remaining on exhaustion |
| Unlimited Budget | timeBudgetSeconds=0 runs all tasks regardless of declared cost |
| Disabled Mode | enabled=false executes inline, ignores budget, isolates exceptions |
Run with:
bash
vendor/bin/phpunit tests/Unit/DeferredTaskClient Extension Points
- Add new deferred tasks: Call
di()->get('deferred_task')->defer(...)from any controller. Follow theservice.actionnaming convention for thenameparameter. - Override budget: Set
APP_DEFERRED_TASK_BUDGET_SECONDSin.envto tune for the client's integration mix. A checkout page with 4 CRITICAL integrations may need more than 10s. - Disable for debugging: Set
APP_DEFERRED_TASK_ENABLED=falseto run all tasks inline, making their timing visible in browser devtools. - Adjust log threshold: Set
DEFERRED_TASK_LOG_THRESHOLD=DEBUGto see budget calculations and skip decisions inapplication/logs/deferred_task_log.php. - Custom service integration: Any service that exposes a
getMaxCostSeconds()method fits naturally into the deferral pattern. Wrap the call in a closure, declare the cost, and assign an appropriate priority.
Worst-Case Scenario: Budget Exhaustion on Checkout
A checkout completion (Adv_checkout::thank_you_page) with all integrations active queues the most tasks:
| Task | Priority | Max Cost |
|---|---|---|
meta.purchase | CRITICAL (100) | ~3s (fb timeout + connect) |
manago.purchase | CRITICAL (100) | ~5s (CRM timeout) |
project_agora.order | CRITICAL (100) | ~5s (ad timeout + connect) |
advisable_ai.purchase | CRITICAL (100) | ~8s (AI timeout + connect) |
matomo.flush | LOW (10) | ~3s (analytics timeout) |
With the default 10s budget, if all four CRITICAL tasks take their worst case (~21s), some will be skipped. In practice, most complete in under 1s each (timeout values are ceilings, not expectations). Matomo (LOW priority) is the most likely candidate for being dropped under pressure.
If budget exhaustion is observed in logs, the operator should either increase APP_DEFERRED_TASK_BUDGET_SECONDS or lower individual service timeouts.
Known Issues & Security Gaps
No known issues at this time. The runner has been stable since the SY-32 logger DI migration completed; all log records now route through NamedLoggerInterface->withName('deferred-task') and reach the file/stdout sinks correctly.
Related Flows
- IN-07 Facebook Catalog & CAPI -- Meta Conversions API events deferred via this runner
- CF-34 AI Recommendations -- Advisable AI signals deferred via this runner
- IN-13 Newsletter Integration -- Manago CRM events deferred via this runner
- SY-22 Job Manager -- Background job processing (complementary: durable/scheduled vs. fire-and-forget/immediate)
- SY-26 Circuit Breaker -- each deferred closure checks
circuitBreaker->isOpen()before making network calls; failure recording interacts with the breaker's state machine - SY-29 Deployment Architecture -- post-response tasks run on both Plesk and K8s targets
- Circuit Breaker guide:
docs/guides/CircuitBreaker.md - Developer guide:
docs/guides/DeferredTask.md