Skip to content

Commit 6827989

Browse files
committed
Collaboration: Use persistent object cache for awareness reads
Adds a cache-first read path to get_awareness_state() following the transient pattern: check the persistent object cache, fall back to the database on miss, and prime the cache with the result. set_awareness_state() updates the cached entries in-place after the DB write rather than invalidating, so the cache stays warm for the next reader in the room. This is application-level deduplication: the shared collaboration table cannot carry a UNIQUE KEY on (room, client_id) because sync rows need multiple entries per room+client pair. Sites without a persistent cache see no behavior change — the in-memory WP_Object_Cache provides no cross-request benefit but keeps the code path identical.
1 parent 886f0b1 commit 6827989

2 files changed

Lines changed: 142 additions & 6 deletions

File tree

src/wp-includes/collaboration/class-wp-collaboration-table-storage.php

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
* updates and awareness data during a collaborative session.
1212
*
1313
* All data is stored in the single `collaboration` database table,
14-
* discriminated by the `type` column.
14+
* discriminated by the `type` column. Awareness reads are served from
15+
* the persistent object cache when available, falling back to the
16+
* database — similar to the transient pattern but without wp_options.
1517
*
1618
* This class intentionally fires no actions or filters. Collaboration
1719
* queries run on every poll (0.5–1 s per editor tab), so hook overhead
@@ -72,9 +74,14 @@ public function add_update( string $room, $update ): bool {
7274
/**
7375
* Gets awareness state for a given room.
7476
*
75-
* Retrieves per-client awareness rows from the collaboration table
76-
* where type = 'awareness'. Expired rows are filtered by the WHERE
77-
* clause; actual deletion is handled by cron via
77+
* Checks the persistent object cache first. On a cache miss, queries
78+
* the collaboration table for awareness rows and primes the cache
79+
* with the result. When no persistent cache is available the in-memory
80+
* WP_Object_Cache is used, which provides no cross-request benefit
81+
* but keeps the code path identical.
82+
*
83+
* Expired rows are filtered by the WHERE clause on cache miss;
84+
* actual deletion is handled by cron via
7885
* wp_delete_old_collaboration_data().
7986
*
8087
* @since 7.0.0
@@ -87,6 +94,13 @@ public function add_update( string $room, $update ): bool {
8794
* @phpstan-return list<AwarenessState>
8895
*/
8996
public function get_awareness_state( string $room, int $timeout = 30 ): array {
97+
$cache_key = 'awareness:' . $room;
98+
$cached = wp_cache_get( $cache_key, 'collaboration' );
99+
100+
if ( false !== $cached ) {
101+
return $cached;
102+
}
103+
90104
global $wpdb;
91105

92106
$cutoff = gmdate( 'Y-m-d H:i:s', time() - $timeout );
@@ -115,6 +129,8 @@ public function get_awareness_state( string $room, int $timeout = 30 ): array {
115129
}
116130
}
117131

132+
wp_cache_set( $cache_key, $entries, 'collaboration', $timeout );
133+
118134
return $entries;
119135
}
120136

@@ -257,6 +273,13 @@ public function remove_updates_before_cursor( string $room, int $cursor ): bool
257273
* its own row, eliminating the race condition inherent in shared-state
258274
* approaches.
259275
*
276+
* After writing, the cached awareness entries for the room are updated
277+
* in-place so that subsequent get_awareness_state() calls from other
278+
* clients hit the cache instead of the database. This is application-
279+
* level deduplication: the shared collaboration table cannot carry a
280+
* UNIQUE KEY on (room, client_id) because sync rows need multiple
281+
* entries per room+client pair.
282+
*
260283
* @since 7.0.0
261284
*
262285
* @global wpdb $wpdb WordPress database abstraction object.
@@ -302,9 +325,43 @@ public function set_awareness_state( string $room, string $client_id, array $sta
302325
)
303326
);
304327

305-
return false !== $result;
328+
if ( false === $result ) {
329+
return false;
330+
}
331+
} elseif ( false === $updated ) {
332+
return false;
333+
}
334+
335+
// Update the cached entries in-place so the next reader in this
336+
// room gets a cache hit with fresh data. If the cache is cold,
337+
// skip — the next get_awareness_state() call will prime it.
338+
$cache_key = 'awareness:' . $room;
339+
$cached = wp_cache_get( $cache_key, 'collaboration' );
340+
341+
if ( false !== $cached ) {
342+
$normalized_state = json_decode( $update_value, true );
343+
$found = false;
344+
345+
foreach ( $cached as $i => $entry ) {
346+
if ( $client_id === $entry['client_id'] ) {
347+
$cached[ $i ]['state'] = $normalized_state;
348+
$cached[ $i ]['user_id'] = $user_id;
349+
$found = true;
350+
break;
351+
}
352+
}
353+
354+
if ( ! $found ) {
355+
$cached[] = array(
356+
'client_id' => $client_id,
357+
'state' => $normalized_state,
358+
'user_id' => $user_id,
359+
);
360+
}
361+
362+
wp_cache_set( $cache_key, $cached, 'collaboration', 30 );
306363
}
307364

308-
return false !== $updated;
365+
return true;
309366
}
310367
}

tests/phpunit/tests/rest-api/rest-collaboration-server.php

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1671,6 +1671,85 @@ public function test_collaboration_null_awareness_skips_write() {
16711671
$this->assertSame( array( 'cursor' => 'active' ), $awareness['1'], 'Client 1 awareness state should match.' );
16721672
}
16731673

1674+
/*
1675+
* Cache tests.
1676+
*/
1677+
1678+
/**
1679+
* Verifies that a normal awareness write updates the cache in-place
1680+
* so the next client's poll hits the cache instead of the database.
1681+
*
1682+
* @ticket 64696
1683+
*/
1684+
public function test_collaboration_awareness_cache_hit_after_write(): void {
1685+
global $wpdb;
1686+
1687+
wp_set_current_user( self::$editor_id );
1688+
1689+
$room = $this->get_post_room();
1690+
1691+
// Client 1 polls with awareness — primes cache via get, then
1692+
// updates it in-place via set.
1693+
$this->dispatch_collaboration(
1694+
array(
1695+
$this->build_room( $room, '1', 0, array( 'cursor' => 'pos-a' ) ),
1696+
)
1697+
);
1698+
1699+
// Client 2 polls — awareness read should hit the warm cache.
1700+
$queries_before = $wpdb->num_queries;
1701+
1702+
$this->dispatch_collaboration(
1703+
array(
1704+
$this->build_room( $room, '2', 0, array( 'cursor' => 'pos-b' ) ),
1705+
)
1706+
);
1707+
1708+
$queries_after = $wpdb->num_queries;
1709+
1710+
// With cache hit: awareness read is free, so:
1711+
// awareness UPDATE (1) + snapshot SELECT (1) + awareness INSERT (1) = 3.
1712+
// Without cache: adds awareness SELECT = 4.
1713+
$this->assertLessThanOrEqual(
1714+
3,
1715+
$queries_after - $queries_before,
1716+
'Awareness cache hit should skip the awareness SELECT query.'
1717+
);
1718+
}
1719+
1720+
/**
1721+
* Verifies that the in-place cache update after a write produces
1722+
* correct data, not stale state.
1723+
*
1724+
* @ticket 64696
1725+
*/
1726+
public function test_collaboration_awareness_cache_reflects_latest_write(): void {
1727+
wp_set_current_user( self::$editor_id );
1728+
1729+
$room = $this->get_post_room();
1730+
1731+
// Client 1 sets initial awareness.
1732+
$this->dispatch_collaboration(
1733+
array(
1734+
$this->build_room( $room, '1', 0, array( 'cursor' => 'initial' ) ),
1735+
)
1736+
);
1737+
1738+
// Client 1 updates awareness to a new value.
1739+
$response = $this->dispatch_collaboration(
1740+
array(
1741+
$this->build_room( $room, '1', 0, array( 'cursor' => 'updated' ) ),
1742+
)
1743+
);
1744+
1745+
$awareness = $response->get_data()['rooms'][0]['awareness'];
1746+
$this->assertSame(
1747+
array( 'cursor' => 'updated' ),
1748+
$awareness['1'],
1749+
'Awareness should reflect the updated state, not a stale cache.'
1750+
);
1751+
}
1752+
16741753
/*
16751754
* Query count tests.
16761755
*/

0 commit comments

Comments
 (0)