⚡ 2026-08-25 settings memoization — bench performance fix

Historical record — 2026-08-25

Preserved as observed. Its figures were re-measured and confirmed on 2026-08-27 by Cluster 4; the canonical memoization is still pending. Current truth: Program Masterplan.

Scope: WRDP bench, 2026-08-25. Measured in-container or by curl -H 'Host: wrdp.loca.zone' http://127.0.0.1:51080…. Citations are against the installed source under wp/wp-content/plugins/quizwizz/.

Method: matched A/B in one sitting. Revert to pristine bytes (sha256-verified against copies taken before any edit), warm with three discarded requests, take the full set, reinstall the memoized files, verify byte-identical, retake the same set. Identical method, warm-up and host conditions in both halves.

Symptom

Most PHP requests cost 1.4–1.7s TTFB; / and /?page_id=2 took 7.6s; /?page_id=1289 took 4.7s.

Root cause

  • SettingsStore::get() ran SettingsNormalizer::sanitize( merge_defaults( $stored ) ), a full schema deep-merge plus sanitize, on every call with no memoization: 6.51ms per call (1000 calls = 6,513.27ms).
  • UiLanguage::register() hooks gettext, ngettext, gettext_with_context, ngettext_with_context (UiLanguage.php:31-34), routing every quizwizz-domain string through choice() into SettingsStore::get().
  • Net: one __( 'Quiz', 'quizwizz' ) cost 13.37ms (1000 calls = 13,373.63ms).
  • Boot-time label emission therefore dominated init: 1919.9ms of a 3811.2ms wp-load.

Infrastructure was not the constraint, re-measured on the same bench:

  • database connect 8.8ms, trivial query 1.58ms
  • 103 autoloaded options totalling 42.5KB; 17 scheduled cron events
  • container CPU steal 0 jiffies of 20,382,742
  • opcache state not established: opcache_get_status() returns false under the CLI SAPI used for these probes, so no web-SAPI figure is claimed
  • decisive evidence: memoization alone moved init from 1919.9ms to 51.0ms with no change to the database, host, option table, opcache or any PHP setting

Fix (bench-local, three files)

  • includes/Settings/SettingsStore.php$memo keyed by blog id (:8-9), flush_memo() (:11-13), maybe_flush( $option ) (:25-29), register_hooks() (:15-23). get() (:31-46) computes the projection lazily once per request per blog. save_if_revision() flushes (:105) immediately after its three wp_cache_delete calls (:102-104).
  • includes/UiLanguage.php$choice_memo (:15-16), flush_choice() (:18-20), maybe_flush_choice( $option ) (:22-26); choice() (:52-60) memoized. Filter bodies, load(), mofile() and script-translation logic untouched.
  • includes/Bootstrap/CoreProvider.php\QuizWizz\Settings\SettingsStore::register_hooks(); (:10) directly after Installer::register_hooks() (:9).

Trap

The first attempt registered invalidation on added_option_qw_exposure_settings, updated_option_qw_exposure_settings and deleted_option_qw_exposure_settings. WordPress core never fires those. Core fires add_option_{$option} and update_option_{$option} (present tense) plus the generic added_option, updated_option and deleted_option, each with the option name as first argument. The past-tense specific names exist in this codebase only because save_if_revision() emits them manually (SettingsStore.php:119-122).

Consequence, proven in-container: after update_option( 'qw_exposure_settings', … ) the stored iface_lang was de while get() still returned en — a silently stale cache that passed lint, activation and all 46 probe assertions. Corrected wiring uses the generic actions with an option-name guard (SettingsStore.php:19-29, UiLanguage.php:40-43), plus switch_blog for the unguarded flush.

Verification

Micro, in-container:

measurementpristinememoized
__( 'Quiz', 'quizwizz' ) x100013,373.63ms2.67ms
SettingsStore::get() x10006,513.27ms0.16ms

Boot phases, same harness in both halves:

phasepristinememoized
plugins_loaded286.1ms24.5ms
init1919.9ms51.0ms
total wp-load3811.2ms359.9ms

2026-08-27 re-verification

A Cluster 3 spot-check on 2026-08-26 reported that the translated-string microbench and the wp-load headline no longer reproduced, at 3.499985ms/1000 and 539.700ms/4q. Those were single runs taken while other measurement slices were hitting the same MariaDB container. Re-measured quiescent, medians of three, nothing else touching the database:

measurementpublished 2026-08-25quiescent median 2026-08-27verdict
__( 'Quiz', 'quizwizz' ) x10002.67ms2.612114ms (2.479 / 2.612 / 2.998)reproduces
SettingsStore::get() x10000.16ms0.178099ms (0.161 / 0.178 / 0.223)reproduces
total wp-load359.9ms367.375ms / 4q (351.3 / 367.4 / 432.0)reproduces

All three figures stand. The 2026-08-26 spot-check is superseded, not the 2026-08-25 measurements: contended single runs on this bench inflate timings by 40-200%, which is why every figure published here is a quiescent median of three.

Latency, TTFB, best of 3 per URL:

URLpristinememoizedgate
/7.645s0.362s< 0.6s
/wp-login.php1.383s0.057s< 0.6s
/?page_id=2 (craft)7.625s0.355s< 1.5s
/?page_id=1289 (library fixture)4.652s0.202s< 1.5s
/?page_id=865 (Mein QuizWizz)1.674s0.118s< 1.0s
/?post_type=qw_quiz&p=99 (play)1.446s0.123s
/?post_type=qw_quiz&p=88 (play)1.489s0.154s

Latency is load-sensitive: a cold single request taken during a concurrent Quartz build (load average 12) measured / at 0.771s. Warmed best-of-3 under that same load: / 0.406s, /?page_id=2 0.382s, both inside gate. Warm with three discarded requests before quoting any figure.

Invalidation, in-container: choice() returned en; after update_option with iface_lang = de both choice() and get()['iface_lang'] returned de; after restoring the original array both returned en and the stored option was byte-identical.

Harness: ./scripts/quizwizz-probe.sh --wave all gave TAP 1..46, 46 assertions, 0 failures, rerun after the A/B cycle. The harness added no lines to logs/debug.log. The only new lines during this work (38528 → 38532) are four Trying to access array offset on false/null in Command line code warnings from the author’s own throwaway diagnostic one-liner, not from QuizWizz or WordPress.

Browser, cache disabled, https://wrdp.loca.zone:

  • play page ?post_type=qw_quiz&p=99 renders question 1 of 10 with answer choices, navigation TTFB 137ms, zero page errors; QuizWizz strings stayed English under a de_DE site locale, confirming the memoized choice() still honours iface_lang=en
  • craft page ?page_id=2 loads the builder with Print and Play; selecting a category reports 299 matching questions
  • Print opens the printable-quiz editor in 517ms with 10 questions, zero page errors

State: reactivated through WP-CLI when the change landed, and confirmed Active after the A/B cycle — the revert/restore touched only file bytes, so no activation hook re-ran. php -l clean in-container for all three files. Live files remain www-data:www-data 0644 at sha256 d0843a7b (SettingsStore), b006eba0 (UiLanguage), 78ee4d52 (CoreProvider).

Release status

releaseEligible: false — bench-local patch installed over the bind mount. The canonical QuizWizz repository needs the same memoization through its locked build pipeline; until then the shipped plugin retains the ~13ms-per-translated-string cost. The release owner applies source changes through that pipeline.