Skip to content

Commit 6cc98a5

Browse files
committed
Collaborative Editing: Fix race condition and optimize cache invalidation in sync storage
Fixes a race condition in `get_updates_after_cursor` where updates inserted during the fetch window could be skipped by the next poll. The method now snapshots `max_meta_id` before fetching to ensure a consistent cursor. Optimizes `remove_updates_before_cursor` to use a single atomic `DELETE` query instead of the previous read-delete-write approach. Cleanup now uses direct SQL deletion to bypass `clean_post_cache`. This prevents sync operations from updating the global `posts_last_changed` salt, avoiding site-wide cache invalidation storms during high-frequency editing sessions. Includes a regression test using a WPDB proxy to deterministically reproduce the race condition.
1 parent 20560be commit 6cc98a5

2 files changed

Lines changed: 274 additions & 81 deletions

File tree

src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php

Lines changed: 117 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -79,50 +79,16 @@ public function add_update( string $room, $update ): bool {
7979
return false;
8080
}
8181

82-
// Create an envelope and stamp each update to enable cursor-based filtering.
83-
$envelope = array(
84-
'timestamp' => $this->get_time_marker(),
85-
'value' => $update,
82+
$meta_id = $this->with_suspended_posts_last_changed_update(
83+
fn() => add_post_meta( $post_id, self::SYNC_UPDATE_META_KEY, $update, false )
8684
);
8785

88-
return $this->with_suspended_posts_last_changed_update(
89-
fn() => (bool) add_post_meta( $post_id, self::SYNC_UPDATE_META_KEY, $envelope, false )
90-
);
91-
}
92-
93-
/**
94-
* Retrieves all sync updates for a given room.
95-
*
96-
* @since 7.0.0
97-
*
98-
* @param string $room Room identifier.
99-
* @return array<int, array{ timestamp: int, value: mixed }> Sync updates.
100-
*/
101-
private function get_all_updates( string $room ): array {
102-
$this->room_cursors[ $room ] = $this->get_time_marker() - 100; // Small buffer to ensure consistency.
103-
104-
$post_id = $this->get_storage_post_id( $room );
105-
if ( null === $post_id ) {
106-
return array();
86+
if ( $meta_id ) {
87+
$room_hash = md5( $room );
88+
wp_cache_delete( "sync_room_state_{$room_hash}", 'sync' );
10789
}
10890

109-
$updates = get_post_meta( $post_id, self::SYNC_UPDATE_META_KEY, false );
110-
111-
if ( ! is_array( $updates ) ) {
112-
$updates = array();
113-
}
114-
115-
// Filter out any updates that don't have the expected structure.
116-
$updates = array_filter(
117-
$updates,
118-
static function ( $update ): bool {
119-
return is_array( $update ) && isset( $update['timestamp'], $update['value'] ) && is_int( $update['timestamp'] );
120-
}
121-
);
122-
123-
$this->room_update_counts[ $room ] = count( $updates );
124-
125-
return $updates;
91+
return (bool) $meta_id;
12692
}
12793

12894
/**
@@ -134,6 +100,14 @@ static function ( $update ): bool {
134100
* @return array<int, mixed> Awareness state.
135101
*/
136102
public function get_awareness_state( string $room ): array {
103+
$room_hash = md5( $room );
104+
$cache_key = "sync_awareness_{$room_hash}";
105+
$cached = wp_cache_get( $cache_key, 'sync' );
106+
107+
if ( is_array( $cached ) ) {
108+
return array_values( $cached );
109+
}
110+
137111
$post_id = $this->get_storage_post_id( $room );
138112
if ( null === $post_id ) {
139113
return array();
@@ -145,6 +119,8 @@ public function get_awareness_state( string $room ): array {
145119
return array();
146120
}
147121

122+
wp_cache_set( $cache_key, $awareness, 'sync', MINUTE_IN_SECONDS );
123+
148124
return array_values( $awareness );
149125
}
150126

@@ -158,7 +134,25 @@ public function get_awareness_state( string $room ): array {
158134
* @return bool True on success, false on failure.
159135
*/
160136
public function set_awareness_state( string $room, array $awareness ): bool {
137+
$room_hash = md5( $room );
138+
$cache_key = "sync_awareness_{$room_hash}";
139+
140+
/**
141+
* Cache the awareness state to reduce post meta reads and writes.
142+
*
143+
* Awareness is inherently ephemeral and is not critical for
144+
* content consistency, so it's acceptable for it to be stale
145+
* or missing for a short period of time.
146+
*
147+
* For sites without object caching, fall back to post meta.
148+
*/
149+
wp_cache_set( $cache_key, $awareness, 'sync', MINUTE_IN_SECONDS );
150+
if ( wp_using_ext_object_cache() ) {
151+
return true;
152+
}
153+
161154
$post_id = $this->get_storage_post_id( $room );
155+
162156
if ( null === $post_id ) {
163157
return false;
164158
}
@@ -174,8 +168,7 @@ public function set_awareness_state( string $room, array $awareness ): bool {
174168
* Gets the current cursor for a given room.
175169
*
176170
* The cursor is set during get_updates_after_cursor() and represents the
177-
* point in time just before the updates were retrieved, with a small buffer
178-
* to ensure consistency.
171+
* highest meta_id seen for the room's sync updates.
179172
*
180173
* @since 7.0.0
181174
*
@@ -239,17 +232,6 @@ private function get_storage_post_id( string $room ): ?int {
239232
return null;
240233
}
241234

242-
/**
243-
* Gets the current time in milliseconds as a comparable time marker.
244-
*
245-
* @since 7.0.0
246-
*
247-
* @return int Current time in milliseconds.
248-
*/
249-
private function get_time_marker(): int {
250-
return (int) floor( microtime( true ) * 1000 );
251-
}
252-
253235
/**
254236
* Gets the number of updates stored for a given room.
255237
*
@@ -263,32 +245,83 @@ public function get_update_count( string $room ): int {
263245
}
264246

265247
/**
266-
* Retrieves sync updates from a room for a given client and cursor. Updates
267-
* from the specified client should be excluded.
248+
* Retrieves sync updates from a room after the given cursor.
268249
*
269250
* @since 7.0.0
270251
*
271252
* @param string $room Room identifier.
272-
* @param int $cursor Return updates after this cursor.
253+
* @param int $cursor Return updates after this cursor (meta_id).
273254
* @return array<int, mixed> Sync updates.
274255
*/
275256
public function get_updates_after_cursor( string $room, int $cursor ): array {
276-
$all_updates = $this->get_all_updates( $room );
277-
$updates = array();
257+
global $wpdb;
278258

279-
foreach ( $all_updates as $update ) {
280-
if ( $update['timestamp'] > $cursor ) {
281-
$updates[] = $update;
282-
}
259+
$room_hash = md5( $room );
260+
$state_cache_key = "sync_room_state_{$room_hash}";
261+
$cached = wp_cache_get( $state_cache_key, 'sync' );
262+
263+
if ( is_array( $cached ) && $cached['cursor'] <= $cursor ) {
264+
$this->room_cursors[ $room ] = $cached['cursor'];
265+
$this->room_update_counts[ $room ] = $cached['count'];
266+
return array();
267+
}
268+
269+
$post_id = $this->get_storage_post_id( $room );
270+
if ( null === $post_id ) {
271+
$this->room_cursors[ $room ] = 0;
272+
$this->room_update_counts[ $room ] = 0;
273+
return array();
274+
}
275+
276+
// Capture the current room state first so the returned cursor is race-safe.
277+
$stats = $wpdb->get_row(
278+
$wpdb->prepare(
279+
"SELECT COUNT(*) AS total_updates, COALESCE( MAX(meta_id), 0 ) AS max_meta_id FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s",
280+
$post_id,
281+
self::SYNC_UPDATE_META_KEY
282+
)
283+
);
284+
285+
$total_updates = $stats ? (int) $stats->total_updates : 0;
286+
$max_meta_id = $stats ? (int) $stats->max_meta_id : 0;
287+
288+
$this->room_update_counts[ $room ] = $total_updates;
289+
$this->room_cursors[ $room ] = $max_meta_id;
290+
291+
wp_cache_set(
292+
$state_cache_key,
293+
array(
294+
'cursor' => $max_meta_id,
295+
'count' => $total_updates,
296+
),
297+
'sync'
298+
);
299+
300+
if ( $max_meta_id <= $cursor ) {
301+
return array();
283302
}
284303

285-
// Sort by timestamp to ensure order.
286-
usort(
287-
$updates,
288-
fn ( $a, $b ) => $a['timestamp'] <=> $b['timestamp']
304+
$rows = $wpdb->get_results(
305+
$wpdb->prepare(
306+
"SELECT meta_value FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s AND meta_id > %d AND meta_id <= %d ORDER BY meta_id ASC",
307+
$post_id,
308+
self::SYNC_UPDATE_META_KEY,
309+
$cursor,
310+
$max_meta_id
311+
)
289312
);
290313

291-
return wp_list_pluck( $updates, 'value' );
314+
if ( ! $rows ) {
315+
return array();
316+
}
317+
318+
$updates = array();
319+
foreach ( $rows as $row ) {
320+
$update = maybe_unserialize( $row->meta_value );
321+
$updates[] = $update;
322+
}
323+
324+
return $updates;
292325
}
293326

294327
/**
@@ -297,33 +330,37 @@ public function get_updates_after_cursor( string $room, int $cursor ): array {
297330
* @since 7.0.0
298331
*
299332
* @param string $room Room identifier.
300-
* @param int $cursor Remove updates with markers < this cursor.
333+
* @param int $cursor Remove updates with meta_id < this cursor.
301334
* @return bool True on success, false on failure.
302335
*/
303336
public function remove_updates_before_cursor( string $room, int $cursor ): bool {
337+
global $wpdb;
338+
304339
$post_id = $this->get_storage_post_id( $room );
305340
if ( null === $post_id ) {
306341
return false;
307342
}
308343

309-
$all_updates = $this->get_all_updates( $room );
344+
$deleted_rows = $wpdb->query(
345+
$wpdb->prepare(
346+
"DELETE FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s AND meta_id < %d",
347+
$post_id,
348+
self::SYNC_UPDATE_META_KEY,
349+
$cursor
350+
)
351+
);
310352

311-
// Remove all updates for the room and re-store only those that are newer than the cursor.
312-
if ( ! $this->with_suspended_posts_last_changed_update( fn() => delete_post_meta( $post_id, self::SYNC_UPDATE_META_KEY ) ) ) {
353+
if ( false === $deleted_rows ) {
313354
return false;
314355
}
315356

316-
// Re-store envelopes directly to avoid double-wrapping by add_update().
317-
$add_result = true;
318-
foreach ( $all_updates as $envelope ) {
319-
if ( $add_result && $envelope['timestamp'] >= $cursor ) {
320-
$add_result = $this->with_suspended_posts_last_changed_update(
321-
fn() => (bool) add_post_meta( $post_id, self::SYNC_UPDATE_META_KEY, $envelope, false )
322-
);
323-
}
357+
if ( $deleted_rows > 0 ) {
358+
wp_cache_delete( $post_id, 'post_meta' );
359+
$room_hash = md5( $room );
360+
wp_cache_delete( "sync_room_state_{$room_hash}", 'sync' );
324361
}
325362

326-
return $add_result;
363+
return true;
327364
}
328365

329366
/**

0 commit comments

Comments
 (0)