Skip to content

Commit 808604e

Browse files
committed
Collaboration: Replace post meta storage with dedicated database table
Backport of WordPress/wordpress-develop#11256. Replaces WP_Sync_Post_Meta_Storage / WP_Sync_Storage / WP_HTTP_Polling_Sync_Server with WP_Collaboration_Table_Storage / WP_HTTP_Polling_Collaboration_Server backed by a dedicated `wp_collaboration` table. Key changes: - New `wp_collaboration` table created via dbDelta in lib/upgrade.php - Table creation also exposed as `gutenberg_create_collaboration_table` action hook for WP-CLI usage - Storage uses per-client awareness rows (eliminates race condition) - Awareness reads served from persistent object cache with DB fallback - REST namespace changed to wp-collaboration/v1 with wp-sync/v1 alias - Payload limits: 16 MB body, 50 rooms/request, 1 MB per update - Permission hardening: post type mismatch check, non-numeric ID rejection - Compaction insert-before-delete to close new-client race window - Cron cleanup for stale data (daily, 7-day sync / 60-second awareness)
1 parent e88a273 commit 808604e

File tree

8 files changed

+1224
-1350
lines changed

8 files changed

+1224
-1350
lines changed
Lines changed: 387 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,387 @@
1+
<?php
2+
/**
3+
* WP_Collaboration_Table_Storage class
4+
*
5+
* @package gutenberg
6+
* @since 7.0.0
7+
*/
8+
9+
if ( ! class_exists( 'WP_Collaboration_Table_Storage' ) ) {
10+
11+
/**
12+
* Core class that provides an interface for storing and retrieving
13+
* updates and awareness data during a collaborative session.
14+
*
15+
* All data is stored in the single `collaboration` database table,
16+
* discriminated by the `type` column. Awareness reads are served from
17+
* the persistent object cache when available, falling back to the
18+
* database — similar to the transient pattern but without wp_options.
19+
*
20+
* This class intentionally fires no actions or filters. Collaboration
21+
* queries run on every poll (0.5–1 s per editor tab), so hook overhead
22+
* would degrade the real-time editing loop for all active sessions.
23+
*
24+
* @since 7.0.0
25+
*
26+
* @access private
27+
*
28+
* @phpstan-type AwarenessState array{client_id: string, state: array<string, mixed>, user_id: int}
29+
*/
30+
class WP_Collaboration_Table_Storage {
31+
/**
32+
* Cache of cursors by room.
33+
*
34+
* @since 7.0.0
35+
* @var array<string, int>
36+
*/
37+
private array $room_cursors = array();
38+
39+
/**
40+
* Cache of update counts by room.
41+
*
42+
* @since 7.0.0
43+
* @var array<string, int>
44+
*/
45+
private array $room_update_counts = array();
46+
47+
/**
48+
* Adds an update to a given room.
49+
*
50+
* @since 7.0.0
51+
*
52+
* @global wpdb $wpdb WordPress database abstraction object.
53+
*
54+
* @param string $room Room identifier.
55+
* @param mixed $update Update data.
56+
* @return bool True on success, false on failure.
57+
*/
58+
public function add_update( string $room, $update ): bool {
59+
global $wpdb;
60+
61+
$result = $wpdb->insert(
62+
$wpdb->collaboration,
63+
array(
64+
'room' => $room,
65+
'type' => $update['type'] ?? '',
66+
'client_id' => $update['client_id'] ?? '',
67+
'data' => wp_json_encode( $update ),
68+
'date_gmt' => gmdate( 'Y-m-d H:i:s' ),
69+
'user_id' => get_current_user_id(),
70+
),
71+
array( '%s', '%s', '%s', '%s', '%s', '%d' )
72+
);
73+
74+
return false !== $result;
75+
}
76+
77+
/**
78+
* Gets awareness state for a given room.
79+
*
80+
* Checks the persistent object cache first. On a cache miss, queries
81+
* the collaboration table for awareness rows and primes the cache
82+
* with the result. When no persistent cache is available the in-memory
83+
* WP_Object_Cache is used, which provides no cross-request benefit
84+
* but keeps the code path identical.
85+
*
86+
* Expired rows are filtered by the WHERE clause on cache miss;
87+
* actual deletion is handled by cron via
88+
* gutenberg_delete_old_collaboration_data().
89+
*
90+
* @since 7.0.0
91+
*
92+
* @global wpdb $wpdb WordPress database abstraction object.
93+
*
94+
* @param string $room Room identifier.
95+
* @param int $timeout Seconds before an awareness entry is considered expired.
96+
* @return array<int, array> Awareness entries.
97+
* @phpstan-return list<AwarenessState>
98+
*/
99+
public function get_awareness_state( string $room, int $timeout = 30 ): array {
100+
global $wpdb;
101+
102+
$cache_key = 'awareness:' . str_replace( '/', ':', $room );
103+
$cached = wp_cache_get( $cache_key, 'collaboration' );
104+
105+
if ( false !== $cached ) {
106+
return $cached;
107+
}
108+
109+
$cutoff = gmdate( 'Y-m-d H:i:s', time() - $timeout );
110+
111+
$rows = $wpdb->get_results(
112+
$wpdb->prepare(
113+
"SELECT client_id, user_id, data FROM {$wpdb->collaboration} WHERE room = %s AND type = 'awareness' AND date_gmt >= %s",
114+
$room,
115+
$cutoff
116+
)
117+
);
118+
119+
if ( ! is_array( $rows ) ) {
120+
return array();
121+
}
122+
123+
$entries = array();
124+
foreach ( $rows as $row ) {
125+
$decoded = json_decode( $row->data, true );
126+
if ( is_array( $decoded ) ) {
127+
$entries[] = array(
128+
'client_id' => $row->client_id,
129+
'state' => $decoded,
130+
'user_id' => (int) $row->user_id,
131+
);
132+
}
133+
}
134+
135+
wp_cache_set( $cache_key, $entries, 'collaboration', $timeout );
136+
137+
return $entries;
138+
}
139+
140+
/**
141+
* Gets the current cursor for a given room.
142+
*
143+
* The cursor is set during get_updates_after_cursor() and represents the
144+
* maximum row ID at the time updates were retrieved.
145+
*
146+
* @since 7.0.0
147+
*
148+
* @param string $room Room identifier.
149+
* @return int Current cursor for the room.
150+
*/
151+
public function get_cursor( string $room ): int {
152+
return $this->room_cursors[ $room ] ?? 0;
153+
}
154+
155+
/**
156+
* Gets the number of updates stored for a given room.
157+
*
158+
* @since 7.0.0
159+
*
160+
* @param string $room Room identifier.
161+
* @return int Number of updates stored for the room.
162+
*/
163+
public function get_update_count( string $room ): int {
164+
return $this->room_update_counts[ $room ] ?? 0;
165+
}
166+
167+
/**
168+
* Retrieves updates from a room after a given cursor.
169+
*
170+
* @since 7.0.0
171+
*
172+
* @global wpdb $wpdb WordPress database abstraction object.
173+
*
174+
* @param string $room Room identifier.
175+
* @param int $cursor Return updates after this cursor.
176+
* @return array<int, mixed> Updates.
177+
*/
178+
public function get_updates_after_cursor( string $room, int $cursor ): array {
179+
global $wpdb;
180+
181+
/*
182+
* Uses a snapshot approach: captures MAX(id) and COUNT(*) in a single
183+
* query, then fetches rows WHERE id > cursor AND id <= max_id. Updates
184+
* arriving after the snapshot are deferred to the next poll, never lost.
185+
*
186+
* Only retrieves non-awareness rows — awareness rows are handled
187+
* separately via get_awareness_state().
188+
*/
189+
190+
/* Snapshot the current max ID and total row count in a single query. */
191+
$snapshot = $wpdb->get_row(
192+
$wpdb->prepare(
193+
"SELECT COALESCE( MAX( id ), 0 ) AS max_id, COUNT(*) AS total FROM {$wpdb->collaboration} WHERE room = %s AND type != 'awareness'",
194+
$room
195+
)
196+
);
197+
198+
if ( ! $snapshot ) {
199+
$this->room_cursors[ $room ] = 0;
200+
$this->room_update_counts[ $room ] = 0;
201+
return array();
202+
}
203+
204+
$max_id = (int) $snapshot->max_id;
205+
$total = (int) $snapshot->total;
206+
207+
$this->room_cursors[ $room ] = $max_id;
208+
209+
if ( 0 === $max_id || $max_id <= $cursor ) {
210+
/*
211+
* Preserve the real row count so the server can still
212+
* trigger compaction when updates have accumulated but
213+
* no new ones arrived since the client's last poll.
214+
*/
215+
$this->room_update_counts[ $room ] = $total;
216+
return array();
217+
}
218+
219+
$this->room_update_counts[ $room ] = $total;
220+
221+
/* Fetch updates after the cursor up to the snapshot boundary. */
222+
$rows = $wpdb->get_results(
223+
$wpdb->prepare(
224+
"SELECT data FROM {$wpdb->collaboration} WHERE room = %s AND type != 'awareness' AND id > %d AND id <= %d ORDER BY id ASC",
225+
$room,
226+
$cursor,
227+
$max_id
228+
)
229+
);
230+
231+
if ( ! is_array( $rows ) ) {
232+
return array();
233+
}
234+
235+
$updates = array();
236+
foreach ( $rows as $row ) {
237+
$decoded = json_decode( $row->data, true );
238+
if ( is_array( $decoded ) ) {
239+
$updates[] = $decoded;
240+
}
241+
}
242+
243+
return $updates;
244+
}
245+
246+
/**
247+
* Removes updates from a room up to and including the given cursor.
248+
*
249+
* @since 7.0.0
250+
*
251+
* @global wpdb $wpdb WordPress database abstraction object.
252+
*
253+
* @param string $room Room identifier.
254+
* @param int $cursor Remove updates up to and including this cursor.
255+
* @return bool True on success, false on failure.
256+
*/
257+
public function remove_updates_through_cursor( string $room, int $cursor ): bool {
258+
global $wpdb;
259+
260+
// Uses a single atomic DELETE query, avoiding the race-prone
261+
// "delete all, re-add some" pattern.
262+
$result = $wpdb->query(
263+
$wpdb->prepare(
264+
"DELETE FROM {$wpdb->collaboration} WHERE room = %s AND type != 'awareness' AND id <= %d",
265+
$room,
266+
$cursor
267+
)
268+
);
269+
270+
return false !== $result;
271+
}
272+
273+
/**
274+
* Sets awareness state for a given client in a room.
275+
*
276+
* Uses SELECT-then-UPDATE/INSERT: checks for an existing row by
277+
* primary key, then updates or inserts accordingly. Each client
278+
* writes only its own row, eliminating the race condition inherent
279+
* in shared-state approaches.
280+
*
281+
* After writing, the cached awareness entries for the room are updated
282+
* in-place so that subsequent get_awareness_state() calls from other
283+
* clients hit the cache instead of the database. This is application-
284+
* level deduplication: the shared collaboration table cannot carry a
285+
* UNIQUE KEY on (room, client_id) because sync rows need multiple
286+
* entries per room+client pair.
287+
*
288+
* @since 7.0.0
289+
*
290+
* @global wpdb $wpdb WordPress database abstraction object.
291+
*
292+
* @param string $room Room identifier.
293+
* @param string $client_id Client identifier.
294+
* @param array<string, mixed> $state Serializable awareness state for this client.
295+
* @param int $user_id WordPress user ID that owns this client.
296+
* @return bool True on success, false on failure.
297+
*/
298+
public function set_awareness_state( string $room, string $client_id, array $state, int $user_id ): bool {
299+
global $wpdb;
300+
301+
$data = wp_json_encode( $state );
302+
303+
/*
304+
* Bucket the timestamp to 5-second intervals so most polls
305+
* short-circuit without a database write. Ceil is used instead
306+
* of floor to prevent the awareness timeout from being hit early.
307+
*/
308+
$now = gmdate( 'Y-m-d H:i:s', (int) ceil( time() / 5 ) * 5 );
309+
310+
/* Check if a row already exists. */
311+
$exists = $wpdb->get_row(
312+
$wpdb->prepare(
313+
"SELECT id, date_gmt FROM {$wpdb->collaboration} WHERE room = %s AND type = 'awareness' AND client_id = %s LIMIT 1",
314+
$room,
315+
$client_id
316+
)
317+
);
318+
319+
if ( $exists && $exists->date_gmt === $now ) {
320+
// Row already has the current date, consider update a success.
321+
return true;
322+
}
323+
324+
if ( $exists ) {
325+
$result = $wpdb->update(
326+
$wpdb->collaboration,
327+
array(
328+
'user_id' => $user_id,
329+
'data' => $data,
330+
'date_gmt' => $now,
331+
),
332+
array( 'id' => $exists->id )
333+
);
334+
} else {
335+
$result = $wpdb->insert(
336+
$wpdb->collaboration,
337+
array(
338+
'room' => $room,
339+
'type' => 'awareness',
340+
'client_id' => $client_id,
341+
'user_id' => $user_id,
342+
'data' => $data,
343+
'date_gmt' => $now,
344+
)
345+
);
346+
}
347+
348+
if ( false === $result ) {
349+
return false;
350+
}
351+
352+
/*
353+
* Update the cached entries in-place so the next reader in this
354+
* room gets a cache hit with fresh data. If the cache is cold,
355+
* skip — the next get_awareness_state() call will prime it.
356+
*/
357+
$cache_key = 'awareness:' . str_replace( '/', ':', $room );
358+
$cached = wp_cache_get( $cache_key, 'collaboration' );
359+
360+
if ( false !== $cached ) {
361+
$normalized_state = json_decode( $data, true );
362+
$found = false;
363+
364+
foreach ( $cached as $i => $entry ) {
365+
if ( $client_id === $entry['client_id'] ) {
366+
$cached[ $i ]['state'] = $normalized_state;
367+
$cached[ $i ]['user_id'] = $user_id;
368+
$found = true;
369+
break;
370+
}
371+
}
372+
373+
if ( ! $found ) {
374+
$cached[] = array(
375+
'client_id' => $client_id,
376+
'state' => $normalized_state,
377+
'user_id' => $user_id,
378+
);
379+
}
380+
381+
wp_cache_set( $cache_key, $cached, 'collaboration', 30 );
382+
}
383+
384+
return true;
385+
}
386+
}
387+
}

0 commit comments

Comments
 (0)