Skip to content

Commit 62054e9

Browse files
authored
Improve sync performance metrics (#75029)
* Add a "debug" mode to expose performance timing of sync functions * Yield to event loop before updating CRDT document * Do not await promise when loading entity for syncing * Add tests for new performance functions * Add missing `type` option for REST API endoint * Remove debugWrap of getAwareness (generics)
1 parent 8d2888d commit 62054e9

File tree

6 files changed

+399
-26
lines changed

6 files changed

+399
-26
lines changed

lib/experimental/sync/class-gutenberg-http-polling-sync-server.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ public function register_routes(): void {
8787
),
8888
'awareness' => array(
8989
'required' => true,
90+
'type' => 'object',
9091
),
9192
'client_id' => array(
9293
'minimum' => 1,

packages/core-data/src/resolvers.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,8 +185,8 @@ export const getEntityRecord =
185185
transientConfig.read( recordWithTransients );
186186
} );
187187

188-
// Load the entity record for syncing.
189-
await getSyncManager()?.load(
188+
// Load the entity record for syncing. Do not await promise.
189+
void getSyncManager()?.load(
190190
entityConfig.syncConfig,
191191
objectType,
192192
objectId,

packages/sync/src/manager.ts

Lines changed: 47 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ import {
1313
CRDT_RECORD_METADATA_MAP_KEY as RECORD_METADATA_KEY,
1414
CRDT_RECORD_METADATA_SAVED_AT_KEY as SAVED_AT_KEY,
1515
} from './config';
16+
import {
17+
logPerformanceTiming,
18+
passThru,
19+
yieldToEventLoop,
20+
} from './performance';
1621
import { createPersistedCRDTDoc, getPersistedCrdtDoc } from './persistence';
1722
import { getProviderCreators } from './providers';
1823
import type {
@@ -50,12 +55,28 @@ interface EntityState {
5055
ydoc: CRDTDoc;
5156
}
5257

58+
/**
59+
* Get the entity ID for the given object type and object ID.
60+
*
61+
* @param {ObjectType} objectType Object type.
62+
* @param {ObjectID|null} objectId Object ID.
63+
*/
64+
function getEntityId(
65+
objectType: ObjectType,
66+
objectId: ObjectID | null
67+
): EntityID {
68+
return `${ objectType }_${ objectId }`;
69+
}
70+
5371
/**
5472
* The sync manager orchestrates the lifecycle of syncing entity records. It
5573
* creates Yjs documents, connects to providers, creates awareness instances,
5674
* and coordinates with the `core-data` store.
75+
*
76+
* @param debug Whether to enable performance and debug logging.
5777
*/
58-
export function createSyncManager(): SyncManager {
78+
export function createSyncManager( debug = false ): SyncManager {
79+
const debugWrap = debug ? logPerformanceTiming : passThru;
5980
const collectionStates: Map< ObjectType, CollectionState > = new Map();
6081
const entityStates: Map< EntityID, EntityState > = new Map();
6182

@@ -118,6 +139,15 @@ export function createSyncManager(): SyncManager {
118139
return; // Already bootstrapped.
119140
}
120141

142+
handlers = {
143+
addUndoMeta: debugWrap( handlers.addUndoMeta ),
144+
editRecord: debugWrap( handlers.editRecord ),
145+
getEditedRecord: debugWrap( handlers.getEditedRecord ),
146+
refetchRecord: debugWrap( handlers.refetchRecord ),
147+
restoreUndoMeta: debugWrap( handlers.restoreUndoMeta ),
148+
saveRecord: debugWrap( handlers.saveRecord ),
149+
};
150+
121151
const ydoc = createYjsDoc( { objectType } );
122152
const recordMap = ydoc.getMap( RECORD_KEY );
123153
const recordMetaMap = ydoc.getMap( RECORD_METADATA_KEY );
@@ -148,7 +178,7 @@ export function createSyncManager(): SyncManager {
148178
return;
149179
}
150180

151-
void updateEntityRecord( objectType, objectId );
181+
void internal.updateEntityRecord( objectType, objectId );
152182
};
153183

154184
const onRecordMetaUpdate = (
@@ -208,7 +238,7 @@ export function createSyncManager(): SyncManager {
208238
recordMetaMap.observe( onRecordMetaUpdate );
209239

210240
// Get and apply the persisted CRDT document, if it exists.
211-
applyPersistedCrdtDoc( objectType, objectId, record );
241+
internal.applyPersistedCrdtDoc( objectType, objectId, record );
212242
}
213243

214244
/**
@@ -308,19 +338,6 @@ export function createSyncManager(): SyncManager {
308338
updateCRDTDoc( objectType, null, {}, origin, { isSave: true } );
309339
}
310340

311-
/**
312-
* Get the entity ID for the given object type and object ID.
313-
*
314-
* @param {ObjectType} objectType Object type.
315-
* @param {ObjectID|null} objectId Object ID.
316-
*/
317-
function getEntityId(
318-
objectType: ObjectType,
319-
objectId: ObjectID | null
320-
): EntityID {
321-
return `${ objectType }_${ objectId }`;
322-
}
323-
324341
/**
325342
* Get the awareness instance for the given object type and object ID, if supported.
326343
*
@@ -352,7 +369,7 @@ export function createSyncManager(): SyncManager {
352369
* @param {ObjectID} objectId Object ID.
353370
* @param {ObjectData} record Entity record representing this object type.
354371
*/
355-
function applyPersistedCrdtDoc(
372+
function _applyPersistedCrdtDoc(
356373
objectType: ObjectType,
357374
objectId: ObjectID,
358375
record: ObjectData
@@ -505,7 +522,7 @@ export function createSyncManager(): SyncManager {
505522
* @param {ObjectType} objectType Object type of record to update.
506523
* @param {ObjectID} objectId Object ID of record to update.
507524
*/
508-
async function updateEntityRecord(
525+
async function _updateEntityRecord(
509526
objectType: ObjectType,
510527
objectId: ObjectID
511528
): Promise< void > {
@@ -556,16 +573,23 @@ export function createSyncManager(): SyncManager {
556573
return createPersistedCRDTDoc( entityState.ydoc );
557574
}
558575

576+
// Collect internal functions so that they can be wrapped before calling.
577+
const internal = {
578+
applyPersistedCrdtDoc: debugWrap( _applyPersistedCrdtDoc ),
579+
updateEntityRecord: debugWrap( _updateEntityRecord ),
580+
};
581+
582+
// Wrap and return the public API.
559583
return {
560-
createMeta: createEntityMeta,
584+
createMeta: debugWrap( createEntityMeta ),
561585
getAwareness,
562-
load: loadEntity,
563-
loadCollection,
586+
load: debugWrap( loadEntity ),
587+
loadCollection: debugWrap( loadCollection ),
564588
// Use getter to ensure we always return the current value of `undoManager`.
565589
get undoManager(): SyncUndoManager | undefined {
566590
return undoManager;
567591
},
568-
unload: unloadEntity,
569-
update: updateCRDTDoc,
592+
unload: debugWrap( unloadEntity ),
593+
update: debugWrap( yieldToEventLoop( updateCRDTDoc ) ),
570594
};
571595
}

packages/sync/src/performance.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* Wraps a function and logs the time it takes to execute.
3+
*
4+
* @param fn - The function to be wrapped.
5+
*/
6+
export function logPerformanceTiming<
7+
Params extends Array< unknown >,
8+
ReturnType = void,
9+
>( fn: ( ...args: Params ) => ReturnType ): typeof fn {
10+
return function ( this: unknown, ...args: Params ): ReturnType {
11+
const start = performance.now();
12+
const result = fn.apply( this, args );
13+
const end = performance.now();
14+
15+
// eslint-disable-next-line no-console
16+
console.log( `${ fn.name } took ${ ( end - start ).toFixed( 2 ) } ms` );
17+
18+
return result;
19+
};
20+
}
21+
22+
/**
23+
* A pass-through function that invokes the provided function with its arguments
24+
* without moidyfing its type.
25+
*
26+
* @param fn - The function to be invoked.
27+
*/
28+
export function passThru< T extends ( ...args: any[] ) => any >( fn: T ): T {
29+
return ( ( ...args: Parameters< T > ): ReturnType< T > =>
30+
fn( ...args ) ) as T;
31+
}
32+
33+
/**
34+
* Wraps a function so that every invocation is delayed until the next tick of
35+
* the event loop.
36+
*
37+
* @param fn - The function to be scheduled.
38+
*/
39+
export function yieldToEventLoop< Params extends Array< unknown > >(
40+
fn: ( ...args: Params ) => void
41+
): typeof fn {
42+
return function ( this: unknown, ...args: Params ): void {
43+
setTimeout( () => {
44+
fn.apply( this, args );
45+
}, 0 );
46+
};
47+
}

packages/sync/src/test/manager.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,9 @@ describe( 'SyncManager', () => {
480480
jest.clearAllMocks();
481481
manager.update( 'post', '456', { title: 'Updated' }, 'local' );
482482

483+
// Wait a tick for yieldToEventLoop.
484+
await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
485+
483486
expect( mockSyncConfig.applyChangesToCRDTDoc ).toHaveBeenCalled();
484487
} );
485488
} );
@@ -508,6 +511,9 @@ describe( 'SyncManager', () => {
508511
const changes = { title: 'Updated Title' };
509512
manager.update( 'post', '123', changes, 'local-editor' );
510513

514+
// Wait a tick for yieldToEventLoop.
515+
await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
516+
511517
// Verify that applyChangesToCRDTDoc was called with the changes.
512518
expect( mockSyncConfig.applyChangesToCRDTDoc ).toHaveBeenCalledWith(
513519
expect.any( Y.Doc ),
@@ -521,12 +527,15 @@ describe( 'SyncManager', () => {
521527
expect( metadataMap.get( SAVED_BY_KEY ) ).toBeUndefined();
522528
} );
523529

524-
it( 'does not update when entity is not loaded', () => {
530+
it( 'does not update when entity is not loaded', async () => {
525531
const manager = createSyncManager();
526532

527533
const changes = { title: 'Updated Title' };
528534
manager.update( 'post', '999', changes, 'local-editor' );
529535

536+
// Wait a tick for yieldToEventLoop.
537+
await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
538+
530539
expect(
531540
mockSyncConfig.applyChangesToCRDTDoc
532541
).not.toHaveBeenCalled();
@@ -564,6 +573,9 @@ describe( 'SyncManager', () => {
564573

565574
manager.update( 'post', '123', changes, customOrigin );
566575

576+
// Wait a tick for yieldToEventLoop.
577+
await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
578+
567579
expect( transactSpy ).toHaveBeenCalledWith(
568580
expect.any( Function ),
569581
customOrigin
@@ -597,6 +609,9 @@ describe( 'SyncManager', () => {
597609
isSave: true,
598610
} );
599611

612+
// Wait a tick for yieldToEventLoop.
613+
await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
614+
600615
// Verify that applyChangesToCRDTDoc was called with the changes.
601616
expect( mockSyncConfig.applyChangesToCRDTDoc ).toHaveBeenCalledWith(
602617
expect.any( Y.Doc ),

0 commit comments

Comments
 (0)