PluginProbe
Gutenberg / 23.5.1
Gutenberg v23.5.1
23.9.1 23.9.0 23.8.0 23.7.2 23.7.1 23.7.0 23.6.1 23.6.2 23.6.0 23.5.3 23.5.2 23.5.1 23.5.0 23.4.0 23.3.2 23.3.1 23.3.0 23.2.0 23.2.1 23.2.2 23.1.1 23.1.0 23.0.1 12.6.0 7.4.0 All 402 releases
gutenberg / lib / compat / wordpress-7.1 / class-wp-sync-post-meta-storage.php

class-wp-sync-post-meta-storage.php in Gutenberg 23.5.1, at lib/compat/wordpress-7.1/class-wp-sync-post-meta-storage.php

504 lines 13.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WP_Sync_Post_Meta_Storage class
4 *
5 * @package gutenberg
6 */
7
8 if ( ! class_exists( 'WP_Sync_Post_Meta_Storage' ) ) {
9
10 /**
11 * Core class that provides an interface for storing and retrieving sync
12 * updates and awareness data during a collaborative session.
13 *
14 * Data is stored as post meta on a dedicated post per room of a custom post type.
15 *
16 * @since 7.0.0
17 *
18 * @access private
19 */
20 class WP_Sync_Post_Meta_Storage implements WP_Sync_Storage {
21 /**
22 * Post type for sync storage.
23 *
24 * @since 7.0.0
25 * @var string
26 */
27 const POST_TYPE = 'wp_sync_storage';
28
29 /**
30 * Meta key for awareness state.
31 *
32 * @since 7.0.0
33 * @var string
34 */
35 const AWARENESS_META_KEY = 'wp_sync_awareness_state';
36
37 /**
38 * Meta key for sync updates.
39 *
40 * @since 7.0.0
41 * @var string
42 */
43 const SYNC_UPDATE_META_KEY = 'wp_sync_update_data';
44
45 /**
46 * Cache of cursors by room.
47 *
48 * @since 7.0.0
49 * @var array<string, int>
50 */
51 private array $room_cursors = array();
52
53 /**
54 * Cache of update counts by room.
55 *
56 * @since 7.0.0
57 * @var array<string, int>
58 */
59 private array $room_update_counts = array();
60
61 /**
62 * Cache of storage post IDs by room hash.
63 *
64 * @since 7.0.0
65 * @var array<string, int>
66 */
67 private static array $storage_post_ids = array();
68
69 /**
70 * Adds a sync update to a given room.
71 *
72 * @since 7.0.0
73 *
74 * @global wpdb $wpdb WordPress database abstraction object.
75 *
76 * @param string $room Room identifier.
77 * @param mixed $update Sync update.
78 * @return bool True on success, false on failure.
79 */
80 public function add_update( string $room, $update ): bool {
81 global $wpdb;
82
83 $post_id = $this->get_storage_post_id( $room );
84 if ( null === $post_id ) {
85 return false;
86 }
87
88 // Use direct database operation to avoid cache invalidation performed by
89 // post meta functions (`wp_cache_set_posts_last_changed()` and direct
90 // `wp_cache_delete()` calls).
91 return (bool) $wpdb->insert(
92 $wpdb->postmeta,
93 array(
94 'post_id' => $post_id,
95 'meta_key' => self::SYNC_UPDATE_META_KEY,
96 'meta_value' => wp_json_encode( $update ),
97 ),
98 array( '%d', '%s', '%s' )
99 );
100 }
101
102 /**
103 * Gets awareness state for a given room.
104 *
105 * @since 7.0.0
106 *
107 * @global wpdb $wpdb WordPress database abstraction object.
108 *
109 * @param string $room Room identifier.
110 * @return array<int, mixed> Awareness state.
111 */
112 public function get_awareness_state( string $room ): array {
113 global $wpdb;
114
115 $post_id = $this->get_storage_post_id( $room );
116 if ( null === $post_id ) {
117 return array();
118 }
119
120 // Use direct database operation to avoid updating the post meta cache.
121 // ORDER BY meta_id DESC ensures the latest row wins if duplicates exist
122 // from a past race condition in set_awareness_state().
123 $meta_value = $wpdb->get_var(
124 $wpdb->prepare(
125 "SELECT meta_value FROM $wpdb->postmeta WHERE post_id = %d AND meta_key = %s ORDER BY meta_id DESC LIMIT 1",
126 $post_id,
127 self::AWARENESS_META_KEY
128 )
129 );
130
131 if ( null === $meta_value ) {
132 return array();
133 }
134
135 $awareness = json_decode( $meta_value, true );
136
137 if ( ! is_array( $awareness ) ) {
138 return array();
139 }
140
141 return array_values( $awareness );
142 }
143
144 /**
145 * Sets awareness state for a given room.
146 *
147 * @since 7.0.0
148 *
149 * @global wpdb $wpdb WordPress database abstraction object.
150 *
151 * @param string $room Room identifier.
152 * @param array<int, mixed> $awareness Serializable awareness state.
153 * @return bool True on success, false on failure.
154 */
155 public function set_awareness_state( string $room, array $awareness ): bool {
156 global $wpdb;
157
158 $post_id = $this->get_storage_post_id( $room );
159 if ( null === $post_id ) {
160 return false;
161 }
162
163 // Use direct database operation to avoid cache invalidation performed by
164 // post meta functions (`wp_cache_set_posts_last_changed()` and direct
165 // `wp_cache_delete()` calls).
166 //
167 // If two concurrent requests both see no row and both INSERT, the
168 // duplicate is harmless: get_awareness_state() reads the latest row
169 // (ORDER BY meta_id DESC).
170 $meta_id = $wpdb->get_var(
171 $wpdb->prepare(
172 "SELECT meta_id FROM $wpdb->postmeta WHERE post_id = %d AND meta_key = %s ORDER BY meta_id DESC LIMIT 1",
173 $post_id,
174 self::AWARENESS_META_KEY
175 )
176 );
177
178 if ( $meta_id ) {
179 return (bool) $wpdb->update(
180 $wpdb->postmeta,
181 array( 'meta_value' => wp_json_encode( $awareness ) ),
182 array( 'meta_id' => $meta_id ),
183 array( '%s' ),
184 array( '%d' )
185 );
186 }
187
188 return (bool) $wpdb->insert(
189 $wpdb->postmeta,
190 array(
191 'post_id' => $post_id,
192 'meta_key' => self::AWARENESS_META_KEY,
193 'meta_value' => wp_json_encode( $awareness ),
194 ),
195 array( '%d', '%s', '%s' )
196 );
197 }
198
199 /**
200 * Gets the current cursor for a given room.
201 *
202 * The cursor is set during get_updates_after_cursor() and represents the
203 * highest meta_id seen for the room's sync updates.
204 *
205 * @since 7.0.0
206 *
207 * @param string $room Room identifier.
208 * @return int Current cursor for the room.
209 */
210 public function get_cursor( string $room ): int {
211 return $this->room_cursors[ $room ] ?? 0;
212 }
213
214 /**
215 * Gets or creates the storage post for a given room.
216 *
217 * Each room gets its own dedicated post so that post meta cache
218 * invalidation is scoped to a single room rather than all of them.
219 *
220 * @since 7.0.0
221 *
222 * @param string $room Room identifier.
223 * @return int|null Post ID.
224 */
225 private function get_storage_post_id( string $room ): ?int {
226 $room_hash = md5( $room );
227
228 if ( isset( self::$storage_post_ids[ $room_hash ] ) ) {
229 return self::$storage_post_ids[ $room_hash ];
230 }
231
232 // Try to find an existing post for this room.
233 $posts = get_posts(
234 array(
235 'post_type' => self::POST_TYPE,
236 'posts_per_page' => 1,
237 'post_status' => 'publish',
238 'name' => $room_hash,
239 'fields' => 'ids',
240 'orderby' => 'ID',
241 'order' => 'ASC',
242 )
243 );
244
245 /*
246 * array_first() is a PHP 8.5 function. WordPress added
247 * a polyfill in WP 6.9 (see https://core.trac.wordpress.org/changeset/60672).
248 */
249 $post_id = array_first( $posts );
250 if ( is_int( $post_id ) ) {
251 self::$storage_post_ids[ $room_hash ] = $post_id;
252 return $post_id;
253 }
254
255 // Create new post for this room.
256 $post_id = wp_insert_post(
257 array(
258 'post_type' => self::POST_TYPE,
259 'post_status' => 'publish',
260 'post_title' => 'Sync Storage',
261 'post_name' => $room_hash,
262 )
263 );
264
265 if ( is_int( $post_id ) && $post_id > 0 ) {
266 $canonical_post_id = $this->resolve_canonical_storage_post_id_after_insert( $room_hash, $post_id );
267 if ( null === $canonical_post_id ) {
268 return null;
269 }
270
271 self::$storage_post_ids[ $room_hash ] = $canonical_post_id;
272 return $canonical_post_id;
273 }
274
275 return null;
276 }
277
278 /**
279 * Resolves the canonical room storage post after inserting a new post.
280 *
281 * Two concurrent first writers can both miss the lookup above and create
282 * storage posts for the same room hash. Depending on the exact interleaving,
283 * WordPress may create either a duplicate exact slug or a suffixed slug.
284 * When this request receives a non-canonical post, redirect it to the
285 * canonical storage before any sync or awareness data is written.
286 *
287 * @since 7.0.0
288 *
289 * @param string $room_hash MD5 hash of the room identifier.
290 * @param int $inserted_post_id Post ID returned by wp_insert_post().
291 * @return int|null Canonical storage post ID.
292 */
293 private function resolve_canonical_storage_post_id_after_insert( string $room_hash, int $inserted_post_id ): ?int {
294 $canonical_post_id = $this->find_canonical_storage_post_id( $room_hash );
295 if ( null === $canonical_post_id ) {
296 $canonical_post_id = $this->promote_storage_post_to_canonical_slug( $room_hash, $inserted_post_id );
297 }
298
299 if ( null === $canonical_post_id ) {
300 wp_delete_post( $inserted_post_id, true );
301 return null;
302 }
303
304 if ( $inserted_post_id !== $canonical_post_id ) {
305 /*
306 * This request just created a duplicate empty storage post because
307 * another first writer won the exact-slug race. Delete only that
308 * just-created empty post and write this request's data to canonical
309 * storage.
310 *
311 * Do not merge or delete older duplicate storage posts here. A stale
312 * request may already hold a duplicate post ID, and MySQL advisory
313 * locks/raw transactions are not a reliable cross-server fence under
314 * HyperDB or database proxies. Future historical repair should be
315 * bounded and idempotent, or run out of band with primary-pinned
316 * verification and a grace period before deleting duplicates.
317 */
318 wp_delete_post( $inserted_post_id, true );
319 }
320
321 return $canonical_post_id;
322 }
323
324 /**
325 * Finds the canonical storage post for a room hash.
326 *
327 * The canonical post is the oldest published storage post with the exact
328 * room hash slug. Suffixed slugs are repair candidates, not canonical.
329 *
330 * @since 7.0.0
331 *
332 * @param string $room_hash MD5 hash of the room identifier.
333 * @return int|null Canonical storage post ID.
334 */
335 private function find_canonical_storage_post_id( string $room_hash ): ?int {
336 $posts = get_posts(
337 array(
338 'post_type' => self::POST_TYPE,
339 'posts_per_page' => 1,
340 'post_status' => 'publish',
341 'name' => $room_hash,
342 'fields' => 'ids',
343 'orderby' => 'ID',
344 'order' => 'ASC',
345 )
346 );
347
348 if ( empty( $posts ) ) {
349 return null;
350 }
351
352 return $posts[0];
353 }
354
355 /**
356 * Promotes a storage post to the canonical room slug.
357 *
358 * @since 7.0.0
359 *
360 * @param string $room_hash MD5 hash of the room identifier.
361 * @param int $post_id Post ID to promote.
362 * @return int|null Promoted post ID on success.
363 */
364 private function promote_storage_post_to_canonical_slug( string $room_hash, int $post_id ): ?int {
365 global $wpdb;
366
367 /*
368 * @todo Could this be replaced by {@see wp_update_post()}? Could we experience
369 * a race with other posts having a different post type or post status?
370 */
371 $result = $wpdb->update(
372 $wpdb->posts,
373 array( 'post_name' => $room_hash ),
374 array(
375 'ID' => $post_id,
376 'post_type' => self::POST_TYPE,
377 'post_status' => 'publish',
378 ),
379 array( '%s' ),
380 array( '%d', '%s', '%s' )
381 );
382
383 if ( false === $result ) {
384 return null;
385 }
386
387 clean_post_cache( $post_id );
388 return $post_id;
389 }
390
391 /**
392 * Gets the number of updates stored for a given room.
393 *
394 * @since 7.0.0
395 *
396 * @param string $room Room identifier.
397 * @return int Number of updates stored for the room.
398 */
399 public function get_update_count( string $room ): int {
400 return $this->room_update_counts[ $room ] ?? 0;
401 }
402
403 /**
404 * Retrieves sync updates from a room after the given cursor.
405 *
406 * @since 7.0.0
407 *
408 * @global wpdb $wpdb WordPress database abstraction object.
409 *
410 * @param string $room Room identifier.
411 * @param int $cursor Return updates after this cursor (meta_id).
412 * @return array<int, mixed> Sync updates.
413 */
414 public function get_updates_after_cursor( string $room, int $cursor ): array {
415 global $wpdb;
416
417 $post_id = $this->get_storage_post_id( $room );
418 if ( null === $post_id ) {
419 $this->room_cursors[ $room ] = 0;
420 $this->room_update_counts[ $room ] = 0;
421 return array();
422 }
423
424 // Capture the current room state first so the returned cursor is race-safe.
425 $stats = $wpdb->get_row(
426 $wpdb->prepare(
427 "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",
428 $post_id,
429 self::SYNC_UPDATE_META_KEY
430 )
431 );
432
433 $total_updates = $stats ? (int) $stats->total_updates : 0;
434 $max_meta_id = $stats ? (int) $stats->max_meta_id : 0;
435
436 $this->room_update_counts[ $room ] = $total_updates;
437 $this->room_cursors[ $room ] = $max_meta_id;
438
439 if ( $max_meta_id <= $cursor ) {
440 return array();
441 }
442
443 $rows = $wpdb->get_results(
444 $wpdb->prepare(
445 "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",
446 $post_id,
447 self::SYNC_UPDATE_META_KEY,
448 $cursor,
449 $max_meta_id
450 )
451 );
452
453 if ( ! $rows ) {
454 return array();
455 }
456
457 $updates = array();
458 foreach ( $rows as $row ) {
459 $decoded = json_decode( $row->meta_value, true );
460 if ( null !== $decoded ) {
461 $updates[] = $decoded;
462 }
463 }
464
465 return $updates;
466 }
467
468 /**
469 * Removes updates from a room that are older than the given cursor.
470 *
471 * @since 7.0.0
472 *
473 * @global wpdb $wpdb WordPress database abstraction object.
474 *
475 * @param string $room Room identifier.
476 * @param int $cursor Remove updates with meta_id < this cursor.
477 * @return bool True on success, false on failure.
478 */
479 public function remove_updates_before_cursor( string $room, int $cursor ): bool {
480 global $wpdb;
481
482 $post_id = $this->get_storage_post_id( $room );
483 if ( null === $post_id ) {
484 return false;
485 }
486
487 $deleted_rows = $wpdb->query(
488 $wpdb->prepare(
489 "DELETE FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s AND meta_id < %d",
490 $post_id,
491 self::SYNC_UPDATE_META_KEY,
492 $cursor
493 )
494 );
495
496 if ( false === $deleted_rows ) {
497 return false;
498 }
499
500 return true;
501 }
502 }
503 }
504