Skip to content

Commit 73dad02

Browse files
feat: SentTaskHandle and ReceivedTaskHandle
1 parent 61c76ea commit 73dad02

32 files changed

Lines changed: 2848 additions & 72 deletions

‎caddy/caddy_test.go‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -989,7 +989,7 @@ func TestWorkerMetrics(t *testing.T) {
989989
# TYPE frankenphp_busy_threads gauge
990990
frankenphp_busy_threads 2
991991
992-
# HELP frankenphp_busy_workers Number of busy PHP workers for this worker
992+
# HELP frankenphp_busy_workers Number of busy PHP workers for this worker: processing a request, or a task for a background worker
993993
# TYPE frankenphp_busy_workers gauge
994994
frankenphp_busy_workers{worker="` + workerName + `"} 0
995995
@@ -1146,7 +1146,7 @@ func TestNamedWorkerMetrics(t *testing.T) {
11461146
# TYPE frankenphp_busy_threads gauge
11471147
frankenphp_busy_threads 2
11481148
1149-
# HELP frankenphp_busy_workers Number of busy PHP workers for this worker
1149+
# HELP frankenphp_busy_workers Number of busy PHP workers for this worker: processing a request, or a task for a background worker
11501150
# TYPE frankenphp_busy_workers gauge
11511151
frankenphp_busy_workers{worker="my_app"} 0
11521152
@@ -1242,7 +1242,7 @@ func TestAutoWorkerConfig(t *testing.T) {
12421242
# TYPE frankenphp_busy_threads gauge
12431243
frankenphp_busy_threads ` + workers + `
12441244
1245-
# HELP frankenphp_busy_workers Number of busy PHP workers for this worker
1245+
# HELP frankenphp_busy_workers Number of busy PHP workers for this worker: processing a request, or a task for a background worker
12461246
# TYPE frankenphp_busy_workers gauge
12471247
frankenphp_busy_workers{worker="` + workerName + `"} 0
12481248
@@ -1516,6 +1516,7 @@ func TestMaxWaitTimeWorker(t *testing.T) {
15161516
require.NoError(t, err)
15171517

15181518
expectedMetrics := `
1519+
# HELP frankenphp_worker_queue_depth Number of queued requests for this worker, or of tasks waiting for a thread of a background worker
15191520
# TYPE frankenphp_worker_queue_depth gauge
15201521
frankenphp_worker_queue_depth{worker="service"} 0
15211522
`
@@ -1616,7 +1617,7 @@ func TestMultiWorkersMetrics(t *testing.T) {
16161617
# TYPE frankenphp_busy_threads gauge
16171618
frankenphp_busy_threads 5
16181619
1619-
# HELP frankenphp_busy_workers Number of busy PHP workers for this worker
1620+
# HELP frankenphp_busy_workers Number of busy PHP workers for this worker: processing a request, or a task for a background worker
16201621
# TYPE frankenphp_busy_workers gauge
16211622
frankenphp_busy_workers{worker="service1"} 0
16221623

‎caddy/config_test.go‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,39 @@ func TestWorkerBackgroundVars(t *testing.T) {
390390
require.Equal(t, []string{"redis", "feature_flags"}, module.Workers[0].Vars)
391391
}
392392

393+
func TestWorkerBackgroundTasks(t *testing.T) {
394+
d := caddyfile.NewTestDispenser(`
395+
{
396+
php_server {
397+
worker {
398+
file ../testdata/worker-with-env.php
399+
background
400+
num 4
401+
tasks image.resize image.thumbnail
402+
}
403+
}
404+
}`)
405+
module := &FrankenPHPModule{}
406+
407+
require.NoError(t, module.UnmarshalCaddyfile(d))
408+
require.Equal(t, []string{"image.resize", "image.thumbnail"}, module.Workers[0].Tasks)
409+
}
410+
411+
func TestWorkerTasksRequireBackground(t *testing.T) {
412+
d := caddyfile.NewTestDispenser(`
413+
{
414+
php_server {
415+
worker {
416+
file ../testdata/worker-with-env.php
417+
tasks jobs
418+
}
419+
}
420+
}`)
421+
module := &FrankenPHPModule{}
422+
423+
require.ErrorContains(t, module.UnmarshalCaddyfile(d), `"tasks" is only supported for background workers`)
424+
}
425+
393426
func TestWorkerVarsRequireBackground(t *testing.T) {
394427
d := caddyfile.NewTestDispenser(`
395428
{
@@ -482,3 +515,11 @@ func TestWorkerVarsReachTheCoreWithoutBackground(t *testing.T) {
482515

483516
require.ErrorContains(t, frankenphp.Validate(frankenphp.WithWorkers("http", wc.FileName, 1, opts...)), "only background workers publish them")
484517
}
518+
519+
func TestWorkerTasksReachTheCoreWithoutBackground(t *testing.T) {
520+
wc := workerConfig{FileName: "../testdata/worker-with-env.php", Tasks: []string{"jobs"}}
521+
opts, err := wc.toWorkerOptions()
522+
require.NoError(t, err)
523+
524+
require.ErrorContains(t, frankenphp.Validate(frankenphp.WithWorkers("http", wc.FileName, 1, opts...)), "only background workers receive them")
525+
}

‎caddy/workerconfig.go‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ type workerConfig struct {
4545
Background bool `json:"background,omitempty"`
4646
// Vars lists the namespaces a background worker publishes with WorkerHandle::setVars(), read by frankenphp_get_vars().
4747
Vars []string `json:"vars,omitempty"`
48+
// Tasks lists the namespaces a background worker receives, the ones a FrankenPHP\SentTaskHandle is sent to.
49+
Tasks []string `json:"tasks,omitempty"`
4850

4951
options []frankenphp.WorkerOption
5052
}
@@ -169,8 +171,14 @@ func unmarshalWorker(d *caddyfile.Dispenser) (workerConfig, error) {
169171
return wc, d.ArgErr()
170172
}
171173
wc.Vars = append(wc.Vars, namespaces...)
174+
case "tasks":
175+
namespaces := d.RemainingArgs()
176+
if len(namespaces) == 0 {
177+
return wc, d.ArgErr()
178+
}
179+
wc.Tasks = append(wc.Tasks, namespaces...)
172180
default:
173-
return wc, wrongSubDirectiveError("worker", "name, file, num, env, watch, match, max_consecutive_failures, max_threads, background, boot_timeout, vars", v)
181+
return wc, wrongSubDirectiveError("worker", "name, file, num, env, watch, match, max_consecutive_failures, max_threads, background, boot_timeout, vars, tasks", v)
174182
}
175183
}
176184

@@ -190,6 +198,10 @@ func unmarshalWorker(d *caddyfile.Dispenser) (workerConfig, error) {
190198
return wc, d.Err(`"vars" is only supported for background workers`)
191199
}
192200

201+
if !wc.Background && len(wc.Tasks) != 0 {
202+
return wc, d.Err(`"tasks" is only supported for background workers`)
203+
}
204+
193205
if frankenphp.EmbeddedAppPath != "" && filepath.IsLocal(wc.FileName) {
194206
wc.FileName = filepath.Join(frankenphp.EmbeddedAppPath, wc.FileName)
195207
}
@@ -219,12 +231,16 @@ func (wc *workerConfig) toWorkerOptions() ([]frankenphp.WorkerOption, error) {
219231
}
220232
}
221233

222-
// whatever the kind of worker: the core refuses vars on an HTTP one, which
223-
// a JSON config reaches without the Caddyfile check
234+
// whatever the kind of worker: the core refuses vars and tasks on an HTTP
235+
// one, which a JSON config reaches without the Caddyfile check
224236
if len(wc.Vars) > 0 {
225237
opts = append(opts, frankenphp.WithWorkerVars(wc.Vars...))
226238
}
227239

240+
if len(wc.Tasks) > 0 {
241+
opts = append(opts, frankenphp.WithWorkerTasks(wc.Tasks...))
242+
}
243+
228244
// copy the caddy match logic and create a unique matcher function for this worker
229245
// inject the matcher into frankenphp
230246
if len(wc.MatchPath) > 0 {

‎docs/config.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ You can also explicitly configure FrankenPHP using the [global option](https://c
114114
boot_timeout <duration> # EXPERIMENTAL: how long a background worker may take to reach its first tick() before the start fails, 0 waits forever. Default: 30s on PHP builds without Zend max execution timers, none elsewhere.
115115
background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker, whose script ticks its FrankenPHP\WorkerHandle at least once
116116
vars <namespace...> # EXPERIMENTAL: the vars namespaces a background worker publishes, read with frankenphp_get_vars()
117+
tasks <namespace...> # EXPERIMENTAL: the task namespaces a background worker receives, sent with FrankenPHP\SentTaskHandle
117118
}
118119
}
119120
}
@@ -205,6 +206,7 @@ php_server [<matcher>] {
205206
boot_timeout <duration> # EXPERIMENTAL: how long a background worker may take to reach its first tick() before the start fails, 0 waits forever. Default: 30s on PHP builds without Zend max execution timers, none elsewhere.
206207
background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker, whose script ticks its FrankenPHP\WorkerHandle at least once
207208
vars <namespace...> # EXPERIMENTAL: the vars namespaces a background worker publishes, read with frankenphp_get_vars()
209+
tasks <namespace...> # EXPERIMENTAL: the task namespaces a background worker receives, sent with FrankenPHP\SentTaskHandle
208210
}
209211
worker <other_file> <num> # Can also use the short form like in the global frankenphp block.
210212
}

‎docs/metrics.md‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@ When [Caddy metrics](https://caddyserver.com/docs/metrics) are enabled, FrankenP
1616
- `frankenphp_busy_threads`: The number of PHP threads currently processing a request (running workers always consume a thread).
1717
- `frankenphp_queue_depth`: The number of regular queued requests.
1818
- `frankenphp_total_workers{worker="[worker_name]"}`: The total number of workers.
19-
- `frankenphp_busy_workers{worker="[worker_name]"}`: The number of workers currently processing a request.
19+
- `frankenphp_busy_workers{worker="[worker_name]"}`: The number of workers currently processing a request, or a task for a background worker.
2020
- `frankenphp_worker_request_time{worker="[worker_name]"}`: The time spent processing requests by all workers.
2121
- `frankenphp_worker_request_count{worker="[worker_name]"}`: The number of requests processed by all workers.
2222
- `frankenphp_ready_workers{worker="[worker_name]"}`: The number of workers that have reached their ready point at least once: `frankenphp_handle_request()` for HTTP workers, `WorkerHandle::tick()` for background workers.
2323
- `frankenphp_worker_crashes{worker="[worker_name]"}`: The number of times a worker has unexpectedly terminated.
2424
- `frankenphp_worker_restarts{worker="[worker_name]"}`: The number of times a worker has been deliberately restarted.
25-
- `frankenphp_worker_queue_depth{worker="[worker_name]"}`: The number of queued requests.
25+
- `frankenphp_worker_queue_depth{worker="[worker_name]"}`: The number of queued requests, or of tasks waiting for a thread of a background worker.
26+
- `frankenphp_worker_task_count{worker="[worker_name]",outcome="[outcome]"}`: The number of tasks sent to a background worker, by outcome: `completed`, `aborted` (the script ended with the task open), `abandoned` (the sender closed its stream first), `timeout` (no thread picked the task up in time) or `cancelled` (the wait for a thread ended on a restart or the shutdown).
27+
- `frankenphp_worker_task_time{worker="[worker_name]"}`: The time spent on tasks by all threads of a background worker, from pickup to the close of the task's stream.
2628
- `frankenphp_opcache_restarts{reason="[reason]"}`: (experimental) The number of restarts of opcache's shared memory scheduled, by reason (`oom`, `hash`, `manual`). PHP 8.4 and up.
2729

2830
For worker metrics, the `[worker_name]` placeholder is replaced by the worker name in the Caddyfile, otherwise the absolute path of the worker file will be used.
@@ -76,8 +78,8 @@ Each entry in `ThreadDebugStates` contains:
7678
| `Index` | integer | The index of the thread. |
7779
| `Name` | string | The name of the thread (e.g., the worker file path). |
7880
| `State` | string | The internal state of the thread (e.g., `ready`, `shutting down`). |
79-
| `IsWaiting` | boolean | Whether the thread is waiting for a request. |
80-
| `IsBusy` | boolean | Whether the thread is currently processing a request. |
81+
| `IsWaiting` | boolean | Whether the thread is waiting for a request, or for a task in a background worker. |
82+
| `IsBusy` | boolean | Whether the thread is currently processing a request, or a task in a background worker. |
8183
| `WaitingSinceMilliseconds` | integer | How long the thread has been idle, in milliseconds. `0` if the thread is busy. |
8284
| `CurrentURI` | string | The URI currently being processed. Empty if the thread is idle. |
8385
| `CurrentMethod` | string | The HTTP method of the current request (e.g., `GET`, `POST`). Empty if the thread is idle. |

‎docs/worker.md‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,62 @@ Values must be null, scalars, arrays or enums. Each call replaces the whole snap
310310

311311
`frankenphp_get_vars()` can only block while workers boot, a background worker or the bootstrap of an HTTP worker waiting for the publisher of a namespace. A cycle between background workers throws instead of hanging, and so does a wait once the start failed. It also throws when no worker publishes the namespace.
312312

313+
### Sending tasks to background workers
314+
315+
A background worker receives the task namespaces declared with `tasks` on it, and a request, an HTTP worker or another background worker hands work to it by constructing a `FrankenPHP\SentTaskHandle` with one of those namespaces, resolved the way `frankenphp_get_vars()` resolves vars: within the `php_server` first, then among global workers.
316+
317+
```caddyfile
318+
php_server {
319+
worker {
320+
file images.php
321+
background
322+
num 4
323+
tasks image.resize image.thumbnail
324+
}
325+
}
326+
```
327+
328+
A namespace has one receiving worker per scope, and the threads of that worker share one queue for all its namespaces, so a pool is sized for the work it takes on, and `getNamespace()` on a received task tells which namespace it was sent to. The payload follows the same rules as `setVars()`: null, scalars, arrays or enums. Constructing blocks until a thread of the worker picks the task up and throws if none did before the timeout, so a busy worker pushes back on its senders instead of queueing without bounds. `read()` then blocks for the next update and returns `null` once the worker completed the task, and `abandon()`, like dropping the handle, gives up on the task. Waiting on a task, alone or with others, goes through a context as it does for the worker's handle, or through `getStream()` for a library that takes a stream: reading that stream steals the signal `read()` needs, writing to it goes nowhere, and closing it ends the task on that side.
329+
330+
On the worker side, each task sent wakes one parked thread of the worker through its handle, in the loop of the previous section: `tick()` consumes the wake-up, which is not a count and in a pool may belong to a task a sibling thread took. `receive()` dequeues a task without blocking, a `FrankenPHP\ReceivedTaskHandle`, or `null` when another thread of the pool got there first, so the example below drains the queue on each wake-up and treats `null` as the normal outcome. A thread that ticks while tasks are queued finds its handle readable at once, whichever loop shape it uses. `update()` sends progress back, `complete()` ends the task with a last update when one is given, and a script that ends with a task still open, a `complete()` from a destructor or a shutdown function at that point included, makes the sender's next `read()` throw. When the sender gives up instead, the task's side of the worker becomes readable and its stream reaches EOF, so a context or a `feof()` tells a long task that nobody waits for its result, and `update()` throws.
331+
332+
```php
333+
// background worker, in the loop of the previous section
334+
while ($handle->tick()) {
335+
foreach ($poll->wait() as $watcher) {
336+
}
337+
338+
while ($task = $handle->receive()) {
339+
$task->update(['progress' => 50]);
340+
$task->complete(['result' => process($task->getNamespace(), $task->getPayload())]);
341+
}
342+
}
343+
344+
// request, HTTP worker or another background worker
345+
$task = new FrankenPHP\SentTaskHandle('image.resize', ['file' => 'photo.jpg']);
346+
while (null !== $update = $task->read()) {
347+
// ['progress' => 50], then ['result' => ...]
348+
}
349+
```
350+
351+
Both task handles are `Io\Poll\Handle` as well, so a context follows several tasks at once, and a script that never asks for a stream has none built for it:
352+
353+
```php
354+
use Io\Poll\{Context, Event};
355+
356+
$poll = new Context();
357+
$task = new FrankenPHP\SentTaskHandle('image.resize', ['file' => 'photo.jpg']);
358+
$poll->add($task, [Event::Read], 'photo');
359+
360+
foreach ($poll->wait() as $watcher) {
361+
$update = $task->read();
362+
}
363+
```
364+
365+
`getStream()` is there for the libraries that take a stream, and `stream_select()` or a blocking read work on it as they do on the worker's handle.
366+
367+
Sixteen updates are buffered per task; past that, `update()` waits for the sender to read, and it throws once the sender gave up. The channel of a task is a pair of eventfd descriptors on Linux and of kqueue descriptors on macOS, pooled between tasks, and a socket pair elsewhere.
368+
313369
## Superglobals behavior
314370

315371
[PHP superglobals](https://www.php.net/manual/language.variables.superglobals.php) (`$_SERVER`, `$_ENV`, `$_GET`...)

0 commit comments

Comments
 (0)