RetryPolicy mit retryDelayMsFor (bestehende Aufrufer unverändert), Phasen transcribe/extract mit eigenen Wartezeiten (5/15 min bzw. 30 s/2 min, 3 Versuche) und Stale-Fenstern (30 bzw. 5 min). Claims per FOR UPDATE SKIP LOCKED mit claimToken als Compare-and-Set, nur Notizen mit gültigem Inhalt; Erfolg setzt den nächsten Status und nach dem Abschreiben attempts = 0; Fehler meldet 'lost', wenn die Notiz inzwischen bestätigt oder verworfen wurde; Aufschub mit Grund; Stale-Freigabe transaktionsfähig mit den beendeten Notizen als Rückgabe. Eine Quelle für die 14 Fehlercodes. Refs #27 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
140 lines
4.0 KiB
TypeScript
140 lines
4.0 KiB
TypeScript
import { Prisma } from '../../../generated/prisma';
|
|
import { PrismaService } from '../../prisma/prisma.service';
|
|
import {
|
|
claimVoiceCaptures,
|
|
deferVoiceClaim,
|
|
releaseStaleVoiceClaims,
|
|
settleVoiceFailure,
|
|
settleVoiceSuccess,
|
|
VoiceClaim,
|
|
} from './voice-claims';
|
|
|
|
function fakeDb() {
|
|
const queryRaw = jest.fn().mockResolvedValue([]);
|
|
const executeRaw = jest.fn().mockResolvedValue(1);
|
|
return {
|
|
db: {
|
|
$queryRaw: queryRaw,
|
|
$executeRaw: executeRaw,
|
|
} as unknown as PrismaService,
|
|
queryRaw,
|
|
executeRaw,
|
|
};
|
|
}
|
|
|
|
const claim: VoiceClaim = {
|
|
id: 'c1',
|
|
claimToken: 't1',
|
|
attempts: 0,
|
|
ownerUserId: 'u1',
|
|
colonyId: 'k1',
|
|
audioMime: 'audio/webm',
|
|
durationSec: 30,
|
|
};
|
|
|
|
function sqlOf(call: unknown): Prisma.Sql {
|
|
return (call as unknown[])[0] as Prisma.Sql;
|
|
}
|
|
|
|
describe('voice claims (SQL shape)', () => {
|
|
it('claims with SKIP LOCKED against the database clock', async () => {
|
|
const { db, queryRaw } = fakeDb();
|
|
await claimVoiceCaptures(db, 'transcribe', { limit: 5, maxTotalSec: 600 });
|
|
const text = sqlOf(queryRaw.mock.calls[0]).sql;
|
|
expect(text).toContain('FOR UPDATE SKIP LOCKED');
|
|
expect(text).toContain('now()');
|
|
expect(text).toContain('gen_random_uuid()');
|
|
});
|
|
|
|
it.each([0, -1, 1.5])('refuses the claim limit %p', async (limit) => {
|
|
const { db } = fakeDb();
|
|
await expect(claimVoiceCaptures(db, 'extract', { limit })).rejects.toThrow(
|
|
RangeError,
|
|
);
|
|
});
|
|
|
|
it('refuses an unknown phase', async () => {
|
|
const { db } = fakeDb();
|
|
await expect(
|
|
claimVoiceCaptures(db, 'drop' as never, { limit: 1 }),
|
|
).rejects.toThrow(/Unknown voice phase/);
|
|
});
|
|
|
|
it('settles by compare-and-set on status and claim token', async () => {
|
|
const { db, executeRaw } = fakeDb();
|
|
await expect(settleVoiceSuccess(db, 'transcribe', claim)).resolves.toBe(
|
|
true,
|
|
);
|
|
const { sql, values } = sqlOf(executeRaw.mock.calls[0]);
|
|
expect(sql).toContain('"claimToken"');
|
|
expect(values).toEqual(
|
|
expect.arrayContaining(['c1', 't1', 'TRANSCRIBING']),
|
|
);
|
|
});
|
|
|
|
it('reports a lost claim when no row changed', async () => {
|
|
const { db, executeRaw } = fakeDb();
|
|
executeRaw.mockResolvedValue(0);
|
|
await expect(settleVoiceSuccess(db, 'extract', claim)).resolves.toBe(false);
|
|
await expect(deferVoiceClaim(db, 'extract', claim, 1000)).resolves.toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it('decides retry or failed from the attempts before', async () => {
|
|
const { db } = fakeDb();
|
|
await expect(
|
|
settleVoiceFailure(
|
|
db,
|
|
'transcribe',
|
|
{ ...claim, attempts: 1 },
|
|
'TRANSCRIBE_FAILED',
|
|
),
|
|
).resolves.toBe('retry');
|
|
await expect(
|
|
settleVoiceFailure(
|
|
db,
|
|
'transcribe',
|
|
{ ...claim, attempts: 2 },
|
|
'TRANSCRIBE_FAILED',
|
|
),
|
|
).resolves.toBe('failed');
|
|
});
|
|
|
|
it('reports a lost failure settle', async () => {
|
|
const { db, executeRaw } = fakeDb();
|
|
executeRaw.mockResolvedValue(0);
|
|
await expect(
|
|
settleVoiceFailure(db, 'extract', claim, 'EXTRACT_RETRY'),
|
|
).resolves.toBe('lost');
|
|
});
|
|
|
|
it('turns the last EXTRACT_RETRY into EXTRACT_FAILED', async () => {
|
|
const { db, executeRaw } = fakeDb();
|
|
await settleVoiceFailure(
|
|
db,
|
|
'extract',
|
|
{ ...claim, attempts: 2 },
|
|
'EXTRACT_RETRY',
|
|
);
|
|
const { values } = sqlOf(executeRaw.mock.calls[0]);
|
|
expect(values).toContain('EXTRACT_FAILED');
|
|
expect(values).not.toContain('EXTRACT_RETRY');
|
|
});
|
|
|
|
it.each([-1, 1.5])('refuses the defer delay %p', async (delay) => {
|
|
const { db } = fakeDb();
|
|
await expect(deferVoiceClaim(db, 'extract', claim, delay)).rejects.toThrow(
|
|
RangeError,
|
|
);
|
|
});
|
|
|
|
it('releases stale claims with the phase stale window and attempt limit', async () => {
|
|
const { db, queryRaw } = fakeDb();
|
|
await releaseStaleVoiceClaims(db, 'extract');
|
|
const { values } = sqlOf(queryRaw.mock.calls[0]);
|
|
expect(values).toContain(300_000);
|
|
expect(values).toContain(3);
|
|
});
|
|
});
|