feat(server): reduce duplidated merge with cache (#4975)

This commit is contained in:
liuyi
2023-11-22 04:09:06 +00:00
parent c69e542b98
commit 525b196cae
2 changed files with 111 additions and 34 deletions

View File

@@ -16,6 +16,7 @@ import {
transact, transact,
} from 'yjs'; } from 'yjs';
import { Cache } from '../../cache';
import { Config } from '../../config'; import { Config } from '../../config';
import { Metrics } from '../../metrics/metrics'; import { Metrics } from '../../metrics/metrics';
import { PrismaService } from '../../prisma'; import { PrismaService } from '../../prisma';
@@ -58,17 +59,18 @@ const MAX_SEQ_NUM = 0x3fffffff; // u31
*/ */
@Injectable() @Injectable()
export class DocManager implements OnModuleInit, OnModuleDestroy { export class DocManager implements OnModuleInit, OnModuleDestroy {
protected logger = new Logger(DocManager.name); private logger = new Logger(DocManager.name);
private job: NodeJS.Timeout | null = null; private job: NodeJS.Timeout | null = null;
private seqMap = new Map<string, number>(); private seqMap = new Map<string, number>();
private busy = false; private busy = false;
constructor( constructor(
protected readonly db: PrismaService,
@Inject('DOC_MANAGER_AUTOMATION') @Inject('DOC_MANAGER_AUTOMATION')
protected readonly automation: boolean, private readonly automation: boolean,
protected readonly config: Config, private readonly db: PrismaService,
protected readonly metrics: Metrics private readonly config: Config,
private readonly metrics: Metrics,
private readonly cache: Cache
) {} ) {}
onModuleInit() { onModuleInit() {
@@ -82,7 +84,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
this.destroy(); this.destroy();
} }
protected recoverDoc(...updates: Buffer[]): Promise<Doc> { private recoverDoc(...updates: Buffer[]): Promise<Doc> {
const doc = new Doc(); const doc = new Doc();
const chunks = chunk(updates, 10); const chunks = chunk(updates, 10);
@@ -95,11 +97,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
try { try {
applyUpdate(doc, u); applyUpdate(doc, u);
} catch (e) { } catch (e) {
this.logger.error( this.logger.error('Failed to apply update', e);
`Failed to apply update: ${updates
.map(u => u.toString('hex'))
.join('\n')}`
);
} }
}); });
}); });
@@ -117,14 +115,12 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
}); });
} }
protected async applyUpdates( private async applyUpdates(guid: string, ...updates: Buffer[]): Promise<Doc> {
guid: string,
...updates: Buffer[]
): Promise<Doc> {
const doc = await this.recoverDoc(...updates); const doc = await this.recoverDoc(...updates);
// test jwst codec // test jwst codec
if ( if (
this.config.affine.canary &&
this.config.doc.manager.experimentalMergeWithJwstCodec && this.config.doc.manager.experimentalMergeWithJwstCodec &&
updates.length < 100 /* avoid overloading */ updates.length < 100 /* avoid overloading */
) { ) {
@@ -149,7 +145,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
this.logger.warn(`jwst apply update failed for ${guid}: ${e}`); this.logger.warn(`jwst apply update failed for ${guid}: ${e}`);
log = true; log = true;
} finally { } finally {
if (log) { if (log && this.config.node.dev) {
this.logger.warn( this.logger.warn(
`Updates: ${updates.map(u => u.toString('hex')).join('\n')}` `Updates: ${updates.map(u => u.toString('hex')).join('\n')}`
); );
@@ -223,8 +219,8 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
.pipe(retry(retryTimes)) // retry until seq num not conflict .pipe(retry(retryTimes)) // retry until seq num not conflict
.subscribe({ .subscribe({
next: () => { next: () => {
this.logger.verbose( this.logger.debug(
`pushed update for workspace: ${workspaceId}, guid: ${guid}` `pushed 1 update for ${guid} in workspace ${workspaceId}`
); );
resolve(); resolve();
}, },
@@ -233,6 +229,8 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
reject(new Error('Failed to push update')); reject(new Error('Failed to push update'));
}, },
}); });
}).then(() => {
return this.updateCachedUpdatesCount(workspaceId, guid, 1);
}); });
} }
@@ -267,8 +265,8 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
.pipe(retry(retryTimes)) // retry until seq num not conflict .pipe(retry(retryTimes)) // retry until seq num not conflict
.subscribe({ .subscribe({
next: () => { next: () => {
this.logger.verbose( this.logger.debug(
`pushed updates for workspace: ${workspaceId}, guid: ${guid}` `pushed ${updates.length} updates for ${guid} in workspace ${workspaceId}`
); );
resolve(); resolve();
}, },
@@ -277,6 +275,8 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
reject(new Error('Failed to push update')); reject(new Error('Failed to push update'));
}, },
}); });
}).then(() => {
return this.updateCachedUpdatesCount(workspaceId, guid, updates.length);
}); });
} }
@@ -363,21 +363,22 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
/** /**
* apply pending updates to snapshot * apply pending updates to snapshot
*/ */
protected async autoSquash() { private async autoSquash() {
// find the first update and batch process updates with same id // find the first update and batch process updates with same id
const first = await this.db.update.findFirst({ const candidate = await this.getAutoSquashCandidate();
select: {
id: true,
workspaceId: true,
},
});
// no pending updates // no pending updates
if (!first) { if (!candidate) {
return; return;
} }
const { id, workspaceId } = first; const { id, workspaceId } = candidate;
// acquire lock
const ok = await this.lockUpdatesForAutoSquash(workspaceId, id);
if (!ok) {
return;
}
try { try {
await this._get(workspaceId, id); await this._get(workspaceId, id);
@@ -386,10 +387,27 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
`Failed to apply updates for workspace: ${workspaceId}, guid: ${id}` `Failed to apply updates for workspace: ${workspaceId}, guid: ${id}`
); );
this.logger.error(e); this.logger.error(e);
} finally {
await this.unlockUpdatesForAutoSquash(workspaceId, id);
} }
} }
protected async upsert( private async getAutoSquashCandidate() {
const cache = await this.getAutoSquashCandidateFromCache();
if (cache) {
return cache;
}
return this.db.update.findFirst({
select: {
id: true,
workspaceId: true,
},
});
}
private async upsert(
workspaceId: string, workspaceId: string,
guid: string, guid: string,
doc: Doc, doc: Doc,
@@ -426,7 +444,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
}); });
} }
protected async _get( private async _get(
workspaceId: string, workspaceId: string,
guid: string guid: string
): Promise<{ doc: Doc } | { snapshot: Buffer } | null> { ): Promise<{ doc: Doc } | { snapshot: Buffer } | null> {
@@ -446,22 +464,25 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
* Squash updates into a single update and save it as snapshot, * Squash updates into a single update and save it as snapshot,
* and delete the updates records at the same time. * and delete the updates records at the same time.
*/ */
protected async squash(updates: Update[], snapshot: Snapshot | null) { private async squash(updates: Update[], snapshot: Snapshot | null) {
if (!updates.length) { if (!updates.length) {
throw new Error('No updates to squash'); throw new Error('No updates to squash');
} }
const first = updates[0]; const first = updates[0];
const last = updates[updates.length - 1]; const last = updates[updates.length - 1];
const { id, workspaceId } = first;
const doc = await this.applyUpdates( const doc = await this.applyUpdates(
first.id, first.id,
snapshot ? snapshot.blob : Buffer.from([0, 0]), snapshot ? snapshot.blob : Buffer.from([0, 0]),
...updates.map(u => u.blob) ...updates.map(u => u.blob)
); );
const { id, workspaceId } = first;
await this.upsert(workspaceId, id, doc, last.seq); await this.upsert(workspaceId, id, doc, last.seq);
this.logger.debug(
`Squashed ${updates.length} updates for ${id} in workspace ${workspaceId}`
);
await this.db.update.deleteMany({ await this.db.update.deleteMany({
where: { where: {
id, id,
@@ -471,6 +492,8 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
}, },
}, },
}); });
await this.updateCachedUpdatesCount(workspaceId, id, -updates.length);
return doc; return doc;
} }
@@ -516,4 +539,56 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
return last + batch; return last + batch;
} }
} }
private async updateCachedUpdatesCount(
workspaceId: string,
guid: string,
count: number
) {
const result = await this.cache.mapIncrease(
`doc:manager:updates`,
`${workspaceId}::${guid}`,
count
);
if (result <= 0) {
await this.cache.mapDelete(
`doc:manager:updates`,
`${workspaceId}::${guid}`
);
}
}
private async getAutoSquashCandidateFromCache() {
const key = await this.cache.mapRandomKey('doc:manager:updates');
if (key) {
const count = await this.cache.mapGet<number>('doc:manager:updates', key);
if (typeof count === 'number' && count > 0) {
const [workspaceId, id] = key.split('::');
return { id, workspaceId };
}
}
return null;
}
private async lockUpdatesForAutoSquash(workspaceId: string, guid: string) {
return this.cache.setnx(
`doc:manager:updates-lock:${workspaceId}::${guid}`,
1,
{
ttl: 60 * 1000,
}
);
}
private async unlockUpdatesForAutoSquash(workspaceId: string, guid: string) {
return this.cache
.delete(`doc:manager:updates-lock:${workspaceId}::${guid}`)
.catch(e => {
// safe, the lock will be expired when ttl ends
this.logger.error('Failed to release updates lock', e);
});
}
} }

View File

@@ -7,6 +7,7 @@ import { register } from 'prom-client';
import * as Sinon from 'sinon'; import * as Sinon from 'sinon';
import { Doc as YDoc, encodeStateAsUpdate } from 'yjs'; import { Doc as YDoc, encodeStateAsUpdate } from 'yjs';
import { CacheModule } from '../src/cache';
import { Config, ConfigModule } from '../src/config'; import { Config, ConfigModule } from '../src/config';
import { MetricsModule } from '../src/metrics'; import { MetricsModule } from '../src/metrics';
import { DocManager, DocModule } from '../src/modules/doc'; import { DocManager, DocModule } from '../src/modules/doc';
@@ -18,6 +19,7 @@ const createModule = () => {
imports: [ imports: [
PrismaModule, PrismaModule,
MetricsModule, MetricsModule,
CacheModule,
ConfigModule.forRoot(), ConfigModule.forRoot(),
DocModule.forRoot(), DocModule.forRoot(),
], ],