Skip to content

Commit 030bbce

Browse files
committed
Collaboration: Harden storage layer, fix duplicate awareness rows, and expand test coverage
1 parent d833d2f commit 030bbce

4 files changed

Lines changed: 545 additions & 156 deletions

File tree

src/wp-includes/collaboration.php

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ function wp_delete_old_collaboration_data() {
7070
*/
7171
$wpdb->query(
7272
$wpdb->prepare(
73-
"DELETE FROM {$wpdb->collaboration} WHERE type != 'awareness' AND date_gmt < %s",
73+
"DELETE FROM {$wpdb->collaboration} WHERE date_gmt < %s",
7474
gmdate( 'Y-m-d H:i:s', time() - WEEK_IN_SECONDS )
7575
)
7676
);
@@ -86,16 +86,10 @@ function wp_delete_old_collaboration_data() {
8686
return;
8787
}
8888

89-
/*
90-
* Clean up sync rows older than 7 days.
91-
*
92-
* The type != 'awareness' exclusion keeps awareness rows untouched —
93-
* they are cleaned up separately below. Future persistent types
94-
* (e.g. persisted_crdt_doc) may also need exclusion here.
95-
*/
89+
/* Clean up rows older than 7 days. */
9690
$wpdb->query(
9791
$wpdb->prepare(
98-
"DELETE FROM {$wpdb->collaboration} WHERE type != 'awareness' AND date_gmt < %s",
92+
"DELETE FROM {$wpdb->collaboration} WHERE date_gmt < %s",
9993
gmdate( 'Y-m-d H:i:s', time() - WEEK_IN_SECONDS )
10094
)
10195
);

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

Lines changed: 27 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -241,10 +241,7 @@ public function get_updates_after_cursor( string $room, int $cursor ): array {
241241
}
242242

243243
/**
244-
* Removes updates from a room that are older than the given cursor.
245-
*
246-
* Uses a single atomic DELETE query, avoiding the race-prone
247-
* "delete all, re-add some" pattern.
244+
* Removes updates from a room up to and including the given cursor.
248245
*
249246
* @since 7.0.0
250247
*
@@ -254,9 +251,11 @@ public function get_updates_after_cursor( string $room, int $cursor ): array {
254251
* @param int $cursor Remove updates up to and including this cursor.
255252
* @return bool True on success, false on failure.
256253
*/
257-
public function remove_updates_before_cursor( string $room, int $cursor ): bool {
254+
public function remove_updates_up_to_cursor( string $room, int $cursor ): bool {
258255
global $wpdb;
259256

257+
// Uses a single atomic DELETE query, avoiding the race-prone
258+
// "delete all, re-add some" pattern.
260259
$result = $wpdb->query(
261260
$wpdb->prepare(
262261
"DELETE FROM {$wpdb->collaboration} WHERE room = %s AND type != 'awareness' AND id <= %d",
@@ -271,10 +270,10 @@ public function remove_updates_before_cursor( string $room, int $cursor ): bool
271270
/**
272271
* Sets awareness state for a given client in a room.
273272
*
274-
* Uses UPDATE-then-INSERT: tries to update the existing row first,
275-
* and only inserts if no row was updated. Each client writes only
276-
* its own row, eliminating the race condition inherent in shared-state
277-
* approaches.
273+
* Uses SELECT-then-UPDATE/INSERT: checks for an existing row by
274+
* primary key, then updates or inserts accordingly. Each client
275+
* writes only its own row, eliminating the race condition inherent
276+
* in shared-state approaches.
278277
*
279278
* After writing, the cached awareness entries for the room are updated
280279
* in-place so that subsequent get_awareness_state() calls from other
@@ -299,23 +298,26 @@ public function set_awareness_state( string $room, string $client_id, array $sta
299298
$data = wp_json_encode( $state );
300299
$now = gmdate( 'Y-m-d H:i:s' );
301300

302-
/* Try UPDATE first. */
303-
$updated = $wpdb->update(
304-
$wpdb->collaboration,
305-
array(
306-
'user_id' => $user_id,
307-
'data' => $data,
308-
'date_gmt' => $now,
309-
),
310-
array(
311-
'room' => $room,
312-
'type' => 'awareness',
313-
'client_id' => $client_id,
301+
/* Check if a row already exists. */
302+
$exists = $wpdb->get_var(
303+
$wpdb->prepare(
304+
"SELECT id FROM {$wpdb->collaboration} WHERE room = %s AND type = 'awareness' AND client_id = %s LIMIT 1",
305+
$room,
306+
$client_id
314307
)
315308
);
316309

317-
/* INSERT only if no existing row. */
318-
if ( 0 === (int) $updated ) {
310+
if ( $exists ) {
311+
$result = $wpdb->update(
312+
$wpdb->collaboration,
313+
array(
314+
'user_id' => $user_id,
315+
'data' => $data,
316+
'date_gmt' => $now,
317+
),
318+
array( 'id' => $exists )
319+
);
320+
} else {
319321
$result = $wpdb->insert(
320322
$wpdb->collaboration,
321323
array(
@@ -327,11 +329,9 @@ public function set_awareness_state( string $room, string $client_id, array $sta
327329
'date_gmt' => $now,
328330
)
329331
);
332+
}
330333

331-
if ( false === $result ) {
332-
return false;
333-
}
334-
} elseif ( false === $updated ) {
334+
if ( false === $result ) {
335335
return false;
336336
}
337337

src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -373,8 +373,10 @@ private function can_user_collaborate_on_entity_type( string $entity_kind, strin
373373
return current_user_can( 'edit_comment', (int) $object_id );
374374
}
375375

376-
// All the remaining checks are for collections. If an object ID is provided,
377-
// reject the request.
376+
/*
377+
* All the remaining checks are for collections. If an object ID is
378+
* provided, reject the request.
379+
*/
378380
if ( null !== $object_id ) {
379381
return false;
380382
}
@@ -389,9 +391,11 @@ private function can_user_collaborate_on_entity_type( string $entity_kind, strin
389391
return current_user_can( $post_type_object->cap->edit_posts );
390392
}
391393

392-
// Collection collaboration does not exchange entity data. It only signals if
393-
// another user has updated an entity in the collection. Therefore, we only
394-
// compare against an allow list of collection types.
394+
/*
395+
* Collection collaboration does not exchange entity data. It only
396+
* signals if another user has updated an entity in the collection.
397+
* Therefore, we only compare against an allow list of collection types.
398+
*/
395399
$allowed_collection_entity_kinds = array(
396400
'postType',
397401
'root',
@@ -441,10 +445,12 @@ private function process_awareness_update( string $room, string $client_id, ?arr
441445
$response[ $entry['client_id'] ] = $entry['state'];
442446
}
443447

444-
// Other clients' states were decoded from the DB. Run the current
445-
// client's state through the same encode/decode path so the response
446-
// is consistent — wp_json_encode may normalize values (e.g. strip
447-
// invalid UTF-8) that would otherwise differ on the next poll.
448+
/*
449+
* Other clients' states were decoded from the DB. Run the current
450+
* client's state through the same encode/decode path so the response
451+
* is consistent — wp_json_encode may normalize values (e.g. strip
452+
* invalid UTF-8) that would otherwise differ on the next poll.
453+
*/
448454
if ( null !== $awareness_update ) {
449455
$response[ $client_id ] = json_decode( wp_json_encode( $awareness_update ), true );
450456
}
@@ -488,18 +494,20 @@ private function process_collaboration_update( string $room, string $client_id,
488494
}
489495

490496
if ( ! $has_newer_compaction ) {
491-
// Insert the compaction row before deleting old rows.
492-
// Reversing the order closes a race window where a
493-
// client joining with cursor=0 between the DELETE and
494-
// INSERT would see an empty room for one poll cycle.
495-
// The compaction row always has a higher ID than the
496-
// deleted rows, so cursor-based filtering is unaffected.
497+
/*
498+
* Insert the compaction row before deleting old rows.
499+
* Reversing the order closes a race window where a
500+
* client joining with cursor=0 between the DELETE and
501+
* INSERT would see an empty room for one poll cycle.
502+
* The compaction row always has a higher ID than the
503+
* deleted rows, so cursor-based filtering is unaffected.
504+
*/
497505
$insert_result = $this->add_update( $room, $client_id, $type, $data );
498506
if ( is_wp_error( $insert_result ) ) {
499507
return $insert_result;
500508
}
501509

502-
if ( ! $this->storage->remove_updates_before_cursor( $room, $cursor ) ) {
510+
if ( ! $this->storage->remove_updates_up_to_cursor( $room, $cursor ) ) {
503511
global $wpdb;
504512
$error_data = array( 'status' => 500 );
505513
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
@@ -515,8 +523,10 @@ private function process_collaboration_update( string $room, string $client_id,
515523
return true;
516524
}
517525

518-
// Reaching this point means there's a newer compaction, so we can
519-
// silently ignore this one.
526+
/*
527+
* Reaching this point means there's a newer compaction,
528+
* so we can silently ignore this one.
529+
*/
520530
return true;
521531

522532
case self::UPDATE_TYPE_SYNC_STEP1:

0 commit comments

Comments
 (0)