fix(server): doc upsert without row lock (#5765)

This commit is contained in:
liuyi
2024-02-01 09:49:02 +00:00
parent 7d951a975f
commit 2f3c6f104e
4 changed files with 189 additions and 172 deletions

View File

@@ -265,7 +265,9 @@ model Snapshot {
seq Int @default(0) @db.Integer seq Int @default(0) @db.Integer
state Bytes? @db.ByteA state Bytes? @db.ByteA
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) // the `updated_at` field will not record the time of record changed,
// but the created time of last seen update that has been merged into snapshot.
updatedAt DateTime @map("updated_at") @db.Timestamptz(6)
@@id([id, workspaceId]) @@id([id, workspaceId])
@@map("snapshots") @@map("snapshots")

View File

@@ -10,7 +10,6 @@ import { chunk } from 'lodash-es';
import { defer, retry } from 'rxjs'; import { defer, retry } from 'rxjs';
import { import {
applyUpdate, applyUpdate,
decodeStateVector,
Doc, Doc,
encodeStateAsUpdate, encodeStateAsUpdate,
encodeStateVector, encodeStateVector,
@@ -46,36 +45,6 @@ function compare(yBinary: Buffer, jwstBinary: Buffer, strict = false): boolean {
return compare(yBinary, yBinary2, true); return compare(yBinary, yBinary2, true);
} }
/**
* Detect whether rhs state is newer than lhs state.
*
* How could we tell a state is newer:
*
* i. if the state vector size is larger, it's newer
* ii. if the state vector size is same, compare each client's state
*/
function isStateNewer(lhs: Buffer, rhs: Buffer): boolean {
const lhsVector = decodeStateVector(lhs);
const rhsVector = decodeStateVector(rhs);
if (lhsVector.size < rhsVector.size) {
return true;
}
for (const [client, state] of lhsVector) {
const rstate = rhsVector.get(client);
if (!rstate) {
return false;
}
if (state < rstate) {
return true;
}
}
return false;
}
export function isEmptyBuffer(buf: Buffer): boolean { export function isEmptyBuffer(buf: Buffer): boolean {
return ( return (
buf.length === 0 || buf.length === 0 ||
@@ -120,6 +89,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
this.destroy(); this.destroy();
} }
@CallTimer('doc', 'yjs_recover_updates_to_doc')
private 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);
@@ -383,7 +353,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
const updates = await this.getUpdates(workspaceId, guid); const updates = await this.getUpdates(workspaceId, guid);
if (updates.length) { if (updates.length) {
const doc = await this.squash(updates, snapshot); const doc = await this.squash(snapshot, updates);
return Buffer.from(encodeStateVector(doc)); return Buffer.from(encodeStateVector(doc));
} }
@@ -464,97 +434,94 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
}); });
} }
/**
* @returns whether the snapshot is updated to the latest, `undefined` means the doc to be upserted is outdated.
*/
@CallTimer('doc', 'upsert') @CallTimer('doc', 'upsert')
private async upsert( private async upsert(
workspaceId: string, workspaceId: string,
guid: string, guid: string,
doc: Doc, doc: Doc,
// we always delay the snapshot update to avoid db overload, // we always delay the snapshot update to avoid db overload,
// so the value of `updatedAt` will not be accurate to user's real action time // so the value of auto updated `updatedAt` by db will never be accurate to user's real action time
updatedAt: Date, updatedAt: Date,
initialSeq?: number seq: number
) { ) {
const blob = Buffer.from(encodeStateAsUpdate(doc)); const blob = Buffer.from(encodeStateAsUpdate(doc));
if (isEmptyBuffer(blob)) { if (isEmptyBuffer(blob)) {
return false; return undefined;
} }
const state = Buffer.from(encodeStateVector(doc)); const state = Buffer.from(encodeStateVector(doc));
await this.db.$queryRaw`BEGIN;`; // CONCERNS:
let committed = false; // i. Because we save the real user's last seen action time as `updatedAt`,
const commit = async () => { // it's possible to simply compare the `updatedAt` to determine if the snapshot is older than the one we are going to save.
if (!committed) { //
committed = true; // ii. Prisma doesn't support `upsert` with additional `where` condition along side unique constraint.
await this.db.$queryRaw`COMMIT;`; // In our case, we need to manually check the `updatedAt` to avoid overriding the newer snapshot.
} // where: { id_workspaceId: {}, updatedAt: { lt: updatedAt } }
}; // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
//
// iii. Only set the seq number when creating the snapshot.
// For updating scenario, the seq number will be updated when updates pushed to db.
try { try {
const [snapshot]: { const result: { updatedAt: Date }[] = await this.db.$queryRaw`
workspace_id: string; INSERT INTO "snapshots" ("workspace_id", "guid", "blob", "state", "seq", "created_at", "updated_at")
id: string; VALUES (${workspaceId}, ${guid}, ${blob}, ${state}, ${seq}, DEFAULT, ${updatedAt})
blob: Buffer; ON CONFLICT ("workspace_id", "guid")
state?: Buffer; DO UPDATE SET "blob" = ${blob}, "state" = ${state}, "updated_at" = ${updatedAt}, "seq" = ${seq}
}[] = await this.db.$queryRaw` WHERE "snapshots"."workspace_id" = ${workspaceId} AND "snapshots"."guid" = ${guid} AND "snapshots"."updated_at" <= ${updatedAt}
-- LOCK TABLE "Snapshot" IN SHARE ROW EXCLUSIVE MODE; RETURNING "snapshots"."workspace_id" as "workspaceId", "snapshots"."guid" as "id", "snapshots"."updated_at" as "updatedAt"
SELECT * FROM snapshots WHERE workspace_id = ${workspaceId} AND guid = ${guid} limit 1
FOR UPDATE;
`; `;
// update // const result = await this.db.snapshot.upsert({
if (snapshot) { // select: {
// only update if state is newer // updatedAt: true,
if (isStateNewer(snapshot.state ?? Buffer.from([0]), state)) { // seq: true,
await this.db.snapshot.update({ // },
select: { // where: {
seq: true, // id_workspaceId: {
}, // workspaceId,
where: { // id: guid,
id_workspaceId: { // },
workspaceId, // ⬇️ NOT SUPPORTED BY PRISMA YET
id: guid, // updatedAt: {
}, // lt: updatedAt,
}, // },
data: { // },
blob, // update: {
state, // blob,
updatedAt, // state,
}, // updatedAt,
}); // },
// create: {
// workspaceId,
// id: guid,
// blob,
// state,
// updatedAt,
// seq,
// },
// });
return true; // if the condition `snapshot.updatedAt > updatedAt` is true, by which means the snapshot has already been updated by other process,
} else { // the updates has been applied to current `doc` must have been seen by the other process as well.
return false; // The `updatedSnapshot` will be `undefined` in this case.
} const updatedSnapshot = result.at(0);
} else {
// create
// no record exists, should commit the previous row lock first
await commit();
await this.db.snapshot.create({
select: {
seq: true,
},
data: {
id: guid,
workspaceId,
blob,
state,
seq: initialSeq,
createdAt: updatedAt,
updatedAt,
},
});
return true; if (!updatedSnapshot) {
return undefined;
} }
return true;
} catch (e) { } catch (e) {
await this.db.$queryRaw`ROLLBACK;`; this.logger.error('Failed to upsert snapshot', e);
throw e; return false;
} finally {
await commit();
} }
} }
private async _get( private async _get(
workspaceId: string, workspaceId: string,
guid: string guid: string
@@ -564,7 +531,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
if (updates.length) { if (updates.length) {
return { return {
doc: await this.squash(updates, snapshot), doc: await this.squash(snapshot, updates),
}; };
} }
@@ -576,17 +543,16 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
* and delete the updates records at the same time. * and delete the updates records at the same time.
*/ */
@CallTimer('doc', 'squash') @CallTimer('doc', 'squash')
private async squash(updates: Update[], snapshot: Snapshot | null) { private async squash(snapshot: Snapshot | null, updates: Update[]) {
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 last = updates[updates.length - 1];
const { id, workspaceId } = first; const last = updates[updates.length - 1];
const { id, workspaceId } = last;
const doc = await this.applyUpdates( const doc = await this.applyUpdates(
first.id, 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)
); );
@@ -617,19 +583,24 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
); );
} }
// always delete updates // we will keep the updates only if the upsert failed on unknown reason
// the upsert will return false if the state is not newer, so we don't need to worry about it // `done === undefined` means the updates is outdated(have already been merged by other process), safe to be deleted
const { count } = await this.db.update.deleteMany({ // `done === true` means the upsert is successful, safe to be deleted
where: { if (done !== false) {
id, // always delete updates
workspaceId, // the upsert will return false if the state is not newer, so we don't need to worry about it
seq: { const { count } = await this.db.update.deleteMany({
in: updates.map(u => u.seq), where: {
id,
workspaceId,
seq: {
in: updates.map(u => u.seq),
},
}, },
}, });
});
await this.updateCachedUpdatesCount(workspaceId, id, -count); await this.updateCachedUpdatesCount(workspaceId, id, -count);
}
return doc; return doc;
} }

View File

@@ -277,6 +277,7 @@ export class WorkspaceResolver {
id: workspace.id, id: workspace.id,
workspaceId: workspace.id, workspaceId: workspace.id,
blob: buffer, blob: buffer,
updatedAt: new Date(),
}, },
}); });
} }

View File

@@ -4,12 +4,7 @@ import { TestingModule } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import test from 'ava'; import test from 'ava';
import * as Sinon from 'sinon'; import * as Sinon from 'sinon';
import { import { applyUpdate, Doc as YDoc, encodeStateAsUpdate } from 'yjs';
applyUpdate,
decodeStateVector,
Doc as YDoc,
encodeStateAsUpdate,
} from 'yjs';
import { DocManager, DocModule } from '../src/core/doc'; import { DocManager, DocModule } from '../src/core/doc';
import { QuotaModule } from '../src/core/quota'; import { QuotaModule } from '../src/core/quota';
@@ -277,72 +272,120 @@ test('should throw if meet max retry times', async t => {
t.is(stub.callCount, 5); t.is(stub.callCount, 5);
}); });
test('should not update snapshot if state is outdated', async t => { test('should be able to insert the snapshot if it is new created', async t => {
const db = m.get(PrismaClient);
const manager = m.get(DocManager); const manager = m.get(DocManager);
await db.snapshot.create({
data: {
id: '2',
workspaceId: '2',
blob: Buffer.from([0, 0]),
seq: 1,
},
});
const doc = new YDoc(); const doc = new YDoc();
const text = doc.getText('content'); const text = doc.getText('content');
const updates: Buffer[] = [];
doc.on('update', update => {
updates.push(Buffer.from(update));
});
text.insert(0, 'hello'); text.insert(0, 'hello');
text.insert(5, 'world'); const update = encodeStateAsUpdate(doc);
text.insert(5, ' ');
await Promise.all(updates.map(update => manager.push('2', '2', update))); await manager.push('1', '1', Buffer.from(update));
const updateWith3Records = await manager.getUpdates('2', '2'); const updates = await manager.getUpdates('1', '1');
text.insert(11, '!'); t.is(updates.length, 1);
await manager.push('2', '2', updates[3]);
const updateWith4Records = await manager.getUpdates('2', '2');
// Simulation:
// Node A get 3 updates and squash them at time 1, will finish at time 10
// Node B get 4 updates and squash them at time 3, will finish at time 8
// Node B finish the squash first, and update the snapshot
// Node A finish the squash later, and update the snapshot to an outdated state
// Time: ---------------------->
// A: ^get ^upsert
// B: ^get ^upsert
//
// We should avoid such situation
// @ts-expect-error private // @ts-expect-error private
await manager.squash(updateWith4Records, null); const snapshot = await manager.squash(null, updates);
// @ts-expect-error private
await manager.squash(updateWith3Records, null);
const result = await db.snapshot.findUnique({ t.truthy(snapshot);
t.is(snapshot.getText('content').toString(), 'hello');
const restUpdates = await manager.getUpdates('1', '1');
t.is(restUpdates.length, 0);
});
test('should be able to merge updates into snapshot', async t => {
const manager = m.get(DocManager);
const updates: Buffer[] = [];
{
const doc = new YDoc();
doc.on('update', data => {
updates.push(Buffer.from(data));
});
const text = doc.getText('content');
text.insert(0, 'hello');
text.insert(5, 'world');
text.insert(5, ' ');
text.insert(11, '!');
}
{
await manager.batchPush('1', '1', updates.slice(0, 2));
// do the merge
const doc = (await manager.get('1', '1'))!;
t.is(doc.getText('content').toString(), 'helloworld');
}
{
await manager.batchPush('1', '1', updates.slice(2));
const doc = (await manager.get('1', '1'))!;
t.is(doc.getText('content').toString(), 'hello world!');
}
const restUpdates = await manager.getUpdates('1', '1');
t.is(restUpdates.length, 0);
});
test('should not update snapshot if doc is outdated', async t => {
const manager = m.get(DocManager);
const db = m.get(PrismaClient);
const updates: Buffer[] = [];
{
const doc = new YDoc();
doc.on('update', data => {
updates.push(Buffer.from(data));
});
const text = doc.getText('content');
text.insert(0, 'hello');
text.insert(5, 'world');
text.insert(5, ' ');
text.insert(11, '!');
}
await manager.batchPush('2', '1', updates.slice(0, 2)); // 'helloworld'
// merge updates into snapshot
await manager.get('2', '1');
// fake the snapshot is a lot newer
await db.snapshot.update({
where: { where: {
id_workspaceId: { id_workspaceId: {
id: '2',
workspaceId: '2', workspaceId: '2',
id: '1',
}, },
}, },
data: {
updatedAt: new Date(Date.now() + 10000),
},
}); });
if (!result) { {
t.fail('snapshot not found'); const snapshot = await manager.getSnapshot('2', '1');
return; await manager.batchPush('2', '1', updates.slice(2)); // 'hello world!'
const updateRecords = await manager.getUpdates('2', '1');
// @ts-expect-error private
const doc = await manager.squash(snapshot, updateRecords);
// all updated will merged into doc not matter it's timestamp is outdated or not,
// but the snapshot record will not be updated
t.is(doc.getText('content').toString(), 'hello world!');
} }
const state = decodeStateVector(result.state!); {
t.is(state.get(doc.clientID), 12); const doc = new YDoc();
applyUpdate(doc, (await manager.getSnapshot('2', '1'))!.blob);
// the snapshot will not get touched if the new doc's timestamp is outdated
t.is(doc.getText('content').toString(), 'helloworld');
const d = new YDoc(); // the updates are known as outdated, so they will be deleted
applyUpdate(d, result.blob!); t.is((await manager.getUpdates('2', '1')).length, 0);
}
const dtext = d.getText('content');
t.is(dtext.toString(), 'hello world!');
}); });