feat(workspace): more status for SyncEngine (#4984)

This commit is contained in:
EYHN
2023-11-20 22:51:20 +08:00
committed by GitHub
parent c9f1fd9649
commit 9370110cdc
6 changed files with 320 additions and 89 deletions

View File

@@ -1,5 +1,8 @@
import { WorkspaceFlavour } from '@affine/env/workspace'; import { WorkspaceFlavour } from '@affine/env/workspace';
import { SyncEngineStatus } from '@affine/workspace/providers'; import {
type SyncEngineStatus,
SyncEngineStep,
} from '@affine/workspace/providers';
import { import {
CloudWorkspaceIcon, CloudWorkspaceIcon,
LocalWorkspaceIcon, LocalWorkspaceIcon,
@@ -86,14 +89,13 @@ const WorkspaceStatus = ({
}) => { }) => {
const isOnline = useSystemOnline(); const isOnline = useSystemOnline();
const [syncEngineStatus, setSyncEngineStatus] = useState<SyncEngineStatus>( const [syncEngineStatus, setSyncEngineStatus] =
SyncEngineStatus.Synced useState<SyncEngineStatus | null>(null);
);
const syncEngine = useCurrentSyncEngine(); const syncEngine = useCurrentSyncEngine();
useEffect(() => { useEffect(() => {
setSyncEngineStatus(syncEngine?.status ?? SyncEngineStatus.Synced); setSyncEngineStatus(syncEngine?.status ?? null);
const disposable = syncEngine?.onStatusChange.on( const disposable = syncEngine?.onStatusChange.on(
debounce(status => { debounce(status => {
setSyncEngineStatus(status); setSyncEngineStatus(status);
@@ -112,26 +114,19 @@ const WorkspaceStatus = ({
if (!isOnline) { if (!isOnline) {
return 'Disconnected, please check your network connection'; return 'Disconnected, please check your network connection';
} }
switch (syncEngineStatus) { if (!syncEngineStatus || syncEngineStatus.step === SyncEngineStep.Syncing) {
case SyncEngineStatus.Syncing: return 'Syncing with AFFiNE Cloud';
case SyncEngineStatus.LoadingSubDoc:
case SyncEngineStatus.LoadingRootDoc:
return 'Syncing with AFFiNE Cloud';
case SyncEngineStatus.Retrying:
return 'Sync disconnected due to unexpected issues, reconnecting.';
default:
return 'Synced with AFFiNE Cloud';
} }
}, [currentWorkspace.flavour, syncEngineStatus, isOnline]); if (syncEngineStatus.retrying) {
return 'Sync disconnected due to unexpected issues, reconnecting.';
}
return 'Synced with AFFiNE Cloud';
}, [currentWorkspace.flavour, isOnline, syncEngineStatus]);
const CloudWorkspaceSyncStatus = useCallback(() => { const CloudWorkspaceSyncStatus = useCallback(() => {
if ( if (!syncEngineStatus || syncEngineStatus.step === SyncEngineStep.Syncing) {
syncEngineStatus === SyncEngineStatus.Syncing ||
syncEngineStatus === SyncEngineStatus.LoadingSubDoc ||
syncEngineStatus === SyncEngineStatus.LoadingRootDoc
) {
return SyncingWorkspaceStatus(); return SyncingWorkspaceStatus();
} else if (syncEngineStatus === SyncEngineStatus.Retrying) { } else if (syncEngineStatus.retrying) {
return UnSyncWorkspaceStatus(); return UnSyncWorkspaceStatus();
} else { } else {
return CloudWorkspaceStatus(); return CloudWorkspaceStatus();

View File

@@ -5,7 +5,7 @@ import {
} from '@affine/component/page-list'; } from '@affine/component/page-list';
import { WorkspaceSubPath } from '@affine/env/workspace'; import { WorkspaceSubPath } from '@affine/env/workspace';
import { globalBlockSuiteSchema } from '@affine/workspace/manager'; import { globalBlockSuiteSchema } from '@affine/workspace/manager';
import { SyncEngineStatus } from '@affine/workspace/providers'; import { SyncEngineStep } from '@affine/workspace/providers';
import type { EditorContainer } from '@blocksuite/editor'; import type { EditorContainer } from '@blocksuite/editor';
import { assertExists } from '@blocksuite/global/utils'; import { assertExists } from '@blocksuite/global/utils';
import type { Page } from '@blocksuite/store'; import type { Page } from '@blocksuite/store';
@@ -144,7 +144,7 @@ export const DetailPage = (): ReactElement => {
// if sync engine has been synced and the page is null, wait 1s and jump to 404 page. // if sync engine has been synced and the page is null, wait 1s and jump to 404 page.
useEffect(() => { useEffect(() => {
if (currentSyncEngineStatus === SyncEngineStatus.Synced && !page) { if (currentSyncEngineStatus?.step === SyncEngineStep.Synced && !page) {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
navigate.jumpTo404(); navigate.jumpTo404();
}, 1000); }, 1000);

View File

@@ -0,0 +1,172 @@
import 'fake-indexeddb/auto';
import { setTimeout } from 'node:timers/promises';
import { __unstableSchemas, AffineSchemas } from '@blocksuite/blocks/models';
import { Schema, Workspace } from '@blocksuite/store';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { Doc } from 'yjs';
import { createIndexedDBStorage } from '../../storage';
import { SyncEngine, SyncEngineStep, SyncPeerStep } from '../';
import { createTestStorage } from './test-storage';
const schema = new Schema();
schema.register(AffineSchemas).register(__unstableSchemas);
beforeEach(() => {
vi.useFakeTimers({ toFake: ['requestIdleCallback'] });
});
describe('SyncEngine', () => {
test('basic - indexeddb', async () => {
let prev: any;
{
const workspace = new Workspace({
id: 'test',
isSSR: true,
schema,
});
const syncEngine = new SyncEngine(
workspace.doc,
createIndexedDBStorage(workspace.doc.guid),
[
createIndexedDBStorage(workspace.doc.guid + '1'),
createIndexedDBStorage(workspace.doc.guid + '2'),
]
);
syncEngine.start();
const page = workspace.createPage({
id: 'page0',
});
await page.load();
const pageBlockId = page.addBlock('affine:page', {
title: new page.Text(''),
});
page.addBlock('affine:surface', {}, pageBlockId);
const frameId = page.addBlock('affine:note', {}, pageBlockId);
page.addBlock('affine:paragraph', {}, frameId);
await syncEngine.waitForSynced();
syncEngine.stop();
prev = workspace.doc.toJSON();
}
{
const workspace = new Workspace({
id: 'test',
isSSR: true,
schema,
});
const syncEngine = new SyncEngine(
workspace.doc,
createIndexedDBStorage(workspace.doc.guid),
[]
);
syncEngine.start();
await syncEngine.waitForSynced();
expect(workspace.doc.toJSON()).toEqual({
...prev,
});
syncEngine.stop();
}
{
const workspace = new Workspace({
id: 'test',
isSSR: true,
schema,
});
const syncEngine = new SyncEngine(
workspace.doc,
createIndexedDBStorage(workspace.doc.guid + '1'),
[]
);
syncEngine.start();
await syncEngine.waitForSynced();
expect(workspace.doc.toJSON()).toEqual({
...prev,
});
syncEngine.stop();
}
{
const workspace = new Workspace({
id: 'test',
isSSR: true,
schema,
});
const syncEngine = new SyncEngine(
workspace.doc,
createIndexedDBStorage(workspace.doc.guid + '2'),
[]
);
syncEngine.start();
await syncEngine.waitForSynced();
expect(workspace.doc.toJSON()).toEqual({
...prev,
});
syncEngine.stop();
}
});
test('status', async () => {
const ydoc = new Doc({ guid: 'test - status' });
const localStorage = createTestStorage(createIndexedDBStorage(ydoc.guid));
const remoteStorage = createTestStorage(createIndexedDBStorage(ydoc.guid));
localStorage.pausePull();
localStorage.pausePush();
remoteStorage.pausePull();
remoteStorage.pausePush();
const syncEngine = new SyncEngine(ydoc, localStorage, [remoteStorage]);
expect(syncEngine.status.step).toEqual(SyncEngineStep.Stopped);
syncEngine.start();
await setTimeout(100);
expect(syncEngine.status.step).toEqual(SyncEngineStep.Syncing);
expect(syncEngine.status.local?.step).toEqual(SyncPeerStep.LoadingRootDoc);
localStorage.resumePull();
await setTimeout(100);
expect(syncEngine.status.step).toEqual(SyncEngineStep.Syncing);
expect(syncEngine.status.local?.step).toEqual(SyncPeerStep.Synced);
expect(syncEngine.status.remotes[0]?.step).toEqual(
SyncPeerStep.LoadingRootDoc
);
remoteStorage.resumePull();
await setTimeout(100);
expect(syncEngine.status.step).toEqual(SyncEngineStep.Synced);
expect(syncEngine.status.local?.step).toEqual(SyncPeerStep.Synced);
expect(syncEngine.status.remotes[0]?.step).toEqual(SyncPeerStep.Synced);
ydoc.getArray('test').insert(0, [1, 2, 3]);
await setTimeout(100);
expect(syncEngine.status.step).toEqual(SyncEngineStep.Syncing);
expect(syncEngine.status.local?.step).toEqual(SyncPeerStep.Syncing);
expect(syncEngine.status.remotes[0]?.step).toEqual(SyncPeerStep.Syncing);
localStorage.resumePush();
await setTimeout(100);
expect(syncEngine.status.step).toEqual(SyncEngineStep.Syncing);
expect(syncEngine.status.local?.step).toEqual(SyncPeerStep.Synced);
expect(syncEngine.status.remotes[0]?.step).toEqual(SyncPeerStep.Syncing);
remoteStorage.resumePush();
await setTimeout(100);
expect(syncEngine.status.step).toEqual(SyncEngineStep.Synced);
expect(syncEngine.status.local?.step).toEqual(SyncPeerStep.Synced);
expect(syncEngine.status.remotes[0]?.step).toEqual(SyncPeerStep.Synced);
});
});

View File

@@ -15,7 +15,7 @@ beforeEach(() => {
vi.useFakeTimers({ toFake: ['requestIdleCallback'] }); vi.useFakeTimers({ toFake: ['requestIdleCallback'] });
}); });
describe('sync', () => { describe('SyncPeer', () => {
test('basic - indexeddb', async () => { test('basic - indexeddb', async () => {
let prev: any; let prev: any;
{ {

View File

@@ -0,0 +1,42 @@
import type { Storage } from '../../storage';
export function createTestStorage(origin: Storage) {
const controler = {
pausedPull: Promise.resolve(),
resumePull: () => {},
pausedPush: Promise.resolve(),
resumePush: () => {},
};
return {
name: `${origin.name}(testing)`,
pull(docId: string, state: Uint8Array) {
return controler.pausedPull.then(() => origin.pull(docId, state));
},
push(docId: string, data: Uint8Array) {
return controler.pausedPush.then(() => origin.push(docId, data));
},
subscribe(
cb: (docId: string, data: Uint8Array) => void,
disconnect: (reason: string) => void
) {
return origin.subscribe(cb, disconnect);
},
pausePull() {
controler.pausedPull = new Promise(resolve => {
controler.resumePull = resolve;
});
},
resumePull() {
controler.resumePull?.();
},
pausePush() {
controler.pausedPush = new Promise(resolve => {
controler.resumePush = resolve;
});
},
resumePush() {
controler.resumePush?.();
},
};
}

View File

@@ -3,13 +3,27 @@ import { Slot } from '@blocksuite/global/utils';
import type { Doc } from 'yjs'; import type { Doc } from 'yjs';
import type { Storage } from '../storage'; import type { Storage } from '../storage';
import { SyncPeer, SyncPeerStep } from './peer'; import { SyncPeer, type SyncPeerStatus, SyncPeerStep } from './peer';
export const MANUALLY_STOP = 'manually-stop'; export const MANUALLY_STOP = 'manually-stop';
export enum SyncEngineStep {
Stopped = 0,
Syncing = 1,
Synced = 2,
}
export interface SyncEngineStatus {
step: SyncEngineStep;
local: SyncPeerStatus | null;
remotes: (SyncPeerStatus | null)[];
retrying: boolean;
}
/** /**
* # SyncEngine * # SyncEngine
* *
* ```
* ┌────────────┐ * ┌────────────┐
* │ SyncEngine │ * │ SyncEngine │
* └─────┬──────┘ * └─────┬──────┘
@@ -25,6 +39,7 @@ export const MANUALLY_STOP = 'manually-stop';
* │ SyncPeer │ │ SyncPeer │ │ SyncPeer │ * │ SyncPeer │ │ SyncPeer │ │ SyncPeer │
* │ Remote │ │ Remote │ │ Remote │ * │ Remote │ │ Remote │ │ Remote │
* └────────────┘ └────────────┘ └────────────┘ * └────────────┘ └────────────┘ └────────────┘
* ```
* *
* Sync engine manage sync peers * Sync engine manage sync peers
* *
@@ -34,29 +49,18 @@ export const MANUALLY_STOP = 'manually-stop';
* 3. start remote sync * 3. start remote sync
* 4. continuously sync local and remote * 4. continuously sync local and remote
*/ */
export enum SyncEngineStatus {
Stopped = 0,
Retrying = 1,
LoadingRootDoc = 2,
LoadingSubDoc = 3,
Syncing = 4,
Synced = 5,
}
export class SyncEngine { export class SyncEngine {
get rootDocId() { get rootDocId() {
return this.rootDoc.guid; return this.rootDoc.guid;
} }
logger = new DebugLogger('affine:sync-engine:' + this.rootDocId); logger = new DebugLogger('affine:sync-engine:' + this.rootDocId);
private _status = SyncEngineStatus.Stopped; private _status: SyncEngineStatus;
onStatusChange = new Slot<SyncEngineStatus>(); onStatusChange = new Slot<SyncEngineStatus>();
private set status(s: SyncEngineStatus) { private set status(s: SyncEngineStatus) {
if (s !== this._status) { this.logger.info('status change', SyncEngineStep[s.step]);
this.logger.info('status change', SyncEngineStatus[s]); this._status = s;
this._status = s; this.onStatusChange.emit(s);
this.onStatusChange.emit(s);
}
} }
get status() { get status() {
@@ -69,15 +73,21 @@ export class SyncEngine {
private rootDoc: Doc, private rootDoc: Doc,
private local: Storage, private local: Storage,
private remotes: Storage[] private remotes: Storage[]
) {} ) {
this._status = {
step: SyncEngineStep.Stopped,
local: null,
remotes: remotes.map(() => null),
retrying: false,
};
}
start() { start() {
if (this.status !== SyncEngineStatus.Stopped) { if (this.status.step !== SyncEngineStep.Stopped) {
this.stop(); this.stop();
} }
this.abort = new AbortController(); this.abort = new AbortController();
this.status = SyncEngineStatus.LoadingRootDoc;
this.sync(this.abort.signal).catch(err => { this.sync(this.abort.signal).catch(err => {
// should never reach here // should never reach here
this.logger.error(err); this.logger.error(err);
@@ -86,37 +96,54 @@ export class SyncEngine {
stop() { stop() {
this.abort.abort(MANUALLY_STOP); this.abort.abort(MANUALLY_STOP);
this.status = SyncEngineStatus.Stopped; this._status = {
step: SyncEngineStep.Stopped,
local: null,
remotes: this.remotes.map(() => null),
retrying: false,
};
} }
// main sync process, should never return until abort // main sync process, should never return until abort
async sync(signal: AbortSignal) { async sync(signal: AbortSignal) {
let localPeer: SyncPeer | null = null; const state: {
const remotePeers: SyncPeer[] = []; localPeer: SyncPeer | null;
remotePeers: (SyncPeer | null)[];
} = {
localPeer: null,
remotePeers: this.remotes.map(() => null),
};
const cleanUp: (() => void)[] = []; const cleanUp: (() => void)[] = [];
try { try {
// Step 1: start local sync peer // Step 1: start local sync peer
localPeer = new SyncPeer(this.rootDoc, this.local); state.localPeer = new SyncPeer(this.rootDoc, this.local);
// Step 2: wait for local sync complete cleanUp.push(
await localPeer.waitForLoaded(signal); state.localPeer.onStatusChange.on(() => {
if (!signal.aborted)
// Step 3: start remote sync peer this.updateSyncingState(state.localPeer, state.remotePeers);
remotePeers.push( }).dispose
...this.remotes.map(remote => new SyncPeer(this.rootDoc, remote))
); );
const peers = [localPeer, ...remotePeers]; this.updateSyncingState(state.localPeer, state.remotePeers);
this.updateSyncingState(peers); // Step 2: wait for local sync complete
await state.localPeer.waitForLoaded(signal);
for (const peer of peers) { // Step 3: start remote sync peer
state.remotePeers = this.remotes.map(remote => {
const peer = new SyncPeer(this.rootDoc, remote);
cleanUp.push( cleanUp.push(
peer.onStatusChange.on(() => { peer.onStatusChange.on(() => {
if (!signal.aborted) this.updateSyncingState(peers); if (!signal.aborted)
this.updateSyncingState(state.localPeer, state.remotePeers);
}).dispose }).dispose
); );
} return peer;
});
this.updateSyncingState(state.localPeer, state.remotePeers);
// Step 4: continuously sync local and remote // Step 4: continuously sync local and remote
@@ -136,9 +163,9 @@ export class SyncEngine {
throw error; throw error;
} finally { } finally {
// stop peers // stop peers
localPeer?.stop(); state.localPeer?.stop();
for (const remotePeer of remotePeers) { for (const remotePeer of state.remotePeers) {
remotePeer.stop(); remotePeer?.stop();
} }
for (const clean of cleanUp) { for (const clean of cleanUp) {
clean(); clean();
@@ -146,43 +173,33 @@ export class SyncEngine {
} }
} }
updateSyncingState(peers: SyncPeer[]) { updateSyncingState(local: SyncPeer | null, remotes: (SyncPeer | null)[]) {
let status = SyncEngineStatus.Synced; let step = SyncEngineStep.Synced;
for (const peer of peers) { const allPeer = [local, ...remotes];
if (peer.status.step !== SyncPeerStep.Synced) { for (const peer of allPeer) {
status = SyncEngineStatus.Syncing; if (!peer || peer.status.step !== SyncPeerStep.Synced) {
step = SyncEngineStep.Syncing;
break; break;
} }
} }
for (const peer of peers) { this.status = {
if (peer.status.step === SyncPeerStep.LoadingSubDoc) { step,
status = SyncEngineStatus.LoadingSubDoc; local: local?.status ?? null,
break; remotes: remotes.map(peer => peer?.status ?? null),
} retrying: allPeer.some(
} peer => peer?.status.step === SyncPeerStep.Retrying
for (const peer of peers) { ),
if (peer.status.step === SyncPeerStep.LoadingRootDoc) { };
status = SyncEngineStatus.LoadingRootDoc;
break;
}
}
for (const peer of peers) {
if (peer.status.step === SyncPeerStep.Retrying) {
status = SyncEngineStatus.Retrying;
break;
}
}
this.status = status;
} }
async waitForSynced(abort?: AbortSignal) { async waitForSynced(abort?: AbortSignal) {
if (this.status == SyncEngineStatus.Synced) { if (this.status.step == SyncEngineStep.Synced) {
return; return;
} else { } else {
return Promise.race([ return Promise.race([
new Promise<void>(resolve => { new Promise<void>(resolve => {
this.onStatusChange.on(status => { this.onStatusChange.on(status => {
if (status == SyncEngineStatus.Synced) { if (status.step == SyncEngineStep.Synced) {
resolve(); resolve();
} }
}); });
@@ -200,13 +217,18 @@ export class SyncEngine {
} }
async waitForLoadedRootDoc(abort?: AbortSignal) { async waitForLoadedRootDoc(abort?: AbortSignal) {
if (this.status > SyncEngineStatus.LoadingRootDoc) { function isLoadedRootDoc(status: SyncEngineStatus) {
return ![status.local, ...status.remotes].some(
peer => !peer || peer.step <= SyncPeerStep.LoadingRootDoc
);
}
if (isLoadedRootDoc(this.status)) {
return; return;
} else { } else {
return Promise.race([ return Promise.race([
new Promise<void>(resolve => { new Promise<void>(resolve => {
this.onStatusChange.on(status => { this.onStatusChange.on(status => {
if (status > SyncEngineStatus.LoadingRootDoc) { if (isLoadedRootDoc(status)) {
resolve(); resolve();
} }
}); });