Skip to content

Make the class-loader cache build-time and add a loader debug page - #35

Open
darylldoyle wants to merge 10 commits into
developfrom
fix/issue-30-autoloader-cache-refresh
Open

Make the class-loader cache build-time and add a loader debug page#35
darylldoyle wants to merge 10 commits into
developfrom
fix/issue-30-autoloader-cache-refresh

Conversation

@darylldoyle

@darylldoyle darylldoyle commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

Reworks the class-loader cache so it is generated at build time and only ever read at
runtime, and adds a hidden admin page for inspecting loader caches on a running site.

Closes #30.

Screenshots

Loader debug page — admin.php?page=tenup-framework-loaders:

CleanShot 2026-06-29 at 09 58 57

⚠️ Breaking change (targets a major release)

The runtime no longer generates the cache automatically. On 1.x the first
production/staging request wrote the cache and later requests read it forever — the
stale-cache bug behind #30. Now the framework reads a pre-built cache if present and
discovers live otherwise; it never writes. Caching becomes opt-in via a build step.

Existing installs that upgrade without adding the build step keep working but run
uncached. See the new Upgrade Guide.

Removed: should_use_cache(), the production/staging gating, and VIP_GO_APP_ENVIRONMENT
handling. TENUP_FRAMEWORK_DISABLE_CLASS_CACHE now means "ignore any shipped cache and
discover live." Cache filename bumped to class-loader-cache-v2.php so 1.x caches are
ignored after upgrade.

What's included

Build-time cache

  • ReadOnlyFileDiscoverCacheDriver — read-only Spatie driver (no-op writes, no mkdir).
  • ModuleInitialization::generate_cache() plus bin/tenup-framework-generate-class-cache
    (runs without bootstrapping WordPress) and a composer generate-class-cache alias.

Loader debug page

  • Hidden, admin-only page at admin.php?page=tenup-framework-loaders (manage_options).
  • Aggregates every loader cache across all framework copies via the
    tenup_framework_debug_loaders filter (works across php-scoped / multi-version copies).
  • Per loader: a colour-coded status badge, owner, cache path/detail, legacy-file warnings,
    a collapsible list of loaded classes (with resolved files), and an on-demand
    live-vs-cache staleness check.
  • Recording and the page are gated behind is_admin(), so the front end loads none of it.
    Read-only; disable via tenup_framework_enable_loader_debug / TENUP_FRAMEWORK_DISABLE_LOADER_DEBUG.

Docs

  • New: Build-and-Deployment.md (CLI + GitHub Actions/GitLab CI/CircleCI examples + per-package
    model), Debugging.md, Upgrade-Guide.md.
  • Rewrote the cache sections of Autoloading.md and Modules-and-Initialization.md.

Testing

  • composer lint (PHPCS 10up), composer static (PHPStan level 10), and composer test
    (63 tests) all pass.
  • Coverage includes the read-only driver, generate/read paths, admin vs front-end dispatch,
    the debug registry/page, and the staleness diff.

Credits

Props @darylldoyle, @rleeson. Build-command tests, discovery/lookup timing, and the corrupt-cache runtime resilience came from #36 (@rleeson), merged into this branch; the debug page then surfaces a failed cache load rather than reporting it healthy.

Class-loader cache (breaking — targets a major release):
- Runtime is now read-only. ModuleInitialization reads a pre-built cache if
  present and discovers live otherwise; it never writes the cache, removing the
  stale-cache failure mode from #30.
- Caching is opt-in, produced at build time via a shipped
  `vendor/bin/tenup-framework-generate-class-cache` command (no WordPress
  required) and a `composer generate-class-cache` alias in this repo.
- Removed should_use_cache(), the production/staging gating, and
  VIP_GO_APP_ENVIRONMENT handling. TENUP_FRAMEWORK_DISABLE_CLASS_CACHE now forces
  live discovery. Cache filename bumped to class-loader-cache-v2.php so caches
  written by 1.x are ignored after upgrade rather than served stale.

Loader debug page:
- Hidden, admin-only page (admin.php?page=tenup-framework-loaders, manage_options)
  aggregating every loader cache across all framework copies via the
  tenup_framework_debug_loaders filter, with an on-demand live-vs-cache staleness
  check. Recording and the page are gated behind is_admin() so the front end pays
  nothing. Read-only; disable via the tenup_framework_enable_loader_debug filter
  or the TENUP_FRAMEWORK_DISABLE_LOADER_DEBUG constant.

Docs and tests:
- New docs: Build-and-Deployment, Debugging, Upgrade-Guide. Rewrote the cache
  sections of Autoloading and Modules-and-Initialization.
- Tests cover the read-only driver, generate/read paths, admin vs front-end
  dispatch, the debug registry/page, and the staleness diff. phpcs, phpstan
  (level 10) and phpunit all green.

Refs #30
- Card per loader with a colour-coded status badge (in use / uncached /
  disabled / present-but-unused) and an explanatory notice.
- Move the loaded class list into a collapsible <details> accordion so a long
  list no longer makes the page huge.
- Surface uncached as a noticeable amber state (valid default, not an error);
  reserve red for genuine problems (present-but-unused, stale, legacy files).
- Self-contained scoped styles using the WP admin palette.
@darylldoyle
darylldoyle marked this pull request as ready for review June 29, 2026 08:59
@darylldoyle darylldoyle added this to the 2.0.0 milestone Jun 29, 2026
@darylldoyle darylldoyle self-assigned this Jun 29, 2026
@claytoncollie

claytoncollie commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

@darylldoyle This is very nice and thorough. I read through all of the information and want to make sure that the generate cache script can run at the project root and build individual caches for each of the themes and plugins using wp-framework. Or do we have to run the script multiple times if the theme and a plugin both use WPF?

rleeson and others added 5 commits July 7, 2026 12:20
Testing:
- Add tests/Bin/GenerateClassCacheTest: shells out to the actual
  tenup-framework-generate-class-cache script (as CI would) and covers the
  generate path, no-args usage/exit-1, a missing directory failing while valid
  ones still cache, and multi-directory runs. Backed by small, salient example
  loader directories under tests/examples/ (a real ModuleInterface module, a
  plain support class, and a second directory).
- Cover the previously untested LoaderDebug branches: legacy_files() detection,
  every cache_state() variant, and the staleness "up to date" path.
- Replace the vacuous assertGreaterThanOrEqual(0, ...) in
  test_it_can_find_classes_to_register with an assertion that the registered set
  is non-empty and contains only ModuleInterface implementations.

Loader timing:
- ModuleInitialization::init_classes() now times discovery (cache read or live
  scan) and class lookup (reflection/instantiation/registration) separately and
  records both on the loader debug record.
- The debug page shows both timings per loader, and the staleness check reports
  how long its live discovery ran — so the cache's saving on a given site is
  measurable. New format_duration() helper picks a sensible unit.

phpcs, phpstan (level 10) and phpunit all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- format_duration(): reject non-finite input (INF/NAN) alongside non-positive,
  so a misbehaving contributor to the tenup_framework_debug_loaders filter can
  no longer render "inf s" / "nan s". Makes the "untrusted mixed input" contract
  in the docblock actually hold. Cover with negative, NAN and INF data cases.
- FrameworkTestSetup: reset the ModuleInitialization singleton in setUp(). The
  trait-level @runTestsInSeparateProcesses annotation does not take effect
  (PHPUnit ignores it on a used trait; confirmed by suite wall-time and the
  author's explicit method-level @runInSeparateProcess on the two define()
  tests), so the singleton's accumulated $classes previously leaked between
  tests. Prevents order-dependent flakiness as more tests are added.

phpcs, phpstan (level 10) and phpunit all green (57 tests / 148 assertions).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…repo & opcache

Addresses a Fable re-review of the build-time-cache PR plus the timing addition,
targeting mono-repo flexibility, genuine live-timing measurement, and security/perf.

Resilience (security/perf):
- get_classes() now catches a corrupt/truncated shipped cache (the cache is
  executable PHP loaded via `require`) and falls back to a live discovery instead
  of fataling every request until redeploy. Same spirit as #30: a bad cache must
  never take the site down. Covered by a new test.
- record_loader_debug() bails on wp_doing_ajax(): is_admin() is also true on
  admin-ajax.php, and the debug page re-records on its own GET, so ajax recording
  was pure waste (often front-end triggered).

Timing correctness:
- Switch discovery/lookup timing from microtime(true) to the monotonic
  hrtime(true), so an NTP adjustment mid-request cannot skew a delta.
- Rewrite the timing tests that were vacuous (assertGreaterThanOrEqual(0.0) passed
  the never-wired 0.0 default; the staleness string also matched the "took —."
  failure rendering). Now: the no-cache case asserts cache_used===false and
  strictly-positive live discovery time; a new cached case asserts cache_used===true
  and positive cache-read time; the staleness checks assert a real duration via
  regex.

Mono-repo dedupe:
- LoaderDebug::record() keeps one record per directory, so a repeated init_classes()
  for the same directory refreshes rather than duplicating a card.

Docs:
- Build-and-Deployment: opcache in-place-deploy staleness caveat, corrupt-cache
  fallback behaviour, and mixed-framework-version generation guidance for mono-repos.
- Debugging: known limitations (per-request visibility, oldest-UI renders on mixed
  versions). CHANGELOG: fallback note; "identifier" -> "filename" wording fix.

phpcs, phpstan (level 10) and phpunit all green (59 tests / 153 assertions).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cache detail now reads "Built <age> ago · <size> · <YYYY-MM-DD HH:MM:SS UTC>",
using gmdate() so the build timestamp is unambiguous regardless of site or server
timezone. Covered by a cache_detail() test asserting the size and trailing UTC
segment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The class table's long file paths pushed it past the card's right border. The card
now sizes to its content (min 60em, capped at the admin content width) so it widens
when a class list is expanded, and long paths in the meta/class tables wrap
(overflow-wrap: anywhere) so nothing spills once the width cap is reached.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@rleeson

rleeson commented Jul 10, 2026

Copy link
Copy Markdown

@darylldoyle The Debug page is a great addition. I validated the changes are working on a project where the framework is used in 5 separate instances, incrementally turning them on and verifying functionality. There were a few improvements to testing and the debug display which I worked into a PR (#36) branched from this PR.

@rleeson rleeson left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@darylldoyle I've submitted a PR (#36) with some additions I think improve both test coverage and debug capabilities.

The adjustments I would consider if those changes are not used, I'll mark inline.

Comment thread src/ModuleInitialization.php Outdated
Comment thread src/Debug/LoaderDebug.php Outdated
Loader cache: build-command tests, discovery timing, and runtime resilience
Ryan's #36 made a corrupt or truncated cache fall back to a live scan instead
of fataling, but the loader record still derived cache_used from file existence
alone — so the debug page badged a failed cache green "Cache in use", the
staleness check read "up to date", and nothing was logged. The one screen meant
to diagnose cache health hid the failure the fallback recovers from.

- get_classes() records whether it fell back (a corrupt read) and fires a
  `tenup_framework_cache_load_failed` action so projects can log or alert. The
  array_filter now sits inside the try, so a cache that parses but returns a
  non-array also falls back rather than fataling on the filter.
- The loader record carries `cache_failed`; cache_used is now false when the
  read failed. LoaderDebug badges that loader red, "Cache failed to load —
  running live".
- Staleness timing switched from wall-clock microtime() to monotonic hrtime(),
  matching the request path.
- Tests: corrupt cache falls back without fataling; the failed state is recorded
  and rendered. Docs + changelog note the action hook and the red state.

Refs #30
@darylldoyle

Copy link
Copy Markdown
Collaborator Author

Noting that if a cache file is corrupted, it will now show like this:

CleanShot 2026-07-30 at 16 08 09

@darylldoyle
darylldoyle requested a review from rleeson July 30, 2026 15:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Autoloader cache not refreshing

3 participants