perf(comment): унести Yjs-обновление comment-mark с HTTP-критического пути (#399)
resolve/unresolve/delete comment-mark синхронно дёргали collab-gateway
(handleYjsEvent) прямо в HTTP-запросе — сетевой раунд-трип к collab на
горячем пути. Теперь:
- DB-строка пишется синхронно (источник истины) с общим таймстампом;
- сама mark-операция уходит идемпотентным COMMENT_MARK_UPDATE-джобом на
GENERAL_QUEUE, воркер проигрывает тот же handleYjsEvent;
- resolve/unresolve — fire-and-forget (best-effort), delete — await энкью
ДО необратимого hard-delete (durability split);
- race-guard: устаревшее ПРОТИВОПОЛОЖНОЕ событие (ts <= updatedAt строки и
состояние расходится) пропускается, а не флипает mark в устаревшее;
- DI-цикл обойдён ленивым moduleRef.get(CollaborationGateway, strict:false).
Внутренний цикл: 2 прохода. Правки по внутреннему ревью: `<` → `<=` в
race-guard (безопасная обработка суб-миллисекундной ничьи двух
противоположных тогглов); задокументирован сознательный компромисс —
транзиентный page.updated-broadcast из воркера несёт только {id}, теряя
name/avatarUrl «кто редактировал» (lastUpdatedById выставляется верно,
косметика, самочинится на следующем реальном редактировании).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+151
@@ -0,0 +1,151 @@
|
||||
import { Job } from 'bullmq';
|
||||
import { GeneralQueueProcessor } from './general-queue.processor';
|
||||
import { QueueJob } from '../constants';
|
||||
import { ICommentMarkUpdateJob } from '../constants/queue.interface';
|
||||
|
||||
/**
|
||||
* #399: the GENERAL_QUEUE worker replays the comment inline-mark op that used to
|
||||
* run synchronously on the HTTP path. It must call the SAME gateway handler with
|
||||
* the SAME semantics (resolve/unresolve → flip the `resolved` attribute; delete
|
||||
* → strip the anchor), and its timestamp race-guard must skip an event a newer,
|
||||
* opposite event already superseded.
|
||||
*/
|
||||
describe('GeneralQueueProcessor — COMMENT_MARK_UPDATE (#399)', () => {
|
||||
function makeProc() {
|
||||
const collaborationGateway: any = {
|
||||
handleYjsEvent: jest.fn(async () => undefined),
|
||||
};
|
||||
const commentRepo: any = { findById: jest.fn() };
|
||||
// #399: the processor resolves CollaborationGateway lazily via ModuleRef
|
||||
// (strict:false) to avoid a DI cycle; the fake returns our gateway spy.
|
||||
const moduleRef: any = { get: jest.fn(() => collaborationGateway) };
|
||||
const proc = new GeneralQueueProcessor(
|
||||
{} as any, // db
|
||||
{} as any, // backlinkRepo
|
||||
{} as any, // watcherRepo
|
||||
commentRepo,
|
||||
moduleRef,
|
||||
);
|
||||
return { proc, collaborationGateway, commentRepo };
|
||||
}
|
||||
|
||||
const job = (data: ICommentMarkUpdateJob): Job =>
|
||||
({ name: QueueJob.COMMENT_MARK_UPDATE, data }) as unknown as Job;
|
||||
|
||||
const base = {
|
||||
documentName: 'page.page-1',
|
||||
commentId: 'c-1',
|
||||
userId: 'user-1',
|
||||
};
|
||||
|
||||
it('resolve → resolveCommentMark with resolved:true and the same-shape args', async () => {
|
||||
const { proc, collaborationGateway, commentRepo } = makeProc();
|
||||
const ts = 1000;
|
||||
// Row reflects the resolve (source of truth), stamped at the same ts.
|
||||
commentRepo.findById.mockResolvedValue({
|
||||
id: 'c-1',
|
||||
resolvedAt: new Date(ts),
|
||||
updatedAt: new Date(ts),
|
||||
});
|
||||
|
||||
await proc.process(job({ ...base, action: 'resolve', ts }));
|
||||
|
||||
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledTimes(1);
|
||||
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
||||
'resolveCommentMark',
|
||||
'page.page-1',
|
||||
{ commentId: 'c-1', resolved: true, user: { id: 'user-1' } },
|
||||
);
|
||||
});
|
||||
|
||||
it('unresolve → resolveCommentMark with resolved:false', async () => {
|
||||
const { proc, collaborationGateway, commentRepo } = makeProc();
|
||||
const ts = 2000;
|
||||
commentRepo.findById.mockResolvedValue({
|
||||
id: 'c-1',
|
||||
resolvedAt: null,
|
||||
updatedAt: new Date(ts),
|
||||
});
|
||||
|
||||
await proc.process(job({ ...base, action: 'unresolve', ts }));
|
||||
|
||||
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
||||
'resolveCommentMark',
|
||||
'page.page-1',
|
||||
{ commentId: 'c-1', resolved: false, user: { id: 'user-1' } },
|
||||
);
|
||||
});
|
||||
|
||||
it('delete → deleteCommentMark (strip the anchor), no row lookup / no state guard', async () => {
|
||||
const { proc, collaborationGateway, commentRepo } = makeProc();
|
||||
|
||||
await proc.process(job({ ...base, action: 'delete', ts: 123 }));
|
||||
|
||||
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
||||
'deleteCommentMark',
|
||||
'page.page-1',
|
||||
{ commentId: 'c-1', user: { id: 'user-1' } },
|
||||
);
|
||||
// Delete carries no state guard — the row is (being) removed.
|
||||
expect(commentRepo.findById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SKIPS a stale resolve superseded by a newer unresolve (row unresolved, job ts older)', async () => {
|
||||
const { proc, collaborationGateway, commentRepo } = makeProc();
|
||||
// A later unresolve already set the row: resolvedAt null, updatedAt = 5000.
|
||||
commentRepo.findById.mockResolvedValue({
|
||||
id: 'c-1',
|
||||
resolvedAt: null,
|
||||
updatedAt: new Date(5000),
|
||||
});
|
||||
|
||||
// Stale resolve job enqueued at ts=1000 (< 5000), intends resolved=true,
|
||||
// but the row's authoritative state is unresolved → skip.
|
||||
await proc.process(job({ ...base, action: 'resolve', ts: 1000 }));
|
||||
|
||||
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SKIPS a stale unresolve superseded by a newer resolve', async () => {
|
||||
const { proc, collaborationGateway, commentRepo } = makeProc();
|
||||
commentRepo.findById.mockResolvedValue({
|
||||
id: 'c-1',
|
||||
resolvedAt: new Date(5000),
|
||||
updatedAt: new Date(5000),
|
||||
});
|
||||
|
||||
await proc.process(job({ ...base, action: 'unresolve', ts: 1000 }));
|
||||
|
||||
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies when the row state agrees even if ts is older (idempotent, not a stale flip)', async () => {
|
||||
const { proc, collaborationGateway, commentRepo } = makeProc();
|
||||
// Row is resolved and its updatedAt is newer than the job ts, but the state
|
||||
// AGREES with the job → this is a harmless idempotent replay, not a stale
|
||||
// opposite event, so it must still apply.
|
||||
commentRepo.findById.mockResolvedValue({
|
||||
id: 'c-1',
|
||||
resolvedAt: new Date(9000),
|
||||
updatedAt: new Date(9000),
|
||||
});
|
||||
|
||||
await proc.process(job({ ...base, action: 'resolve', ts: 1000 }));
|
||||
|
||||
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
||||
'resolveCommentMark',
|
||||
'page.page-1',
|
||||
{ commentId: 'c-1', resolved: true, user: { id: 'user-1' } },
|
||||
);
|
||||
});
|
||||
|
||||
it('skips (no throw) when the comment row has vanished', async () => {
|
||||
const { proc, collaborationGateway, commentRepo } = makeProc();
|
||||
commentRepo.findById.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
proc.process(job({ ...base, action: 'resolve', ts: 1000 })),
|
||||
).resolves.toBeUndefined();
|
||||
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user