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 +4 -4
- data/CHANGELOG.md +21 -1
- data/TODO.md +353 -0
- data/lib/cmdx/circuit_breaker.rb +221 -0
- data/lib/cmdx/configuration.rb +4 -1
- data/lib/cmdx/deprecation.rb +2 -2
- data/lib/cmdx/idempotency.rb +171 -0
- data/lib/cmdx/limiters/fixed_window.rb +30 -0
- data/lib/cmdx/limiters/leaky_bucket.rb +75 -0
- data/lib/cmdx/limiters.rb +107 -0
- data/lib/cmdx/lock.rb +192 -0
- data/lib/cmdx/pipeline.rb +1 -9
- data/lib/cmdx/rate_limit.rb +244 -0
- data/lib/cmdx/retry.rb +20 -2
- data/lib/cmdx/runtime.rb +51 -19
- data/lib/cmdx/store_key.rb +78 -0
- data/lib/cmdx/stores/memory.rb +158 -0
- data/lib/cmdx/stores/redis.rb +118 -0
- data/lib/cmdx/stores.rb +111 -0
- data/lib/cmdx/task.rb +171 -12
- data/lib/cmdx/telemetry.rb +36 -4
- data/lib/cmdx/util.rb +7 -0
- data/lib/cmdx/version.rb +1 -1
- data/lib/cmdx.rb +15 -0
- data/lib/generators/cmdx/templates/install.rb +41 -5
- data/mkdocs.yml +76 -21
- metadata +13 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 140f9e96a60ea339e1fd9c95d26e4d06569a1286deab7cfa9851d97c84f69184
|
|
4
|
+
data.tar.gz: 2923f0db4e2bc30849fb703e9b614610d1b75361b944ab845b98bf41c2ad1d4f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
data/lib/cmdx/configuration.rb
CHANGED
|
@@ -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
|
data/lib/cmdx/deprecation.rb
CHANGED
|
@@ -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
|