29 KiB
| title | type | audience | authority | status | source |
|---|---|---|---|---|---|
| arts/timer — guía de diseño e implementación (histórico) | decision-log | human + agent | 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. | historical | 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 estimer; 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, futurocachey futuroauthn.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:
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:
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:
lógica duplicada
leaks difíciles de rastrear
tests lentos o frágiles
código no determinista
limpieza incompleta en dispose()
arts/timr centraliza:
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:
const App = createActiveApp({
timers: {}
});
App.timers.schedule(...);
Incorrecto:
export const Timers = createActiveTimers();
Por qué evitar singleton global:
SSR isolation
test isolation
HMR cleanup
multi-App support
controlled dispose
no cross-user contamination
2.2 Engine primero, Active después
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:
TimerScheduler;
Así pueden recibir:
EngineTimers
ActiveTimers
FakeTimers en tests
2.4 Timers con key
Cada timer debe tener una key estable.
Ejemplos:
sess:auto-refresh
conn:main:reconnect
conn:main:heartbeat
conn:main:ack:msg_123
cache:products:gc
authn:otp:cooldown
Esto permite:
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:
cron
background jobs persistentes
server queue
cross-tab scheduling
ejecución offline
3. Nombre y estructura
Artifact recomendado:
src/arts/timer/
Nombres públicos:
EngineTimers;
ActiveTimers;
TimerScheduler;
TimerHandle;
TimerEntrySnapshot;
createEngineTimers;
createActiveTimers;
Evitar:
TimerManager
TimerService
SchedulerService
Estructura objetivo:
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:
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:
cron
persistencia
cross-tab scheduling
background jobs
colas durables
5. Constantes públicas
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
export type TimerStatus = 'pending' | 'running' | 'cancelled' | 'completed' | 'failed';
export type TimerKind = 'timeout' | 'interval';
6.2 Task
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:
driftMs = firedAt - dueAt
runCount empieza en 1
signal se aborta al cancelar/dispose
6.3 Opciones
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
export interface TimerHandle {
readonly key: string;
readonly active: boolean;
cancel(): boolean;
reschedule(delayMs: number): void;
}
Reglas:
active = false después de cancel/complete/dispose
reschedule() solo funciona si active
reschedule() en timer inactivo lanza TimerInactiveError
6.5 Snapshot
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.
export interface TimerClock {
now(): number;
setTimeout(fn: () => void, delayMs: number): TimerNativeHandle;
clearTimeout(handle: TimerNativeHandle): void;
}
export type TimerNativeHandle = ReturnType<typeof globalThis.setTimeout>;
Default:
export function createSystemTimerClock(): TimerClock;
Tests pueden inyectar fake clock:
const timers = createEngineTimers({ clock: fakeClock });
8. Interfaces principales
8.1 TimerScheduler
Interfaz mínima que consumirán otros artifacts.
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
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
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
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:
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:
sess:auto-refresh
conn:main:heartbeat
conn:main:ack:abc
cache:products:gc
Scope por defecto:
scopeOf('conn:main:ack:abc') === 'conn:main:ack';
scopeOf('conn:main') === 'conn';
scopeOf('sess:auto-refresh') === 'sess';
cancelAll(scope) usa prefijo:
cancelAll('conn:main');
cancela:
conn:main:reconnect
conn:main:heartbeat
conn:main:ack:msg_1
pero no:
conn:market:heartbeat
Helper:
function isInScope(key: string, scope: string): boolean {
return key === scope || key.startsWith(`${scope}:`);
}
12. Implementación interna
12.1 Entry interna
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
export interface EngineTimersOptions {
readonly clock?: TimerClock;
readonly logger?: Logger;
}
export function createEngineTimers(options?: EngineTimersOptions): EngineTimers;
12.3 Algoritmo de schedule
- Verificar que no está disposed.
- Validar key.
- Validar
delayMs: finito y>= 0. - Si key existe:
replace: true=> cancelar anterior.- si no, lanzar
TimerDuplicateKeyError.
- Crear entry.
- Crear
AbortController. - Crear native timeout.
- Guardar entry en
Map. - Emitir
scheduled. - Devolver
TimerHandle.
12.4 Ejecución de timeout
Cuando el timeout dispara:
- Si la entry ya no existe o está cancelada, salir.
status = 'running'.- Set
lastFiredAt. - Incrementar
runCount. - Emitir
running. - Construir
TimerTaskContext. - Ejecutar task y esperar si devuelve promise.
- Si ok:
status = 'completed'- set
lastCompletedAt - emitir
completed - eliminar si
removeOnComplete !== false
- 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:
evita solapamiento de ejecuciones async
permite awaitTask
facilita drift/runCount/maxRuns
Default:
awaitTask = true;
12.5.1 Semántica de awaitTask: false
awaitTask: false produce una cadencia fire-and-forget:
- 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:
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:
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):
- Buscar entry.
- Si no existe, devolver false.
- Abort controller.
- Clear native timeout.
- Marcar
cancelled. - Eliminar del map.
- Emitir
cancelled. - 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():
- Si ya está disposed, no-op.
- Cancelar todos los timers.
- Emitir
disposed. - Limpiar listeners.
- Marcar disposed.
Después de dispose:
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:
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:
ididentifica una entry concreta.keyidentifica el timer desde la API pública.versioninvalida callbacks viejos de la misma entry.replace: truecrea una nueva entry con un nuevoid.reschedule()mantiene el mismoid, pero incrementaversion.cancel()incrementaversion, 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:
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
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í:
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.
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.
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.
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:
- Re-check
(id, key, version). - Check
signal.aborted. - Check
maxRuns. - Set status back to
pending. - Increment
versionfor the next native callback. - 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.
Timers.schedule('example:fetch', 1_000, async ({ signal }) => {
await fetch('/api', { signal });
});
Important:
AbortSignalis 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:
cancel
replace
reschedule
dispose
interval next tick
Implementation rule:
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:
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:
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:
No usar $effect para sincronizar timers.
Usar engine.onChange.
Esto evita effect_update_depth_exceeded.
14. Backoff helper
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:
attempt empieza en 0
random inyectable para tests
clamp a maxDelayMs
nunca devolver negativo
Algoritmo:
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
export interface ActiveAppTimersOptions {
readonly enabled?: boolean;
}
En la práctica App.timers debería estar siempre presente.
const App = createActiveApp({
timers: {}
});
App.timers.schedule(...);
Si no se configuran timers:
App.timers = createActiveTimers();
15.2 Inyección a otros artifacts
Artifacts deben aceptar:
timers?: TimerScheduler;
Cuando se crean desde App:
timers: App.timers;
No deben importar un scheduler global.
16. Integración con sess
withAutoRefresh debería aceptar timers:
const stop = withAutoRefresh(Sess, {
timers: App.timers,
tickMs,
marginMs,
jitterMs
});
Keys sugeridas:
sess:auto-refresh
sess:refresh-retry
Cleanup:
timers.cancelAll('sess');
Si en el futuro hay varias sesiones:
sess:main:auto-refresh
17. Integración con conn
Keys sugeridas:
conn:{name}:reconnect
conn:{name}:heartbeat
conn:{name}:heartbeat-timeout
conn:{name}:ack:{messageId}
conn:{name}:channel:{channel}:rejoin
Reconnect:
timers.schedule(`conn:${name}:reconnect`, delay, () => {
return connection.reconnect('scheduled');
});
Heartbeat:
timers.interval(`conn:${name}:heartbeat`, intervalMs, () => {
return connection.send('connection.ping', {});
});
Ack timeout:
timers.schedule(`conn:${name}:ack:${id}`, timeoutMs, () => {
resolveAckTimeout(id);
});
Al recibir reply:
timers.cancel(`conn:${name}:ack:${id}`);
Al hacer dispose de la conexión:
timers.cancelAll(`conn:${name}`);
18. Integración futura con cache
Keys posibles:
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:
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:
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:
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
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
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
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
consts.tstypes.tserrors.tsclock.tsbackoff.tsengine-timers.tsengine-timers.test.tsactive-timers.svelte.tsactive-timers.test.ts- integración con
aapp - actualizar
sessauto-refresh para aceptartimers - actualizar diseño de
connpara requerirtimers - 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
const Timers = createEngineTimers();
Timers.schedule('demo:hello', 1_000, () => {
console.log('hello');
});
23.2 Debounce por replace
Timers.schedule('search:debounce', 300, runSearch, {
replace: true
});
23.3 Intervalo
Timers.interval('conn:main:heartbeat', 25_000, () => {
return Main.send('connection.ping', {});
});
23.4 Limpieza por scope
Timers.cancelAll('conn:main');
23.5 App singleton
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:
$effect(() => {
if (Sess.current) {
timers.schedule(...);
}
});
Preferir:
Sess.onChange(() => {
timers.schedule(..., { replace: true });
});
24.2 No crear singleton global
Evitar:
export const Timers = createActiveTimers();
24.3 No dejar raw timers colgados
Cada artifact debe limpiar su scope:
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:
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.
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.