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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -1
  3. data/README.md +390 -46
  4. data/Rakefile +4 -0
  5. data/cliff.toml +62 -0
  6. data/docs/design/per-child-commit-overhead.md +213 -0
  7. data/docs/fanout-scale-test.md +247 -0
  8. data/docs/superpowers/plans/2026-06-25-chrono_forge-dashboard.md +1748 -0
  9. data/docs/superpowers/plans/2026-06-25-chrono_forge-dashboard.md.tasks.json +17 -0
  10. data/docs/superpowers/plans/2026-06-25-composite-retry-policies.md +930 -0
  11. data/docs/superpowers/plans/2026-06-25-composite-retry-policies.md.tasks.json +54 -0
  12. data/docs/superpowers/plans/2026-06-25-reserved-kwarg-guard.md +241 -0
  13. data/docs/superpowers/plans/2026-06-25-reserved-kwarg-guard.md.tasks.json +12 -0
  14. data/docs/superpowers/plans/2026-06-26-branches-spawn-merge.md +1378 -0
  15. data/docs/superpowers/plans/2026-06-26-branches-spawn-merge.md.tasks.json +67 -0
  16. data/docs/superpowers/plans/2026-06-26-deferral-continuation-race-and-catchup.md +709 -0
  17. data/docs/superpowers/plans/2026-06-26-deferral-continuation-race-and-catchup.md.tasks.json +19 -0
  18. data/docs/superpowers/plans/2026-06-30-poller-rekick-and-eta-cadence.md +205 -0
  19. data/docs/superpowers/plans/2026-06-30-poller-rekick-and-eta-cadence.md.tasks.json +33 -0
  20. data/docs/superpowers/plans/2026-07-01-workflow-definition-dag.md +1373 -0
  21. data/docs/superpowers/plans/2026-07-01-workflow-definition-dag.md.tasks.json +68 -0
  22. data/docs/superpowers/specs/2026-06-03-unified-retry-policy-design.md +226 -0
  23. data/docs/superpowers/specs/2026-06-25-chrono_forge-dashboard-design.md +190 -0
  24. data/docs/superpowers/specs/2026-06-25-composite-retry-policies-design.md +228 -0
  25. data/docs/superpowers/specs/2026-06-25-reserved-kwarg-guard-design.md +169 -0
  26. data/docs/superpowers/specs/2026-06-25-spawn-merge-branches-design.md +468 -0
  27. data/docs/superpowers/specs/2026-06-26-dashboard-branch-view-design.md +142 -0
  28. data/docs/superpowers/specs/2026-06-26-deferral-continuation-race-and-catchup-design.md +265 -0
  29. data/docs/superpowers/specs/2026-07-01-workflow-definition-dag-design.md +203 -0
  30. data/lib/chrono_forge/branch_merge_job.rb +275 -0
  31. data/lib/chrono_forge/branch_probe.rb +70 -0
  32. data/lib/chrono_forge/cleanup.rb +6 -0
  33. data/lib/chrono_forge/configuration.rb +25 -0
  34. data/lib/chrono_forge/definition.rb +37 -0
  35. data/lib/chrono_forge/definition_analyzer.rb +501 -0
  36. data/lib/chrono_forge/execution_log.rb +6 -0
  37. data/lib/chrono_forge/executor/composite_retry_policy.rb +47 -0
  38. data/lib/chrono_forge/executor/context.rb +23 -0
  39. data/lib/chrono_forge/executor/lock_strategy.rb +10 -3
  40. data/lib/chrono_forge/executor/methods/branch.rb +185 -0
  41. data/lib/chrono_forge/executor/methods/continue_if.rb +15 -6
  42. data/lib/chrono_forge/executor/methods/durably_execute.rb +36 -26
  43. data/lib/chrono_forge/executor/methods/durably_repeat.rb +148 -39
  44. data/lib/chrono_forge/executor/methods/merge_branches.rb +84 -0
  45. data/lib/chrono_forge/executor/methods/wait.rb +2 -4
  46. data/lib/chrono_forge/executor/methods/wait_until.rb +25 -25
  47. data/lib/chrono_forge/executor/methods/workflow_states.rb +50 -46
  48. data/lib/chrono_forge/executor/methods.rb +2 -0
  49. data/lib/chrono_forge/executor/retry_policy.rb +111 -0
  50. data/lib/chrono_forge/executor.rb +241 -28
  51. data/lib/chrono_forge/version.rb +1 -1
  52. data/lib/chrono_forge/workflow.rb +10 -1
  53. data/lib/chrono_forge.rb +8 -0
  54. data/lib/generators/chrono_forge/migration_actions.rb +1 -0
  55. data/lib/generators/chrono_forge/templates/add_chrono_forge_parent_execution_log.rb +38 -0
  56. data/lib/tasks/release.rake +212 -0
  57. metadata +67 -4
  58. 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 provides a powerful solution for handling long-running processes, managing state, and recovering from failures in your Rails applications. Built on top of ActiveJob, it ensures your critical business processes remain resilient and traceable.
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**: Comprehensive error tracking with configurable retry strategies
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
- - **Wait States**: Support for time-based waits and condition-based waiting
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
+ [![ChronoForge dashboard](chrono_forge-dashboard/docs/screenshots/workflows.png)](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
+ [![Definition graph](chrono_forge-dashboard/docs/screenshots/definition-graph-scheduled-payment.png)](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
- The upgrade migration is idempotent (`if_not_exists`), so it is safe to run even
63
- if your schema already has the index. Fresh installs get the index from the
64
- install migration and do **not** need to run the upgrade.
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 ensures operations are executed exactly once with automatic retry logic and fault tolerance:
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 attempts
172
- durably_execute :critical_payment_processing, max_attempts: 5
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 with exponential backoff (2^attempt seconds, capped at 32s)
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**: Customize retry attempts and step naming
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
- The `durably_repeat` method enables robust periodic task execution within workflows. Tasks are scheduled at regular intervals until a specified condition is met, with automatic catch-up for missed executions and configurable error handling.
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
- - **Flexible Timing**: Support for custom start times and precise interval scheduling
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, # Retries per execution (default: 3)
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 โ€” so **symbol keys inside a stored hash come back as strings**:
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 โ€” `context[:totals]` and `context["totals"]` refer to the same entry.)
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** โ€” 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.
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 provides configurable retry capabilities:
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
- private
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
- def should_retry?(error, attempt_count)
473
- case error
474
- when NetworkError
475
- attempt_count < 5 # Retry network errors up to 5 times
476
- when ValidationError
477
- false # Don't retry validation errors
478
- else
479
- attempt_count < 3 # Default retry policy
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 follow a sophisticated state machine model to ensure durability and fault tolerance. Understanding these states and transitions is essential for troubleshooting and recovery.
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 โ€” no need to
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** โ€” calling `retry_later`
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** โ€” one row per scheduled execution. A
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 โ€” so they are safe to prune (see
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 โ€” schedule
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 โ€” so the window is purely a retention preference
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 โ€” a long-running workflow that only just finished is kept for
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 โ€” run `chrono_forge:upgrade` if you installed an earlier
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`, `max_attempts: 3`, `name: nil` |
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`, `retry_on: []` |
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`, `max_attempts: 3`, `timeout: 1.hour`, `on_error: :continue` |
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 }