PluginProbe
Gutenberg / 23.2.2
Gutenberg v23.2.2
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.2.2, at lib/compat/wordpress-7.1/class-wp-sync-post-meta-storage.php

506 lines 13.5 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/ticket/63853).
248 * Since Gutenberg must support the two most recent WordPress
249 * versions (currently 6.8+), we cannot rely on it here.
250 */
251 $post_id = $posts[0] ?? null;
252 if ( is_int( $post_id ) ) {
253 self::$storage_post_ids[ $room_hash ] = $post_id;
254 return $post_id;
255 }
256
257 // Create new post for this room.
258 $post_id = wp_insert_post(
259 array(
260 'post_type' => self::POST_TYPE,
261 'post_status' => 'publish',
262 'post_title' => 'Sync Storage',
263 'post_name' => $room_hash,
264 )
265 );
266
267 if ( is_int( $post_id ) && $post_id > 0 ) {
268 $canonical_post_id = $this->resolve_canonical_storage_post_id_after_insert( $room_hash, $post_id );
269 if ( null === $canonical_post_id ) {
270 return null;
271 }
272
273 self::$storage_post_ids[ $room_hash ] = $canonical_post_id;
274 return $canonical_post_id;
275 }
276
277 return null;
278 }
279
280 /**
281 * Resolves the canonical room storage post after inserting a new post.
282 *
283 * Two concurrent first writers can both miss the lookup above and create
284 * storage posts for the same room hash. Depending on the exact interleaving,
285 * WordPress may create either a duplicate exact slug or a suffixed slug.
286 * When this request receives a non-canonical post, redirect it to the
287 * canonical storage before any sync or awareness data is written.
288 *
289 * @since 7.0.0
290 *
291 * @param string $room_hash MD5 hash of the room identifier.
292 * @param int $inserted_post_id Post ID returned by wp_insert_post().
293 * @return int|null Canonical storage post ID.
294 */
295 private function resolve_canonical_storage_post_id_after_insert( string $room_hash, int $inserted_post_id ): ?int {
296 $canonical_post_id = $this->find_canonical_storage_post_id( $room_hash );
297 if ( null === $canonical_post_id ) {
298 $canonical_post_id = $this->promote_storage_post_to_canonical_slug( $room_hash, $inserted_post_id );
299 }
300
301 if ( null === $canonical_post_id ) {
302 wp_delete_post( $inserted_post_id, true );
303 return null;
304 }
305
306 if ( $inserted_post_id !== $canonical_post_id ) {
307 /*
308 * This request just created a duplicate empty storage post because
309 * another first writer won the exact-slug race. Delete only that
310 * just-created empty post and write this request's data to canonical
311 * storage.
312 *
313 * Do not merge or delete older duplicate storage posts here. A stale
314 * request may already hold a duplicate post ID, and MySQL advisory
315 * locks/raw transactions are not a reliable cross-server fence under
316 * HyperDB or database proxies. Future historical repair should be
317 * bounded and idempotent, or run out of band with primary-pinned
318 * verification and a grace period before deleting duplicates.
319 */
320 wp_delete_post( $inserted_post_id, true );
321 }
322
323 return $canonical_post_id;
324 }
325
326 /**
327 * Finds the canonical storage post for a room hash.
328 *
329 * The canonical post is the oldest published storage post with the exact
330 * room hash slug. Suffixed slugs are repair candidates, not canonical.
331 *
332 * @since 7.0.0
333 *
334 * @param string $room_hash MD5 hash of the room identifier.
335 * @return int|null Canonical storage post ID.
336 */
337 private function find_canonical_storage_post_id( string $room_hash ): ?int {
338 $posts = get_posts(
339 array(
340 'post_type' => self::POST_TYPE,
341 'posts_per_page' => 1,
342 'post_status' => 'publish',
343 'name' => $room_hash,
344 'fields' => 'ids',
345 'orderby' => 'ID',
346 'order' => 'ASC',
347 )
348 );
349
350 if ( empty( $posts ) ) {
351 return null;
352 }
353
354 return $posts[0];
355 }
356
357 /**
358 * Promotes a storage post to the canonical room slug.
359 *
360 * @since 7.0.0
361 *
362 * @param string $room_hash MD5 hash of the room identifier.
363 * @param int $post_id Post ID to promote.
364 * @return int|null Promoted post ID on success.
365 */
366 private function promote_storage_post_to_canonical_slug( string $room_hash, int $post_id ): ?int {
367 global $wpdb;
368
369 /*
370 * @todo Could this be replaced by {@see wp_update_post()}? Could we experience
371 * a race with other posts having a different post type or post status?
372 */
373 $result = $wpdb->update(
374 $wpdb->posts,
375 array( 'post_name' => $room_hash ),
376 array(
377 'ID' => $post_id,
378 'post_type' => self::POST_TYPE,
379 'post_status' => 'publish',
380 ),
381 array( '%s' ),
382 array( '%d', '%s', '%s' )
383 );
384
385 if ( false === $result ) {
386 return null;
387 }
388
389 clean_post_cache( $post_id );
390 return $post_id;
391 }
392
393 /**
394 * Gets the number of updates stored for a given room.
395 *
396 * @since 7.0.0
397 *
398 * @param string $room Room identifier.
399 * @return int Number of updates stored for the room.
400 */
401 public function get_update_count( string $room ): int {
402 return $this->room_update_counts[ $room ] ?? 0;
403 }
404
405 /**
406 * Retrieves sync updates from a room after the given cursor.
407 *
408 * @since 7.0.0
409 *
410 * @global wpdb $wpdb WordPress database abstraction object.
411 *
412 * @param string $room Room identifier.
413 * @param int $cursor Return updates after this cursor (meta_id).
414 * @return array<int, mixed> Sync updates.
415 */
416 public function get_updates_after_cursor( string $room, int $cursor ): array {
417 global $wpdb;
418
419 $post_id = $this->get_storage_post_id( $room );
420 if ( null === $post_id ) {
421 $this->room_cursors[ $room ] = 0;
422 $this->room_update_counts[ $room ] = 0;
423 return array();
424 }
425
426 // Capture the current room state first so the returned cursor is race-safe.
427 $stats = $wpdb->get_row(
428 $wpdb->prepare(
429 "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",
430 $post_id,
431 self::SYNC_UPDATE_META_KEY
432 )
433 );
434
435 $total_updates = $stats ? (int) $stats->total_updates : 0;
436 $max_meta_id = $stats ? (int) $stats->max_meta_id : 0;
437
438 $this->room_update_counts[ $room ] = $total_updates;
439 $this->room_cursors[ $room ] = $max_meta_id;
440
441 if ( $max_meta_id <= $cursor ) {
442 return array();
443 }
444
445 $rows = $wpdb->get_results(
446 $wpdb->prepare(
447 "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",
448 $post_id,
449 self::SYNC_UPDATE_META_KEY,
450 $cursor,
451 $max_meta_id
452 )
453 );
454
455 if ( ! $rows ) {
456 return array();
457 }
458
459 $updates = array();
460 foreach ( $rows as $row ) {
461 $decoded = json_decode( $row->meta_value, true );
462 if ( null !== $decoded ) {
463 $updates[] = $decoded;
464 }
465 }
466
467 return $updates;
468 }
469
470 /**
471 * Removes updates from a room that are older than the given cursor.
472 *
473 * @since 7.0.0
474 *
475 * @global wpdb $wpdb WordPress database abstraction object.
476 *
477 * @param string $room Room identifier.
478 * @param int $cursor Remove updates with meta_id < this cursor.
479 * @return bool True on success, false on failure.
480 */
481 public function remove_updates_before_cursor( string $room, int $cursor ): bool {
482 global $wpdb;
483
484 $post_id = $this->get_storage_post_id( $room );
485 if ( null === $post_id ) {
486 return false;
487 }
488
489 $deleted_rows = $wpdb->query(
490 $wpdb->prepare(
491 "DELETE FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s AND meta_id < %d",
492 $post_id,
493 self::SYNC_UPDATE_META_KEY,
494 $cursor
495 )
496 );
497
498 if ( false === $deleted_rows ) {
499 return false;
500 }
501
502 return true;
503 }
504 }
505 }
506