cmdx 2.2.0 → 2.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f8adbc69a3e91974b801b1f11c404a12b12bdcfcfd3f3d947a8cb3c730b7a934
4
- data.tar.gz: e0daecd4316350294630dc58f1a05bf1c0fde6203360981b156e468504108a29
3
+ metadata.gz: 140f9e96a60ea339e1fd9c95d26e4d06569a1286deab7cfa9851d97c84f69184
4
+ data.tar.gz: 2923f0db4e2bc30849fb703e9b614610d1b75361b944ab845b98bf41c2ad1d4f
5
5
  SHA512:
6
- metadata.gz: 93022dbeef63aab15d43cc0902d97dc0e0e41aa72edfc2ecc00d57b7d103e0a2518271b4fa5f0cd0f4e037d39d9bda5a2c07210c24a76996bfbc830785f521fb
7
- data.tar.gz: 57a130066332c5bc60645c3dff28eb5909711c18eeb54d304961ba4787543d270773e52161f5a95dca988a971972d80de18547c1f2d59a5e6cd7f39342f28c69
6
+ metadata.gz: f2c85ae86fcc8f920e9685a2a9a99c00f16f794ffec743fed1b3def2355e1058b20b763ea03347a8d8a7e15d52211f661a3b33580043f1a1354f878c0205c1b6
7
+ data.tar.gz: 7b4396cf1ed6d3e5eadb2cb388c5ac01c8f5c8874b9d8ca6b0e0539f18c19081447555fa21d178def486248c17756b88150cece92d1c92411e80bc2d778f30a6
data/CHANGELOG.md CHANGED
@@ -6,7 +6,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## UNRELEASED
8
8
 
9
- ## [2.2.0] - UNRELEASED
9
+ ## [2.4.0] - 2026-10-22
10
+
11
+ ### Added
12
+ - `Task.rate_limit` — admission control via `CMDx::Stores` once per `execute` (retries do not consume extra quota); built-in `:fixed_window` and `:leaky_bucket` limiters with `CMDx::Limiters` registry (register custom strategies globally or with `register :limiter`); `on_limit:` `:fail` / `:skip`, `if` / `unless` gates; runs after input resolution and before `circuit_breaker` in `Runtime`
13
+ - Telemetry events `:rate_limit_admitted` and `:rate_limit_exceeded` for rate-limited tasks (gated by `Telemetry#subscribed?` like other store-backed helpers)
14
+
15
+ ### Changed
16
+ - `Stores::Redis` — default `namespace` is now `""` so Redis keys match logical store keys (for example `cmdx:idem:MyTask:segment`); set `namespace` explicitly when sharing a Redis instance with non-CMDx data (previously defaulted to `cmdx:store`)
17
+ - Move integration recipes from `examples/` into published docs under `docs/cookbook/` with a dedicated **Cookbook** site tab
18
+
19
+ ## [2.3.0] - 2026-10-20
20
+
21
+ ### Added
22
+ - `Task.lock_with` — distributed lease via `CMDx::Stores` so only one worker runs the same logical key at a time; optional `wait:` polling, `on_busy:` `:fail` / `:skip`, and `if` / `unless` gates; wraps `idempotent_by` and the `retry_on` loop in `Runtime`; pass `false` on a subclass to disable
23
+ - `CMDx::Stores` — `get` / `set` / `incr` / `del` with optional TTL, delegating to `CMDx.configuration.store`; ships with `Stores::Memory` (default) and `Stores::Redis` (Redis-compatible client + namespace prefix)
24
+ - `Task.idempotent_by` — declare a stable key from context (or a callable) so duplicate invocations short-circuit via `CMDx::Stores` before `work` runs; releases the claim on failure so `retry_on` can retry safely; pass `false` on a subclass to disable
25
+ - `Task.circuit_breaker` — shared failure counter and open flag via `CMDx::Stores` so repeated `StandardError` outcomes open the circuit until `cool_off` elapses; wraps `idempotent_by` and the `retry_on` loop in `Runtime`; pass `false` on a subclass to disable
26
+ - `Task.retry_on` — pass `false` on a subclass to disable inherited retries (`Retry.disabled`, `Retry#enabled?`)
27
+ - Telemetry events for `lock_with`, `idempotent_by`, and `circuit_breaker` — `:lock_acquired`, `:lock_busy`, `:idempotency_claimed`, `:idempotency_completed`, `:idempotency_duplicate`, `:circuit_open`, `:circuit_tripped`, and `:circuit_reset` (gated by `Telemetry#subscribed?` like lifecycle events); `Telemetry.emit` centralizes dispatch from `Runtime`, `Pipeline`, and store-backed helpers
28
+
29
+ ## [2.2.0] - 2026-10-17
10
30
 
11
31
  ### Added
12
32
  - Post-work lifecycle callbacks (`after_execution`, every `on_*` hook) receive the finalized `Signal` as their second argument — `reason`, `cause`, `metadata`, `origin`, and `backtrace` are available before `Result` construction. Pre-work hooks and `around_execution` keep the single-`task` arity
data/TODO.md ADDED
@@ -0,0 +1,353 @@
1
+ # CMDx roadmap (ideas)
2
+
3
+ Items here are **exploratory roadmap ideas**, not shipped features or release commitments. Names, APIs, and layering may change as designs are validated against real apps and benchmarks.
4
+
5
+ Use this file to track **what we might build next** and **why**. When work starts on an item, link a design note or issue and move status toward *in progress* / *done* in the changelog instead of treating this doc as the source of truth for behavior.
6
+
7
+ ---
8
+
9
+ ## Infrastructure primitives
10
+
11
+ ### Serializers (`config.serializers` + registry)
12
+
13
+ **Status:** Idea (foundational — intended before or alongside cache and checkpoint)
14
+ **Theme:** One generic encode/decode layer for anything that persists Ruby-ish data through `CMDx::Stores` or job payloads.
15
+
16
+ #### Problem
17
+
18
+ `CMDx::Stores` only speaks **strings** (see [Stores](docs/stores.md)). Features that need to round-trip structured state—workflow checkpoints, cached task outputs, large context snapshots—must agree on:
19
+
20
+ - **Format** (JSON, MessagePack, compressed JSON, app-specific encryption),
21
+ - **Versioning** (blob schema when task classes or context keys change),
22
+ - **Safety** (what types may appear in a payload; no accidental `Marshal` of arbitrary objects),
23
+ - **Failure modes** (corrupt blob, unknown serializer id, decode errors).
24
+
25
+ Today, `Context#to_json` and `Result#to_h` help with logging and introspection, but they are not a **contract** for store-backed persistence. Cache and checkpoint would otherwise each invent their own envelope and error types.
26
+
27
+ #### Intended function
28
+
29
+ A small **serializer framework** parallel to existing registries (`coercions`, `validators`, `executors`, `mergers`):
30
+
31
+ 1. **Global config** — `CMDx.configure { |c| c.serializers = … }` with a default registry instance (and `CMDx.reset_configuration!` restores it in tests, same hierarchy rules as other registries).
32
+ 2. **Registry** — `CMDx::Serializers` (name TBD) maps **symbols** to serializer objects that implement a narrow contract, e.g. `#dump(object) → String` and `#load(string) → object` (exact names aligned with Ruby conventions).
33
+ 3. **Built-ins** — at minimum a **JSON** serializer that produces UTF-8 strings safe for Redis/memory adapters; optional built-ins (e.g. compressed JSON, MessagePack) only if they stay stdlib/light-deps friendly or live behind optional requires.
34
+ 4. **Registration** — `CMDx.serializers.register :my_app, MySerializer` or `config.serializers.register(...)` so apps can plug encryption, Avro, or “store id in SQL, blob in S3” wrappers without forking core.
35
+ 5. **Envelope** — higher-level helpers (not necessarily exposed to every user) wrap domain objects in a **versioned document**, e.g. serializer id, format version, task/workflow identity, payload — so cache and checkpoint share one on-the-wire shape and telemetry can record `serializer:` and `bytes:`.
36
+ 6. **Resolution order** — feature-level option → task/workflow `settings` → global default (same layering story as loggers and middlewares).
37
+ 7. **Explicit boundaries** — serializers used for **persistence** should default to **JSON-safe** data (Hash-like structures with string keys, scalars, arrays); opt-in hooks for types that implement a CMDx protocol (e.g. `#as_cmdx_payload` / `.from_cmdx_payload`) instead of implicit `as_json` on every value.
38
+
39
+ #### Proposed API (sketch)
40
+
41
+ ```ruby
42
+ # config/initializers/cmdx.rb
43
+ CMDx.configure do |config|
44
+ config.serializers = CMDx::Serializers.new
45
+ config.serializers.register :json, CMDx::Serializers::JSON.new
46
+ config.serializers.register :encrypted_json, MyApp::EncryptedJsonSerializer.new
47
+ config.default_serializer = :json
48
+ end
49
+
50
+ # Lookup
51
+ blob = CMDx.serializers[:json].dump({ "v" => 1, "outputs" => { "rate" => "1.02" } })
52
+ data = CMDx.serializers[:json].load(blob)
53
+
54
+ # Feature override (cache / checkpoint / future store features)
55
+ class FetchExchangeRate < ApplicationTask
56
+ cache_result key: :pair, ttl: 300, serializer: :json
57
+ end
58
+
59
+ class FulfillmentWorkflow < CMDx::Task
60
+ include CMDx::Workflow
61
+ settings checkpoint_serializer: :encrypted_json
62
+ end
63
+ ```
64
+
65
+ #### Design notes
66
+
67
+ - **Not idempotency values:** Idempotency store entries (`processing` / `completed`) stay tiny opaque strings; serializers target **payload blobs**, not lease tokens.
68
+ - **Size limits:** Registry or envelope may expose `max_bytes` / compression hints; adapters can refuse writes before hitting Redis limits.
69
+ - **Corruption:** `#load` raises a dedicated error (subclass of `CMDx::Error` or similar) so cache/checkpoint can treat decode failure as a **miss** or **resume from scratch**, with telemetry `:serializer_decode_failed`.
70
+ - **Testing:** In-memory store + round-trip helpers in spec support (`dump_load_roundtrip`) keep features from re-testing JSON edge cases.
71
+ - **Job adapters:** The same registry can validate enqueue payloads (`serializers[:json].dump(context_hash)` must succeed) so background-job and store features share one definition of “serializable context.”
72
+
73
+ #### Open questions
74
+
75
+ - Single `CMDx::Serializers::JSON` vs reusing `Context#to_json` / custom key normalization (symbols → strings)?
76
+ - Built-in **allowlist** of context keys per task (`persist: %i[rate user_id]`) enforced at dump time?
77
+ - Whether envelope lives in **core** or in a `CMDx::Persistence::Envelope` module shared by cache/checkpoint only?
78
+ - Compression as a serializer decorator vs separate `:gzip_json` registration?
79
+
80
+ #### Related
81
+
82
+ - [Configuration](docs/configuration.md) — pattern for global registries
83
+ - [Stores](docs/stores.md) — string values, TTL
84
+ - `CMDx::Context#to_json`, `CMDx::Result#to_h` — logging/introspection, not persistence contract
85
+ - `cache_result` and **Checkpoint / resume** (below) — primary consumers
86
+
87
+ ---
88
+
89
+ ### `CMDx::Cache` / `cache_result`
90
+
91
+ **Status:** Idea
92
+ **Theme:** Cross-process memoization of successful task runs (read-through cache), distinct from idempotency.
93
+
94
+ #### Problem
95
+
96
+ Many tasks are **pure or expensive-but-deterministic** for a given input set: geocoding, FX rates, permission snapshots, third-party “get user” calls. Today you either:
97
+
98
+ - Recompute on every `execute`, or
99
+ - Hand-roll caching in `#work` with Redis/Memcached and inconsistent key/TTL/error semantics.
100
+
101
+ `idempotent_by` solves **duplicate side effects** (at-most-once *execution*), not **reuse of a prior successful result** when you are happy to skip `work` entirely and return stored outputs.
102
+
103
+ #### Intended function
104
+
105
+ A declarative helper (working name `cache_result`, backed by something like `CMDx::Cache`) that:
106
+
107
+ 1. Runs **after** input resolution (same general placement as idempotency/locks in `Runtime`) so keys are built from validated, coerced inputs and context.
108
+ 2. Computes a **stable cache key** from the task class and a user-defined segment (symbol on context, proc, etc.—same ergonomics as `idempotent_by` / `lock_with`).
109
+ 3. On **cache hit** with a valid, non-expired entry: short-circuit before `#work`, hydrate `context` / declared **outputs** from the stored payload, and finish with a successful result (telemetry should distinguish *cached* vs *executed*).
110
+ 4. On **miss**: run the normal lifecycle (`work`, output verification, etc.). On **success only**, **dump** a snapshot of the return surface (outputs and any agreed context keys) through the configured **serializer** into `CMDx::Stores` with a TTL.
111
+ 5. On **failure** (`fail!`, `skip!`, raised errors): do **not** write or refresh the cache entry (failed runs must not poison the cache).
112
+
113
+ #### Proposed API (sketch)
114
+
115
+ ```ruby
116
+ class FetchExchangeRate < ApplicationTask
117
+ cache_result key: ->(t) { "#{t.base_currency}:#{t.quote_currency}:#{t.as_of}" },
118
+ ttl: 300,
119
+ namespace: "cmdx:cache",
120
+ serializer: :json
121
+
122
+ required :base_currency, :quote_currency
123
+ required :as_of, coerce: :date
124
+
125
+ output :rate
126
+
127
+ def work
128
+ context.rate = ExternalRates.fetch(base_currency, quote_currency, as_of)
129
+ end
130
+ end
131
+ ```
132
+
133
+ #### Design notes
134
+
135
+ - **Shared store layer:** Reuse `CMDx::Stores` (Redis/Memory adapters, TTL, namespacing) so cache entries behave like idempotency and circuit-breaker keys in multi-worker deployments.
136
+ - **Similar machinery to checkpoint (below):** Key composition, TTL, conditional writes, and telemetry hooks should follow the same patterns as `Idempotency` and `Lock`—one implementation style for “store-backed runtime primitives.”
137
+ - **Serializers registry:** Use `config.serializers` (see above) for dump/load; cache owns *what* to persist (outputs, optional context keys), not *how* to encode bytes.
138
+ - Must respect `output` declarations and avoid caching secrets unless the app opts in (`serializer:` / allowlists).
139
+ - **Interaction with `idempotent_by`:** A duplicate *invocation* might still be an idempotency skip while cache is a separate concern; ordering in `Runtime` and docs need to spell out combined behavior (e.g. idempotency claim first, then cache read inside the claim).
140
+
141
+ #### Open questions
142
+
143
+ - Cache **invalidation** API (manual `del`, version in key, `if:` / `unless:` gates)?
144
+ - Should cache hits **run callbacks** (`on_executed`, telemetry subscribers) or a lighter “cache_hit” event only?
145
+ - Stale-while-revalidate or single-flight deduplication under load?
146
+
147
+ #### Related
148
+
149
+ - [Stores](docs/stores.md) — planned home for cache and checkpoint bytes
150
+ - [Idempotency](docs/idempotency.md) — duplicate execution, not result reuse
151
+ - `CMDx::Idempotency` — reference for store key layout and `Runtime` wrapping
152
+ - **Serializers** (above) — shared encode/decode for cache entries
153
+
154
+ ---
155
+
156
+ ## Tasks
157
+
158
+ ### `execute_async` (non-blocking execution)
159
+
160
+ **Status:** Idea
161
+ **Theme:** Run a task without blocking the caller; observe completion via a future-like handle.
162
+
163
+ #### Problem
164
+
165
+ `Task.execute` / `execute!` are **synchronous**: the caller blocks until `Runtime` finishes, including retries, locks, and parallel workflow children. That is correct for HTTP requests and scripts, but awkward when you want to:
166
+
167
+ - Kick off work and continue in the same process (UI thread, event loop, `Async` integration),
168
+ - Compose multiple tasks concurrently without manually managing threads/fibers,
169
+ - Attach timeouts or cancellation at the call site without pushing that into every task.
170
+
171
+ Apps today wrap `execute` in `Thread.new`, custom executors, or job queues—losing consistent telemetry, chain semantics, and error surfaces.
172
+
173
+ #### Intended function
174
+
175
+ An async entry point (name TBD, e.g. `execute_async`) that:
176
+
177
+ 1. Schedules the same **full** `Runtime` lifecycle on a configurable executor (thread pool, fiber scheduler, or injected backend).
178
+ 2. Returns immediately with a **future-like object** that can:
179
+ - Block with `value` / `wait` (raising under `execute!`-equivalent strictness),
180
+ - Poll `ready?` / `failed?`,
181
+ - Expose the frozen `CMDx::Result` when complete,
182
+ - Optionally propagate **correlation** into the child execution’s chain/telemetry.
183
+ 3. Preserves existing semantics: middlewares, callbacks, idempotency, locks, circuit breaker, and output verification run unchanged inside the async run.
184
+ 4. Documents interaction with **fiber-local** `Chain` (child run should not corrupt the parent’s chain; likely a fresh chain root unless explicitly linked).
185
+
186
+ #### Proposed API (sketch)
187
+
188
+ ```ruby
189
+ future = ProcessUpload.execute_async(file_id: upload.id)
190
+ # ... do other work ...
191
+ result = future.wait # blocks
192
+ result = future.wait(timeout: 5) # TBD
193
+
194
+ ProcessUpload.execute_async(file_id: upload.id).then { |r| notify(r) } # TBD callback style
195
+ ```
196
+
197
+ #### Design notes
198
+
199
+ - Not a replacement for **distributed** background jobs (see next section); optimized for **in-process** concurrency and testability.
200
+ - Strict vs non-strict should mirror `execute` / `execute!` on the future.
201
+ - Error objects raised on `wait` should match today’s `Fault` / exception story.
202
+
203
+ #### Open questions
204
+
205
+ - Default executor vs explicit `executor:` on each call vs global config?
206
+ - Cancellation API (kill thread vs cooperative cancel token into `work`)?
207
+ - Integration with workflow `:parallel` groups—shared pool or separate?
208
+
209
+ #### Related
210
+
211
+ - `CMDx::Runtime` — lifecycle to schedule unchanged
212
+ - [Workflows – parallel groups](docs/workflows.md) — existing in-pipeline concurrency
213
+ - Blog: [Background Jobs + CMDx](docs/blog/posts/real-world-cmdx-background-jobs.md) — when async leaves the process
214
+
215
+ ---
216
+
217
+ ### Background-job adapter (`perform_async` / `perform_in` / `perform_at`)
218
+
219
+ **Status:** Idea
220
+ **Theme:** First-class enqueue helpers for Sidekiq, ActiveJob, GoodJob, etc., with safe serialization at the boundary.
221
+
222
+ #### Problem
223
+
224
+ Production apps almost always run CMDx at the **edge of a job runner**. The recommended pattern today is a thin job class that calls `execute!`—which works but duplicates boilerplate across apps:
225
+
226
+ - JSON-safe argument hashes (string keys),
227
+ - Correlation id handoff across processes,
228
+ - Mapping permanent `fail!` outcomes to “do not retry” vs transient errors,
229
+ - Optional `idempotent_by` keys derived from job arguments.
230
+
231
+ Self-including `Sidekiq::Job` on the task class (documented in the blog) blurs “domain task” and “infrastructure adapter” and makes testing/no-Sidekiq environments heavier.
232
+
233
+ #### Intended function
234
+
235
+ Optional adapter layer (per backend or a small family of modules) that:
236
+
237
+ 1. Exposes familiar enqueue methods on the task class, e.g. `perform_async`, `perform_in`, `perform_at` (exact names aligned with the target gem where possible).
238
+ 2. At **enqueue time**, validates that the payload round-trips through the configured **serializer** (default `:json`): attribute keys, primitive types, no live objects. Fail fast in development if someone passes an `ActiveRecord` instance.
239
+ 3. At **perform time**, rebuilds context from the payload and calls `execute` / `execute!` with documented retry behavior hooks (e.g. register which `metadata[:code]` values are permanent).
240
+ 4. Supports **correlation id** propagation via an optional key in the payload and a documented callback or `settings` hook so telemetry lines up across web → job → nested job.
241
+ 5. Stays **optional**: no Sidekiq gem required in core; adapters live in optional requires or companion gems if needed.
242
+
243
+ #### Proposed API (sketch)
244
+
245
+ ```ruby
246
+ class Users::SendVerification < ApplicationTask
247
+ include CMDx::Adapters::Sidekiq # name TBD
248
+
249
+ sidekiq_options queue: :mailers, retry: 5
250
+ idempotent_by :user_id
251
+
252
+ required :user_id, coerce: :integer
253
+
254
+ def work
255
+ # ...
256
+ end
257
+ end
258
+
259
+ Users::SendVerification.perform_async(user_id: 42)
260
+ Users::SendVerification.perform_in(5.minutes, user_id: 42)
261
+ ```
262
+
263
+ #### Design notes
264
+
265
+ - Enqueue surface should mirror what teams already know from Sidekiq/ActiveJob to reduce migration friction.
266
+ - Document pairing with `idempotent_by` and `lock_with` for redelivered messages and concurrent workers.
267
+ - ActiveJob wrapper might be a generator template first, then a shared module.
268
+
269
+ #### Open questions
270
+
271
+ - One meta-adapter vs per-framework gems?
272
+ - Built-in mapping from `CMDx::Fault` / `metadata[:code]` to “discard” vs “retry” without per-app `rescue`?
273
+ - Workflow steps that enqueue child jobs—official pattern vs app responsibility?
274
+
275
+ #### Related
276
+
277
+ - [Background Jobs + CMDx](docs/blog/posts/real-world-cmdx-background-jobs.md)
278
+ - [Idempotency](docs/idempotency.md), [Locks](docs/locks.md)
279
+ - [Stores](docs/stores.md) — shared state across workers
280
+ - **Serializers** (above) — shared definition of serializable job/context payloads
281
+
282
+ ---
283
+
284
+ ## Workflows
285
+
286
+ ### Checkpoint / resume
287
+
288
+ **Status:** Idea
289
+ **Theme:** Persist workflow progress so a restarted run can skip completed step groups.
290
+
291
+ #### Problem
292
+
293
+ Long-running workflows (onboarding, fulfillment, provisioning) often span **multiple processes** or survive **worker crashes**. Today a workflow is a single `Pipeline` run on one context in one process:
294
+
295
+ - If the process dies after step 3 of 5, a retry typically **re-runs from the start** unless the app manually tracks state in the database.
296
+ - Parallel groups and rollback semantics make ad hoc “save state in `#work`” easy to get wrong.
297
+
298
+ #### Intended function
299
+
300
+ Checkpointing that:
301
+
302
+ 1. Identifies the workflow run with a stable **run id** (from context, `idempotent_by`, or explicit `checkpoint_by`—design TBD).
303
+ 2. After each **execution group** completes successfully (respecting `if:` / `unless:` skips), **dumps** a versioned snapshot of workflow `context` (plus resume metadata: completed group ids, skip/fail markers) via the **serializers registry** into `CMDx::Stores` or a pluggable backend.
304
+ 3. On a subsequent `execute` with the same run id, **loads** the blob through the same serializer (or fails gracefully if the format is unknown/corrupt), **hydrates** context, and **skips** groups already marked complete, then continues from the next pending group.
305
+ 4. Handles **failure** without advancing the checkpoint; failed groups remain retryable.
306
+ 5. Defines behavior for **parallel groups** (checkpoint only after merger succeeds), **conditional groups** (record skipped vs completed distinctly), and **rollback** (whether checkpoint is cleared or branched).
307
+
308
+ #### Proposed API (sketch)
309
+
310
+ ```ruby
311
+ class FulfillmentWorkflow < ApplicationTask
312
+ include CMDx::Workflow
313
+
314
+ idempotent_by :order_id # natural pairing: same order → same logical run
315
+
316
+ checkpoint true # or checkpoint_by: :order_id, ttl: 1.day, serializer: :json
317
+
318
+ task ValidateOrder
319
+ task ReserveInventory
320
+ tasks ChargePayment, CreateShipmentLabel, strategy: :parallel
321
+ task NotifyCustomer
322
+ end
323
+ ```
324
+
325
+ #### Design notes
326
+
327
+ - **Pairs with `idempotent_by`:** Duplicate job delivery can resume the same checkpointed run instead of duplicating side effects; docs should show recommended key shapes.
328
+ - **Serializers registry:** Checkpoint blobs use the same envelope as cache (serializer id + version); large payloads may use a custom registered serializer (compression, external blob pointer).
329
+ - **Store size and TTL:** Allowlists of persistable context keys enforced at dump time; core may store pointers when the serializer returns a short reference string.
330
+ - **Security:** Checkpoint blobs may contain PII; encryption and namespace isolation are app concerns but the API should not surprise authors.
331
+ - **Observability:** Telemetry events for checkpoint write, resume, and skip-per-group aid debugging stuck workflows.
332
+
333
+ #### Open questions
334
+
335
+ - Opt-in per workflow vs per-group `checkpoint: false` for sensitive steps?
336
+ - Migration when task classes change (envelope `format_version` vs per-workflow `checkpoint_version`)?
337
+ - Interaction with saga **rollback**—truncate checkpoint or fork a new run?
338
+
339
+ #### Related
340
+
341
+ - [Workflows](docs/workflows.md) — execution groups and pipeline
342
+ - [Stores](docs/stores.md) — “(eventually) workflow checkpoints”
343
+ - `CMDx::Pipeline` — group boundaries for persistence hooks
344
+ - `cache_result` (above) — shared store primitive patterns
345
+ - **Serializers** (above) — dump/load contract for checkpoint blobs
346
+
347
+ ---
348
+
349
+ ## How to contribute to this list
350
+
351
+ 1. Add or refine a section with **problem**, **intended function**, and **open questions**—not only a one-line bullet.
352
+ 2. When prototyping, add a link to a PR or discussion at the top of that section.
353
+ 3. Ship user-facing behavior through **docs + CHANGELOG**; trim or archive roadmap text here once the feature is released.
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CMDx
4
+ # Declared via {Task.circuit_breaker}. After inputs resolve, consults shared
5
+ # store counters before the `retry_on` loop; opens after repeated failures
6
+ # and short-circuits until `cool_off` elapses.
7
+ #
8
+ # Store keys come from {StoreKey.task_key} with `class_scoped: true`, plus
9
+ # `:failures` and `:open` suffix segments (see {StoreKey.compose}).
10
+ class CircuitBreaker
11
+
12
+ OPEN = "open"
13
+ FAILURES = "failures"
14
+
15
+ class << self
16
+
17
+ # @return [CircuitBreaker] disabled configuration
18
+ def disabled
19
+ DISABLED
20
+ end
21
+
22
+ end
23
+
24
+ attr_reader :key
25
+
26
+ # @param key [Symbol, String, Proc, #call, nil] `:class` (default when
27
+ # enabling via options only), context `Symbol`, static `String`, or
28
+ # callable; `nil` disables the breaker
29
+ # @param options [Hash{Symbol => Object}]
30
+ # @option options [Integer] :threshold (5) failures before the circuit opens
31
+ # @option options [Integer] :cool_off (30) seconds the circuit stays open
32
+ # @option options [Integer] :window (60) sliding window for the failure counter
33
+ # @option options [String] :namespace ("cmdx:cb") first segment of store keys
34
+ # @option options [Symbol] :on_open (:fail) `:fail` or `:skip` when open
35
+ # @option options [Array<Class>] :on (`[StandardError]`) exception classes that
36
+ # increment the failure counter
37
+ # @option options [Symbol, Proc, #call] :if gate `(task)` before checking
38
+ # @option options [Symbol, Proc, #call] :unless gate `(task)` before checking
39
+ def initialize(key, options = EMPTY_HASH)
40
+ @key = key
41
+ @options = options.freeze
42
+ end
43
+
44
+ # @return [String]
45
+ def namespace
46
+ @options[:namespace] || "cmdx:cb"
47
+ end
48
+
49
+ # @return [Integer]
50
+ def threshold
51
+ @options[:threshold] || 5
52
+ end
53
+
54
+ # @return [Integer]
55
+ def cool_off
56
+ @options[:cool_off] || 30
57
+ end
58
+
59
+ # @return [Integer]
60
+ def window
61
+ @options[:window] || 60
62
+ end
63
+
64
+ # @return [Array<Class>]
65
+ def on_exceptions
66
+ @options.fetch(:on) { [StandardError] }.flatten
67
+ end
68
+
69
+ # @return [Symbol] `:fail` or `:skip`
70
+ def on_open
71
+ @options[:on_open] || :fail
72
+ end
73
+
74
+ # @return [Symbol, Proc, #call, nil]
75
+ def condition_if
76
+ @options[:if]
77
+ end
78
+
79
+ # @return [Symbol, Proc, #call, nil]
80
+ def condition_unless
81
+ @options[:unless]
82
+ end
83
+
84
+ # @return [Boolean]
85
+ def enabled?
86
+ !key.nil?
87
+ end
88
+
89
+ # Returns a new CircuitBreaker layering `new_options` onto the current one.
90
+ # When `new_key` is non-nil it replaces the resolver; when `new_key` is
91
+ # `nil` and only options change, the existing resolver is kept. When the
92
+ # breaker was disabled and only options are passed, the resolver becomes
93
+ # `:class`.
94
+ #
95
+ # @param new_key [Symbol, String, Proc, #call, nil]
96
+ # @param new_options [Hash{Symbol => Object}]
97
+ # @return [CircuitBreaker]
98
+ def build(new_key, new_options)
99
+ return self if new_key.nil? && new_options.empty?
100
+
101
+ merged_key =
102
+ if new_key.nil?
103
+ enabled? || new_options.empty? ? @key : StoreKey::CLASS_KEY
104
+ else
105
+ new_key
106
+ end
107
+ merged_options = @options.merge(new_options)
108
+
109
+ self.class.new(merged_key, merged_options)
110
+ end
111
+
112
+ # Runs the given block when the circuit is closed (or inactive). Records
113
+ # failures for configured exception classes and clears the failure counter
114
+ # after a successful run.
115
+ #
116
+ # @param task [Task]
117
+ # @yield invoked when the circuit is closed or the breaker is inactive
118
+ # @yieldreturn [Object] return value from the wrapped work
119
+ # @return [Object] the block's return value
120
+ # @note When the circuit is open, signals `fail!` or `skip!` on `task` —
121
+ # control does not return to the caller of {#process}.
122
+ # @raise [ArgumentError] when `on_open` is not `:fail` or `:skip`
123
+ def process(task)
124
+ return yield unless enabled?
125
+ return yield unless Util.satisfied?(condition_if, condition_unless, task)
126
+
127
+ store_key = StoreKey.task_key(task, namespace:, resolver: key, class_scoped: true)
128
+ return yield if store_key.nil?
129
+
130
+ open_key = StoreKey.compose(store_key, OPEN)
131
+ failures_key = StoreKey.compose(store_key, FAILURES)
132
+ open!(task, open_key) if circuit_open?(open_key)
133
+
134
+ begin
135
+ yield
136
+ rescue StandardError => e
137
+ record_failure(e, failures_key, open_key, task)
138
+ raise e
139
+ else
140
+ clear_failures(failures_key, task)
141
+ end
142
+ end
143
+
144
+ private
145
+
146
+ def circuit_open?(open_key)
147
+ Stores.get(open_key)
148
+ rescue StandardError
149
+ false
150
+ end
151
+
152
+ def record_failure(exception, failures_key, open_key, task)
153
+ return unless counts_as_failure?(exception)
154
+
155
+ trip_open(failures_key, open_key, task)
156
+ rescue StandardError
157
+ nil
158
+ end
159
+
160
+ def clear_failures(failures_key, task)
161
+ cleared = Stores.del(failures_key)
162
+ Telemetry.emit(task, :circuit_reset, failures_key:) if cleared
163
+ rescue StandardError
164
+ nil
165
+ end
166
+
167
+ def counts_as_failure?(exception)
168
+ list = on_exceptions
169
+ return false if list.empty?
170
+
171
+ list.any? { |klass| exception.is_a?(klass) }
172
+ end
173
+
174
+ def trip_open(failures_key, open_key, task)
175
+ count = Stores.incr(failures_key, ttl: window)
176
+ return unless count >= threshold
177
+
178
+ Stores.set(open_key, OPEN, ttl: cool_off)
179
+ Stores.del(failures_key)
180
+
181
+ Telemetry.emit(
182
+ task,
183
+ :circuit_tripped,
184
+ circuit_breaker_key: open_key,
185
+ failures_key:,
186
+ failure_count: count,
187
+ threshold:,
188
+ cool_off:
189
+ )
190
+ end
191
+
192
+ def open!(task, open_key)
193
+ message = "circuit open"
194
+ metadata = {
195
+ code: :circuit_open,
196
+ circuit_breaker_key: open_key
197
+ }
198
+
199
+ remaining = Stores.remaining_ttl(open_key)
200
+ metadata[:retry_after] = remaining if remaining
201
+
202
+ Telemetry.emit(task, :circuit_open, **metadata, on_open:)
203
+
204
+ case on_open
205
+ when :fail
206
+ task.fail!(message, **metadata)
207
+ when :skip
208
+ task.skip!(message, **metadata)
209
+ else
210
+ raise ArgumentError, <<~MSG.chomp
211
+ circuit_breaker on_open must be :fail or :skip (got #{on_open.inspect}).
212
+ See https://drexed.github.io/cmdx/circuit_breaker/
213
+ MSG
214
+ end
215
+ end
216
+
217
+ DISABLED = new(nil).freeze
218
+ private_constant :DISABLED
219
+
220
+ end
221
+ end
@@ -10,7 +10,7 @@ module CMDx
10
10
  class Configuration
11
11
 
12
12
  attr_accessor :middlewares, :callbacks, :coercions, :validators, :executors,
13
- :mergers, :retriers, :deprecators, :telemetry, :correlation_id, :default_locale,
13
+ :mergers, :retriers, :limiters, :deprecators, :telemetry, :store, :correlation_id, :default_locale,
14
14
  :strict_context, :backtrace_cleaner, :log_exclusions, :log_formatter,
15
15
  :log_level, :logger
16
16
 
@@ -22,8 +22,10 @@ module CMDx
22
22
  @executors = Executors.new
23
23
  @mergers = Mergers.new
24
24
  @retriers = Retriers.new
25
+ @limiters = Limiters.new
25
26
  @deprecators = Deprecators.new
26
27
  @telemetry = Telemetry.new
28
+ @store = Stores::Memory.new
27
29
 
28
30
  @correlation_id = nil
29
31
  @default_locale = "en"
@@ -85,6 +87,7 @@ module CMDx
85
87
  @executors
86
88
  @mergers
87
89
  @retriers
90
+ @limiters
88
91
  @deprecators
89
92
  @telemetry
90
93
  ].freeze
@@ -25,14 +25,14 @@ module CMDx
25
25
  # @raise [DeprecationError] when `value` is `:error`
26
26
  # @raise [ArgumentError] when `value` is an unsupported type
27
27
  def execute(task)
28
- return if @value.nil?
29
- return unless Util.satisfied?(@options[:if], @options[:unless], task)
28
+ return if @value.nil? || !Util.satisfied?(@options[:if], @options[:unless], task)
30
29
 
31
30
  yield
32
31
 
33
32
  case @value
34
33
  when Symbol
35
34
  registry = deprecators_registry(task)
35
+
36
36
  if registry.key?(@value)
37
37
  registry.lookup(@value).call(task)
38
38
  else