2023-09-01 22:41:29 +03:00
|
|
|
import { mock } from 'node:test';
|
2023-08-29 13:07:05 +03:00
|
|
|
|
2023-09-29 06:02:26 +03:00
|
|
|
import type { INestApplication } from '@nestjs/common';
|
2023-08-29 13:07:05 +03:00
|
|
|
import { Test, TestingModule } from '@nestjs/testing';
|
2023-09-01 22:41:29 +03:00
|
|
|
import test from 'ava';
|
2023-08-29 13:07:05 +03:00
|
|
|
import { register } from 'prom-client';
|
|
|
|
import * as Sinon from 'sinon';
|
2023-12-04 14:12:15 +03:00
|
|
|
import {
|
|
|
|
applyUpdate,
|
|
|
|
decodeStateVector,
|
|
|
|
Doc as YDoc,
|
|
|
|
encodeStateAsUpdate,
|
|
|
|
} from 'yjs';
|
2023-08-29 13:07:05 +03:00
|
|
|
|
2023-11-22 07:09:06 +03:00
|
|
|
import { CacheModule } from '../src/cache';
|
2023-09-15 10:34:14 +03:00
|
|
|
import { Config, ConfigModule } from '../src/config';
|
2023-12-14 12:50:46 +03:00
|
|
|
import {
|
|
|
|
collectMigrations,
|
|
|
|
RevertCommand,
|
|
|
|
RunCommand,
|
|
|
|
} from '../src/data/commands/run';
|
2023-12-08 08:00:58 +03:00
|
|
|
import { EventModule } from '../src/event';
|
2023-09-15 10:34:14 +03:00
|
|
|
import { DocManager, DocModule } from '../src/modules/doc';
|
2023-12-14 12:50:46 +03:00
|
|
|
import { QuotaModule } from '../src/modules/quota';
|
2024-01-03 13:56:54 +03:00
|
|
|
import { StorageModule } from '../src/modules/storage';
|
2023-09-15 10:34:14 +03:00
|
|
|
import { PrismaModule, PrismaService } from '../src/prisma';
|
2024-01-03 13:56:54 +03:00
|
|
|
import { flushDB } from './utils';
|
2023-08-29 13:07:05 +03:00
|
|
|
|
|
|
|
const createModule = () => {
|
|
|
|
return Test.createTestingModule({
|
|
|
|
imports: [
|
|
|
|
PrismaModule,
|
2023-11-22 07:09:06 +03:00
|
|
|
CacheModule,
|
2023-12-08 08:00:58 +03:00
|
|
|
EventModule,
|
2023-12-14 12:50:46 +03:00
|
|
|
QuotaModule,
|
2024-01-03 13:56:54 +03:00
|
|
|
StorageModule,
|
2023-08-29 13:07:05 +03:00
|
|
|
ConfigModule.forRoot(),
|
2023-12-13 05:12:37 +03:00
|
|
|
DocModule,
|
2023-12-14 12:50:46 +03:00
|
|
|
RevertCommand,
|
|
|
|
RunCommand,
|
2023-08-29 13:07:05 +03:00
|
|
|
],
|
|
|
|
}).compile();
|
|
|
|
};
|
|
|
|
|
2023-09-01 22:41:29 +03:00
|
|
|
let app: INestApplication;
|
|
|
|
let m: TestingModule;
|
|
|
|
let timer: Sinon.SinonFakeTimers;
|
2023-08-29 13:07:05 +03:00
|
|
|
|
2023-09-01 22:41:29 +03:00
|
|
|
// cleanup database before each test
|
|
|
|
test.beforeEach(async () => {
|
|
|
|
timer = Sinon.useFakeTimers({
|
|
|
|
toFake: ['setInterval'],
|
2023-08-29 13:07:05 +03:00
|
|
|
});
|
2023-09-01 22:41:29 +03:00
|
|
|
await flushDB();
|
|
|
|
m = await createModule();
|
|
|
|
app = m.createNestApplication();
|
|
|
|
app.enableShutdownHooks();
|
|
|
|
await app.init();
|
2023-12-14 12:50:46 +03:00
|
|
|
|
|
|
|
// init features
|
|
|
|
const run = m.get(RunCommand);
|
|
|
|
const revert = m.get(RevertCommand);
|
|
|
|
const migrations = await collectMigrations();
|
|
|
|
await Promise.allSettled(migrations.map(m => revert.run([m.name])));
|
|
|
|
await run.run();
|
2023-09-01 22:41:29 +03:00
|
|
|
});
|
2023-08-29 13:07:05 +03:00
|
|
|
|
2023-09-11 12:30:39 +03:00
|
|
|
test.afterEach.always(async () => {
|
2023-09-01 22:41:29 +03:00
|
|
|
await app.close();
|
2023-09-07 23:55:04 +03:00
|
|
|
await m.close();
|
2023-09-01 22:41:29 +03:00
|
|
|
timer.restore();
|
|
|
|
});
|
2023-08-29 13:07:05 +03:00
|
|
|
|
2023-09-01 22:41:29 +03:00
|
|
|
test('should setup update poll interval', async t => {
|
|
|
|
register.clear();
|
|
|
|
const m = await createModule();
|
|
|
|
const manager = m.get(DocManager);
|
|
|
|
const fake = mock.method(manager, 'setup');
|
2023-08-29 13:07:05 +03:00
|
|
|
|
2023-09-01 22:41:29 +03:00
|
|
|
await m.createNestApplication().init();
|
2023-08-29 13:07:05 +03:00
|
|
|
|
2023-09-05 11:01:45 +03:00
|
|
|
t.is(fake.mock.callCount(), 1);
|
2023-09-01 22:41:29 +03:00
|
|
|
// @ts-expect-error private member
|
2023-09-05 11:01:45 +03:00
|
|
|
t.truthy(manager.job);
|
2023-09-01 22:41:29 +03:00
|
|
|
});
|
2023-08-29 13:07:05 +03:00
|
|
|
|
2023-09-01 22:41:29 +03:00
|
|
|
test('should be able to stop poll', async t => {
|
|
|
|
const manager = m.get(DocManager);
|
|
|
|
const fake = mock.method(manager, 'destroy');
|
2023-08-29 13:07:05 +03:00
|
|
|
|
2023-09-01 22:41:29 +03:00
|
|
|
await app.close();
|
2023-08-29 13:07:05 +03:00
|
|
|
|
2023-09-05 11:01:45 +03:00
|
|
|
t.is(fake.mock.callCount(), 1);
|
2023-09-01 22:41:29 +03:00
|
|
|
// @ts-expect-error private member
|
2023-09-05 11:01:45 +03:00
|
|
|
t.is(manager.job, null);
|
2023-09-01 22:41:29 +03:00
|
|
|
});
|
2023-08-29 13:07:05 +03:00
|
|
|
|
2023-09-01 22:41:29 +03:00
|
|
|
test('should poll when intervel due', async t => {
|
|
|
|
const manager = m.get(DocManager);
|
|
|
|
const interval = m.get(Config).doc.manager.updatePollInterval;
|
2023-08-29 13:07:05 +03:00
|
|
|
|
2023-09-01 22:41:29 +03:00
|
|
|
let resolve: any;
|
2023-10-10 06:23:12 +03:00
|
|
|
// @ts-expect-error private method
|
|
|
|
const fake = mock.method(manager, 'autoSquash', () => {
|
2023-09-01 22:41:29 +03:00
|
|
|
return new Promise(_resolve => {
|
|
|
|
resolve = _resolve;
|
2023-08-29 13:07:05 +03:00
|
|
|
});
|
|
|
|
});
|
|
|
|
|
2023-09-01 22:41:29 +03:00
|
|
|
timer.tick(interval);
|
2023-09-05 11:01:45 +03:00
|
|
|
t.is(fake.mock.callCount(), 1);
|
2023-08-29 13:07:05 +03:00
|
|
|
|
2023-09-01 22:41:29 +03:00
|
|
|
// busy
|
|
|
|
timer.tick(interval);
|
|
|
|
// @ts-expect-error private member
|
2023-09-05 11:01:45 +03:00
|
|
|
t.is(manager.busy, true);
|
|
|
|
t.is(fake.mock.callCount(), 1);
|
2023-08-29 13:07:05 +03:00
|
|
|
|
2023-09-01 22:41:29 +03:00
|
|
|
resolve();
|
|
|
|
await timer.tickAsync(1);
|
2023-08-29 13:07:05 +03:00
|
|
|
|
2023-09-01 22:41:29 +03:00
|
|
|
// @ts-expect-error private member
|
2023-09-05 11:01:45 +03:00
|
|
|
t.is(manager.busy, false);
|
2023-09-01 22:41:29 +03:00
|
|
|
timer.tick(interval);
|
2023-09-05 11:01:45 +03:00
|
|
|
t.is(fake.mock.callCount(), 2);
|
2023-09-01 22:41:29 +03:00
|
|
|
});
|
2023-08-29 13:07:05 +03:00
|
|
|
|
2023-09-01 22:41:29 +03:00
|
|
|
test('should merge update when intervel due', async t => {
|
|
|
|
const db = m.get(PrismaService);
|
|
|
|
const manager = m.get(DocManager);
|
2023-08-29 13:07:05 +03:00
|
|
|
|
2023-09-01 22:41:29 +03:00
|
|
|
const doc = new YDoc();
|
|
|
|
const text = doc.getText('content');
|
|
|
|
text.insert(0, 'hello');
|
|
|
|
const update = encodeStateAsUpdate(doc);
|
2023-08-29 13:07:05 +03:00
|
|
|
|
2023-09-01 22:41:29 +03:00
|
|
|
const ws = await db.workspace.create({
|
|
|
|
data: {
|
|
|
|
id: '1',
|
|
|
|
public: false,
|
|
|
|
},
|
|
|
|
});
|
2023-08-29 13:07:05 +03:00
|
|
|
|
2023-09-01 22:41:29 +03:00
|
|
|
await db.update.createMany({
|
|
|
|
data: [
|
|
|
|
{
|
2023-08-29 13:07:05 +03:00
|
|
|
id: '1',
|
2023-09-01 22:41:29 +03:00
|
|
|
workspaceId: '1',
|
|
|
|
blob: Buffer.from([0, 0]),
|
2023-10-10 06:23:12 +03:00
|
|
|
seq: 1,
|
2023-08-29 13:07:05 +03:00
|
|
|
},
|
2023-09-01 22:41:29 +03:00
|
|
|
{
|
|
|
|
id: '1',
|
|
|
|
workspaceId: '1',
|
|
|
|
blob: Buffer.from(update),
|
2023-10-10 06:23:12 +03:00
|
|
|
seq: 2,
|
2023-09-01 22:41:29 +03:00
|
|
|
},
|
|
|
|
],
|
|
|
|
});
|
|
|
|
|
2023-10-10 06:23:12 +03:00
|
|
|
// @ts-expect-error private method
|
|
|
|
await manager.autoSquash();
|
2023-08-29 13:07:05 +03:00
|
|
|
|
2023-09-05 11:01:45 +03:00
|
|
|
t.deepEqual(
|
2023-10-10 06:23:12 +03:00
|
|
|
(await manager.getBinary(ws.id, '1'))?.toString('hex'),
|
2023-09-05 11:01:45 +03:00
|
|
|
Buffer.from(update.buffer).toString('hex')
|
|
|
|
);
|
2023-08-29 13:07:05 +03:00
|
|
|
|
2023-09-01 22:41:29 +03:00
|
|
|
let appendUpdate = Buffer.from([]);
|
|
|
|
doc.on('update', update => {
|
|
|
|
appendUpdate = Buffer.from(update);
|
2023-08-29 13:07:05 +03:00
|
|
|
});
|
2023-09-01 22:41:29 +03:00
|
|
|
text.insert(5, 'world');
|
|
|
|
|
|
|
|
await db.update.create({
|
|
|
|
data: {
|
|
|
|
workspaceId: ws.id,
|
|
|
|
id: '1',
|
|
|
|
blob: appendUpdate,
|
2023-10-10 06:23:12 +03:00
|
|
|
seq: 3,
|
2023-09-01 22:41:29 +03:00
|
|
|
},
|
|
|
|
});
|
|
|
|
|
2023-10-10 06:23:12 +03:00
|
|
|
// @ts-expect-error private method
|
|
|
|
await manager.autoSquash();
|
2023-09-01 22:41:29 +03:00
|
|
|
|
2023-09-05 11:01:45 +03:00
|
|
|
t.deepEqual(
|
2023-10-10 06:23:12 +03:00
|
|
|
(await manager.getBinary(ws.id, '1'))?.toString('hex'),
|
2023-09-05 11:01:45 +03:00
|
|
|
Buffer.from(encodeStateAsUpdate(doc)).toString('hex')
|
2023-09-01 22:41:29 +03:00
|
|
|
);
|
2023-08-29 13:07:05 +03:00
|
|
|
});
|
2023-10-10 06:23:12 +03:00
|
|
|
|
|
|
|
test('should have sequential update number', async t => {
|
|
|
|
const db = m.get(PrismaService);
|
|
|
|
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 Promise.all(updates.map(update => manager.push('2', '2', update)));
|
|
|
|
|
|
|
|
// [1,2,3]
|
|
|
|
let records = await manager.getUpdates('2', '2');
|
|
|
|
|
|
|
|
t.deepEqual(
|
|
|
|
records.map(({ seq }) => seq),
|
|
|
|
[1, 2, 3]
|
|
|
|
);
|
|
|
|
|
|
|
|
// @ts-expect-error private method
|
|
|
|
await manager.autoSquash();
|
|
|
|
|
|
|
|
await db.snapshot.update({
|
|
|
|
where: {
|
|
|
|
id_workspaceId: {
|
|
|
|
id: '2',
|
|
|
|
workspaceId: '2',
|
|
|
|
},
|
|
|
|
},
|
|
|
|
data: {
|
|
|
|
seq: 0x3ffffffe,
|
|
|
|
},
|
|
|
|
});
|
|
|
|
|
|
|
|
await Promise.all(updates.map(update => manager.push('2', '2', update)));
|
|
|
|
|
|
|
|
records = await manager.getUpdates('2', '2');
|
|
|
|
|
|
|
|
// push a new update with new seq num
|
|
|
|
await manager.push('2', '2', updates[0]);
|
|
|
|
|
|
|
|
// let the manager ignore update with the new seq num
|
|
|
|
const stub = Sinon.stub(manager, 'getUpdates').resolves(records);
|
|
|
|
|
|
|
|
// @ts-expect-error private method
|
|
|
|
await manager.autoSquash();
|
|
|
|
stub.restore();
|
|
|
|
|
|
|
|
records = await manager.getUpdates('2', '2');
|
|
|
|
|
|
|
|
// should not merge in one run
|
|
|
|
t.not(records.length, 0);
|
|
|
|
});
|
|
|
|
|
2023-11-02 12:05:28 +03:00
|
|
|
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]
|
|
|
|
);
|
|
|
|
});
|
|
|
|
|
2023-10-10 06:23:12 +03:00
|
|
|
test('should retry if seq num conflict', async t => {
|
|
|
|
const manager = m.get(DocManager);
|
|
|
|
|
|
|
|
// @ts-expect-error private method
|
|
|
|
const stub = Sinon.stub(manager, 'getUpdateSeq');
|
|
|
|
|
|
|
|
stub.onCall(0).resolves(1);
|
|
|
|
// seq num conflict
|
|
|
|
stub.onCall(1).resolves(1);
|
|
|
|
stub.onCall(2).resolves(2);
|
|
|
|
await t.notThrowsAsync(() => manager.push('1', '1', Buffer.from([0, 0])));
|
|
|
|
await t.notThrowsAsync(() => manager.push('1', '1', Buffer.from([0, 0])));
|
|
|
|
|
|
|
|
t.is(stub.callCount, 3);
|
|
|
|
});
|
2023-11-02 12:05:28 +03:00
|
|
|
|
|
|
|
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);
|
|
|
|
});
|
2023-12-04 14:12:15 +03:00
|
|
|
|
|
|
|
test('should not update snapshot if state is outdated', async t => {
|
|
|
|
const db = m.get(PrismaService);
|
|
|
|
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 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 Promise.all(updates.map(update => manager.push('2', '2', update)));
|
|
|
|
|
|
|
|
const updateWith3Records = await manager.getUpdates('2', '2');
|
|
|
|
text.insert(11, '!');
|
|
|
|
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
|
|
|
|
await manager.squash(updateWith4Records, null);
|
|
|
|
// @ts-expect-error private
|
|
|
|
await manager.squash(updateWith3Records, null);
|
|
|
|
|
|
|
|
const result = await db.snapshot.findUnique({
|
|
|
|
where: {
|
|
|
|
id_workspaceId: {
|
|
|
|
id: '2',
|
|
|
|
workspaceId: '2',
|
|
|
|
},
|
|
|
|
},
|
|
|
|
});
|
|
|
|
|
|
|
|
if (!result) {
|
|
|
|
t.fail('snapshot not found');
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const state = decodeStateVector(result.state!);
|
|
|
|
t.is(state.get(doc.clientID), 12);
|
|
|
|
|
|
|
|
const d = new YDoc();
|
|
|
|
applyUpdate(d, result.blob!);
|
|
|
|
|
|
|
|
const dtext = d.getText('content');
|
|
|
|
t.is(dtext.toString(), 'hello world!');
|
|
|
|
});
|