feat(server): sync data with ack (#4791)

This commit is contained in:
liuyi
2023-11-02 09:05:28 +00:00
committed by GitHub
parent af9663d3e7
commit 6a93203d68
11 changed files with 697 additions and 133 deletions
+94 -13
View File
@@ -1,4 +1,8 @@
import { Counter, Gauge, Summary } from 'prom-client'; import { Counter, Gauge, register, Summary } from 'prom-client';
function getOr<T>(name: string, or: () => T): T {
return (register.getSingleMetric(name) as T) || or();
}
type LabelValues<T extends string> = Partial<Record<T, string | number>>; type LabelValues<T extends string> = Partial<Record<T, string | number>>;
type MetricsCreator<T extends string> = ( type MetricsCreator<T extends string> = (
@@ -14,11 +18,15 @@ export const metricsCreatorGenerator = () => {
name: string, name: string,
labelNames?: T[] labelNames?: T[]
): MetricsCreator<T> => { ): MetricsCreator<T> => {
const counter = new Counter({ const counter = getOr(
name, name,
help: name, () =>
...(labelNames ? { labelNames } : {}), new Counter({
}); name,
help: name,
...(labelNames ? { labelNames } : {}),
})
);
return (value: number, labels: LabelValues<T>) => { return (value: number, labels: LabelValues<T>) => {
counter.inc(labels, value); counter.inc(labels, value);
@@ -29,11 +37,15 @@ export const metricsCreatorGenerator = () => {
name: string, name: string,
labelNames?: T[] labelNames?: T[]
): MetricsCreator<T> => { ): MetricsCreator<T> => {
const gauge = new Gauge({ const gauge = getOr(
name, name,
help: name, () =>
...(labelNames ? { labelNames } : {}), new Gauge({
}); name,
help: name,
...(labelNames ? { labelNames } : {}),
})
);
return (value: number, labels: LabelValues<T>) => { return (value: number, labels: LabelValues<T>) => {
gauge.set(labels, value); gauge.set(labels, value);
@@ -44,11 +56,15 @@ export const metricsCreatorGenerator = () => {
name: string, name: string,
labelNames?: T[] labelNames?: T[]
): TimerMetricsCreator<T> => { ): TimerMetricsCreator<T> => {
const summary = new Summary({ const summary = getOr(
name, name,
help: name, () =>
...(labelNames ? { labelNames } : {}), new Summary({
}); name,
help: name,
...(labelNames ? { labelNames } : {}),
})
);
return (labels: LabelValues<T>) => { return (labels: LabelValues<T>) => {
const now = process.hrtime(); const now = process.hrtime();
@@ -71,3 +87,68 @@ export const metricsCreatorGenerator = () => {
}; };
export const metricsCreator = metricsCreatorGenerator(); export const metricsCreator = metricsCreatorGenerator();
export const CallTimer = (
name: string,
labels: Record<string, any> = {}
): MethodDecorator => {
const timer = metricsCreator.timer(name, Object.keys(labels));
// @ts-expect-error allow
return (
_target,
_key,
desc: TypedPropertyDescriptor<(...args: any[]) => any>
) => {
const originalMethod = desc.value;
if (!originalMethod) {
return desc;
}
desc.value = function (...args: any[]) {
const endTimer = timer(labels);
let result: any;
try {
result = originalMethod.apply(this, args);
} catch (e) {
endTimer();
throw e;
}
if (result instanceof Promise) {
return result.finally(endTimer);
} else {
endTimer();
return result;
}
};
return desc;
};
};
export const CallCounter = (
name: string,
labels: Record<string, any> = {}
): MethodDecorator => {
const count = metricsCreator.counter(name, Object.keys(labels));
// @ts-expect-error allow
return (
_target,
_key,
desc: TypedPropertyDescriptor<(...args: any[]) => any>
) => {
const originalMethod = desc.value;
if (!originalMethod) {
return desc;
}
desc.value = function (...args: any[]) {
count(1, labels);
return originalMethod.apply(this, args);
};
return desc;
};
};
@@ -6,6 +6,7 @@ import {
OnModuleInit, OnModuleInit,
} from '@nestjs/common'; } from '@nestjs/common';
import { Snapshot, Update } from '@prisma/client'; import { Snapshot, Update } from '@prisma/client';
import { chunk } from 'lodash-es';
import { defer, retry } from 'rxjs'; import { defer, retry } from 'rxjs';
import { applyUpdate, Doc, encodeStateAsUpdate, encodeStateVector } from 'yjs'; import { applyUpdate, Doc, encodeStateAsUpdate, encodeStateVector } from 'yjs';
@@ -89,10 +90,10 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
protected applyUpdates(guid: string, ...updates: Buffer[]): Doc { protected applyUpdates(guid: string, ...updates: Buffer[]): Doc {
const doc = this.recoverDoc(...updates); const doc = this.recoverDoc(...updates);
this.metrics.jwstCodecMerge(1, {});
// test jwst codec // test jwst codec
if (this.config.doc.manager.experimentalMergeWithJwstCodec) { if (this.config.doc.manager.experimentalMergeWithJwstCodec) {
this.metrics.jwstCodecMerge(1, {});
const yjsResult = Buffer.from(encodeStateAsUpdate(doc)); const yjsResult = Buffer.from(encodeStateAsUpdate(doc));
let log = false; let log = false;
try { try {
@@ -163,7 +164,12 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
/** /**
* add update to manager for later processing. * add update to manager for later processing.
*/ */
async push(workspaceId: string, guid: string, update: Buffer) { async push(
workspaceId: string,
guid: string,
update: Buffer,
retryTimes = 10
) {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
defer(async () => { defer(async () => {
const seq = await this.getUpdateSeq(workspaceId, guid); const seq = await this.getUpdateSeq(workspaceId, guid);
@@ -176,7 +182,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
}, },
}); });
}) })
.pipe(retry(MAX_SEQ_NUM)) // retry until seq num not conflict .pipe(retry(retryTimes)) // retry until seq num not conflict
.subscribe({ .subscribe({
next: () => { next: () => {
this.logger.verbose( this.logger.verbose(
@@ -184,7 +190,54 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
); );
resolve(); resolve();
}, },
error: reject, error: e => {
this.logger.error('Failed to push updates', e);
reject(new Error('Failed to push update'));
},
});
});
}
async batchPush(
workspaceId: string,
guid: string,
updates: Buffer[],
retryTimes = 10
) {
await new Promise<void>((resolve, reject) => {
defer(async () => {
const seq = await this.getUpdateSeq(workspaceId, guid, updates.length);
let turn = 0;
const batchCount = 10;
for (const batch of chunk(updates, batchCount)) {
await this.db.update.createMany({
data: batch.map((update, i) => ({
workspaceId,
id: guid,
// `seq` is the last seq num of the batch
// example for 11 batched updates, start from seq num 20
// seq for first update in the batch should be:
// 31 - 11 + 0 * 10 + 0 + 1 = 21
// ^ last seq num ^ updates.length ^ turn ^ batchCount ^i
seq: seq - updates.length + turn * batchCount + i + 1,
blob: update,
})),
});
turn++;
}
})
.pipe(retry(retryTimes)) // retry until seq num not conflict
.subscribe({
next: () => {
this.logger.verbose(
`pushed updates for workspace: ${workspaceId}, guid: ${guid}`
);
resolve();
},
error: e => {
this.logger.error('Failed to push updates', e);
reject(new Error('Failed to push update'));
},
}); });
}); });
} }
@@ -370,7 +423,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
return doc; return doc;
} }
private async getUpdateSeq(workspaceId: string, guid: string) { private async getUpdateSeq(workspaceId: string, guid: string, batch = 1) {
try { try {
const { seq } = await this.db.snapshot.update({ const { seq } = await this.db.snapshot.update({
select: { select: {
@@ -384,13 +437,13 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
}, },
data: { data: {
seq: { seq: {
increment: 1, increment: batch,
}, },
}, },
}); });
// reset // reset
if (seq === MAX_SEQ_NUM) { if (seq >= MAX_SEQ_NUM) {
await this.db.snapshot.update({ await this.db.snapshot.update({
where: { where: {
id_workspaceId: { id_workspaceId: {
@@ -406,9 +459,10 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
return seq; return seq;
} catch { } catch {
// not existing snapshot just count it from 1
const last = this.seqMap.get(workspaceId + guid) ?? 0; const last = this.seqMap.get(workspaceId + guid) ?? 0;
this.seqMap.set(workspaceId + guid, last + 1); this.seqMap.set(workspaceId + guid, last + batch);
return last + 1; return last + batch;
} }
} }
} }
@@ -0,0 +1,81 @@
enum EventErrorCode {
WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND',
DOC_NOT_FOUND = 'DOC_NOT_FOUND',
NOT_IN_WORKSPACE = 'NOT_IN_WORKSPACE',
ACCESS_DENIED = 'ACCESS_DENIED',
INTERNAL = 'INTERNAL',
VERSION_REJECTED = 'VERSION_REJECTED',
}
// Such errore are generally raised from the gateway handling to user,
// the stack must be full of internal code,
// so there is no need to inherit from `Error` class.
export class EventError {
constructor(
public readonly code: EventErrorCode,
public readonly message: string
) {}
toJSON() {
return {
code: this.code,
message: this.message,
};
}
}
export class WorkspaceNotFoundError extends EventError {
constructor(public readonly workspaceId: string) {
super(
EventErrorCode.WORKSPACE_NOT_FOUND,
`You are trying to access an unknown workspace ${workspaceId}.`
);
}
}
export class DocNotFoundError extends EventError {
constructor(
public readonly workspaceId: string,
public readonly docId: string
) {
super(
EventErrorCode.DOC_NOT_FOUND,
`You are trying to access an unknown doc ${docId} under workspace ${workspaceId}.`
);
}
}
export class NotInWorkspaceError extends EventError {
constructor(public readonly workspaceId: string) {
super(
EventErrorCode.NOT_IN_WORKSPACE,
`You should join in workspace ${workspaceId} before broadcasting messages.`
);
}
}
export class AccessDeniedError extends EventError {
constructor(public readonly workspaceId: string) {
super(
EventErrorCode.ACCESS_DENIED,
`You have no permission to access workspace ${workspaceId}.`
);
}
}
export class InternalError extends EventError {
constructor(public readonly error: Error) {
super(EventErrorCode.INTERNAL, `Internal error happened: ${error.message}`);
}
}
export class VersionRejectedError extends EventError {
constructor(public readonly version: number) {
super(
EventErrorCode.VERSION_REJECTED,
// TODO: Too general error message,
// need to be more specific when versioning system is implemented.
`The version ${version} is rejected by server.`
);
}
}
@@ -1,10 +1,10 @@
import { Logger } from '@nestjs/common'; import { applyDecorators, Logger } from '@nestjs/common';
import { import {
ConnectedSocket, ConnectedSocket,
MessageBody, MessageBody,
OnGatewayConnection, OnGatewayConnection,
OnGatewayDisconnect, OnGatewayDisconnect,
SubscribeMessage, SubscribeMessage as RawSubscribeMessage,
WebSocketGateway, WebSocketGateway,
WebSocketServer, WebSocketServer,
} from '@nestjs/websockets'; } from '@nestjs/websockets';
@@ -12,12 +12,40 @@ import { Server, Socket } from 'socket.io';
import { encodeStateAsUpdate, encodeStateVector } from 'yjs'; import { encodeStateAsUpdate, encodeStateVector } from 'yjs';
import { Metrics } from '../../../metrics/metrics'; import { Metrics } from '../../../metrics/metrics';
import { CallCounter, CallTimer } from '../../../metrics/utils';
import { DocID } from '../../../utils/doc'; import { DocID } from '../../../utils/doc';
import { Auth, CurrentUser } from '../../auth'; import { Auth, CurrentUser } from '../../auth';
import { DocManager } from '../../doc'; import { DocManager } from '../../doc';
import { UserType } from '../../users'; import { UserType } from '../../users';
import { PermissionService } from '../../workspaces/permission'; import { PermissionService } from '../../workspaces/permission';
import { Permission } from '../../workspaces/types'; import { Permission } from '../../workspaces/types';
import {
AccessDeniedError,
DocNotFoundError,
EventError,
InternalError,
NotInWorkspaceError,
WorkspaceNotFoundError,
} from './error';
const SubscribeMessage = (event: string) =>
applyDecorators(
CallCounter('socket_io_counter', { event }),
CallTimer('socket_io_timer', { event }),
RawSubscribeMessage(event)
);
type EventResponse<Data = any> =
| {
error: EventError;
}
| (Data extends never
? {
data?: never;
}
: {
data: Data;
});
@WebSocketGateway({ @WebSocketGateway({
cors: process.env.NODE_ENV !== 'production', cors: process.env.NODE_ENV !== 'production',
@@ -52,38 +80,50 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
@CurrentUser() user: UserType, @CurrentUser() user: UserType,
@MessageBody() workspaceId: string, @MessageBody() workspaceId: string,
@ConnectedSocket() client: Socket @ConnectedSocket() client: Socket
) { ): Promise<EventResponse<{ clientId: string }>> {
this.metric.socketIOEventCounter(1, { event: 'client-handshake' });
const endTimer = this.metric.socketIOEventTimer({
event: 'client-handshake',
});
const canWrite = await this.permissions.tryCheck( const canWrite = await this.permissions.tryCheck(
workspaceId, workspaceId,
user.id, user.id,
Permission.Write Permission.Write
); );
if (canWrite) await client.join(workspaceId);
endTimer(); if (canWrite) {
return canWrite; await client.join(workspaceId);
return {
data: {
clientId: client.id,
},
};
} else {
return {
error: new AccessDeniedError(workspaceId),
};
}
} }
@SubscribeMessage('client-leave') @SubscribeMessage('client-leave')
async handleClientLeave( async handleClientLeave(
@MessageBody() workspaceId: string, @MessageBody() workspaceId: string,
@ConnectedSocket() client: Socket @ConnectedSocket() client: Socket
) { ): Promise<EventResponse> {
this.metric.socketIOEventCounter(1, { event: 'client-leave' }); if (client.rooms.has(workspaceId)) {
const endTimer = this.metric.socketIOEventTimer({ await client.leave(workspaceId);
event: 'client-leave', return {};
}); } else {
await client.leave(workspaceId); return {
endTimer(); error: new NotInWorkspaceError(workspaceId),
};
}
} }
/**
* This is the old version of the `client-update` event without any data protocol.
* It only exists for backwards compatibility to adapt older clients.
*
* @deprecated
*/
@SubscribeMessage('client-update') @SubscribeMessage('client-update')
async handleClientUpdate( async handleClientUpdateV1(
@MessageBody() @MessageBody()
{ {
workspaceId, workspaceId,
@@ -96,31 +136,37 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
}, },
@ConnectedSocket() client: Socket @ConnectedSocket() client: Socket
) { ) {
this.metric.socketIOEventCounter(1, { event: 'client-update' });
const endTimer = this.metric.socketIOEventTimer({ event: 'client-update' });
if (!client.rooms.has(workspaceId)) { if (!client.rooms.has(workspaceId)) {
this.logger.verbose( this.logger.verbose(
`Client ${client.id} tried to push update to workspace ${workspaceId} without joining it first` `Client ${client.id} tried to push update to workspace ${workspaceId} without joining it first`
); );
endTimer();
return; return;
} }
const docId = new DocID(guid, workspaceId); const docId = new DocID(guid, workspaceId);
client client
.to(docId.workspace) .to(docId.workspace)
.emit('server-update', { workspaceId, guid, update }); .emit('server-update', { workspaceId, guid, update });
const buf = Buffer.from(update, 'base64'); // broadcast to all clients with newer version that only listen to `server-updates`
client
.to(docId.workspace)
.emit('server-updates', { workspaceId, guid, updates: [update] });
const buf = Buffer.from(update, 'base64');
await this.docManager.push(docId.workspace, docId.guid, buf); await this.docManager.push(docId.workspace, docId.guid, buf);
endTimer();
} }
/**
* This is the old version of the `doc-load` event without any data protocol.
* It only exists for backwards compatibility to adapt older clients.
*
* @deprecated
*/
@Auth() @Auth()
@SubscribeMessage('doc-load') @SubscribeMessage('doc-load')
async loadDoc( async loadDocV1(
@ConnectedSocket() client: Socket, @ConnectedSocket() client: Socket,
@CurrentUser() user: UserType, @CurrentUser() user: UserType,
@MessageBody() @MessageBody()
@@ -134,12 +180,9 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
stateVector?: string; stateVector?: string;
} }
): Promise<{ missing: string; state?: string } | false> { ): Promise<{ missing: string; state?: string } | false> {
this.metric.socketIOEventCounter(1, { event: 'doc-load' });
const endTimer = this.metric.socketIOEventTimer({ event: 'doc-load' });
if (!client.rooms.has(workspaceId)) { if (!client.rooms.has(workspaceId)) {
const canRead = await this.permissions.tryCheck(workspaceId, user.id); const canRead = await this.permissions.tryCheck(workspaceId, user.id);
if (!canRead) { if (!canRead) {
endTimer();
return false; return false;
} }
} }
@@ -148,7 +191,6 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
const doc = await this.docManager.get(docId.workspace, docId.guid); const doc = await this.docManager.get(docId.workspace, docId.guid);
if (!doc) { if (!doc) {
endTimer();
return false; return false;
} }
@@ -160,53 +202,138 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
).toString('base64'); ).toString('base64');
const state = Buffer.from(encodeStateVector(doc)).toString('base64'); const state = Buffer.from(encodeStateVector(doc)).toString('base64');
endTimer();
return { return {
missing, missing,
state, state,
}; };
} }
@SubscribeMessage('client-update-v2')
async handleClientUpdateV2(
@MessageBody()
{
workspaceId,
guid,
updates,
}: {
workspaceId: string;
guid: string;
updates: string[];
},
@ConnectedSocket() client: Socket
): Promise<EventResponse<{ accepted: true }>> {
if (!client.rooms.has(workspaceId)) {
return {
error: new NotInWorkspaceError(workspaceId),
};
}
try {
const docId = new DocID(guid, workspaceId);
client
.to(docId.workspace)
.emit('server-updates', { workspaceId, guid, updates });
const buffers = updates.map(update => Buffer.from(update, 'base64'));
await this.docManager.batchPush(docId.workspace, docId.guid, buffers);
return {
data: {
accepted: true,
},
};
} catch (e) {
return {
error: new InternalError(e as Error),
};
}
}
@Auth()
@SubscribeMessage('doc-load-v2')
async loadDocV2(
@ConnectedSocket() client: Socket,
@CurrentUser() user: UserType,
@MessageBody()
{
workspaceId,
guid,
stateVector,
}: {
workspaceId: string;
guid: string;
stateVector?: string;
}
): Promise<EventResponse<{ missing: string; state?: string }>> {
if (!client.rooms.has(workspaceId)) {
const canRead = await this.permissions.tryCheck(workspaceId, user.id);
if (!canRead) {
return {
error: new AccessDeniedError(workspaceId),
};
}
}
const docId = new DocID(guid, workspaceId);
const doc = await this.docManager.get(docId.workspace, docId.guid);
if (!doc) {
return {
error: docId.isWorkspace
? new WorkspaceNotFoundError(workspaceId)
: new DocNotFoundError(workspaceId, docId.guid),
};
}
const missing = Buffer.from(
encodeStateAsUpdate(
doc,
stateVector ? Buffer.from(stateVector, 'base64') : undefined
)
).toString('base64');
const state = Buffer.from(encodeStateVector(doc)).toString('base64');
return {
data: {
missing,
state,
},
};
}
@SubscribeMessage('awareness-init') @SubscribeMessage('awareness-init')
async handleInitAwareness( async handleInitAwareness(
@MessageBody() workspaceId: string, @MessageBody() workspaceId: string,
@ConnectedSocket() client: Socket @ConnectedSocket() client: Socket
) { ): Promise<EventResponse<{ clientId: string }>> {
this.metric.socketIOEventCounter(1, { event: 'awareness-init' });
const endTimer = this.metric.socketIOEventTimer({
event: 'init-awareness',
});
if (client.rooms.has(workspaceId)) { if (client.rooms.has(workspaceId)) {
client.to(workspaceId).emit('new-client-awareness-init'); client.to(workspaceId).emit('new-client-awareness-init');
return {
data: {
clientId: client.id,
},
};
} else { } else {
this.logger.verbose( return {
`Client ${client.id} tried to init awareness for workspace ${workspaceId} without joining it first` error: new NotInWorkspaceError(workspaceId),
); };
} }
endTimer();
} }
@SubscribeMessage('awareness-update') @SubscribeMessage('awareness-update')
async handleHelpGatheringAwareness( async handleHelpGatheringAwareness(
@MessageBody() message: { workspaceId: string; awarenessUpdate: string }, @MessageBody() message: { workspaceId: string; awarenessUpdate: string },
@ConnectedSocket() client: Socket @ConnectedSocket() client: Socket
) { ): Promise<EventResponse> {
this.metric.socketIOEventCounter(1, { event: 'awareness-update' });
const endTimer = this.metric.socketIOEventTimer({
event: 'awareness-update',
});
if (client.rooms.has(message.workspaceId)) { if (client.rooms.has(message.workspaceId)) {
client.to(message.workspaceId).emit('server-awareness-broadcast', { client
...message, .to(message.workspaceId)
}); .emit('server-awareness-broadcast', message);
return {};
} else { } else {
this.logger.verbose( return {
`Client ${client.id} tried to update awareness for workspace ${message.workspaceId} without joining it first` error: new NotInWorkspaceError(message.workspaceId),
); };
} }
endTimer();
return 'ack';
} }
} }
+41
View File
@@ -225,6 +225,31 @@ test('should have sequential update number', async t => {
t.not(records.length, 0); t.not(records.length, 0);
}); });
test('should have correct sequential update number with batching push', async t => {
const manager = m.get(DocManager);
const doc = new YDoc();
const text = doc.getText('content');
const updates: Buffer[] = [];
doc.on('update', update => {
updates.push(Buffer.from(update));
});
text.insert(0, 'hello');
text.insert(5, 'world');
text.insert(5, ' ');
await manager.batchPush('2', '2', updates);
// [1,2,3]
const records = await manager.getUpdates('2', '2');
t.deepEqual(
records.map(({ seq }) => seq),
[1, 2, 3]
);
});
test('should retry if seq num conflict', async t => { test('should retry if seq num conflict', async t => {
const manager = m.get(DocManager); const manager = m.get(DocManager);
@@ -240,3 +265,19 @@ test('should retry if seq num conflict', async t => {
t.is(stub.callCount, 3); t.is(stub.callCount, 3);
}); });
test('should throw if meet max retry times', async t => {
const manager = m.get(DocManager);
// @ts-expect-error private method
const stub = Sinon.stub(manager, 'getUpdateSeq');
stub.resolves(1);
await t.notThrowsAsync(() => manager.push('1', '1', Buffer.from([0, 0])));
await t.throwsAsync(
() => manager.push('1', '1', Buffer.from([0, 0]), 3 /* retry 3 times */),
{ message: 'Failed to push update' }
);
t.is(stub.callCount, 5);
});
+1
View File
@@ -27,6 +27,7 @@
"js-base64": "^3.7.5", "js-base64": "^3.7.5",
"ky": "^1.0.1", "ky": "^1.0.1",
"lib0": "^0.2.87", "lib0": "^0.2.87",
"lodash-es": "^4.17.21",
"nanoid": "^5.0.1", "nanoid": "^5.0.1",
"next-auth": "^4.23.2", "next-auth": "^4.23.2",
"react": "18.2.0", "react": "18.2.0",
@@ -0,0 +1,107 @@
interface SyncUpdateSender {
(
guid: string,
updates: Uint8Array[]
): Promise<{
accepted: boolean;
retry: boolean;
}>;
}
/**
* BatchSyncSender is simple wrapper with vanilla update sync with several advanced features:
* - ACK mechanism, send updates sequentially with previous sync request correctly responds with ACK
* - batching updates, when waiting for previous ACK, new updates will be buffered and sent in single sync request
* - retryable, allow retry when previous sync request failed but with retry flag been set to true
*/
export class BatchSyncSender {
private buffered: Uint8Array[] = [];
private job: Promise<void> | null = null;
private started = true;
constructor(
private guid: string,
private readonly rawSender: SyncUpdateSender
) {}
send(update: Uint8Array) {
this.buffered.push(update);
this.next();
return Promise.resolve();
}
stop() {
this.started = false;
}
start() {
this.started = true;
this.next();
}
private next() {
if (!this.started || this.job || !this.buffered.length) {
return;
}
const lastIndex = Math.min(
this.buffered.length - 1,
99 /* max batch updates size */
);
const updates = this.buffered.slice(0, lastIndex + 1);
if (updates.length) {
this.job = this.rawSender(this.guid, updates)
.then(({ accepted, retry }) => {
// remove pending updates if updates are accepted
if (accepted) {
this.buffered.splice(0, lastIndex + 1);
}
// stop when previous sending failed and non-recoverable
if (accepted || retry) {
// avoid call stack overflow
setTimeout(() => {
this.next();
}, 0);
} else {
this.stop();
}
})
.catch(() => {
this.stop();
})
.finally(() => {
this.job = null;
});
}
}
}
export class MultipleBatchSyncSender {
private senders: Record<string, BatchSyncSender> = {};
constructor(private readonly rawSender: SyncUpdateSender) {}
async send(guid: string, update: Uint8Array) {
return this.getSender(guid).send(update);
}
private getSender(guid: string) {
let sender = this.senders[guid];
if (!sender) {
sender = new BatchSyncSender(guid, this.rawSender);
this.senders[guid] = sender;
}
return sender;
}
start() {
Object.values(this.senders).forEach(sender => sender.start());
}
stop() {
Object.values(this.senders).forEach(sender => sender.stop());
}
}
+10 -8
View File
@@ -59,14 +59,6 @@ export const CRUD: WorkspaceCRUD<WorkspaceFlavour.AFFINE_CLOUD> = {
WorkspaceFlavour.AFFINE_CLOUD WorkspaceFlavour.AFFINE_CLOUD
); );
const datasource = createAffineDataSource(
createWorkspace.id,
newBlockSuiteWorkspace.doc,
newBlockSuiteWorkspace.awarenessStore.awareness
);
await syncDataSourceFromDoc(upstreamWorkspace.doc, datasource);
Y.applyUpdate( Y.applyUpdate(
newBlockSuiteWorkspace.doc, newBlockSuiteWorkspace.doc,
Y.encodeStateAsUpdate(upstreamWorkspace.doc) Y.encodeStateAsUpdate(upstreamWorkspace.doc)
@@ -85,6 +77,16 @@ export const CRUD: WorkspaceCRUD<WorkspaceFlavour.AFFINE_CLOUD> = {
}) })
); );
const datasource = createAffineDataSource(
createWorkspace.id,
newBlockSuiteWorkspace.doc,
newBlockSuiteWorkspace.awarenessStore.awareness
);
const disconnect = datasource.onDocUpdate(() => {});
await syncDataSourceFromDoc(upstreamWorkspace.doc, datasource);
disconnect();
const provider = createIndexedDBProvider( const provider = createIndexedDBProvider(
newBlockSuiteWorkspace.doc, newBlockSuiteWorkspace.doc,
DEFAULT_DB_NAME DEFAULT_DB_NAME
+113 -41
View File
@@ -1,4 +1,5 @@
import { DebugLogger } from '@affine/debug'; import { DebugLogger } from '@affine/debug';
import { isEqual } from 'lodash-es';
import type { Socket } from 'socket.io-client'; import type { Socket } from 'socket.io-client';
import { Manager } from 'socket.io-client'; import { Manager } from 'socket.io-client';
import { import {
@@ -10,6 +11,7 @@ import {
import type { DocDataSource } from 'y-provider'; import type { DocDataSource } from 'y-provider';
import type { Doc } from 'yjs'; import type { Doc } from 'yjs';
import { MultipleBatchSyncSender } from './batch-sync-sender';
import { import {
type AwarenessChanges, type AwarenessChanges,
base64ToUint8Array, base64ToUint8Array,
@@ -41,8 +43,44 @@ export const createAffineDataSource = (
console.warn('important!! please use doc.guid as roomName'); console.warn('important!! please use doc.guid as roomName');
} }
logger.debug('createAffineDataSource', id, rootDoc.guid, awareness); logger.debug('createAffineDataSource', id, rootDoc.guid);
const socket = getIoManager().socket('/'); const socket = getIoManager().socket('/');
const syncSender = new MultipleBatchSyncSender(async (guid, updates) => {
const payload = await Promise.all(
updates.map(update => uint8ArrayToBase64(update))
);
return new Promise(resolve => {
socket.emit(
'client-update-v2',
{
workspaceId: rootDoc.guid,
guid,
updates: payload,
},
(response: {
// TODO: reuse `EventError` with server
error?: any;
data: any;
}) => {
// TODO: raise error with different code to users
if (response.error) {
logger.error('client-update-v2 error', {
workspaceId: rootDoc.guid,
guid,
response,
});
}
resolve({
accepted: !response.error,
// TODO: reuse `EventError` with server
retry: response.error?.code === 'INTERNAL',
});
}
);
});
});
return { return {
get socket() { get socket() {
@@ -54,78 +92,93 @@ export const createAffineDataSource = (
: undefined; : undefined;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
logger.debug('doc-load', { logger.debug('doc-load-v2', {
workspaceId: rootDoc.guid, workspaceId: rootDoc.guid,
guid, guid,
stateVector, stateVector,
}); });
socket.emit( socket.emit(
'doc-load', 'doc-load-v2',
{ {
workspaceId: rootDoc.guid, workspaceId: rootDoc.guid,
guid, guid,
stateVector, stateVector,
}, },
(docState: Error | { missing: string; state: string } | null) => { (
response: // TODO: reuse `EventError` with server
{ error: any } | { data: { missing: string; state: string } }
) => {
logger.debug('doc-load callback', { logger.debug('doc-load callback', {
workspaceId: rootDoc.guid, workspaceId: rootDoc.guid,
guid, guid,
stateVector, stateVector,
docState, response,
}); });
if (docState instanceof Error) {
reject(docState);
return;
}
resolve( if ('error' in response) {
docState // TODO: result `EventError` with server
? { if (response.error.code === 'DOC_NOT_FOUND') {
missing: base64ToUint8Array(docState.missing), resolve(false);
state: docState.state } else {
? base64ToUint8Array(docState.state) reject(new Error(response.error.message));
: undefined, }
} } else {
: false resolve({
); missing: base64ToUint8Array(response.data.missing),
state: response.data.state
? base64ToUint8Array(response.data.state)
: undefined,
});
}
} }
); );
}); });
}, },
sendDocUpdate: async (guid: string, update: Uint8Array) => { sendDocUpdate: async (guid: string, update: Uint8Array) => {
logger.debug('client-update', { logger.debug('client-update-v2', {
workspaceId: rootDoc.guid, workspaceId: rootDoc.guid,
guid, guid,
update, update,
}); });
socket.emit('client-update', {
workspaceId: rootDoc.guid,
guid,
update: await uint8ArrayToBase64(update),
});
return Promise.resolve(); await syncSender.send(guid, update);
}, },
onDocUpdate: callback => { onDocUpdate: callback => {
socket.on('connect', () => {
socket.emit('client-handshake', rootDoc.guid);
});
const onUpdate = async (message: { const onUpdate = async (message: {
workspaceId: string; workspaceId: string;
guid: string; guid: string;
update: string; updates: string[];
}) => { }) => {
if (message.workspaceId === rootDoc.guid) { if (message.workspaceId === rootDoc.guid) {
callback(message.guid, base64ToUint8Array(message.update)); message.updates.forEach(update => {
callback(message.guid, base64ToUint8Array(update));
});
} }
}; };
socket.on('server-update', onUpdate); let destroyAwareness = () => {};
const destroyAwareness = setupAffineAwareness(socket, rootDoc, awareness); socket.on('server-updates', onUpdate);
socket.on('connect', () => {
socket.emit(
'client-handshake',
rootDoc.guid,
(response: { error?: any }) => {
if (!response.error) {
syncSender.start();
destroyAwareness = setupAffineAwareness(
socket,
rootDoc,
awareness
);
}
}
);
});
socket.connect(); socket.connect();
return () => { return () => {
syncSender.stop();
socket.emit('client-leave', rootDoc.guid); socket.emit('client-leave', rootDoc.guid);
socket.off('server-update', onUpdate); socket.off('server-updates', onUpdate);
destroyAwareness(); destroyAwareness();
socket.disconnect(); socket.disconnect();
}; };
@@ -138,6 +191,23 @@ function setupAffineAwareness(
rootDoc: Doc, rootDoc: Doc,
awareness: Awareness awareness: Awareness
) { ) {
let lastAwarenessState: Map<number, any> = new Map();
// can't compare on update binary because the protocol will encode clock in it but the state is still the same
const compareAwarenessState = (clients: number[]) => {
const newAwarenessState = new Map();
clients.forEach(client => {
newAwarenessState.set(client, awareness.states.get(client));
});
const equal = isEqual(lastAwarenessState, newAwarenessState);
if (!equal) {
lastAwarenessState = newAwarenessState;
}
return equal;
};
const awarenessBroadcast = ({ const awarenessBroadcast = ({
workspaceId, workspaceId,
awarenessUpdate, awarenessUpdate,
@@ -148,7 +218,6 @@ function setupAffineAwareness(
if (workspaceId !== rootDoc.guid) { if (workspaceId !== rootDoc.guid) {
return; return;
} }
applyAwarenessUpdate( applyAwarenessUpdate(
awareness, awareness,
base64ToUint8Array(awarenessUpdate), base64ToUint8Array(awarenessUpdate),
@@ -166,6 +235,11 @@ function setupAffineAwareness(
...cur, ...cur,
]); ]);
// hit the last awareness update cache, skip
if (compareAwarenessState(changedClients)) {
return;
}
const update = encodeAwarenessUpdate(awareness, changedClients); const update = encodeAwarenessUpdate(awareness, changedClients);
uint8ArrayToBase64(update) uint8ArrayToBase64(update)
.then(encodedUpdate => { .then(encodedUpdate => {
@@ -174,7 +248,7 @@ function setupAffineAwareness(
awarenessUpdate: encodedUpdate, awarenessUpdate: encodedUpdate,
}); });
}) })
.catch(err => console.error(err)); .catch(err => logger.error(err));
}; };
const newClientAwarenessInitHandler = () => { const newClientAwarenessInitHandler = () => {
@@ -188,7 +262,7 @@ function setupAffineAwareness(
awarenessUpdate: encodedAwarenessUpdate, awarenessUpdate: encodedAwarenessUpdate,
}); });
}) })
.catch(err => console.error(err)); .catch(err => logger.error(err));
}; };
const windowBeforeUnloadHandler = () => { const windowBeforeUnloadHandler = () => {
@@ -199,12 +273,10 @@ function setupAffineAwareness(
conn.on('new-client-awareness-init', newClientAwarenessInitHandler); conn.on('new-client-awareness-init', newClientAwarenessInitHandler);
awareness.on('update', awarenessUpdate); awareness.on('update', awarenessUpdate);
conn.on('connect', () => {
conn.emit('awareness-init', rootDoc.guid);
});
window.addEventListener('beforeunload', windowBeforeUnloadHandler); window.addEventListener('beforeunload', windowBeforeUnloadHandler);
conn.emit('awareness-init', rootDoc.guid);
return () => { return () => {
awareness.off('update', awarenessUpdate); awareness.off('update', awarenessUpdate);
conn.off('server-awareness-broadcast', awarenessBroadcast); conn.off('server-awareness-broadcast', awarenessBroadcast);
@@ -38,13 +38,10 @@ const createAffineSocketIOProvider: DocProviderCreator = (
const lazyProvider = createLazyProvider(doc, dataSource, { const lazyProvider = createLazyProvider(doc, dataSource, {
origin: 'affine-socket-io', origin: 'affine-socket-io',
}); });
return {
flavour: 'affine-socket-io', Object.assign(lazyProvider, { flavour: 'affine-socket-io' });
...lazyProvider,
get status() { return lazyProvider as unknown as AffineSocketIOProvider;
return lazyProvider.status;
},
};
}; };
const createIndexedDBBackgroundProvider: DocProviderCreator = ( const createIndexedDBBackgroundProvider: DocProviderCreator = (
+1
View File
@@ -865,6 +865,7 @@ __metadata:
js-base64: "npm:^3.7.5" js-base64: "npm:^3.7.5"
ky: "npm:^1.0.1" ky: "npm:^1.0.1"
lib0: "npm:^0.2.87" lib0: "npm:^0.2.87"
lodash-es: "npm:^4.17.21"
nanoid: "npm:^5.0.1" nanoid: "npm:^5.0.1"
next-auth: "npm:^4.23.2" next-auth: "npm:^4.23.2"
react: "npm:18.2.0" react: "npm:18.2.0"