Problem/Motivation
Persistent app servers (such as FrankenPHP worker mode) handle tons of requests with one kernel. They do this by keeping services in memory, avoiding the need to bootstrap repeatedly. However, most of Drupal assumes that one bootstrap = one request. Between requests, core currently only offers two choices for handling instantiated services:
- Keep all instantiated services. In this case, with a persistent app server, any service that stores request, user, or session data leaks it into the next request. Example: #3050383: PageCache cannot handle multiple main requests
- Drop all instantiated services, with Container::reset() or DrupalKernel::resetContainer(), or by rebooting the kernel. This is safe, but then every request rebuilds the module handler, entity type manager, plugin managers, event dispatcher, and Twig environment, which removes most of the benefit of a persistent worker.
Proposed resolution
Symfony already has support for worker mode. Symfony keeps every service instance between requests by default, but has an opt-in reset list that resets services at the start of each main request after the first.
Drupal should basically do the same thing in a new major version (Drupal 13, hopefully?):
- Require all services to be stateless or opt-in to resetting by tagging themselves with a "reset" tag.
- This reset will happen in place: the same instance will be kept and its reset method will be called. This way, services that depend on it keep a valid reference.
- Only reset services that were instantiated.
Remaining tasks
- Work out the details of the approach.
- How do we find services that have state that does not have a reset method: request data in properties, drupal_static(), or static caches?
- How should we order resets?
- Should the tag name reuse Symfony's kernel.reset with its method and on_invalid attributes?
- Reach agreement on the tag name.
- Implement the code and tests.
- Tag the relevant core services.
API changes
- New service tag.
- The kernel calls reset methods on tagged, instantiated services before each main request after the first.
Release notes snippet
TBD
Comments
Comment #2
ptmkenny commentedComment #3
mfbWould there be a way to signal services to start a new runtime context? This is what I would need e.g. for the Sentry integration - it doesn't need to be reset in the sense of rebuilt, just provided a new runtime context or signaled to start a new runtime context.
Comment #4
ghost of drupal pastYes, this would be great for temporal too.
Currently, the biggest issue temporal struggles with is the database connection going away between requests. The database API needs a driver dependent method which checks whether the exception thrown is such a one and if yes then the query needs to be automatically retried.
Until then, no service which holds the database connection can be marked safe which would render the issue moot :(
Comment #5
catch#3494917: Reconnect and retry if the database connection goes away is open for that.
Comment #6
ghost of drupal pastRecommendation:
Comment #7
ptmkenny commented@mfb Hmm, with an allow list, services would be rebuilt for the next request unless opted in (designated safe to keep), so there wouldn't be a signal. For a tagged service that implements ResetInterface, the kernel would call reset() either at the end of the request (terminate()) or at the start of the next main request, which would be an opportunity to do something. Is that enough, or do you need something else?
@ghost of drupal past: For point 1, I'm not sure that this issue should be blocked on the connection one; we need both, but they are separate scopes so hopefully they can be worked on in parallel.
Regarding your point 2, although "should be very rare" is what I was hoping for as well, testing FrankenPHP in worker mode over the past few days revealed a lot of services holding onto stuff that they should not be. I used Opus to quickly make a list of issues that I had to patch locally to get my behat and playwright tests to pass (initially, I didn't do any allow list or deny list; I just tried to fix everything):
I am going to keep experimenting; now that I have tests passing, I've been fixing memory leaks and will next try to actually get a big increase in performance.
Comment #8
catchConsidering the number of services in core, the list in #7 looks relatively short to me. I would also expect that in at least some cases, we'd want to fix those rather than refreshing the services, making the deny list eventually even shorter.
Comment #9
ghost of drupal pastMost of this has a shape of "on kernel terminate needs to clean X out so the next request will start clean". This is better than recreating the service because service unclean propagates to the dependents and if AccountProxy and Renderer are unclean then the party is over before it started.
Changing Block access control handler caching logic is a separate issue, SessionManager is likely to be immense fun (if someone wants in, this SessionInitalizer class gives you a very concise overview of how session init happens with Symfony, the initialize methods of various bags create a reference into $_SESSION),
the rest is just subscribe to KernelEvents::TERMINATE and do some internal cleanup as far as I can see.
Comment #10
ptmkenny commented@catch and @ghost of drupal past:
Ok, you've convinced me :) I initially suggested the allow list because it was very easy to prototype, but when I actually tried to allow more services, I ran into a lot of trouble.
Symfony's EventDispatcher in particular looked to be very difficult to solve with an allow list, as Symfony seems to be designed to deny and reset everything in place.
I am now trying to follow what Symfony does, since they already have worker mode support, and seeing how we can adapt that to Drupal. I'll update the IS later after I understand it better.
Comment #11
ptmkenny commentedOld Problem/Motivation (reference only)
So, we need a way to identify which services are safe to keep, and which need to be reset for each request. There are a few ways we could do this. In #3324241: Provide DIC-friendly, concurrency-safe alternative(s) to `drupal_static()`, geek-merlin suggested an allow list:
Alternately, kim.pepper suggested collecting services that implement ResetInterface and resetting them (deny list approach), which is the approach that Symfony uses with the kernel.reset tag.
I think both approaches could work, but I favor an allow list because there are so many services in Drupal and if we allow by default, every service that may be stateful in contrib would leak data into subsequent requests, which seems like a security nightmare.
Comment #12
ptmkenny commentedOk, I rewrote the IS to follow Symfony's approach. Also, I think this approach is workable because I have a prototype already sort-of working: https://github.com/ptmkenny/drupal_frankenphp_worker_mode/blob/main/patc...
What are your thoughts on the new IS?
Comment #13
ptmkenny commented