feat(nbstore): remove async on connection api (#9187)

We should not use async on `connect` and `disconnect`, for `WebSocketConnection` will never connect when offline.

We should handle the connection status of each storage in sync, using the `connection.waitForConnect`

This PR also puts the connection reference count on the `connect` and disconnect`
This commit is contained in:
EYHN
2024-12-18 03:59:49 +00:00
parent 3fddf050a4
commit 64b017dc1b
20 changed files with 160 additions and 226 deletions
@@ -23,7 +23,9 @@ test('doc', async () => {
type: 'workspace', type: 'workspace',
}); });
await docStorage.connect(); docStorage.connect();
await docStorage.waitForConnected();
const frontend1 = new DocFrontend(docStorage, null); const frontend1 = new DocFrontend(docStorage, null);
frontend1.start(); frontend1.start();
@@ -66,8 +68,11 @@ test('awareness', async () => {
type: 'workspace', type: 'workspace',
}); });
await storage1.connect(); storage1.connect();
await storage2.connect(); storage2.connect();
await storage1.waitForConnected();
await storage2.waitForConnected();
// peer a // peer a
const docA = new YDoc({ guid: 'test-doc' }); const docA = new YDoc({ guid: 'test-doc' });
@@ -44,9 +44,13 @@ test('doc', async () => {
const peerB = new SpaceStorage([peerBDoc]); const peerB = new SpaceStorage([peerBDoc]);
const peerC = new SpaceStorage([peerCDoc]); const peerC = new SpaceStorage([peerCDoc]);
await peerA.connect(); peerA.connect();
await peerB.connect(); peerB.connect();
await peerC.connect(); peerC.connect();
await peerA.waitForConnected();
await peerB.waitForConnected();
await peerC.waitForConnected();
await peerA.get('doc').pushDocUpdate({ await peerA.get('doc').pushDocUpdate({
docId: 'doc1', docId: 'doc1',
@@ -121,6 +125,18 @@ test('blob', async () => {
type: 'workspace', type: 'workspace',
}); });
const peerA = new SpaceStorage([a]);
const peerB = new SpaceStorage([b]);
const peerC = new SpaceStorage([c]);
peerA.connect();
peerB.connect();
peerC.connect();
await peerA.waitForConnected();
await peerB.waitForConnected();
await peerC.waitForConnected();
await a.set({ await a.set({
key: 'test', key: 'test',
data: new Uint8Array([1, 2, 3, 4]), data: new Uint8Array([1, 2, 3, 4]),
@@ -135,14 +151,6 @@ test('blob', async () => {
createdAt: new Date(100), createdAt: new Date(100),
}); });
const peerA = new SpaceStorage([a]);
const peerB = new SpaceStorage([b]);
const peerC = new SpaceStorage([c]);
await peerA.connect();
await peerB.connect();
await peerC.connect();
const sync = new Sync(peerA, [peerB, peerC]); const sync = new Sync(peerA, [peerB, peerC]);
sync.start(); sync.start();
@@ -1,4 +1,5 @@
import EventEmitter2 from 'eventemitter2'; import EventEmitter2 from 'eventemitter2';
import { throttle } from 'lodash-es';
export type ConnectionStatus = export type ConnectionStatus =
| 'idle' | 'idle'
@@ -13,6 +14,8 @@ export abstract class Connection<T = any> {
private _status: ConnectionStatus = 'idle'; private _status: ConnectionStatus = 'idle';
protected error?: Error; protected error?: Error;
private refCount = 0; private refCount = 0;
private _enableAutoReconnect = false;
private connectingAbort?: AbortController;
constructor() { constructor() {
this.autoReconnect(); this.autoReconnect();
@@ -45,7 +48,7 @@ export abstract class Connection<T = any> {
} }
protected setStatus(status: ConnectionStatus, error?: Error) { protected setStatus(status: ConnectionStatus, error?: Error) {
const shouldEmit = status !== this._status && error !== this.error; const shouldEmit = status !== this._status || error !== this.error;
this._status = status; this._status = status;
this.error = error; this.error = error;
if (shouldEmit) { if (shouldEmit) {
@@ -53,45 +56,56 @@ export abstract class Connection<T = any> {
} }
} }
abstract doConnect(): Promise<T>; protected abstract doConnect(signal?: AbortSignal): Promise<T>;
abstract doDisconnect(conn: T): Promise<void>; protected abstract doDisconnect(conn: T): void;
ref() { private innerConnect() {
this.refCount++;
}
deref() {
this.refCount = Math.max(0, this.refCount - 1);
}
async connect() {
if (this.status === 'idle' || this.status === 'error') { if (this.status === 'idle' || this.status === 'error') {
this._enableAutoReconnect = true;
this.setStatus('connecting'); this.setStatus('connecting');
try { this.connectingAbort = new AbortController();
this._inner = await this.doConnect(); this.doConnect(this.connectingAbort.signal)
this.setStatus('connected'); .then(value => {
} catch (error) { if (!this.connectingAbort?.signal.aborted) {
this.setStatus('error', error as any); this.setStatus('connected');
} this._inner = value;
} else {
try {
this.doDisconnect(value);
} catch (error) {
console.error('failed to disconnect', error);
}
}
})
.catch(error => {
if (!this.connectingAbort?.signal.aborted) {
this.setStatus('error', error as any);
}
});
} }
} }
async disconnect() { connect() {
this.deref(); this.refCount++;
if (this.refCount > 0) { if (this.refCount === 1) {
return; this.innerConnect();
} }
}
if (this.status === 'connected') { disconnect() {
this.refCount--;
if (this.refCount === 0) {
this._enableAutoReconnect = false;
this.connectingAbort?.abort();
try { try {
if (this._inner) { if (this._inner) {
await this.doDisconnect(this._inner); this.doDisconnect(this._inner);
this._inner = null;
} }
this.setStatus('closed');
} catch (error) { } catch (error) {
this.setStatus('error', error as any); console.error('failed to disconnect', error);
} }
this.setStatus('closed');
this._inner = null;
} }
} }
@@ -99,9 +113,15 @@ export abstract class Connection<T = any> {
// TODO: // TODO:
// - maximum retry count // - maximum retry count
// - dynamic sleep time (attempt < 3 ? 1s : 1min)? // - dynamic sleep time (attempt < 3 ? 1s : 1min)?
this.onStatusChanged(() => { this.onStatusChanged(
this.connect().catch(() => {}); throttle(() => {
}); () => {
if (this._enableAutoReconnect) {
this.innerConnect();
}
};
}, 1000)
);
} }
waitForConnected(signal?: AbortSignal) { waitForConnected(signal?: AbortSignal) {
@@ -146,6 +166,6 @@ export class DummyConnection extends Connection<undefined> {
} }
doDisconnect() { doDisconnect() {
return Promise.resolve(undefined); return;
} }
} }
@@ -11,12 +11,10 @@ export function share<T extends Connection<any>>(conn: T): T {
const existing = CONNECTIONS.get(conn.shareId); const existing = CONNECTIONS.get(conn.shareId);
if (existing) { if (existing) {
existing.ref();
return existing as T; return existing as T;
} }
CONNECTIONS.set(conn.shareId, conn); CONNECTIONS.set(conn.shareId, conn);
conn.ref();
return conn; return conn;
} }
@@ -12,7 +12,7 @@ export class BroadcastChannelConnection extends Connection<BroadcastChannel> {
return new BroadcastChannel(this.channelName); return new BroadcastChannel(this.channelName);
} }
override async doDisconnect() { override doDisconnect() {
this.close(); this.close();
} }
@@ -25,10 +25,6 @@ export class CloudAwarenessStorage extends AwarenessStorage<CloudAwarenessStorag
return this.connection.inner; return this.connection.inner;
} }
override async connect(): Promise<void> {
await super.connect();
}
override async update(record: AwarenessRecord): Promise<void> { override async update(record: AwarenessRecord): Promise<void> {
const encodedUpdate = await uint8ArrayToBase64(record.bin); const encodedUpdate = await uint8ArrayToBase64(record.bin);
this.socket.emit('space:update-awareness', { this.socket.emit('space:update-awareness', {
@@ -44,6 +40,7 @@ export class CloudAwarenessStorage extends AwarenessStorage<CloudAwarenessStorag
onUpdate: (update: AwarenessRecord, origin?: string) => void, onUpdate: (update: AwarenessRecord, origin?: string) => void,
onCollect: () => AwarenessRecord onCollect: () => AwarenessRecord
): () => void { ): () => void {
// TODO: handle disconnect
// leave awareness // leave awareness
const leave = () => { const leave = () => {
this.socket.emit('space:leave-awareness', { this.socket.emit('space:leave-awareness', {
+21 -12
View File
@@ -24,29 +24,38 @@ export class CloudDocStorage extends DocStorage<CloudDocStorageOptions> {
new SocketConnection(this.peer, this.options.socketOptions) new SocketConnection(this.peer, this.options.socketOptions)
); );
private disposeConnectionStatusListener?: () => void;
private get socket() { private get socket() {
return this.connection.inner; return this.connection.inner;
} }
override async connect(): Promise<void> { override connect() {
await super.connect(); if (!this.disposeConnectionStatusListener) {
this.connection.onStatusChanged(status => { this.disposeConnectionStatusListener = this.connection.onStatusChanged(
if (status === 'connected') { status => {
this.join().catch(err => { if (status === 'connected') {
console.error('doc storage join failed', err); this.join().catch(err => {
}); console.error('doc storage join failed', err);
this.socket.on('space:broadcast-doc-update', this.onServerUpdate); });
} this.socket.on('space:broadcast-doc-update', this.onServerUpdate);
}); }
}
);
}
super.connect();
} }
override async disconnect(): Promise<void> { override disconnect() {
if (this.disposeConnectionStatusListener) {
this.disposeConnectionStatusListener();
}
this.socket.emit('space:leave', { this.socket.emit('space:leave', {
spaceType: this.spaceType, spaceType: this.spaceType,
spaceId: this.spaceId, spaceId: this.spaceId,
}); });
this.socket.off('space:broadcast-doc-update', this.onServerUpdate); this.socket.off('space:broadcast-doc-update', this.onServerUpdate);
await super.connect(); super.disconnect();
} }
async join() { async join() {
@@ -184,7 +184,7 @@ export class SocketConnection extends Connection<Socket> {
return conn; return conn;
} }
override async doDisconnect(conn: Socket) { override doDisconnect(conn: Socket) {
conn.close(); conn.close();
} }
+8 -9
View File
@@ -25,7 +25,8 @@ export class IDBConnection extends Connection<{
blocking: () => { blocking: () => {
// if, for example, an tab with newer version is opened, this function will be called. // if, for example, an tab with newer version is opened, this function will be called.
// we should close current connection to allow the new version to upgrade the db. // we should close current connection to allow the new version to upgrade the db.
this.close( this.setStatus(
'closed',
new Error('Blocking a new version. Closing the connection.') new Error('Blocking a new version. Closing the connection.')
); );
}, },
@@ -38,13 +39,11 @@ export class IDBConnection extends Connection<{
}; };
} }
override async doDisconnect() { override doDisconnect(db: {
this.close(); db: IDBPDatabase<DocStorageSchema>;
} channel: BroadcastChannel;
}) {
private close(error?: Error) { db.channel.close();
this.maybeConnection?.channel.close(); db.db.close();
this.maybeConnection?.db.close();
this.setStatus('closed', error);
} }
} }
+1 -2
View File
@@ -1,4 +1,3 @@
import { share } from '../../connection';
import { import {
type DocClock, type DocClock,
type DocClocks, type DocClocks,
@@ -17,7 +16,7 @@ interface ChannelMessage {
} }
export class IndexedDBDocStorage extends DocStorage { export class IndexedDBDocStorage extends DocStorage {
readonly connection = share(new IDBConnection(this.options)); readonly connection = new IDBConnection(this.options);
get db() { get db() {
return this.connection.inner.db; return this.connection.inner.db;
@@ -28,7 +28,7 @@ export class DocIDBConnection extends Connection<IDBPDatabase<DocDBSchema>> {
}); });
} }
override async doDisconnect(conn: IDBPDatabase<DocDBSchema>) { override doDisconnect(conn: IDBPDatabase<DocDBSchema>) {
conn.close(); conn.close();
} }
} }
@@ -57,7 +57,7 @@ export class BlobIDBConnection extends Connection<IDBPDatabase<BlobDBSchema>> {
}); });
} }
override async doDisconnect(conn: IDBPDatabase<BlobDBSchema>) { override doDisconnect(conn: IDBPDatabase<BlobDBSchema>) {
conn.close(); conn.close();
} }
} }
+6 -19
View File
@@ -1,6 +1,6 @@
import { apis, events } from '@affine/electron-api'; import { apis } from '@affine/electron-api';
import { Connection, type ConnectionStatus } from '../../connection'; import { Connection } from '../../connection';
import { type SpaceType, universalId } from '../../storage'; import { type SpaceType, universalId } from '../../storage';
type NativeDBApis = NonNullable<typeof apis>['nbstore'] extends infer APIs type NativeDBApis = NonNullable<typeof apis>['nbstore'] extends infer APIs
@@ -27,7 +27,6 @@ export class NativeDBConnection extends Connection<void> {
} }
this.apis = this.bindApis(apis.nbstore); this.apis = this.bindApis(apis.nbstore);
this.listenToConnectionEvents();
} }
override get shareId(): string { override get shareId(): string {
@@ -63,21 +62,9 @@ export class NativeDBConnection extends Connection<void> {
await this.apis.connect(); await this.apis.connect();
} }
override async doDisconnect() { override doDisconnect() {
await this.apis.close(); this.apis.close().catch(err => {
} console.error('NativeDBConnection close failed', err);
});
private listenToConnectionEvents() {
events?.nbstore.onConnectionStatusChanged(
({ peer, spaceType, spaceId, status, error }) => {
if (
peer === this.peer &&
spaceType === this.type &&
spaceId === this.id
) {
this.setStatus(status as ConnectionStatus, error);
}
}
);
} }
} }
@@ -42,15 +42,6 @@ export class SpaceStorageConsumer extends SpaceStorage {
}); });
this.consumer.register('connect', this.connect.bind(this)); this.consumer.register('connect', this.connect.bind(this));
this.consumer.register('disconnect', this.disconnect.bind(this)); this.consumer.register('disconnect', this.disconnect.bind(this));
this.consumer.register('connection', () => {
return new Observable(subscriber => {
subscriber.add(
this.on('connection', payload => {
subscriber.next(payload);
})
);
});
});
this.consumer.register('destroy', this.destroy.bind(this)); this.consumer.register('destroy', this.destroy.bind(this));
} }
+17 -50
View File
@@ -1,6 +1,5 @@
import EventEmitter2 from 'eventemitter2'; import EventEmitter2 from 'eventemitter2';
import type { ConnectionStatus } from '../connection';
import type { AwarenessStorage } from './awareness'; import type { AwarenessStorage } from './awareness';
import type { BlobStorage } from './blob'; import type { BlobStorage } from './blob';
import type { DocStorage } from './doc'; import type { DocStorage } from './doc';
@@ -39,59 +38,27 @@ export class SpaceStorage {
return storage as Extract<Storages, { storageType: T }>; return storage as Extract<Storages, { storageType: T }>;
} }
async connect() { connect() {
await Promise.allSettled( Array.from(this.storages.values()).forEach(storage => {
Array.from(this.storages.values()).map(async storage => { storage.connect();
// FIXME: multiple calls will register multiple listeners });
this.disposables.add( }
storage.connection.onStatusChanged((status, error) => {
this.event.emit('connection', { disconnect() {
storage: storage.storageType, Array.from(this.storages.values()).forEach(storage => {
status, storage.disconnect();
error, });
}); }
})
); async waitForConnected() {
await storage.connect(); await Promise.all(
}) Array.from(this.storages.values()).map(storage =>
storage.waitForConnected()
)
); );
} }
async disconnect() {
await Promise.allSettled(
Array.from(this.storages.values()).map(async storage => {
await storage.disconnect();
})
);
}
on(
event: 'connection',
cb: (payload: {
storage: StorageType;
status: ConnectionStatus;
error?: Error;
}) => void
): () => void {
this.event.on(event, cb);
return () => {
this.event.off(event, cb);
};
}
off(
event: 'connection',
cb: (payload: {
storage: StorageType;
status: ConnectionStatus;
error?: Error;
}) => void
): void {
this.event.off(event, cb);
}
async destroy() { async destroy() {
await this.disconnect();
this.disposables.forEach(disposable => disposable()); this.disposables.forEach(disposable => disposable());
this.event.removeAllListeners(); this.event.removeAllListeners();
this.storages.clear(); this.storages.clear();
@@ -102,11 +102,15 @@ export abstract class Storage<Opts extends StorageOptions = StorageOptions> {
constructor(public readonly options: Opts) {} constructor(public readonly options: Opts) {}
async connect() { connect() {
await this.connection.connect(); this.connection.connect();
} }
async disconnect() { disconnect() {
await this.connection.disconnect(); this.connection.disconnect();
}
async waitForConnected() {
await this.connection.waitForConnected();
} }
} }
@@ -1,10 +1,5 @@
import { dialogHandlers } from './dialog'; import { dialogHandlers } from './dialog';
import { import { dbEventsV1, dbHandlersV1, nbstoreHandlers } from './nbstore';
dbEventsV1,
dbHandlersV1,
nbstoreEvents,
nbstoreHandlers,
} from './nbstore';
import { provideExposed } from './provide'; import { provideExposed } from './provide';
import { workspaceEvents, workspaceHandlers } from './workspace'; import { workspaceEvents, workspaceHandlers } from './workspace';
@@ -18,7 +13,6 @@ export const handlers = {
export const events = { export const events = {
db: dbEventsV1, db: dbEventsV1,
workspace: workspaceEvents, workspace: workspaceEvents,
nbstore: nbstoreEvents,
}; };
const getExposedMeta = () => { const getExposedMeta = () => {
@@ -33,8 +33,14 @@ export class NativeDBConnection extends Connection<NativeDocStorage> {
return conn; return conn;
} }
override async doDisconnect(conn: NativeDocStorage) { override doDisconnect(conn: NativeDocStorage) {
await conn.close(); conn
logger.info('[nbstore] connection closed', this.shareId); .close()
.then(() => {
logger.info('[nbstore] connection closed', this.shareId);
})
.catch(err => {
logger.error('[nbstore] connection close failed', this.shareId, err);
});
} }
} }
@@ -4,13 +4,7 @@ import {
type DocUpdate, type DocUpdate,
} from '@affine/nbstore'; } from '@affine/nbstore';
import type { MainEventRegister } from '../type'; import { ensureStorage, getStorage } from './storage';
import {
type ConnectionStatus,
ensureStorage,
getStorage,
onConnectionChanged,
} from './storage';
export const nbstoreHandlers = { export const nbstoreHandlers = {
connect: async (id: string) => { connect: async (id: string) => {
@@ -21,7 +15,7 @@ export const nbstoreHandlers = {
const store = getStorage(id); const store = getStorage(id);
if (store) { if (store) {
await store.disconnect(); store.disconnect();
// The store may be shared with other tabs, so we don't delete it from cache // The store may be shared with other tabs, so we don't delete it from cache
// the underlying connection will handle the close correctly // the underlying connection will handle the close correctly
// STORE_CACHE.delete(`${spaceType}:${spaceId}`); // STORE_CACHE.delete(`${spaceType}:${spaceId}`);
@@ -132,12 +126,3 @@ export const nbstoreHandlers = {
return store.get('sync').clearClocks(); return store.get('sync').clearClocks();
}, },
}; };
export const nbstoreEvents = {
onConnectionStatusChanged: (fn: (payload: ConnectionStatus) => void) => {
const sub = onConnectionChanged(fn);
return () => {
sub.unsubscribe();
};
},
} satisfies Record<string, MainEventRegister>;
@@ -1,4 +1,4 @@
export { nbstoreEvents, nbstoreHandlers } from './handlers'; export { nbstoreHandlers } from './handlers';
export * from './storage'; export * from './storage';
export { dbEvents as dbEventsV1, dbHandlers as dbHandlersV1 } from './v1'; export { dbEvents as dbEventsV1, dbHandlers as dbHandlersV1 } from './v1';
export { universalId } from '@affine/nbstore'; export { universalId } from '@affine/nbstore';
@@ -1,10 +1,4 @@
import { import { parseUniversalId, SpaceStorage } from '@affine/nbstore';
parseUniversalId,
SpaceStorage,
type SpaceType,
type StorageType,
} from '@affine/nbstore';
import { Subject } from 'rxjs';
import { applyUpdate, Doc as YDoc } from 'yjs'; import { applyUpdate, Doc as YDoc } from 'yjs';
import { logger } from '../logger'; import { logger } from '../logger';
@@ -57,18 +51,8 @@ export class SqliteSpaceStorage extends SpaceStorage {
} }
const STORE_CACHE = new Map<string, SqliteSpaceStorage>(); const STORE_CACHE = new Map<string, SqliteSpaceStorage>();
export interface ConnectionStatus {
peer: string;
spaceType: SpaceType;
spaceId: string;
storage: StorageType;
status: string;
error?: Error;
}
const CONNECTION$ = new Subject<ConnectionStatus>();
process.on('beforeExit', () => { process.on('beforeExit', () => {
CONNECTION$.complete();
STORE_CACHE.forEach(store => { STORE_CACHE.forEach(store => {
store.destroy().catch(err => { store.destroy().catch(err => {
logger.error('[nbstore] destroy store failed', err); logger.error('[nbstore] destroy store failed', err);
@@ -76,10 +60,6 @@ process.on('beforeExit', () => {
}); });
}); });
export function onConnectionChanged(fn: (payload: ConnectionStatus) => void) {
return CONNECTION$.subscribe({ next: fn });
}
export function getStorage(universalId: string) { export function getStorage(universalId: string) {
return STORE_CACHE.get(universalId); return STORE_CACHE.get(universalId);
} }
@@ -101,24 +81,9 @@ export async function ensureStorage(universalId: string) {
new SqliteSyncStorage(opts), new SqliteSyncStorage(opts),
]); ]);
store.on('connection', ({ storage, status, error }) => { store.connect();
CONNECTION$.next({
peer,
spaceType: type,
spaceId: id,
storage,
status,
error,
});
logger.info(
`[nbstore] status changed: ${status}, spaceType: ${type}, spaceId: ${id}, storage: ${storage}`
);
if (error) {
logger.error(`[nbstore] connection error: ${error}`);
}
});
await store.connect(); await store.waitForConnected();
STORE_CACHE.set(universalId, store); STORE_CACHE.set(universalId, store);
} }