You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1514 lines
29 KiB
1514 lines
29 KiB
---
|
|
title: arts/timer — guía de diseño e implementación (histórico)
|
|
type: decision-log
|
|
audience: human + agent
|
|
authority: historical design record — the deterministic timer scheduler (clock injection, race-safety §12.8, one-shots, intervals, backoff); superseded by src/arts/timer/README.md as the current reference. Kept in Spanish.
|
|
status: historical
|
|
source: moved from src/arts/timer/DESIGN_TIMR.md (2026-07-03, arts-docs-reconciliation B2; kept verbatim in Spanish)
|
|
---
|
|
|
|
# `arts/timer` — Guía de diseño e implementación
|
|
|
|
> **Nota historica 2026-05-14.** Este documento conserva el vocabulario de la
|
|
> fase de diseno (`timr`, `sess`, `conn`, `aapp`). El nombre canonico actual
|
|
> del artefacto es `timer`; los consumidores usan `$timer` → `src/arts/timer`.
|
|
> Las menciones abreviadas siguientes son contexto historico, no doctrina de
|
|
> naming nueva.
|
|
>
|
|
> **Propósito.** Implementar una infraestructura zero-deps de timers para la App, reutilizable por `sess`, `conn`, futuro `cache` y futuro `authn`.
|
|
>
|
|
> **Decisión central.** Debe existir un scheduler singleton **por instancia de App/runtime**, no un singleton global de módulo.
|
|
|
|
---
|
|
|
|
## 0. Decisiones cerradas (preceden al resto del documento)
|
|
|
|
Las siguientes reglas son normativas y tienen precedencia sobre cualquier
|
|
sugerencia en el resto del documento que pudiera quedar ambigua:
|
|
|
|
```txt
|
|
0.1 Artifact path final actual: src/arts/timer/ (historico: src/arts/timr/).
|
|
0.2 AbortController es por entry, no por tick.
|
|
0.3 Intervalos reutilizan el signal de la entry; el signal NO se renueva
|
|
entre ticks. Cancelar/reemplazar/dispose aborta para siempre.
|
|
0.4 replace:true cancela/aborta la entry anterior y crea una entry nueva
|
|
con un AbortController nuevo (id distinto).
|
|
0.5 scheduleAt(dueAt) con dueAt < now() programa con delayMs = 0 y emite
|
|
un debug log opcional. NO lanza error.
|
|
0.6 reschedule(delayMs) mantiene la misma entry e incrementa version;
|
|
el AbortController se preserva.
|
|
0.7 cancel / replace / dispose / interval-next-tick invalidan callbacks
|
|
viejos via la tupla guard (id, key, version). Ver §12.8.
|
|
```
|
|
|
|
---
|
|
|
|
## 1. Problema
|
|
|
|
Varios artifacts necesitan timers:
|
|
|
|
```txt
|
|
sess:
|
|
auto-refresh
|
|
refresh margin
|
|
transient refresh retry
|
|
visibility recovery
|
|
|
|
conn:
|
|
reconnect backoff
|
|
heartbeat ping/pong
|
|
request/ack timeout
|
|
buffer flush
|
|
channel rejoin delay
|
|
|
|
future cache:
|
|
staleTime
|
|
gcTime
|
|
refetch interval
|
|
|
|
future authn:
|
|
OTP expiry
|
|
cooldowns
|
|
step-up timeout
|
|
```
|
|
|
|
Si cada artifact usa directamente `setTimeout`, `setInterval` y `clearTimeout`, acabaremos con:
|
|
|
|
```txt
|
|
lógica duplicada
|
|
leaks difíciles de rastrear
|
|
tests lentos o frágiles
|
|
código no determinista
|
|
limpieza incompleta en dispose()
|
|
```
|
|
|
|
`arts/timr` centraliza:
|
|
|
|
```txt
|
|
schedule
|
|
interval
|
|
cancel
|
|
cancelAll
|
|
dispose
|
|
scoped keys
|
|
fake-clock testing
|
|
runtime observability
|
|
```
|
|
|
|
---
|
|
|
|
## 2. Principios de diseño
|
|
|
|
### 2.1 Singleton por App, no global
|
|
|
|
Correcto:
|
|
|
|
```ts
|
|
const App = createActiveApp({
|
|
timers: {}
|
|
});
|
|
|
|
App.timers.schedule(...);
|
|
```
|
|
|
|
Incorrecto:
|
|
|
|
```ts
|
|
export const Timers = createActiveTimers();
|
|
```
|
|
|
|
Por qué evitar singleton global:
|
|
|
|
```txt
|
|
SSR isolation
|
|
test isolation
|
|
HMR cleanup
|
|
multi-App support
|
|
controlled dispose
|
|
no cross-user contamination
|
|
```
|
|
|
|
### 2.2 Engine primero, Active después
|
|
|
|
```txt
|
|
EngineTimers
|
|
TypeScript puro
|
|
sin runes
|
|
testeable
|
|
seguro para importar server-side
|
|
|
|
ActiveTimers
|
|
wrapper reactivo Svelte
|
|
usa $state
|
|
expone entries/keys/scopes para debug/UI
|
|
```
|
|
|
|
### 2.3 Otros artifacts dependen de una interfaz mínima
|
|
|
|
`sess`, `conn`, `cache`, `authn` no deberían depender de `ActiveTimers`.
|
|
|
|
Dependen de:
|
|
|
|
```ts
|
|
TimerScheduler;
|
|
```
|
|
|
|
Así pueden recibir:
|
|
|
|
```txt
|
|
EngineTimers
|
|
ActiveTimers
|
|
FakeTimers en tests
|
|
```
|
|
|
|
### 2.4 Timers con key
|
|
|
|
Cada timer debe tener una key estable.
|
|
|
|
Ejemplos:
|
|
|
|
```txt
|
|
sess:auto-refresh
|
|
conn:main:reconnect
|
|
conn:main:heartbeat
|
|
conn:main:ack:msg_123
|
|
cache:products:gc
|
|
authn:otp:cooldown
|
|
```
|
|
|
|
Esto permite:
|
|
|
|
```ts
|
|
timers.cancel('conn:main:reconnect');
|
|
timers.cancelAll('conn:main');
|
|
```
|
|
|
|
### 2.5 No cron, no jobs durables
|
|
|
|
`arts/timr` es runtime-local. No garantiza ejecución si el navegador se cierra o si el proceso muere.
|
|
|
|
No implementa:
|
|
|
|
```txt
|
|
cron
|
|
background jobs persistentes
|
|
server queue
|
|
cross-tab scheduling
|
|
ejecución offline
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Nombre y estructura
|
|
|
|
Artifact recomendado:
|
|
|
|
```txt
|
|
src/arts/timer/
|
|
```
|
|
|
|
Nombres públicos:
|
|
|
|
```ts
|
|
EngineTimers;
|
|
ActiveTimers;
|
|
TimerScheduler;
|
|
TimerHandle;
|
|
TimerEntrySnapshot;
|
|
createEngineTimers;
|
|
createActiveTimers;
|
|
```
|
|
|
|
Evitar:
|
|
|
|
```txt
|
|
TimerManager
|
|
TimerService
|
|
SchedulerService
|
|
```
|
|
|
|
Estructura objetivo:
|
|
|
|
```txt
|
|
src/arts/timer/
|
|
index.ts
|
|
consts.ts
|
|
types.ts
|
|
errors.ts
|
|
clock.ts
|
|
backoff.ts
|
|
engine-timers.ts
|
|
active-timers.svelte.ts
|
|
engine-timers.test.ts
|
|
active-timers.test.ts
|
|
backoff.test.ts
|
|
```
|
|
|
|
---
|
|
|
|
## 4. Alcance obligatorio v1
|
|
|
|
La primera implementación debe incluir:
|
|
|
|
```txt
|
|
EngineTimers
|
|
ActiveTimers
|
|
TimerScheduler interface
|
|
keyed one-shot timers
|
|
keyed intervals
|
|
cancel by key
|
|
cancel by scope/prefix
|
|
dispose
|
|
entry snapshots
|
|
fake clock injection
|
|
backoff helper
|
|
tests
|
|
App integration
|
|
```
|
|
|
|
No incluir en v1:
|
|
|
|
```txt
|
|
cron
|
|
persistencia
|
|
cross-tab scheduling
|
|
background jobs
|
|
colas durables
|
|
```
|
|
|
|
---
|
|
|
|
## 5. Constantes públicas
|
|
|
|
```ts
|
|
export const TIMER_STATUS_PENDING = 'pending';
|
|
export const TIMER_STATUS_RUNNING = 'running';
|
|
export const TIMER_STATUS_CANCELLED = 'cancelled';
|
|
export const TIMER_STATUS_COMPLETED = 'completed';
|
|
export const TIMER_STATUS_FAILED = 'failed';
|
|
|
|
export const TIMER_KIND_TIMEOUT = 'timeout';
|
|
export const TIMER_KIND_INTERVAL = 'interval';
|
|
|
|
export const DEFAULT_TIMER_SCOPE_SEPARATOR = ':';
|
|
|
|
export const DEFAULT_BACKOFF_MIN_DELAY_MS = 500;
|
|
export const DEFAULT_BACKOFF_MAX_DELAY_MS = 15_000;
|
|
export const DEFAULT_BACKOFF_FACTOR = 1.8;
|
|
export const DEFAULT_BACKOFF_JITTER_MS = 500;
|
|
```
|
|
|
|
---
|
|
|
|
## 6. Tipos base
|
|
|
|
### 6.1 Estado y tipo de timer
|
|
|
|
```ts
|
|
export type TimerStatus = 'pending' | 'running' | 'cancelled' | 'completed' | 'failed';
|
|
|
|
export type TimerKind = 'timeout' | 'interval';
|
|
```
|
|
|
|
### 6.2 Task
|
|
|
|
```ts
|
|
export type TimerTask = (ctx: TimerTaskContext) => void | Promise<void>;
|
|
|
|
export interface TimerTaskContext {
|
|
readonly key: string;
|
|
readonly kind: TimerKind;
|
|
|
|
readonly scheduledAt: number;
|
|
readonly dueAt: number;
|
|
readonly firedAt: number;
|
|
readonly driftMs: number;
|
|
|
|
readonly runCount: number;
|
|
|
|
readonly signal: AbortSignal;
|
|
}
|
|
```
|
|
|
|
Notas:
|
|
|
|
```txt
|
|
driftMs = firedAt - dueAt
|
|
runCount empieza en 1
|
|
signal se aborta al cancelar/dispose
|
|
```
|
|
|
|
### 6.3 Opciones
|
|
|
|
```ts
|
|
export interface TimerOptions {
|
|
/** Reemplaza un timer existente con la misma key. */
|
|
readonly replace?: boolean;
|
|
|
|
/** Default true para timeout. */
|
|
readonly removeOnComplete?: boolean;
|
|
|
|
/** Debug/devtools only. */
|
|
readonly meta?: Readonly<Record<string, unknown>>;
|
|
}
|
|
|
|
export interface TimerIntervalOptions extends TimerOptions {
|
|
/**
|
|
* Si true, la siguiente iteración se agenda después de terminar la task.
|
|
* Default true.
|
|
*/
|
|
readonly awaitTask?: boolean;
|
|
|
|
/** Undefined = infinito hasta cancelación. */
|
|
readonly maxRuns?: number;
|
|
}
|
|
```
|
|
|
|
### 6.4 Handle
|
|
|
|
```ts
|
|
export interface TimerHandle {
|
|
readonly key: string;
|
|
readonly active: boolean;
|
|
|
|
cancel(): boolean;
|
|
|
|
reschedule(delayMs: number): void;
|
|
}
|
|
```
|
|
|
|
Reglas:
|
|
|
|
```txt
|
|
active = false después de cancel/complete/dispose
|
|
reschedule() solo funciona si active
|
|
reschedule() en timer inactivo lanza TimerInactiveError
|
|
```
|
|
|
|
### 6.5 Snapshot
|
|
|
|
```ts
|
|
export interface TimerEntrySnapshot {
|
|
readonly key: string;
|
|
readonly scope: string;
|
|
readonly kind: TimerKind;
|
|
readonly status: TimerStatus;
|
|
|
|
readonly scheduledAt: number;
|
|
readonly dueAt: number;
|
|
readonly delayMs: number;
|
|
|
|
readonly runCount: number;
|
|
readonly lastFiredAt: number | null;
|
|
readonly lastCompletedAt: number | null;
|
|
readonly lastErrorAt: number | null;
|
|
|
|
readonly error: unknown | null;
|
|
|
|
readonly meta?: Readonly<Record<string, unknown>>;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 7. Clock abstraction
|
|
|
|
Para testabilidad, no acoplar directamente a `globalThis.setTimeout`.
|
|
|
|
```ts
|
|
export interface TimerClock {
|
|
now(): number;
|
|
|
|
setTimeout(fn: () => void, delayMs: number): TimerNativeHandle;
|
|
|
|
clearTimeout(handle: TimerNativeHandle): void;
|
|
}
|
|
|
|
export type TimerNativeHandle = ReturnType<typeof globalThis.setTimeout>;
|
|
```
|
|
|
|
Default:
|
|
|
|
```ts
|
|
export function createSystemTimerClock(): TimerClock;
|
|
```
|
|
|
|
Tests pueden inyectar fake clock:
|
|
|
|
```ts
|
|
const timers = createEngineTimers({ clock: fakeClock });
|
|
```
|
|
|
|
---
|
|
|
|
## 8. Interfaces principales
|
|
|
|
### 8.1 TimerScheduler
|
|
|
|
Interfaz mínima que consumirán otros artifacts.
|
|
|
|
```ts
|
|
export interface TimerScheduler {
|
|
readonly size: number;
|
|
|
|
schedule(key: string, delayMs: number, task: TimerTask, options?: TimerOptions): TimerHandle;
|
|
|
|
scheduleAt(key: string, dueAt: number, task: TimerTask, options?: TimerOptions): TimerHandle;
|
|
|
|
interval(
|
|
key: string,
|
|
everyMs: number,
|
|
task: TimerTask,
|
|
options?: TimerIntervalOptions
|
|
): TimerHandle;
|
|
|
|
cancel(key: string): boolean;
|
|
|
|
cancelAll(scope?: string): number;
|
|
|
|
has(key: string): boolean;
|
|
|
|
dispose(): void;
|
|
}
|
|
```
|
|
|
|
### 8.2 EngineTimers
|
|
|
|
```ts
|
|
export interface EngineTimers extends TimerScheduler {
|
|
readonly disposed: boolean;
|
|
|
|
keys(): readonly string[];
|
|
|
|
entries(): readonly TimerEntrySnapshot[];
|
|
|
|
entry(key: string): TimerEntrySnapshot | null;
|
|
|
|
onChange(listener: (event: TimerEvent) => void): () => void;
|
|
}
|
|
```
|
|
|
|
### 8.3 ActiveTimers
|
|
|
|
```ts
|
|
export interface ActiveTimers extends EngineTimers {
|
|
readonly size: number;
|
|
readonly keysSnapshot: readonly string[];
|
|
readonly entriesSnapshot: readonly TimerEntrySnapshot[];
|
|
readonly scopes: readonly string[];
|
|
}
|
|
```
|
|
|
|
`ActiveTimers` sirve para observabilidad/debug/UI. Los artifacts pueden recibirlo como `TimerScheduler`.
|
|
|
|
---
|
|
|
|
## 9. Eventos
|
|
|
|
```ts
|
|
export type TimerEvent =
|
|
| {
|
|
readonly type: 'scheduled';
|
|
readonly entry: TimerEntrySnapshot;
|
|
}
|
|
| {
|
|
readonly type: 'cancelled';
|
|
readonly entry: TimerEntrySnapshot;
|
|
}
|
|
| {
|
|
readonly type: 'running';
|
|
readonly entry: TimerEntrySnapshot;
|
|
}
|
|
| {
|
|
readonly type: 'completed';
|
|
readonly entry: TimerEntrySnapshot;
|
|
}
|
|
| {
|
|
readonly type: 'failed';
|
|
readonly entry: TimerEntrySnapshot;
|
|
readonly error: unknown;
|
|
}
|
|
| {
|
|
readonly type: 'disposed';
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## 10. Errores
|
|
|
|
Errores custom para errores de programación:
|
|
|
|
```ts
|
|
export class TimrError extends Error {}
|
|
|
|
export class TimerDisposedError extends TimrError {}
|
|
export class TimerInvalidKeyError extends TimrError {}
|
|
export class TimerDuplicateKeyError extends TimrError {}
|
|
export class TimerInvalidDelayError extends TimrError {}
|
|
export class TimerInactiveError extends TimrError {}
|
|
```
|
|
|
|
Fallos de tasks en runtime se reportan con eventos `failed`, no necesariamente como throws hacia fuera.
|
|
|
|
---
|
|
|
|
## 11. Keys y scopes
|
|
|
|
Una key es string no vacío.
|
|
|
|
Ejemplos válidos:
|
|
|
|
```txt
|
|
sess:auto-refresh
|
|
conn:main:heartbeat
|
|
conn:main:ack:abc
|
|
cache:products:gc
|
|
```
|
|
|
|
Scope por defecto:
|
|
|
|
```ts
|
|
scopeOf('conn:main:ack:abc') === 'conn:main:ack';
|
|
scopeOf('conn:main') === 'conn';
|
|
scopeOf('sess:auto-refresh') === 'sess';
|
|
```
|
|
|
|
`cancelAll(scope)` usa prefijo:
|
|
|
|
```ts
|
|
cancelAll('conn:main');
|
|
```
|
|
|
|
cancela:
|
|
|
|
```txt
|
|
conn:main:reconnect
|
|
conn:main:heartbeat
|
|
conn:main:ack:msg_1
|
|
```
|
|
|
|
pero no:
|
|
|
|
```txt
|
|
conn:market:heartbeat
|
|
```
|
|
|
|
Helper:
|
|
|
|
```ts
|
|
function isInScope(key: string, scope: string): boolean {
|
|
return key === scope || key.startsWith(`${scope}:`);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 12. Implementación interna
|
|
|
|
### 12.1 Entry interna
|
|
|
|
```ts
|
|
interface InternalTimerEntry {
|
|
/** Unique per entry. New replace creates a new id even if key is reused. */
|
|
id: number;
|
|
|
|
key: string;
|
|
|
|
/** Incremented whenever this entry future execution is invalidated. */
|
|
version: number;
|
|
kind: TimerKind;
|
|
status: TimerStatus;
|
|
|
|
task: TimerTask;
|
|
controller: AbortController;
|
|
|
|
native: TimerNativeHandle | null;
|
|
|
|
scheduledAt: number;
|
|
dueAt: number;
|
|
delayMs: number;
|
|
|
|
intervalMs: number | null;
|
|
awaitTask: boolean;
|
|
maxRuns: number | undefined;
|
|
|
|
runCount: number;
|
|
lastFiredAt: number | null;
|
|
lastCompletedAt: number | null;
|
|
lastErrorAt: number | null;
|
|
error: unknown | null;
|
|
|
|
removeOnComplete: boolean;
|
|
meta?: Readonly<Record<string, unknown>>;
|
|
}
|
|
```
|
|
|
|
### 12.2 Factory
|
|
|
|
```ts
|
|
export interface EngineTimersOptions {
|
|
readonly clock?: TimerClock;
|
|
readonly logger?: Logger;
|
|
}
|
|
|
|
export function createEngineTimers(options?: EngineTimersOptions): EngineTimers;
|
|
```
|
|
|
|
### 12.3 Algoritmo de `schedule`
|
|
|
|
1. Verificar que no está disposed.
|
|
2. Validar key.
|
|
3. Validar `delayMs`: finito y `>= 0`.
|
|
4. Si key existe:
|
|
- `replace: true` => cancelar anterior.
|
|
- si no, lanzar `TimerDuplicateKeyError`.
|
|
5. Crear entry.
|
|
6. Crear `AbortController`.
|
|
7. Crear native timeout.
|
|
8. Guardar entry en `Map`.
|
|
9. Emitir `scheduled`.
|
|
10. Devolver `TimerHandle`.
|
|
|
|
### 12.4 Ejecución de timeout
|
|
|
|
Cuando el timeout dispara:
|
|
|
|
1. Si la entry ya no existe o está cancelada, salir.
|
|
2. `status = 'running'`.
|
|
3. Set `lastFiredAt`.
|
|
4. Incrementar `runCount`.
|
|
5. Emitir `running`.
|
|
6. Construir `TimerTaskContext`.
|
|
7. Ejecutar task y esperar si devuelve promise.
|
|
8. Si ok:
|
|
- `status = 'completed'`
|
|
- set `lastCompletedAt`
|
|
- emitir `completed`
|
|
- eliminar si `removeOnComplete !== false`
|
|
9. Si error:
|
|
- `status = 'failed'`
|
|
- set `lastErrorAt`
|
|
- guardar error
|
|
- emitir `failed`
|
|
- eliminar si `removeOnComplete !== false`
|
|
|
|
No reintentar automáticamente.
|
|
|
|
### 12.5 Intervalos
|
|
|
|
Implementar intervalos con `setTimeout` recursivo, no con `setInterval` nativo.
|
|
|
|
Motivo:
|
|
|
|
```txt
|
|
evita solapamiento de ejecuciones async
|
|
permite awaitTask
|
|
facilita drift/runCount/maxRuns
|
|
```
|
|
|
|
Default:
|
|
|
|
```ts
|
|
awaitTask = true;
|
|
```
|
|
|
|
#### 12.5.1 Semántica de `awaitTask: false`
|
|
|
|
`awaitTask: false` produce una **cadencia fire-and-forget**:
|
|
|
|
```txt
|
|
- The next tick is scheduled BEFORE the current task settles.
|
|
- Task failures are emitted as `failed` events but do NOT stop the
|
|
interval. The next tick fires regardless.
|
|
- This mode is intended for fire-and-forget cadence (e.g. heartbeat
|
|
pings where each tick is independent and a missed reply doesn't
|
|
block the next ping).
|
|
- Use `awaitTask: true` when task completion or error should control
|
|
whether the next tick fires.
|
|
```
|
|
|
|
If a consumer needs the interval to stop on failure, they should:
|
|
|
|
```ts
|
|
Timers.interval(
|
|
'foo',
|
|
everyMs,
|
|
async (ctx) => {
|
|
try {
|
|
await doWork();
|
|
} catch (err) {
|
|
// explicit stop — awaitTask:false won't auto-stop on throw
|
|
ctx.signal.throwIfAborted();
|
|
throw err;
|
|
}
|
|
},
|
|
{ awaitTask: true }
|
|
);
|
|
```
|
|
|
|
…or cancel explicitly from inside the task:
|
|
|
|
```ts
|
|
Timers.interval('bar', everyMs, async () => {
|
|
const ok = await tryWork();
|
|
if (!ok) Timers.cancel('bar');
|
|
});
|
|
```
|
|
|
|
This semantic is **frozen** — see the dedicated test
|
|
`awaitTask:false continues ticking after task failure` in
|
|
`engine-timers.test.ts`.
|
|
|
|
### 12.6 Cancelación
|
|
|
|
`cancel(key)`:
|
|
|
|
1. Buscar entry.
|
|
2. Si no existe, devolver false.
|
|
3. Abort controller.
|
|
4. Clear native timeout.
|
|
5. Marcar `cancelled`.
|
|
6. Eliminar del map.
|
|
7. Emitir `cancelled`.
|
|
8. Devolver true.
|
|
|
|
`cancelAll(scope?)`:
|
|
|
|
- Sin scope: cancela todo.
|
|
- Con scope: cancela keys que matchean prefijo.
|
|
- Devuelve número de timers cancelados.
|
|
|
|
### 12.7 Dispose
|
|
|
|
`dispose()`:
|
|
|
|
1. Si ya está disposed, no-op.
|
|
2. Cancelar todos los timers.
|
|
3. Emitir `disposed`.
|
|
4. Limpiar listeners.
|
|
5. Marcar disposed.
|
|
|
|
Después de dispose:
|
|
|
|
```ts
|
|
schedule(...)
|
|
interval(...)
|
|
```
|
|
|
|
lanzan `TimerDisposedError`.
|
|
|
|
### 12.8 Seguridad de renovación y concurrencia
|
|
|
|
La implementación MUST ser segura cuando `cancel`, `replace`, `reschedule`, `dispose` o una ejecución async coinciden en el tiempo.
|
|
|
|
Aunque JavaScript ejecute en un único hilo, un callback nativo puede estar ya encolado en el event loop cuando se llama a `clearTimeout`. Además, una task async puede terminar después de que el timer haya sido reemplazado por otro con la misma key. Por tanto, `clearTimeout` y `AbortSignal` no son suficientes.
|
|
|
|
Regla normativa:
|
|
|
|
> Toda ejecución de timer está protegida por la tupla `(entryId, key, version)`.
|
|
|
|
Cada callback nativo captura esos tres valores cuando se programa. Antes de ejecutar la task, y también después de que la task termine, el engine debe comprobar que la entry registrada sigue siendo exactamente la misma.
|
|
|
|
#### 12.8.1 Identidad y versión
|
|
|
|
Cada entry interna MUST tener:
|
|
|
|
```ts
|
|
interface InternalTimerEntry {
|
|
/** Unique per entry. New `replace` creates a new id even if key is reused. */
|
|
id: number;
|
|
|
|
/** Stable user-facing key. */
|
|
key: string;
|
|
|
|
/** Incremented every time this entry's future execution is invalidated. */
|
|
version: number;
|
|
|
|
// ...rest of entry
|
|
}
|
|
```
|
|
|
|
Semántica:
|
|
|
|
- `id` identifica una entry concreta.
|
|
- `key` identifica el timer desde la API pública.
|
|
- `version` invalida callbacks viejos de la misma entry.
|
|
- `replace: true` crea una nueva entry con un nuevo `id`.
|
|
- `reschedule()` mantiene el mismo `id`, pero incrementa `version`.
|
|
- `cancel()` incrementa `version`, aborta la señal, limpia el native timeout y elimina la entry.
|
|
- `dispose()` invalida todas las entries.
|
|
|
|
#### 12.8.2 Programación segura
|
|
|
|
Al programar:
|
|
|
|
```ts
|
|
function armEntry(entry: InternalTimerEntry, delayMs: number): void {
|
|
entry.version += 1;
|
|
|
|
const capturedId = entry.id;
|
|
const capturedKey = entry.key;
|
|
const capturedVersion = entry.version;
|
|
|
|
entry.native = clock.setTimeout(() => {
|
|
void runEntry(capturedId, capturedKey, capturedVersion);
|
|
}, delayMs);
|
|
}
|
|
```
|
|
|
|
#### 12.8.3 Guard antes de ejecutar
|
|
|
|
```ts
|
|
function getLiveEntry(id: number, key: string, version: number): InternalTimerEntry | null {
|
|
const current = entries.get(key);
|
|
|
|
if (!current) return null;
|
|
if (current.id !== id) return null;
|
|
if (current.version !== version) return null;
|
|
if (current.controller.signal.aborted) return null;
|
|
|
|
return current;
|
|
}
|
|
```
|
|
|
|
`runEntry` debe empezar así:
|
|
|
|
```ts
|
|
async function runEntry(id: number, key: string, version: number): Promise<void> {
|
|
const entry = getLiveEntry(id, key, version);
|
|
|
|
if (!entry) return;
|
|
if (entry.status !== 'pending') return;
|
|
|
|
entry.status = 'running';
|
|
// ... ejecutar task
|
|
}
|
|
```
|
|
|
|
#### 12.8.4 Guard después de ejecutar
|
|
|
|
Después de `await task(ctx)`, el engine debe volver a comprobar la misma tupla antes de mutar estado, emitir `completed`/`failed`, eliminar la entry o programar el siguiente tick de un intervalo.
|
|
|
|
```ts
|
|
try {
|
|
await entry.task(ctx);
|
|
|
|
const latest = getLiveEntry(id, key, version);
|
|
if (!latest) return;
|
|
|
|
completeEntry(latest);
|
|
} catch (error) {
|
|
const latest = getLiveEntry(id, key, version);
|
|
if (!latest) return;
|
|
|
|
failEntry(latest, error);
|
|
}
|
|
```
|
|
|
|
Esto evita que una ejecución vieja pueda cerrar, fallar, eliminar o reprogramar una entry nueva con la misma key.
|
|
|
|
#### 12.8.5 `replace: true`
|
|
|
|
`replace: true` debe cancelar la entry previa y crear una nueva entry.
|
|
|
|
```ts
|
|
function replaceTimer(key: string, config: NewTimerConfig): TimerHandle {
|
|
const previous = entries.get(key);
|
|
|
|
if (previous) {
|
|
cancelEntry(previous, 'replaced');
|
|
}
|
|
|
|
return createEntry(key, config);
|
|
}
|
|
```
|
|
|
|
La nueva entry debe tener un `id` nuevo. Si un callback viejo se ejecuta tarde, `getLiveEntry(oldId, key, oldVersion)` devolverá `null`.
|
|
|
|
#### 12.8.6 `reschedule()`
|
|
|
|
`reschedule(delayMs)` mantiene la misma entry pero invalida cualquier callback pendiente incrementando `version`.
|
|
|
|
```ts
|
|
function rescheduleEntry(entry: InternalTimerEntry, delayMs: number): void {
|
|
if (entry.status === 'running') {
|
|
throw new TimerInactiveError(entry.key);
|
|
}
|
|
|
|
validateDelay(delayMs);
|
|
|
|
if (entry.native !== null) {
|
|
clock.clearTimeout(entry.native);
|
|
entry.native = null;
|
|
}
|
|
|
|
entry.status = 'pending';
|
|
entry.scheduledAt = clock.now();
|
|
entry.delayMs = delayMs;
|
|
entry.dueAt = entry.scheduledAt + delayMs;
|
|
|
|
armEntry(entry, delayMs);
|
|
emitScheduled(entry);
|
|
}
|
|
```
|
|
|
|
For v1, `reschedule()` MUST NOT be allowed while the entry is `running`. Use `schedule(key, delay, task, { replace: true })` if running work should be superseded.
|
|
|
|
#### 12.8.7 Intervalos seguros
|
|
|
|
Intervals MUST be implemented with recursive `setTimeout`, never native `setInterval`.
|
|
|
|
After each interval task completes:
|
|
|
|
1. Re-check `(id, key, version)`.
|
|
2. Check `signal.aborted`.
|
|
3. Check `maxRuns`.
|
|
4. Set status back to `pending`.
|
|
5. Increment `version` for the next native callback.
|
|
6. Arm the next timeout.
|
|
|
|
A cancelled or replaced interval must never schedule a new tick after its current task completes.
|
|
|
|
#### 12.8.8 AbortSignal
|
|
|
|
Every task receives `ctx.signal`. Cancelling/replacing/disposing aborts it.
|
|
|
|
```ts
|
|
Timers.schedule('example:fetch', 1_000, async ({ signal }) => {
|
|
await fetch('/api', { signal });
|
|
});
|
|
```
|
|
|
|
Important:
|
|
|
|
> `AbortSignal` is cooperative. It helps cancel work that supports aborting, but it does not replace the `(entryId, key, version)` guard.
|
|
|
|
#### 12.8.9 Operations that invalidate callbacks
|
|
|
|
The following operations MUST invalidate old callbacks:
|
|
|
|
```txt
|
|
cancel
|
|
replace
|
|
reschedule
|
|
dispose
|
|
interval next tick
|
|
```
|
|
|
|
Implementation rule:
|
|
|
|
```txt
|
|
Any operation that changes the future execution of an entry increments `version` or replaces the entry id.
|
|
```
|
|
|
|
#### 12.8.10 Race scenarios that must be safe
|
|
|
|
The implementation must remain correct when these happen in the same event-loop window:
|
|
|
|
```txt
|
|
timeout fires while cancel() is called
|
|
timeout fires while replace:true is called
|
|
timeout fires while reschedule() is called
|
|
async task completes after cancel()
|
|
async task completes after replace:true
|
|
interval task completes after dispose()
|
|
ack timeout fires after reply already arrived
|
|
reconnect timer fires after manual disconnect
|
|
```
|
|
|
|
In all cases, stale executions must become no-ops.
|
|
|
|
---
|
|
|
|
## 13. ActiveTimers
|
|
|
|
`active-timers.svelte.ts` envuelve `EngineTimers`.
|
|
|
|
Patrón recomendado:
|
|
|
|
```ts
|
|
export function createActiveTimers(options?: EngineTimersOptions): ActiveTimers {
|
|
const engine = createEngineTimers(options);
|
|
|
|
let entries = $state<readonly TimerEntrySnapshot[]>(engine.entries());
|
|
|
|
const off = engine.onChange(() => {
|
|
entries = engine.entries();
|
|
});
|
|
|
|
return {
|
|
...engine,
|
|
|
|
get size() {
|
|
return entries.length;
|
|
},
|
|
|
|
get keysSnapshot() {
|
|
return entries.map((entry) => entry.key);
|
|
},
|
|
|
|
get entriesSnapshot() {
|
|
return entries;
|
|
},
|
|
|
|
get scopes() {
|
|
return Array.from(new Set(entries.map((entry) => entry.scope)));
|
|
},
|
|
|
|
dispose() {
|
|
off();
|
|
engine.dispose();
|
|
}
|
|
};
|
|
}
|
|
```
|
|
|
|
Importante:
|
|
|
|
```txt
|
|
No usar $effect para sincronizar timers.
|
|
Usar engine.onChange.
|
|
Esto evita effect_update_depth_exceeded.
|
|
```
|
|
|
|
---
|
|
|
|
## 14. Backoff helper
|
|
|
|
```ts
|
|
export interface BackoffOptions {
|
|
readonly minDelayMs?: number;
|
|
readonly maxDelayMs?: number;
|
|
readonly factor?: number;
|
|
readonly jitterMs?: number;
|
|
}
|
|
|
|
export function computeBackoffDelay(
|
|
attempt: number,
|
|
options?: BackoffOptions,
|
|
random?: () => number
|
|
): number;
|
|
```
|
|
|
|
Reglas:
|
|
|
|
```txt
|
|
attempt empieza en 0
|
|
random inyectable para tests
|
|
clamp a maxDelayMs
|
|
nunca devolver negativo
|
|
```
|
|
|
|
Algoritmo:
|
|
|
|
```ts
|
|
const base = Math.min(maxDelayMs, minDelayMs * factor ** attempt);
|
|
const jitter = randomBetween(-jitterMs, jitterMs);
|
|
return clamp(0, maxDelayMs, base + jitter);
|
|
```
|
|
|
|
Este helper solo calcula. No agenda timers.
|
|
|
|
---
|
|
|
|
## 15. Integración con App
|
|
|
|
### 15.1 Opciones
|
|
|
|
```ts
|
|
export interface ActiveAppTimersOptions {
|
|
readonly enabled?: boolean;
|
|
}
|
|
```
|
|
|
|
En la práctica `App.timers` debería estar siempre presente.
|
|
|
|
```ts
|
|
const App = createActiveApp({
|
|
timers: {}
|
|
});
|
|
|
|
App.timers.schedule(...);
|
|
```
|
|
|
|
Si no se configuran timers:
|
|
|
|
```ts
|
|
App.timers = createActiveTimers();
|
|
```
|
|
|
|
### 15.2 Inyección a otros artifacts
|
|
|
|
Artifacts deben aceptar:
|
|
|
|
```ts
|
|
timers?: TimerScheduler;
|
|
```
|
|
|
|
Cuando se crean desde App:
|
|
|
|
```ts
|
|
timers: App.timers;
|
|
```
|
|
|
|
No deben importar un scheduler global.
|
|
|
|
---
|
|
|
|
## 16. Integración con `sess`
|
|
|
|
`withAutoRefresh` debería aceptar timers:
|
|
|
|
```ts
|
|
const stop = withAutoRefresh(Sess, {
|
|
timers: App.timers,
|
|
tickMs,
|
|
marginMs,
|
|
jitterMs
|
|
});
|
|
```
|
|
|
|
Keys sugeridas:
|
|
|
|
```txt
|
|
sess:auto-refresh
|
|
sess:refresh-retry
|
|
```
|
|
|
|
Cleanup:
|
|
|
|
```ts
|
|
timers.cancelAll('sess');
|
|
```
|
|
|
|
Si en el futuro hay varias sesiones:
|
|
|
|
```txt
|
|
sess:main:auto-refresh
|
|
```
|
|
|
|
---
|
|
|
|
## 17. Integración con `conn`
|
|
|
|
Keys sugeridas:
|
|
|
|
```txt
|
|
conn:{name}:reconnect
|
|
conn:{name}:heartbeat
|
|
conn:{name}:heartbeat-timeout
|
|
conn:{name}:ack:{messageId}
|
|
conn:{name}:channel:{channel}:rejoin
|
|
```
|
|
|
|
Reconnect:
|
|
|
|
```ts
|
|
timers.schedule(`conn:${name}:reconnect`, delay, () => {
|
|
return connection.reconnect('scheduled');
|
|
});
|
|
```
|
|
|
|
Heartbeat:
|
|
|
|
```ts
|
|
timers.interval(`conn:${name}:heartbeat`, intervalMs, () => {
|
|
return connection.send('connection.ping', {});
|
|
});
|
|
```
|
|
|
|
Ack timeout:
|
|
|
|
```ts
|
|
timers.schedule(`conn:${name}:ack:${id}`, timeoutMs, () => {
|
|
resolveAckTimeout(id);
|
|
});
|
|
```
|
|
|
|
Al recibir reply:
|
|
|
|
```ts
|
|
timers.cancel(`conn:${name}:ack:${id}`);
|
|
```
|
|
|
|
Al hacer dispose de la conexión:
|
|
|
|
```ts
|
|
timers.cancelAll(`conn:${name}`);
|
|
```
|
|
|
|
---
|
|
|
|
## 18. Integración futura con `cache`
|
|
|
|
Keys posibles:
|
|
|
|
```txt
|
|
cache:{queryKey}:stale
|
|
cache:{queryKey}:gc
|
|
cache:{queryKey}:refetch
|
|
```
|
|
|
|
`TimerQueue` permitirá `staleTime`, `gcTime`, `refetchInterval` sin duplicación.
|
|
|
|
---
|
|
|
|
## 19. Integración futura con `authn`
|
|
|
|
Keys posibles:
|
|
|
|
```txt
|
|
authn:otp:expiry
|
|
authn:otp:cooldown
|
|
authn:step-up:expiry
|
|
authn:login-attempt:cooldown
|
|
```
|
|
|
|
Esto es solo runtime/UI. Las decisiones de seguridad deben seguir viviendo en servidor.
|
|
|
|
---
|
|
|
|
## 20. SSR
|
|
|
|
`EngineTimers` debe ser seguro de importar server-side.
|
|
|
|
Reglas:
|
|
|
|
```txt
|
|
crear EngineTimers es seguro
|
|
agendar timers durante SSR debe evitarse salvo intención explícita
|
|
App client runtime puede crear ActiveTimers
|
|
Apps request-scoped server deben hacer dispose si agendan algo
|
|
no hay singleton global de módulo
|
|
```
|
|
|
|
---
|
|
|
|
## 21. Testing
|
|
|
|
### 21.1 Fake clock
|
|
|
|
Los tests no deben esperar tiempo real.
|
|
|
|
Mínimo fake clock:
|
|
|
|
```ts
|
|
const clock = createFakeTimerClock();
|
|
|
|
clock.now();
|
|
clock.advanceBy(ms);
|
|
clock.advanceTo(ms);
|
|
clock.runAll();
|
|
```
|
|
|
|
Si no se implementa como API pública, puede vivir en tests.
|
|
|
|
### 21.2 Tests de EngineTimers
|
|
|
|
```txt
|
|
schedule ejecuta task
|
|
scheduleAt ejecuta en dueAt
|
|
duplicate key lanza por defecto
|
|
replace cancela anterior y agenda nuevo
|
|
cancel evita ejecución
|
|
cancel devuelve false en missing key
|
|
cancelAll cancela todo
|
|
cancelAll(scope) cancela solo scope
|
|
dispose cancela todo
|
|
schedule después de dispose lanza
|
|
task recibe context
|
|
driftMs se calcula
|
|
async task emite completed
|
|
task error emite failed
|
|
interval repite
|
|
interval maxRuns se detiene
|
|
interval cancel se detiene
|
|
handle.reschedule cambia dueAt
|
|
stale callback after reschedule is ignored
|
|
stale callback after replace:true is ignored
|
|
async task completion after cancel is ignored
|
|
async task completion after replace:true cannot mutate new entry
|
|
interval does not schedule next tick after cancel while running
|
|
ack-style timeout can be cancelled safely before firing
|
|
entries actualizan snapshots
|
|
onChange emite scheduled/running/completed/cancelled/failed
|
|
```
|
|
|
|
### 21.3 Tests de backoff
|
|
|
|
```txt
|
|
attempt 0 usa minDelay + jitter
|
|
crece por factor
|
|
clamp a maxDelay
|
|
random inyectado es determinista
|
|
jitter negativo no produce delay negativo
|
|
```
|
|
|
|
### 21.4 Tests de ActiveTimers
|
|
|
|
```txt
|
|
size reactivo tras schedule
|
|
size reactivo tras cancel
|
|
entriesSnapshot actualiza
|
|
keysSnapshot actualiza
|
|
dispose limpia listener
|
|
no usa $effect para sincronizar
|
|
```
|
|
|
|
---
|
|
|
|
## 22. Orden de implementación para Claude Code
|
|
|
|
1. `consts.ts`
|
|
2. `types.ts`
|
|
3. `errors.ts`
|
|
4. `clock.ts`
|
|
5. `backoff.ts`
|
|
6. `engine-timers.ts`
|
|
7. `engine-timers.test.ts`
|
|
8. `active-timers.svelte.ts`
|
|
9. `active-timers.test.ts`
|
|
10. integración con `aapp`
|
|
11. actualizar `sess` auto-refresh para aceptar `timers`
|
|
12. actualizar diseño de `conn` para requerir `timers`
|
|
13. documentación de ejemplos
|
|
|
|
No empezar integrando todos los artifacts. Primero hacer `arts/timr` sólido y testeado.
|
|
|
|
---
|
|
|
|
## 23. Ejemplos
|
|
|
|
### 23.1 Schedule básico
|
|
|
|
```ts
|
|
const Timers = createEngineTimers();
|
|
|
|
Timers.schedule('demo:hello', 1_000, () => {
|
|
console.log('hello');
|
|
});
|
|
```
|
|
|
|
### 23.2 Debounce por replace
|
|
|
|
```ts
|
|
Timers.schedule('search:debounce', 300, runSearch, {
|
|
replace: true
|
|
});
|
|
```
|
|
|
|
### 23.3 Intervalo
|
|
|
|
```ts
|
|
Timers.interval('conn:main:heartbeat', 25_000, () => {
|
|
return Main.send('connection.ping', {});
|
|
});
|
|
```
|
|
|
|
### 23.4 Limpieza por scope
|
|
|
|
```ts
|
|
Timers.cancelAll('conn:main');
|
|
```
|
|
|
|
### 23.5 App singleton
|
|
|
|
```ts
|
|
const App = createActiveApp({
|
|
timers: {}
|
|
});
|
|
|
|
App.timers.schedule('sess:auto-refresh', 30_000, () => {
|
|
return Sess.refresh();
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 24. Pitfalls
|
|
|
|
### 24.1 No usar `$effect` para conducir timers
|
|
|
|
Evitar:
|
|
|
|
```ts
|
|
$effect(() => {
|
|
if (Sess.current) {
|
|
timers.schedule(...);
|
|
}
|
|
});
|
|
```
|
|
|
|
Preferir:
|
|
|
|
```ts
|
|
Sess.onChange(() => {
|
|
timers.schedule(..., { replace: true });
|
|
});
|
|
```
|
|
|
|
### 24.2 No crear singleton global
|
|
|
|
Evitar:
|
|
|
|
```ts
|
|
export const Timers = createActiveTimers();
|
|
```
|
|
|
|
### 24.3 No dejar raw timers colgados
|
|
|
|
Cada artifact debe limpiar su scope:
|
|
|
|
```ts
|
|
timers.cancelAll(`conn:${name}`);
|
|
```
|
|
|
|
### 24.4 No prometer trabajo futuro
|
|
|
|
Los timers son scheduling local de runtime. No son workers persistentes.
|
|
|
|
---
|
|
|
|
## 25. Criterios de aceptación
|
|
|
|
La implementación es aceptable si:
|
|
|
|
```txt
|
|
EngineTimers pasa tests de timing/cancel/dispose
|
|
ActiveTimers expone entries reactivas sin loops
|
|
App expone una instancia Timers por instancia App
|
|
sess/conn reciben TimerScheduler sin importar ActiveTimers
|
|
no existe singleton global de módulo
|
|
no se usan raw setTimeout para lifecycle timers tras integración
|
|
las keys son scoped y se limpian en dispose
|
|
backoff es determinista en tests
|
|
```
|
|
|
|
---
|
|
|
|
## 26. Regla final de arquitectura
|
|
|
|
`arts/timr` es infraestructura compartida de scheduling runtime.
|
|
|
|
No sabe nada de sesiones, conexiones, cache, usuarios, auth, UI o dominio.
|
|
|
|
Los demás artifacts expresan sus necesidades temporales mediante keys scoped.
|
|
|
|
```txt
|
|
App.timers
|
|
├── sess:auto-refresh
|
|
├── conn:main:reconnect
|
|
├── conn:main:heartbeat
|
|
├── conn:main:ack:msg_123
|
|
├── cache:products:gc
|
|
└── authn:otp:cooldown
|
|
```
|
|
|
|
La regla:
|
|
|
|
> Un scheduler por App runtime.
|
|
> Engine API para artifacts.
|
|
> Active wrapper para observabilidad.
|
|
> Nunca singleton global.
|