chrono_forge 0.9.1 โ 0.11.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 +56 -1
- data/README.md +390 -46
- data/Rakefile +4 -0
- data/cliff.toml +62 -0
- data/docs/design/per-child-commit-overhead.md +213 -0
- data/docs/fanout-scale-test.md +247 -0
- data/docs/superpowers/plans/2026-06-25-chrono_forge-dashboard.md +1748 -0
- data/docs/superpowers/plans/2026-06-25-chrono_forge-dashboard.md.tasks.json +17 -0
- data/docs/superpowers/plans/2026-06-25-composite-retry-policies.md +930 -0
- data/docs/superpowers/plans/2026-06-25-composite-retry-policies.md.tasks.json +54 -0
- data/docs/superpowers/plans/2026-06-25-reserved-kwarg-guard.md +241 -0
- data/docs/superpowers/plans/2026-06-25-reserved-kwarg-guard.md.tasks.json +12 -0
- data/docs/superpowers/plans/2026-06-26-branches-spawn-merge.md +1378 -0
- data/docs/superpowers/plans/2026-06-26-branches-spawn-merge.md.tasks.json +67 -0
- data/docs/superpowers/plans/2026-06-26-deferral-continuation-race-and-catchup.md +709 -0
- data/docs/superpowers/plans/2026-06-26-deferral-continuation-race-and-catchup.md.tasks.json +19 -0
- data/docs/superpowers/plans/2026-06-30-poller-rekick-and-eta-cadence.md +205 -0
- data/docs/superpowers/plans/2026-06-30-poller-rekick-and-eta-cadence.md.tasks.json +33 -0
- data/docs/superpowers/plans/2026-07-01-workflow-definition-dag.md +1373 -0
- data/docs/superpowers/plans/2026-07-01-workflow-definition-dag.md.tasks.json +68 -0
- data/docs/superpowers/specs/2026-06-03-unified-retry-policy-design.md +226 -0
- data/docs/superpowers/specs/2026-06-25-chrono_forge-dashboard-design.md +190 -0
- data/docs/superpowers/specs/2026-06-25-composite-retry-policies-design.md +228 -0
- data/docs/superpowers/specs/2026-06-25-reserved-kwarg-guard-design.md +169 -0
- data/docs/superpowers/specs/2026-06-25-spawn-merge-branches-design.md +468 -0
- data/docs/superpowers/specs/2026-06-26-dashboard-branch-view-design.md +142 -0
- data/docs/superpowers/specs/2026-06-26-deferral-continuation-race-and-catchup-design.md +265 -0
- data/docs/superpowers/specs/2026-07-01-workflow-definition-dag-design.md +203 -0
- data/lib/chrono_forge/branch_merge_job.rb +275 -0
- data/lib/chrono_forge/branch_probe.rb +70 -0
- data/lib/chrono_forge/cleanup.rb +6 -0
- data/lib/chrono_forge/configuration.rb +25 -0
- data/lib/chrono_forge/definition.rb +37 -0
- data/lib/chrono_forge/definition_analyzer.rb +501 -0
- data/lib/chrono_forge/execution_log.rb +6 -0
- data/lib/chrono_forge/executor/composite_retry_policy.rb +47 -0
- data/lib/chrono_forge/executor/context.rb +23 -0
- data/lib/chrono_forge/executor/lock_strategy.rb +10 -3
- data/lib/chrono_forge/executor/methods/branch.rb +185 -0
- data/lib/chrono_forge/executor/methods/continue_if.rb +15 -6
- data/lib/chrono_forge/executor/methods/durably_execute.rb +36 -26
- data/lib/chrono_forge/executor/methods/durably_repeat.rb +148 -39
- data/lib/chrono_forge/executor/methods/merge_branches.rb +84 -0
- data/lib/chrono_forge/executor/methods/wait.rb +2 -4
- data/lib/chrono_forge/executor/methods/wait_until.rb +25 -25
- data/lib/chrono_forge/executor/methods/workflow_states.rb +50 -46
- data/lib/chrono_forge/executor/methods.rb +2 -0
- data/lib/chrono_forge/executor/retry_policy.rb +111 -0
- data/lib/chrono_forge/executor.rb +241 -28
- data/lib/chrono_forge/version.rb +1 -1
- data/lib/chrono_forge/workflow.rb +10 -1
- data/lib/chrono_forge.rb +8 -0
- data/lib/generators/chrono_forge/migration_actions.rb +1 -0
- data/lib/generators/chrono_forge/templates/add_chrono_forge_parent_execution_log.rb +38 -0
- data/lib/tasks/release.rake +212 -0
- metadata +67 -4
- data/lib/chrono_forge/executor/retry_strategy.rb +0 -29
data/README.md
CHANGED
|
@@ -7,20 +7,109 @@
|
|
|
7
7
|
|
|
8
8
|
> A robust framework for building durable, distributed workflows in Ruby on Rails applications
|
|
9
9
|
|
|
10
|
-
ChronoForge
|
|
10
|
+
ChronoForge handles long-running processes, manages state, and recovers from failures in your Rails applications. Built on ActiveJob, it keeps critical business processes resilient and traceable.
|
|
11
|
+
|
|
12
|
+
Workflows are **plain Ruby**. Ordinary `if`/`else`, loops, and early returns drive the flow. There's no declarative DSL to learn and no extra service to run, which makes ChronoForge a good fit for business processes whose shape depends on runtime state: conditional branches, iteration over data, and built-in periodic tasks (`durably_repeat`).
|
|
13
|
+
|
|
14
|
+
> [!NOTE]
|
|
15
|
+
> **In production** at **achieve by Petra**, an investment platform in the Petra Group โ where it has executed over 3.6 million workflows and 32 million durable steps across scheduled payments, investment rollovers, and membership lifecycle management.
|
|
16
|
+
|
|
17
|
+
## ๐งญ Why ChronoForge
|
|
18
|
+
|
|
19
|
+
Most Rails workflow tools ask you to declare your steps up front in a DSL:
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
step :send_welcome_email
|
|
23
|
+
step :remind_of_tasks, wait: 2.days
|
|
24
|
+
step :complete_onboarding, wait: 15.days
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
That reads cleanly for a fixed, linear sequence. But many business processes branch, loop, and react to data that only exists at runtime, and a declarative schema gets awkward there. ChronoForge takes the opposite approach: **the workflow is plain Ruby, and each step is just a method.** Conditionals, iteration, early returns, and helper methods all work the way they normally do; you drive durable steps, waits, and retries inline with a few primitives like `durably_execute` and `wait_until`:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
class OnboardingWorkflow < ApplicationJob
|
|
31
|
+
prepend ChronoForge::Executor
|
|
32
|
+
|
|
33
|
+
def perform(user_id:)
|
|
34
|
+
@user_id = user_id
|
|
35
|
+
|
|
36
|
+
durably_execute :send_welcome_email
|
|
37
|
+
wait 2.days, :remind_delay
|
|
38
|
+
durably_execute :remind_of_tasks
|
|
39
|
+
wait 15.days, :onboarding_delay
|
|
40
|
+
durably_execute :complete_onboarding
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def send_welcome_email = UserMailer.welcome(@user_id).deliver_now
|
|
46
|
+
def remind_of_tasks = UserMailer.task_reminder(@user_id).deliver_now
|
|
47
|
+
def complete_onboarding = User.find(@user_id).complete_onboarding!
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
There is a trade-off, though a smaller one than it used to be. A declarative engine can show the steps a run *hasn't* reached yet; plain imperative code can't. The [definition graph](#-dashboard) closes most of that gap. It reads your `perform` with Prism (without running it) and draws the steps the run will take, with live status on each. The view is best-effort: a computed step name or a data-dependent loop collapses to one `dynamic` node. For a simple, fixed sequence ("send email, wait 2 days, send another") a declarative DSL can still read more cleanly, and that's a fine reason to use one.
|
|
52
|
+
|
|
53
|
+
### How it compares
|
|
54
|
+
|
|
55
|
+
| | ChronoForge | AJ Continuations | GenevaDrive | AcidicJob | Temporal |
|
|
56
|
+
| ---------------------------- | -------------------- | -------------------------- | ------------------ | --------------- | --------------- |
|
|
57
|
+
| Programming model | procedural (plain Ruby) | procedural (`step` blocks) | declarative DSL | declarative DSL | procedural (via SDK) |
|
|
58
|
+
| Built-in periodic tasks | โ `durably_repeat` | โ | โ | โ | โ |
|
|
59
|
+
| Parallel sub-workflows | โ `branch` / `spawn` | โ | โ | โ | โ |
|
|
60
|
+
| Pending-step visibility | โ (procedural) | โ (procedural) | โ | โ | โ (procedural) |
|
|
61
|
+
| Web dashboard | โ (free gem) | job-level (Mission Control)| paid only | โ | โ |
|
|
62
|
+
| Extra infrastructure | none (DB + ActiveJob)| none (built into Rails) | none | none | server required |
|
|
63
|
+
| Rails support | 7.1+ | 8.1+ | 7.2+ | 7.1+ | any (Ruby SDK) |
|
|
64
|
+
| License | MIT | MIT | LGPL / commercial | MIT | MIT |
|
|
65
|
+
|
|
66
|
+
<sub>Comparison reflects each project's documented features as of mid-2026, to the best of our knowledge; corrections welcome via PR.</sub>
|
|
67
|
+
|
|
68
|
+
A few deliberate choices behind that table:
|
|
69
|
+
|
|
70
|
+
- **Periodic tasks are built in.** `durably_repeat` runs a step on a schedule until a condition holds, with automatic catch-up for missed runs, so a workflow can be its own recurring job and cron-style monitor, right alongside the rest of its logic. Without built-in support, periodic behavior usually lives in a separate scheduler that you reconcile with workflow state by hand.
|
|
71
|
+
- **No extra infrastructure.** ChronoForge is a gem over your existing database and ActiveJob backend. There's no separate server or daemon to operate, unlike Temporal.
|
|
72
|
+
- **Large-scale fan-out is built in.** `branch` with `spawn`/`spawn_each` fans a workflow out into child workflows that run concurrently and join when their results are needed, streaming ActiveRecord relations in constant memory for large sets. Among the Ruby-native engines here, only ChronoForge offers this without a separate orchestration server (Temporal does, server-side).
|
|
73
|
+
- **Recovery is built into the model.** Steps are append-only history, so a crashed step leaves the workflow `stalled`, recoverable directly with `retry_later`.
|
|
74
|
+
- **A real dashboard, free.** ChronoForge's [mountable dashboard](#-dashboard) โ workflow list, step-replay timeline, a per-run **definition graph** (the steps a run will take, read from `perform`, with live status on each), context inspector, retry/unlock โ ships as a separate MIT gem.
|
|
75
|
+
- **MIT licensed.** Permissive and dependency-policy-friendly.
|
|
76
|
+
- **ActiveJob Continuations solve a narrower problem.** Rails 8.1's built-in [continuations](https://api.rubyonrails.org/classes/ActiveJob/Continuation.html) make a *single* long job survive interruptions: you wrap work in `step` blocks and track a `cursor`, and at each checkpoint the job asks the queue adapter whether it's `stopping?`, re-enqueuing to resume from the last completed step/cursor โ no gem, no tables. They deliberately stop short of being a workflow engine: there's no durable waiting on time, conditions, or external events; no periodic steps; no parallel fan-out; and no persisted, queryable history. Reach for continuations to make one big job restart-safe; reach for ChronoForge when a process spans steps that wait, recur, fan out, and need recovery and visibility. They also compose โ a ChronoForge workflow *is* ActiveJob work.
|
|
11
77
|
|
|
12
78
|
## ๐ Features
|
|
13
79
|
|
|
80
|
+
- **Plain-Ruby control flow**: Branching, loops, and iteration over runtime data, without a DSL or step registry
|
|
14
81
|
- **Durable Execution**: Automatically tracks and recovers from failures during workflow execution
|
|
82
|
+
- **Periodic tasks built in**: `durably_repeat` runs a step on an interval until a condition is met, with catch-up for missed runs. Acts as a recurring task and a cron-style monitor in one
|
|
83
|
+
- **Wait States**: Time-based waits and condition-based waiting (`wait_until`) that survive restarts
|
|
84
|
+
- **Parallel sub-workflows**: `branch` with `spawn`/`spawn_each` fans out into concurrent child workflows and joins them (`automerge` or `merge_branches`); large sets stream in constant memory. See [Branches](#-branches-parallel-sub-workflows)
|
|
15
85
|
- **State Management**: Built-in workflow state tracking with persistent context storage
|
|
16
86
|
- **Concurrency Control**: Advanced locking mechanisms to prevent parallel execution of the same workflow
|
|
17
|
-
- **Error Handling**:
|
|
87
|
+
- **Error Handling**: Error tracking with a unified, configurable [`RetryPolicy`](#-retry-policies) (including per-error-type policies)
|
|
18
88
|
- **Execution Logging**: Detailed logging of workflow steps and errors for visibility
|
|
19
|
-
- **
|
|
20
|
-
- **Database-Backed**: All workflow state is persisted to ensure durability
|
|
89
|
+
- **Database-Backed**: All workflow state is persisted to ensure durability, with no extra services to run
|
|
21
90
|
- **ActiveJob Integration**: Compatible with all ActiveJob backends, though database-backed processors (like Solid Queue) provide the most reliable experience for long-running workflows
|
|
22
91
|
- **Retention & Cleanup**: A schedulable job to prune finished workflows and the unbounded logs that periodic tasks accumulate (see [Cleanup & Retention](#-cleanup--retention))
|
|
23
92
|
|
|
93
|
+
## ๐ฅ๏ธ Dashboard
|
|
94
|
+
|
|
95
|
+
ChronoForge has a free, mountable dashboard for visibility and recovery: workflow list, step replay timeline, a per-run **definition graph** (the durable steps a workflow will run, statically parsed from `perform`, with live run status overlaid), context inspector, periodic-task health, wait-state age, and retry/unlock actions. It ships as a separate gem, `chrono_forge-dashboard`, so the core stays lean.
|
|
96
|
+
|
|
97
|
+
[](chrono_forge-dashboard/README.md#screenshots)
|
|
98
|
+
|
|
99
|
+
The per-run **definition graph**: the steps a workflow will run, read from `perform`, with the run's status shown on each node.
|
|
100
|
+
|
|
101
|
+
[](chrono_forge-dashboard/README.md#screenshots)
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
# Gemfile
|
|
105
|
+
gem "chrono_forge-dashboard"
|
|
106
|
+
|
|
107
|
+
# config/routes.rb
|
|
108
|
+
mount ChronoForge::Dashboard::Engine, at: "/chrono_forge"
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
See [`chrono_forge-dashboard`](chrono_forge-dashboard/README.md) for setup, authentication, and [more screenshots](chrono_forge-dashboard/README.md#screenshots).
|
|
112
|
+
|
|
24
113
|
## ๐ฆ Installation
|
|
25
114
|
|
|
26
115
|
Add to your application's Gemfile:
|
|
@@ -59,9 +148,9 @@ $ rails generate chrono_forge:upgrade
|
|
|
59
148
|
$ rails db:migrate
|
|
60
149
|
```
|
|
61
150
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
151
|
+
Re-running the generator is safe โ it skips any migrations you already have.
|
|
152
|
+
Fresh installs get everything from `chrono_forge:install` and don't need the
|
|
153
|
+
upgrade.
|
|
65
154
|
|
|
66
155
|
## ๐ Usage
|
|
67
156
|
|
|
@@ -136,6 +225,54 @@ class OrderProcessingWorkflow < ApplicationJob
|
|
|
136
225
|
end
|
|
137
226
|
```
|
|
138
227
|
|
|
228
|
+
### A workflow you can't flatten into a step list
|
|
229
|
+
|
|
230
|
+
The example above is linear, but most real processes aren't. Because a ChronoForge workflow is plain Ruby, branching and dynamic iteration are justโฆ branching and iteration:
|
|
231
|
+
|
|
232
|
+
```ruby
|
|
233
|
+
class OrderProcessingWorkflow < ApplicationJob
|
|
234
|
+
prepend ChronoForge::Executor
|
|
235
|
+
|
|
236
|
+
def perform(order_id:)
|
|
237
|
+
@order_id = order_id
|
|
238
|
+
|
|
239
|
+
wait_until :payment_confirmed?
|
|
240
|
+
durably_execute :validate_order
|
|
241
|
+
|
|
242
|
+
# Runtime branching: the path depends on data known only at execution time
|
|
243
|
+
if context["requires_compliance_check"]
|
|
244
|
+
durably_execute :run_compliance_review
|
|
245
|
+
wait_until :compliance_approved?, timeout: 48.hours
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Iterate over runtime data: one durable, idempotent step per item
|
|
249
|
+
context["line_item_ids"].each do |item_id|
|
|
250
|
+
context["current_item_id"] = item_id
|
|
251
|
+
durably_execute :fulfill_item, name: "fulfill_#{item_id}"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Recurring notification: nudge the customer until they confirm delivery
|
|
255
|
+
durably_repeat :send_delivery_reminder, every: 3.days, till: :delivery_confirmed?
|
|
256
|
+
|
|
257
|
+
durably_execute :complete_order
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
private
|
|
261
|
+
|
|
262
|
+
def fulfill_item
|
|
263
|
+
FulfillmentService.fulfill(@order_id, context["current_item_id"])
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def send_delivery_reminder
|
|
267
|
+
OrderMailer.delivery_reminder(@order_id).deliver_later
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# ... other condition and step methods ...
|
|
271
|
+
end
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Each `durably_execute` is checkpointed by its step name, so on resume the completed branches and items are skipped and the workflow continues where it left off. A fixed, declared list of steps can't easily express runtime branches, a loop over a runtime-sized collection, and an open-ended recurring notification.
|
|
275
|
+
|
|
139
276
|
### Core Workflow Features
|
|
140
277
|
|
|
141
278
|
#### ๐ Executing Workflows
|
|
@@ -162,14 +299,15 @@ OrderProcessingWorkflow.perform_later(
|
|
|
162
299
|
|
|
163
300
|
#### โก Durable Execution
|
|
164
301
|
|
|
165
|
-
The `durably_execute` method
|
|
302
|
+
The `durably_execute` method runs an operation with automatic retries, and skips it on replay once it has completed:
|
|
166
303
|
|
|
167
304
|
```ruby
|
|
168
305
|
# Basic execution
|
|
169
306
|
durably_execute :send_welcome_email
|
|
170
307
|
|
|
171
|
-
# With custom retry
|
|
172
|
-
durably_execute :critical_payment_processing,
|
|
308
|
+
# With a custom retry policy
|
|
309
|
+
durably_execute :critical_payment_processing,
|
|
310
|
+
retry_policy: RetryPolicy.new(max_attempts: 5)
|
|
173
311
|
|
|
174
312
|
# With custom name for tracking multiple calls to same method
|
|
175
313
|
durably_execute :upload_file, name: "profile_image_upload"
|
|
@@ -182,10 +320,10 @@ class FileProcessingWorkflow < ApplicationJob
|
|
|
182
320
|
@file_id = file_id
|
|
183
321
|
|
|
184
322
|
# This might fail due to network issues, rate limits, etc.
|
|
185
|
-
durably_execute :upload_to_s3, max_attempts: 5
|
|
323
|
+
durably_execute :upload_to_s3, retry_policy: RetryPolicy.new(max_attempts: 5)
|
|
186
324
|
|
|
187
325
|
# Process file after successful upload
|
|
188
|
-
durably_execute :generate_thumbnails, max_attempts: 3
|
|
326
|
+
durably_execute :generate_thumbnails, retry_policy: RetryPolicy.new(max_attempts: 3)
|
|
189
327
|
end
|
|
190
328
|
|
|
191
329
|
private
|
|
@@ -204,9 +342,77 @@ end
|
|
|
204
342
|
|
|
205
343
|
**Key Features:**
|
|
206
344
|
- **Idempotent**: Same operation won't be executed twice during replays
|
|
207
|
-
- **Automatic Retries**: Failed executions retry
|
|
345
|
+
- **Automatic Retries**: Failed executions retry per a unified `RetryPolicy` (exponential backoff with jitter; the step default caps at 30s over 3 attempts)
|
|
208
346
|
- **Error Tracking**: All failures are logged with detailed error information
|
|
209
|
-
- **Configurable**:
|
|
347
|
+
- **Configurable**: Pass a `retry_policy:` per call, or set a class-wide default with the `retry_policy` DSL (see [Retry Policies](#retry-policies))
|
|
348
|
+
|
|
349
|
+
#### ๐ Retry Policies
|
|
350
|
+
|
|
351
|
+
All retrying in ChronoForge goes through a single `RetryPolicy` (`ChronoForge::Executor::RetryPolicy`). It answers two questions: *should this failure be retried?* and *how long until the next attempt?*
|
|
352
|
+
|
|
353
|
+
```ruby
|
|
354
|
+
RetryPolicy.new(
|
|
355
|
+
max_attempts: 3, # cap on total attempts; nil = no count cap (bounded elsewhere)
|
|
356
|
+
base: 1, # seconds; delay of the first retry
|
|
357
|
+
cap: 30, # seconds; ceiling for a single delay
|
|
358
|
+
jitter: true, # spread retries with equal jitter
|
|
359
|
+
retry_on: nil # nil = retry any StandardError; [Classes] = only those; [] = none
|
|
360
|
+
)
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
Backoff is exponential with equal jitter, computed once at re-enqueue time (never replayed, so it stays deterministic where it matters).
|
|
364
|
+
|
|
365
|
+
**Resolution order:**
|
|
366
|
+
|
|
367
|
+
- **`durably_execute`, `durably_repeat`, workflow-level errors**: per-call `retry_policy:` โ class-level `retry_policy` default โ built-in default.
|
|
368
|
+
- **`wait_until`**: per-call `retry_policy:` โ built-in default. It deliberately does **not** inherit the class default, so a class-wide "retry everything" can't silently turn condition-evaluation bugs into retried errors.
|
|
369
|
+
|
|
370
|
+
**Built-in defaults:**
|
|
371
|
+
|
|
372
|
+
| Site | Default | Why |
|
|
373
|
+
|------|---------|-----|
|
|
374
|
+
| Steps (`durably_execute`/`durably_repeat`) | 3 attempts, cap 30s, retry any error | flaky calls fail fast |
|
|
375
|
+
| Workflow-level (uncaught errors) | 10 attempts, cap 600s, retry any error | tolerant window up to ~8.5 min (โ4 min typical w/ jitter) for transient infra errors; each retry replays the whole workflow from the top |
|
|
376
|
+
| `wait_until` condition errors | retry nothing | a raised condition is usually a bug, not transient |
|
|
377
|
+
|
|
378
|
+
**Class-wide default via the `retry_policy` DSL:**
|
|
379
|
+
|
|
380
|
+
```ruby
|
|
381
|
+
class ChargeWorkflow < ApplicationJob
|
|
382
|
+
prepend ChronoForge::Executor
|
|
383
|
+
retry_policy max_attempts: 5, base: 2, cap: 60 # applies to steps + workflow-level
|
|
384
|
+
|
|
385
|
+
def perform
|
|
386
|
+
durably_execute :charge,
|
|
387
|
+
retry_policy: RetryPolicy.new(max_attempts: 8, retry_on: [Net::OpenTimeout])
|
|
388
|
+
wait_until :settled?,
|
|
389
|
+
retry_policy: RetryPolicy.new(retry_on: [BankApiError])
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
**Composite policies (per-error budgets):**
|
|
395
|
+
|
|
396
|
+
Pass an **array** of policies to handle different error types differently. On a failure, the **first** policy whose `retry_on` matches the raised error applies, and each error type gets its **own attempt budget and backoff**:
|
|
397
|
+
|
|
398
|
+
```ruby
|
|
399
|
+
durably_execute :charge_card, retry_policy: [
|
|
400
|
+
RetryPolicy.new(retry_on: [NetworkError], max_attempts: 5), # transient: retry hard
|
|
401
|
+
RetryPolicy.new(retry_on: [RateLimitError], max_attempts: 10, base: 5), # back off longer
|
|
402
|
+
RetryPolicy.new(retry_on: [PaymentDeclinedError], max_attempts: 1), # fail fast, never retry
|
|
403
|
+
RetryPolicy.new(retry_on: nil) # catch-all (optional), keep last
|
|
404
|
+
]
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
- **Order matters**: the first matching policy wins, so list specific errors first and a catch-all (`retry_on: nil`) last. An error matched by no policy is **not retried** (fails fast).
|
|
408
|
+
- A subclass of a listed error routes to that policy and draws from its budget.
|
|
409
|
+
- Per-error counts are tracked by the policy's declared errors, so the budgets are stable even if you reorder the list.
|
|
410
|
+
- The class-level DSL accepts the same form as positional arguments (applies to steps **and** workflow-level errors):
|
|
411
|
+
|
|
412
|
+
```ruby
|
|
413
|
+
retry_policy RetryPolicy.new(retry_on: [NetworkError], max_attempts: 5),
|
|
414
|
+
RetryPolicy.new(retry_on: nil, max_attempts: 2)
|
|
415
|
+
```
|
|
210
416
|
|
|
211
417
|
#### โฑ๏ธ Wait States
|
|
212
418
|
|
|
@@ -243,11 +449,11 @@ wait_until :external_api_ready?,
|
|
|
243
449
|
timeout: 30.minutes,
|
|
244
450
|
check_interval: 1.minute
|
|
245
451
|
|
|
246
|
-
# Wait with retry on specific errors
|
|
452
|
+
# Wait with retry on specific errors raised while evaluating the condition
|
|
247
453
|
wait_until :database_migration_complete?,
|
|
248
454
|
timeout: 2.hours,
|
|
249
455
|
check_interval: 30.seconds,
|
|
250
|
-
retry_on: [ActiveRecord::ConnectionNotEstablished, Net::TimeoutError]
|
|
456
|
+
retry_policy: RetryPolicy.new(retry_on: [ActiveRecord::ConnectionNotEstablished, Net::TimeoutError])
|
|
251
457
|
|
|
252
458
|
# Complex condition example
|
|
253
459
|
def third_party_service_ready?
|
|
@@ -258,7 +464,7 @@ end
|
|
|
258
464
|
wait_until :third_party_service_ready?,
|
|
259
465
|
timeout: 1.hour,
|
|
260
466
|
check_interval: 2.minutes,
|
|
261
|
-
retry_on: [Net::TimeoutError, Net::HTTPClientException]
|
|
467
|
+
retry_policy: RetryPolicy.new(retry_on: [Net::TimeoutError, Net::HTTPClientException])
|
|
262
468
|
```
|
|
263
469
|
|
|
264
470
|
**3. Event-driven Waits (`continue_if`)**
|
|
@@ -328,7 +534,7 @@ PaymentWorkflow.perform_later("order-#{order_id}", order_id: order_id)
|
|
|
328
534
|
|
|
329
535
|
#### ๐ Periodic Tasks
|
|
330
536
|
|
|
331
|
-
|
|
537
|
+
`durably_repeat` runs periodic tasks inside a workflow. A task is scheduled at a regular interval until a condition is met, with automatic catch-up for missed executions and configurable error handling.
|
|
332
538
|
|
|
333
539
|
```ruby
|
|
334
540
|
class NotificationWorkflow < ApplicationJob
|
|
@@ -379,7 +585,7 @@ end
|
|
|
379
585
|
|
|
380
586
|
- **Idempotent Execution**: Each repetition gets a unique execution log, preventing duplicates during replays
|
|
381
587
|
- **Automatic Catch-up**: Missed executions due to downtime are automatically skipped using timeout-based fast-forwarding
|
|
382
|
-
- **
|
|
588
|
+
- **Custom Timing**: Custom start times and precise interval scheduling
|
|
383
589
|
- **Error Resilience**: Individual execution failures don't break the periodic schedule
|
|
384
590
|
- **Configurable Error Handling**: Choose between continuing despite failures or failing the entire workflow
|
|
385
591
|
|
|
@@ -390,7 +596,7 @@ durably_repeat :generate_daily_report,
|
|
|
390
596
|
every: 1.day, # Execution interval
|
|
391
597
|
till: :reports_complete?, # Stop condition
|
|
392
598
|
start_at: Date.tomorrow.beginning_of_day, # Custom start time (optional)
|
|
393
|
-
max_attempts: 5,
|
|
599
|
+
retry_policy: RetryPolicy.new(max_attempts: 5), # Retry policy per execution (default: step_default)
|
|
394
600
|
timeout: 2.hours, # Catch-up timeout (default: 1.hour)
|
|
395
601
|
on_error: :fail_workflow, # Error handling (:continue or :fail_workflow)
|
|
396
602
|
name: "daily_reports" # Custom task name (optional)
|
|
@@ -418,6 +624,21 @@ def cleanup_files(scheduled_time)
|
|
|
418
624
|
end
|
|
419
625
|
```
|
|
420
626
|
|
|
627
|
+
#### ๐ฟ Branches
|
|
628
|
+
|
|
629
|
+
When a workflow needs to fan out โ process every pending order, reconcile each region โ `branch` spawns child workflows that run concurrently and join when their results are needed:
|
|
630
|
+
|
|
631
|
+
```ruby
|
|
632
|
+
branch :reconcile, automerge: true do
|
|
633
|
+
spawn :eu, ReconcileWorkflow, region: "EU"
|
|
634
|
+
spawn_each :orders, Order.pending do |order|
|
|
635
|
+
[OrderWorkflow, { order_id: order.id }]
|
|
636
|
+
end
|
|
637
|
+
end
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
`spawn_each` streams ActiveRecord relations by primary key in constant memory, so a branch can fan out over very large sets. Join inline with `automerge: true`, or open branches without it and `merge_branches` them later after doing other work. See [Branches: parallel sub-workflows](#-branches-parallel-sub-workflows) for the full model, a worked example, and the crash-recovery caveats.
|
|
641
|
+
|
|
421
642
|
#### ๐ Workflow Context
|
|
422
643
|
|
|
423
644
|
ChronoForge provides a persistent context that survives job restarts. The context behaves like a Hash but with additional capabilities:
|
|
@@ -439,6 +660,12 @@ context.set(:total_amount, 99.99)
|
|
|
439
660
|
# Set a value only if the key doesn't already exist
|
|
440
661
|
context.set_once(:created_at, Time.current.iso8601)
|
|
441
662
|
|
|
663
|
+
# Set several values at once (alias: set_multiple)
|
|
664
|
+
context.merge(status: "processing", total_amount: 99.99, attempts: 0)
|
|
665
|
+
|
|
666
|
+
# Set several values at once, but only keys that don't already exist (alias: set_multiple_once)
|
|
667
|
+
context.merge_once(created_at: Time.current.iso8601, attempts: 0)
|
|
668
|
+
|
|
442
669
|
# Check if a key exists
|
|
443
670
|
if context.key?(:user_id)
|
|
444
671
|
# Do something with the user ID
|
|
@@ -447,7 +674,7 @@ end
|
|
|
447
674
|
|
|
448
675
|
The context supports serializable Ruby objects (Hash, Array, String, Integer, Float, Boolean, and nil) and validates types automatically.
|
|
449
676
|
|
|
450
|
-
Hash and Array values are stored as JSON, which has no symbols
|
|
677
|
+
Hash and Array values are stored as JSON, which has no symbols, so **symbol keys inside a stored hash come back as strings**:
|
|
451
678
|
|
|
452
679
|
```ruby
|
|
453
680
|
context[:totals] = { paid: 5, pending: 2 }
|
|
@@ -455,33 +682,138 @@ context[:totals] # => { "paid" => 5, "pending" => 2 }
|
|
|
455
682
|
context[:totals]["paid"] # => 5 (not context[:totals][:paid])
|
|
456
683
|
```
|
|
457
684
|
|
|
458
|
-
(The top-level context key itself is interchangeable
|
|
685
|
+
(The top-level context key itself is interchangeable: `context[:totals]` and `context["totals"]` refer to the same entry.)
|
|
459
686
|
|
|
460
|
-
Context is meant for **small working state
|
|
687
|
+
Context is meant for **small working state**: ids, flags, timestamps, and small structures used to coordinate steps. Each value is capped at **16 KB** (a `ChronoForge::Executor::Context::ValidationError` is raised above that). Store large payloads (documents, uploads, API responses) in their own storage and keep just a reference (an id or key) in the context.
|
|
461
688
|
|
|
462
689
|
### ๐ก๏ธ Error Handling
|
|
463
690
|
|
|
464
|
-
ChronoForge automatically tracks errors and
|
|
691
|
+
ChronoForge automatically tracks errors and routes all retrying through a single [`RetryPolicy`](#-retry-policies). Configure it per call with `retry_policy:`, or set a class-wide default with the `retry_policy` DSL:
|
|
465
692
|
|
|
466
693
|
```ruby
|
|
467
694
|
class MyWorkflow < ApplicationJob
|
|
468
695
|
prepend ChronoForge::Executor
|
|
469
696
|
|
|
470
|
-
|
|
697
|
+
# Class-wide default for workflow-level errors and steps without an override
|
|
698
|
+
retry_policy max_attempts: 5, base: 2, cap: 60
|
|
699
|
+
|
|
700
|
+
def perform
|
|
701
|
+
# Retry only network errors, up to 5 times, for this step
|
|
702
|
+
durably_execute :call_external_api,
|
|
703
|
+
retry_policy: RetryPolicy.new(max_attempts: 5, retry_on: [NetworkError])
|
|
704
|
+
end
|
|
705
|
+
end
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
To make an error non-retryable, leave it out of `retry_on:` (an empty `retry_on: []` retries nothing).
|
|
709
|
+
|
|
710
|
+
## ๐ฟ Branches: parallel sub-workflows
|
|
711
|
+
|
|
712
|
+
`branch` / `spawn` / `spawn_each` / `merge_branches` let a workflow fan out into
|
|
713
|
+
child workflows that run concurrently, then join them when their results are
|
|
714
|
+
needed.
|
|
715
|
+
|
|
716
|
+
### Model
|
|
471
717
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
718
|
+
- **`branch :name do โฆ end`** opens a named branch (a durable step). Inside the
|
|
719
|
+
block, `spawn` and `spawn_each` create and immediately enqueue child workflows โ
|
|
720
|
+
children start running as soon as the branch block is entered.
|
|
721
|
+
- **`spawn :name, WorkflowClass, **kwargs`** โ enqueues one child workflow.
|
|
722
|
+
- **`spawn_each :name, source do |item| [WorkflowClass, kwargs] end`** โ enqueues
|
|
723
|
+
one child per item. The block returns the class and kwargs, so one branch can
|
|
724
|
+
fan out into mixed workflow types. Sources are iterated in constant memory;
|
|
725
|
+
ActiveRecord relations are streamed by primary key โ pass them **without** an
|
|
726
|
+
explicit `.order`.
|
|
727
|
+
- **`automerge: true`** โ joins the branch **inline at the block's close**.
|
|
728
|
+
Execution does not continue past the `branch` call until every child has
|
|
729
|
+
completed. Use it for "dispatch this group and wait right here."
|
|
730
|
+
- **`merge_branches :a, :b`** (or the singular alias `merge_branch :a`) โ the
|
|
731
|
+
separate join point. Open branches without `automerge`, do other work while the
|
|
732
|
+
children run, then join when you need their results. `merge_branches` blocks
|
|
733
|
+
until all named branches are complete.
|
|
734
|
+
|
|
735
|
+
### Worked example
|
|
736
|
+
|
|
737
|
+
```ruby
|
|
738
|
+
class FulfillmentWorkflow < ApplicationJob
|
|
739
|
+
prepend ChronoForge::Executor
|
|
740
|
+
|
|
741
|
+
def perform(cycle_id:)
|
|
742
|
+
# automerge: the branch is joined inline, right where the block closes โ
|
|
743
|
+
# `perform` does not continue past it until every child has completed.
|
|
744
|
+
branch :reconcile, automerge: true do
|
|
745
|
+
spawn :eu, ReconcileWorkflow, region: "EU"
|
|
746
|
+
spawn_each :orders, Order.pending do |order|
|
|
747
|
+
order.priority? ? [PriorityOrderWorkflow, { order_id: order.id }]
|
|
748
|
+
: [OrderWorkflow, { order_id: order.id }]
|
|
749
|
+
end
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
# For branches you want to run concurrently and join later, omit automerge
|
|
753
|
+
# and use merge_branches:
|
|
754
|
+
branch :invoices do
|
|
755
|
+
spawn_each :unpaid, Invoice.unpaid do |inv|
|
|
756
|
+
[InvoiceWorkflow, { invoice_id: inv.id }]
|
|
757
|
+
end
|
|
758
|
+
end
|
|
759
|
+
branch :shipments do
|
|
760
|
+
spawn_each :ready, Shipment.ready do |s|
|
|
761
|
+
[ShipmentWorkflow, { shipment_id: s.id }]
|
|
762
|
+
end
|
|
480
763
|
end
|
|
764
|
+
do_other_work # runs while :invoices and :shipments dispatch/run
|
|
765
|
+
merge_branches :invoices, :shipments # join both here
|
|
766
|
+
|
|
767
|
+
durably_execute :finalize
|
|
481
768
|
end
|
|
482
769
|
end
|
|
483
770
|
```
|
|
484
771
|
|
|
772
|
+
### Caveats
|
|
773
|
+
|
|
774
|
+
> **Every branch must be joined.** A branch opened and never joined raises
|
|
775
|
+
> `ChronoForge::Executor::UnmergedBranchError` when the workflow tries to
|
|
776
|
+
> complete โ fail-fast, no silently-orphaned children. Use either
|
|
777
|
+
> `automerge: true` or a matching `merge_branches` call.
|
|
778
|
+
|
|
779
|
+
> **The parent isn't replayed while waiting.** A lightweight
|
|
780
|
+
> `ChronoForge::BranchMergeJob` polls for child completion; the parent workflow
|
|
781
|
+
> only runs again once the branch is fully done. Polling cadence tracks the
|
|
782
|
+
> **estimated time-to-drain** (measured from the children's completion rate), so
|
|
783
|
+
> the parent is woken within ~`min_interval` of the last child finishing rather
|
|
784
|
+
> than up to `max_interval` late; a branch that can only wait or is blocked on a
|
|
785
|
+
> failure backs off to `max_interval` (those need a wait to elapse or operator
|
|
786
|
+
> recovery, not faster polling).
|
|
787
|
+
>
|
|
788
|
+
> **Queue placement matters.** The poller is enqueued *after* the branch's
|
|
789
|
+
> children, so on a queue those children saturate it starves behind the backlog
|
|
790
|
+
> (the parent then converges up to `max_interval` late). Point it at a queue that
|
|
791
|
+
> isn't saturated by the fan-out:
|
|
792
|
+
>
|
|
793
|
+
> ```ruby
|
|
794
|
+
> ChronoForge.configure { |c| c.branch_merge_queue = :chrono_forge_pollers } # default: :default
|
|
795
|
+
> ```
|
|
796
|
+
|
|
797
|
+
> **`spawn_each` sources must re-enumerate deterministically across replays.**
|
|
798
|
+
> ActiveRecord relations are streamed by primary key (children are keyed by
|
|
799
|
+
> record id, so crash-resume is idempotent); a relation carrying an explicit
|
|
800
|
+
> `.order(...)` raises. For non-AR enumerables, items are keyed by position, so
|
|
801
|
+
> inserting or removing items mid-dispatch would shift keys and break idempotency.
|
|
802
|
+
|
|
803
|
+
> **`spawn_each` AR sources must have stable membership.** Dispatch streams by
|
|
804
|
+
> ascending primary key and resumes from the last key on crash-recovery, so a row
|
|
805
|
+
> that enters the relation *below* the cursor after it has passed (e.g. a
|
|
806
|
+
> `where(state: โฆ)` scope whose rows mutate mid-dispatch) will never get a child.
|
|
807
|
+
> Point `spawn_each` at a set that is fixed for the branch's lifetime โ a frozen id
|
|
808
|
+
> range, an append-only table, or `where(id: [...])` over a snapshot.
|
|
809
|
+
|
|
810
|
+
> **`branch` blocks cannot be lexically nested within one workflow.** Opening a
|
|
811
|
+
> `branch` inside another `branch` block raises `ArgumentError`; spawns belong to
|
|
812
|
+
> exactly one branch. (A *spawned child workflow* may open its own branches โ it
|
|
813
|
+
> runs in its own executor โ so cross-workflow nesting is fine.)
|
|
814
|
+
|
|
815
|
+
Verified correct at 500,000 children on a single Postgres instance. A follow-up commit-consolidation change halved per-child execution time. See the [scale test](docs/fanout-scale-test.md).
|
|
816
|
+
|
|
485
817
|
## ๐งช Testing
|
|
486
818
|
|
|
487
819
|
ChronoForge is designed to be easily testable using [ChaoticJob](https://github.com/fractaledmind/chaotic_job), a testing framework that makes it simple to test complex job workflows:
|
|
@@ -550,7 +882,7 @@ ChronoForge is ideal for:
|
|
|
550
882
|
|
|
551
883
|
## ๐ง Advanced State Management
|
|
552
884
|
|
|
553
|
-
ChronoForge workflows
|
|
885
|
+
ChronoForge workflows move through a state machine. Understanding these states and transitions helps with troubleshooting and recovery.
|
|
554
886
|
|
|
555
887
|
### Workflow State Diagram
|
|
556
888
|
|
|
@@ -609,8 +941,7 @@ stateDiagram-v2
|
|
|
609
941
|
|
|
610
942
|
#### Recovering Stalled/Failed Workflows
|
|
611
943
|
|
|
612
|
-
Re-execute a failed or stalled workflow directly from its record
|
|
613
|
-
constantize the job class or re-pass the key. Execution resumes via replay, so
|
|
944
|
+
Re-execute a failed or stalled workflow directly from its record. Execution resumes via replay, so
|
|
614
945
|
completed steps are skipped and it picks up at the step that failed:
|
|
615
946
|
|
|
616
947
|
```ruby
|
|
@@ -621,7 +952,7 @@ workflow.retry_now # re-run inline (console/debugging)
|
|
|
621
952
|
```
|
|
622
953
|
|
|
623
954
|
Only `stalled` or `failed` workflows are retryable. `retryable?` lets you check
|
|
624
|
-
first, and both methods **validate up front
|
|
955
|
+
first, and both methods **validate up front**: calling `retry_later`
|
|
625
956
|
on a non-retryable workflow raises `ChronoForge::Executor::WorkflowNotRetryableError`
|
|
626
957
|
immediately rather than enqueuing a job that would fail in the worker:
|
|
627
958
|
|
|
@@ -660,14 +991,14 @@ ChronoForge keeps every workflow and execution-log row indefinitely so that
|
|
|
660
991
|
replays remain idempotent. Over time two things grow without bound:
|
|
661
992
|
|
|
662
993
|
1. **Terminal workflows** (`completed` / `failed`) that are no longer needed.
|
|
663
|
-
2. **`durably_repeat` repetition logs
|
|
994
|
+
2. **`durably_repeat` repetition logs**: one row per scheduled execution. A
|
|
664
995
|
long-lived periodic workflow never reaches a terminal state, so its
|
|
665
996
|
repetition logs accumulate indefinitely. Past repetitions (those behind the
|
|
666
997
|
task's current frontier) are never read again, since each resume recomputes
|
|
667
|
-
the next execution from the coordination log
|
|
998
|
+
the next execution from the coordination log, so they are safe to prune (see
|
|
668
999
|
the safety note below).
|
|
669
1000
|
|
|
670
|
-
`ChronoForge::Cleanup` reclaims both. It is **not** run automatically
|
|
1001
|
+
`ChronoForge::Cleanup` reclaims both. It is **not** run automatically; schedule
|
|
671
1002
|
it from your own scheduler so you stay in control of retention:
|
|
672
1003
|
|
|
673
1004
|
```ruby
|
|
@@ -692,14 +1023,14 @@ Notes:
|
|
|
692
1023
|
that are both older than the window **and** scheduled strictly before the
|
|
693
1024
|
periodic task's current frontier (the coordination log's `last_execution_at`).
|
|
694
1025
|
Anything at or after the frontier is kept so `durably_repeat`'s catch-up
|
|
695
|
-
mechanism is never disrupted
|
|
1026
|
+
mechanism is never disrupted, so the window is purely a retention preference
|
|
696
1027
|
and is safe even for yearly schedules.
|
|
697
1028
|
- Workflow retention is measured from when a workflow became terminal, not when
|
|
698
|
-
it was created
|
|
1029
|
+
it was created. A long-running workflow that only just finished is kept for
|
|
699
1030
|
the full window. Completed workflows use `completed_at` (immutable); failed
|
|
700
1031
|
workflows use `updated_at` (they have no `completed_at`).
|
|
701
1032
|
- The composite `[state, completed_at]` index added in this version keeps these
|
|
702
|
-
scans efficient
|
|
1033
|
+
scans efficient; run `chrono_forge:upgrade` if you installed an earlier
|
|
703
1034
|
version.
|
|
704
1035
|
|
|
705
1036
|
A ready-made job is bundled so you can schedule it with any recurring-job
|
|
@@ -762,11 +1093,22 @@ This gem is available as open source under the terms of the [MIT License](https:
|
|
|
762
1093
|
|
|
763
1094
|
| Method | Purpose | Key Parameters |
|
|
764
1095
|
|--------|---------|----------------|
|
|
765
|
-
| `durably_execute` | Execute method with retry logic | `method`, `
|
|
1096
|
+
| `durably_execute` | Execute method with retry logic | `method`, `retry_policy: nil`, `name: nil` |
|
|
766
1097
|
| `wait` | Time-based pause | `duration`, `name` |
|
|
767
|
-
| `wait_until` | Condition-based waiting | `condition`, `timeout: 1.hour`, `check_interval: 15.minutes`, `
|
|
1098
|
+
| `wait_until` | Condition-based waiting | `condition`, `timeout: 1.hour`, `check_interval: 15.minutes`, `retry_policy: nil` |
|
|
768
1099
|
| `continue_if` | Manual continuation wait | `condition`, `name: nil` |
|
|
769
|
-
| `durably_repeat` | Periodic task execution | `method`, `every:`, `till:`, `start_at: nil`, `
|
|
1100
|
+
| `durably_repeat` | Periodic task execution | `method`, `every:`, `till:`, `start_at: nil`, `retry_policy: nil`, `timeout: 1.hour`, `on_error: :continue` |
|
|
1101
|
+
|
|
1102
|
+
### Branch Methods
|
|
1103
|
+
|
|
1104
|
+
Fan a workflow out into parallel child sub-workflows (see [Branches](#-branches-parallel-sub-workflows)).
|
|
1105
|
+
|
|
1106
|
+
| Method | Purpose | Key Parameters |
|
|
1107
|
+
|--------|---------|----------------|
|
|
1108
|
+
| `branch` | Open a named branch (takes a block) to dispatch children | `name`, `automerge: false` |
|
|
1109
|
+
| `spawn` | Enqueue one child workflow inside a branch | `name`, `workflow_class`, `**kwargs` |
|
|
1110
|
+
| `spawn_each` | Enqueue one child per item, streamed (block returns `[WorkflowClass, kwargs]`) | `name`, `source`, `of: 1000` |
|
|
1111
|
+
| `merge_branches` | Join named branches; blocks until all complete (alias `merge_branch`) | `*names`, `min_interval: 5.seconds`, `max_interval: 5.minutes` |
|
|
770
1112
|
|
|
771
1113
|
### Context Methods
|
|
772
1114
|
|
|
@@ -776,6 +1118,8 @@ This gem is available as open source under the terms of the [MIT License](https:
|
|
|
776
1118
|
| `context[:key]` | Get context value | `user_id = context[:user_id]` |
|
|
777
1119
|
| `context.set(key, value)` | Set context value (alias) | `context.set(:status, "active")` |
|
|
778
1120
|
| `context.set_once(key, value)` | Set only if key doesn't exist | `context.set_once(:created_at, Time.current)` |
|
|
1121
|
+
| `context.merge(hash)` | Set multiple values atomically (alias: `set_multiple`) | `context.merge(status: "active", count: 0)` |
|
|
1122
|
+
| `context.merge_once(hash)` | Set multiple values, skipping existing keys (alias: `set_multiple_once`) | `context.merge_once(created_at: Time.current, count: 0)` |
|
|
779
1123
|
| `context.fetch(key, default)` | Get with default value | `context.fetch(:count, 0)` |
|
|
780
1124
|
| `context.key?(key)` | Check if key exists | `context.key?(:user_id)` |
|
|
781
1125
|
|
data/Rakefile
CHANGED
|
@@ -12,3 +12,7 @@ Rake::TestTask.new do |t|
|
|
|
12
12
|
t.test_files = FileList["test/**/*_test.rb"]
|
|
13
13
|
t.verbose = true
|
|
14
14
|
end
|
|
15
|
+
|
|
16
|
+
# Release tasks (release:core:*, release:dashboard:*). Loaded after
|
|
17
|
+
# bundler/gem_tasks so it can neutralize the bare `rake release` footgun.
|
|
18
|
+
Dir.glob(File.expand_path("lib/tasks/*.rake", __dir__)).sort.each { |f| load f }
|