Skip to content
Open
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions docker-compose.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion docker/frankenphp/Caddyfile
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
60 changes: 60 additions & 0 deletions docker/frankenphp/s6/frankenphp/run
Original file line number Diff line number Diff line change
Expand Up @@ -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
18 changes: 17 additions & 1 deletion symfony/src/Controller/DashboardController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down
17 changes: 16 additions & 1 deletion symfony/src/Service/HealthService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;

Expand Down Expand Up @@ -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].
Expand Down
112 changes: 112 additions & 0 deletions symfony/tests/Service/WorkerModeResetTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<?php

namespace App\Tests\Service;

use App\Controller\DashboardController;
use App\Service\HealthService;
use App\Service\Media\RadarrClient;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Contracts\Service\ResetInterface;

/**
* FrankenPHP worker-mode regression suite for the two services the readiness
* audit flagged as missing ResetInterface (HealthService, DashboardController)
* plus a re-assertion of the highest-severity reset (RadarrClient's per-request
* instance binding).
*
* In worker mode Symfony boots the kernel once and keeps every service alive
* across requests; Symfony's services_resetter calls reset() on each
* kernel.reset-tagged service between requests. Any service implementing
* ResetInterface is auto-tagged, so implementing it here is what wires the
* reset in. These tests assert (a) the contract (implements ResetInterface, so
* the container tags it) and (b) that reset() actually clears the documented
* per-request state — without which a first request's data would bleed into
* every later request served by the same worker.
*
* NOTE: the end-to-end firing of the container's services_resetter under a live
* FrankenPHP worker is not exercised here (a WebTestCase reboots the kernel per
* test, so it cannot observe cross-request bleed). That must be confirmed by
* the manual beta soak with PRISMARR_WORKER=1.
*/
class WorkerModeResetTest extends TestCase
{
/**
* @return list<array{0: class-string}>
*/
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');
}
}