@gkoos/caracal 0.1.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 +5 -0
- package/LICENSE +21 -0
- package/README.md +311 -0
- package/dist/chunk-5CXDW7W6.js +202 -0
- package/dist/chunk-5CXDW7W6.js.map +1 -0
- package/dist/circuit-breaker-BSkcV0W_.d.ts +296 -0
- package/dist/fetch.d.ts +58 -0
- package/dist/fetch.js +117 -0
- package/dist/fetch.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +1065 -0
- package/dist/index.js.map +1 -0
- package/dist/postgres.d.ts +28 -0
- package/dist/postgres.js +56 -0
- package/dist/postgres.js.map +1 -0
- package/dist/redis.d.ts +59 -0
- package/dist/redis.js +549 -0
- package/dist/redis.js.map +1 -0
- package/dist/retry-BFP_k3Hg.d.ts +26 -0
- package/dist/testing/index.d.ts +45 -0
- package/dist/testing/index.js +101 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/types-Tf9T76C7.d.ts +187 -0
- package/package.json +127 -0
- package/src/adapters/fetch/adapter.ts +122 -0
- package/src/adapters/fetch/index.ts +15 -0
- package/src/adapters/fetch/retry-after.ts +111 -0
- package/src/adapters/postgres/adapter.ts +102 -0
- package/src/adapters/postgres/index.ts +7 -0
- package/src/coordination/redis/bulkhead.ts +61 -0
- package/src/coordination/redis/circuit-breaker.ts +270 -0
- package/src/coordination/redis/client.ts +78 -0
- package/src/coordination/redis/eval-script.ts +71 -0
- package/src/coordination/redis/keys.ts +32 -0
- package/src/coordination/redis/leases.ts +44 -0
- package/src/coordination/redis/scripts.ts +314 -0
- package/src/core/bulkhead.ts +336 -0
- package/src/core/circuit-breaker.ts +1066 -0
- package/src/core/index.ts +36 -0
- package/src/core/operation.ts +174 -0
- package/src/core/retry.ts +204 -0
- package/src/core/runtime.ts +123 -0
- package/src/core/scope-state-cache.ts +50 -0
- package/src/core/timeout.ts +73 -0
- package/src/core/types.ts +230 -0
- package/src/fetch.ts +17 -0
- package/src/index.ts +49 -0
- package/src/postgres.ts +9 -0
- package/src/redis.ts +8 -0
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Caracal contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="docs/caracal.svg" alt="Caracal" width="140">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# Caracal
|
|
6
|
+
|
|
7
|
+
**Scoped distributed resilience for asynchronous operations.**
|
|
8
|
+
|
|
9
|
+
Caracal is a library for wrapping asynchronous operations (like HTTP requests or database queries) with resilience policies. It provides a composable model for applying timeouts, retries, circuit breakers, and bulkheads to any operation that can be expressed as an adapter. The library is designed to work in distributed environments, allowing multiple instances of an application to **share and coordinate** their resilience policies through a shared Redis backend.
|
|
10
|
+
|
|
11
|
+
### The problem
|
|
12
|
+
|
|
13
|
+
There are many libraries for applying resilience policies to operations, but most of them are designed for a single process. In a distributed system, each replica of an application may have its own local concurrency limits and circuit breakers, which can lead to uncoordinated failures and resource exhaustion.
|
|
14
|
+
|
|
15
|
+
If 40 replicas each enforce a local concurrency limit of 20, the downstream can still receive 800 concurrent requests. If each replica maintains its own circuit breaker, you get 40 independent failure windows and 40 independent recovery probes. Caracal uses Redis to coordinate those constraints across whichever scope actually matches your failure domain: region, shard, tenant, credential, workload class, or any grouping that makes sense for your application.
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
npm install @gkoos/caracal
|
|
21
|
+
# Redis coordination (optional peer dependency for distributed policies):
|
|
22
|
+
npm install ioredis
|
|
23
|
+
# PostgreSQL adapter (optional peer dependency for database operations):
|
|
24
|
+
npm install pg
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { bulkhead, circuitBreaker, operation, retry, timeout } from "@gkoos/caracal"
|
|
29
|
+
import { createCoordinationClient, redisCoordinator, redisCircuitBreakerCoordinator } from "@gkoos/caracal/redis"
|
|
30
|
+
import { fetchAdapter } from "@gkoos/caracal/fetch"
|
|
31
|
+
import { Pool } from "pg"
|
|
32
|
+
import { postgresAdapter } from "@gkoos/caracal/postgres"
|
|
33
|
+
|
|
34
|
+
// Redis coordination - connect once, share across all policies
|
|
35
|
+
const redis = createCoordinationClient(process.env.REDIS_URL!)
|
|
36
|
+
await redis.connect()
|
|
37
|
+
|
|
38
|
+
// Circuit breaker shared across all replicas, tracked per region.
|
|
39
|
+
// Use circuitBreaker.local({ name, minimumThroughput, failureThreshold, openMs }) if you only need in-process tracking.
|
|
40
|
+
const breaker = circuitBreaker.distributed({
|
|
41
|
+
name: "partner-api",
|
|
42
|
+
coordinator: redisCircuitBreakerCoordinator(redis, { namespace: "svc:prod" }),
|
|
43
|
+
scope: (ctx) => `region:${String(ctx.metadata.region)}`,
|
|
44
|
+
minimumThroughput: 20,
|
|
45
|
+
failureThreshold: 0.5,
|
|
46
|
+
openMs: 30_000,
|
|
47
|
+
halfOpenProbes: 3,
|
|
48
|
+
onCoordinatorError: "fail-open",
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// Concurrency limit enforced across all replicas, per region.
|
|
52
|
+
// Use bulkhead.local({ name, limit, queue }) if you only need a per-process limit.
|
|
53
|
+
const capacity = bulkhead.distributed({
|
|
54
|
+
name: "partner-api",
|
|
55
|
+
coordinator: redisCoordinator(redis, { namespace: "svc:prod" }),
|
|
56
|
+
scope: (ctx) => `region:${String(ctx.metadata.region)}`,
|
|
57
|
+
limit: 20,
|
|
58
|
+
leaseMs: 30_000,
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// Fetch operation: breaker outermost, bulkhead innermost around the adapter.
|
|
62
|
+
// timeout bounds the whole retry sequence; retry drives individual attempts.
|
|
63
|
+
const fetchOp = operation({
|
|
64
|
+
name: "partner-api",
|
|
65
|
+
adapter: fetchAdapter(),
|
|
66
|
+
policies: [
|
|
67
|
+
breaker,
|
|
68
|
+
timeout({ ms: 10_000 }),
|
|
69
|
+
retry({ maxAttempts: 3, delay: (n) => 100 * 2 ** (n - 1) }),
|
|
70
|
+
capacity,
|
|
71
|
+
],
|
|
72
|
+
events: { emit: (e) => console.log(e.type, e) },
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const response = await fetchOp.execute(
|
|
76
|
+
{ url: "https://api.partner.com/orders/42" },
|
|
77
|
+
{ metadata: { region: "eu-west-2" } },
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
// PostgreSQL operation reusing the same policy instances.
|
|
81
|
+
// A distributed policy is identified by (namespace, policy name, operation
|
|
82
|
+
// name, scope), so `orders-db` gets its own breaker window and capacity
|
|
83
|
+
// budget. Reuse the same operation name to share them deliberately.
|
|
84
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
|
|
85
|
+
const dbOp = operation({
|
|
86
|
+
name: "orders-db",
|
|
87
|
+
adapter: postgresAdapter(pool),
|
|
88
|
+
policies: [breaker, retry({ maxAttempts: 2 }), timeout({ ms: 2_000 }), capacity],
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const row = await dbOp.execute(
|
|
92
|
+
{ sql: "select * from orders where id = $1", values: ["42"], replay: "safe" },
|
|
93
|
+
{ metadata: { region: "eu-west-2" } },
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
// At shutdown
|
|
97
|
+
await pool.end()
|
|
98
|
+
redis.disconnect()
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Distributed policies coordinate by `(namespace, policy name, operation name, scope)`. Two operations with different names get independent budgets and breaker windows even when they reuse the same policy instances. To share a budget or breaker deliberately, give the operations the same name - see [bulkheads](docs/bulkhead.md) and the [Redis key scheme](docs/redis.md).
|
|
102
|
+
|
|
103
|
+
## How it works
|
|
104
|
+
|
|
105
|
+
### Operations and adapters
|
|
106
|
+
|
|
107
|
+
An `operation` wraps an `adapter`: an object that declares its cancellation and replay capabilities before executing underlying work:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
import { operation, type Adapter } from "@gkoos/caracal"
|
|
111
|
+
|
|
112
|
+
const adapter: Adapter<{ id: string }, Order> = {
|
|
113
|
+
capabilities: () => ({ abort: "supported", replay: "safe" }),
|
|
114
|
+
execute: async ({ id }, context) => fetchOrder(id, { signal: context.signal }),
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const orders = operation({ name: "orders", adapter })
|
|
118
|
+
const order = await orders.execute({ id: "42" }, { metadata: { region: "eu-west-2" } })
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`abort` tells Caracal whether it can signal cancellation to the underlying work. `replay` tells retry whether a repeated attempt is safe. Both are declared explicitly by the adapter, Caracal never infers them.
|
|
122
|
+
|
|
123
|
+
### Policies
|
|
124
|
+
|
|
125
|
+
Policies are applied in array order, outermost first. The recommended ordering for a fully-configured operation is:
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
// Schematic - order only; each entry is a constructed policy instance.
|
|
129
|
+
policies: [breaker, timeout, retry, capacity]
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Each policy is described below.
|
|
133
|
+
|
|
134
|
+
#### Timeout
|
|
135
|
+
|
|
136
|
+
Bounds how long the caller waits. Local only, there is no distributed timeout.
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
import { timeout } from "@gkoos/caracal"
|
|
140
|
+
|
|
141
|
+
timeout({ ms: 5_000 })
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
If the adapter declares `abort: "supported"`, Caracal cancels the underlying work when the deadline expires. If not, the caller still receives `TimeoutError` on time, but the underlying work may continue until it naturally settles.
|
|
145
|
+
|
|
146
|
+
#### Retry
|
|
147
|
+
|
|
148
|
+
Retries adapter-classified failures. Local only, there is no distributed retry.
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
import { retry } from "@gkoos/caracal"
|
|
152
|
+
|
|
153
|
+
retry({
|
|
154
|
+
maxAttempts: 3,
|
|
155
|
+
delay: (attempt) => 100 * 2 ** (attempt - 1) * (0.5 + Math.random() * 0.5),
|
|
156
|
+
})
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
`maxAttempts` includes the initial attempt. An attempt is only retried if the adapter classifies the outcome as `retryable` and declares `replay: "safe"`. The delay function receives the attempt number (1 = first retry) and a context carrying the settled outcome, so a protocol-specific helper can pace retries from server feedback. The fetch adapter ships an opt-in `Retry-After` implementation: `retry({ maxAttempts: 3, delay: retryAfterDelay })`. See [fetch](docs/fetch.md#retry-after).
|
|
160
|
+
|
|
161
|
+
#### Bulkhead
|
|
162
|
+
|
|
163
|
+
Limits concurrent underlying adapter calls. Available as local (per-process) or distributed (shared across replicas via Redis).
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
import { bulkhead } from "@gkoos/caracal"
|
|
167
|
+
import { createCoordinationClient, redisCoordinator } from "@gkoos/caracal/redis"
|
|
168
|
+
|
|
169
|
+
// Local - each process enforces its own limit independently
|
|
170
|
+
const localCapacity = bulkhead.local({
|
|
171
|
+
name: "partner-api",
|
|
172
|
+
limit: 10,
|
|
173
|
+
queue: { limit: 50, timeoutMs: 2_000 }, // optional; default is immediate rejection
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
// Distributed - limit is shared across all replicas in the same scope
|
|
177
|
+
const sharedCapacity = bulkhead.distributed({
|
|
178
|
+
name: "partner-api",
|
|
179
|
+
coordinator: redisCoordinator(redis, { namespace: "svc:prod" }),
|
|
180
|
+
scope: (ctx) => `region:${String(ctx.metadata.region)}`,
|
|
181
|
+
limit: 20,
|
|
182
|
+
leaseMs: 30_000,
|
|
183
|
+
})
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Permits are held for the duration of the underlying adapter call only, not for the caller's wait or the retry loop. **A distributed bulkhead rejects immediately when full, there is no distributed queue**. On coordinator loss, admission always fails closed with `CoordinatorUnavailableError`.
|
|
187
|
+
|
|
188
|
+
#### Circuit breaker
|
|
189
|
+
|
|
190
|
+
Opens when the failure rate in a sliding window exceeds a threshold, blocking further attempts until a probe succeeds. Available as local (per-process) or distributed (shared across replicas via Redis).
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
import { circuitBreaker } from "@gkoos/caracal"
|
|
194
|
+
import { redisCircuitBreakerCoordinator } from "@gkoos/caracal/redis"
|
|
195
|
+
|
|
196
|
+
// Local - each process tracks its own failure window independently
|
|
197
|
+
const localBreaker = circuitBreaker.local({
|
|
198
|
+
name: "partner-api",
|
|
199
|
+
minimumThroughput: 10, // minimum observations before the breaker may open
|
|
200
|
+
failureThreshold: 0.5, // open when ≥ 50% of the window are failures
|
|
201
|
+
openMs: 10_000, // stay open for 10s before allowing a probe
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
// Distributed - failure window and probe budget are shared across all replicas in the same scope
|
|
205
|
+
const sharedBreaker = circuitBreaker.distributed({
|
|
206
|
+
name: "partner-api",
|
|
207
|
+
coordinator: redisCircuitBreakerCoordinator(redis, { namespace: "svc:prod" }),
|
|
208
|
+
scope: (ctx) => `region:${String(ctx.metadata.region)}`,
|
|
209
|
+
minimumThroughput: 20,
|
|
210
|
+
failureThreshold: 0.5,
|
|
211
|
+
openMs: 30_000,
|
|
212
|
+
halfOpenProbes: 3, // globally bounded concurrent recovery probes per scope
|
|
213
|
+
onCoordinatorError: "fail-open", // or "fail-closed"
|
|
214
|
+
})
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Place the local circuit breaker outside retry. The breaker then observes one outcome per logical execution - a transient failure that a retry recovers from never counts against the breaker. **A distributed breaker never silently falls back to local state on coordinator loss.**
|
|
218
|
+
|
|
219
|
+
The `scope` function maps each execution context to a coordination key. Everything sharing that key shares the same failure window and state:
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
scope: (ctx) => `region:${String(ctx.metadata.region)}` // one breaker per region
|
|
223
|
+
scope: (ctx) => `tenant:${String(ctx.metadata.tenantId)}` // one breaker per tenant
|
|
224
|
+
scope: () => "global" // one breaker for all replicas
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Use stable, non-secret values, scope keys are observable in the Redis keyspace.
|
|
228
|
+
|
|
229
|
+
### Events and observability
|
|
230
|
+
|
|
231
|
+
Every operation accepts an `events` sink - or an array of sinks - that receives structured events from every policy decision. Sinks are output-only and isolated: an exception in a sink cannot affect execution.
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
const op = operation({
|
|
235
|
+
name: "partner-api",
|
|
236
|
+
adapter: fetchAdapter(),
|
|
237
|
+
policies: [sharedBreaker, timeout({ ms: 5_000 }), retry({ maxAttempts: 3 }), sharedCapacity],
|
|
238
|
+
events: { emit: (event) => metrics.record(event) },
|
|
239
|
+
})
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
The full set can be found in the [Events and observability](docs/events-and-observability.md) section.
|
|
243
|
+
|
|
244
|
+
## Why not just use X, bro?
|
|
245
|
+
|
|
246
|
+
There are many libraries for applying resilience policies to operations, but most of them are designed for a single process. Caracal'
|
|
247
|
+
|
|
248
|
+
| Library / category | What it covers | Distributed aspect | Caracal difference |
|
|
249
|
+
|---|---|---|---|
|
|
250
|
+
| Polly-TS | Retry, breaker, timeout, bulkhead, rate limiting, cache, hedge, fallback; HTTP/framework integrations | Redis-backed distributed circuit breaker | Polly-TS adds Redis to one policy in an otherwise local-first, HTTP-centric library. In Caracal the policies coordinate across a user-defined scope - per region, per tenant, or any grouping - and the same model works for HTTP, PostgreSQL, or any async operation. |
|
|
251
|
+
| Breakwater | TS resilience pipeline: breaker, retry, timeout, fallback, bulkhead, rate limiter, cache, telemetry | Redis distributed circuit breaker; shared rate quota | Same pattern: Redis is an add-on to a local pipeline. Caracal makes scoped coordination the foundation, not an afterthought. |
|
|
252
|
+
| resilience4ts | Broad functional TS fault-tolerance patterns; distributed-first positioning | Distributed locks/cache and planned distributed context; project is small/early | Caracal's scope is narrower and more precise: explicit abort/replay traits, per-scope breaker and bulkhead with documented failure guarantees, and property/fuzz/multi-process integration testing. |
|
|
253
|
+
| Cockatiel / Resilience4j / Polly (.NET) | Mature, well-tested composable resilience patterns | Per-process state only | These are excellent libraries for single-process resilience. Caracal exists for the case where the constraint - capacity, health - belongs to a shared downstream, not to one replica. |
|
|
254
|
+
| Envoy / Envoy Gateway | Network-level circuit breaking, retries, concurrency limits; global rate limiting as a separate service | Circuit-breaker counters are not synchronised across Envoy processes | Envoy operates at the network layer without application semantics. Caracal works at the call site: it understands abort capability, replay safety, and lets you scope coordination to any application-defined group without a sidecar. |
|
|
255
|
+
| Redis semaphore/rate-limit libraries | Individual distributed primitives (semaphores, token buckets) | Shared Redis state | Assembling raw primitives means wiring up admission, leasing, renewal, failure handling, and observability yourself, for each policy. Caracal provides a tested, composed model with consistent semantics across policies. |
|
|
256
|
+
|
|
257
|
+
## Development
|
|
258
|
+
|
|
259
|
+
Requires Node.js 20+.
|
|
260
|
+
|
|
261
|
+
```sh
|
|
262
|
+
npm install
|
|
263
|
+
npm run check # format, lint, typecheck, build, unit tests
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
### Tests
|
|
267
|
+
|
|
268
|
+
```sh
|
|
269
|
+
npm test # unit suite (test/unit) - fast, no external dependencies
|
|
270
|
+
npm run test:property # property suite - fast-check, CARACAL_TEST_SEED for replay
|
|
271
|
+
npm run test:fuzz # fuzz suite - seeded event-history generator
|
|
272
|
+
npm run test:integration # integration suite - requires services and CARACAL_* URLs
|
|
273
|
+
npm run test:integration:cluster # cluster suite - local three-master Valkey cluster (not run in CI)
|
|
274
|
+
npm run test:all # check, then the property, fuzz, and integration suites
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Integration tests use real Valkey and PostgreSQL via Docker Compose. Each suite skips itself unless its URL is set, so starting the containers alone is not enough:
|
|
278
|
+
|
|
279
|
+
```sh
|
|
280
|
+
npm run redis:up
|
|
281
|
+
CARACAL_REDIS_URL=redis://127.0.0.1:6379 npm run test:integration
|
|
282
|
+
npm run redis:down
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
See [Testing](docs/testing.md) and [Local development](docs/development.md) for the full test environment setup, seeded replay, and benchmarks.
|
|
286
|
+
|
|
287
|
+
## Documentation
|
|
288
|
+
|
|
289
|
+
### Core
|
|
290
|
+
|
|
291
|
+
- [Core API](docs/core-api.md) - Operation, adapter, policy composition, events
|
|
292
|
+
- [Redis foundation](docs/redis.md) - Standalone and cluster clients, key scheme, security, tuning
|
|
293
|
+
|
|
294
|
+
### Policies
|
|
295
|
+
|
|
296
|
+
- [Timeout and retry](docs/timeout-and-retry.md) - Cancellation semantics, backoff, replay safety
|
|
297
|
+
- [Bulkheads](docs/bulkhead.md) - Local and distributed, lease guarantees, failure modes
|
|
298
|
+
- [Circuit breaker](docs/circuit-breaker.md) - State machine, sliding window, distributed coordination
|
|
299
|
+
- [Events and observability](docs/events-and-observability.md) - Full event reference, metrics, alerting
|
|
300
|
+
|
|
301
|
+
### Adapters
|
|
302
|
+
|
|
303
|
+
- [Fetch adapter](docs/fetch.md) - Cancellation, replay safety, response classification, streaming
|
|
304
|
+
- [PostgreSQL adapter](docs/postgres.md) - Cancellation, replay safety, SQLSTATE classification
|
|
305
|
+
|
|
306
|
+
### Contributing
|
|
307
|
+
|
|
308
|
+
- [Writing your own adapter](docs/adapter-contracts.md) - The `Adapter` interface, capabilities, and the contract test harness
|
|
309
|
+
- [Testing](docs/testing.md) - Test suites, property/fuzz testing, multi-process harness
|
|
310
|
+
- [Architecture](docs/architecture.md) - Package structure, subpath exports, tree-shaking rules
|
|
311
|
+
- [Local development](docs/development.md) - Setup, scripts, integration environments, benchmarks
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
|
|
3
|
+
// src/core/operation.ts
|
|
4
|
+
|
|
5
|
+
// src/core/runtime.ts
|
|
6
|
+
var eventSinks = /* @__PURE__ */ Symbol("caracal.eventSinks");
|
|
7
|
+
var admissionSignals = /* @__PURE__ */ new WeakMap();
|
|
8
|
+
function admissionSignal(context) {
|
|
9
|
+
const admission = admissionSignals.get(context);
|
|
10
|
+
return admission && context.signal ? AbortSignal.any([admission, context.signal]) : admission ?? context.signal;
|
|
11
|
+
}
|
|
12
|
+
function withAdmissionSignal(context, signal) {
|
|
13
|
+
const derived = attachRuntime(
|
|
14
|
+
{ ...context },
|
|
15
|
+
runtimeContext(context)[eventSinks] ?? []
|
|
16
|
+
);
|
|
17
|
+
const previous = admissionSignal(context);
|
|
18
|
+
admissionSignals.set(
|
|
19
|
+
derived,
|
|
20
|
+
previous ? AbortSignal.any([previous, signal]) : signal
|
|
21
|
+
);
|
|
22
|
+
return derived;
|
|
23
|
+
}
|
|
24
|
+
function inheritAdmission(source, target) {
|
|
25
|
+
const signal = admissionSignals.get(source);
|
|
26
|
+
if (signal) admissionSignals.set(target, signal);
|
|
27
|
+
return target;
|
|
28
|
+
}
|
|
29
|
+
function runtimeContext(context) {
|
|
30
|
+
return context;
|
|
31
|
+
}
|
|
32
|
+
function attachRuntime(values, sinks) {
|
|
33
|
+
const context = values;
|
|
34
|
+
Object.defineProperty(context, eventSinks, { value: sinks });
|
|
35
|
+
return Object.freeze(context);
|
|
36
|
+
}
|
|
37
|
+
function createExecutionContext(values, sinks) {
|
|
38
|
+
return attachRuntime({ attempt: 1, ...values }, sinks);
|
|
39
|
+
}
|
|
40
|
+
function nextAttempt(context) {
|
|
41
|
+
return inheritAdmission(
|
|
42
|
+
context,
|
|
43
|
+
attachRuntime(
|
|
44
|
+
{ ...context, attempt: context.attempt + 1 },
|
|
45
|
+
runtimeContext(context)[eventSinks] ?? []
|
|
46
|
+
)
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
function withSignal(context, signal) {
|
|
50
|
+
return inheritAdmission(
|
|
51
|
+
context,
|
|
52
|
+
attachRuntime(
|
|
53
|
+
{ ...context, signal },
|
|
54
|
+
runtimeContext(context)[eventSinks] ?? []
|
|
55
|
+
)
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
function emitRuntimeEvent(context, event) {
|
|
59
|
+
const sinks = runtimeContext(context)[eventSinks] ?? [];
|
|
60
|
+
const fullEvent = { ...event, at: Date.now(), context };
|
|
61
|
+
for (const sink of sinks) {
|
|
62
|
+
try {
|
|
63
|
+
sink.emit(fullEvent);
|
|
64
|
+
} catch {
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function createClassifier(classify) {
|
|
69
|
+
return (outcome) => {
|
|
70
|
+
if (classify === void 0) {
|
|
71
|
+
return outcome.status === "success" ? "success" : "failure";
|
|
72
|
+
}
|
|
73
|
+
return classify(outcome);
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// src/core/operation.ts
|
|
78
|
+
var summarizeSuccess = () => ({
|
|
79
|
+
status: "success",
|
|
80
|
+
value: void 0
|
|
81
|
+
});
|
|
82
|
+
var summarizeFailure = (error) => ({
|
|
83
|
+
status: "failure",
|
|
84
|
+
error
|
|
85
|
+
});
|
|
86
|
+
function immutableCapabilities(capabilities) {
|
|
87
|
+
return Object.freeze({ ...capabilities });
|
|
88
|
+
}
|
|
89
|
+
function immutableMetadata(metadata) {
|
|
90
|
+
return Object.freeze({ ...metadata ?? {} });
|
|
91
|
+
}
|
|
92
|
+
function normalizeSinks(events) {
|
|
93
|
+
if (events === void 0) {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
return "emit" in events ? [events] : events;
|
|
97
|
+
}
|
|
98
|
+
function emit(sinks, event) {
|
|
99
|
+
for (const sink of sinks) {
|
|
100
|
+
try {
|
|
101
|
+
sink.emit(event);
|
|
102
|
+
} catch {
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function validateName(name, kind) {
|
|
107
|
+
if (name.trim().length === 0) {
|
|
108
|
+
throw new Error(`${kind} name must not be empty`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function createPipeline(policies, adapter) {
|
|
112
|
+
return policies.reduceRight(
|
|
113
|
+
(next, policy) => async (context) => policy.execute(context, next),
|
|
114
|
+
adapter
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
function invokeAdapter(adapter, args, sinks) {
|
|
118
|
+
return async (context) => {
|
|
119
|
+
admissionSignal(context)?.throwIfAborted();
|
|
120
|
+
emit(sinks, { type: "attempt.started", at: Date.now(), context });
|
|
121
|
+
try {
|
|
122
|
+
const value = await adapter.execute(args, context);
|
|
123
|
+
const outcome = { status: "success", value };
|
|
124
|
+
const classification = context.classify(outcome);
|
|
125
|
+
emit(sinks, {
|
|
126
|
+
type: "attempt.settled",
|
|
127
|
+
at: Date.now(),
|
|
128
|
+
context,
|
|
129
|
+
outcome: summarizeSuccess(),
|
|
130
|
+
classification
|
|
131
|
+
});
|
|
132
|
+
return value;
|
|
133
|
+
} catch (error) {
|
|
134
|
+
const outcome = { status: "failure", error };
|
|
135
|
+
const classification = context.classify(outcome);
|
|
136
|
+
emit(sinks, {
|
|
137
|
+
type: "attempt.settled",
|
|
138
|
+
at: Date.now(),
|
|
139
|
+
context,
|
|
140
|
+
outcome: summarizeFailure(error),
|
|
141
|
+
classification
|
|
142
|
+
});
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
function operation(options) {
|
|
148
|
+
validateName(options.name, "operation");
|
|
149
|
+
for (const policy of options.policies ?? []) {
|
|
150
|
+
validateName(policy.name, "policy");
|
|
151
|
+
}
|
|
152
|
+
const policies = Object.freeze([...options.policies ?? []]);
|
|
153
|
+
const sinks = Object.freeze(normalizeSinks(options.events));
|
|
154
|
+
return Object.freeze({
|
|
155
|
+
name: options.name,
|
|
156
|
+
async execute(args, executeOptions = {}) {
|
|
157
|
+
const capabilities = options.adapter.capabilities(args);
|
|
158
|
+
const context = createExecutionContext(
|
|
159
|
+
{
|
|
160
|
+
operationName: options.name,
|
|
161
|
+
executionId: executeOptions.executionId ?? randomUUID(),
|
|
162
|
+
signal: executeOptions.signal,
|
|
163
|
+
metadata: immutableMetadata(executeOptions.metadata),
|
|
164
|
+
capabilities: immutableCapabilities(capabilities),
|
|
165
|
+
classify: createClassifier(options.adapter.classify)
|
|
166
|
+
},
|
|
167
|
+
sinks
|
|
168
|
+
);
|
|
169
|
+
const adapter = createPipeline(
|
|
170
|
+
policies.filter((policy) => policy.phase === "attempt"),
|
|
171
|
+
invokeAdapter(options.adapter, args, sinks)
|
|
172
|
+
);
|
|
173
|
+
const pipeline = createPipeline(
|
|
174
|
+
policies.filter((policy) => policy.phase !== "attempt"),
|
|
175
|
+
adapter
|
|
176
|
+
);
|
|
177
|
+
emit(sinks, { type: "execution.started", at: Date.now(), context });
|
|
178
|
+
try {
|
|
179
|
+
const value = await pipeline(context);
|
|
180
|
+
emit(sinks, {
|
|
181
|
+
type: "execution.settled",
|
|
182
|
+
at: Date.now(),
|
|
183
|
+
context,
|
|
184
|
+
outcome: summarizeSuccess()
|
|
185
|
+
});
|
|
186
|
+
return value;
|
|
187
|
+
} catch (error) {
|
|
188
|
+
emit(sinks, {
|
|
189
|
+
type: "execution.settled",
|
|
190
|
+
at: Date.now(),
|
|
191
|
+
context,
|
|
192
|
+
outcome: summarizeFailure(error)
|
|
193
|
+
});
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export { admissionSignal, emitRuntimeEvent, nextAttempt, operation, withAdmissionSignal, withSignal };
|
|
201
|
+
//# sourceMappingURL=chunk-5CXDW7W6.js.map
|
|
202
|
+
//# sourceMappingURL=chunk-5CXDW7W6.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/core/runtime.ts","../src/core/operation.ts"],"names":[],"mappings":"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","file":"chunk-5CXDW7W6.js","sourcesContent":["import type {\n Classification,\n EventSink,\n ExecutionContext,\n OperationEvent,\n Outcome,\n OutcomeClassifier,\n} from \"./types.js\"\n\nconst eventSinks = Symbol(\"caracal.eventSinks\")\nconst admissionSignals = new WeakMap<ExecutionContext, AbortSignal>()\nexport function admissionSignal(\n context: ExecutionContext,\n): AbortSignal | undefined {\n const admission = admissionSignals.get(context)\n return admission && context.signal\n ? AbortSignal.any([admission, context.signal])\n : (admission ?? context.signal)\n}\nexport function withAdmissionSignal(\n context: ExecutionContext,\n signal: AbortSignal,\n): ExecutionContext {\n const derived = attachRuntime(\n { ...context },\n runtimeContext(context)[eventSinks] ?? [],\n )\n const previous = admissionSignal(context)\n admissionSignals.set(\n derived,\n previous ? AbortSignal.any([previous, signal]) : signal,\n )\n return derived\n}\nfunction inheritAdmission(\n source: ExecutionContext,\n target: ExecutionContext,\n): ExecutionContext {\n const signal = admissionSignals.get(source)\n if (signal) admissionSignals.set(target, signal)\n return target\n}\n\ntype EventWithoutRuntimeFields = OperationEvent extends infer Event\n ? Event extends OperationEvent\n ? Omit<Event, \"at\" | \"context\">\n : never\n : never\n\ntype RuntimeExecutionContext = ExecutionContext & {\n readonly [eventSinks]: readonly EventSink[]\n}\n\nfunction runtimeContext(context: ExecutionContext): RuntimeExecutionContext {\n return context as RuntimeExecutionContext\n}\n\nfunction attachRuntime(\n values: ExecutionContext,\n sinks: readonly EventSink[],\n): ExecutionContext {\n const context = values as RuntimeExecutionContext\n Object.defineProperty(context, eventSinks, { value: sinks })\n return Object.freeze(context)\n}\n\nexport function createExecutionContext(\n values: Omit<ExecutionContext, \"attempt\">,\n sinks: readonly EventSink[],\n): ExecutionContext {\n return attachRuntime({ attempt: 1, ...values }, sinks)\n}\n\nexport function nextAttempt(context: ExecutionContext): ExecutionContext {\n return inheritAdmission(\n context,\n attachRuntime(\n { ...context, attempt: context.attempt + 1 },\n runtimeContext(context)[eventSinks] ?? [],\n ),\n )\n}\n\nexport function withSignal(\n context: ExecutionContext,\n signal: AbortSignal | undefined,\n): ExecutionContext {\n return inheritAdmission(\n context,\n attachRuntime(\n { ...context, signal },\n runtimeContext(context)[eventSinks] ?? [],\n ),\n )\n}\n\nexport function emitRuntimeEvent(\n context: ExecutionContext,\n event: EventWithoutRuntimeFields,\n): void {\n const sinks = runtimeContext(context)[eventSinks] ?? []\n const fullEvent = { ...event, at: Date.now(), context } as OperationEvent\n\n for (const sink of sinks) {\n try {\n sink.emit(fullEvent)\n } catch {\n // Observability must not modify resilience execution.\n }\n }\n}\n\nexport function createClassifier<Result>(\n classify: ((outcome: Outcome<Result>) => Classification) | undefined,\n): OutcomeClassifier {\n return (outcome) => {\n if (classify === undefined) {\n return outcome.status === \"success\" ? \"success\" : \"failure\"\n }\n\n return classify(outcome as Outcome<Result>)\n }\n}\n","import { randomUUID } from \"node:crypto\"\n\nimport {\n admissionSignal,\n createClassifier,\n createExecutionContext,\n} from \"./runtime.js\"\nimport type {\n Adapter,\n EventSink,\n EventSinks,\n ExecutionMetadata,\n Next,\n Operation,\n OperationCapabilities,\n OperationEvent,\n OperationExecuteOptions,\n OperationOptions,\n Outcome,\n Policy,\n} from \"./types.js\"\n\nconst summarizeSuccess = (): Outcome<undefined> => ({\n status: \"success\",\n value: undefined,\n})\nconst summarizeFailure = (error: unknown): Outcome<undefined> => ({\n status: \"failure\",\n error,\n})\n\nfunction immutableCapabilities(\n capabilities: OperationCapabilities,\n): OperationCapabilities {\n return Object.freeze({ ...capabilities })\n}\n\nfunction immutableMetadata(\n metadata: Readonly<Record<string, unknown>> | undefined,\n): ExecutionMetadata {\n return Object.freeze({ ...(metadata ?? {}) })\n}\n\nfunction normalizeSinks(events: EventSinks | undefined): readonly EventSink[] {\n if (events === undefined) {\n return []\n }\n\n return \"emit\" in events ? [events] : events\n}\n\nfunction emit(sinks: readonly EventSink[], event: OperationEvent): void {\n for (const sink of sinks) {\n try {\n sink.emit(event)\n } catch {\n // Observability must not modify resilience execution.\n }\n }\n}\n\nfunction validateName(name: string, kind: \"operation\" | \"policy\"): void {\n if (name.trim().length === 0) {\n throw new Error(`${kind} name must not be empty`)\n }\n}\n\nfunction createPipeline<Result>(\n policies: readonly Policy[],\n adapter: Next<Result>,\n): Next<Result> {\n return policies.reduceRight<Next<Result>>(\n (next, policy) => async (context) => policy.execute(context, next),\n adapter,\n )\n}\n\nfunction invokeAdapter<Args, Result>(\n adapter: Adapter<Args, Result>,\n args: Args,\n sinks: readonly EventSink[],\n): Next<Result> {\n return async (context) => {\n admissionSignal(context)?.throwIfAborted()\n emit(sinks, { type: \"attempt.started\", at: Date.now(), context })\n\n try {\n const value = await adapter.execute(args, context)\n const outcome: Outcome<Result> = { status: \"success\", value }\n const classification = context.classify(outcome)\n emit(sinks, {\n type: \"attempt.settled\",\n at: Date.now(),\n context,\n outcome: summarizeSuccess(),\n classification,\n })\n return value\n } catch (error) {\n const outcome: Outcome<Result> = { status: \"failure\", error }\n const classification = context.classify(outcome)\n emit(sinks, {\n type: \"attempt.settled\",\n at: Date.now(),\n context,\n outcome: summarizeFailure(error),\n classification,\n })\n throw error\n }\n }\n}\n\n/** Creates a named, protocol-agnostic operation. */\nexport function operation<Args, Result>(\n options: OperationOptions<Args, Result>,\n): Operation<Args, Result> {\n validateName(options.name, \"operation\")\n for (const policy of options.policies ?? []) {\n validateName(policy.name, \"policy\")\n }\n\n const policies = Object.freeze([...(options.policies ?? [])])\n const sinks = Object.freeze(normalizeSinks(options.events))\n\n return Object.freeze({\n name: options.name,\n async execute(\n args: Args,\n executeOptions: OperationExecuteOptions = {},\n ): Promise<Result> {\n const capabilities = options.adapter.capabilities(args)\n const context = createExecutionContext(\n {\n operationName: options.name,\n executionId: executeOptions.executionId ?? randomUUID(),\n signal: executeOptions.signal,\n metadata: immutableMetadata(executeOptions.metadata),\n capabilities: immutableCapabilities(capabilities),\n classify: createClassifier(options.adapter.classify),\n },\n sinks,\n )\n const adapter = createPipeline(\n policies.filter((policy) => policy.phase === \"attempt\"),\n invokeAdapter(options.adapter, args, sinks),\n )\n const pipeline = createPipeline(\n policies.filter((policy) => policy.phase !== \"attempt\"),\n adapter,\n )\n\n emit(sinks, { type: \"execution.started\", at: Date.now(), context })\n try {\n const value = await pipeline(context)\n emit(sinks, {\n type: \"execution.settled\",\n at: Date.now(),\n context,\n outcome: summarizeSuccess(),\n })\n return value\n } catch (error) {\n emit(sinks, {\n type: \"execution.settled\",\n at: Date.now(),\n context,\n outcome: summarizeFailure(error),\n })\n throw error\n }\n },\n })\n}\n"]}
|