Skip to main content

PHP Structured Concurrency and Beyond (3): structured state, and why its absence breaks concurrency

Merlin Rutz

Part 2 gave concurrency an owner. It said nothing about what the owned work can see, and that is the harder part.

The bug, in three lines

$this->accountSwitcher->switchTo($dummy);
$entity = $this->storage->load($id);   // suspends here
$this->accountSwitcher->switchBack();

The load suspends. The scheduler runs another fiber. That fiber asks who the current user is and gets the dummy, because "the current user" is a property of a service, and every fiber in the process shares that service.

Switch it back afterwards and the damage stays. The other fiber already read it. Nothing in that fiber's code says so. You cannot read it and know what it sees, which is the loss part 2 was about, this time for state.

Already filed, by other people

Drupal #3576074 is that snippet, almost literally: an AccountSwitcher switch, a fiber suspension in between, and the dummy user still current in a lazily built form. Drupal 11.3.

Drupal #3569172 is the same failure with the negotiated language. It leaks from the first fiber into the second, and one page renders in mixed languages.

The same bug in two more communities. Symfony worker mode has api-platform/core#7918, a meta issue that audits which services are stateless. FrankenPHP has php/frankenphp#207, where the standing advice is that every service resets itself.

It happens whenever code written for one request per process meets fibers or a server that does not exit.

The fiber trap is a bandaid

Both Drupal issues got the same answer, a fiber trap. That is an inner event loop that prevents fibers from running where state might bleed. It switches concurrency off at the places that would benefit from it.

That is a defensible patch-release fix, and I would have committed it too. It is affordable because Drupal core drives fibers from two loops, the renderer's placeholder loop and BigPipe's. The two traps landed within a month of each other, and a follow-up, #3593008, narrows one of them. Every new bleed gets its own trap, and each trap blocks the placeholders it wraps. It stops being affordable altogether if #3425212 moves the renderer to Revolt and concurrency stops being a curiosity.

A fix that works by not using the feature does not survive the feature being used.

Two options, and the one nobody was trying

(a) Eradicate service state. Refactor every service to be stateless, pass what used to be global explicitly, and keep doing it forever, for every module, including the ones you do not maintain. All three communities are discussing this option. It is correct. It is also unbounded work spread over people who did not ask for it, and a single forgotten service brings the bug back.

Its cheaper cousin, ResetInterface, leaves the state and resets it between requests. That covers the persistent server and does nothing for two fibers inside one request. The fiber trap covers the fibers and does nothing for the server that does not exit. Each existing fix covers half the bug.

(b) Tolerate service state, and change what resolves. Leave the services holding state, and make "the current user" resolve per task rather than per process.

I gave myself one evening for (b), filed under learning fibers properly, and expected to confirm that it was a hack for legacy code. I came out too many nights later having implemented structured concurrency, because every attempt to make state task-local hit the same missing thing. There was no task. A fiber is not a task. It has no parent, no children, nothing to inherit along. The store needed a tree to hang on, and building that tree is the other half of this series. These articles are in the reverse of the order it happened in.

And (b) turned out to be the better option, for new code too.

Fibers share everything a thread shares and isolate nothing. Whatever the design, something has to keep per-task state somewhere. Under (a) that something is every service, each with its own stack, its own reset logic, its own bugs, and its own chance of being forgotten. Under (b) it is one mechanism, in one place, that every stateful service inherits. The clean option distributes an unsolved problem. The pragmatic one solves it once.

Structured state

Same trade as everywhere in this series. A stateful service gives up deciding where its state lives. In return the runtime scopes that state to the task tree and enforces the boundary.

  • You tag a service as stateful, in the container.
  • Asking for it returns a delegator that resolves the real instance from a store attached to the current task.
  • The store inherits along the task tree, so a child sees what its parent had. Siblings see nothing of each other.
  • A fiber gets its own copy of the store where it is spawned. Switching the current user inside it changes that copy only. The fiber next to it never sees it.

No fiber trap. No rewrite of every service. Nothing changes at the call sites. The tag is set once, and for core the adapter sets it.

And local reasoning now holds for state. What a task sees is decided by its ancestors, which you can read off the code that spawned it, not by whatever happened to run beside it, which you cannot.

And: What we get is an equivalent of the Effects paradigm for free. But this is another article, to be told another time.

Persistent server loops use the same mechanism the other way round. Each request starts from a clean store, so the request that just ended cannot contaminate the next one. A sub-request inherits, and gets only its request-scoped state fresh.

What this is not

It is not a child container that re-binds factories. Those exist (hat-tip to spiral!) and solve a different problem. The services are the same services. What changes is which instance you get.

It has three edges. State that is global, such as a connection pool or a cache backend, must stay global. Deciding which is which is a judgement call per service, made once, in the adapter. State that lives in the process rather than in a service, such as the timezone, is out of the store's reach. The adapter mirrors it on every task switch, and that is a cost the mechanism does not hide. And a service that hands out a raw reference to its state can still leak it past the boundary. The mechanism makes that leak rarer. It does not rule it out.

Does it work

Yes. Two fibers, one stateful service, different values in each, no bleed, under code shaped like the AccountSwitcher snippet above. A child fiber sees what its parent had; a sibling does not. Plain Symfony DI, no extension, no ext-ffi. OpenTelemetry's context propagation, the only other way I know of to carry context across fibers, needs ext-ffi and engine hooks.

On Drupal, on a locally patched core, both fiber traps are out and the regression tests for both filed bugs stay green. Nothing of that is on drupal.org yet, and nobody but me has reviewed it.

The repo is not public yet. Part 4 says why, and how to get in.

Next

The proposal is ownership for the work and isolation for the state it runs against. It works, it is not finished, and finishing it is more than evenings. Part 4 is the ask.

Talk to me

I am at DrupalCon Rotterdam, 28 September to 1 October, and at the International PHP Conference in Munich on 27 and 28 October. Otherwise, write to merlin [at] hook-dev-alter.com (merlin[at]hook-dev-alter[dot]com). Tell me which of your services holds state it should not share, and how you found out.

Disclaimer: my friend the babble-machine helped a lot with research, structure and wording. All errors are mine.

merlin portrait

Merlin Rutz

Based on 20 years in-depth drupal coding and a long-lasting contribution history Merlin helps teams to fix problems and introduce technical innovations. Founder and technical lead at HOOK_DEV_ALTER().

Alongside technical work, he brings experience with collaborative organizational models such as sociocracy, holacracy, and agile practices - helping teams improve how they share knowledge and make technical decisions.

merlinathook-dev-alter.com

Get notified about new articles.