diff --git a/CHANGELOG.md b/CHANGELOG.md index d653acfd..0ac2d8a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Service-health pings are now cached across requests.** `HealthService::statusFor()`'s 10 s memo lived in a per-object array, which the classic (non-worker) FrankenPHP throws away with each request — so every topbar/dashboard health poll, from every open tab, re-pinged all configured services with sequential blocking calls. The verdicts are now shared through `cache.app` (same 10 s TTL): the whole install performs at most one probe sweep per window, and `invalidate()` (admin "Test connection" / settings save) rotates a generation token so stale verdicts can't outlive a reconfiguration. - **Static assets get real browser-cache headers.** Caddy now serves AssetMapper-compiled `/assets/*` (content-hashed filenames) with `Cache-Control: public, max-age=31536000, immutable`, and the unfingerprinted `/static/*` vendor bundles (Tabler, Chart.js) plus `/img/*` with a one-day TTL — repeat page loads stop re-negotiating ~600 KB of CSS/JS. - **Prod cache pre-warmed at image build.** The Dockerfile runs `cache:warmup` after `asset-map:compile`, so the first request after a container (re)start no longer pays the 1–3 s container/route/Twig compile. Env vars stay runtime-resolved placeholders, so the boot-generated `APP_SECRET` doesn't invalidate the baked cache. +- **Opt-in FrankenPHP/Symfony worker mode (`PRISMARR_WORKER`).** Setting `PRISMARR_WORKER=1` boots the Symfony kernel once and keeps it resident, so requests skip the per-request kernel bootstrap (the single biggest remaining per-request cost). It is **OFF by default** — unset, the container runs exactly as before in classic single-request mode. `PRISMARR_WORKER_NUM` optionally pins the worker thread count (default: FrankenPHP's 2× CPU). No image rebuild is needed to toggle it. Symfony's default runtime natively drives FrankenPHP's worker loop, so no `APP_RUNTIME` or extra Composer package is involved. Correctness across the shared-kernel boundary relies on request-scoped services implementing `ResetInterface` (Symfony's `services_resetter` clears their per-request state between requests); this change adds it to `HealthService` (in-process health memo + shared-pool generation token) and `DashboardController` (per-request movie/series library memo), which also removes a latent staleness edge in classic mode. - **Faster Radarr / Sonarr library pages.** The heavy `getMovies()` / `getSeries()` payload is now cached per instance for 45 s (`MediaLibraryCache`) instead of being re-fetched and re-normalised on every visit, and the per-page status / queue / indexers / health / calendar calls run in a single `curl_multi` batch (`multiGet()`) rather than sequentially. Cold loads are unchanged, but revisits within the window are roughly 3× faster, and a slow or unreachable instance now costs one timeout window for the whole page instead of stacking one timeout per call. Empty results are not cached and library mutations invalidate the entry, so user changes still show immediately. Same per-handle semantics as the existing `get()` (SSRF protocol guard, connect/total timeouts, per-instance circuit breaker). ### Internal diff --git a/README.md b/README.md index 2c41a1eb..e1f89de3 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,8 @@ rotate or back them up manually. | `TZ` | `UTC` | Container time zone (e.g. `Europe/Paris`, `Pacific/Honolulu`). Drives both the OS clock and the PHP date helpers — see issue [#12](https://github.com/Shoshuo/Prismarr/issues/12) | | `PHP_MEMORY_LIMIT` | `1024M` | PHP memory ceiling per request. Bump (e.g. `2048M`, `-1` for unlimited) if you have a very large Radarr / Sonarr library — see issue [#13](https://github.com/Shoshuo/Prismarr/issues/13) | | `PHP_MAX_EXECUTION_TIME` | `120` | PHP wall-time ceiling per request, in seconds. Bump alongside `PHP_MEMORY_LIMIT` if the films / series page times out | +| `PRISMARR_WORKER` | _(unset)_ | Enables FrankenPHP/Symfony **worker mode** — the kernel is booted once and kept resident, skipping the per-request bootstrap for faster responses. Set to `1` (or `true`) for the default worker count, or to a number (e.g. `4`) to run that many workers. Off by default / `0` / `false` = classic per-request mode; no image rebuild needed to toggle | +| `PRISMARR_WORKER_NUM` | _(2× CPU)_ | Optional explicit worker-thread count; overrides the count when `PRISMARR_WORKER` is enabled. Leave unset to use FrankenPHP's default (2× the available CPUs) | ### Persistent data diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 11cd082b..3d14f08a 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -45,6 +45,14 @@ services: # itself. Setting it drops X-Frame-Options. (Issue #25.) # PRISMARR_FRAME_ANCESTORS: https://organizr.example.com + # FrankenPHP/Symfony worker mode (opt-in, OFF by default). Set to 1 to + # keep the Symfony kernel resident across requests (skips the per-request + # bootstrap for faster responses). Toggling needs no image rebuild. Leave + # unset for the classic per-request mode. + # PRISMARR_WORKER: 1 + # Optional worker/thread count when PRISMARR_WORKER=1 (default: 2x CPU). + # PRISMARR_WORKER_NUM: 4 + # Persistent volume — contains SQLite DB, .env.local, sessions, cache. volumes: - prismarr_data:/var/www/html/var/data diff --git a/docker/frankenphp/Caddyfile b/docker/frankenphp/Caddyfile index 71f2b55e..ba831763 100644 --- a/docker/frankenphp/Caddyfile +++ b/docker/frankenphp/Caddyfile @@ -1,5 +1,17 @@ { - frankenphp + # Worker mode is opt-in and OFF by default. The s6 run script exports + # PRISMARR_WORKER_DIRECTIVE=`worker /var/www/html/public/index.php [num]` + # only when PRISMARR_WORKER=1; otherwise this placeholder expands to + # nothing and the block is empty — i.e. classic single-request mode, + # byte-for-byte the old `frankenphp` behaviour. In worker mode the global + # `worker` directive keeps the Symfony kernel resident and `php_server` + # below automatically routes matching requests through it. Symfony 8's + # default runtime (symfony/runtime FrankenPhpWorkerRunner) enters the + # worker loop when FrankenPHP sets FRANKENPHP_WORKER, so no APP_RUNTIME or + # extra composer package is required. + frankenphp { + {$PRISMARR_WORKER_DIRECTIVE} + } order mercure after encode # No HTTPS (local or behind a reverse proxy) diff --git a/docker/frankenphp/s6/frankenphp/run b/docker/frankenphp/s6/frankenphp/run index dc724ae7..2b1cd8e1 100644 --- a/docker/frankenphp/s6/frankenphp/run +++ b/docker/frankenphp/s6/frankenphp/run @@ -9,5 +9,65 @@ fi # Caddy needs a writable HOME for its internal data store (XDG paths). # var/caddy is created and chowned by init.sh. export HOME=/var/www/html/var/caddy + +# ── FrankenPHP worker mode (opt-in, default OFF) ──────────────────────── +# Worker mode boots the Symfony kernel once and keeps it resident, so requests +# skip the per-request kernel bootstrap. Controlled by PRISMARR_WORKER: +# unset / "" / 0 / false / no / off → OFF (classic per-request mode) +# 1 (or true / yes / on) → ON, FrankenPHP's default count (2x CPU) +# a positive integer N (e.g. 4) → ON, with N resident workers +# anything else → OFF + a warning (fail closed: worker +# mode changes request-isolation +# semantics, so it must never be +# enabled by a typo) +# PRISMARR_WORKER_NUM, if set to a positive integer, overrides the count; any +# other value is ignored with a warning — it is interpolated into the +# Caddyfile, so an unvalidated value would fail Caddy's parse and take the +# whole container down. The directive is injected into the global +# `frankenphp { }` block via the {$PRISMARR_WORKER_DIRECTIVE} placeholder; an +# unset/OFF value leaves it empty — byte-for-byte the classic path. Symfony +# 8's default runtime auto-detects FrankenPHP's worker loop (FRANKENPHP_WORKER) +# — no APP_RUNTIME / extra package needed. +_pw_on="" +_pw_count="" +case "${PRISMARR_WORKER:-}" in + ''|0|false|False|FALSE|no|No|NO|off|Off|OFF) + : ;; # classic — leave the directive empty + 1|true|True|TRUE|yes|Yes|YES|on|On|ON) + _pw_on=1 ;; + *[!0-9]*) + # Unrecognized non-integer → fail CLOSED (classic mode), say so. + echo "[prismarr] WARNING: unrecognized PRISMARR_WORKER='${PRISMARR_WORKER}' — worker mode stays OFF (use 1/true/on or a worker count)" ;; + *) + # Pure integer (>1 → also the worker count; 0 was handled above). + _pw_on=1 + [ "${PRISMARR_WORKER}" -gt 1 ] && _pw_count="${PRISMARR_WORKER}" ;; +esac +if [ -n "${_pw_on}" ]; then + # Optional count override — accepted only as a pure positive integer, since + # this value lands verbatim inside the Caddyfile. + case "${PRISMARR_WORKER_NUM:-}" in + '') : ;; + 0|*[!0-9]*) + echo "[prismarr] WARNING: ignoring invalid PRISMARR_WORKER_NUM='${PRISMARR_WORKER_NUM}' (must be a positive integer)" ;; + *) + _pw_count="${PRISMARR_WORKER_NUM}" ;; + esac + if [ -n "${_pw_count}" ]; then + export PRISMARR_WORKER_DIRECTIVE="worker /var/www/html/public/index.php ${_pw_count}" + else + export PRISMARR_WORKER_DIRECTIVE="worker /var/www/html/public/index.php" + fi +fi + +# Log the decision so worker status is visible in the container log (the +# FrankenPHP process runs as www-data / non-dumpable, so its env can't be +# inspected from outside, and FrankenPHP doesn't log worker startup at info). +if [ -n "${PRISMARR_WORKER_DIRECTIVE:-}" ]; then + echo "[prismarr] FrankenPHP worker mode ENABLED — ${PRISMARR_WORKER_DIRECTIVE}" +else + echo "[prismarr] FrankenPHP worker mode disabled (classic per-request; set PRISMARR_WORKER=1)" +fi + # Drop privileges: FrankenPHP and all PHP scripts run as www-data (UID 82). exec s6-setuidgid www-data frankenphp run --config /etc/caddy/Caddyfile diff --git a/symfony/src/Controller/DashboardController.php b/symfony/src/Controller/DashboardController.php index bd32e44f..1f350bd0 100644 --- a/symfony/src/Controller/DashboardController.php +++ b/symfony/src/Controller/DashboardController.php @@ -17,6 +17,7 @@ use Symfony\Component\Routing\Attribute\Route; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\ItemInterface; +use Symfony\Contracts\Service\ResetInterface; use Symfony\Contracts\Translation\TranslatorInterface; /** @@ -27,7 +28,7 @@ * whole page. Session 9c will wire the UI preferences (timezone, date * format, density…) into this template. */ -class DashboardController extends AbstractController +class DashboardController extends AbstractController implements ResetInterface { private const UPCOMING_DAYS = 7; private const MAX_REQUESTS = 5; @@ -66,6 +67,21 @@ public function __construct( private readonly \App\Service\DashboardLayoutService $layout, ) {} + /** + * FrankenPHP worker mode — controllers autowired as services are shared + * singletons that survive across requests in a worker. Without this, the + * per-request `$moviesCache` / `$seriesCache` memo (primed by the first + * dashboard paint) would be served to every later request in the same + * worker, showing stale library data until the worker recycled. Symfony's + * services_resetter calls reset() between requests (auto-tagged + * kernel.reset via the ResetInterface autoconfiguration). + */ + public function reset(): void + { + $this->moviesCache = null; + $this->seriesCache = null; + } + /** * #30 — wrap an expensive upstream aggregate in a short shared cache. * An empty result (every instance failed / nothing configured) is NOT diff --git a/symfony/src/Service/HealthService.php b/symfony/src/Service/HealthService.php index 70692394..68151eb7 100644 --- a/symfony/src/Service/HealthService.php +++ b/symfony/src/Service/HealthService.php @@ -17,6 +17,7 @@ use App\Service\Media\Usenet\SabnzbdClient; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\ItemInterface; +use Symfony\Contracts\Service\ResetInterface; /** * Tests third-party service availability. @@ -30,7 +31,7 @@ * the admin "Test connection" button can return an actionable hint * without leaking internal stack traces. */ -class HealthService +class HealthService implements ResetInterface { private const CACHE_TTL = 10; @@ -354,6 +355,20 @@ public function invalidate(?string $service = null): void } } + /** + * FrankenPHP worker mode — Symfony's services_resetter calls reset() + * between requests (this service is auto-tagged kernel.reset via the + * ResetInterface autoconfiguration). Drop the per-request in-process + * isHealthy() memo AND the memoized shared-pool generation token so one + * request's health verdicts can't bleed into the next; this also removes + * a latent staleness edge in classic mode. + */ + public function reset(): void + { + $this->statusCache = []; + $this->generation = null; + } + /** * Probe a service directly and return a categorized diagnosis the admin * UI can show. Returns ['ok' => bool, 'category' => string, 'http' => ?int]. diff --git a/symfony/tests/Service/WorkerModeResetTest.php b/symfony/tests/Service/WorkerModeResetTest.php new file mode 100644 index 00000000..d815ebbc --- /dev/null +++ b/symfony/tests/Service/WorkerModeResetTest.php @@ -0,0 +1,112 @@ + + */ + public static function resettableProvider(): array + { + return [ + [HealthService::class], + [DashboardController::class], + // Highest-severity path (audit): clears the per-request Radarr + // instance binding so a non-default instance can't leak to the + // next request. Already covered behaviourally by ClientResetTest; + // re-asserted here as part of the worker-mode contract set. + [RadarrClient::class], + ]; + } + + /** + * @param class-string $class + */ + #[DataProvider('resettableProvider')] + public function testServiceImplementsResetInterface(string $class): void + { + $this->assertTrue( + is_subclass_of($class, ResetInterface::class), + "$class must implement ResetInterface so Symfony auto-tags it kernel.reset and clears its per-request state between worker requests" + ); + } + + public function testHealthServiceResetClearsInProcessMemo(): void + { + // reset() only touches one private property; skip the heavy + // constructor (many collaborators) via newInstanceWithoutConstructor. + $ref = new \ReflectionClass(HealthService::class); + $service = $ref->newInstanceWithoutConstructor(); + + $statusCache = $ref->getProperty('statusCache'); + $statusCache->setAccessible(true); + $generation = $ref->getProperty('generation'); + $generation->setAccessible(true); + + // Simulate a request having populated the 10 s in-process memo and + // memoized the shared-pool generation token. + $statusCache->setValue($service, [ + 'radarr:radarr-4k' => ['result' => ['status' => 'up', 'latencyMs' => 12], 'at' => time()], + ]); + $generation->setValue($service, 'deadbeef'); + + $this->assertNotSame([], $statusCache->getValue($service)); + $this->assertNotNull($generation->getValue($service)); + + $service->reset(); + + $this->assertSame([], $statusCache->getValue($service), 'statusCache must be emptied on reset'); + $this->assertNull($generation->getValue($service), 'generation memo must be nulled on reset so a recycled worker re-reads the pool token'); + } + + public function testDashboardControllerResetNullsLibraryMemos(): void + { + $ref = new \ReflectionClass(DashboardController::class); + $controller = $ref->newInstanceWithoutConstructor(); + + $movies = $ref->getProperty('moviesCache'); + $movies->setAccessible(true); + $series = $ref->getProperty('seriesCache'); + $series->setAccessible(true); + + // Prime the per-request memo as the first dashboard paint would. + $movies->setValue($controller, [['id' => 1, 'title' => 'Dune']]); + $series->setValue($controller, [['id' => 2, 'title' => 'Severance']]); + + $this->assertNotNull($movies->getValue($controller)); + $this->assertNotNull($series->getValue($controller)); + + $controller->reset(); + + $this->assertNull($movies->getValue($controller), 'moviesCache must be nulled on reset'); + $this->assertNull($series->getValue($controller), 'seriesCache must be nulled on reset'); + } +}