fix: skip hourly terms overlapped by a long walk (WealthLedger/backend#54)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 13s
Backend verify / verify (push) Failing after 6m56s

This commit was merged in pull request #93.
This commit is contained in:
2026-10-14 22:43:08 +03:00
parent 14bcb9da5f
commit 6975853656
2 changed files with 89 additions and 2 deletions
+77
View File
@@ -385,6 +385,83 @@ describe('hourly attempt lifecycle (SQLite)', () => {
expect(next?.state).not.toBe('running');
});
describe('hourly term eligibility', () => {
const at = (h: number, m = 0) => new Date(Date.UTC(2026, 0, 1, h, m));
const seed = (
state: 'complete' | 'failed' | 'running',
start: Date,
end: Date,
processRunId = 'other-process',
) =>
prisma.tbankSyncWork.create({
data: {
id: randomUUID(),
userId: USER,
revision: REVISION,
scopeKey: 'hourly',
attemptId: randomUUID(),
processRunId,
priority: 0,
intent: {},
progress: {},
state,
createdAt: start,
updatedAt: end,
},
});
const reserve = async (when: Date) => {
jest.setSystemTime(when);
return repository.reserveHourly(USER, 'this-process', 3_600_000);
};
it.each([
['short attempt', 'complete', at(10), at(10, 5), at(10, 59), at(11)],
['failed attempt', 'failed', at(10), at(10, 5), at(10, 59), at(11)],
['walk past one term', 'complete', at(10), at(11, 10), at(11, 59), at(12)],
['walk past two terms', 'complete', at(10), at(12, 10), at(12, 59), at(13)],
['walk ending on a term', 'complete', at(10), at(11), at(10, 59), at(11)],
] as const)('%s', async (_name, state, start, end, early, due) => {
await seed(state, start, end);
expect(await reserve(early)).toBeNull();
const reserved = await reserve(due);
expect(reserved?.work.state).toBe('running');
expect(reserved?.work.createdAt).toEqual(due);
});
it('starts a new revision at once', async () => {
await seed('complete', at(10), at(10, 5));
await prisma.tbankConnection.deleteMany({ where: { userId: USER } });
await prisma.tbankConnection.create({
data: {
userId: USER,
revision: 'next-revision',
encryptedToken: tokens.encrypt('synthetic'),
connectedAt: at(10, 10),
},
});
expect((await reserve(at(10, 10)))?.work.revision).toBe('next-revision');
});
it('closes a dead process attempt without early retry and runs one catch-up after downtime', async () => {
await seed('running', at(10), at(10, 1), 'dead-process');
expect(await reserve(at(10, 30))).toBeNull();
expect((await hourlyRow())?.errorCode).toBe('INTERRUPTED');
expect(await reserve(at(10, 59))).toBeNull();
expect((await reserve(at(14, 30)))?.work.createdAt).toEqual(at(14, 30));
expect(await reserve(at(14, 31))).toBeNull();
expect(
await prisma.tbankSyncWork.count({
where: { userId: USER, scopeKey: 'hourly' },
}),
).toBe(1);
});
it('does not reserve while this process still runs the attempt', async () => {
await seed('running', at(10), at(10, 1), 'this-process');
expect(await reserve(at(12, 30))).toBeNull();
});
});
it('does not overlap a running attempt with a second walk', async () => {
operations.request.mockImplementation(async () => {
const row = await child('running', null, 'operations');