Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ecb33fd
Add Cache_Prefix trait for object cache group invalidation
donnapep Feb 26, 2026
c27bf77
Add is_cache_enabled() to Progress_Storage_Settings
donnapep Feb 26, 2026
2e32f0a
Add inline caching to student progress repositories
donnapep Feb 26, 2026
4e2524c
Add inline caching to quiz submission repositories
donnapep Feb 26, 2026
43c447b
Add sentinel property, thundering herd fix, and @since tags to Cache_…
donnapep Feb 26, 2026
360fc91
Memoize is_cache_enabled() to prevent mid-request instability
donnapep Feb 26, 2026
3aad620
Harden caching in student progress repositories
donnapep Feb 26, 2026
5fc5d01
Harden caching in quiz submission repositories
donnapep Feb 26, 2026
9827d82
Add missing cache tests and harden test teardown
donnapep Feb 26, 2026
eedf0ba
Address second code review findings for HPPS caching
donnapep Feb 26, 2026
bea908b
Add changelog entry for HPPS inline caching
donnapep Feb 26, 2026
b3887c7
Fix Psalm errors: remove unnecessary sentinel in array-returning methods
donnapep Feb 26, 2026
e8a934d
Restore has() filters and fix CacheDisabled test assertions
donnapep Feb 26, 2026
e656c4c
Use $$next-version$$ placeholder for @since tags
donnapep Feb 27, 2026
22a8f33
Extract get_prefix_key() to remove string duplication in Cache_Prefix…
donnapep Feb 27, 2026
86fb93a
Remove redundant sensei_ prefix from cache prefix key
donnapep Feb 27, 2026
e3e9440
Add assertion messages to tests with multiple assertions
donnapep Feb 27, 2026
f48cec8
Move $cache_enabled property to top of class with other declarations
donnapep Feb 27, 2026
c892aca
Use lightweight COUNT query in tables-based progress has() methods
donnapep Mar 2, 2026
323cf60
Remove redundant has() cache test and group tests by method
donnapep Mar 2, 2026
c7f4517
Update changelog entry
donnapep Mar 2, 2026
30dee5b
Use mocked wpdb in cache read tests to prove DB is skipped
donnapep Mar 2, 2026
d6311ab
Strip space from microtime() output in cache prefix to avoid invalid …
donnapep Mar 3, 2026
d9a3889
Add (int) cast to apply_filters calls in submission repositories
donnapep Mar 3, 2026
c0e8871
Extract get_cache_key() method in tables-based repositories
donnapep Mar 3, 2026
20fec9e
Use get_all filter for cache invalidation keys in grade and answer re…
donnapep Mar 6, 2026
448db59
Merge branch 'trunk' into add/hpps-inline-caching
donnapep Mar 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Composer production dependencies that may conflict with other plugins are scoped

- **CRITICAL: WordPress filters persist between test cases.** Always remove filters added during a test in `tearDown()` or the test will leak state into other tests.
- **Use `assertSame()` over `assertEquals()`** — strict type + equality comparison.
- **Use assertion messages when a test has multiple assertions** — pass the `$message` parameter to differentiate which assertion failed (e.g., `self::assertSame( 1, $id, 'ID should be 1' )`).
- **Text domain is `sensei-lms`** (not `sensei`). All user-facing strings MUST use this.
- **Never concatenate translatable strings** — use `sprintf()` with placeholders.
- **Never use `extract()`, `eval()`, or `create_function()`.**
Expand All @@ -45,6 +46,7 @@ Composer production dependencies that may conflict with other plugins are scoped
- **Branch naming**: `type/description` — e.g. `fix/course-average-query`, `add/show-tailored-course-outline`, `feature/ai-make-quiz`
- **PRs**: Must reference an issue (`Resolves #123`), include testing instructions, and follow `.github/PULL_REQUEST_TEMPLATE.md`.
- **Changelogs**: Every user-facing change MUST have a changelog entry before opening a PR. Run `npm run changelog` (entries stored in `changelog/`).
- Use the `$$next-version$$` placeholder for the `@since` parameter.

## Boundaries

Expand Down
4 changes: 4 additions & 0 deletions changelog/add-hpps-inline-caching
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Add object caching to HPPS (High-Performance Progress Storage) repositories
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

use DateTimeImmutable;
use DateTimeZone;
use Sensei\Internal\Cache_Prefix;
use Sensei\Internal\Services\Progress_Storage_Settings;
use Sensei\Internal\Quiz_Submission\Answer\Models\Answer_Interface;
use Sensei\Internal\Quiz_Submission\Answer\Models\Tables_Based_Answer;
use Sensei\Internal\Quiz_Submission\Submission\Models\Submission_Interface;
Expand All @@ -26,6 +28,17 @@
* @since 4.16.1
*/
class Tables_Based_Answer_Repository implements Answer_Repository_Interface {
use Cache_Prefix;

/**
* Cache group for quiz answers.
*
* @since $$next-version$$
*
* @var string
*/
private const CACHE_GROUP = 'sensei_quiz_answers';

/**
* WordPress database object.
*
Expand Down Expand Up @@ -102,14 +115,21 @@ public function create( Submission_Interface $submission, int $question_id, stri
]
);

return new Tables_Based_Answer(
$answer = new Tables_Based_Answer(
$this->wpdb->insert_id,
$submission_id,
$question_id,
$value,
$current_datetime,
$current_datetime
);

if ( $this->wpdb->insert_id && Progress_Storage_Settings::is_cache_enabled() ) {
$cache_key = (string) $submission_id;
wp_cache_delete( self::get_prefixed_key( $cache_key, self::CACHE_GROUP ), self::CACHE_GROUP );
}

return $answer;
}

/**
Expand All @@ -135,6 +155,15 @@ public function get_all( int $submission_id ): array {
*/
$submission_id = (int) apply_filters( 'sensei_quiz_answer_get_all_submission_id', $submission_id, 'tables' );

$cache_key = (string) $submission_id;

if ( Progress_Storage_Settings::is_cache_enabled() ) {
$cached = wp_cache_get( self::get_prefixed_key( $cache_key, self::CACHE_GROUP ), self::CACHE_GROUP );
if ( false !== $cached ) {
return $cached;
}
}

$query = $this->wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT * FROM {$this->get_table_name()} WHERE submission_id = %d",
Expand All @@ -154,6 +183,10 @@ public function get_all( int $submission_id ): array {
);
}

if ( Progress_Storage_Settings::is_cache_enabled() ) {
wp_cache_set( self::get_prefixed_key( $cache_key, self::CACHE_GROUP ), $answers, self::CACHE_GROUP );
}

return $answers;
}

Expand Down Expand Up @@ -187,6 +220,11 @@ public function delete_all( Submission_Interface $submission ): void {
'%d',
]
);

if ( Progress_Storage_Settings::is_cache_enabled() ) {
$cache_key = (string) $submission_id;
wp_cache_delete( self::get_prefixed_key( $cache_key, self::CACHE_GROUP ), self::CACHE_GROUP );
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
exit;
}

use Sensei\Internal\Cache_Prefix;
use Sensei\Internal\Services\Progress_Storage_Settings;
use Sensei\Internal\Quiz_Submission\Answer\Models\Answer_Interface;
use Sensei\Internal\Quiz_Submission\Grade\Models\Tables_Based_Grade;
use Sensei\Internal\Quiz_Submission\Grade\Models\Grade_Interface;
Expand All @@ -25,6 +27,17 @@
* @since 4.16.1
*/
class Tables_Based_Grade_Repository implements Grade_Repository_Interface {
use Cache_Prefix;

/**
* Cache group for quiz grades.
*
* @since $$next-version$$
*
* @var string
*/
private const CACHE_GROUP = 'sensei_quiz_grades';

/**
* WordPress database object.
*
Expand Down Expand Up @@ -105,7 +118,7 @@ public function create( Submission_Interface $submission, Answer_Interface $answ
]
);

return new Tables_Based_Grade(
$grade = new Tables_Based_Grade(
$this->wpdb->insert_id,
$answer_id,
$question_id,
Expand All @@ -114,6 +127,13 @@ public function create( Submission_Interface $submission, Answer_Interface $answ
$current_date,
$current_date
);

if ( $this->wpdb->insert_id && Progress_Storage_Settings::is_cache_enabled() ) {
$cache_key = (string) $submission->get_id();
wp_cache_delete( self::get_prefixed_key( $cache_key, self::CACHE_GROUP ), self::CACHE_GROUP );
}

return $grade;
}

/**
Expand All @@ -139,8 +159,21 @@ public function get_all( int $submission_id ): array {
*/
$submission_id = (int) apply_filters( 'sensei_quiz_grade_get_all_submission_id', $submission_id, 'tables' );

$cache_key = (string) $submission_id;

if ( Progress_Storage_Settings::is_cache_enabled() ) {
$cached = wp_cache_get( self::get_prefixed_key( $cache_key, self::CACHE_GROUP ), self::CACHE_GROUP );
if ( false !== $cached ) {
return $cached;
}
}

$answer_ids = $this->get_answer_ids_by_submission_id( $submission_id );
if ( empty( $answer_ids ) ) {
if ( Progress_Storage_Settings::is_cache_enabled() ) {
wp_cache_set( self::get_prefixed_key( $cache_key, self::CACHE_GROUP ), array(), self::CACHE_GROUP );
}

return [];
}

Expand All @@ -162,6 +195,10 @@ public function get_all( int $submission_id ): array {
);
}

if ( Progress_Storage_Settings::is_cache_enabled() ) {
wp_cache_set( self::get_prefixed_key( $cache_key, self::CACHE_GROUP ), $grades, self::CACHE_GROUP );
}

return $grades;
}

Expand All @@ -177,6 +214,11 @@ public function save_many( Submission_Interface $submission, array $grades ): vo
foreach ( $grades as $grade ) {
$this->save( $grade );
}

if ( Progress_Storage_Settings::is_cache_enabled() ) {
$cache_key = (string) $submission->get_id();
wp_cache_delete( self::get_prefixed_key( $cache_key, self::CACHE_GROUP ), self::CACHE_GROUP );
}
}

/**
Expand Down Expand Up @@ -209,6 +251,11 @@ public function delete_all( Submission_Interface $submission ): void {
$delete_query = 'DELETE FROM ' . $this->get_table_name() . ' WHERE answer_id IN (' . $placeholders . ')';
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$this->wpdb->query( $this->wpdb->prepare( $delete_query, ...$answer_ids ) );

if ( Progress_Storage_Settings::is_cache_enabled() ) {
$cache_key = (string) $submission_id;
wp_cache_delete( self::get_prefixed_key( $cache_key, self::CACHE_GROUP ), self::CACHE_GROUP );
}
}

/**
Expand Down Expand Up @@ -244,6 +291,7 @@ private function save( Grade_Interface $grade ): void {
* Get all answer IDs for a submission.
*
* @param int $submission_id The submission ID.
* @return array The answer IDs.
*/
private function get_answer_ids_by_submission_id( int $submission_id ): array {
$answers_query = $this->wpdb->prepare(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

use DateTimeImmutable;
use DateTimeZone;
use Sensei\Internal\Cache_Prefix;
use Sensei\Internal\Services\Progress_Storage_Settings;
use Sensei\Internal\Quiz_Submission\Submission\Models\Submission_Interface;
use Sensei\Internal\Quiz_Submission\Submission\Models\Tables_Based_Submission;
use wpdb;
Expand All @@ -25,6 +27,17 @@
* @since 4.16.1
*/
class Tables_Based_Submission_Repository implements Submission_Repository_Interface {
use Cache_Prefix;

/**
* Cache group for quiz submissions.
*
* @since $$next-version$$
*
* @var string
*/
private const CACHE_GROUP = 'sensei_quiz_submissions';

/**
* WordPress database object.
*
Expand Down Expand Up @@ -88,14 +101,21 @@ public function create( int $quiz_id, int $user_id, float $final_grade = null ):
]
);

return new Tables_Based_Submission(
$submission = new Tables_Based_Submission(
$this->wpdb->insert_id,
$quiz_id,
$user_id,
$final_grade,
$current_datetime,
$current_datetime
);

if ( $this->wpdb->insert_id && Progress_Storage_Settings::is_cache_enabled() ) {
$cache_key = $quiz_id . '_' . $user_id;
wp_cache_set( self::get_prefixed_key( $cache_key, self::CACHE_GROUP ), $submission, self::CACHE_GROUP );
}

return $submission;
}

/**
Expand Down Expand Up @@ -154,6 +174,15 @@ public function get( int $quiz_id, int $user_id ): ?Submission_Interface {
*/
$quiz_id = apply_filters( 'sensei_quiz_submission_get_quiz_id', $quiz_id );

$cache_key = $quiz_id . '_' . $user_id;

if ( Progress_Storage_Settings::is_cache_enabled() ) {
$cached = wp_cache_get( self::get_prefixed_key( $cache_key, self::CACHE_GROUP ), self::CACHE_GROUP );
if ( false !== $cached ) {
return self::$cache_not_found === $cached ? null : $cached;
}
}

$query = $this->wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT * FROM {$this->get_table_name()} WHERE quiz_id = %d AND user_id = %d",
Expand All @@ -165,17 +194,27 @@ public function get( int $quiz_id, int $user_id ): ?Submission_Interface {
$row = $this->wpdb->get_row( $query );

if ( ! $row ) {
if ( Progress_Storage_Settings::is_cache_enabled() ) {
wp_cache_set( self::get_prefixed_key( $cache_key, self::CACHE_GROUP ), self::$cache_not_found, self::CACHE_GROUP );
}

return null;
}

return new Tables_Based_Submission(
$submission = new Tables_Based_Submission(
(int) $row->id,
(int) $row->quiz_id,
(int) $row->user_id,
$row->final_grade,
new DateTimeImmutable( $row->created_at, new DateTimeZone( 'UTC' ) ),
new DateTimeImmutable( $row->updated_at, new DateTimeZone( 'UTC' ) )
);

if ( Progress_Storage_Settings::is_cache_enabled() ) {
wp_cache_set( self::get_prefixed_key( $cache_key, self::CACHE_GROUP ), $submission, self::CACHE_GROUP );
}

return $submission;
}

/**
Expand Down Expand Up @@ -243,6 +282,11 @@ public function save( Submission_Interface $submission ): void {
'%d',
]
);

if ( Progress_Storage_Settings::is_cache_enabled() ) {
$cache_key = $submission->get_quiz_id() . '_' . $submission->get_user_id();
wp_cache_delete( self::get_prefixed_key( $cache_key, self::CACHE_GROUP ), self::CACHE_GROUP );
}
}

/**
Expand All @@ -264,6 +308,11 @@ public function delete( Submission_Interface $submission ): void {
'%d',
]
);

if ( Progress_Storage_Settings::is_cache_enabled() ) {
$cache_key = $submission->get_quiz_id() . '_' . $submission->get_user_id();
wp_cache_delete( self::get_prefixed_key( $cache_key, self::CACHE_GROUP ), self::CACHE_GROUP );
}
}

/**
Expand Down
44 changes: 44 additions & 0 deletions includes/internal/services/class-progress-storage-settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ class Progress_Storage_Settings {
*/
public const TABLES_STORAGE = 'custom_tables';

/**
* Memoized cache-enabled flag. Null means not yet computed.
*
* @var bool|null
*/
private static ?bool $cache_enabled = null;

/**
* Get the storage repositories.
*
Expand Down Expand Up @@ -89,4 +96,41 @@ public static function is_tables_repository(): bool {
public static function is_sync_enabled(): bool {
return Sensei()->settings->settings['experimental_progress_storage_synchronization'] ?? false;
}

/**
* Returns true if HPPS caching is enabled.
*
* Defaults to true when using tables-based storage. Filterable via `sensei_hpps_cache_enabled`.
*
* @since $$next-version$$
*
* @return bool
*/
public static function is_cache_enabled(): bool {
if ( null === self::$cache_enabled ) {
/**
* Filter whether HPPS caching is enabled.
*
* @hook sensei_hpps_cache_enabled
*
* @since $$next-version$$
*
* @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() );
Copy link
Member

Choose a reason for hiding this comment

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

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We could reconsider this in the future if we get reports about people wanting to do this.

}
return self::$cache_enabled;
}

/**
* Reset the memoized cache-enabled flag. Useful for tests.
*
* @since $$next-version$$
*
* @internal
*/
public static function reset_cache_enabled(): void {
self::$cache_enabled = null;
}
}
Loading
Loading