Skip to content

Running under a persistent worker

Propulsion is built to run under persistent-worker SAPIs: FrankenPHP worker mode, RoadRunner, Swoole, frankenphp run, and long-lived CLI consumers. One process serves many requests.

Classic PHP-FPM gives you a correctness guarantee for free: the process dies at the end of the request, so anything left behind is discarded. A worker has no such boundary. Every static property, every open connection, every memoised value survives into the next request, serving a different user. That single difference is the source of everything on this page.

If you’re deploying under PHP-FPM or running ordinary CLI scripts, none of this is required — the process exit does it for you.

Call Session::reset() at every request boundary, in a finally.

// once, at worker start
Propulsion::init('/path/to/runtime-conf.php');
// then, per request
try {
handle($request);
} finally {
Propulsion::getSession()->reset();
}

The finally is not a stylistic preference. A request that threw is the one most likely to have left an open transaction behind, and on PostgreSQL an uncommitted transaction poisons the connection for everything that reuses it — every subsequent statement fails with current transaction is aborted until an explicit ROLLBACK.

If you use Propulsion through a framework integration — Quiote’s own adapter, for instance — that integration calls reset() at its own request boundary and application code needs to do nothing.

reset() does seven things, in this order:

  1. Force-rolls-back any dangling transaction on every connection Propulsion has open.
  2. Clears every generated Peer’s instance pool, so one request’s hydrated objects can’t be handed to the next.
  3. Resets forceMasterConnection to false, so a request that opted into forcing master reads doesn’t leak that choice.
  4. Clears the request-scoped (L1) query result cache and the shared tier’s per-request bookkeeping.
  5. Drops any outstanding instance-pooling suspension, so an abandoned FORMAT_ON_DEMAND iteration can’t leave pooling off for every later request.
  6. Zeroes each open connection’s debug counters — query count and last executed query.
  7. Clears the correlation id, so a request that set one doesn’t stamp it onto the next request’s queries.

What reset() does not do is flush buffered OpenTelemetry spans, because span export isn’t request-scoped state — it’s an outbound side effect with its own batch timer. Under a worker runtime that timer is the only thing that ever fires, so call Propulsion::flushTelemetry() alongside reset() if telemetry is on. Query observers themselves are process-scoped by design and deliberately survive the boundary.

Two things are deliberately not reset:

  • Propulsion::disableInstancePooling(), the explicit switch. That reads as deployment configuration — a batch worker that turns pooling off at boot means it for the life of the process. Only the transient, scoped suspension in step 5 is request state.

    That suspension is Session::suspendInstancePooling() / resumeInstancePooling(), with isInstancePoolingSuspended() to read it, and it is a counter rather than a boolean — suspensions nest, so an on-demand iteration inside another one doesn’t have the inner resume() re-enable pooling out from under the outer one. An unbalanced resume() floors at zero rather than going negative, which is why step 5 can drop an outstanding suspension without having to know how deep it got.

  • The compiled-query cache, which is process-scoped by design. See its scope.

BucketHomeLifetimeExamples
Process-scopedServiceContainer, Propulsion’s staticsThe worker processConnections, adapters, table maps, the runtime configuration, the PSR-3 logger, the PSR-14 dispatcher, the L2 cache pool, the compiled-query cache
Request-scopedSessionOne requestInstance pools, the L1 result cache, per-request cache-version bookkeeping, forceMasterConnection, instance-pooling suspensions

Propulsion::init() belongs at worker start, once — not per request. Connections follow the same rule and are deliberately reused; that reuse is most of the point of worker mode.

If you write your own code that memoises anything process-wide alongside Propulsion, the test is: is this value identical for every request this process will ever serve? If not, it belongs on the request-scoped side. Hydrated model objects always do — one escaped reference retains a subgraph of FK-related objects, referrer collections, and the Criteria that built them for the life of the process.

Threaded workers share the process, not just the request

Section titled “Threaded workers share the process, not just the request”

Under FrankenPHP with worker <n> above 1, Propulsion::$session and every connection it reaches are one instance shared by every thread in the process. This is measured, not assumed.

There is no per-thread isolation to lean on, so what makes concurrent requests safe is the request-boundary reset — not separation between threads. Anything that would only be safe “because each thread has its own copy” is not safe here.

It also means per-process caches share less than they look like they do: the array cache driver is per-process and per-thread, and apcu is per-host but gives CLI its own segment. Both are covered in Choosing a driver.

getQueryCount() and getLastExecutedQuery() live on the connection, which is process-scoped and survives the boundary — but they’re read as per-request numbers, which is what they were under PHP-FPM where the connection died with the request. Session::reset() zeroes them on every open connection via PropulsionPDO::resetDebugCounters(), which leaves the debug mode untouched (unlike useDebug(false), which clears the same two fields as part of switching mode).

If you implement the PropulsionPDO interface directly rather than getting it from PropulsionPDOTrait, you have to provide resetDebugCounters() yourself.

In PHP-FPM an unbounded array is bounded by the request. In a worker it’s bounded by nothing, so every long-lived structure in Propulsion has an explicit cap:

StructureCap
L1 query result cacheQueryResultCache::MAX_ENTRIES — 500 entries, oldest-first
Compiled-query cacheCompiledQueryCache::MAX_ENTRIES — 1000 entries, oldest-first
Per-connection prepared-statement cacheMAX_CACHED_PREPARED_STATEMENTS — 256, oldest-first (with PROPEL_ATTR_CACHE_PREPARES on)
L2 cache poolWhatever you configure — max_entries, max_bytes, apc.shm_size, or Redis’s maxmemory

Note that entry-count caps are not byte caps: 500 wide result sets is still a lot of memory. If your workload has very large result sets and you cache them, size on measurement rather than on the entry count.

Reclaiming the memory the legacy aliases cost

Section titled “Reclaiming the memory the legacy aliases cost”

Propulsion installs 102 global class_alias() calls at load, so that historic bare names — Criteria, PropulsionPDO, PropelException, and friends — resolve without a namespace. In a worker that cost is paid by every process. You can skip it:

define('PROPULSION_SKIP_LEGACY_CLASS_ALIASES', true);

Define it before Propulsion is loaded. Measured effect: 176 loaded classes and interfaces down to 1, and 3,208 KB down to 115 KB, per process.

It’s opt-out rather than opt-in because code using the bare names breaks silently without the aliases. PHP consults the autoloader for only some references to a missing class:

ContextAutoloads?Failure mode without the alias
new Foo / Foo::bar() / class_exists('Foo')yes
catch (Foo $e)nothe catch silently doesn’t match
$x instanceof Foonosilently false
is_a($x, 'Foo')nosilently false
A parameter or return type check against FoonoTypeError

So the failures are wrong answers rather than errors, which is why this can’t be made lazy behind an autoloader and why it’s off by default.

Safe to set when nothing references the bare names:

  • Namespaced schemas — generated code has always imported the runtime classes properly, so it never needed the aliases.
  • Flat (global-namespace) schemas — newly generated code imports them too, as of 2.0. Regenerate first; existing generated code predates the change and still relies on the aliases.
  • Your own code is the part to check. Grep for the bare names, paying attention to catch, instanceof, is_a(), and type declarations.

This is not a PSR-4 or autoloading concern: Composer already autoloads Propulsion\… properly. The alias table exists to create global symbols, which no autoloader configuration can substitute for.

Worth measuring alongside this: opcache preloading. Preloaded classes live in shared memory instead of being materialised per process, which is the standard way to stop this class of cost scaling with worker count.

Two things worth knowing before you build on them:

  • Reconfiguring a live process keeps the schema maps, and only those. initialize() and setConfiguration() both drop the connection map, the adapter map and the memoised default datasource name — a registration made with setDB() is superseded too — so the next access rebuilds all three from whatever configuration is now current. DatabaseMaps are the one thing deliberately left alone: they describe the generated model classes rather than the DSN, each one is registered once from a statement at the bottom of its own class file that never runs again, and DatabaseMap::getTable() has only a table name to resolve from — clearing it would leave every already-loaded model unable to find its own table for the rest of the process. Emptying the connection map also cannot close the connections in it: PHP ends a PDO connection when its last reference goes, and an adapter that cached the handle still holds one. close() and initialize() therefore roll each pooled connection back before dropping it — one rollBack() per open nesting level — so its locks are released now rather than whenever the last holder happens to be collected. Under a per-request SAPI process death hid this; under a worker it did not, and the visible symptom was idle-in-transaction backends holding locks against which later work blocked.
  • Dropped-connection detection does not cover persistent connections. Everywhere else — every prepared statement, plus exec() and query() — a dropped connection is detected and evicted from the pool; under PDO::ATTR_PERSISTENT, PDO refuses the custom statement class that does it, so a dropped connection surfaces as a plain PDOException with no eviction. See Connection resilience, which also covers the two opt-in recovery features that matter most here: pre-checkout liveness pings and transaction retry.

Propulsion’s own test suite includes a FrankenPHP worker harness (composer test:worker) that boots the ORM once and serves many requests from one process, calling Session::reset() between them — the same wiring a real deployment uses. It asserts that no pooled object, open transaction, or forceMasterConnection setting survives a boundary, that the same connection is reused, that memory stays flat under sustained load, and that the L2 cache survives a boundary while L1 does not. It runs single-threaded and at WORKER_THREAD_COUNT=4, against SQLite and PostgreSQL. See Working with the test suite.

  • The instance pool — what step 2 of reset() clears, and why pools moved off the Peer classes.
  • Query caches — which tier survives the boundary and which doesn’t.
  • ReplicationforceMasterConnection is request-scoped for this reason.