Initial Commit
This commit is contained in:
114
openspec/archive/2026-05-12-retry-injectable-timers/proposal.md
Normal file
114
openspec/archive/2026-05-12-retry-injectable-timers/proposal.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# OpenSpec Change: retry-injectable-timers
|
||||
|
||||
- **Change ID**: retry-injectable-timers
|
||||
- **Packages touched**: `@suaveplan/types`, `@suaveplan/retry`, `@suaveplan/cron`, `@suaveplan/job-scheduler`
|
||||
- **Category**: control-flow / foundation
|
||||
- **Status**: PROPOSED
|
||||
- **Date**: 2026-05-12
|
||||
- **Motivation**: When `@suaveplan/job-scheduler` was implemented (archived 2026-05-12), `executeJob` could not delegate to `@suaveplan/retry.retry()` because the retry primitive's `sleep()` and `withTimeout()` use raw `setTimeout`/`clearTimeout`. Under `FakeClock`-driven tests those timers don't advance, so the retry loop hangs and the integration test would never deterministically progress. The job-scheduler shipped with an inline retry loop carrying the comment "switch over when retry's timers become injectable". This change does that.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Add an injectable `TimersLike` contract (`{ setTimer, clearTimer }`) to `@suaveplan/types` so every package needing test-controllable scheduling can accept it through one shared interface. Extend `@suaveplan/retry`'s `RetryConfig` with an optional `timers?: TimersLike` field. Thread the pair through `sleep()` and `withTimeout()`. Update `@suaveplan/cron`'s pre-existing `SchedulerTimers` interface to be a type alias of `TimersLike` (preserving its public name for backwards compatibility). Once retry's timers are injectable, replace `@suaveplan/job-scheduler`'s inline retry loop with `retry.retry()` — removing duplicated retry semantics from the scheduler and letting the proven retry primitive own that concern.
|
||||
|
||||
---
|
||||
|
||||
## Current-state audit
|
||||
|
||||
- `@suaveplan/types` exposes `Clock`, `MonotonicClock`, `Rng`, etc. — but no timer-pair contract.
|
||||
- `@suaveplan/cron` defines `SchedulerTimers` locally in `src/scheduler/cron-scheduler.types.ts`. This is the de-facto contract that should move to `@suaveplan/types`.
|
||||
- `@suaveplan/retry` calls `setTimeout`/`clearTimeout` directly in `src/retry/retry.ts` (`sleep` and `withTimeout`). `RetryConfig` has `clock?` and `rng?` but no `timers?`.
|
||||
- `@suaveplan/job-scheduler` has an inline retry loop in `src/execution/job-execution.ts` driven by the scheduler's injected `timers`. The `@suaveplan/retry` workspace dep is declared but only as a future hook.
|
||||
|
||||
---
|
||||
|
||||
## Public API changes
|
||||
|
||||
### `@suaveplan/types`
|
||||
|
||||
```ts
|
||||
// New, exported from the package root.
|
||||
export interface TimersLike {
|
||||
/** Schedule `callback` to run after `delayMs`. Returns a handle. */
|
||||
setTimer(callback: () => void, delayMs: number): number;
|
||||
/** Cancel a pending timer by handle. Idempotent for unknown handles. */
|
||||
clearTimer(handle: number): void;
|
||||
}
|
||||
```
|
||||
|
||||
The shape mirrors `globalThis.setTimeout`/`clearTimeout` minus variadic args and Node's `Timeout` object — the lowest-common-denominator that works across `Date.now()`-driven production code and `FakeClock`-driven test code.
|
||||
|
||||
### `@suaveplan/cron`
|
||||
|
||||
```ts
|
||||
// Was: locally-defined interface.
|
||||
// Now: type alias of the shared contract; existing import names remain valid.
|
||||
import type { TimersLike } from "@suaveplan/types";
|
||||
export type SchedulerTimers = TimersLike;
|
||||
```
|
||||
|
||||
No behavioral change. The local name is preserved so existing internal call sites compile unchanged.
|
||||
|
||||
### `@suaveplan/retry`
|
||||
|
||||
```ts
|
||||
import type { Clock, Rng, TimersLike } from "@suaveplan/types";
|
||||
|
||||
export interface RetryConfig<_T = unknown> {
|
||||
maxAttempts: number;
|
||||
delay: RetryStrategy;
|
||||
shouldRetry?: (error: unknown, attempt: number) => boolean;
|
||||
signal?: AbortSignal;
|
||||
clock?: Clock;
|
||||
rng?: Rng;
|
||||
/** Pluggable timer pair. Defaults to `globalThis.setTimeout`/`clearTimeout`. */
|
||||
timers?: TimersLike;
|
||||
}
|
||||
|
||||
// sleep + withTimeout gain an optional final `timers?: TimersLike` argument.
|
||||
export function sleep(
|
||||
ms: number,
|
||||
signal?: AbortSignal,
|
||||
timers?: TimersLike
|
||||
): Promise<void>;
|
||||
|
||||
export function withTimeout<T>(
|
||||
fn: (signal?: AbortSignal) => Promise<T>,
|
||||
ms: number,
|
||||
attemptNumber: number,
|
||||
signal?: AbortSignal,
|
||||
timers?: TimersLike
|
||||
): Promise<T>;
|
||||
```
|
||||
|
||||
When `timers` is omitted the implementation falls back to the platform `setTimeout`/`clearTimeout` — every existing caller keeps the current behavior.
|
||||
|
||||
### `@suaveplan/job-scheduler`
|
||||
|
||||
No public-API change. The inline retry loop in `src/execution/job-execution.ts` is replaced with a single `retry()` call, threading the scheduler's injected `timers` and `clock` through. `JobSchedulerOptions.timers` stays exactly the same.
|
||||
|
||||
---
|
||||
|
||||
## Why a shared `TimersLike`
|
||||
|
||||
Per `openspec/AGENTS.md §0 step 6`: any type shared across ≥2 packages belongs in `@suaveplan/types` to keep the public contract in one place and prevent circular deps. Two packages (`cron` shipped, `retry` after this change) already need it; a third (`job-scheduler`) consumes it transitively. The alternative — three copies of the same interface — is the kind of drift the rule explicitly exists to prevent.
|
||||
|
||||
---
|
||||
|
||||
## Backwards compatibility
|
||||
|
||||
- `retry`: every existing call site works unchanged (new field is optional; new function parameters are optional).
|
||||
- `cron`: `SchedulerTimers` keeps its name as a type alias; consumers importing it from `@suaveplan/cron` see no behavioral or type change.
|
||||
- `job-scheduler`: `JobScheduler` public surface unchanged. Internally swapping retry implementations is a refactor, not an API break.
|
||||
|
||||
---
|
||||
|
||||
## Impact
|
||||
|
||||
- Removes ~40 lines of duplicated retry logic from `job-scheduler/src/execution/job-execution.ts`.
|
||||
- Unifies the timers contract for future packages (e.g. a future `@suaveplan/circuit-breaker` extracted from retry, or workflow-tier scheduler peers).
|
||||
- Slightly increases `@suaveplan/types` payload (one tiny interface, zero runtime impact — types-only).
|
||||
|
||||
**Prerequisites on Verdaccio**: `@suaveplan/types@0.0.0+`, `@suaveplan/retry@0.0.0+`, `@suaveplan/cron@0.0.0+`, `@suaveplan/job-scheduler@0.0.0+`. All already published.
|
||||
71
openspec/archive/2026-05-12-retry-injectable-timers/tasks.md
Normal file
71
openspec/archive/2026-05-12-retry-injectable-timers/tasks.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Tasks: retry-injectable-timers
|
||||
|
||||
**Change ID**: retry-injectable-timers
|
||||
**Packages touched**: `@suaveplan/types`, `@suaveplan/retry`, `@suaveplan/cron`, `@suaveplan/job-scheduler`
|
||||
|
||||
---
|
||||
|
||||
## 1. Land `TimersLike` in `@suaveplan/types`
|
||||
|
||||
- [x] Add `src/timers-like.ts` exporting `TimersLike` per proposal.
|
||||
- [x] Add sibling `src/timers-like.md` (≥200 words, Purpose / Features / Usage Basic / Usage Advanced / API Reference / Implementation Notes).
|
||||
- [x] Re-export `TimersLike` from `src/index.ts` (explicit named, alphabetic placement).
|
||||
- [x] `bunx turbo lint typecheck --filter=@suaveplan/types` clean.
|
||||
|
||||
## 2. Alias `SchedulerTimers` to `TimersLike` in `@suaveplan/cron`
|
||||
|
||||
- [x] In `packages/primitives/cron/src/scheduler/cron-scheduler.types.ts`:
|
||||
- Replace the local `SchedulerTimers` interface declaration with:
|
||||
`import type { TimersLike } from "@suaveplan/types"; export type SchedulerTimers = TimersLike;`
|
||||
- [x] Verify no internal cron code structurally needs anything beyond the two methods (the alias is transparent — no call-site changes expected).
|
||||
- [x] `bunx turbo lint typecheck test --filter=@suaveplan/cron` — all green, 100% coverage preserved.
|
||||
|
||||
## 3. Thread `TimersLike` through `@suaveplan/retry`
|
||||
|
||||
- [x] In `packages/control-flow/retry/src/types/retry.types.ts`: add `timers?: TimersLike` to `RetryConfig`. Update the Zod schema in `retry.schemas.ts` if it strict-checks unknown keys (must permit `timers`).
|
||||
- [x] In `packages/control-flow/retry/src/retry/retry.ts`:
|
||||
- `sleep(ms, signal, timers?)`: pull `setTimer` / `clearTimer` from `timers` when supplied; fall back to `globalThis.setTimeout` / `clearTimeout`.
|
||||
- `withTimeout(fn, ms, attemptNumber, signal, timers?)`: same pattern.
|
||||
- `retry()`: pass `config.timers` through both `sleep()` calls.
|
||||
- `withRetry()`: no change beyond config passthrough (already delegates to `retry()`).
|
||||
- [x] Update `retry.md` to mention the new option in API Reference + add an "Advanced: deterministic tests with FakeClock" example.
|
||||
- [x] Add `retry/retry-timers.test.ts` (or extend `retry.test.ts` if still under 500 lines): FakeClock-driven exponential backoff exercising injected timers end-to-end — assert the run completes in zero real wall-clock time after enough `clock.advance(ms)` calls.
|
||||
- [x] `bunx turbo lint typecheck test:coverage --filter=@suaveplan/retry` — 100% coverage on `retry.ts` and `retry.types.ts` preserved.
|
||||
|
||||
## 4. Replace inline retry loop in `@suaveplan/job-scheduler`
|
||||
|
||||
- [x] In `packages/control-flow/job-scheduler/src/execution/job-execution.ts`:
|
||||
- Import `retry` from `@suaveplan/retry` and a `RetryStrategy` builder.
|
||||
- Build a `delay: RetryStrategy` from `JobOptions.retryDelayMs` (constant strategy via `@suaveplan/retry`'s `constantBackoff`).
|
||||
- Compose `retry({ maxAttempts: retries + 1, delay, signal, timers, shouldRetry: () => true })` around the handler invocation.
|
||||
- Translate `RetryExhaustedError` → `JOB_SCHEDULER.EXECUTION_FAILED` (preserve `cause`) and `AttemptTimeoutError` → `JOB_SCHEDULER.TIMEOUT`.
|
||||
- Preserve `job:start` event emission per attempt; emit `job:fail` on final exhaustion; emit `job:complete` on success.
|
||||
- Delete the dead inline-loop helpers in `job-execution.ts` once their callers are gone.
|
||||
- [x] If `executeJob`'s test file references the inline retry helpers, port the assertions to the new behavior. Add a regression test proving FakeClock + `timers` injection still produces deterministic retry timing.
|
||||
- [x] `bunx turbo lint typecheck test:coverage --filter=@suaveplan/job-scheduler` — 100% coverage preserved. No skipped tests, no lowered thresholds.
|
||||
|
||||
## 5. Cross-package verification
|
||||
|
||||
- [x] `bunx turbo lint typecheck test --filter=@suaveplan/types --filter=@suaveplan/retry --filter=@suaveplan/cron --filter=@suaveplan/job-scheduler` — every gate green.
|
||||
- [x] Banned-pattern scan over the four touched packages:
|
||||
```bash
|
||||
grep -rn "@deprecated\|TODO\|FIXME\|ts-ignore\|ts-expect-error\|\.skip\|\.only\|console\.log\|biome-ignore" \
|
||||
packages/core/types/src/timers-like.ts \
|
||||
packages/control-flow/retry/src/ \
|
||||
packages/primitives/cron/src/scheduler/ \
|
||||
packages/control-flow/job-scheduler/src/ --include="*.ts"
|
||||
```
|
||||
- [x] Verify no direct runner imports introduced (`from "vitest"` / `from "bun:test"`) in any modified test file.
|
||||
- [x] Verify no `node:` imports introduced in any modified universal-tier file.
|
||||
|
||||
## 6. Publish + archive
|
||||
|
||||
- [ ] `bunx turbo build --filter=@suaveplan/types --filter=@suaveplan/retry --filter=@suaveplan/cron --filter=@suaveplan/job-scheduler` — confirm `dist/` outputs.
|
||||
- [ ] `bun scripts/publish.ts --package @suaveplan/types` (publish-or-skip; bumps allowed if Verdaccio rejects the static `0.1.0`).
|
||||
- [ ] `bun scripts/publish.ts --package @suaveplan/retry`.
|
||||
- [ ] `bun scripts/publish.ts --package @suaveplan/cron`.
|
||||
- [ ] `bun scripts/publish.ts --package @suaveplan/job-scheduler`.
|
||||
- [ ] Update `CLAUDE.md` `retry` entry to mention `timers?: TimersLike`; update `job-scheduler` entry to drop the FakeClock-determinism caveat and note it now delegates to `@suaveplan/retry`.
|
||||
- [ ] Update `MEMORY.md` if any in-flight memory references the inline retry-loop justification.
|
||||
- [ ] `bun scripts/folder-reorg/generate-manifest.ts` — no manifest changes expected (no new packages) but run to confirm.
|
||||
- [ ] Archive proposal + tasks + spec delta to `openspec/archive/<today>-retry-injectable-timers/`. Fold the `## ADDED Requirements` from `specs/types/spec.md` into `openspec/specs/types/spec.md` (create the file if it does not yet exist); fold the `## MODIFIED Requirements` from `specs/retry/spec.md` into `openspec/specs/retry/spec.md`.
|
||||
Reference in New Issue
Block a user