Skip to content

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');
MethodSignatureDescription
defer()defer(Closure $task, int $maxCostSeconds, int $priority = 50, string $name = '')Enqueue a task for post-response execution
run()run(): voidExecute all queued tasks (called automatically by post_system hook)
hasTasks()hasTasks(): boolWhether any tasks are currently queued

Priority Constants

ConstantValueSemantics
PRIORITY_CRITICAL100Revenue-affecting events -- executes first, least likely to be budget-skipped
PRIORITY_NORMAL50Standard tracking (page views, add-to-cart, bookmarks)
PRIORITY_LOW10Best-effort analytics (Matomo bulk flush) -- first to be dropped under budget pressure

defer() Parameters

ParameterTypeRequiredDescription
task\ClosureYesZero-argument closure containing the work. Capture variables by value via arrow function or use.
maxCostSecondsintYesDeclared worst-case duration (seconds). Used for skip decisions -- the task is not started if this exceeds remaining budget.
priorityintNoExecution order weight (higher = sooner). Defaults to PRIORITY_NORMAL (50).
namestringNoIdentifier 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_time

From 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 names

Key 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 warning level.
  • Budget exhaustion: When remaining time drops to zero or below, all remaining tasks in the queue are drained and logged as skipped at notice level.

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
3010set_time_limit(10) -- tighten below ini
3040No call -- ini is already tighter
0 (unlimited)10set_time_limit(10) -- impose budget
300 (unlimited budget)No call -- ini governs
00No 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:

PropertyTypeDescription
callback\ClosureThe work to execute
maxCostSecondsintDeclared worst-case duration
priorityintPriority weight for queue ordering
namestringLogging 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

ComponentPathPurpose
DeferredTasksrc/DeferredTask/DeferredTask.phpTask value object (closure, cost, priority, name)
DeferredTaskRunnersrc/DeferredTask/DeferredTaskRunner.phpCore runner -- queue, budget, flush, execution (162 LOC)
Configapplication/config/deferred_tasks.phpEnabled flag and budget seconds
DI registrationapplication/config/container/deferred_task.phpRegisters deferred_task as public DI service with logger
Hookapplication/config/hooks.phppost_system hook that triggers run()
Log fileapplication/logs/deferred_task_log.phpRotated log output (15 files)
Teststests/Unit/DeferredTask/DeferredTaskRunnerTest.phpFull unit test suite (233 LOC)

Dependencies

  • SplPriorityQueue (PHP SPL): Priority-ordered task storage. EXTR_DATA mode extracts DeferredTask objects 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 via application/config/container/deferred_task.php. Used for warnings (task failures) and notices (budget skips).
  • Symfony DI Container: Service registration and autowiring.

SAPI Support

SAPIFinish functionBehavior
PHP-FPM (production)fastcgi_finish_request()Response sent immediately, worker continues
LiteSpeedlitespeed_finish_request()Same semantics
mod_php / CLINo-opTasks still run but block the response

Configuration

Environment Variables

VariableTypeDefaultDescription
APP_DEFERRED_TASK_ENABLEDbooltrueEnable post-response deferral. When false, tasks run inline (legacy behavior).
APP_DEFERRED_TASK_BUDGET_SECONDSint10Total time budget for all deferred tasks. 0 = unlimited (no skip logic).
DEFERRED_TASK_LOG_THRESHOLDstringWARNINGMonolog log level threshold
DEFERRED_TASK_LOG_MAX_FILESint15Log 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)

ControllerModuleEvent TypeTask NamePriority
Adv_productseshopVIEW_CONTENTmeta.view_contentNORMAL
Adv_vendorseshopVIEW_CONTENTmeta.view_contentNORMAL
Adv_searchsearchSEARCHmeta.searchNORMAL
AdvApiCartControllerapiADD_TO_CARTmeta.add_to_cartNORMAL
Adv_customereshopCOMPLETE_REGISTRATIONmeta.complete_registrationNORMAL
Adv_customereshopADD_TO_WISHLISTmeta.add_to_wishlistNORMAL
Adv_ordereshopINITIATE_CHECKOUTmeta.initiate_checkoutCRITICAL
Adv_checkoutcheckoutPURCHASEmeta.purchaseCRITICAL

Cost derived from $fb->getMaxCostSeconds() = $timeout + $connectTimeout (configured per-client).

Advisable AI (Recommendation Engine Signals)

ControllerModuleAI MethodTask NamePriority
Adv_front_controllercorebookmark()advisable_ai.bookmarkNORMAL
Adv_front_controllercoreremoveBookmark()advisable_ai.remove_bookmarkNORMAL
Adv_front_controllercorerating()advisable_ai.ratingNORMAL
Adv_front_controllercoreview()advisable_ai.viewNORMAL
Adv_front_controllercorepurchaseOrder()advisable_ai.purchaseCRITICAL
Adv_front_controllercoresessionToCustomerSwitch()advisable_ai.session_switchNORMAL
AdvApiCartControllerapiaddToCart()advisable_ai.add_to_cartNORMAL
AdvApiCartControllerapiremoveFromCart()advisable_ai.remove_from_cartNORMAL

Cost derived from getAdvisableAIMaxCostSeconds() = registry('ADVISABLE_AI', 'TIMEOUT') + registry('ADVISABLE_AI', 'CONNECT_TIMEOUT') (defaults: 5+3 = 8s).

Manago CRM

ControllerModuleActionTask NamePriority
Adv_checkoutcheckoutaddEvent() (purchase)manago.purchaseCRITICAL

Cost derived from $manago->getMaxCostSeconds() = registry-configured timeout.

ProjectAgora (Ad Serving)

ControllerModuleActionTask NamePriority
Adv_checkoutcheckoutsendOrder()project_agora.orderCRITICAL

Cost derived from $projectAgora->getMaxCostSeconds() = config['timeout'] + config['connectTimeout'].

Matomo Analytics

ControllerModuleActionTask NamePriority
Adv_front_controllercoreflush() (bulk tracking)matomo.flushLOW

Cost derived from $matomo->getMaxCostSeconds() = $timeout + $connectionTimeout.


Execution Modes

ModeConditionResponse FlushBudget EnforcementUse Case
Normalenabled=true, budget>0YesYesProduction default
Unlimitedenabled=true, budget=0YesNo (all tasks run)High-resource environments, debugging
Disabled (Inline)enabled=falseNo (tasks block response)NoDebugging, 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:

ConcernSolution
Latency isolation -- tracking calls must not slow TTFBDeferred Task Runner (post-response execution)
Failure isolation -- a down third-party must not waste budgetCircuit 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 circuit

Business Rules

  1. Post-response execution: All deferred tasks run after the HTTP response is sent to the client. The user never waits for them.
  2. Priority ordering: Tasks with higher priority values execute first. Within the same priority level, insertion order is preserved by SplPriorityQueue.
  3. Budget enforcement via declared cost: A task is skipped (never started) if its maxCostSeconds exceeds the remaining budget. Actual elapsed time is tracked for accurate budget accounting.
  4. Exception isolation: A thrown exception in one task is caught, logged, and does not prevent subsequent tasks from executing.
  5. Session closure: The PHP session is closed before flushing the response to avoid holding session locks during deferred execution.
  6. Cost derivation convention: All services expose getMaxCostSeconds() returning timeout + connectTimeout, ensuring the declared cost matches the HTTP client's configured worst case.
  7. 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.
  8. 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.
  9. Guard clause safety: The post_system hook checks both function_exists('di') and di()->has('deferred_task') to handle CLI contexts and bootstrap failures gracefully.
  10. 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 GroupCases
Basic ExecutionEmpty queue no-op, priority ordering, hasTasks() state tracking
Exception IsolationFailing task does not block subsequent tasks, warning logged
Budget EnforcementSkip over-budget tasks, skip all when budget too small, drain remaining on exhaustion
Unlimited BudgettimeBudgetSeconds=0 runs all tasks regardless of declared cost
Disabled Modeenabled=false executes inline, ignores budget, isolates exceptions

Run with:

bash
vendor/bin/phpunit tests/Unit/DeferredTask

Client Extension Points

  • Add new deferred tasks: Call di()->get('deferred_task')->defer(...) from any controller. Follow the service.action naming convention for the name parameter.
  • Override budget: Set APP_DEFERRED_TASK_BUDGET_SECONDS in .env to 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=false to run all tasks inline, making their timing visible in browser devtools.
  • Adjust log threshold: Set DEFERRED_TASK_LOG_THRESHOLD=DEBUG to see budget calculations and skip decisions in application/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:

TaskPriorityMax Cost
meta.purchaseCRITICAL (100)~3s (fb timeout + connect)
manago.purchaseCRITICAL (100)~5s (CRM timeout)
project_agora.orderCRITICAL (100)~5s (ad timeout + connect)
advisable_ai.purchaseCRITICAL (100)~8s (AI timeout + connect)
matomo.flushLOW (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.