actly 1.1.5 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +234 -0
- package/MIGRATION.md +141 -0
- package/README.md +334 -428
- package/SECURITY.md +56 -0
- package/dist/core/act.cjs +630 -126
- package/dist/core/act.d.ts +6 -84
- package/dist/core/act.d.ts.map +1 -1
- package/dist/core/act.js +630 -129
- package/dist/core/executor.cjs +1 -26
- package/dist/core/executor.d.ts +2 -38
- package/dist/core/executor.d.ts.map +1 -1
- package/dist/core/executor.js +1 -27
- package/dist/core/health.cjs +163 -0
- package/dist/core/health.d.ts +33 -0
- package/dist/core/health.d.ts.map +1 -0
- package/dist/core/health.js +150 -0
- package/dist/core/shutdown.cjs +71 -0
- package/dist/core/shutdown.d.ts +5 -0
- package/dist/core/shutdown.d.ts.map +1 -0
- package/dist/core/shutdown.js +65 -0
- package/dist/core/tenant.cjs +113 -0
- package/dist/core/tenant.d.ts +24 -0
- package/dist/core/tenant.d.ts.map +1 -0
- package/dist/core/tenant.js +109 -0
- package/dist/errors.cjs +149 -0
- package/dist/errors.d.ts +94 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +134 -0
- package/dist/index.cjs +51 -8
- package/dist/index.d.ts +22 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -6
- package/dist/observability.cjs +6 -0
- package/dist/observability.d.ts +86 -0
- package/dist/observability.d.ts.map +1 -0
- package/dist/observability.js +3 -0
- package/dist/policies/bulkhead.cjs +137 -0
- package/dist/policies/bulkhead.d.ts +3 -0
- package/dist/policies/bulkhead.d.ts.map +1 -0
- package/dist/policies/bulkhead.js +134 -0
- package/dist/policies/cache.cjs +84 -67
- package/dist/policies/cache.d.ts +0 -33
- package/dist/policies/cache.d.ts.map +1 -1
- package/dist/policies/cache.js +84 -68
- package/dist/policies/circuitBreaker.cjs +179 -0
- package/dist/policies/circuitBreaker.d.ts +3 -0
- package/dist/policies/circuitBreaker.d.ts.map +1 -0
- package/dist/policies/circuitBreaker.js +176 -0
- package/dist/policies/dedupe.cjs +46 -64
- package/dist/policies/dedupe.d.ts +0 -41
- package/dist/policies/dedupe.d.ts.map +1 -1
- package/dist/policies/dedupe.js +46 -65
- package/dist/policies/noop.cjs +6 -0
- package/dist/policies/noop.d.ts +3 -0
- package/dist/policies/noop.d.ts.map +1 -0
- package/dist/policies/noop.js +3 -0
- package/dist/policies/rateLimit.cjs +45 -0
- package/dist/policies/rateLimit.d.ts +3 -0
- package/dist/policies/rateLimit.d.ts.map +1 -0
- package/dist/policies/rateLimit.js +42 -0
- package/dist/policies/retry.cjs +129 -57
- package/dist/policies/retry.d.ts +0 -27
- package/dist/policies/retry.d.ts.map +1 -1
- package/dist/policies/retry.js +129 -58
- package/dist/policies/timeout.cjs +47 -103
- package/dist/policies/timeout.d.ts +2 -46
- package/dist/policies/timeout.d.ts.map +1 -1
- package/dist/policies/timeout.js +46 -102
- package/dist/stores/base.cjs +0 -10
- package/dist/stores/base.d.ts +4 -67
- package/dist/stores/base.d.ts.map +1 -1
- package/dist/stores/base.js +0 -11
- package/dist/stores/memory.cjs +147 -87
- package/dist/stores/memory.d.ts +11 -59
- package/dist/stores/memory.d.ts.map +1 -1
- package/dist/stores/memory.js +146 -88
- package/dist/testing/index.cjs +56 -0
- package/dist/testing/index.d.ts +6 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +52 -0
- package/dist/types/index.cjs +0 -1
- package/dist/types/index.d.ts +62 -179
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +0 -2
- package/dist/utils/abort.cjs +46 -73
- package/dist/utils/abort.d.ts +4 -51
- package/dist/utils/abort.d.ts.map +1 -1
- package/dist/utils/abort.js +46 -74
- package/dist/utils/abortPool.cjs +25 -0
- package/dist/utils/abortPool.d.ts +4 -0
- package/dist/utils/abortPool.d.ts.map +1 -0
- package/dist/utils/abortPool.js +20 -0
- package/dist/utils/backoff.cjs +8 -15
- package/dist/utils/backoff.d.ts +0 -11
- package/dist/utils/backoff.d.ts.map +1 -1
- package/dist/utils/backoff.js +8 -16
- package/dist/utils/decorator.cjs +44 -0
- package/dist/utils/decorator.d.ts +3 -0
- package/dist/utils/decorator.d.ts.map +1 -0
- package/dist/utils/decorator.js +41 -0
- package/dist/utils/key.cjs +45 -0
- package/dist/utils/key.d.ts +2 -0
- package/dist/utils/key.d.ts.map +1 -0
- package/dist/utils/key.js +42 -0
- package/dist/utils/limits.cjs +22 -0
- package/dist/utils/limits.d.ts +21 -0
- package/dist/utils/limits.d.ts.map +1 -0
- package/dist/utils/limits.js +19 -0
- package/dist/utils/safeCall.cjs +28 -0
- package/dist/utils/safeCall.d.ts +2 -0
- package/dist/utils/safeCall.d.ts.map +1 -0
- package/dist/utils/safeCall.js +25 -0
- package/dist/utils/sanitize.cjs +50 -0
- package/dist/utils/sanitize.d.ts +3 -0
- package/dist/utils/sanitize.d.ts.map +1 -0
- package/dist/utils/sanitize.js +46 -0
- package/dist/utils/validate.cjs +163 -26
- package/dist/utils/validate.d.ts +6 -8
- package/dist/utils/validate.d.ts.map +1 -1
- package/dist/utils/validate.js +158 -27
- package/package.json +42 -7
- package/dist/core/act.js.map +0 -1
- package/dist/core/executor.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/policies/cache.js.map +0 -1
- package/dist/policies/dedupe.js.map +0 -1
- package/dist/policies/retry.js.map +0 -1
- package/dist/policies/timeout.js.map +0 -1
- package/dist/state/store.cjs +0 -14
- package/dist/state/store.d.ts +0 -11
- package/dist/state/store.d.ts.map +0 -1
- package/dist/state/store.js +0 -11
- package/dist/state/store.js.map +0 -1
- package/dist/stores/base.js.map +0 -1
- package/dist/stores/memory.js.map +0 -1
- package/dist/types/index.js.map +0 -1
- package/dist/utils/abort.js.map +0 -1
- package/dist/utils/backoff.js.map +0 -1
- package/dist/utils/validate.js.map +0 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## v1.3.0 - 2026-07-06
|
|
4
|
+
|
|
5
|
+
Hardening release. Smaller tarball, more policies, deeper audit. 6 breaking changes (see [MIGRATION.md](./MIGRATION.md)).
|
|
6
|
+
|
|
7
|
+
A line-by-line audit across core, policies, utils, stores, types, errors, and observability turned up 61 bugs. All fixed. Test count grew from 609 to 633.
|
|
8
|
+
|
|
9
|
+
### Breaking changes
|
|
10
|
+
|
|
11
|
+
1. Hedge cancels the losing promise via `AbortController` instead of running it to completion. Set `hedge.keepLoser: true` for the old behavior.
|
|
12
|
+
2. Hedge placement defaults to `outside-retry` (one hedge per `act()` call). Was `inside-retry` (one per retry attempt, multiplying downstream load).
|
|
13
|
+
3. `dedupe.inflightTtl: 0` now throws. Use `inflightTtl: 1` or omit.
|
|
14
|
+
4. `defaultShouldRetry` skips per-attempt `TimeoutError`. `timeout: 1000, retry: { attempts: 5 }` now fails at 1s, not 5s. Override with `shouldRetry: () => true`.
|
|
15
|
+
5. Default `inflightTtl` is 5 minutes. Was `Infinity` (a hung `fn` blocked the key forever).
|
|
16
|
+
6. `observability` shape is validated. Typos in hook names throw at call time.
|
|
17
|
+
|
|
18
|
+
### Bug fixes
|
|
19
|
+
|
|
20
|
+
**Contract and resource leaks**
|
|
21
|
+
|
|
22
|
+
- Fast-path `act()` rejected under resource exhaustion. The never-rejects contract now holds on every path. (`BUG-CORE-001`)
|
|
23
|
+
- `registerInflight` failure leaked an abort listener on `options.signal` in both main and scoped paths. (`BUG-CORE-002`, `BUG-CORE-003`)
|
|
24
|
+
- Scoped `act()` did not wrap `buildPolicies` in try/catch. A buggy policy constructor leaked the inflight counter, drain counter, and signal listener, and rejected the promise. (`BUG-CORE-004`)
|
|
25
|
+
- `InMemoryStore` leaked its cleanup interval if `destroy()` was never called. Registered a `FinalizationRegistry` as a safety net. (`BUG-ST-003`)
|
|
26
|
+
- `createAsyncTenantStore.evict()` did not call `destroy()` on the evicted store. Connection pools leaked per eviction.
|
|
27
|
+
- `InMemoryStore.destroy()` did not clear the map. Entry closures leaked.
|
|
28
|
+
- `_sweep()` had no try/catch. A corrupted entry could crash the process via `uncaughtException` from a `setInterval` callback.
|
|
29
|
+
- `cache` and `dedupe` cleanup callbacks were not wrapped in try/catch. A buggy custom store could trigger `unhandledRejection`. (`BUG-POL-009`, `BUG-POL-011`)
|
|
30
|
+
- Idle policy state (bulkhead, circuit breaker, rate limit) was never cleaned up. State lingered per-key forever.
|
|
31
|
+
- `drain()` on a non-existent scope created an empty entry that was never pruned. (`BUG-CORE-012`)
|
|
32
|
+
- Hedge aborted the winner's controller too. Downstream side-effects (streaming bodies, cursor cleanup) could be cancelled. Now only the loser is aborted. (`BUG-CORE-015`)
|
|
33
|
+
- `RetryExhaustedError.errors[]` was unbounded. Capped at 10.
|
|
34
|
+
- `withStore` scope ID used 6-char `Math.random`. Birthday-paradox collision at ~47k scoped stores. Switched to `crypto.randomUUID()`.
|
|
35
|
+
|
|
36
|
+
**Correctness**
|
|
37
|
+
|
|
38
|
+
- Observability hook throws crashed the main path. Wrapped in `safeCall`.
|
|
39
|
+
- `onDedupeJoin` and `onTimeout` hooks were never emitted. Dead code, now fire correctly.
|
|
40
|
+
- Hedge did not cancel the losing promise. Self-DoS on downstream.
|
|
41
|
+
- Hedge inside retry multiplied downstream load. Moved outside by default.
|
|
42
|
+
- `defaultShouldRetry` retried on per-attempt timeout. Misleading. Now skips.
|
|
43
|
+
- Count-strategy circuit breaker did not reset the sliding window on a successful half-open probe. One failure after recovery re-tripped it immediately. (`BUG-POL-006`)
|
|
44
|
+
- Abort during a half-open probe re-opened the breaker. Caller cancellation is not a downstream failure. Now the breaker stays half-open so the next caller probes. (`BUG-POL-007`)
|
|
45
|
+
- `isAbortError` heuristic was too broad. A manual `AbortError` thrown by `fn` for non-signal reasons did not trip the breaker. Now gated on `signal.aborted`. (`BUG-POL-014`)
|
|
46
|
+
- Idle-reset behavior diverged between `consecutive` and `count` strategies. Now symmetric: both fully reset on idle. (`BUG-POL-005`)
|
|
47
|
+
- NaN delay from `backoffFn` defeated backoff. `Math.max(0, NaN)` is `NaN`, so `if (delay > 0)` was false and the sleep was skipped. Now sanitized to 0. (`BUG-POL-001`)
|
|
48
|
+
- Async `shouldRetryResult` predicate was silently treated as "accept" (a Promise is truthy). Runtime type guard added. (`BUG-POL-003`)
|
|
49
|
+
- `onBackpressure` flag could get stuck at `true`, suppressing future events. Now resets in `releaseSlot`. (`BUG-POL-012`)
|
|
50
|
+
- `Date.now()` was used for duration measurement. Non-monotonic, jumped under NTP. Switched to `performance.now()` via a `monotonicNow()` helper. (`BUG-CORE-014`)
|
|
51
|
+
- `hedge.delayMs` had no upper bound. `setTimeout` wraps to 1ms for values over 2^31-1. Capped at 300s. (`BUG-CORE-020`)
|
|
52
|
+
- `computeDelay` decorrelated jitter produced zero jitter when `maxDelay < base`. Now degrades to full jitter. (`BUG-UTIL-007`)
|
|
53
|
+
- `sanitizeError` discarded `ActlyError.code` and `.key`. Audit logs lost the stable discriminator. Now preserved. (`BUG-CORE-010`)
|
|
54
|
+
- `sanitizeError` lost the original stack trace. Now copies `.stack` and chains via `.cause`. (`BUG-UTIL-009`)
|
|
55
|
+
- `sanitizeErrorMessage` did not coerce `Error.message` to string. A non-string message threw `TypeError` inside the audit path. (`BUG-UTIL-004`)
|
|
56
|
+
- `safeCall` called `result.catch(...)` on thenables without verifying `.catch` exists. Custom thenables were silently dropped. Switched to `Promise.resolve(result).catch(...)`. (`BUG-UTIL-002`)
|
|
57
|
+
- `safeCall` accessed `process.env.NODE_ENV` per invocation. Threw `ReferenceError` in environments without `process`. Cached at module load with a typeof guard. (`BUG-UTIL-003`)
|
|
58
|
+
- Async observability hook rejections were silently swallowed. Now emit a dev-mode `console.warn`. (`BUG-ST-009`)
|
|
59
|
+
- `usePolicy` performed `await import()` on every method invocation. Cached as a top-level Promise. (`BUG-UTIL-006`)
|
|
60
|
+
- `usePolicy` did not preserve the original method `name`. Stack traces showed `wrappedMethod`. Now copies via `Object.defineProperty`. (`BUG-UTIL-005`)
|
|
61
|
+
- `usePolicy` used `instanceof AbortSignal` for signal detection. Broke across realms. Switched to duck-typing. (`BUG-UTIL-010`)
|
|
62
|
+
- `assertOptions` signal duck-type check did not verify `removeEventListener`. A signal-like object without it threw `TypeError` later. (`BUG-UTIL-008`)
|
|
63
|
+
- `assertNonNegativeFinite` rejected `Infinity` for `circuitBreaker.resetTimeoutMs`. Inconsistent with `dedupe.inflightTtl`. Now allowed. (`BUG-UTIL-011`)
|
|
64
|
+
- `dedupe: null` threw a confusing `TypeError` instead of a clear validation error. Treated as "not provided" now. (`BUG-UTIL-001`)
|
|
65
|
+
- `enableWatchdog` did not recreate the interval when `thresholdMs` changed. A 100ms threshold with a 15s interval fired 15s late. (`BUG-CORE-013`)
|
|
66
|
+
- Watchdog fired every `intervalMs` for the entire stuck duration. A 10-minute-stuck `fn` produced 40 events. Now fires once per busy period. (`BUG-CORE-018`)
|
|
67
|
+
- Scoped `act()` fallback path did not emit `onFinalSuccess`. Operators saw a phantom "missing success" for scoped fallback calls. (`BUG-CORE-005`)
|
|
68
|
+
- Scoped `act()` fallback audit-log timestamp used stale `now` captured before the fallback ran. (`BUG-CORE-011`)
|
|
69
|
+
- Fallback error was silently swallowed. Now emits `onFinalFailure` and `console.warn` in non-production. (`BUG-CORE-006`)
|
|
70
|
+
- `registerInflight` catch did not emit `onFinalFailure`, audit, or `recordError`. Resource exhaustion was invisible to dashboards. (`BUG-CORE-007`)
|
|
71
|
+
- `waitForObsHook` timer was not `unref`'d and had no `cancel()`. Tests could hang or leak wrappers across test boundaries. (`BUG-ST-010`)
|
|
72
|
+
|
|
73
|
+
**Type safety and taxonomy**
|
|
74
|
+
|
|
75
|
+
- `HedgeTimeoutError` extended `Error` instead of `ActlyError`. `instanceof ActlyError` missed it. `JSON.stringify` returned `{}`. Moved into `errors.ts`, now extends `ActlyError` with `as const` code. (`BUG-ST-001`)
|
|
76
|
+
- `ActlyError.toJSON()` silently dropped subclass fields (`attempts`, `ms`, `current`, `limit`, `field`). Now picks up enumerable own properties via `Object.keys(this)`. (`BUG-ST-002`)
|
|
77
|
+
- `act<T>()` and `scopedAct<T>()` accepted wrong-typed `fallback.value`. TypeScript now propagates `T` to `ActOptions<T>`. (`BUG-ST-004`)
|
|
78
|
+
- `AuditEntry.failedBy` was typed as `string`. Now the `ActlyFailedBy` literal union. (`BUG-ST-008`)
|
|
79
|
+
- `ObservabilityContext` was duplicated in `types/index.ts` and `observability.ts`. Re-exported from the source of truth. (`BUG-ST-006`)
|
|
80
|
+
- `ExecutorInput` and `ObservabilityContext` were not re-exported from `index.ts`. Now exported. (`BUG-ST-007`)
|
|
81
|
+
- `ActlyError.toJSON()` included `message` verbatim. Added `{ redact: true }` option to HTML-escape and length-cap for log shipping. (`BUG-ST-014`)
|
|
82
|
+
- `cause` support was inconsistent across `ActlyError` subclasses. Standardized: every subclass accepts `cause` in its options. (`BUG-ST-013`)
|
|
83
|
+
- `instanceof ActlyError` is unreliable across realms. Documented. Added `isActlyError(e)` realm-safe predicate. (`BUG-ST-016`)
|
|
84
|
+
|
|
85
|
+
**Memory safety**
|
|
86
|
+
|
|
87
|
+
- `InMemoryStore` default `maxSize` was `Infinity`. Unbounded memory growth in long-running servers. Default is now 10,000. Pass `Infinity` explicitly for unbounded. (`BUG-ST-005`)
|
|
88
|
+
- `InMemoryStore` allowed non-integer `maxSize`. Surprising eviction behavior. Now requires integer or `Infinity`. (`BUG-ST-015`)
|
|
89
|
+
- Tenant manager `tenants` Map grew monotonically. No eviction. Added `maxTenants` (default 10,000) with LRU eviction. (`BUG-CORE-008`, `BUG-CORE-009`)
|
|
90
|
+
|
|
91
|
+
### New features
|
|
92
|
+
|
|
93
|
+
**Cockatiel parity**
|
|
94
|
+
|
|
95
|
+
- `shouldRetryResult`: retry on returned values that are semantic failures (HTTP 500 without throwing).
|
|
96
|
+
- `timeout.strategy: 'cooperative'`: wait for `fn` to settle after signal abort.
|
|
97
|
+
- `circuitBreaker.strategy: 'count'`: sliding-window ratio breaker.
|
|
98
|
+
- `retry.backoffFn`: custom backoff with per-call state carry.
|
|
99
|
+
- `retry.dangerouslyUnref`: unref sleep timer for CLI/scripts/tests.
|
|
100
|
+
- `noopPolicy()`: passthrough policy for testing and conditional chains.
|
|
101
|
+
- `@usePolicy(options)`: method decorator for class methods.
|
|
102
|
+
- `./testing` subpath: `waitForObsHook()` and `isActlyEventType()` test helpers.
|
|
103
|
+
|
|
104
|
+
**Stability**
|
|
105
|
+
|
|
106
|
+
- Per-scope health state. `createHealthCheck(store, { scope })` respects the scope parameter.
|
|
107
|
+
- `drainAll(timeoutMs)`: drain all scopes in parallel for K8s graceful shutdown.
|
|
108
|
+
- `createHealthCheck(store, { probeIntervalMs })`: periodic probe warns on stuck inflight.
|
|
109
|
+
- Resource budget: `MAX_GLOBAL_INFLIGHT = 100_000` and `ResourceExhaustedError` prevent self-DoS. Opt out via `ACTLY_NO_INFLIGHT_LIMIT=1`.
|
|
110
|
+
- `onBackpressure` event: fires when bulkhead queue utilization crosses 80%.
|
|
111
|
+
- Watchdog: `enableWatchdog(thresholdMs, hooks)` detects hung `fn` calls.
|
|
112
|
+
- `memoryPressureCleanup`: `InMemoryStore({ memoryPressureCleanup: true })` reacts to Node 22+ `process.on('memory')` events.
|
|
113
|
+
|
|
114
|
+
**Store interface**
|
|
115
|
+
|
|
116
|
+
- `SyncStateStore.deleteIfExists()` and `AsyncStateStore.deleteIfExists()`: optional atomic delete-and-return-existed. TOCTOU-free `invalidate`.
|
|
117
|
+
- `SyncStateStore.destroy()` and `AsyncStateStore.destroy()`: optional lifecycle method. Prevents connection pool leaks on tenant eviction.
|
|
118
|
+
|
|
119
|
+
**Error taxonomy**
|
|
120
|
+
|
|
121
|
+
- `ResourceExhaustedError` (`ACTLY_RESOURCE_EXHAUSTED`).
|
|
122
|
+
- `HedgeTimeoutError` (`ACTLY_HEDGE_TIMEOUT`), now in the `ActlyError` hierarchy.
|
|
123
|
+
|
|
124
|
+
### Performance
|
|
125
|
+
|
|
126
|
+
- Fast path: ~680k ops/sec (no regression).
|
|
127
|
+
- Cache hit: ~700k ops/sec (no regression).
|
|
128
|
+
- Slow path: marginal improvement from const caching, lazy `errors[]` allocation, and `rateLimit` in-place filter.
|
|
129
|
+
- `anySignal` polyfill: hoisted `AbortSignal.any` detection to a module-level constant.
|
|
130
|
+
- Bulkhead queue entry: hidden class stabilized (all fields declared up-front).
|
|
131
|
+
- `raceAbort` skip: when no `options.signal` is configured, skip Promise and listener allocation.
|
|
132
|
+
- `usePolicy`: dynamic `import()` cached as a top-level Promise. No per-call allocation.
|
|
133
|
+
- `safeCall`: `IS_DEV` flag cached at module load. No `process.env` access per invocation.
|
|
134
|
+
- `monotonicNow()`: uses `performance.now()` (sub-microsecond, monotonic). No regression.
|
|
135
|
+
|
|
136
|
+
### Size
|
|
137
|
+
|
|
138
|
+
- tarball: 91 KB to 57 KB.
|
|
139
|
+
- dist: 435 KB to 287 KB.
|
|
140
|
+
- `.d.ts`: 57 KB to 21 KB via `removeComments` + `stripInternal`.
|
|
141
|
+
- Source maps dropped from the npm tarball (kept in repo).
|
|
142
|
+
- Deleted deprecated `src/state/store.ts` shim.
|
|
143
|
+
|
|
144
|
+
### Naming
|
|
145
|
+
|
|
146
|
+
- Private methods: `_appendTail` to `appendTail`, `_removeNode` to `removeNode`, `_moveToTail` to `moveToTail`, `_sweep` to `sweep`. The TS `private` keyword suffices.
|
|
147
|
+
- Namespace prefixes: `__inflight:` to `inflight:`. Removed redundant `__tenant:`.
|
|
148
|
+
- Hedge sentinel: `'__HEDGE_TIMEOUT__'` string to `HedgeTimeoutError` class.
|
|
149
|
+
|
|
150
|
+
### Tests
|
|
151
|
+
|
|
152
|
+
576 tests in v1.2.0. 633 in v1.3.0 across 25 files. New: `phase2-stability`, `phase4-features`, `phase5-stability`, `debug-fixes`, `edge-cases`, `debug2` through `debug7` edge cases, `audit-fixes`, `v131-audit-fixes` (24 regression tests for the audit bugs above).
|
|
153
|
+
|
|
154
|
+
### Known limitations
|
|
155
|
+
|
|
156
|
+
- Bulkhead queue cleanup on `store.destroy()` needs a cooperative cleanup API. Deferred.
|
|
157
|
+
- Circuit breaker `serialize()`/`hydrate()` for serverless cold start. Per-key state in store, complex API. Deferred.
|
|
158
|
+
- `runStoreConformanceTests()` export. Too complex for this release.
|
|
159
|
+
- `SamplingBreaker`. Cockatiel has 3 breaker strategies; actly has 2 (consecutive + count).
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## v1.2.0 - 2026-07-04
|
|
164
|
+
|
|
165
|
+
Hardening release. New resilience policies, an error taxonomy, observability hooks, security hardening, and a set of correctness fixes from a deep-dive audit.
|
|
166
|
+
|
|
167
|
+
### Breaking changes
|
|
168
|
+
|
|
169
|
+
- Node 20+ required (was 18+). Node 18 reached EOL April 2025. Native `AbortSignal.any()` enables leak-free signal composition.
|
|
170
|
+
- `retryPolicy` now throws `RetryExhaustedError` when all attempts fail and at least one retry happened. The raw last error is on `err.lastError` and `err.errors[]`. If `shouldRetry` returned `false` on the first attempt (no retries), the raw error is still surfaced unwrapped.
|
|
171
|
+
- `TimeoutError` and `TotalTimeoutError` now extend `ActlyError` (which extends `Error`). Existing `instanceof Error` and `instanceof TimeoutError` checks continue to work. New `.code` field provides a stable string discriminator.
|
|
172
|
+
- `ActResult` has new optional fields: `traceId?`, `durationMs?`. Existing code that reads only `ok`, `value`, `error`, `source`, `attempts` is unaffected.
|
|
173
|
+
|
|
174
|
+
### New policies
|
|
175
|
+
|
|
176
|
+
- Circuit breaker (`circuitBreaker: { threshold, cooldownMs }`).
|
|
177
|
+
- Bulkhead (`bulkhead: { maxConcurrent, maxQueue?, queueTimeoutMs? }`).
|
|
178
|
+
- Rate limit (`rateLimit: { maxCalls, windowMs }`).
|
|
179
|
+
- Hedge (`hedge: { delayMs }`).
|
|
180
|
+
|
|
181
|
+
### Correctness fixes
|
|
182
|
+
|
|
183
|
+
- Zero listener accumulation on long-lived `AbortSignal`s. Every `addEventListener('abort', ...)` is paired with `removeEventListener` on the success path.
|
|
184
|
+
- Default store is bounded (`maxSize: 10_000`, `autoCleanup: 60s`).
|
|
185
|
+
- Dedupe joiner isolation: originator's caller-signal abort does not propagate to joiners.
|
|
186
|
+
- Generation-safe dedupe cleanup: stale originator cleanup does not delete newer entries when `inflightTtl` triggers replacement.
|
|
187
|
+
- Cache single-flight originator isolation.
|
|
188
|
+
- Cache hit honors abort.
|
|
189
|
+
- Async store cache path re-checks signal between awaits.
|
|
190
|
+
- `act()` returns promptly on caller abort even without `timeout` policy.
|
|
191
|
+
- Joiner abort reports `attempts: 0`.
|
|
192
|
+
- `InMemoryStore.size()` is O(1). LRU uses an explicit doubly-linked list.
|
|
193
|
+
- `computeDelay` decorrelated jitter no longer produces a negative delay when `maxDelay` caps below `delayMs`. Clamped to `[0, delay]`.
|
|
194
|
+
- Cache `onCacheHit` event reports the real entry age (`ageMs`) instead of always `0`.
|
|
195
|
+
- `withStore`'s async `invalidate` no longer has a TOCTOU race. Now a single `store.deleteIfExists()` call when the store implements it.
|
|
196
|
+
|
|
197
|
+
### Security
|
|
198
|
+
|
|
199
|
+
- Key sanitisation: rejects `__proto__`, `constructor`, `prototype`, control chars, CRLF, keys over 1024 chars, reserved prefixes.
|
|
200
|
+
- Numeric input caps: `retry.attempts` 100, `timeout.ms` 100M, `cache.ttl` 24h, `retry.delayMs` 5min, `dedupe.inflightTtl` 24h.
|
|
201
|
+
- Enum validation for `retry.backoff` and `retry.jitter`.
|
|
202
|
+
|
|
203
|
+
### New features
|
|
204
|
+
|
|
205
|
+
- Error taxonomy: 6 classes with stable `.code` field.
|
|
206
|
+
- Observability hooks: 8 event types, zero-cost when not registered.
|
|
207
|
+
- Enriched `ActResult`: optional `traceId` and `durationMs`.
|
|
208
|
+
- Fast path for `act('k', fn)` with no options. Skips policy-chain construction and observability-context allocation.
|
|
209
|
+
- Native `AbortSignal.any()` on Node 20+.
|
|
210
|
+
- `AbortController` pooling (up to 64 reused controllers) to reduce GC pressure.
|
|
211
|
+
- Multi-tenant stores: `createTenantStore()` / `createAsyncTenantStore()`.
|
|
212
|
+
- `drain(timeoutMs, scope?)` for graceful shutdown.
|
|
213
|
+
|
|
214
|
+
### New exports
|
|
215
|
+
|
|
216
|
+
`anySignal`, `raceAbort`, `sleep`, `linkSignal`, `isAbortError`, `sanitizeKey`, `computeDelay`, `LIMITS`, `ActlyError`, `ActlyAbortError`, `RetryExhaustedError`, `ValidationError`, `createTenantStore`, `createAsyncTenantStore`, `drain`.
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## v1.1.x
|
|
221
|
+
|
|
222
|
+
Initial public releases. `totalTimeout` cancels the inner retry loop. `timeout` policy is cooperative via `AbortSignal`. Dedupe joiners can abort independently. Cache stampede prevention (single-flight). Dedupe joiners mirror originator's `attempts`. Cache hit reports `attempts: 0`. `AsyncStateStore` interface, `InMemoryStore` public export, `TotalTimeoutError` distinct from `TimeoutError`, `totalTimeout` option, `dedupe: true` shorthand, `isSyncStore()` / `isAsyncStore()` type guards.
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## v1.0.x
|
|
227
|
+
|
|
228
|
+
Initial stable release. `act()`, `retry`, `timeout`, `totalTimeout`, `dedupe`, `cache`. `TimeoutError` exported for `instanceof` checks. Zero dependencies. ESM + CJS. Node 18+.
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## License
|
|
233
|
+
|
|
234
|
+
MIT
|
package/MIGRATION.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Migration Guide: v1.2.0 to v1.3.0
|
|
2
|
+
|
|
3
|
+
Six breaking changes. If you only use `retry` / `timeout` / `totalTimeout` / `dedupe` / `cache` and don't inspect error types on exhausted retries, you probably don't need to change anything.
|
|
4
|
+
|
|
5
|
+
## 1. Hedge cancels the losing promise
|
|
6
|
+
|
|
7
|
+
Previously the loser ran to completion, wasting downstream resources. Now it is aborted via `AbortController`. If `fn` cooperates with the signal, the underlying work is cancelled.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
// old: loser kept running
|
|
11
|
+
await act('search', async (signal) => searchDB({ signal }), {
|
|
12
|
+
hedge: { delayMs: 200 },
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
// new: loser is cancelled (default)
|
|
16
|
+
await act('search', async (signal) => searchDB({ signal }), {
|
|
17
|
+
hedge: { delayMs: 200 },
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
// opt out if you need the loser's side-effects
|
|
21
|
+
await act('search', async (signal) => searchDB({ signal }), {
|
|
22
|
+
hedge: { delayMs: 200, keepLoser: true },
|
|
23
|
+
})
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## 2. Hedge placement defaults to `outside-retry`
|
|
27
|
+
|
|
28
|
+
Previously hedge wrapped `fn` before the policy chain. Each retry attempt could spawn its own hedge, multiplying downstream load by the retry count. Now hedge wraps the whole chain. One hedge per `act()` call.
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
// old: 5 retries + hedge could spawn up to 10 fn invocations
|
|
32
|
+
await act('k', fn, { retry: { attempts: 5 }, hedge: { delayMs: 50 } })
|
|
33
|
+
|
|
34
|
+
// new: 5 retries + hedge spawns at most 2 fn invocations total
|
|
35
|
+
await act('k', fn, { retry: { attempts: 5 }, hedge: { delayMs: 50 } })
|
|
36
|
+
|
|
37
|
+
// opt in to per-attempt hedge (old behavior)
|
|
38
|
+
await act('k', fn, {
|
|
39
|
+
retry: { attempts: 5 },
|
|
40
|
+
hedge: { delayMs: 50, placement: 'inside-retry' },
|
|
41
|
+
})
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## 3. `inflightTtl: 0` is rejected
|
|
45
|
+
|
|
46
|
+
Previously `inflightTtl: 0` was accepted but silently treated as `Infinity`. Now it throws `RangeError` at call time. Use `inflightTtl: 1` for near-immediate expiry, or omit the field for the 5-minute default.
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
// old: silently Infinity (probably not what you wanted)
|
|
50
|
+
await act('k', fn, { dedupe: { inflightTtl: 0 } })
|
|
51
|
+
|
|
52
|
+
// new: explicit
|
|
53
|
+
await act('k', fn, { dedupe: { inflightTtl: 1 } }) // 1ms
|
|
54
|
+
await act('k', fn, { dedupe: { inflightTtl: 30_000 } }) // 30s
|
|
55
|
+
await act('k', fn, { dedupe: true }) // 5min default
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## 4. `defaultShouldRetry` skips per-attempt `TimeoutError`
|
|
59
|
+
|
|
60
|
+
Previously the default `shouldRetry` retried on every error except `AbortError`. Per-attempt timeout aborts with `TimeoutError` (not `AbortError`), so timeouts were retried. `timeout: 1000, retry: { attempts: 5 }` could wait 5 seconds, not 1.
|
|
61
|
+
|
|
62
|
+
Now `defaultShouldRetry` also skips `ACTLY_TIMEOUT`. `timeout: 1000, retry: { attempts: 5 }` fails after 1 second.
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
// old: retried on per-attempt timeout (5s worst-case)
|
|
66
|
+
await act('k', fn, { timeout: { ms: 1000 }, retry: { attempts: 5 } })
|
|
67
|
+
|
|
68
|
+
// new: fails fast after 1s (default)
|
|
69
|
+
await act('k', fn, { timeout: { ms: 1000 }, retry: { attempts: 5 } })
|
|
70
|
+
|
|
71
|
+
// preserve old behavior
|
|
72
|
+
await act('k', fn, {
|
|
73
|
+
timeout: { ms: 1000 },
|
|
74
|
+
retry: {
|
|
75
|
+
attempts: 5,
|
|
76
|
+
shouldRetry: () => true, // retry on everything, including timeouts
|
|
77
|
+
},
|
|
78
|
+
})
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## 5. Default `inflightTtl` is 5 minutes (was `Infinity`)
|
|
82
|
+
|
|
83
|
+
Previously an unset `inflightTtl` meant in-flight entries lived forever. A single hung `fn` blocked all subsequent callers on that key. Now the default is 5 minutes.
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
// old: Infinity (hung fn blocks forever)
|
|
87
|
+
await act('k', fn, { dedupe: true })
|
|
88
|
+
|
|
89
|
+
// new: 5min default
|
|
90
|
+
await act('k', fn, { dedupe: true })
|
|
91
|
+
|
|
92
|
+
// preserve old behavior (pair with a timeout)
|
|
93
|
+
await act('k', fn, { dedupe: { inflightTtl: Infinity } })
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## 6. `observability` shape is validated
|
|
97
|
+
|
|
98
|
+
Previously typos in hook names (`onFinalSucess` instead of `onFinalSuccess`) were silently ignored. You thought you were observing but weren't. Now unknown keys throw `ValidationError` at call time.
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
// old: typo silently ignored
|
|
102
|
+
await act('k', fn, {
|
|
103
|
+
observability: { onFinalSucess: (e) => log(e) }, // never fires
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// new: typo throws
|
|
107
|
+
await act('k', fn, {
|
|
108
|
+
observability: { onFinalSuccess: (e) => log(e) }, // correct
|
|
109
|
+
})
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Non-breaking improvements
|
|
113
|
+
|
|
114
|
+
- `onDedupeJoin` and `onTimeout` hooks now fire (previously declared but never emitted).
|
|
115
|
+
- `HedgeTimeoutError` is a proper `ActlyError` subclass. The old `__HEDGE_TIMEOUT__` string sentinel is gone.
|
|
116
|
+
- Internal namespace prefixes changed: `__inflight:` to `inflight:`, `__tenant:` removed. User-facing behavior unchanged.
|
|
117
|
+
- `InMemoryStore.destroy()` now clears the map.
|
|
118
|
+
- `sweep()` wraps its body in try/catch. A bad entry no longer crashes the process.
|
|
119
|
+
- Idle policy state (bulkhead, circuit breaker, rate limit) is cleaned up when no longer needed.
|
|
120
|
+
- `RetryExhaustedError.errors[]` capped at 10 entries.
|
|
121
|
+
- `createAsyncTenantStore.evict()` calls `destroy()` on the evicted store.
|
|
122
|
+
- Hedge losing promise is cancelled via `AbortController`.
|
|
123
|
+
- Observability hook throws no longer crash the main path (wrapped in `safeCall`).
|
|
124
|
+
- `InMemoryStore` default `maxSize` is 10,000 (was `Infinity`). Pass `Infinity` explicitly for unbounded.
|
|
125
|
+
- Tenant manager has `maxTenants` (default 10,000) with LRU eviction.
|
|
126
|
+
- `monotonicNow()` uses `performance.now()` for durations. `Date.now()` still used for timestamps.
|
|
127
|
+
- `isActlyError(e)` realm-safe predicate. `instanceof ActlyError` only works within the same realm.
|
|
128
|
+
|
|
129
|
+
## Compatibility
|
|
130
|
+
|
|
131
|
+
| | v1.2.0 | v1.3.0 |
|
|
132
|
+
|---|---|---|
|
|
133
|
+
| Node.js | 20+ | 20+ |
|
|
134
|
+
| TypeScript | 5.x | 5.x |
|
|
135
|
+
| Module formats | ESM + CJS | ESM + CJS |
|
|
136
|
+
| Dependencies | Zero | Zero |
|
|
137
|
+
| Breaking changes | - | 6 |
|
|
138
|
+
|
|
139
|
+
## Need help
|
|
140
|
+
|
|
141
|
+
Open an issue at <https://github.com/albytehq/actly/issues> with the `migration` label. Include the code that broke, the error message, and the version you're upgrading from.
|