Add inline object caching to HPPS repositories#7904
Conversation
Introduces a shared trait mirroring WooCommerce's CacheNameSpaceTrait that provides prefix-based cache group invalidation. This enables rotating a prefix key to make all cached entries in a group unreachable without requiring wp_cache_flush_group(), which is not universally available. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds a filterable feature flag that gates all HPPS cache operations. Defaults to enabled when tables-based storage is active, controllable via the sensei_hpps_cache_enabled filter. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds read-through object caching with sentinel values to the course, lesson, and quiz progress Tables_Based repositories. Refactors has() to delegate to get() for cache reuse. find() warms individual caches via wp_cache_set_multiple(). Bulk deletes use prefix rotation for group invalidation. Filter cleanup moved to tearDown() to prevent leakage on assertion failure. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds read-through object caching to the submission, answer, and grade Tables_Based repositories. Submissions cache get() with per-key invalidation on save/delete. Answers and grades cache get_all() by submission_id, with cache deletion on create/delete_all/save_many. Filter cleanup moved to tearDown() to prevent leakage on assertion failure. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…Prefix trait - Extract '__not_found__' magic string to $cache_not_found static property for centralized sentinel management across all consuming repositories - Replace wp_cache_set with wp_cache_add + re-read in get_cache_prefix() to mitigate thundering herd when the prefix key is evicted - Add @SInCE 4.24.0 tags to all three trait methods Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Cache the filter result in a static property so is_cache_enabled() returns a consistent value throughout the request. Add reset_cache_enabled() for test teardown. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Use trait sentinel property instead of magic string - Skip cache population on failed DB insert (id=0) - Remove duplicate filter from has() since get() already filters - Hoist is_cache_enabled() call out of find() loop - Add @SInCE 4.24.0 to CACHE_GROUP constants - Fix @intenal typos and wrong constructor names in docblocks Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add tests for answer create cache invalidation, grade create and save_many cache invalidation, and has() delegation to get() for lesson/quiz progress. Add reset_cache_enabled() calls in tearDown to prevent filter leakage between tests. Rename $array parameter to $data to satisfy PHPCS naming rules. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add native string type to $cache_not_found, add false fallback in get_cache_prefix() re-read, add sentinel overwrite tests for all 3 progress repos, add is_cache_enabled() unit tests, and add failed-insert-no-cache test. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Introduces request-scoped object caching for HPPS (tables-based) progress/submission repositories to reduce repeated DB reads within a request, with a shared cache-prefix invalidation mechanism and a feature-flag gate.
Changes:
- Added
Cache_Prefixtrait to support cache-group invalidation via prefix rotation plus a not-found sentinel. - Implemented inline read-through caching + targeted invalidation in tables-based repositories (course/lesson/quiz progress, submissions, answers, grades).
- Added unit/integration tests for cache enablement, memoization, invalidation behavior, and prefix logic.
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
includes/internal/trait-cache-prefix.php |
New shared trait for prefix-based cache namespacing and group invalidation. |
includes/internal/services/class-progress-storage-settings.php |
Adds memoized is_cache_enabled() feature flag with sensei_hpps_cache_enabled filter and test reset helper. |
includes/internal/student-progress/course-progress/repositories/class-tables-based-course-progress-repository.php |
Adds inline caching for get() and warming in find(), plus invalidation on writes/deletes. |
includes/internal/student-progress/lesson-progress/repositories/class-tables-based-lesson-progress-repository.php |
Same inline caching + invalidation for lesson progress repository. |
includes/internal/student-progress/quiz-progress/repositories/class-tables-based-quiz-progress-repository.php |
Same inline caching + invalidation for quiz progress repository. |
includes/internal/quiz-submission/submission/repositories/class-tables-based-submission-repository.php |
Adds caching/invalidation for submissions get() and write paths. |
includes/internal/quiz-submission/answer/repositories/class-tables-based-answer-repository.php |
Adds caching/invalidation for answers get_all() and write paths. |
includes/internal/quiz-submission/grade/repositories/class-tables-based-grade-repository.php |
Adds caching/invalidation for grades get_all() and write paths. |
tests/unit-tests/internal/test-class-cache-prefix.php |
New tests covering cache prefix generation and invalidation behavior. |
tests/unit-tests/internal/services/test-class-progress-storage-settings.php |
Tests new cache-enabled flag defaults, filter override, and memoization/reset. |
tests/unit-tests/internal/student-progress/course-progress/repositories/test-class-tables-based-course-progress-repository.php |
Adds caching/invalidation tests for course progress repository. |
tests/unit-tests/internal/student-progress/lesson-progress/repositories/test-class-tables-based-lesson-progress-repository.php |
Adds caching/invalidation tests for lesson progress repository. |
tests/unit-tests/internal/student-progress/quiz-progress/repositories/test-class-tables-based-quiz-progress-repository.php |
Adds caching/invalidation tests for quiz progress repository. |
tests/unit-tests/internal/quiz-submission/submission/repositories/test-class-tables-based-submission-repository.php |
Adds caching/invalidation tests for submission repository. |
tests/unit-tests/internal/quiz-submission/answer/repositories/test-class-tables-based-answer-repository.php |
Adds caching/invalidation tests for answer repository. |
tests/unit-tests/internal/quiz-submission/grade/repositories/test-class-tables-based-grade-repository.php |
Adds caching/invalidation tests for grade repository. |
changelog/add-hpps-inline-caching |
Changelog entry for the performance change. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
The get_all() methods in answer and grade repositories return [] for empty results. Since [] !== false, empty arrays can be cached directly without the __not_found__ sentinel, which is only needed for methods that return null. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Reintroduce the sensei_*_progress_has_*_id filters in all three progress repository has() methods — these are public hooks since 4.23.1 used by WPML and other integrations. Removing them was a backward-incompatible regression. Fix CacheDisabled_DoesNotCache tests in all 6 repositories to assert on the cache prefix marker key instead of an un-prefixed key, which would always return false regardless of whether caching occurred. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… trait Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The cache group already contains 'sensei_', so the prefix key was producing 'sensei_sensei_...' unnecessarily. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Also add AGENTS.md rule requiring assertion messages when a test has multiple assertions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace get() delegation with a direct SELECT COUNT(*) query in course, lesson, and quiz tables-based progress repositories. This avoids fetching all columns, hydrating full entity objects, and populating the cache when callers only need a boolean existence check. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove testHas_CacheEnabled_DelegatesToGet from all three progress repository test files since has() no longer delegates to get(). Reorganize tests so all tests for each method (unit + cache) are grouped together instead of having cache tests in a separate section. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Convert read-caching tests (ReturnsCachedValueOnSecondCall, CachesNullAsNotFound/CachesEmptyResult, CreateOverwritesNotFoundSentinel, WarmsIndividualCaches) from integration tests using global $wpdb to unit tests using mocked wpdb with expects() assertions. This proves the second call actually hits cache instead of DB, which the previous tests could not verify. Also removes redundant get() calls in invalidation tests where create() already warms the cache via write-through caching. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
@merkushin This one adds caching. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Overall, the solution is straightforward but fine. The original idea was to have caching wrappers: simpler logic, separate concern. |
…Memcached keys microtime() returns "0.12345600 1712345678" which contains a space. While most WordPress Memcached drop-ins sanitize keys, not all do. Stripping the space defensively ensures valid keys with any backend. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Ensures filtered values are cast to int before use in cache keys and queries, matching the pattern used in all other repositories. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replaces inline cache key construction with a private method per repository, making the key format a single source of truth. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 19 out of 19 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
@merkushin The first approach I took was to use caching decorators, but I didn't like the number of extra files it added, plus there were issues with flushing the cache so I decided to pivot to Woo's approach. |
Do you remember what the issue was? (My personal interest.) |
Claude wanted to use |
mdawaffe
left a comment
There was a problem hiding this comment.
Looks reasonable to me. One question inline about group invalidation, but I don't think it's a blocker.
| if ( Progress_Storage_Settings::is_cache_enabled() ) { | ||
| self::invalidate_cache_group( self::CACHE_GROUP ); | ||
| } |
There was a problem hiding this comment.
Are these ->delete_for_{object}() and ->delete_for_user() methods common operations? Invalidating the entire cache just to delete one user's data could be painful for some sites.
Other possibilities:
SELECTto get IDs thenDELETEeither in one transaction (atomic but more expensive), or in separate queries (racey but cheap).DELETE … RETURNING: just what we need but not portable.- "Nested" prefixes so that we could invalidate the whole outer group or just individual nested ones. This is sort of a pain and doesn't actually solve the whole problem: there are multiple relevant ways to nest: group -> {object}_id, or group -> user_id. Picking one doesn't help the invalidation of the other, so it's only useful if one is common and the other is rare. This feels like too much complexity to me.
I may have talked myself into this simpler invalidate-all approach :) Are there sites that are currently using the custom tables? If so, they don't have any cacheing anyway so even a cacheing strategy with "inefficient" invalidations is an improvement, right?
There was a problem hiding this comment.
Are these ->delete_for_{object}() and ->delete_for_user() methods common operations?
They're not. It should be quite rare which is why invalidating the entire cache seems OK. Of course, people do unexpected things so I can't say this with any kind of certainty. 😅
Are there sites that are currently using the custom tables?
Some, but likely not that many since this feature is labelled as experimental and we didn't promote it widely due to gaps like the one this PR addresses.
| * @param {bool} $enabled Whether caching is enabled. | ||
| * @return {bool} Whether caching should be enabled. | ||
| */ | ||
| self::$cache_enabled = (bool) apply_filters( 'sensei_hpps_cache_enabled', self::is_tables_repository() ); |
There was a problem hiding this comment.
No action needed, but the WP Cache API is a little annoying here. I could see a site wanting a persistent cache (like Memcached) for most of their site but to only use a in-memory (not persisted) cache for Sensei. That's possible, but it requires different tweaking depending on what WP Cache dropin the site has installed, so it isn't anything we can do in the plugin.
There was a problem hiding this comment.
We could reconsider this in the future if we get reports about people wanting to do this.
…positories Cache invalidation in create(), save_many(), and delete_all() now applies the same filter as get_all() when computing cache keys, ensuring consistent invalidation when filters (e.g. WPML) remap submission IDs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Proposed Changes
Adds inline object caching to all six HPPS (High-Performance Progress Storage)
Tables_Basedrepositories, improving read performance by avoiding repeated database queries for the same progress/submission data within a request.Architecture:
Cache_Prefixtrait — Shared cache infrastructure with prefix-rotation for group invalidation (modeled after WooCommerce'sCacheNameSpaceTrait). Useswp_cache_add()+ re-read to mitigate thundering herd on prefix initialization.get(),get_all()). Write methods (create(),save(),delete()) invalidate individual entries; bulk deletes rotate the group prefix.Progress_Storage_Settings::is_cache_enabled(), which defaults totruewhen HPPS tables are active. Filterable viasensei_hpps_cache_enabled. Memoized to prevent mid-request instability.'__not_found__'sentinel distinguishes "cached null" from "cache miss", preventing repeated DB queries for non-existent entities.find()cache warming — After DB queries,find()warms individual caches for returned objects without caching the query itself.Repositories cached:
sensei_course_progress{course_id}_{user_id}sensei_lesson_progress{lesson_id}_{user_id}sensei_quiz_progress{quiz_id}_{user_id}sensei_quiz_submissions{quiz_id}_{user_id}sensei_quiz_answers{submission_id}sensei_quiz_grades{submission_id}Not cached (by design):
has(),find()query results,count(),get_question_ids()— complex/cross-table queries where invalidation would be fragile, or lightweight queries where caching adds unnecessary overhead.Testing Instructions
Prerequisites: Enable HPPS: WP Admin → Sensei → Settings → Experimental → set progress storage to Custom tables. Install Query Monitor for query inspection.
1. No stale data after completing a lesson
2. Quiz submission flow
3. Query reduction with persistent object cache (optional, requires Redis or Memcached)
wp_sensei_lms_progressNew/Updated Hooks
sensei_hpps_cache_enabled(filter) — Controls whether HPPS repository caching is active. Receives a boolean (default: result ofis_tables_repository()). Returnfalseto disable caching.🤖 Generated with Claude Code