test: replace operations sync delays with explicit barriers (WealthLedger/backend#104)
This commit is contained in:
@@ -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'),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user