6bf8361936
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>
152 lines
5.5 KiB
TypeScript
152 lines
5.5 KiB
TypeScript
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();
|
|
});
|
|
});
|