feat(core): voice record state machine with backoff
Refs #1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6b2466cf05
commit
a4163fa5fe
87
src/core/record.ts
Normal file
87
src/core/record.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
// Персистентные состояния. created и transcribed живут только в памяти и сюда не входят.
|
||||||
|
export type Status = 'saved' | 'sent' | 'retry' | 'failed' | 'inserted';
|
||||||
|
export const STATUSES: readonly Status[] = ['saved', 'sent', 'retry', 'failed', 'inserted'];
|
||||||
|
|
||||||
|
export const MAX_ATTEMPTS = 5;
|
||||||
|
// Пауза перед повтором после 1-й, 2-й, 3-й и 4-й временной неудачи.
|
||||||
|
export const BACKOFF_MS: readonly number[] = [30_000, 120_000, 600_000, 1_800_000];
|
||||||
|
// sent старше этого считается прерванной отправкой.
|
||||||
|
export const SENT_STALE_MS = 600_000;
|
||||||
|
// Код причины в voice-error для пустого транскрипта.
|
||||||
|
export const NO_SPEECH = 'no-speech';
|
||||||
|
|
||||||
|
export interface VoiceRecord {
|
||||||
|
id: string;
|
||||||
|
status: Status;
|
||||||
|
device: string;
|
||||||
|
audio: string; // путь в vault, без [[ ]]
|
||||||
|
mime: string;
|
||||||
|
attempts: number;
|
||||||
|
nextAttempt?: string; // ISO UTC
|
||||||
|
sentAt?: string; // ISO UTC
|
||||||
|
error?: string;
|
||||||
|
interrupted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RecordEvent =
|
||||||
|
| { type: 'send' }
|
||||||
|
| { type: 'success' }
|
||||||
|
| { type: 'temporary'; message: string }
|
||||||
|
| { type: 'permanent'; message: string }
|
||||||
|
| { type: 'retry-now'; device: string }
|
||||||
|
| { type: 'claim'; device: string };
|
||||||
|
|
||||||
|
export class InvalidTransition extends Error {
|
||||||
|
constructor(from: Status, event: RecordEvent['type']) {
|
||||||
|
super(`invalid transition: ${event} from ${from}`);
|
||||||
|
this.name = 'InvalidTransition';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Transient = 'nextAttempt' | 'sentAt' | 'error';
|
||||||
|
|
||||||
|
function without(record: VoiceRecord, ...keys: Transient[]): VoiceRecord {
|
||||||
|
const copy = { ...record };
|
||||||
|
for (const key of keys) delete copy[key];
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transition(record: VoiceRecord, event: RecordEvent, now: Date): VoiceRecord {
|
||||||
|
const from = record.status;
|
||||||
|
switch (event.type) {
|
||||||
|
case 'send':
|
||||||
|
if (from !== 'saved' && from !== 'retry') break;
|
||||||
|
return { ...without(record, 'nextAttempt'), status: 'sent', sentAt: now.toISOString() };
|
||||||
|
case 'success':
|
||||||
|
if (from !== 'sent') break;
|
||||||
|
return { ...without(record, 'nextAttempt', 'sentAt', 'error'), status: 'inserted' };
|
||||||
|
case 'temporary': {
|
||||||
|
if (from !== 'sent') break;
|
||||||
|
const attempts = record.attempts + 1;
|
||||||
|
const next = { ...without(record, 'sentAt', 'nextAttempt'), attempts, error: event.message };
|
||||||
|
if (attempts >= MAX_ATTEMPTS) return { ...next, status: 'failed' };
|
||||||
|
const at = new Date(now.getTime() + BACKOFF_MS[attempts - 1]).toISOString();
|
||||||
|
return { ...next, status: 'retry', nextAttempt: at };
|
||||||
|
}
|
||||||
|
case 'permanent':
|
||||||
|
if (from !== 'sent') break;
|
||||||
|
return {
|
||||||
|
...without(record, 'sentAt', 'nextAttempt'),
|
||||||
|
status: 'failed',
|
||||||
|
attempts: record.attempts + 1,
|
||||||
|
error: event.message,
|
||||||
|
};
|
||||||
|
case 'retry-now':
|
||||||
|
if (from !== 'failed' && from !== 'retry') break;
|
||||||
|
return {
|
||||||
|
...without(record, 'nextAttempt', 'sentAt', 'error'),
|
||||||
|
status: 'saved',
|
||||||
|
attempts: 0,
|
||||||
|
device: event.device,
|
||||||
|
};
|
||||||
|
case 'claim':
|
||||||
|
if (from !== 'saved' && from !== 'retry' && from !== 'sent') break;
|
||||||
|
return { ...without(record, 'nextAttempt', 'sentAt'), status: 'saved', device: event.device };
|
||||||
|
}
|
||||||
|
throw new InvalidTransition(from, event.type);
|
||||||
|
}
|
||||||
119
tests/record.test.ts
Normal file
119
tests/record.test.ts
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
BACKOFF_MS,
|
||||||
|
InvalidTransition,
|
||||||
|
MAX_ATTEMPTS,
|
||||||
|
transition,
|
||||||
|
type RecordEvent,
|
||||||
|
type Status,
|
||||||
|
type VoiceRecord,
|
||||||
|
} from '../src/core/record';
|
||||||
|
|
||||||
|
const NOW = new Date('2026-09-27T11:15:03.000Z');
|
||||||
|
const base: VoiceRecord = Object.freeze({
|
||||||
|
id: '20260927-111503-0000',
|
||||||
|
status: 'saved',
|
||||||
|
device: 'd1',
|
||||||
|
audio: 'voice/source/20260927-111503-0000.m4a',
|
||||||
|
mime: 'audio/mp4',
|
||||||
|
attempts: 0,
|
||||||
|
});
|
||||||
|
const sent = (over: Partial<VoiceRecord> = {}): VoiceRecord =>
|
||||||
|
Object.freeze({ ...base, status: 'sent' as const, sentAt: NOW.toISOString(), ...over });
|
||||||
|
|
||||||
|
describe('transition', () => {
|
||||||
|
it('send: saved → sent with sentAt', () => {
|
||||||
|
expect(transition(base, { type: 'send' }, NOW)).toEqual({
|
||||||
|
...base,
|
||||||
|
status: 'sent',
|
||||||
|
sentAt: '2026-09-27T11:15:03.000Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('send: retry → sent, drops nextAttempt, keeps attempts and error', () => {
|
||||||
|
const r = Object.freeze({ ...base, status: 'retry' as const, attempts: 1, nextAttempt: 'x', error: 'timeout' });
|
||||||
|
expect(transition(r, { type: 'send' }, NOW)).toEqual({
|
||||||
|
...base,
|
||||||
|
status: 'sent',
|
||||||
|
attempts: 1,
|
||||||
|
error: 'timeout',
|
||||||
|
sentAt: NOW.toISOString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('success: sent → inserted, clears transient keys, keeps attempts', () => {
|
||||||
|
expect(transition(sent({ error: 'old', attempts: 2 }), { type: 'success' }, NOW)).toEqual({
|
||||||
|
...base,
|
||||||
|
status: 'inserted',
|
||||||
|
attempts: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('temporary: backoff 30s, 2m, 10m, 30m after failures 1..4', () => {
|
||||||
|
const got = [0, 1, 2, 3].map((a) =>
|
||||||
|
transition(sent({ attempts: a }), { type: 'temporary', message: 'timeout' }, NOW),
|
||||||
|
);
|
||||||
|
expect(got.map((r) => r.status)).toEqual(['retry', 'retry', 'retry', 'retry']);
|
||||||
|
expect(got.map((r) => r.attempts)).toEqual([1, 2, 3, 4]);
|
||||||
|
expect(got.map((r) => Date.parse(r.nextAttempt!) - NOW.getTime())).toEqual([...BACKOFF_MS]);
|
||||||
|
expect(got.every((r) => r.sentAt === undefined && r.error === 'timeout')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('temporary: fifth failure → failed with last message', () => {
|
||||||
|
expect(
|
||||||
|
transition(sent({ attempts: MAX_ATTEMPTS - 1 }), { type: 'temporary', message: 'e5' }, NOW),
|
||||||
|
).toEqual({ ...base, status: 'failed', attempts: 5, error: 'e5' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('permanent: sent → failed immediately', () => {
|
||||||
|
expect(transition(sent(), { type: 'permanent', message: '401 Unauthorized' }, NOW)).toEqual({
|
||||||
|
...base,
|
||||||
|
status: 'failed',
|
||||||
|
attempts: 1,
|
||||||
|
error: '401 Unauthorized',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retry-now: failed → saved, attempts 0, device reassigned', () => {
|
||||||
|
const r = Object.freeze({ ...base, status: 'failed' as const, attempts: 5, error: '401' });
|
||||||
|
expect(transition(r, { type: 'retry-now', device: 'd2' }, NOW)).toEqual({ ...base, device: 'd2' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retry-now: retry → saved, drops nextAttempt', () => {
|
||||||
|
const r = Object.freeze({ ...base, status: 'retry' as const, attempts: 2, nextAttempt: 'x', error: 't' });
|
||||||
|
expect(transition(r, { type: 'retry-now', device: 'd1' }, NOW)).toEqual(base);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('claim: retry → saved, new device, attempts kept', () => {
|
||||||
|
const r = Object.freeze({ ...base, status: 'retry' as const, attempts: 2, nextAttempt: 'x', error: 't' });
|
||||||
|
expect(transition(r, { type: 'claim', device: 'd2' }, NOW)).toEqual({
|
||||||
|
...base,
|
||||||
|
device: 'd2',
|
||||||
|
attempts: 2,
|
||||||
|
error: 't',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('claim: sent → saved, drops sentAt', () => {
|
||||||
|
expect(transition(sent(), { type: 'claim', device: 'd2' }, NOW)).toEqual({ ...base, device: 'd2' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalid: [Status, RecordEvent][] = [
|
||||||
|
['inserted', { type: 'send' }],
|
||||||
|
['failed', { type: 'send' }],
|
||||||
|
['sent', { type: 'send' }],
|
||||||
|
['saved', { type: 'success' }],
|
||||||
|
['retry', { type: 'success' }],
|
||||||
|
['saved', { type: 'temporary', message: 'x' }],
|
||||||
|
['inserted', { type: 'permanent', message: 'x' }],
|
||||||
|
['saved', { type: 'retry-now', device: 'd1' }],
|
||||||
|
['inserted', { type: 'retry-now', device: 'd1' }],
|
||||||
|
['failed', { type: 'claim', device: 'd2' }],
|
||||||
|
['inserted', { type: 'claim', device: 'd2' }],
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each(invalid)('rejects %s + %o', (status, event) => {
|
||||||
|
const r = Object.freeze({ ...base, status });
|
||||||
|
expect(() => transition(r, event, NOW)).toThrow(InvalidTransition);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user