refactor(server): use events system (#5149)

This commit is contained in:
liuyi
2023-12-08 05:00:58 +00:00
parent 52cfe4521a
commit 17d584b336
8 changed files with 122 additions and 96 deletions
+7 -12
View File
@@ -1,23 +1,18 @@
import type { Snapshot, User, Workspace } from '@prisma/client'; import type { Snapshot, Workspace } from '@prisma/client';
import { ChangePayload, Flatten, Payload } from './types'; import { Flatten, Payload } from './types';
interface EventDefinitions { interface EventDefinitions {
user: {
created: Payload<User>;
updated: Payload<ChangePayload<User>>;
deleted: Payload<User['id']>;
};
workspace: { workspace: {
created: Payload<Workspace>;
updated: Payload<ChangePayload<Workspace>>;
deleted: Payload<Workspace['id']>; deleted: Payload<Workspace['id']>;
}; };
snapshot: { snapshot: {
created: Payload<Snapshot>; updated: Payload<
updated: Payload<ChangePayload<Snapshot>>; Pick<Snapshot, 'id' | 'workspaceId'> & {
previous: Pick<Snapshot, 'blob' | 'state' | 'updatedAt'>;
}
>;
deleted: Payload<Pick<Snapshot, 'id' | 'workspaceId'>>; deleted: Payload<Pick<Snapshot, 'id' | 'workspaceId'>>;
}; };
} }
+2 -1
View File
@@ -5,7 +5,7 @@ import {
OnEvent as RawOnEvent, OnEvent as RawOnEvent,
} from '@nestjs/event-emitter'; } from '@nestjs/event-emitter';
import { Event, EventPayload } from './events'; import type { Event, EventPayload } from './events';
@Injectable() @Injectable()
export class EventEmitter { export class EventEmitter {
@@ -42,3 +42,4 @@ export const OnEvent = (
exports: [EventEmitter], exports: [EventEmitter],
}) })
export class EventModule {} export class EventModule {}
export { EventPayload };
+1 -6
View File
@@ -3,11 +3,6 @@ export type Payload<T> = {
data: T; data: T;
}; };
export type ChangePayload<T> = {
from: Partial<T>;
to: Partial<T>;
};
export type Join<A extends string, B extends string> = A extends '' export type Join<A extends string, B extends string> = A extends ''
? B ? B
: `${A}.${B}`; : `${A}.${B}`;
@@ -33,6 +28,6 @@ export type Leaves<T, P extends string = ''> = T extends Payload<any>
export type Flatten<T> = Leaves<T> extends infer R export type Flatten<T> = Leaves<T> extends infer R
? { ? {
// @ts-expect-error yo, ts can't make it // @ts-expect-error yo, ts can't make it
[K in R]: PathType<T, K> extends Payload<infer U> ? { data: U } : never; [K in R]: PathType<T, K> extends Payload<infer U> ? U : never;
} }
: never; : never;
@@ -1,15 +1,15 @@
import { isDeepStrictEqual } from 'node:util'; import { isDeepStrictEqual } from 'node:util';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
import type { Snapshot } from '@prisma/client';
import { Config } from '../../config'; import { Config } from '../../config';
import { type EventPayload, OnEvent } from '../../event';
import { metrics } from '../../metrics'; import { metrics } from '../../metrics';
import { PrismaService } from '../../prisma'; import { PrismaService } from '../../prisma';
import { SubscriptionStatus } from '../payment/service'; import { SubscriptionStatus } from '../payment/service';
import { Permission } from '../workspaces/types'; import { Permission } from '../workspaces/types';
import { isEmptyBuffer } from './manager';
@Injectable() @Injectable()
export class DocHistoryManager { export class DocHistoryManager {
@@ -19,16 +19,38 @@ export class DocHistoryManager {
private readonly db: PrismaService private readonly db: PrismaService
) {} ) {}
@OnEvent('doc:manager:snapshot:beforeUpdate') @OnEvent('workspace.deleted')
async onDocUpdated(snapshot: Snapshot, forceCreate = false) { onWorkspaceDeleted(workspaceId: EventPayload<'workspace.deleted'>) {
const last = await this.last(snapshot.workspaceId, snapshot.id); return this.db.snapshotHistory.deleteMany({
where: {
workspaceId,
},
});
}
@OnEvent('snapshot.deleted')
onSnapshotDeleted({ workspaceId, id }: EventPayload<'snapshot.deleted'>) {
return this.db.snapshotHistory.deleteMany({
where: {
workspaceId,
id,
},
});
}
@OnEvent('snapshot.updated')
async onDocUpdated(
{ workspaceId, id, previous }: EventPayload<'snapshot.updated'>,
forceCreate = false
) {
const last = await this.last(workspaceId, id);
let shouldCreateHistory = false; let shouldCreateHistory = false;
if (!last) { if (!last) {
// never created // never created
shouldCreateHistory = true; shouldCreateHistory = true;
} else if (last.timestamp === snapshot.updatedAt) { } else if (last.timestamp === previous.updatedAt) {
// no change // no change
shouldCreateHistory = false; shouldCreateHistory = false;
} else if ( } else if (
@@ -36,16 +58,23 @@ export class DocHistoryManager {
forceCreate || forceCreate ||
// last history created before interval in configs // last history created before interval in configs
last.timestamp.getTime() < last.timestamp.getTime() <
snapshot.updatedAt.getTime() - this.config.doc.history.interval previous.updatedAt.getTime() - this.config.doc.history.interval
) { ) {
shouldCreateHistory = true; shouldCreateHistory = true;
} }
if (shouldCreateHistory) { if (shouldCreateHistory) {
// skip the history recording when no actual update on snapshot happended // skip the history recording when no actual update on snapshot happended
if (last && isDeepStrictEqual(last.state, snapshot.state)) { if (last && isDeepStrictEqual(last.state, previous.state)) {
this.logger.debug( this.logger.debug(
`State matches, skip creating history record for ${snapshot.id} in workspace ${snapshot.workspaceId}` `State matches, skip creating history record for ${id} in workspace ${workspaceId}`
);
return;
}
if (isEmptyBuffer(previous.blob)) {
this.logger.debug(
`Doc is empty, skip creating history record for ${id} in workspace ${workspaceId}`
); );
return; return;
} }
@@ -56,12 +85,12 @@ export class DocHistoryManager {
timestamp: true, timestamp: true,
}, },
data: { data: {
workspaceId: snapshot.workspaceId, workspaceId,
id: snapshot.id, id,
timestamp: snapshot.updatedAt, timestamp: previous.updatedAt,
blob: snapshot.blob, blob: previous.blob,
state: snapshot.state, state: previous.state,
expiredAt: await this.getExpiredDateFromNow(snapshot.workspaceId), expiredAt: await this.getExpiredDateFromNow(workspaceId),
}, },
}) })
.catch(() => { .catch(() => {
@@ -73,9 +102,7 @@ export class DocHistoryManager {
description: 'How many times the snapshot history created', description: 'How many times the snapshot history created',
}) })
.add(1); .add(1);
this.logger.log( this.logger.log(`History created for ${id} in workspace ${workspaceId}.`);
`History created for ${snapshot.id} in workspace ${snapshot.workspaceId}.`
);
} }
} }
@@ -180,7 +207,7 @@ export class DocHistoryManager {
} }
// save old snapshot as one history record // save old snapshot as one history record
await this.onDocUpdated(oldSnapshot, true); await this.onDocUpdated({ workspaceId, id, previous: oldSnapshot }, true);
// WARN: // WARN:
// we should never do the snapshot updating in recovering, // we should never do the snapshot updating in recovering,
// which is not the solution in CRDT. // which is not the solution in CRDT.
@@ -5,7 +5,6 @@ import {
OnModuleDestroy, OnModuleDestroy,
OnModuleInit, OnModuleInit,
} from '@nestjs/common'; } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Snapshot, Update } from '@prisma/client'; import { Snapshot, Update } from '@prisma/client';
import { chunk } from 'lodash-es'; import { chunk } from 'lodash-es';
import { defer, retry } from 'rxjs'; import { defer, retry } from 'rxjs';
@@ -20,6 +19,7 @@ import {
import { Cache } from '../../cache'; import { Cache } from '../../cache';
import { Config } from '../../config'; import { Config } from '../../config';
import { EventEmitter, type EventPayload, OnEvent } from '../../event';
import { metrics } from '../../metrics/metrics'; import { metrics } from '../../metrics/metrics';
import { PrismaService } from '../../prisma'; import { PrismaService } from '../../prisma';
import { mergeUpdatesInApplyWay as jwstMergeUpdates } from '../../storage'; import { mergeUpdatesInApplyWay as jwstMergeUpdates } from '../../storage';
@@ -71,7 +71,7 @@ function isStateNewer(lhs: Buffer, rhs: Buffer): boolean {
return false; return false;
} }
function isEmptyBuffer(buf: Buffer): boolean { export function isEmptyBuffer(buf: Buffer): boolean {
return ( return (
buf.length === 0 || buf.length === 0 ||
// 0x0000 // 0x0000
@@ -102,7 +102,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
private readonly db: PrismaService, private readonly db: PrismaService,
private readonly config: Config, private readonly config: Config,
private readonly cache: Cache, private readonly cache: Cache,
private readonly event: EventEmitter2 private readonly event: EventEmitter
) {} ) {}
onModuleInit() { onModuleInit() {
@@ -224,6 +224,33 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
} }
} }
@OnEvent('workspace.deleted')
async onWorkspaceDeleted(workspaceId: string) {
await this.db.snapshot.deleteMany({
where: {
workspaceId,
},
});
await this.db.update.deleteMany({
where: {
workspaceId,
},
});
}
@OnEvent('snapshot.deleted')
async onSnapshotDeleted({
id,
workspaceId,
}: EventPayload<'snapshot.deleted'>) {
await this.db.update.deleteMany({
where: {
id,
workspaceId,
},
});
}
/** /**
* add update to manager for later processing. * add update to manager for later processing.
*/ */
@@ -538,8 +565,17 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
...updates.map(u => u.blob) ...updates.map(u => u.blob)
); );
await this.upsert(workspaceId, id, doc, last.seq);
if (snapshot) { if (snapshot) {
this.event.emit('doc:manager:snapshot:beforeUpdate', snapshot); this.event.emit('snapshot.updated', {
id,
workspaceId,
previous: {
blob: snapshot.blob,
state: snapshot.state,
updatedAt: snapshot.updatedAt,
},
});
} }
const done = await this.upsert(workspaceId, id, doc, last.seq); const done = await this.upsert(workspaceId, id, doc, last.seq);
@@ -33,6 +33,7 @@ import type {
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { applyUpdate, Doc } from 'yjs'; import { applyUpdate, Doc } from 'yjs';
import { EventEmitter } from '../../event';
import { PrismaService } from '../../prisma'; import { PrismaService } from '../../prisma';
import { StorageProvide } from '../../storage'; import { StorageProvide } from '../../storage';
import { CloudThrottlerGuard, Throttle } from '../../throttler'; import { CloudThrottlerGuard, Throttle } from '../../throttler';
@@ -146,6 +147,7 @@ export class WorkspaceResolver {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly permissions: PermissionService, private readonly permissions: PermissionService,
private readonly users: UsersService, private readonly users: UsersService,
private readonly event: EventEmitter,
@Inject(StorageProvide) private readonly storage: Storage @Inject(StorageProvide) private readonly storage: Storage
) {} ) {}
@@ -388,18 +390,7 @@ export class WorkspaceResolver {
}, },
}); });
await this.prisma.$transaction([ this.event.emit('workspace.deleted', id);
this.prisma.update.deleteMany({
where: {
workspaceId: id,
},
}),
this.prisma.snapshot.deleteMany({
where: {
workspaceId: id,
},
}),
]);
return true; return true;
} }
+2 -2
View File
@@ -1,7 +1,6 @@
import { mock } from 'node:test'; import { mock } from 'node:test';
import type { INestApplication } from '@nestjs/common'; import type { INestApplication } from '@nestjs/common';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import test from 'ava'; import test from 'ava';
import { register } from 'prom-client'; import { register } from 'prom-client';
@@ -15,6 +14,7 @@ import {
import { CacheModule } from '../src/cache'; import { CacheModule } from '../src/cache';
import { Config, ConfigModule } from '../src/config'; import { Config, ConfigModule } from '../src/config';
import { EventModule } from '../src/event';
import { DocManager, DocModule } from '../src/modules/doc'; import { DocManager, DocModule } from '../src/modules/doc';
import { PrismaModule, PrismaService } from '../src/prisma'; import { PrismaModule, PrismaService } from '../src/prisma';
import { flushDB } from './utils'; import { flushDB } from './utils';
@@ -24,7 +24,7 @@ const createModule = () => {
imports: [ imports: [
PrismaModule, PrismaModule,
CacheModule, CacheModule,
EventEmitterModule.forRoot(), EventModule,
ConfigModule.forRoot(), ConfigModule.forRoot(),
DocModule.forRoot(), DocModule.forRoot(),
], ],
+21 -40
View File
@@ -6,6 +6,7 @@ import test from 'ava';
import * as Sinon from 'sinon'; import * as Sinon from 'sinon';
import { ConfigModule } from '../src/config'; import { ConfigModule } from '../src/config';
import type { EventPayload } from '../src/event';
import { DocHistoryManager } from '../src/modules/doc'; import { DocHistoryManager } from '../src/modules/doc';
import { PrismaModule, PrismaService } from '../src/prisma'; import { PrismaModule, PrismaService } from '../src/prisma';
import { flushDB } from './utils'; import { flushDB } from './utils';
@@ -41,21 +42,28 @@ test.afterEach(async () => {
const snapshot: Snapshot = { const snapshot: Snapshot = {
workspaceId: '1', workspaceId: '1',
id: 'doc1', id: 'doc1',
blob: Buffer.from([0, 0]), blob: Buffer.from([1, 0]),
state: Buffer.from([0, 0]), state: Buffer.from([0]),
seq: 0, seq: 0,
updatedAt: new Date(), updatedAt: new Date(),
createdAt: new Date(), createdAt: new Date(),
}; };
function getEventData(
timestamp: Date = new Date()
): EventPayload<'snapshot.updated'> {
return {
workspaceId: snapshot.workspaceId,
id: snapshot.id,
previous: { ...snapshot, updatedAt: timestamp },
};
}
test('should create doc history if never created before', async t => { test('should create doc history if never created before', async t => {
Sinon.stub(manager, 'last').resolves(null); Sinon.stub(manager, 'last').resolves(null);
const timestamp = new Date(); const timestamp = new Date();
await manager.onDocUpdated({ await manager.onDocUpdated(getEventData(timestamp));
...snapshot,
updatedAt: timestamp,
});
const history = await db.snapshotHistory.findFirst({ const history = await db.snapshotHistory.findFirst({
where: { where: {
@@ -72,10 +80,7 @@ test('should not create history if timestamp equals to last record', async t =>
const timestamp = new Date(); const timestamp = new Date();
Sinon.stub(manager, 'last').resolves({ timestamp, state: null }); Sinon.stub(manager, 'last').resolves({ timestamp, state: null });
await manager.onDocUpdated({ await manager.onDocUpdated(getEventData(timestamp));
...snapshot,
updatedAt: timestamp,
});
const history = await db.snapshotHistory.findFirst({ const history = await db.snapshotHistory.findFirst({
where: { where: {
@@ -94,10 +99,7 @@ test('should not create history if state equals to last record', async t => {
state: snapshot.state, state: snapshot.state,
}); });
await manager.onDocUpdated({ await manager.onDocUpdated(getEventData(timestamp));
...snapshot,
updatedAt: timestamp,
});
const history = await db.snapshotHistory.findFirst({ const history = await db.snapshotHistory.findFirst({
where: { where: {
@@ -116,10 +118,7 @@ test('should not create history if time diff is less than interval config', asyn
state: Buffer.from([0, 1]), state: Buffer.from([0, 1]),
}); });
await manager.onDocUpdated({ await manager.onDocUpdated(getEventData(timestamp));
...snapshot,
updatedAt: timestamp,
});
const history = await db.snapshotHistory.findFirst({ const history = await db.snapshotHistory.findFirst({
where: { where: {
@@ -138,10 +137,7 @@ test('should create history if time diff is larger than interval config and stat
state: Buffer.from([0, 1]), state: Buffer.from([0, 1]),
}); });
await manager.onDocUpdated({ await manager.onDocUpdated(getEventData(timestamp));
...snapshot,
updatedAt: timestamp,
});
const history = await db.snapshotHistory.findFirst({ const history = await db.snapshotHistory.findFirst({
where: { where: {
@@ -160,13 +156,7 @@ test('should create history with force flag even if time diff in small', async t
state: Buffer.from([0, 1]), state: Buffer.from([0, 1]),
}); });
await manager.onDocUpdated( await manager.onDocUpdated(getEventData(timestamp), true);
{
...snapshot,
updatedAt: timestamp,
},
true
);
const history = await db.snapshotHistory.findFirst({ const history = await db.snapshotHistory.findFirst({
where: { where: {
@@ -224,13 +214,7 @@ test('should correctly list all history records', async t => {
test('should be able to get history data', async t => { test('should be able to get history data', async t => {
const timestamp = new Date(); const timestamp = new Date();
await manager.onDocUpdated( await manager.onDocUpdated(getEventData(timestamp), true);
{
...snapshot,
updatedAt: timestamp,
},
true
);
const history = await manager.get( const history = await manager.get(
snapshot.workspaceId, snapshot.workspaceId,
@@ -274,10 +258,7 @@ test('should be able to recover from history', async t => {
}, },
}); });
const history1Timestamp = snapshot.updatedAt.getTime() - 10; const history1Timestamp = snapshot.updatedAt.getTime() - 10;
await manager.onDocUpdated({ await manager.onDocUpdated(getEventData(new Date(history1Timestamp)));
...snapshot,
updatedAt: new Date(history1Timestamp),
});
await manager.recover( await manager.recover(
snapshot.workspaceId, snapshot.workspaceId,