Octane keeps the application in memory and finds every static you left

Octane shipped in April and the first benchmark was genuinely startling: a request that took forty milliseconds took eight, because thirty-two of those forty had been spent constructing an application that was then thrown away. The second thing we found was a customer name from a previous request appearing on somebody else’s invoice.

The symptom

$ php artisan octane:start --workers=4

$ hey -n 2000 -c 20 https://staging/api/orders
  Requests/sec:  1204.11        # was 218
  Latency p95:   0.021s         # was 0.184s

# and the test that failed on the fourth request:
$ ./bin/identity-soak --requests=500 --concurrency=10
  request 1-3   ✓
  request 4     ✗ expected customer 4471, got 8814
  ...
  41 of 500 returned data belonging to another request

Forty-one leaks in five hundred requests is not a subtle bug, and it is entirely invisible under a single-worker single-request test. Every functional test passed.

Why it happens

PHP’s shared-nothing model means every request starts from an empty process, so a static property is scoped to one request by accident rather than by design. Fifteen years of code has been written against that accident.

Octane keeps the framework and the container in memory between requests and resets what it knows about. It cannot reset a static property in your code, because nothing tells it one exists.

The fix

Finding the state, which is an audit rather than a fix

$ grep -rn 'private static |protected static |public static ' src/ 
    | grep -vc 'function'
41

# of those, by inspection:
#   19  a memoised pure computation      — harmless
#   14  a cached lookup keyed on an id   — harmless, grows
#    5  a request-scoped value           — A LEAK
#    3  a singleton holding a Request    — A LEAK

# and what grep cannot find: a provider closure capturing
# $request, or a singleton resolved once and kept.

The grep finds the visible half and the invisible half is worse — a service provider that binds a singleton whose constructor takes the current request captures request one’s object for the life of the worker, and nothing about that reads as a static.

// the shape that leaks, and reads as ordinary code
final class CurrentTenant
{
    private static ?Tenant $tenant = null;

    public static function set(Tenant $t): void { self::$tenant = $t; }
    public static function get(): ?Tenant { return self::$tenant; }
}

// and the container version, which is the same bug
$this->app->singleton(Reporter::class, fn ($app) =>
    new Reporter($app->make(Request::class))   // captured, once
);

Resetting between requests

// config/octane.php
'listeners' => [
    RequestReceived::class => [
        ...Octane::prepareApplicationForNextOperation(),
        FlushTenantState::class,
        FlushRequestScopedSingletons::class,
    ],
],

// each listener is a handle() calling a flush(), and the
// list is something somebody has to remember to extend.

This works and is the wrong shape as a permanent solution, because it is a list somebody has to remember to extend. Every new static needs a line here and nothing enforces it — the failure mode of forgetting is a data leak rather than an error.

It is the right shape as a transitional measure while the underlying statics are removed, and treating it as a debt register rather than as architecture is what stops it becoming permanent.

Removing the state instead

// bound per request, not per worker
$this->app->scoped(TenantContext::class, function ($app) {
    return new TenantContext(
        $app->make(Request::class)->attributes->get('tenant')
    );
});

// scoped(): resolved once per request lifecycle and
// flushed automatically by Octane. this is the fix.

// injected, rather than reached for
public function __construct(private TenantContext $tenant) {}

scoped exists precisely for this and is the answer for anything request-scoped — it behaves like a singleton within a request and is discarded between them, with no listener to maintain. Converting the five leaking statics to scoped bindings took an afternoon and removed the listener list.

The three singletons capturing a request are the harder case, because the fix is changing a constructor signature and every call site. Injecting the container and resolving on demand is the smaller change and reads worse; passing the request as a method argument reads better and touches more files.

Memory, and the worker that must be recycled

$ php artisan octane:status
  worker 1   PID 8814   memory 41.2 MB   requests 12,402
  worker 2   PID 8815   memory 88.9 MB   requests 12,388
  worker 3   PID 8816   memory 142.1 MB  requests 12,411   ← growing

# the fourteen "harmless" memoised caches, keyed on an id,
# with no bound. every distinct id is a permanent entry.

$ php artisan octane:start --max-requests=500

--max-requests recycles a worker after a number of requests and is a workaround presented as a feature. It is genuinely necessary because a leak somewhere in a dependency is not something you can audit, and it means the memory problem is bounded rather than solved.

The fourteen memoised lookups are the real cause here and each is individually reasonable — a cache keyed on a product id is correct in a request and unbounded in a worker. Replacing them with a bounded LRU, or with the real cache, is the fix; recycling is what makes it survivable until then.

What else changes

no longer true under Octane:

  a request writes to a temp file and cleans up at
  shutdown       → register_shutdown_function fires when
                   the WORKER dies, not the request

  ini_set(...)   → persists to the next request
  setlocale(...) → persists. this bit us on a currency
                   format for two hours.
  chdir(...)     → persists

  a fatal in a request kills the worker, not one request
  — so an unhandled error is now a capacity event

The setlocale case is a good illustration of how these arrive: an export set a locale for number formatting, did not restore it, and every subsequent request on that worker formatted money in a different convention. It affected a quarter of traffic, which is one worker in four.

Verifying it worked

$ ./bin/identity-soak --requests=5000 --concurrency=20
5000 requests, 0 identity mismatches

$ php artisan octane:status
  worker 1   41.8 MB   requests 4,988
  worker 2   42.1 MB   requests 5,012
  # flat across 50,000 requests

$ hey -n 5000 -c 50 https://staging/api/orders
  Requests/sec: 1188.40
  p95: 0.024s

The identity soak is the test that matters and it has to assert on data rather than on status codes — every leaked response was a 200. Building it took two hours and it is now the gate on any change to a service provider.

Flat memory across fifty thousand requests is the second acceptance criterion and needs a long enough run to be meaningful; five hundred requests would have shown nothing.

What this costs

A class of bug that exists only in production and only under concurrency, which no functional test will find. The soak test covers the cases we know about and cannot cover a static introduced next month in a dependency — so the operational answer is worker recycling and a memory alert, both of which are admissions rather than solutions.

The performance gain is real and it is worth asking what it buys before taking it. Forty milliseconds to eight matters on an API serving a mobile client and matters much less on a page where the database query is a hundred milliseconds. Running Octane because the benchmark is impressive, on an application whose bottleneck is elsewhere, is paying a large correctness risk for a number nobody notices.