refactor(workspace): sync doc update in background using data source (#4081)

This commit is contained in:
Alex Yang
2023-08-31 16:20:57 -05:00
committed by GitHub
parent 4091ff8e36
commit 7082937b62
7 changed files with 189 additions and 62 deletions

View File

@@ -1,15 +1,20 @@
import { syncDataSource } from '@affine/y-provider';
import { createIndexeddbStorage } from '@blocksuite/store'; import { createIndexeddbStorage } from '@blocksuite/store';
import { pushBinary } from '@toeverything/y-indexeddb'; import {
createIndexedDBDatasource,
DEFAULT_DB_NAME,
downloadBinary,
} from '@toeverything/y-indexeddb';
import type { Doc } from 'yjs'; import type { Doc } from 'yjs';
import { applyUpdate } from 'yjs'; import { applyUpdate } from 'yjs';
import { createCloudBlobStorage } from '../blob/cloud-blob-storage'; import { createCloudBlobStorage } from '../blob/cloud-blob-storage';
import { downloadBinaryFromCloud } from '../providers/cloud'; import { createAffineDataSource } from '.';
import { CRUD } from './crud'; import { CRUD } from './crud';
let abortController: AbortController | undefined; let abortController: AbortController | undefined;
const downloadRecursive = async ( const downloadRootFromIndexedDB = async (
rootGuid: string, rootGuid: string,
doc: Doc, doc: Doc,
signal: AbortSignal signal: AbortSignal
@@ -17,33 +22,43 @@ const downloadRecursive = async (
if (signal.aborted) { if (signal.aborted) {
return; return;
} }
const binary = await downloadBinaryFromCloud(rootGuid, doc.guid); const update = await downloadBinary(rootGuid);
if (typeof binary !== 'boolean') { if (update !== false) {
const update = new Uint8Array(binary); applyUpdate(doc, update);
if (rootGuid === doc.guid) {
// only apply the root doc
applyUpdate(doc, update, 'affine-cloud-service');
} else {
await pushBinary(doc.guid, update);
}
} }
return Promise.all(
[...doc.subdocs.values()].map(subdoc =>
downloadRecursive(rootGuid, subdoc, signal)
)
).then();
}; };
export async function startSync() { export async function startSync() {
if (!environment.isDesktop) {
return;
}
abortController = new AbortController(); abortController = new AbortController();
const signal = abortController.signal; const signal = abortController.signal;
const workspaces = await CRUD.list(); const workspaces = await CRUD.list();
const downloadCloudPromises = workspaces.map(workspace => const syncDocPromises = workspaces.map(workspace =>
downloadRecursive(workspace.id, workspace.blockSuiteWorkspace.doc, signal) downloadRootFromIndexedDB(
workspace.id,
workspace.blockSuiteWorkspace.doc,
signal
)
); );
await Promise.all(syncDocPromises);
const syncPromises = workspaces.map(workspace => {
const remoteDataSource = createAffineDataSource(
workspace.id,
workspace.blockSuiteWorkspace.doc,
workspace.blockSuiteWorkspace.awarenessStore.awareness
);
const indexeddbDataSource = createIndexedDBDatasource({
dbName: DEFAULT_DB_NAME,
});
return syncDataSource(
(): string[] => [
workspace.blockSuiteWorkspace.doc.guid,
...[...workspace.blockSuiteWorkspace.doc.subdocs].map(doc => doc.guid),
],
remoteDataSource,
indexeddbDataSource
);
});
const syncBlobPromises = workspaces.map(async workspace => { const syncBlobPromises = workspaces.map(async workspace => {
const cloudBlobStorage = createCloudBlobStorage(workspace.id); const cloudBlobStorage = createCloudBlobStorage(workspace.id);
const indexeddbBlobStorage = createIndexeddbStorage(workspace.id); const indexeddbBlobStorage = createIndexeddbStorage(workspace.id);
@@ -88,7 +103,7 @@ export async function startSync() {
]); ]);
}); });
}); });
await Promise.all([...downloadCloudPromises, ...syncBlobPromises]); await Promise.all([...syncPromises, ...syncBlobPromises]);
} }
export async function stopSync() { export async function stopSync() {

View File

@@ -24,7 +24,7 @@ export function setMergeCount(count: number) {
mergeCount = count; mergeCount = count;
} }
const createDatasource = ({ export const createIndexedDBDatasource = ({
dbName, dbName,
mergeCount, mergeCount,
}: { }: {
@@ -109,7 +109,7 @@ export const createIndexedDBProvider = (
doc: Doc, doc: Doc,
dbName: string = DEFAULT_DB_NAME dbName: string = DEFAULT_DB_NAME
): IndexedDBProvider => { ): IndexedDBProvider => {
let datasource: ReturnType<typeof createDatasource> | null = null; let datasource: ReturnType<typeof createIndexedDBDatasource> | null = null;
let provider: ReturnType<typeof createLazyProvider> | null = null; let provider: ReturnType<typeof createLazyProvider> | null = null;
const apis = { const apis = {
@@ -125,7 +125,7 @@ export const createIndexedDBProvider = (
if (apis.connected) { if (apis.connected) {
apis.disconnect(); apis.disconnect();
} }
datasource = createDatasource({ dbName, mergeCount }); datasource = createIndexedDBDatasource({ dbName, mergeCount });
provider = createLazyProvider(doc, datasource, { origin: 'idb' }); provider = createLazyProvider(doc, datasource, { origin: 'idb' });
provider.connect(); provider.connect();
}, },

View File

@@ -3,8 +3,8 @@ import { setTimeout } from 'node:timers/promises';
import { describe, expect, test, vi } from 'vitest'; import { describe, expect, test, vi } from 'vitest';
import { applyUpdate, Doc, encodeStateAsUpdate, encodeStateVector } from 'yjs'; import { applyUpdate, Doc, encodeStateAsUpdate, encodeStateVector } from 'yjs';
import type { DatasourceDocAdapter } from '../data-source';
import { createLazyProvider } from '../lazy-provider'; import { createLazyProvider } from '../lazy-provider';
import type { DatasourceDocAdapter } from '../types';
import { getDoc } from '../utils'; import { getDoc } from '../utils';
const createMemoryDatasource = (rootDoc: Doc) => { const createMemoryDatasource = (rootDoc: Doc) => {
@@ -92,6 +92,16 @@ describe('y-provider', () => {
remotesubdoc.getText('text').insert(0, 'prefix-'); remotesubdoc.getText('text').insert(0, 'prefix-');
await setTimeout(); await setTimeout();
expect(subdoc.getText('text').toJSON()).toBe('prefix-test-subdoc-value'); expect(subdoc.getText('text').toJSON()).toBe('prefix-test-subdoc-value');
// disconnect then reconnect
provider.disconnect();
remotesubdoc.getText('text').delete(0, 'prefix-'.length);
await setTimeout();
expect(subdoc.getText('text').toJSON()).toBe('prefix-test-subdoc-value');
provider.connect();
await setTimeout();
expect(subdoc.getText('text').toJSON()).toBe('test-subdoc-value');
}); });
test('should sync a shouldLoad=true subdoc on connect', async () => { test('should sync a shouldLoad=true subdoc on connect', async () => {
@@ -196,4 +206,30 @@ describe('y-provider', () => {
expect(spy).not.toBeCalled(); expect(spy).not.toBeCalled();
}); });
test('only sync', async () => {
const remoteRootDoc = new Doc(); // this is the remote doc lives in remote
const datasource = createMemoryDatasource(remoteRootDoc);
remoteRootDoc.getText().insert(0, 'hello, world!');
const rootDoc = new Doc({ guid: remoteRootDoc.guid }); // this is the doc that we want to sync
const provider = createLazyProvider(rootDoc, datasource);
await provider.sync(true);
expect(rootDoc.getText().toJSON()).toBe('hello, world!');
const remotesubdoc = new Doc();
remotesubdoc.getText('text').insert(0, 'test-subdoc-value');
remoteRootDoc.getMap('map').set('subdoc', remotesubdoc);
expect(rootDoc.subdocs.size).toBe(0);
await provider.sync(true);
expect(rootDoc.subdocs.size).toBe(1);
const subdoc = rootDoc.getMap('map').get('subdoc') as Doc;
expect(subdoc.getText('text').toJSON()).toBe('');
await provider.sync(true);
expect(subdoc.getText('text').toJSON()).toBe('');
await provider.sync(false);
expect(subdoc.getText('text').toJSON()).toBe('test-subdoc-value');
});
}); });

View File

@@ -0,0 +1,65 @@
import type { DocState, StatusAdapter } from './types';
export interface DatasourceDocAdapter extends Partial<StatusAdapter> {
/**
* request diff update from other clients
*/
queryDocState: (
guid: string,
options?: {
stateVector?: Uint8Array;
targetClientId?: number;
}
) => Promise<DocState | false>;
/**
* send update to the datasource
*/
sendDocUpdate: (guid: string, update: Uint8Array) => Promise<void>;
/**
* listen to update from the datasource. Returns a function to unsubscribe.
* this is optional because some datasource might not support it
*/
onDocUpdate?(
callback: (guid: string, update: Uint8Array) => void
): () => void;
}
/**
* query the datasource from source, and save the latest update to target
*
* @example
* bindDataSource(socketIO, indexedDB)
* bindDataSource(socketIO, sqlite)
*/
export async function syncDataSource(
listDocGuids: () => string[],
remoteDataSource: DatasourceDocAdapter,
localDataSource: DatasourceDocAdapter
) {
const guids = listDocGuids();
await Promise.all(
guids.map(guid => {
return localDataSource.queryDocState(guid).then(async docState => {
const remoteDocState = await (async () => {
if (docState) {
return remoteDataSource.queryDocState(guid, {
stateVector: docState.state,
});
} else {
return remoteDataSource.queryDocState(guid);
}
})();
if (remoteDocState) {
const missing = remoteDocState.missing;
if (missing.length === 2 && missing[0] === 0 && missing[1] === 0) {
// empty update
return;
}
await localDataSource.sendDocUpdate(guid, remoteDocState.missing);
}
});
})
);
}

View File

@@ -1,3 +1,4 @@
export * from './data-source';
export * from './lazy-provider'; export * from './lazy-provider';
export * from './types'; export * from './types';
export * from './utils'; export * from './utils';

View File

@@ -1,4 +1,4 @@
import type { PassiveDocProvider } from '@blocksuite/store'; import { assertExists } from '@blocksuite/global/utils';
import { import {
applyUpdate, applyUpdate,
type Doc, type Doc,
@@ -6,7 +6,8 @@ import {
encodeStateVector, encodeStateVector,
} from 'yjs'; } from 'yjs';
import type { DatasourceDocAdapter, StatusAdapter } from './types'; import type { DatasourceDocAdapter } from './data-source';
import type { StatusAdapter } from './types';
import type { Status } from './types'; import type { Status } from './types';
function getDoc(doc: Doc, guid: string): Doc | undefined { function getDoc(doc: Doc, guid: string): Doc | undefined {
@@ -26,6 +27,17 @@ interface LazyProviderOptions {
origin?: string; origin?: string;
} }
export type DocProvider = {
// backport from `@blocksuite/store`
passive: true;
sync(onlyRootDoc?: boolean): Promise<void>;
get connected(): boolean;
connect(): void;
disconnect(): void;
};
/** /**
* Creates a lazy provider that connects to a datasource and synchronizes a root document. * Creates a lazy provider that connects to a datasource and synchronizes a root document.
*/ */
@@ -33,12 +45,12 @@ export const createLazyProvider = (
rootDoc: Doc, rootDoc: Doc,
datasource: DatasourceDocAdapter, datasource: DatasourceDocAdapter,
options: LazyProviderOptions = {} options: LazyProviderOptions = {}
): Omit<PassiveDocProvider, 'flavour'> & StatusAdapter => { ): DocProvider & StatusAdapter => {
let connected = false; let connected = false;
const pendingMap = new Map<string, Uint8Array[]>(); // guid -> pending-updates const pendingMap = new Map<string, Uint8Array[]>(); // guid -> pending-updates
const disposableMap = new Map<string, Set<() => void>>(); const disposableMap = new Map<string, Set<() => void>>();
const connectedDocs = new Set<string>(); const connectedDocs = new Set<string>();
let datasourceUnsub: (() => void) | undefined; let abortController: AbortController | null = null;
const { origin = 'lazy-provider' } = options; const { origin = 'lazy-provider' } = options;
@@ -187,7 +199,8 @@ export const createLazyProvider = (
* Specifically, listens for updates to documents and applies them to the corresponding Yjs document. * Specifically, listens for updates to documents and applies them to the corresponding Yjs document.
*/ */
function setupDatasourceListeners() { function setupDatasourceListeners() {
datasourceUnsub = datasource.onDocUpdate?.((guid, update) => { assertExists(abortController, 'abortController should be defined');
const unsubscribe = datasource.onDocUpdate?.((guid, update) => {
if (!connected) { if (!connected) {
return; return;
} }
@@ -208,13 +221,16 @@ export const createLazyProvider = (
// This case happens when the father doc is not yet updated, // This case happens when the father doc is not yet updated,
// so that the child doc is not yet created. // so that the child doc is not yet created.
// We need to put it into cache so that it can be applied later. // We need to put it into cache so that it can be applied later.
console.warn('idb: doc not found', guid); console.warn('doc not found', guid);
pendingMap.set(guid, (pendingMap.get(guid) ?? []).concat(update)); pendingMap.set(guid, (pendingMap.get(guid) ?? []).concat(update));
} }
changeStatus({ changeStatus({
type: 'synced', type: 'synced',
}); });
}); });
abortController.signal.addEventListener('abort', () => {
unsubscribe?.();
});
} }
// when a subdoc is loaded, we need to sync it with the datasource and setup listeners // when a subdoc is loaded, we need to sync it with the datasource and setup listeners
@@ -258,6 +274,7 @@ export const createLazyProvider = (
*/ */
function connect() { function connect() {
connected = true; connected = true;
abortController = new AbortController();
changeStatus({ changeStatus({
type: 'syncing', type: 'syncing',
@@ -292,13 +309,32 @@ export const createLazyProvider = (
type: 'idle', type: 'idle',
}); });
disposeAll(); disposeAll();
datasourceUnsub?.(); assertExists(abortController, 'abortController should be defined');
datasourceUnsub = undefined; abortController.abort();
abortController = null;
} }
const syncDocRecursive = async (doc: Doc) => {
await syncDoc(doc);
await Promise.all(
[...doc.subdocs.values()].map(subdoc => syncDocRecursive(subdoc))
);
};
return { return {
sync: async onlyRootDoc => {
connected = true;
try {
if (onlyRootDoc) {
await syncDoc(rootDoc);
} else {
await syncDocRecursive(rootDoc);
}
} finally {
connected = false;
}
},
get status() { get status() {
console.log('currentStatus', currentStatus);
return currentStatus; return currentStatus;
}, },
subscribeStatusChange(cb: () => void) { subscribeStatusChange(cb: () => void) {

View File

@@ -29,29 +29,3 @@ export interface DocState {
*/ */
state?: Uint8Array; state?: Uint8Array;
} }
export interface DatasourceDocAdapter extends Partial<StatusAdapter> {
/**
* request diff update from other clients
*/
queryDocState: (
guid: string,
options?: {
stateVector?: Uint8Array;
targetClientId?: number;
}
) => Promise<DocState | false>;
/**
* send update to the datasource
*/
sendDocUpdate: (guid: string, update: Uint8Array) => Promise<void>;
/**
* listen to update from the datasource. Returns a function to unsubscribe.
* this is optional because some datasource might not support it
*/
onDocUpdate?(
callback: (guid: string, update: Uint8Array) => void
): () => void;
}