feat: background workers = non-HTTP workers with shared state#2287
feat: background workers = non-HTTP workers with shared state#2287nicolas-grekas wants to merge 2 commits intophp:mainfrom
Conversation
e1655ab to
867e9b3
Compare
|
Interesting approach to parallelism, what would be a concrete use case for only letting information flow one way from the sidekick to the http workers? Usually the flow would be inverted, where a http worker offloads work to a pool of 'sidekick' workers and can optionally wait for a task to complete. |
da54ab8 to
a06ba36
Compare
|
Thank you for the contribution. Interesting idea, but I'm thinking we should merge the approach with #1883. The kind of worker is the same, how they are started is but a detail. @nicolas-grekas the Caddyfile setting should likely be per |
ad71bfe to
05e9702
Compare
|
@AlliBalliBaba The use case isn't task offloading (HTTP->worker), but out-of-band reconfigurability (environment->worker->HTTP). Sidekicks observe external systems (Redis Sentinel failover, secret rotation, feature flag changes, etc.) and publish updated configuration that HTTP workers pick up on their next request; with per-request consistency guaranteed via Task offloading (what you describe) is a valid and complementary pattern, but it solves a different problem. The non-HTTP worker foundation here could support both. @henderkes Agreed that the underlying non-HTTP worker type overlaps with #1883. The foundation (skip HTTP startup/shutdown, immediate readiness, cooperative shutdown) is the same. The difference is the API layer and the DX goals:
Happy to follow up with your proposals now that this is hopefully clarified. |
05e9702 to
8a56d4c
Compare
|
Great PR! Couldn't we create a single API that covers both use case? We try to keep the number of public symbols and config option as small as possible! |
Yes, that's why I'd like to unify the two API's and background implementations into one. Unfortunately the first task worker attempt didn't make it into |
|
The PHP-side API has been significantly reworked since the initial iteration: I replaced The old design used
Key improvements:
Other changes:
|
cb65f46 to
4dda455
Compare
|
Thanks @dunglas and @henderkes for the feedback. I share the goal of keeping the API surface minimal. Thinking about it more, the current API is actually quite small and already general:
The name "sidekick" works as a generic concept: a helper running alongside. The current set_vars/get_vars protocol covers the config-publishing use case. For task offloading (HTTP->worker) later, the same sidekick infrastructure could support:
Same worker type, same So the path would be:
The foundation (non-HTTP threads, cooperative shutdown, crash recovery, per-php_server scoping) is shared. Only the communication primitives differ. WDYT? |
b3734f5 to
ed79f46
Compare
|
|
|
Hmm, it seems they are on some versions, for example here: https://github.com/php/frankenphp/actions/runs/23192689128/job/67392820942?pr=2287#step:10:3614 For the cache, I'm not aware of a Github feature that allow to clear everything unfortunately 🙁 |
| preparedEnvNeedsReplacement bool | ||
| logger *slog.Logger | ||
| requestOptions []frankenphp.RequestOption | ||
| backgroundScope string |
There was a problem hiding this comment.
The scope can also just be an integer. It should probably be requested from and managed by the frankenphp package.
There was a problem hiding this comment.
I'm not sure I see benefits doing so. I'm leaving this as is, unless you feel strong about this?
|
Would be nice if someone with fresh eyes could look over this PR. Short summary from my side:
|
|
Another reason I dislike streams is that the polling api from (this RFC) might already be in PHP 8.6. |
|
Thanks for the review. Addressing each point: PR size / simplification: I removed dead methods, moved scope management to the frankenphp package ( API: Runtime worker starting: The main use case is libraries that ship their own background workers (e.g., a Redis Sentinel watcher package) without requiring users to list each one in the Caddyfile. The catch-all Cross-thread copying: Intentionally limited to scalars/arrays/enums - this enables immutable array zero-copy, interned string optimization, and the Stream-based shutdown: I explored signal-based shutdown extensively ( On the "clunky" concern, it's worth noting that the upcoming PHP 8.6 poll API RFC validates this direction: it introduces Example of forward compatibility - same code, both worlds: // Works today (PHP 8.2+)
$stream = frankenphp_worker_get_signaling_stream();
stream_select([$stream], [], [], 5);
$signal = fgets($stream);
// Works on PHP 8.6+ with zero changes to FrankenPHP
$poll = new Poll();
$poll->addReadable($stream, fn() => match (fgets($stream)) {
"stop\n" => false,
"task\n" => handleTask(), // anticipating from the task-based API in #2319
});
$poll->addTimer(5.0, fn() => frankenphp_worker_set_vars(refreshConfig()));
$poll->run();Same // Possible future API (PHP 8.6+, if SignalHandle lands)
$poll = new Poll();
$poll->addHandle(frankenphp_worker_get_poll_handle(), fn(string $signal) => match ($signal) {
'stop' => false,
'task' => handleTask(),
});
$poll->addTimer(5.0, fn() => frankenphp_worker_set_vars(refreshConfig()));
$poll->run();The stream version stays for BC, same underlying pipe, different wrapper. For simple cases today, the userland |
|
Protocol update: instead of using I'm now leaning toward renaming functions:
WDYT? |
@AlliBalliBaba If anything that's a positive because it's built upon streams and will just make the userland handling cleaner (which is my main objection for the stream api).
Much better!
I like it better than get_signaling_stream. Especially because:
Is there actually a reason to not make this |
|
Thanks for the push in that direction :) @dunglas asked me why only arrays as "vars"? Suggesting we should accept strings and other scalars.
About an HTTP-worker state, the purpose would be to expose a thread-safe in-memory KV store to sync them? |
|
I don't like that naming at all. It's too specific because it implies getting the state of a specific worker, which isn't at all the intention of the calling code. It we treat it more as a thread-safe, shared KV store we could want values for a specific topic, regardless of whether background workers, other http workers or even just regular requests set these variables before. I really liked (Though Limiting this PR to only background workers is good, but we shouldn't make the interface so specific that it wouldn't make sense for other related features that we could add in the future. |
|
Agreed the
The About non-array values, I'm also pushing back, with one core reason: contract evolution. When state is an array, the producer can add keys without breaking any consumer, that's a non-breaking change by definition. This makes things so much easier to evolve without limiting anything in practice. Same reason HTTP APIs return JSON objects rather than bare values. The overhead is one hash table, the forward-compatibility guarantee is significant. I'm now exploring adding |
With the $name parameter already in place, just do To elaborate a bit: /**
* @param ?string The name of the scope you wish to access variables from. In case of a lazy-loaded background worker scope, this starts the background worker.
function frankenphp_get_vars(?string $name): array;We can add a more powerful concepts of scopes down the line, let regular threads set vars, anything we want really, with only a change to the docblock. While keeping every addition completely background compatible. If we call them
+1, I think array only already makes sense purely by naming. It's
Different PR, please. This current one is already so massive, it's hard to continuously review the code before everyone here is happy with it in concept. Right now my focus is on making sure we don't make limiting decisions for things we can't foresee right now. Apart from that, one step at a time. |
|
Works for me, PR updated:
|
|
|
|
Ah right, that's part of BC too because people could explicitly pass the parameter by name. No idea, I think |
|
Another idea might be |
set_vars sets a single variable, and get_vars gets one or more variables. i think thinking about this, i wonder why we call the method set_vars? we can only set a single variable. |
|
"vars"-plural refers to the argument being a collections of value wrapped in an array. We're trying to have the minimum API surface, so not sure I'd add one more function. |
It should be setting a number of variables (php array) for a scope. Though it should arguably be
|
Not sure about this: one scope one name, part of the context. Adding a name would require the scope knowing its name, and would allow a scope to alter another scope. Not good either, this makes things way less bounded - aka more fragile to build on IMHO. |
Shouldn't a background worker always knows it (application side) name, since it's given by other application code? If |
|
By trying to make the new API do everything, we might just fail everything... |
|
Maybe we tried to hard to turn this into a global KV store and should get back to |
Yeah I'm talking about abstracting this via handles in the future. But I guess you're right that it would be 8.6+ only.
I actually kind of like that this is a key-value store. If the endgoal is to allow libraries to start their own background processes, wouldn't it be better to do something like this? // In library
$redisEndpoints = frankenphp_get_vars('redis:redis-host');
if(!$redisEndpoints){
frankenphp_start_background_worker('/path/to/background-worker.php', [
'host' => 'redis-host'
]);
}
$redisEndpoints = frankenphp_get_vars('redis');Starting the background worker simply blocks until it has reached 'ready' by calling |
|
@AlliBalliBaba pointed me to this PR: #2306 (comment) Reading the discussion it seems there is agreement that the shared state layer is essentially a process-wide key-value store. Took the time over the weekend to build one as a proof of concept, which I think offers some interesting benefits over the approach proposed in this PR. You can find the code here: https://github.com/johanjanssens/frankenstate. It implements a cross-thread shared state as a standalone FrankenPHP extension, a $state = new FrankenPHP\SharedArray();
$state['feature_flags'] = ['new_ui' => true];
$flags = $state['feature_flags'];Key differences from the
With this approach the "config watcher" flow changes to: The store is a KV with a standard protocol. The sync logic lives wherever it makes sense. A cron job, a deploy script, a Redis subscriber, whatever pushes data in over the Redis protocol, or whatever Go code uses the API. FrankenPHP doesn't need to know or care where the data comes from. Might be worth considering as an alternative approach to the shared state part of this PR. With a standalone shared state store, the background worker and signaling infrastructure in this PR may not be needed, or could be developed as a more generic solution decoupled from state management. Happy to develop it further if there is interest. Do not want to hijack this PR. Created a discussion thread here: #2324. |
|
To truly benefit from ZTS, you'll have to do what's done in this PR and leave the copy mechanism on the C side. Couple that with potential 0-copy if the value hasn't changed and performance is potentially orders of magnitude better. I still think we need a fast built-in cross-thread copy mechanism like was done in this PR, just think that the API could be more generic. |
Essentially my only remaining issue here.
I feel like it would be great if background workers could use this more general KV store as a backend to push/pull values back to http threads that started the background workers. I'm really sorry for being so nitpicky here because it's a great addition, I'm just focussed on making sure the decisions made here won't bite us later. I'll give the code a proper review in a few days again. |
@nicolas-grekas i thought you widened the argument to also accept single string or other scalar value. or have you reverted that? if its reverted, i retire my suggestion :-) regarding the general KV store vs workers: from my understanding, the intention from nikolas was that workers could be automatically launched on demand when a namespace is requested. maybe an approach could be:
setting values in the worker could use the generic set KV store method. except if the worker get method needs to be aware when the value is set, maybe we'd need a specific worker set value method to unlock the blocking call. but this would separate the handling of on-demand workers from the underlying KV mechanism. alternatively, we could have only the worker part and later refactor to a generic KV mechanism... |
Then we have an extra function for essentially the same thing, which is what I want to avoid. I'm closely aligned with @AlliBalliBaba's vision here. Similar to what he proposed: // In library
$redisEndpoints = frankenphp_get_vars('redis:redis-host');
if(!$redisEndpoints){
frankenphp_start_background_worker('/path/to/background-worker.php', [
'host' => 'redis-host'
]);
}
$redisEndpoints = frankenphp_get_vars('redis');Except that I like @nicolas-grekas concept of at-most-one worker, which would simplify this to: # in library
frankenphp_start_background_worker("projectDir/path/to/redis-updater.php", scopes: ['redis-host'], args: []);
$redisEndpoints = frankenphp_get_vars('redis-host');
# in redis-updater
frankenphp_set_vars($scopes, ['host' => 'new-redis-host']); |
Summary
Background workers are long-running PHP workers that run outside the HTTP cycle. They observe their environment (Redis, DB, filesystem, etc.) and publish variables that HTTP workers read per-request - enabling real-time reconfiguration without restarts or polling.
PHP API
frankenphp_set_vars(array $vars): void- publishes vars from a background worker (persistent memory, cross-thread). Skips all work when data is unchanged (===check).frankenphp_get_vars(string|array $name, float $timeout = 30.0): array- reads vars from any worker context (blocks until first publish, generational cache)frankenphp_get_worker_handle(): resource- returns a pipe-based stream forstream_select()integration. Closed on shutdown (EOF = stop).Caddyfile configuration
backgroundmarks a worker as non-HTTPnamespecifies an exact worker name; workers withoutnameare catch-all for lazy-started namesmax_threadson catch-all sets a safety cap for lazy-started instances (defaults to 16)max_consecutive_failuresdefaults to 6 (same as HTTP workers)max_execution_timeautomatically disabled for background workersphp_serverblock has its own isolated scope (managed byNextBackgroundWorkerScope())Shutdown
On restart/shutdown, the signaling stream is closed. Workers detect this via
fgets()returningfalse(EOF). Workers have a 5-second grace period.After the grace period, a best-effort force-kill is attempted:
max_execution_timetimer cross-thread viatimer_settime(EG(max_execution_timer_timer))CancelSynchronousIo+QueueUserAPCinterrupts blocking I/O and alertable waitsDuring the restart window,
get_varsreturns the last published data (stale but available). A warning is logged on crash.Forward compatibility
The signaling stream is forward-compatible with the PHP 8.6 poll API RFC.
Poll::addReadableaccepts stream resources directly - code written today withstream_selectwill work on 8.6 withPoll, no API change needed.Architecture
php_serverscope isolation with internal registry (unexported types, minimal public API viaNextBackgroundWorkerScope())backgroundWorkerThreadhandler implementingthreadHandlerinterface - decoupled from HTTP worker code pathsdrain()closes the signaling stream (EOF) for clean shutdown signalingpemalloc) withRWMutexfor safe cross-thread sharingset_varsskip: uses PHP's===(zend_is_identical) to detect unchanged data - skips validation, persistent copy, write lock, and version bumpget_varscalls return the same array instance (===is O(1))IS_ARRAY_IMMUTABLE)ZSTR_IS_INTERNED) - skip copy/free for shared memory strings$_SERVER['FRANKENPHP_WORKER_NAME']set for background workers$_SERVER['FRANKENPHP_WORKER_BACKGROUND']set for all workers (true/false)Example
Test coverage
17 unit tests + 1 Caddy integration test covering: basic vars, at-most-once start, validation, type support (enums, binary-safe strings), multiple background workers, multiple entrypoints, crash restart, signaling stream, worker restart lifecycle, non-background-worker error handling, identity detection, generational cache, named auto-start with m# prefix.
All tests pass on PHP 8.2, 8.3, 8.4, and 8.5 with
-race. Zero memory leaks on PHP debug builds.Documentation
Full docs at
docs/background-workers.md.