diff --git a/CHANGELOG.md b/CHANGELOG.md index 127735c..3772021 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,12 @@ All notable changes to this project will be documented in this file, per [the Ke ### Added - Build-time class-cache generation: a `tenup-framework-generate-class-cache` command (installed to `vendor/bin/`) and a `composer generate-class-cache` alias that build the cache in CI without bootstrapping WordPress. See [Build and Deployment](docs/Build-and-Deployment.md) ([#30](https://github.com/10up/wp-framework/issues/30)). - Hidden admin page (`admin.php?page=tenup-framework-loaders`, `manage_options`) that aggregates every class-loader cache on the site — across all framework copies — and shows each cache's path, status, loaded classes, and an on-demand live-vs-cache staleness check. Admin-only (no front-end overhead) and read-only. Disable with the `tenup_framework_enable_loader_debug` filter or the `TENUP_FRAMEWORK_DISABLE_LOADER_DEBUG` constant. See [Debugging class loaders](docs/Debugging.md). +- The loader debug page reports per-loader timing: how long class **discovery** took (a cache read when cached, a live filesystem scan otherwise) and how long **class lookup** (reflection, instantiation and registration) took. The staleness check also reports how long its live discovery ran, so the cache's saving on a given site is measurable. ### Changed - The class-loader cache is now **read-only at runtime** and opt-in. The framework reads a pre-built cache if present and discovers live otherwise, but never writes one on the server — fixing stale caches that could only be cleared by hand ([#30](https://github.com/10up/wp-framework/issues/30)). -- Bumped the cache identifier so a cache written by an older version is ignored after upgrade rather than served stale. +- A corrupt or truncated shipped cache is caught at runtime and the request falls back to a live scan instead of fataling, so a bad cache degrades performance rather than taking the site down. +- Bumped the cache filename so a cache written by an older version is ignored after upgrade rather than served stale. - `TENUP_FRAMEWORK_DISABLE_CLASS_CACHE` now forces live discovery (ignores any shipped cache). ### Removed diff --git a/docs/Build-and-Deployment.md b/docs/Build-and-Deployment.md index 2f6c3e3..ef0bd6a 100644 --- a/docs/Build-and-Deployment.md +++ b/docs/Build-and-Deployment.md @@ -158,7 +158,19 @@ workflows: If the build can't run the generate step for some reason, the deploy still works — it just runs uncached. A broken cache after a build means the build is the thing to fix, not the -server. +server. A cache file that is corrupt or truncated (a half-finished rsync, an interrupted +build) is caught at runtime and the request falls back to a live scan, so a bad cache slows +the site rather than taking it down. + +### Opcache and in-place deploys + +The cache is a PHP file loaded with `require`, so PHP's opcache caches it like any other +source file. On hosts with `opcache.validate_timestamps=0` (common on the managed hosts where +issue #30 was reported), overwriting `class-loader-cache-v2.php` **in place** keeps serving the +previously compiled array until opcache is reset — which would reintroduce the very staleness +this design removes. Either deploy to a fresh path (atomic symlink swap, the default on most +zero-downtime deployers) or reset opcache as part of the deploy. The loader debug page's +staleness check live-scans and will flag this if it happens. ## The per-package model @@ -184,6 +196,14 @@ vendor/bin/tenup-framework-generate-class-cache \ wp-content/plugins/bar/inc ``` +One caveat for a mono-repo where packages pin **different** framework versions: the single +invocation above uses one package's `vendor/bin` copy to write every directory's cache. That +copy determines the cache filename and the Spatie discoverer version used. Today the payload is +a plain array of class-name strings and the filename is identical across versions, so this is +safe — but a future cache-format or filename bump would silently mismatch. When packages are on +different framework versions, run **each package's own** `vendor/bin/tenup-framework-generate-class-cache` +against its own directory so the writer and the reader are always the same version. + ## See also - [Docs Home](README.md) - [Autoloading and Modules](Autoloading.md) diff --git a/docs/Debugging.md b/docs/Debugging.md index bf219e4..0f29a1c 100644 --- a/docs/Debugging.md +++ b/docs/Debugging.md @@ -33,6 +33,11 @@ page aggregates every loader recorded across all of them — even copies that ar one (usually leftovers from an older framework version). - **Classes loaded** — every class the loader resolved, with the file each one lives in. A class that no longer resolves is flagged as a likely stale entry. +- **Discovery time** — how long this request spent obtaining the class list. With a cache present + this is the cost of reading it; uncached it is the cost of a live filesystem scan, so the two + states can be compared directly. +- **Class lookup time** — how long reflecting, instantiating and registering the discovered + classes took. ## Staleness check @@ -42,7 +47,9 @@ directory and diffs the result against what the cache loaded, listing: - classes **on disk but missing from the cache** (the cache is behind), and - classes **in the cache but no longer on disk** (renamed/removed). -The check runs only when clicked, so the page itself stays cheap. If it reports drift, the cache +It also reports **how long the live discovery took**, which — compared against the cached +**Discovery time** above — shows what the cache is actually saving on this site. The check runs +only when clicked, so the page itself stays cheap. If it reports drift, the cache is stale: regenerate it in your build (`composer generate-class-cache`) or remove the file and redeploy. The page is **read-only** — it never deletes or rewrites a cache, consistent with the read-only runtime. @@ -52,6 +59,15 @@ read-only runtime. The recording and the page are **admin-only**. On front-end requests nothing is recorded, no hooks are added, and the debug class is never even loaded. +## Known limitations + +- **Per request** — the page shows loaders recorded on the current admin request. A plugin whose + `init_classes()` did not run on this request will not appear. +- **Mixed framework versions render with the oldest UI** — when a mono-repo runs several framework + copies on different versions, the first copy to record a loader registers and renders the page, + so newer per-loader fields degrade to blank rather than showing. Aligning framework versions + across packages avoids this; the data itself is still aggregated correctly across all copies. + ## Disabling it Enabled by default in the admin. Turn it off with either: diff --git a/src/Debug/LoaderDebug.php b/src/Debug/LoaderDebug.php index 1edaa02..20bb500 100644 --- a/src/Debug/LoaderDebug.php +++ b/src/Debug/LoaderDebug.php @@ -88,6 +88,18 @@ public static function record( array $record ) { return; } + // Keep one record per directory: if init_classes() runs more than once for the same + // directory in a request, the latest call (with fresh timing) replaces the earlier one + // rather than producing a duplicate card. + $directory = isset( $record['directory'] ) && is_string( $record['directory'] ) ? $record['directory'] : ''; + foreach ( self::$loaders as $index => $existing ) { + if ( ( $existing['directory'] ?? null ) === $directory ) { + self::$loaders[ $index ] = $record; + self::boot(); + return; + } + } + self::$loaders[] = $record; self::boot(); @@ -253,6 +265,8 @@ protected static function render_loader( array $loader, string $check ) { self::render_row( __( 'Framework version', 'tenup-framework' ), self::version_label( $loader ) ); self::render_row( __( 'Cache file', 'tenup-framework' ), '' !== $cache_file ? $cache_file : '—' ); self::render_row( __( 'Cache detail', 'tenup-framework' ), self::cache_detail( $loader ) ); + self::render_row( __( 'Discovery time', 'tenup-framework' ), self::format_duration( $loader['discovery_seconds'] ?? null ) ); + self::render_row( __( 'Class lookup time', 'tenup-framework' ), self::format_duration( $loader['lookup_seconds'] ?? null ) ); echo ''; echo '
'; @@ -340,18 +354,27 @@ protected static function render_staleness( string $directory, array $classes, s return; } - $live = ModuleInitialization::instance()->discover_live( $directory ); + $live_start = microtime( true ); + $live = ModuleInitialization::instance()->discover_live( $directory ); + $live_seconds = microtime( true ) - $live_start; + $loaded = array_values( $classes ); $removed = array_diff( $loaded, $live ); // In cache but no longer on disk. $added = array_diff( $live, $loaded ); // On disk but missing from the cache. + $timing = sprintf( + /* translators: %s: formatted duration. */ + __( 'Live discovery took %s.', 'tenup-framework' ), + self::format_duration( $live_seconds ) + ); + if ( empty( $removed ) && empty( $added ) ) { - echo '
' . esc_html__( 'Up to date — the cache matches a live scan.', 'tenup-framework' ) . '
'; + echo '
' . esc_html__( 'Up to date — the cache matches a live scan.', 'tenup-framework' ) . ' ' . esc_html( $timing ) . '
'; return; } echo '
'; - echo '' . esc_html__( 'Stale — the cache differs from a live scan.', 'tenup-framework' ) . ''; + echo '' . esc_html__( 'Stale — the cache differs from a live scan.', 'tenup-framework' ) . ' ' . esc_html( $timing ); if ( ! empty( $added ) ) { echo '

' . esc_html__( 'On disk but missing from the cache:', 'tenup-framework' ) . '