test: replace operations sync delays with explicit barriers (WealthLedger/backend#104) #145

Merged
codex-bot merged 1 commits from test/104-operations-sync-delays into develop 2026-10-28 20:41:24 +03:00
+80 -41
View File
@@ -76,7 +76,7 @@ describe('Request-driven operations synchronization (SQLite)', () => {
where: { id }, where: { id },
}); });
if (work.state === expected) return work; if (work.state === expected) return work;
await delay(2); await delay(2); // Backoff between exact DB-state polls; not a timing barrier.
} }
throw new Error(`Sync did not reach ${expected}`); throw new Error(`Sync did not reach ${expected}`);
} }
@@ -113,6 +113,7 @@ describe('Request-driven operations synchronization (SQLite)', () => {
}); });
service = createService(); service = createService();
}); });
afterEach(() => jest.restoreAllMocks());
afterAll(async () => prisma.$disconnect()); afterAll(async () => prisma.$disconnect());
it.each([[false, 1], [true, 1], [false, 2], [true, 2]] as const)( it.each([[false, 1], [true, 1], [false, 2], [true, 2]] as const)(
@@ -186,13 +187,17 @@ describe('Request-driven operations synchronization (SQLite)', () => {
it('publishes the forced visible period before unrelated pending history and retains it if history fails', async () => { it('publishes the forced visible period before unrelated pending history and retains it if history fails', async () => {
const gate = deferred<TbankOperationsByCursorResponse>(); const gate = deferred<TbankOperationsByCursorResponse>();
const started = deferred<void>();
const march = { const march = {
...january, ...january,
from: new Date('2026-03-01Z'), from: new Date('2026-03-01Z'),
to: new Date('2026-04-01Z'), to: new Date('2026-04-01Z'),
}; };
upstream.getOperationsByCursor upstream.getOperationsByCursor
.mockReturnValueOnce(gate.promise) .mockImplementationOnce(() => {
started.resolve();
return gate.promise;
})
.mockImplementation((_token, request) => { .mockImplementation((_token, request) => {
if (Number(request.from?.seconds) + 1 === march.from.valueOf() / 1000) { if (Number(request.from?.seconds) + 1 === march.from.valueOf() / 1000) {
return Promise.resolve({ return Promise.resolve({
@@ -205,7 +210,9 @@ describe('Request-driven operations synchronization (SQLite)', () => {
const history = requiredHandle( const history = requiredHandle(
await service.request('owner', [february, march], false, 'history'), await service.request('owner', [february, march], false, 'history'),
); );
await delay(5); // RPC starts after reserve/claim commits and pending reads the history plan.
// Waiting for state=running alone would not ensure that pending was read.
await started.promise;
const screen = requiredHandle( const screen = requiredHandle(
await service.request('owner', [march], true), await service.request('owner', [march], true),
); );
@@ -225,8 +232,12 @@ describe('Request-driven operations synchronization (SQLite)', () => {
it('moves a newly visible interval ahead of pending backfill and records an unknown requested floor', async () => { it('moves a newly visible interval ahead of pending backfill and records an unknown requested floor', async () => {
const gate = deferred<TbankOperationsByCursorResponse>(); const gate = deferred<TbankOperationsByCursorResponse>();
const started = deferred<void>();
upstream.getOperationsByCursor upstream.getOperationsByCursor
.mockReturnValueOnce(gate.promise) .mockImplementationOnce(() => {
started.resolve();
return gate.promise;
})
.mockResolvedValue({ hasNext: false, items: [] }); .mockResolvedValue({ hasNext: false, items: [] });
const march = { const march = {
...january, ...january,
@@ -241,7 +252,7 @@ describe('Request-driven operations synchronization (SQLite)', () => {
'history', 'history',
), ),
); );
await delay(5); await started.promise;
await service.request('owner', [march], false); await service.request('owner', [march], false);
gate.resolve({ hasNext: false, items: [] }); gate.resolve({ hasNext: false, items: [] });
await state(history, 'complete'); await state(history, 'complete');
@@ -471,7 +482,7 @@ describe('Request-driven operations synchronization (SQLite)', () => {
expect(saved.state).toBe( expect(saved.state).toBe(
checkpoint === 'after-publish' ? 'complete' : 'running', checkpoint === 'after-publish' ? 'complete' : 'running',
); );
await delay(10); await delay(10); // Bounded pause; absence of a recovery RPC cannot be awaited.
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
if (checkpoint === 'after-publish') { if (checkpoint === 'after-publish') {
// eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants.
@@ -504,13 +515,17 @@ describe('Request-driven operations synchronization (SQLite)', () => {
it('joins a request bridging active groups and keeps both original handles observable', async () => { it('joins a request bridging active groups and keeps both original handles observable', async () => {
const blocked = deferred<typeof complete>(); const blocked = deferred<typeof complete>();
const started = deferred<void>();
upstream.getOperationsByCursor upstream.getOperationsByCursor
.mockReturnValueOnce(blocked.promise) .mockImplementationOnce(() => {
started.resolve();
return blocked.promise;
})
.mockResolvedValue({ hasNext: false, items: [] }); .mockResolvedValue({ hasNext: false, items: [] });
const early = { ...january, to: new Date('2026-01-10Z') }; const early = { ...january, to: new Date('2026-01-10Z') };
const late = { ...january, from: new Date('2026-01-20Z') }; const late = { ...january, from: new Date('2026-01-20Z') };
const first = requiredHandle(await service.request('owner', [early], true)); const first = requiredHandle(await service.request('owner', [early], true));
await delay(5); await started.promise;
const second = requiredHandle(await service.request('owner', [late], true)); const second = requiredHandle(await service.request('owner', [late], true));
const merged = requiredHandle( const merged = requiredHandle(
await service.request('owner', [january], true), await service.request('owner', [january], true),
@@ -526,25 +541,28 @@ describe('Request-driven operations synchronization (SQLite)', () => {
it('extends an overlapping atomic group without rereading its intersection or publishing a partial result', async () => { it('extends an overlapping atomic group without rereading its intersection or publishing a partial result', async () => {
const first = deferred<typeof complete>(); const first = deferred<typeof complete>();
const started = deferred<void>();
const remainder = deferred<typeof complete>(); const remainder = deferred<typeof complete>();
const remainderStarted = deferred<void>();
const early = { ...january, to: new Date('2026-01-20Z') }; const early = { ...january, to: new Date('2026-01-20Z') };
const late = { ...january, from: new Date('2026-01-10Z') }; const late = { ...january, from: new Date('2026-01-10Z') };
upstream.getOperationsByCursor upstream.getOperationsByCursor
.mockReturnValueOnce(first.promise) .mockImplementationOnce(() => {
.mockReturnValueOnce(remainder.promise); started.resolve();
return first.promise;
})
.mockImplementationOnce(() => {
remainderStarted.resolve();
return remainder.promise;
});
const handle = requiredHandle( const handle = requiredHandle(
await service.request('owner', [early], true), await service.request('owner', [early], true),
); );
await delay(5); await started.promise;
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1);
expect(await service.request('owner', [late], true)).toBe(handle); expect(await service.request('owner', [late], true)).toBe(handle);
first.resolve({ hasNext: false, items: [] }); first.resolve({ hasNext: false, items: [] });
for ( await remainderStarted.promise;
let index = 0;
index < 500 && upstream.getOperationsByCursor.mock.calls.length < 2;
index += 1
) {
await delay(2);
}
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(2); expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(2);
expect(upstream.getOperationsByCursor.mock.calls[1][1].from).toEqual({ expect(upstream.getOperationsByCursor.mock.calls[1][1].from).toEqual({
seconds: String(early.to.valueOf() / 1000 - 1), seconds: String(early.to.valueOf() / 1000 - 1),
@@ -561,13 +579,17 @@ describe('Request-driven operations synchronization (SQLite)', () => {
items: never[]; items: never[];
nextCursor: string; nextCursor: string;
}>(); }>();
const started = deferred<void>();
upstream.getOperationsByCursor upstream.getOperationsByCursor
.mockReturnValueOnce(first.promise) .mockImplementationOnce(() => {
started.resolve();
return first.promise;
})
.mockResolvedValue({ hasNext: false, items: [] }); .mockResolvedValue({ hasNext: false, items: [] });
const history = requiredHandle( const history = requiredHandle(
await service.request('owner', [january], false, 'history'), await service.request('owner', [january], false, 'history'),
); );
await delay(5); await started.promise;
const screen = requiredHandle( const screen = requiredHandle(
await service.request('owner', [february], true), await service.request('owner', [february], true),
); );
@@ -583,19 +605,17 @@ describe('Request-driven operations synchronization (SQLite)', () => {
it('keeps published initial windows on recovery and restarts only the unfinished month', async () => { it('keeps published initial windows on recovery and restarts only the unfinished month', async () => {
const blocked = deferred<typeof complete>(); const blocked = deferred<typeof complete>();
const blockedStarted = deferred<void>();
upstream.getOperationsByCursor upstream.getOperationsByCursor
.mockResolvedValueOnce(complete) .mockResolvedValueOnce(complete)
.mockReturnValueOnce(blocked.promise); .mockImplementationOnce(() => {
blockedStarted.resolve();
return blocked.promise;
});
const handle = requiredHandle( const handle = requiredHandle(
await service.request('owner', [january, february], false), await service.request('owner', [january, february], false),
); );
for ( await blockedStarted.promise;
let index = 0;
index < 500 && upstream.getOperationsByCursor.mock.calls.length < 2;
index += 1
) {
await delay(2);
}
expect(await prisma.tbankOperation.count()).toBe(1); expect(await prisma.tbankOperation.count()).toBe(1);
const before = await prisma.tbankOperationCoverage.findMany(); const before = await prisma.tbankOperationCoverage.findMany();
expect(before).toHaveLength(1); expect(before).toHaveLength(1);
@@ -616,8 +636,15 @@ describe('Request-driven operations synchronization (SQLite)', () => {
}) })
).fetchedAt, ).fetchedAt,
).toEqual(before[0].fetchedAt); ).toEqual(before[0].fetchedAt);
const stalePage = deferred<Promise<boolean>>();
const stagePage = repository.stagePage.bind(repository);
jest.spyOn(repository, 'stagePage').mockImplementation((...args) => {
const result = stagePage(...args);
stalePage.resolve(result);
return result;
});
blocked.resolve(complete); blocked.resolve(complete);
await delay(10); expect(await stalePage.promise).toBe(false);
expect(await prisma.tbankOperation.count()).toBe(1); expect(await prisma.tbankOperation.count()).toBe(1);
expect(await prisma.tbankOperationCoverage.count()).toBe(2); expect(await prisma.tbankOperationCoverage.count()).toBe(2);
}); });
@@ -651,7 +678,11 @@ describe('Request-driven operations synchronization (SQLite)', () => {
it('coalesces twenty requests, observes without RPC and completes only after the last page', async () => { it('coalesces twenty requests, observes without RPC and completes only after the last page', async () => {
const blocked = deferred<typeof complete>(); const blocked = deferred<typeof complete>();
upstream.getOperationsByCursor.mockReturnValueOnce(blocked.promise); const started = deferred<void>();
upstream.getOperationsByCursor.mockImplementationOnce(() => {
started.resolve();
return blocked.promise;
});
const handles = await Promise.all( const handles = await Promise.all(
Array.from({ length: 20 }, () => Array.from({ length: 20 }, () =>
service.request('owner', [january], true), service.request('owner', [january], true),
@@ -659,7 +690,7 @@ describe('Request-driven operations synchronization (SQLite)', () => {
); );
expect(new Set(handles).size).toBe(1); expect(new Set(handles).size).toBe(1);
const handle = requiredHandle(handles[0]); const handle = requiredHandle(handles[0]);
await delay(5); await started.promise;
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1); expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1);
expect((await service.observe('owner', handle)).state).toBe('running'); expect((await service.observe('owner', handle)).state).toBe('running');
expect(await prisma.tbankOperation.count()).toBe(0); expect(await prisma.tbankOperation.count()).toBe(0);
@@ -699,6 +730,7 @@ describe('Request-driven operations synchronization (SQLite)', () => {
expect(await prisma.tbankOperationCoverage.findMany()).toEqual(oldCoverage); expect(await prisma.tbankOperationCoverage.findMany()).toEqual(oldCoverage);
const calls = upstream.getOperationsByCursor.mock.calls.length; const calls = upstream.getOperationsByCursor.mock.calls.length;
await service.observe('owner', handle); await service.observe('owner', handle);
// Bounded pause; absence of a background retry cannot be awaited.
await delay(10); await delay(10);
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(calls); expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(calls);
upstream.getOperationsByCursor upstream.getOperationsByCursor
@@ -713,29 +745,29 @@ describe('Request-driven operations synchronization (SQLite)', () => {
it('starts an unfinished interval from page one after a new run request and ignores late old pages', async () => { it('starts an unfinished interval from page one after a new run request and ignores late old pages', async () => {
const blocked = deferred<typeof complete>(); const blocked = deferred<typeof complete>();
const blockedStarted = deferred<void>();
upstream.getOperationsByCursor upstream.getOperationsByCursor
.mockResolvedValueOnce({ .mockResolvedValueOnce({
hasNext: true, hasNext: true,
nextCursor: 'provider-cursor', nextCursor: 'provider-cursor',
items: [operation()], items: [operation()],
}) })
.mockReturnValueOnce(blocked.promise); .mockImplementationOnce(() => {
blockedStarted.resolve();
return blocked.promise;
});
const handle = requiredHandle( const handle = requiredHandle(
await service.request('owner', [january], true), await service.request('owner', [january], true),
); );
for ( await blockedStarted.promise;
let index = 0;
index < 500 && upstream.getOperationsByCursor.mock.calls.length < 2;
index += 1
) {
await delay(2);
}
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(2);
const before = await prisma.tbankSyncWork.findUniqueOrThrow({ const before = await prisma.tbankSyncWork.findUniqueOrThrow({
where: { id: handle }, where: { id: handle },
}); });
expect(await prisma.tbankSyncStage.count()).toBeGreaterThan(0);
service = createService(); service = createService();
await service.observe('owner', handle); await service.observe('owner', handle);
// Bounded pause; absence of work adoption by observe cannot be awaited.
await delay(5); await delay(5);
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(2); expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(2);
upstream.getOperationsByCursor.mockResolvedValueOnce({ upstream.getOperationsByCursor.mockResolvedValueOnce({
@@ -752,8 +784,15 @@ describe('Request-driven operations synchronization (SQLite)', () => {
expect( expect(
upstream.getOperationsByCursor.mock.calls[2][1].cursor, upstream.getOperationsByCursor.mock.calls[2][1].cursor,
).toBeUndefined(); ).toBeUndefined();
const stalePage = deferred<Promise<boolean>>();
const stagePage = repository.stagePage.bind(repository);
jest.spyOn(repository, 'stagePage').mockImplementation((...args) => {
const result = stagePage(...args);
stalePage.resolve(result);
return result;
});
blocked.resolve(complete); blocked.resolve(complete);
await delay(10); expect(await stalePage.promise).toBe(false);
expect((await prisma.tbankOperation.findFirstOrThrow()).payload).toEqual( expect((await prisma.tbankOperation.findFirstOrThrow()).payload).toEqual(
operation('30'), operation('30'),
); );