Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ FROM ${BASE_IMAGE}

COPY build/ /var/www/html/
COPY .github/docker/entrypoint.sh /usr/local/bin/evo-entrypoint
COPY .github/docker/worker.Caddyfile /etc/evo/worker.Caddyfile

# Friendly URLs: the repository ships the Apache rules as ht.access. The nginx
# and FrankenPHP runtimes carry their own rules inside the base image, and
Expand Down
148 changes: 148 additions & 0 deletions .github/docker/WORKER.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# Experimental FrankenPHP frontend workers

The default image still serves each request in classic PHP mode. On a FrankenPHP
image, opt in with:

```bash
docker.exe run -d --name evo-worker -p 8080:80 \
-e EVO_WORKER_MODE=1 \
-e EVO_WORKER_ORIGIN=http://localhost:8080 \
ghcr.io/evolution-cms/evolution:3.5.x-php8.4-frankenphp
```

This command requires an image built with the worker implementation. The site
must already be installed. `EVO_WORKER_ORIGIN` is the exact public scheme, hostname
and port, without a path. Behind TLS termination, configure the trusted proxy to
send the public Host and X-Forwarded-Proto.

## Hostnames

The first request of a worker fixes `EVO_SITE_URL` and the constants derived from
it, so a worker serves exactly one origin:

- A request for `EVO_WORKER_ORIGIN` is served. Its Host header is replaced by the
configured one first, so a client cannot choose the letter case of the site URL
(and of the pages cached from it).
- A request for another entry of `EVO_SITE_HOSTNAMES` with the same scheme receives
a 308 redirect to the same path on `EVO_WORKER_ORIGIN` (`www.example.com` to
`example.com`). Classic PHP serves every listed hostname under its own name;
a worker makes the origin canonical. Another scheme is not redirected: behind
a proxy that drops X-Forwarded-Proto, that would loop.
- Any other origin receives 421.

When `EVO_SITE_HOSTNAMES` (environment, `.env` or `core/custom/define.php`) does
not list `EVO_WORKER_ORIGIN`, or a defined `EVO_SITE_URL` points elsewhere, the
worker refuses to start: its pages would link to a site it does not serve. Run
separate worker instances for unrelated origins.

## Application lifetime

The PHP worker, autoloader and loaded classes survive requests. By default each
request gets a **new Evo container**, providers, settings, parser, authorization
and session. It does not install or require Laravel Octane.

`EVO_WORKER_KEEP_APP=1` keeps the booted container instead (experimental). Providers
register and boot once per worker; each request starts from that post-boot state
and builds its own settings, parser, authorization and session as before. After
every request the properties of the container, of Core and of every service
resolved while providers booted (and of the objects those services hold directly,
such as the view finder and the route collection) return to their post-boot values,
so whatever a request bound, resolved, registered or set on them is dropped. Kept
services with a public `flushState()` (Laravel's and Livewire's convention) or
Symfony's `ResetInterface` are reset through it. In debug mode, responses carry
`X-Evo-Worker-App: new|kept` and the log lists the kept services.

Providers boot before the session starts, in both lifetimes and with either
`EVO_SESSION` setting (a worker starts the native session of `EVO_SESSION=false` in
`Core::initialize()`), so while booting they always see an empty session and a
guest. The query string, cookies and request headers they could read there are
those of the first request of each worker in this mode: read them per request,
in middleware, a plugin event or a binding resolved during the request. An extension service resolved
during boot that keeps request state deeper than one object level must implement
one of the reset hooks. Test the installed extensions with this mode before using
it in production; without it, workers behave as described above.

Only the root `index.php` is registered as a worker. Manager, installer and other
PHP entry points remain classic. `EVO_WORKER_MODE=1` alone does not activate the
runtime under Apache, FPM, CLI or a classic FrankenPHP request. To disable workers,
remove the setting and restart the container.

**System info** in the manager has a *Worker mode* row: unavailable outside FrankenPHP,
available but off, or the configured lifetime and recycle limit. The manager runs
classic, so the row reflects the environment the workers start with, not a live worker.

Settings:

| Environment variable | Default | Meaning |
| --- | --- | --- |
| `EVO_WORKER_COUNT` | `2` | Number of frontend workers in the supplied Caddyfile |
| `EVO_WORKER_MAX_REQUESTS` | `500` | Recycle each worker after this many requests; 1–100000 |
| `EVO_WORKER_KEEP_APP` | off | `1` keeps one booted container per worker (see above) |
| `EVO_WORKER_DEBUG` | off | `1` adds X-Evo-Worker, X-Evo-Request and X-Evo-Worker-App response headers |

With a custom Caddyfile, register `/var/www/html/index.php` as the worker and
retain the normal PHP handler for the manager. The supplied `worker.Caddyfile`
contains friendly-URL rewrites and blocks private CMS files. Keep equivalent
access rules when adapting it to another document root or HTTPS.

## Extension compatibility

Core request exits use `evo_exit($status)` and request shutdown jobs use
`evo_register_shutdown($callback)`. In classic PHP these delegate to `exit` and
`register_shutdown_function`. In a worker, exit raises a request termination
signal; cleanup runs once at the end of that request. Shutdown callbacks run
before returning the response to FrankenPHP, so they must not rely on the
connection already being detached. Core deferred jobs retain their existing
finish-request call where the SAPI supports it.

A legacy snippet/plugin that calls native `exit` still finishes its response, but
restarts the worker and loses the reuse benefit. Native shutdown handlers execute
when that worker ends, not on each request; migrate per-request jobs to the helper.
An extension must not catch and swallow `EvolutionCMS\Runtime\RequestTerminated`
when catching `Throwable` or `Error`.

The worker resets CMS singletons, model boot state, session flags, global request
variables, environment superglobal changes, mutable PHP INI settings, error handlers and timezone;
open database transactions are rolled back and connections closed. With a new
container per request, it also empties the static callback lists that providers
append to on every boot (Artisan bootstrappers, the TrimStrings and
ConvertEmptyStringsToNull skip callbacks), whose closures would otherwise keep every
previous container in memory. Exceptions
return a generic 500, are logged server-side and recycle the worker. Project PHP
statics, function statics, constants, custom autoloaders and process-wide changes
made by extensions cannot be reset automatically. Validate installed extensions
before enabling workers on a production site. Restart workers after code,
custom definitions or environment changes; CMS cache/settings changes are read
again on the next request. OPcache revalidates timestamps against the request start
time, which a worker does not advance, so before every request the worker rechecks
the PHP cache files that another process may rewrite (site cache, publishing
schedule, env cache): a change saved through the CLI or another server is seen at
once. Other PHP files, such as custom routes, keep their compiled version until the
worker restarts. A full CMS cache clear also calls `opcache_reset()`; FrankenPHP
versions that restart PHP threads on that call will recycle workers.

## Measurements

Local Docker Desktop, PHP 8.4.24, SQLite, one FrankenPHP image with the default
providers (including EvoUI, Livewire, dTui editor, sGallery and sTask) in all three
modes; one keep-alive connection, 30 warmup requests followed by 300 sequential
requests per page. These measure latency, not concurrent throughput:

| Page | Classic | Worker | Worker, kept app | Kept vs classic |
| --- | ---: | ---: | ---: | ---: |
| Cached homepage | 7.69 ms | 6.87 ms | 3.33 ms | 2.31x |
| Uncached page with a CMS snippet | 9.14 ms | 6.99 ms | 3.90 ms | 2.35x |
| Cached page with a cached snippet | 6.84 ms | 5.69 ms | 2.24 ms | 3.05x |

Of about 5 ms of PHP time per warm worker request, registering and booting the
providers took 3.4 ms; a kept application skips them. The new-container mode saves
autoloader and class-loading work only. Measure a representative site before
choosing a mode; these results do not predict gains for SQL-heavy pages.

The FrankenPHP CI legs run `ci/worker-smoke.py` against a disposable seeded
single-worker container, once per application lifetime, including alternating
user/guest sessions, state reset, redirects, forwards, a real 404, transaction
rollback, native exit, exception recovery, a classic manager, a 100-request memory
check, the hostname redirect and a settings change made by a CLI process. A second container
verifies recycling after exactly two requests. Test routes are installed only by
`ci/seed-worker.php` and must never be copied into a production site's custom routes.
12 changes: 12 additions & 0 deletions .github/docker/ci/external-setting.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

// Changes a setting from a CLI process, as the manager of another server, a deploy or cron would.
if (getenv('EVO_WORKER_MODE') !== '1' || getenv('EVO_WORKER_DEBUG') !== '1') {
throw new RuntimeException('This fixture is only for a disposable worker test container.');
}
define('EVO_API_MODE', true);
require ($argv[1] ?? '/var/www/html') . '/index.php';
evo();
Illuminate\Support\Facades\DB::table('system_settings')
->where('setting_name', 'worker_setting')->update(['setting_value' => $argv[2]]);
evo()->clearCache('full');
29 changes: 29 additions & 0 deletions .github/docker/ci/seed-worker.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

if (getenv('EVO_WORKER_MODE') !== '1' || getenv('EVO_WORKER_DEBUG') !== '1') {
throw new RuntimeException('This fixture is only for a disposable worker test container.');
}
define('EVO_API_MODE', true);
$root = $argv[1] ?? '/var/www/html';
require $root . '/index.php';
evo();
copy(__DIR__ . '/worker-routes.php', EVO_CORE_PATH . 'custom/routes.php');
$snippet = EvolutionCMS\Models\SiteSnippet::firstOrNew(['name' => 'WorkerProbe']);
$snippet->snippet = 'return "worker-demo:" . evo()->documentIdentifier;';
$snippet->save();
$page = EvolutionCMS\Models\SiteContent::firstOrNew(['alias' => 'worker-demo']);
$page->fill(['pagetitle' => 'Worker demo', 'published' => 1, 'template' => 0,
'alias_visible' => 1, 'cacheable' => 0, 'content' => '<h1>[[WorkerProbe]]</h1>']);
$page->save();
$errorPage = EvolutionCMS\Models\SiteContent::firstOrNew(['alias' => 'worker-error']);
$errorPage->fill(['pagetitle' => 'Worker error page', 'published' => 1, 'template' => 0,
'cacheable' => 0, 'content' => '<h1>worker-error</h1>']);
$errorPage->save();
foreach (['seostrict' => '0', 'error_page' => (string) $errorPage->id,
'worker_transaction' => 'original', 'worker_setting' => 'original'] as $name => $value) {
Illuminate\Support\Facades\DB::table('system_settings')->updateOrInsert(
['setting_name' => $name], ['setting_value' => $value]
);
}
evo()->clearCache('full');
echo json_encode(['page' => $page->id]) . PHP_EOL;
117 changes: 117 additions & 0 deletions .github/docker/ci/worker-routes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Route;

// The routes file runs again for every fresh container; a class is declared only once per worker.
if (!class_exists('WorkerProbeController', false)) {
final class WorkerProbeController
{
private ?int $user;

public function __construct(private Illuminate\Http\Request $request)
{
$this->user = app('AuthServices')->id();
}

public function __invoke()
{
return response()->json(['value' => $this->request->query('value'), 'user' => $this->user]);
}
}

// First instantiated by a request, after a kept application took its snapshot.
final class WorkerProbeSetting extends EvolutionCMS\Models\SystemSetting
{
public static array $events = [];

protected static function booted(): void
{
foreach (['saving', 'creating'] as $event) {
static::registerModelEvent($event, static function () use ($event) {
self::$events[] = $event;
});
}
}
}
}

Route::get('/__worker/state', static function () {
$_SESSION['worker_counter'] = ($_SESSION['worker_counter'] ?? 0) + 1;
return response()->json([
'counter' => $_SESSION['worker_counter'],
'user' => app('AuthServices')->id(),
'placeholder' => evo()->getPlaceholder('worker_marker'),
'scripts' => evo()->jscripts,
'core_config' => evo()->getConfig('worker_marker'),
'config' => config('worker_marker'),
'global' => $GLOBALS['worker_marker'] ?? null,
'environment' => $_ENV['worker_marker'] ?? null,
'locale' => app('translator')->getLocale(),
'precision' => ini_get('precision'),
'redirected' => $_SESSION['worker_redirected'] ?? false,
'transaction' => DB::table('system_settings')->where('setting_name', 'worker_transaction')->value('setting_value'),
'shutdowns' => (int) @file_get_contents('/tmp/evo-worker-shutdowns'),
'native_cleanup' => is_file('/tmp/evo-worker-native-cleanup'),
'memory' => memory_get_usage(true),
'setting' => evo()->getConfig('worker_setting'),
]);
});
Route::get('/__worker/mutate', static function () {
evo()->setPlaceholder('worker_marker', 'dirty');
evo()->jscripts[] = 'dirty';
evo()->setConfig('worker_marker', 'dirty');
app('config')->set('worker_marker', 'dirty');
app('translator')->setLocale('fr');
$GLOBALS['worker_marker'] = 'dirty';
$_ENV['worker_marker'] = 'dirty';
ini_set('precision', '9');
date_default_timezone_set('Pacific/Honolulu');
return 'mutated';
});
Route::get('/__worker/login', static function () {
$_SESSION['webValidated'] = true;
$_SESSION['webInternalKey'] = 1;
return 'logged in';
});
Route::get('/__worker/redirect', static function () {
$_SESSION['worker_redirected'] = true;
evo()->sendRedirect('/__worker/state');
});
Route::get('/__worker/forward', static function () {
$id = EvolutionCMS\Models\SiteContent::where('alias', 'worker-demo')->value('id');
evo()->sendForward($id);
});
Route::get('/__worker/shutdown', static function () {
evo_register_shutdown(static function () {
$count = (int) @file_get_contents('/tmp/evo-worker-shutdowns');
file_put_contents('/tmp/evo-worker-shutdowns', (string) ($count + 1));
});
evo_exit('shutdown queued');
});
Route::get('/__worker/transaction', static function () {
DB::beginTransaction();
DB::table('system_settings')->where('setting_name', 'worker_transaction')->update(['setting_value' => 'uncommitted']);
return 'transaction left open';
});
Route::get('/__worker/update-setting', static function () {
$value = evo()->getConfig('worker_setting') === 'updated' ? 'original' : 'updated';
DB::table('system_settings')->where('setting_name', 'worker_setting')->update(['setting_value' => $value]);
evo()->clearCache('full');
return $value;
});
Route::post('/__worker/post', static fn () => response()->json(['value' => request()->input('value')]));
Route::get('/__worker/failure', static function () {
throw new RuntimeException('worker-test-private-detail');
});
Route::get('/__worker/native-exit', static function () {
$_SESSION['worker_redirected'] = true;
evo_register_shutdown(static fn () => file_put_contents('/tmp/evo-worker-native-cleanup', 'done'));
exit('legacy response');
});
Route::get('/__worker/controller', WorkerProbeController::class);
Route::get('/__worker/model', static function () {
WorkerProbeSetting::$events = [];
WorkerProbeSetting::create(['setting_name' => 'worker_model', 'setting_value' => '1'])->delete();
return response()->json(WorkerProbeSetting::$events);
});
Loading
Loading