Skip to content

Commit 7045bed

Browse files
Dan Luudmsnell
andcommitted
RTC: Ensure a single canonical update log for collaborative edits.
Trac ticket: Core-65138 Backport of WordPress/gutenberg#77675 A race condition on opening an editor session allows for Core to create duplicate post meta for sync storage. This creates two copies of the document which the editors operate on independently, leading to a mismatch between the sessions. In this patch, when such a duplicate storage row is detected, a canonical version of the post meta is chosen (the one with the lowest id viz. the oldest one) and the duplicate is merged into it. This should ensure that all edit sessions for a given post use the same synchronized backing store. Co-authored-by: Dennis Snell <[email protected]>
1 parent 93d77a2 commit 7045bed

2 files changed

Lines changed: 371 additions & 8 deletions

File tree

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

Lines changed: 198 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ public function add_update( string $room, $update ): bool {
8686
// Use direct database operation to avoid cache invalidation performed by
8787
// post meta functions (`wp_cache_set_posts_last_changed()` and direct
8888
// `wp_cache_delete()` calls).
89-
return (bool) $wpdb->insert(
89+
$result = $wpdb->insert(
9090
$wpdb->postmeta,
9191
array(
9292
'post_id' => $post_id,
@@ -95,6 +95,13 @@ public function add_update( string $room, $update ): bool {
9595
),
9696
array( '%d', '%s', '%s' )
9797
);
98+
99+
if ( $result ) {
100+
$room_hash = md5( $room );
101+
self::$storage_post_ids[ $room_hash ] = $this->merge_duplicate_storage_posts( $room_hash, $post_id );
102+
}
103+
104+
return (bool) $result;
98105
}
99106

100107
/**
@@ -153,7 +160,8 @@ public function get_awareness_state( string $room ): array {
153160
public function set_awareness_state( string $room, array $awareness ): bool {
154161
global $wpdb;
155162

156-
$post_id = $this->get_storage_post_id( $room );
163+
$room_hash = md5( $room );
164+
$post_id = $this->get_storage_post_id( $room );
157165
if ( null === $post_id ) {
158166
return false;
159167
}
@@ -174,16 +182,22 @@ public function set_awareness_state( string $room, array $awareness ): bool {
174182
);
175183

176184
if ( $meta_id ) {
177-
return (bool) $wpdb->update(
185+
$result = $wpdb->update(
178186
$wpdb->postmeta,
179187
array( 'meta_value' => wp_json_encode( $awareness ) ),
180188
array( 'meta_id' => $meta_id ),
181189
array( '%s' ),
182190
array( '%d' )
183191
);
192+
193+
if ( false !== $result ) {
194+
self::$storage_post_ids[ $room_hash ] = $this->merge_duplicate_storage_posts( $room_hash, $post_id );
195+
}
196+
197+
return false !== $result;
184198
}
185199

186-
return (bool) $wpdb->insert(
200+
$result = $wpdb->insert(
187201
$wpdb->postmeta,
188202
array(
189203
'post_id' => $post_id,
@@ -192,6 +206,12 @@ public function set_awareness_state( string $room, array $awareness ): bool {
192206
),
193207
array( '%d', '%s', '%s' )
194208
);
209+
210+
if ( $result ) {
211+
self::$storage_post_ids[ $room_hash ] = $this->merge_duplicate_storage_posts( $room_hash, $post_id );
212+
}
213+
214+
return (bool) $result;
195215
}
196216

197217
/**
@@ -256,14 +276,185 @@ private function get_storage_post_id( string $room ): ?int {
256276
)
257277
);
258278

259-
if ( is_int( $post_id ) ) {
260-
self::$storage_post_ids[ $room_hash ] = $post_id;
261-
return $post_id;
279+
if ( is_int( $post_id ) && $post_id > 0 ) {
280+
$canonical_post_id = $this->resolve_canonical_storage_post_id_after_insert( $room_hash, $post_id );
281+
if ( null === $canonical_post_id ) {
282+
return null;
283+
}
284+
285+
self::$storage_post_ids[ $room_hash ] = $canonical_post_id;
286+
return $canonical_post_id;
262287
}
263288

264289
return null;
265290
}
266291

292+
/**
293+
* Resolves the canonical room storage post after inserting a new post.
294+
*
295+
* Two concurrent first writers can both miss the lookup above and create
296+
* storage posts for the same room hash. Depending on the exact interleaving,
297+
* WordPress may create either a duplicate exact slug or a suffixed slug.
298+
* When that happens, merge everything back into one canonical lineage.
299+
*
300+
* @since 7.0.0
301+
*
302+
* @param string $room_hash MD5 hash of the room identifier.
303+
* @param int $inserted_post_id Post ID returned by wp_insert_post().
304+
* @return int|null Canonical storage post ID.
305+
*/
306+
private function resolve_canonical_storage_post_id_after_insert( string $room_hash, int $inserted_post_id ): ?int {
307+
$canonical_post_id = $this->find_canonical_storage_post_id( $room_hash );
308+
if ( null === $canonical_post_id ) {
309+
$canonical_post_id = $this->promote_storage_post_to_canonical_slug( $room_hash, $inserted_post_id );
310+
}
311+
312+
if ( null === $canonical_post_id ) {
313+
wp_delete_post( $inserted_post_id, true );
314+
return null;
315+
}
316+
317+
return $this->merge_duplicate_storage_posts( $room_hash, $canonical_post_id );
318+
}
319+
320+
/**
321+
* Merges duplicate storage posts created by a first-access race.
322+
*
323+
* @since 7.0.0
324+
*
325+
* @param string $room_hash MD5 hash of the room identifier.
326+
* @param int $canonical_post_id Preferred post ID that should own the room.
327+
* @return int Canonical storage post ID.
328+
*/
329+
private function merge_duplicate_storage_posts( string $room_hash, int $canonical_post_id ): int {
330+
global $wpdb;
331+
332+
$storage_post_ids = $this->get_storage_post_ids_for_room_hash( $room_hash );
333+
if ( empty( $storage_post_ids ) ) {
334+
return $canonical_post_id;
335+
}
336+
337+
$exact_post_id = $this->find_canonical_storage_post_id( $room_hash );
338+
if ( null === $exact_post_id ) {
339+
$canonical_post_id = in_array( $canonical_post_id, $storage_post_ids, true ) ? $canonical_post_id : (int) $storage_post_ids[0];
340+
$promoted_post_id = $this->promote_storage_post_to_canonical_slug( $room_hash, $canonical_post_id );
341+
if ( null === $promoted_post_id ) {
342+
return $canonical_post_id;
343+
}
344+
345+
$canonical_post_id = $promoted_post_id;
346+
$storage_post_ids = $this->get_storage_post_ids_for_room_hash( $room_hash );
347+
} else {
348+
$canonical_post_id = $exact_post_id;
349+
}
350+
351+
foreach ( $storage_post_ids as $duplicate_id ) {
352+
if ( $canonical_post_id === $duplicate_id ) {
353+
continue;
354+
}
355+
356+
$move_result = $wpdb->update(
357+
$wpdb->postmeta,
358+
array( 'post_id' => $canonical_post_id ),
359+
array( 'post_id' => $duplicate_id ),
360+
array( '%d' ),
361+
array( '%d' )
362+
);
363+
364+
if ( false === $move_result ) {
365+
continue;
366+
}
367+
368+
wp_delete_post( $duplicate_id, true );
369+
}
370+
371+
return $canonical_post_id;
372+
}
373+
374+
/**
375+
* Finds the canonical storage post for a room hash.
376+
*
377+
* The canonical post is the oldest published storage post with the exact
378+
* room hash slug. Suffixed slugs are repair candidates, not canonical.
379+
*
380+
* @since 7.0.0
381+
*
382+
* @param string $room_hash MD5 hash of the room identifier.
383+
* @return int|null Canonical storage post ID.
384+
*/
385+
private function find_canonical_storage_post_id( string $room_hash ): ?int {
386+
global $wpdb;
387+
388+
$post_id = $wpdb->get_var(
389+
$wpdb->prepare(
390+
"SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'publish' AND post_name = %s ORDER BY ID ASC LIMIT 1",
391+
self::POST_TYPE,
392+
$room_hash
393+
)
394+
);
395+
396+
return is_numeric( $post_id ) ? (int) $post_id : null;
397+
}
398+
399+
/**
400+
* Promotes a storage post to the canonical room slug.
401+
*
402+
* @since 7.0.0
403+
*
404+
* @param string $room_hash MD5 hash of the room identifier.
405+
* @param int $post_id Post ID to promote.
406+
* @return int|null Promoted post ID on success.
407+
*/
408+
private function promote_storage_post_to_canonical_slug( string $room_hash, int $post_id ): ?int {
409+
global $wpdb;
410+
411+
$result = $wpdb->update(
412+
$wpdb->posts,
413+
array( 'post_name' => $room_hash ),
414+
array(
415+
'ID' => $post_id,
416+
'post_type' => self::POST_TYPE,
417+
'post_status' => 'publish',
418+
),
419+
array( '%s' ),
420+
array( '%d', '%s', '%s' )
421+
);
422+
423+
if ( false === $result ) {
424+
return null;
425+
}
426+
427+
clean_post_cache( $post_id );
428+
return $post_id;
429+
}
430+
431+
/**
432+
* Lists storage posts belonging to a room hash, including suffixed duplicates.
433+
*
434+
* @since 7.0.0
435+
*
436+
* @param string $room_hash MD5 hash of the room identifier.
437+
* @return array<int> Storage post IDs.
438+
*/
439+
private function get_storage_post_ids_for_room_hash( string $room_hash ): array {
440+
global $wpdb;
441+
442+
$post_ids = $wpdb->get_col(
443+
$wpdb->prepare(
444+
"SELECT ID FROM {$wpdb->posts}
445+
WHERE post_type = %s
446+
AND post_status = 'publish'
447+
AND ( post_name = %s OR post_name LIKE %s )
448+
ORDER BY ID ASC",
449+
self::POST_TYPE,
450+
$room_hash,
451+
$wpdb->esc_like( $room_hash . '-' ) . '%'
452+
)
453+
);
454+
455+
return array_map( 'intval', $post_ids );
456+
}
457+
267458
/**
268459
* Gets the number of updates stored for a given room.
269460
*

0 commit comments

Comments
 (0)