cmdx 2.2.0 → 2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f8adbc69a3e91974b801b1f11c404a12b12bdcfcfd3f3d947a8cb3c730b7a934
4
- data.tar.gz: e0daecd4316350294630dc58f1a05bf1c0fde6203360981b156e468504108a29
3
+ metadata.gz: 3006157f5e5d98af8e442130f87f0427f15868e84e44db171c1f620d0f606ef5
4
+ data.tar.gz: 92e7a594a9ef6b4367bfbeb3fc71b807d7d495d9e977007af8a28b498c0730d9
5
5
  SHA512:
6
- metadata.gz: 93022dbeef63aab15d43cc0902d97dc0e0e41aa72edfc2ecc00d57b7d103e0a2518271b4fa5f0cd0f4e037d39d9bda5a2c07210c24a76996bfbc830785f521fb
7
- data.tar.gz: 57a130066332c5bc60645c3dff28eb5909711c18eeb54d304961ba4787543d270773e52161f5a95dca988a971972d80de18547c1f2d59a5e6cd7f39342f28c69
6
+ metadata.gz: 33ab98bd8eaa2d36c5f01cc790bb09ed644a1f4a01cd561545333c83059cfb1656f9349e684920f2377a99e8fe0fd5409f9e6199e8966358718f37c560cef911
7
+ data.tar.gz: 5276f8e49a7697ce9fca44f64d7bc393429b8e75636f3e8b5865a9388f14f6e96aa9c039f7c3e4efa583e9d7d5c0097a845ca4878dfd73e6a74ea76e1e5b72cf
data/CHANGELOG.md CHANGED
@@ -6,7 +6,17 @@ 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.3.0] - UNRELEASED
10
+
11
+ ### Added
12
+ - `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
13
+ - `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)
14
+ - `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
15
+ - `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
16
+ - `Task.retry_on` — pass `false` on a subclass to disable inherited retries (`Retry.disabled`, `Retry#enabled?`)
17
+ - 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
18
+
19
+ ## [2.2.0] - 2026-10-17
10
20
 
11
21
  ### Added
12
22
  - 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,234 @@
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 are `namespace:TaskName` or `namespace:TaskName:segment`, with
9
+ # `:failures` and `:open` suffix segments (see {StoreKey.compose}).
10
+ class CircuitBreaker
11
+
12
+ OPEN = "open"
13
+ FAILURES = "failures"
14
+ CLASS_KEY = :class
15
+
16
+ class << self
17
+
18
+ # @return [CircuitBreaker] disabled configuration
19
+ def disabled
20
+ DISABLED
21
+ end
22
+
23
+ end
24
+
25
+ attr_reader :key
26
+
27
+ # @param key [Symbol, String, Proc, #call, nil] `:class` (default when
28
+ # enabling via options only), context `Symbol`, static `String`, or
29
+ # callable; `nil` disables the breaker
30
+ # @param options [Hash{Symbol => Object}]
31
+ # @option options [Integer] :threshold (5) failures before the circuit opens
32
+ # @option options [Integer] :cool_off (30) seconds the circuit stays open
33
+ # @option options [Integer] :window (60) sliding window for the failure counter
34
+ # @option options [String] :namespace ("cmdx:cb") first segment of store keys
35
+ # @option options [Symbol] :on_open (:fail) `:fail` or `:skip` when open
36
+ # @option options [Array<Class>] :on (`[StandardError]`) exception classes that
37
+ # increment the failure counter
38
+ # @option options [Symbol, Proc, #call] :if gate `(task)` before checking
39
+ # @option options [Symbol, Proc, #call] :unless gate `(task)` before checking
40
+ def initialize(key, options = EMPTY_HASH)
41
+ @key = key
42
+ @options = options.freeze
43
+ end
44
+
45
+ # @return [String]
46
+ def namespace
47
+ @options[:namespace] || "cmdx:cb"
48
+ end
49
+
50
+ # @return [Integer]
51
+ def threshold
52
+ @options[:threshold] || 5
53
+ end
54
+
55
+ # @return [Integer]
56
+ def cool_off
57
+ @options[:cool_off] || 30
58
+ end
59
+
60
+ # @return [Integer]
61
+ def window
62
+ @options[:window] || 60
63
+ end
64
+
65
+ # @return [Array<Class>]
66
+ def on_exceptions
67
+ @options.fetch(:on) { [StandardError] }.flatten
68
+ end
69
+
70
+ # @return [Symbol] `:fail` or `:skip`
71
+ def on_open
72
+ @options[:on_open] || :fail
73
+ end
74
+
75
+ # @return [Symbol, Proc, #call, nil]
76
+ def condition_if
77
+ @options[:if]
78
+ end
79
+
80
+ # @return [Symbol, Proc, #call, nil]
81
+ def condition_unless
82
+ @options[:unless]
83
+ end
84
+
85
+ # @return [Boolean]
86
+ def enabled?
87
+ !key.nil?
88
+ end
89
+
90
+ # Returns a new CircuitBreaker layering `new_options` onto the current one.
91
+ # When `new_key` is non-nil it replaces the resolver; when `new_key` is
92
+ # `nil` and only options change, the existing resolver is kept. When the
93
+ # breaker was disabled and only options are passed, the resolver becomes
94
+ # `:class`.
95
+ #
96
+ # @param new_key [Symbol, String, Proc, #call, nil]
97
+ # @param new_options [Hash{Symbol => Object}]
98
+ # @return [CircuitBreaker]
99
+ def build(new_key, new_options)
100
+ return self if new_key.nil? && new_options.empty?
101
+
102
+ merged_key =
103
+ if new_key.nil?
104
+ enabled? || new_options.empty? ? @key : CLASS_KEY
105
+ else
106
+ new_key
107
+ end
108
+ merged_options = @options.merge(new_options)
109
+
110
+ self.class.new(merged_key, merged_options)
111
+ end
112
+
113
+ # Runs the given block when the circuit is closed (or inactive). Records
114
+ # failures for configured exception classes and clears the failure counter
115
+ # after a successful run.
116
+ #
117
+ # @param task [Task]
118
+ # @yield invoked when the circuit is closed or the breaker is inactive
119
+ # @yieldreturn [Object] return value from the wrapped work
120
+ # @return [Object] the block's return value
121
+ # @note When the circuit is open, signals `fail!` or `skip!` on `task` —
122
+ # control does not return to the caller of {#process}.
123
+ # @raise [ArgumentError] when `on_open` is not `:fail` or `:skip`
124
+ def process(task)
125
+ return yield unless enabled?
126
+ return yield unless Util.satisfied?(condition_if, condition_unless, task)
127
+
128
+ base = store_base(task)
129
+ return yield if base.nil?
130
+
131
+ open_key = StoreKey.compose(base, OPEN)
132
+ failures_key = StoreKey.compose(base, FAILURES)
133
+ open!(task, open_key) if circuit_open?(open_key)
134
+
135
+ begin
136
+ yield
137
+ rescue StandardError => e
138
+ record_failure(e, failures_key, open_key, task)
139
+ raise e
140
+ else
141
+ clear_failures(failures_key, task)
142
+ end
143
+ end
144
+
145
+ private
146
+
147
+ def store_base(task)
148
+ segment = StoreKey.segment(task, key, skip: CLASS_KEY)
149
+
150
+ if segment.nil?
151
+ return StoreKey.compose(namespace, task.class.name) if key == CLASS_KEY
152
+
153
+ return nil
154
+ end
155
+
156
+ StoreKey.compose(namespace, task.class.name, segment)
157
+ end
158
+
159
+ def circuit_open?(open_key)
160
+ Stores.get(open_key)
161
+ rescue StandardError
162
+ false
163
+ end
164
+
165
+ def record_failure(exception, failures_key, open_key, task)
166
+ return unless counts_as_failure?(exception)
167
+
168
+ trip_open(failures_key, open_key, task)
169
+ rescue StandardError
170
+ nil
171
+ end
172
+
173
+ def clear_failures(failures_key, task)
174
+ cleared = Stores.del(failures_key)
175
+ Telemetry.emit(task, :circuit_reset, failures_key:) if cleared
176
+ rescue StandardError
177
+ nil
178
+ end
179
+
180
+ def counts_as_failure?(exception)
181
+ list = on_exceptions
182
+ return false if list.empty?
183
+
184
+ list.any? { |klass| exception.is_a?(klass) }
185
+ end
186
+
187
+ def trip_open(failures_key, open_key, task)
188
+ count = Stores.incr(failures_key, ttl: window)
189
+ return unless count >= threshold
190
+
191
+ Stores.set(open_key, OPEN, ttl: cool_off)
192
+ Stores.del(failures_key)
193
+
194
+ Telemetry.emit(
195
+ task,
196
+ :circuit_tripped,
197
+ circuit_breaker_key: open_key,
198
+ failures_key:,
199
+ failure_count: count,
200
+ threshold:,
201
+ cool_off:
202
+ )
203
+ end
204
+
205
+ def open!(task, open_key)
206
+ message = "circuit open"
207
+ metadata = {
208
+ code: :circuit_open,
209
+ circuit_breaker_key: open_key
210
+ }
211
+
212
+ remaining = Stores.remaining_ttl(open_key)
213
+ metadata[:retry_after] = remaining if remaining
214
+
215
+ Telemetry.emit(task, :circuit_open, **metadata, on_open:)
216
+
217
+ case on_open
218
+ when :fail
219
+ task.fail!(message, **metadata)
220
+ when :skip
221
+ task.skip!(message, **metadata)
222
+ else
223
+ raise ArgumentError, <<~MSG.chomp
224
+ circuit_breaker on_open must be :fail or :skip (got #{on_open.inspect}).
225
+ See https://drexed.github.io/cmdx/circuit_breaker/
226
+ MSG
227
+ end
228
+ end
229
+
230
+ DISABLED = new(nil).freeze
231
+ private_constant :DISABLED
232
+
233
+ end
234
+ 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, :deprecators, :telemetry, :store, :correlation_id, :default_locale,
14
14
  :strict_context, :backtrace_cleaner, :log_exclusions, :log_formatter,
15
15
  :log_level, :logger
16
16
 
@@ -24,6 +24,7 @@ module CMDx
24
24
  @retriers = Retriers.new
25
25
  @deprecators = Deprecators.new
26
26
  @telemetry = Telemetry.new
27
+ @store = Stores::Memory.new
27
28
 
28
29
  @correlation_id = nil
29
30
  @default_locale = "en"
@@ -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