@@ -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