From 2b560d40f87e038cae3efbc1451095e287759dc5 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Tue, 17 Feb 2026 23:40:44 -0500 Subject: [PATCH 1/4] Use compaction request as signal for full state update --- .../class-wp-http-polling-sync-server.php | 15 +++---- .../sync/src/providers/http-polling/README.md | 16 +++----- .../providers/http-polling/polling-manager.ts | 39 +++++-------------- .../sync/src/providers/http-polling/types.ts | 2 +- 4 files changed, 23 insertions(+), 49 deletions(-) diff --git a/lib/compat/wordpress-7.0/class-wp-http-polling-sync-server.php b/lib/compat/wordpress-7.0/class-wp-http-polling-sync-server.php index 83dc8d9e619f50..680857b1749b2c 100644 --- a/lib/compat/wordpress-7.0/class-wp-http-polling-sync-server.php +++ b/lib/compat/wordpress-7.0/class-wp-http-polling-sync-server.php @@ -434,17 +434,14 @@ private function get_updates( string $room, int $client_id, int $cursor, bool $i } // Determine if this client should perform compaction. - $compaction_request = null; - if ( $is_compactor && $total_updates > self::COMPACTION_THRESHOLD ) { - $compaction_request = $updates_after_cursor; - } + $should_compact = $is_compactor && $total_updates > self::COMPACTION_THRESHOLD; return array( - 'compaction_request' => $compaction_request, - 'end_cursor' => $this->storage->get_cursor( $room ), - 'room' => $room, - 'total_updates' => $total_updates, - 'updates' => $typed_updates, + 'end_cursor' => $this->storage->get_cursor( $room ), + 'room' => $room, + 'should_compact' => $should_compact, + 'total_updates' => $total_updates, + 'updates' => $typed_updates, ); } } diff --git a/packages/sync/src/providers/http-polling/README.md b/packages/sync/src/providers/http-polling/README.md index 42d729d4758e4a..d48d0cfc251d9e 100644 --- a/packages/sync/src/providers/http-polling/README.md +++ b/packages/sync/src/providers/http-polling/README.md @@ -55,7 +55,7 @@ Updates are tagged with a type to enable different server-side handling: | `sync_step1` | State vector announcement | Stored, delivered to other clients | | `sync_step2` | Missing updates response | Stored, delivered to other clients | | `update` | Regular document change | Stored until compacted | -| `compaction` | Merged updates via Y.mergeUpdates | Clears older updates, then stored | +| `compaction` | Full document state via Y.encodeStateAsUpdate | Clears older updates, then stored | ## Data Flow @@ -109,13 +109,9 @@ To prevent unbounded message growth, the server coordinates compaction: 1. **Threshold reached**: Server detects >50 stored updates for a room 2. **Client selection**: Server nominates the lowest active client ID -3. **Compaction request**: Server sends all updates to the nominated client via `compaction_request` -4. **Client merges**: Uses `Y.mergeUpdates()` to combine all updates, preserving operation metadata -5. **Client sends compaction**: The merged update replaces older updates on the server - -**Why Y.mergeUpdates instead of Y.encodeStateAsUpdate?** - -`Y.mergeUpdates()` preserves the original operation metadata (client IDs, logical clocks). This allows Yjs to correctly deduplicate when a compaction is applied to a document that already contains some of those operations. Using `Y.encodeStateAsUpdate()` would create fresh metadata, causing content duplication on clients that already have overlapping state. +3. **Compaction request**: Server sends `should_compact: true` to the nominated client +4. **Client encodes**: Uses `Y.encodeStateAsUpdate()` to capture the full document state +5. **Client sends compaction**: The encoded state replaces older updates on the server ### 5. Awareness @@ -164,7 +160,7 @@ Single endpoint for bidirectional sync including awareness. Clients send their u "updates": [ { "type": "update", "data": "base64-encoded-yjs-update" } ], - "compaction_request": null + "should_compact": false } ] } @@ -177,7 +173,7 @@ Single endpoint for bidirectional sync including awareness. Clients send their u - `after`: Cursor timestamp; only receive updates newer than this - `awareness`: Client's awareness state (or null to disconnect) - `end_cursor`: New cursor to use in next request -- `compaction_request`: Array of all updates if this client should compact (null otherwise) +- `should_compact`: Boolean indicating whether this client should compact - `updates`: Array of typed updates with base64-encoded Yjs data ## Permissions diff --git a/packages/sync/src/providers/http-polling/polling-manager.ts b/packages/sync/src/providers/http-polling/polling-manager.ts index 2ee3df452307fa..c313c9ef8dee85 100644 --- a/packages/sync/src/providers/http-polling/polling-manager.ts +++ b/packages/sync/src/providers/http-polling/polling-manager.ts @@ -50,6 +50,7 @@ interface RegisterRoomOptions { interface RoomState { clientId: number; + createCompactionUpdate: () => SyncUpdate; endCursor: number; localAwarenessState: LocalAwarenessState; log: LogFunction; @@ -62,31 +63,6 @@ interface RoomState { const roomStates: Map< string, RoomState > = new Map(); -/** - * Create a compaction update by merging existing updates. This preserves - * the original operation metadata (client IDs, logical clocks) so that - * Yjs deduplication works correctly when the compaction is applied. - * - * @param updates The updates to merge - */ -function createCompactionUpdate( updates: SyncUpdate[] ): SyncUpdate { - // Extract only compaction and update types for merging (skip sync-step updates). - // Decode base64 updates to Uint8Array for merging. - const mergeable = updates - .filter( ( u ) => - [ SyncUpdateType.COMPACTION, SyncUpdateType.UPDATE ].includes( - u.type - ) - ) - .map( ( u ) => base64ToUint8Array( u.data ) ); - - // Merge all updates while preserving operation metadata. - return createSyncUpdate( - Y.mergeUpdates( mergeable ), - SyncUpdateType.COMPACTION - ); -} - /** * Create sync step 1 update (announce our state vector). * @@ -306,11 +282,11 @@ function poll(): void { roomState.updateQueue.addBulk( responseUpdates ); // Respond to compaction requests from server. The server asks only one - // client at a time to compact (lowest active client ID). We merge the - // received updates (the server has given us everything it has). - if ( room.compaction_request ) { + // client at a time to compact (lowest active client ID). We encode our + // full document state to replace all prior updates on the server. + if ( room.should_compact ) { roomState.updateQueue.add( - createCompactionUpdate( room.compaction_request ) + roomState.createCompactionUpdate() ); } } ); @@ -387,6 +363,11 @@ function registerRoom( { const roomState: RoomState = { clientId: doc.clientID, + createCompactionUpdate: () => + createSyncUpdate( + Y.encodeStateAsUpdate( doc ), + SyncUpdateType.COMPACTION + ), endCursor: 0, localAwarenessState: awareness.getLocalState() ?? {}, log, diff --git a/packages/sync/src/providers/http-polling/types.ts b/packages/sync/src/providers/http-polling/types.ts index 08d143c0e16694..05cdf1b676ee64 100644 --- a/packages/sync/src/providers/http-polling/types.ts +++ b/packages/sync/src/providers/http-polling/types.ts @@ -31,8 +31,8 @@ interface SyncEnvelopeFromClient { interface SyncEnvelopeFromServer { awareness: AwarenessState; - compaction_request?: SyncUpdate[]; end_cursor: number; // use as `after` in next request + should_compact?: boolean; room: string; updates: SyncUpdate[]; } From 439942659696e688b2b631202ae7fa23f31453e7 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Wed, 18 Feb 2026 06:58:20 -0500 Subject: [PATCH 2/4] Restore deprecated code path --- .../providers/http-polling/polling-manager.ts | 36 +++++++++++++++++++ .../sync/src/providers/http-polling/types.ts | 1 + 2 files changed, 37 insertions(+) diff --git a/packages/sync/src/providers/http-polling/polling-manager.ts b/packages/sync/src/providers/http-polling/polling-manager.ts index c313c9ef8dee85..cd8224aedd68f1 100644 --- a/packages/sync/src/providers/http-polling/polling-manager.ts +++ b/packages/sync/src/providers/http-polling/polling-manager.ts @@ -63,6 +63,33 @@ interface RoomState { const roomStates: Map< string, RoomState > = new Map(); +/** + * Create a compaction update by merging existing updates. This preserves + * the original operation metadata (client IDs, logical clocks) so that + * Yjs deduplication works correctly when the compaction is applied. + * + * Deprecated: The server is moving towards full state updates for compaction. + * + * @param updates The updates to merge + */ +function createDeprecatedCompactionUpdate( updates: SyncUpdate[] ): SyncUpdate { + // Extract only compaction and update types for merging (skip sync-step updates). + // Decode base64 updates to Uint8Array for merging. + const mergeable = updates + .filter( ( u ) => + [ SyncUpdateType.COMPACTION, SyncUpdateType.UPDATE ].includes( + u.type + ) + ) + .map( ( u ) => base64ToUint8Array( u.data ) ); + + // Merge all updates while preserving operation metadata. + return createSyncUpdate( + Y.mergeUpdates( mergeable ), + SyncUpdateType.COMPACTION + ); +} + /** * Create sync step 1 update (announce our state vector). * @@ -285,9 +312,18 @@ function poll(): void { // client at a time to compact (lowest active client ID). We encode our // full document state to replace all prior updates on the server. if ( room.should_compact ) { + roomState.log( 'Server requested compaction update' ); roomState.updateQueue.add( roomState.createCompactionUpdate() ); + } else if ( room.compaction_request ) { + // Deprecated + roomState.log( 'Server requested (old) compaction update' ); + roomState.updateQueue.add( + createDeprecatedCompactionUpdate( + room.compaction_request + ) + ); } } ); } catch ( error ) { diff --git a/packages/sync/src/providers/http-polling/types.ts b/packages/sync/src/providers/http-polling/types.ts index 05cdf1b676ee64..a045f66dcf489e 100644 --- a/packages/sync/src/providers/http-polling/types.ts +++ b/packages/sync/src/providers/http-polling/types.ts @@ -31,6 +31,7 @@ interface SyncEnvelopeFromClient { interface SyncEnvelopeFromServer { awareness: AwarenessState; + compaction_request?: SyncUpdate[]; // deprecated end_cursor: number; // use as `after` in next request should_compact?: boolean; room: string; From ad6d99d548fcec6c9743270f8c7b82eb90fac15b Mon Sep 17 00:00:00 2001 From: chriszarate Date: Wed, 18 Feb 2026 07:02:00 -0500 Subject: [PATCH 3/4] Clear update queue --- packages/sync/src/providers/http-polling/polling-manager.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/sync/src/providers/http-polling/polling-manager.ts b/packages/sync/src/providers/http-polling/polling-manager.ts index cd8224aedd68f1..6422fbe9f3d99a 100644 --- a/packages/sync/src/providers/http-polling/polling-manager.ts +++ b/packages/sync/src/providers/http-polling/polling-manager.ts @@ -313,6 +313,7 @@ function poll(): void { // full document state to replace all prior updates on the server. if ( room.should_compact ) { roomState.log( 'Server requested compaction update' ); + roomState.updateQueue.clear(); roomState.updateQueue.add( roomState.createCompactionUpdate() ); From f7605c718dab2e40eeb85194a1d3e2e234460c89 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Wed, 18 Feb 2026 09:17:58 -0500 Subject: [PATCH 4/4] Add to backport changelog --- backport-changelog/7.0/10894.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backport-changelog/7.0/10894.md b/backport-changelog/7.0/10894.md index f55e198f0d95f3..6694148cf3f57f 100644 --- a/backport-changelog/7.0/10894.md +++ b/backport-changelog/7.0/10894.md @@ -1,3 +1,5 @@ https://github.com/WordPress/wordpress-develop/pull/10894 * https://github.com/WordPress/gutenberg/pull/75366 +* https://github.com/WordPress/gutenberg/pull/75681 +* https://github.com/WordPress/gutenberg/pull/75682