/** 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 {
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.