Bust object cache on writes for forms, membership, grades, subscriptions#3146
Bust object cache on writes for forms, membership, grades, subscriptions#3146flintfromthebasement wants to merge 4 commits into
Conversation
LifterLMS has four wp_cache_get/set pairs whose read paths have no
matching delete on the write paths. On hosts with a persistent object
cache (Redis, Memcached, APCu) the cached values become indefinitely
stale until the cache is manually flushed.
Cases fixed:
1. LLMS_Forms::get_core_forms() — `llms_core_forms` / `llms_core_form_ids`
never cleared when a form is saved, trashed, untrashed, deleted, or
when `_llms_form_is_core` / `_llms_form_location` meta changes.
2. LLMS_Membership::query_associated_posts() — `membership_{id}_associated_{type}`
never cleared when restriction/availability metadata changes on the
associated post. Also: the entry was written to the default (empty)
cache group, polluting WordPress's hot path and risking cross-blog
leakage on multisite. Moved to a named per-membership group using
the existing LLMS_Cache_Helper prefix-bump pattern so a single
`invalidate_group()` call orphans every cached post type for that
membership in one shot.
3. LLMS_Grades::get_grade() — `student_{id}` group never cleared when
a quiz attempt is completed, a lesson/section/course is marked
complete or incomplete, or an enrollment is removed. Switched to
the LLMS_Cache_Helper prefix-bump pattern; a single
`clear_student_cache()` call invalidates every cached grade for
that student.
4. LLMS_Product::has_active_subscriptions() — `llms_product_subscriptions_count`
never cleared when an order's status transitions in or out of the
`llms-active` / `llms-pending-cancel` / `llms-on-hold` bucket, or
when the order is deleted.
No reliance on `wp_cache_flush_group()` (unsupported by several
backends including older versions of the Redis Object Cache plugin);
either explicit `wp_cache_delete()` or the existing prefix-bump
helper is used throughout.
Three existing tests asserted the bug (cached read keeps returning the pre-change value after the underlying data changes) as the expected behavior. With the cache invalidation hooks added in the previous commit, the cached read and the fresh read now agree after a state change. Tests updated to match the corrected contract: - LLMS_Test_Model_LLMS_Product::test_has_active_subscriptions_use_cache After `set( 'status', 'llms-cancelled' )` and `set( 'status', 'llms-active' )`, the `transition_post_status` hook now busts the cache so `has_active_subscriptions( true )` reflects reality. - LLMS_Test_Grades and LLMS_Test_Student After `take_quiz()` records an attempt, the `lifterlms_quiz_completed` hook busts the per-student grade cache so `get_grade( ..., true )` returns the new grade instead of the stale null. Also adds `.changelogs/fix_object-cache-invalidation.yml` per the LifterLMS contributor workflow (`npm run dev changelog add`).
|
Thanks @brianhogg — you're right that $order_recurring->set( 'status', 'llms-cancelled' );
// Use cache, I expect an active subscription.
$this->assertTrue( $product->has_active_subscriptions( true ), $order_recurring->get( 'status' ) );The "use cache, I expect an active subscription" assertion is what this PR makes false — and I'd argue that's the right move. Just pushed The three tests now assert that cached and fresh reads agree after a state change, which matches the new contract. Happy to add explicit "invalidation triggered correctly" tests as well if you'd rather see those as separate assertions instead of folded into the existing tests — just say the word. |
Singleton constructors run exactly once per process. WordPress's PHPUnit framework snapshots hooks at bootstrap and calls restore_hooks() between tests; hooks registered later (e.g. inside LLMS_Grades::__construct, which runs the first time something asks for llms()->grades()) get wiped after the first test and never re-register — the singleton's $instance is already set, so the constructor doesn't run again. In production this never fires (one boot, hooks stay registered). In the test suite it caused test_get_grade to fail when any earlier test had already instantiated LLMS_Grades. Match the pattern already used by LLMS_Membership and LLMS_Product in this PR: keep the constructor minimal, expose a static init_grade_cache_hooks() that LifterLMS::__construct() wires at init=0. Static callbacks delegate to instance methods so the public API is unchanged.
|
Update: I got PHPUnit running locally and reproduced the original Initial fix shipped ( Then ran the broader suite and hit a different failure —
Fixed in Local test results after both fixes:
Full suite has 12 errors + 2 failures, but I verified those are all pre-existing on this branch's parent commit (stashed my changes and reran the failing tests — same errors). Most are flaky processor tests or WP-version-conditional skips. Notes for the reviewer pass:
|
Summary
LifterLMS has four
wp_cache_get/wp_cache_setpairs whose read paths have no matching cache delete on the write paths. On hosts with a persistent object cache (Redis, Memcached, APCu) the cached values become indefinitely stale until the cache is manually flushed.All four were reproduced against trunk on a Redis-backed install before the fix and verified resolved after. Patterns are the same shape as recent PMPro fixes #3565 and #3419.
Cases fixed
1.
LLMS_Forms::get_core_forms()Cache keys
llms_core_forms/llms_core_form_idswere never cleared when a form was saved, trashed, untrashed, deleted, or when_llms_form_is_core/_llms_form_locationpostmeta changed. Result: checkout / registration / account screens could render the wrong core form for a location indefinitely after an admin re-saved a form on an object-cached host.2.
LLMS_Membership::query_associated_posts()Cache key
membership_{id}_associated_{post_type}was never cleared when_llms_restricted_levelsor_llms_availability_restrictionspostmeta changed on an associated post, or when the post was trashed / deleted. Result: restricted-content lists and members-only access plan lists could stay wrong indefinitely after a content edit.Bonus: that entry was written to the empty cache group (the third arg of
wp_cache_setwas omitted), which lands it in WordPress'sdefaultgroup — global by default on multisite unless explicitly added towp_cache_add_global_groups(). Moved to a named per-membership groupllms_membership_{id}_associatedusing the existingLLMS_Cache_Helper::get_prefix()prefix-bump pattern (already used by the session handler), so a singleinvalidate_group()call orphans every cached post type for that membership.3.
LLMS_Grades::get_grade()The
student_{user_id}cache group had nowp_cache_deletecallers anywhere in the codebase. Result: a cached grade could survive a quiz attempt being completed, a lesson being marked complete/incomplete, or an enrollment being deleted, indefinitely. Student dashboard, certificates, and reports would render stale grades.Switched to the
LLMS_Cache_Helperprefix-bump pattern keyed on the per-student group. A newclear_student_cache( $student_id )public method bumps the prefix and orphans every cached grade for that student in one call. Hooked intollms_mark_complete,llms_mark_incomplete,lifterlms_quiz_completed, andllms_user_enrollment_deleted.4.
LLMS_Product::has_active_subscriptions()Cache key in group
llms_product_subscriptions_countwas never cleared when an order's status transitioned into or out of thellms-active/llms-pending-cancel/llms-on-holdbucket the query counts on, or when the order was deleted. Result: "this product has active subscriptions" checks (used in admin-side guards and reporting) stayed true forever after the underlying orders were cancelled / refunded / expired.Added a static
flush_active_subscriptions_cache( $product_id )helper, hooked intotransition_post_statusandbefore_delete_postfiltered to thellms_orderpost type.Notes
wp_cache_flush_group()anywhere in this PR. That function is not supported by several persistent backends (older Redis Object Cache versions, Memcached) — using it for invalidation is the bug at the heart of PMPro #3419. LifterLMS already has zero callsites ofwp_cache_flush_group()and this PR keeps it that way: invalidation is either explicitwp_cache_delete()or the existingLLMS_Cache_Helperprefix-bump helper.defaultgroup, which incidentally fixes a latent multisite cross-blog leakage risk.LLMS_Grades::clear_student_cache()does it cheaply.Reproduction (before the fix)
All four were reproduced on a clean LifterLMS install with the Redis Object Cache plugin (drop-in
wp-content/object-cache.phpactive) backed by a localredis-server:_llms_form_is_core=noon form #36[36, 38, 40][37, 38, 40]_llms_restricted_levelson a page[394][]42.5into cache, simulate quiz attempt42.5NULLtruefalseAfter applying this PR, the cached read matches the fresh read in every case.
How to test
wp redis enable).wp eval 'var_dump( wp_using_ext_object_cache() );'(must betrue).use_cache=falseread.Changelog entry
🤖 Generated with Claude Code