chrono_forge 0.10.0 → 0.12.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 +44 -1
- data/README.md +246 -119
- 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 +246 -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-07-01-workflow-definition-dag-design.md +203 -0
- data/docs/superpowers/specs/2026-07-09-chrono-forge-reaper-design.md +175 -0
- data/lib/chrono_forge/branch_merge_job.rb +158 -21
- data/lib/chrono_forge/branch_probe.rb +44 -0
- data/lib/chrono_forge/configuration.rb +51 -0
- data/lib/chrono_forge/definition.rb +37 -0
- data/lib/chrono_forge/definition_analyzer.rb +501 -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/continue_if.rb +15 -6
- data/lib/chrono_forge/executor/methods/durably_execute.rb +15 -7
- data/lib/chrono_forge/executor/methods/durably_repeat.rb +30 -14
- data/lib/chrono_forge/executor/methods/merge_branches.rb +5 -4
- data/lib/chrono_forge/executor/methods/workflow_states.rb +35 -47
- data/lib/chrono_forge/executor.rb +35 -10
- data/lib/chrono_forge/version.rb +1 -1
- data/lib/chrono_forge/workflow.rb +43 -0
- data/lib/chrono_forge.rb +8 -0
- data/lib/tasks/release.rake +212 -0
- metadata +29 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fd4d0c02357d2d185a6d82034d21a6e09c034c0ae04a9b2cbd5cfcb5bb29b8f0
|
|
4
|
+
data.tar.gz: 981e5d7208c8a80dacb9a13a439e28a896037980364b9a810f976daa93597d65
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7a4ccac2097e6c808103327201f00c1e79341bbd433f1564c5a2ffc306c3740509e195825e613781d62d27844e230ce12b3a048d116a2d44ba3b61ea11f16f7c
|
|
7
|
+
data.tar.gz: e974b5cfd5aaf41045b84947993cb1052a19503bab85e2309b8b5ea13563340fac5136800c30d1a63ee34408c47262ed67922dc21166a6f2361d3742d021be18
|
data/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,47 @@
|
|
|
1
|
-
## [
|
|
1
|
+
## [0.12.0] - 2026-07-09
|
|
2
|
+
|
|
3
|
+
### Documentation
|
|
4
|
+
|
|
5
|
+
- Document the stalled-workflow reaper and fix broken README anchors
|
|
6
|
+
|
|
7
|
+
### Features
|
|
8
|
+
|
|
9
|
+
- Reap workflows stranded in running by hard-killed workers
|
|
10
|
+
|
|
11
|
+
## [0.11.0] - 2026-07-04
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
- Back off BranchMergeJob polling for non-progressing children
|
|
16
|
+
- Leave prepare uncommitted for review; publish commits
|
|
17
|
+
- Converge merges promptly via drain-ETA cadence and never-started-count rekick ([#12](https://github.com/radioactive-labs/chrono_forge/issues/12))
|
|
18
|
+
- Overlay/analyzer correctness, detail-panel XSS, and UX polish
|
|
19
|
+
|
|
20
|
+
### Documentation
|
|
21
|
+
|
|
22
|
+
- Refresh dashboard screenshots, badges; fix upgrade note and API reference
|
|
23
|
+
- Restructure READMEs — promote branches, refine cadence note, dashboard cross-link
|
|
24
|
+
- Add ActiveJob Continuations, Rails-version, and dashboard rows to comparison
|
|
25
|
+
- Correct "workflow is just a Ruby method" framing
|
|
26
|
+
- Add ChronoForge equivalent of the step DSL; fix step-vs-workflow framing
|
|
27
|
+
|
|
28
|
+
### Features
|
|
29
|
+
|
|
30
|
+
- Add bulk merge / merge_once for context ([#11](https://github.com/radioactive-labs/chrono_forge/issues/11))
|
|
31
|
+
- Workflow definition graph with static DAG and live run overlay ([#13](https://github.com/radioactive-labs/chrono_forge/issues/13))
|
|
32
|
+
|
|
33
|
+
### Miscellaneous Tasks
|
|
34
|
+
|
|
35
|
+
- Add cliff-driven release script for both gems
|
|
36
|
+
- Replace bin/release with per-gem rake release flow
|
|
37
|
+
|
|
38
|
+
### Performance
|
|
39
|
+
|
|
40
|
+
- Consolidate per-child commits and flatten log writes
|
|
41
|
+
|
|
42
|
+
### Styling
|
|
43
|
+
|
|
44
|
+
- Apply standardrb blank-line formatting to existing files
|
|
2
45
|
|
|
3
46
|
## [0.10.0] - 2026-06-27
|
|
4
47
|
|
data/README.md
CHANGED
|
@@ -11,6 +11,7 @@ ChronoForge handles long-running processes, manages state, and recovers from fai
|
|
|
11
11
|
|
|
12
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
13
|
|
|
14
|
+
> [!NOTE]
|
|
14
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.
|
|
15
16
|
|
|
16
17
|
## 🧭 Why ChronoForge
|
|
@@ -23,19 +24,44 @@ step :remind_of_tasks, wait: 2.days
|
|
|
23
24
|
step :complete_onboarding, wait: 15.days
|
|
24
25
|
```
|
|
25
26
|
|
|
26
|
-
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: **
|
|
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`:
|
|
27
28
|
|
|
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.
|
|
29
52
|
|
|
30
53
|
### How it compares
|
|
31
54
|
|
|
32
|
-
| | ChronoForge | GenevaDrive | AcidicJob | Temporal |
|
|
33
|
-
| ---------------------------- | -------------------- | ------------------ | --------------- | --------------- |
|
|
34
|
-
| Programming model | procedural (plain Ruby) | declarative DSL | declarative DSL | procedural (via SDK) |
|
|
35
|
-
| Built-in periodic tasks | ✓ `durably_repeat` | ✗ | ✗ | ✓ |
|
|
36
|
-
|
|
|
37
|
-
|
|
|
38
|
-
|
|
|
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 |
|
|
39
65
|
|
|
40
66
|
<sub>Comparison reflects each project's documented features as of mid-2026, to the best of our knowledge; corrections welcome via PR.</sub>
|
|
41
67
|
|
|
@@ -43,8 +69,11 @@ A few deliberate choices behind that table:
|
|
|
43
69
|
|
|
44
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.
|
|
45
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).
|
|
46
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.
|
|
47
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.
|
|
48
77
|
|
|
49
78
|
## 🌟 Features
|
|
50
79
|
|
|
@@ -52,6 +81,7 @@ A few deliberate choices behind that table:
|
|
|
52
81
|
- **Durable Execution**: Automatically tracks and recovers from failures during workflow execution
|
|
53
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
|
|
54
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)
|
|
55
85
|
- **State Management**: Built-in workflow state tracking with persistent context storage
|
|
56
86
|
- **Concurrency Control**: Advanced locking mechanisms to prevent parallel execution of the same workflow
|
|
57
87
|
- **Error Handling**: Error tracking with a unified, configurable [`RetryPolicy`](#-retry-policies) (including per-error-type policies)
|
|
@@ -60,12 +90,16 @@ A few deliberate choices behind that table:
|
|
|
60
90
|
- **ActiveJob Integration**: Compatible with all ActiveJob backends, though database-backed processors (like Solid Queue) provide the most reliable experience for long-running workflows
|
|
61
91
|
- **Retention & Cleanup**: A schedulable job to prune finished workflows and the unbounded logs that periodic tasks accumulate (see [Cleanup & Retention](#-cleanup--retention))
|
|
62
92
|
|
|
63
|
-
##
|
|
93
|
+
## 🖥 Dashboard
|
|
64
94
|
|
|
65
|
-
ChronoForge has a free, mountable dashboard for visibility and recovery: workflow list, step replay timeline, 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.
|
|
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.
|
|
66
96
|
|
|
67
97
|
[](chrono_forge-dashboard/README.md#screenshots)
|
|
68
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
|
+
|
|
69
103
|
```ruby
|
|
70
104
|
# Gemfile
|
|
71
105
|
gem "chrono_forge-dashboard"
|
|
@@ -114,9 +148,9 @@ $ rails generate chrono_forge:upgrade
|
|
|
114
148
|
$ rails db:migrate
|
|
115
149
|
```
|
|
116
150
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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.
|
|
120
154
|
|
|
121
155
|
## 📋 Usage
|
|
122
156
|
|
|
@@ -310,7 +344,15 @@ end
|
|
|
310
344
|
- **Idempotent**: Same operation won't be executed twice during replays
|
|
311
345
|
- **Automatic Retries**: Failed executions retry per a unified `RetryPolicy` (exponential backoff with jitter; the step default caps at 30s over 3 attempts)
|
|
312
346
|
- **Error Tracking**: All failures are logged with detailed error information
|
|
313
|
-
- **Configurable**: Pass a `retry_policy:` per call, or set a class-wide default with the `retry_policy` DSL (see [Retry Policies](
|
|
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
|
+
> **Write your side effects to be idempotent.** A step short-circuits on replay only
|
|
350
|
+
> once its log row reaches `completed`. If a worker is hard-killed (SIGKILL/OOM/eviction)
|
|
351
|
+
> *after* a step's side effect commits but *before* its log is marked `completed`, the
|
|
352
|
+
> step re-runs when the workflow is resumed (including by the [reaper](#recovering-stranded-workflows-hard-killed-workers)).
|
|
353
|
+
> Make external side effects safe to repeat — use a natural/unique key with
|
|
354
|
+
> `create_or_find_by`, an upsert, or a rescue on the uniqueness violation — rather than
|
|
355
|
+
> assuming exactly-once execution.
|
|
314
356
|
|
|
315
357
|
#### 🔁 Retry Policies
|
|
316
358
|
|
|
@@ -590,6 +632,21 @@ def cleanup_files(scheduled_time)
|
|
|
590
632
|
end
|
|
591
633
|
```
|
|
592
634
|
|
|
635
|
+
#### 🌿 Branches
|
|
636
|
+
|
|
637
|
+
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:
|
|
638
|
+
|
|
639
|
+
```ruby
|
|
640
|
+
branch :reconcile, automerge: true do
|
|
641
|
+
spawn :eu, ReconcileWorkflow, region: "EU"
|
|
642
|
+
spawn_each :orders, Order.pending do |order|
|
|
643
|
+
[OrderWorkflow, { order_id: order.id }]
|
|
644
|
+
end
|
|
645
|
+
end
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
`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.
|
|
649
|
+
|
|
593
650
|
#### 🔄 Workflow Context
|
|
594
651
|
|
|
595
652
|
ChronoForge provides a persistent context that survives job restarts. The context behaves like a Hash but with additional capabilities:
|
|
@@ -611,6 +668,12 @@ context.set(:total_amount, 99.99)
|
|
|
611
668
|
# Set a value only if the key doesn't already exist
|
|
612
669
|
context.set_once(:created_at, Time.current.iso8601)
|
|
613
670
|
|
|
671
|
+
# Set several values at once (alias: set_multiple)
|
|
672
|
+
context.merge(status: "processing", total_amount: 99.99, attempts: 0)
|
|
673
|
+
|
|
674
|
+
# Set several values at once, but only keys that don't already exist (alias: set_multiple_once)
|
|
675
|
+
context.merge_once(created_at: Time.current.iso8601, attempts: 0)
|
|
676
|
+
|
|
614
677
|
# Check if a key exists
|
|
615
678
|
if context.key?(:user_id)
|
|
616
679
|
# Do something with the user ID
|
|
@@ -652,6 +715,113 @@ end
|
|
|
652
715
|
|
|
653
716
|
To make an error non-retryable, leave it out of `retry_on:` (an empty `retry_on: []` retries nothing).
|
|
654
717
|
|
|
718
|
+
## 🌿 Branches: parallel sub-workflows
|
|
719
|
+
|
|
720
|
+
`branch` / `spawn` / `spawn_each` / `merge_branches` let a workflow fan out into
|
|
721
|
+
child workflows that run concurrently, then join them when their results are
|
|
722
|
+
needed.
|
|
723
|
+
|
|
724
|
+
### Model
|
|
725
|
+
|
|
726
|
+
- **`branch :name do … end`** opens a named branch (a durable step). Inside the
|
|
727
|
+
block, `spawn` and `spawn_each` create and immediately enqueue child workflows —
|
|
728
|
+
children start running as soon as the branch block is entered.
|
|
729
|
+
- **`spawn :name, WorkflowClass, **kwargs`** — enqueues one child workflow.
|
|
730
|
+
- **`spawn_each :name, source do |item| [WorkflowClass, kwargs] end`** — enqueues
|
|
731
|
+
one child per item. The block returns the class and kwargs, so one branch can
|
|
732
|
+
fan out into mixed workflow types. Sources are iterated in constant memory;
|
|
733
|
+
ActiveRecord relations are streamed by primary key — pass them **without** an
|
|
734
|
+
explicit `.order`.
|
|
735
|
+
- **`automerge: true`** — joins the branch **inline at the block's close**.
|
|
736
|
+
Execution does not continue past the `branch` call until every child has
|
|
737
|
+
completed. Use it for "dispatch this group and wait right here."
|
|
738
|
+
- **`merge_branches :a, :b`** (or the singular alias `merge_branch :a`) — the
|
|
739
|
+
separate join point. Open branches without `automerge`, do other work while the
|
|
740
|
+
children run, then join when you need their results. `merge_branches` blocks
|
|
741
|
+
until all named branches are complete.
|
|
742
|
+
|
|
743
|
+
### Worked example
|
|
744
|
+
|
|
745
|
+
```ruby
|
|
746
|
+
class FulfillmentWorkflow < ApplicationJob
|
|
747
|
+
prepend ChronoForge::Executor
|
|
748
|
+
|
|
749
|
+
def perform(cycle_id:)
|
|
750
|
+
# automerge: the branch is joined inline, right where the block closes —
|
|
751
|
+
# `perform` does not continue past it until every child has completed.
|
|
752
|
+
branch :reconcile, automerge: true do
|
|
753
|
+
spawn :eu, ReconcileWorkflow, region: "EU"
|
|
754
|
+
spawn_each :orders, Order.pending do |order|
|
|
755
|
+
order.priority? ? [PriorityOrderWorkflow, { order_id: order.id }]
|
|
756
|
+
: [OrderWorkflow, { order_id: order.id }]
|
|
757
|
+
end
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
# For branches you want to run concurrently and join later, omit automerge
|
|
761
|
+
# and use merge_branches:
|
|
762
|
+
branch :invoices do
|
|
763
|
+
spawn_each :unpaid, Invoice.unpaid do |inv|
|
|
764
|
+
[InvoiceWorkflow, { invoice_id: inv.id }]
|
|
765
|
+
end
|
|
766
|
+
end
|
|
767
|
+
branch :shipments do
|
|
768
|
+
spawn_each :ready, Shipment.ready do |s|
|
|
769
|
+
[ShipmentWorkflow, { shipment_id: s.id }]
|
|
770
|
+
end
|
|
771
|
+
end
|
|
772
|
+
do_other_work # runs while :invoices and :shipments dispatch/run
|
|
773
|
+
merge_branches :invoices, :shipments # join both here
|
|
774
|
+
|
|
775
|
+
durably_execute :finalize
|
|
776
|
+
end
|
|
777
|
+
end
|
|
778
|
+
```
|
|
779
|
+
|
|
780
|
+
### Caveats
|
|
781
|
+
|
|
782
|
+
> **Every branch must be joined.** A branch opened and never joined raises
|
|
783
|
+
> `ChronoForge::Executor::UnmergedBranchError` when the workflow tries to
|
|
784
|
+
> complete — fail-fast, no silently-orphaned children. Use either
|
|
785
|
+
> `automerge: true` or a matching `merge_branches` call.
|
|
786
|
+
|
|
787
|
+
> **The parent isn't replayed while waiting.** A lightweight
|
|
788
|
+
> `ChronoForge::BranchMergeJob` polls for child completion; the parent workflow
|
|
789
|
+
> only runs again once the branch is fully done. Polling cadence tracks the
|
|
790
|
+
> **estimated time-to-drain** (measured from the children's completion rate), so
|
|
791
|
+
> the parent is woken within ~`min_interval` of the last child finishing rather
|
|
792
|
+
> than up to `max_interval` late; a branch that can only wait or is blocked on a
|
|
793
|
+
> failure backs off to `max_interval` (those need a wait to elapse or operator
|
|
794
|
+
> recovery, not faster polling).
|
|
795
|
+
>
|
|
796
|
+
> **Queue placement matters.** The poller is enqueued *after* the branch's
|
|
797
|
+
> children, so on a queue those children saturate it starves behind the backlog
|
|
798
|
+
> (the parent then converges up to `max_interval` late). Point it at a queue that
|
|
799
|
+
> isn't saturated by the fan-out:
|
|
800
|
+
>
|
|
801
|
+
> ```ruby
|
|
802
|
+
> ChronoForge.configure { |c| c.branch_merge_queue = :chrono_forge_pollers } # default: :default
|
|
803
|
+
> ```
|
|
804
|
+
|
|
805
|
+
> **`spawn_each` sources must re-enumerate deterministically across replays.**
|
|
806
|
+
> ActiveRecord relations are streamed by primary key (children are keyed by
|
|
807
|
+
> record id, so crash-resume is idempotent); a relation carrying an explicit
|
|
808
|
+
> `.order(...)` raises. For non-AR enumerables, items are keyed by position, so
|
|
809
|
+
> inserting or removing items mid-dispatch would shift keys and break idempotency.
|
|
810
|
+
|
|
811
|
+
> **`spawn_each` AR sources must have stable membership.** Dispatch streams by
|
|
812
|
+
> ascending primary key and resumes from the last key on crash-recovery, so a row
|
|
813
|
+
> that enters the relation *below* the cursor after it has passed (e.g. a
|
|
814
|
+
> `where(state: …)` scope whose rows mutate mid-dispatch) will never get a child.
|
|
815
|
+
> Point `spawn_each` at a set that is fixed for the branch's lifetime — a frozen id
|
|
816
|
+
> range, an append-only table, or `where(id: [...])` over a snapshot.
|
|
817
|
+
|
|
818
|
+
> **`branch` blocks cannot be lexically nested within one workflow.** Opening a
|
|
819
|
+
> `branch` inside another `branch` block raises `ArgumentError`; spawns belong to
|
|
820
|
+
> exactly one branch. (A *spawned child workflow* may open its own branches — it
|
|
821
|
+
> runs in its own executor — so cross-workflow nesting is fine.)
|
|
822
|
+
|
|
823
|
+
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).
|
|
824
|
+
|
|
655
825
|
## 🧪 Testing
|
|
656
826
|
|
|
657
827
|
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:
|
|
@@ -756,6 +926,7 @@ stateDiagram-v2
|
|
|
756
926
|
- **Identifiers**: Has locked_at and locked_by values set
|
|
757
927
|
- **Behavior**: Protected against concurrent execution
|
|
758
928
|
- **Duration**: Should be brief unless performing long operations
|
|
929
|
+
- **Note**: A workflow whose worker was hard-killed mid-pass stays stuck here with a stale lock. See [Recovering stranded workflows](#recovering-stranded-workflows-hard-killed-workers).
|
|
759
930
|
|
|
760
931
|
#### Completed
|
|
761
932
|
- **Description**: The workflow has successfully executed all steps
|
|
@@ -804,25 +975,60 @@ ChronoForge::Workflow.failed.find_each(&:retry_later)
|
|
|
804
975
|
The class-level form (`MyWorkflow.retry_now(key)` / `retry_later(key)`) still
|
|
805
976
|
works if you have the class and key rather than the record.
|
|
806
977
|
|
|
807
|
-
####
|
|
978
|
+
#### Recovering stranded workflows (hard-killed workers)
|
|
979
|
+
|
|
980
|
+
When a worker is **hard-killed** mid-pass — SIGKILL from a deploy/rollout, an OOM kill,
|
|
981
|
+
a node eviction, or a SolidQueue heartbeat prune — Ruby's `ensure` block does not run.
|
|
982
|
+
The executor releases the workflow's lock and publishes the resume continuation in that
|
|
983
|
+
`ensure`, so a hard kill leaves the workflow stuck in `running` with a stale lock **and**
|
|
984
|
+
nothing scheduled to wake it. It is fully resumable (a resuming pass steals the stale
|
|
985
|
+
lock and replays completed steps as no-ops), but nothing re-enqueues it on its own:
|
|
986
|
+
`retry_now`/`retry_later` refuse a `running` workflow, and a dashboard "force unlock"
|
|
987
|
+
clears the lock but enqueues no job.
|
|
808
988
|
|
|
809
|
-
|
|
989
|
+
`ChronoForge::Workflow.reap_stalled` reconciles these. It finds every workflow in
|
|
990
|
+
`running` whose lock is older than `reap_stale_after` (top-level workflows **and** branch
|
|
991
|
+
children) and re-enqueues it, returning the number reaped:
|
|
810
992
|
|
|
811
993
|
```ruby
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
.where('locked_at < ?', 30.minutes.ago)
|
|
994
|
+
ChronoForge::Workflow.reap_stalled
|
|
995
|
+
# => 3 (also logs "ChronoForge reaped 3 stalled workflow(s)")
|
|
815
996
|
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
997
|
+
# Override the threshold for a one-off sweep:
|
|
998
|
+
ChronoForge::Workflow.reap_stalled(stale_after: 15.minutes)
|
|
999
|
+
```
|
|
1000
|
+
|
|
1001
|
+
It is **not** run automatically — schedule it from your own scheduler, the same way you
|
|
1002
|
+
schedule `ChronoForge::Cleanup`. For example, a SolidQueue recurring task:
|
|
1003
|
+
|
|
1004
|
+
```yaml
|
|
1005
|
+
# config/recurring.yml
|
|
1006
|
+
reap_stalled_workflows:
|
|
1007
|
+
command: "ChronoForge::Workflow.reap_stalled"
|
|
1008
|
+
schedule: "every 5 minutes"
|
|
1009
|
+
```
|
|
1010
|
+
|
|
1011
|
+
Re-enqueue is safe under concurrency: overlapping sweeps (or a re-enqueue landing while
|
|
1012
|
+
the old stale lock still shows) at worst enqueue a duplicate, which loses the
|
|
1013
|
+
lock-acquisition race and no-ops. Because reaping replays the interrupted pass, steps
|
|
1014
|
+
with external side effects must be idempotent — see the note under
|
|
1015
|
+
[Durable Execution](#-durable-execution).
|
|
1016
|
+
|
|
1017
|
+
`reap_stale_after` defaults to **3× `max_duration`** (30 minutes out of the box). Both are
|
|
1018
|
+
configurable, and because the reap threshold derives from `max_duration` it always stays
|
|
1019
|
+
safely above the lock-steal threshold — raise one and the other follows:
|
|
1020
|
+
|
|
1021
|
+
```ruby
|
|
1022
|
+
ChronoForge.configure do |c|
|
|
1023
|
+
c.max_duration = 10.minutes # how long one pass may hold its lock before it's stealable
|
|
1024
|
+
c.reap_stale_after = 45.minutes # optional: pin the reap threshold explicitly (else 3x max_duration)
|
|
823
1025
|
end
|
|
824
1026
|
```
|
|
825
1027
|
|
|
1028
|
+
> **Not covered:** a workflow parked on a branch merge whose `BranchMergeJob` poller was
|
|
1029
|
+
> itself hard-killed sits `idle` (not `running`), so the reaper does not sweep it. That is
|
|
1030
|
+
> a distinct failure mode.
|
|
1031
|
+
|
|
826
1032
|
## 🧹 Cleanup & Retention
|
|
827
1033
|
|
|
828
1034
|
ChronoForge keeps every workflow and execution-log row indefinitely so that
|
|
@@ -895,98 +1101,6 @@ production:
|
|
|
895
1101
|
schedule: every day at 3am
|
|
896
1102
|
```
|
|
897
1103
|
|
|
898
|
-
## 🌿 Branches: parallel sub-workflows
|
|
899
|
-
|
|
900
|
-
`branch` / `spawn` / `spawn_each` / `merge_branches` let a workflow fan out into
|
|
901
|
-
child workflows that run concurrently, then join them when their results are
|
|
902
|
-
needed.
|
|
903
|
-
|
|
904
|
-
### Model
|
|
905
|
-
|
|
906
|
-
- **`branch :name do … end`** opens a named branch (a durable step). Inside the
|
|
907
|
-
block, `spawn` and `spawn_each` create and immediately enqueue child workflows —
|
|
908
|
-
children start running as soon as the branch block is entered.
|
|
909
|
-
- **`spawn :name, WorkflowClass, **kwargs`** — enqueues one child workflow.
|
|
910
|
-
- **`spawn_each :name, source do |item| [WorkflowClass, kwargs] end`** — enqueues
|
|
911
|
-
one child per item. The block returns the class and kwargs, so one branch can
|
|
912
|
-
fan out into mixed workflow types. Sources are iterated in constant memory;
|
|
913
|
-
ActiveRecord relations are streamed by primary key — pass them **without** an
|
|
914
|
-
explicit `.order`.
|
|
915
|
-
- **`automerge: true`** — joins the branch **inline at the block's close**.
|
|
916
|
-
Execution does not continue past the `branch` call until every child has
|
|
917
|
-
completed. Use it for "dispatch this group and wait right here."
|
|
918
|
-
- **`merge_branches :a, :b`** (or the singular alias `merge_branch :a`) — the
|
|
919
|
-
separate join point. Open branches without `automerge`, do other work while the
|
|
920
|
-
children run, then join when you need their results. `merge_branches` blocks
|
|
921
|
-
until all named branches are complete.
|
|
922
|
-
|
|
923
|
-
### Worked example
|
|
924
|
-
|
|
925
|
-
```ruby
|
|
926
|
-
class FulfillmentWorkflow < ApplicationJob
|
|
927
|
-
prepend ChronoForge::Executor
|
|
928
|
-
|
|
929
|
-
def perform(cycle_id:)
|
|
930
|
-
# automerge: the branch is joined inline, right where the block closes —
|
|
931
|
-
# `perform` does not continue past it until every child has completed.
|
|
932
|
-
branch :reconcile, automerge: true do
|
|
933
|
-
spawn :eu, ReconcileWorkflow, region: "EU"
|
|
934
|
-
spawn_each :orders, Order.pending do |order|
|
|
935
|
-
order.priority? ? [PriorityOrderWorkflow, { order_id: order.id }]
|
|
936
|
-
: [OrderWorkflow, { order_id: order.id }]
|
|
937
|
-
end
|
|
938
|
-
end
|
|
939
|
-
|
|
940
|
-
# For branches you want to run concurrently and join later, omit automerge
|
|
941
|
-
# and use merge_branches:
|
|
942
|
-
branch :invoices do
|
|
943
|
-
spawn_each :unpaid, Invoice.unpaid do |inv|
|
|
944
|
-
[InvoiceWorkflow, { invoice_id: inv.id }]
|
|
945
|
-
end
|
|
946
|
-
end
|
|
947
|
-
branch :shipments do
|
|
948
|
-
spawn_each :ready, Shipment.ready do |s|
|
|
949
|
-
[ShipmentWorkflow, { shipment_id: s.id }]
|
|
950
|
-
end
|
|
951
|
-
end
|
|
952
|
-
do_other_work # runs while :invoices and :shipments dispatch/run
|
|
953
|
-
merge_branches :invoices, :shipments # join both here
|
|
954
|
-
|
|
955
|
-
durably_execute :finalize
|
|
956
|
-
end
|
|
957
|
-
end
|
|
958
|
-
```
|
|
959
|
-
|
|
960
|
-
### Caveats
|
|
961
|
-
|
|
962
|
-
> **Every branch must be joined.** A branch opened and never joined raises
|
|
963
|
-
> `ChronoForge::Executor::UnmergedBranchError` when the workflow tries to
|
|
964
|
-
> complete — fail-fast, no silently-orphaned children. Use either
|
|
965
|
-
> `automerge: true` or a matching `merge_branches` call.
|
|
966
|
-
|
|
967
|
-
> **The parent isn't replayed while waiting.** A lightweight
|
|
968
|
-
> `ChronoForge::BranchMergeJob` polls for child completion; the parent workflow
|
|
969
|
-
> only runs again once the branch is fully done. Polling cadence adapts to how
|
|
970
|
-
> many children remain.
|
|
971
|
-
|
|
972
|
-
> **`spawn_each` sources must re-enumerate deterministically across replays.**
|
|
973
|
-
> ActiveRecord relations are streamed by primary key (children are keyed by
|
|
974
|
-
> record id, so crash-resume is idempotent); a relation carrying an explicit
|
|
975
|
-
> `.order(...)` raises. For non-AR enumerables, items are keyed by position, so
|
|
976
|
-
> inserting or removing items mid-dispatch would shift keys and break idempotency.
|
|
977
|
-
|
|
978
|
-
> **`spawn_each` AR sources must have stable membership.** Dispatch streams by
|
|
979
|
-
> ascending primary key and resumes from the last key on crash-recovery, so a row
|
|
980
|
-
> that enters the relation *below* the cursor after it has passed (e.g. a
|
|
981
|
-
> `where(state: …)` scope whose rows mutate mid-dispatch) will never get a child.
|
|
982
|
-
> Point `spawn_each` at a set that is fixed for the branch's lifetime — a frozen id
|
|
983
|
-
> range, an append-only table, or `where(id: [...])` over a snapshot.
|
|
984
|
-
|
|
985
|
-
> **`branch` blocks cannot be lexically nested within one workflow.** Opening a
|
|
986
|
-
> `branch` inside another `branch` block raises `ArgumentError`; spawns belong to
|
|
987
|
-
> exactly one branch. (A *spawned child workflow* may open its own branches — it
|
|
988
|
-
> runs in its own executor — so cross-workflow nesting is fine.)
|
|
989
|
-
|
|
990
1104
|
## 🚀 Development
|
|
991
1105
|
|
|
992
1106
|
After checking out the repo, run:
|
|
@@ -1029,6 +1143,17 @@ This gem is available as open source under the terms of the [MIT License](https:
|
|
|
1029
1143
|
| `continue_if` | Manual continuation wait | `condition`, `name: nil` |
|
|
1030
1144
|
| `durably_repeat` | Periodic task execution | `method`, `every:`, `till:`, `start_at: nil`, `retry_policy: nil`, `timeout: 1.hour`, `on_error: :continue` |
|
|
1031
1145
|
|
|
1146
|
+
### Branch Methods
|
|
1147
|
+
|
|
1148
|
+
Fan a workflow out into parallel child sub-workflows (see [Branches](#-branches-parallel-sub-workflows)).
|
|
1149
|
+
|
|
1150
|
+
| Method | Purpose | Key Parameters |
|
|
1151
|
+
|--------|---------|----------------|
|
|
1152
|
+
| `branch` | Open a named branch (takes a block) to dispatch children | `name`, `automerge: false` |
|
|
1153
|
+
| `spawn` | Enqueue one child workflow inside a branch | `name`, `workflow_class`, `**kwargs` |
|
|
1154
|
+
| `spawn_each` | Enqueue one child per item, streamed (block returns `[WorkflowClass, kwargs]`) | `name`, `source`, `of: 1000` |
|
|
1155
|
+
| `merge_branches` | Join named branches; blocks until all complete (alias `merge_branch`) | `*names`, `min_interval: 5.seconds`, `max_interval: 5.minutes` |
|
|
1156
|
+
|
|
1032
1157
|
### Context Methods
|
|
1033
1158
|
|
|
1034
1159
|
| Method | Purpose | Example |
|
|
@@ -1037,6 +1162,8 @@ This gem is available as open source under the terms of the [MIT License](https:
|
|
|
1037
1162
|
| `context[:key]` | Get context value | `user_id = context[:user_id]` |
|
|
1038
1163
|
| `context.set(key, value)` | Set context value (alias) | `context.set(:status, "active")` |
|
|
1039
1164
|
| `context.set_once(key, value)` | Set only if key doesn't exist | `context.set_once(:created_at, Time.current)` |
|
|
1165
|
+
| `context.merge(hash)` | Set multiple values atomically (alias: `set_multiple`) | `context.merge(status: "active", count: 0)` |
|
|
1166
|
+
| `context.merge_once(hash)` | Set multiple values, skipping existing keys (alias: `set_multiple_once`) | `context.merge_once(created_at: Time.current, count: 0)` |
|
|
1040
1167
|
| `context.fetch(key, default)` | Get with default value | `context.fetch(:count, 0)` |
|
|
1041
1168
|
| `context.key?(key)` | Check if key exists | `context.key?(:user_id)` |
|
|
1042
1169
|
|
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 }
|
data/cliff.toml
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# git-cliff configuration for the chrono_forge monorepo.
|
|
2
|
+
# https://git-cliff.org/docs/configuration
|
|
3
|
+
#
|
|
4
|
+
# Two gems share this one config. bin/release passes per-gem --tag-pattern and
|
|
5
|
+
# --include-path/--exclude-path so each CHANGELOG only reflects its own subtree
|
|
6
|
+
# (core = everything except chrono_forge-dashboard/, dashboard = that subtree).
|
|
7
|
+
# The version heading trims both tag prefixes (`v` and `chrono_forge-dashboard-v`).
|
|
8
|
+
|
|
9
|
+
[changelog]
|
|
10
|
+
header = """
|
|
11
|
+
# Changelog\n
|
|
12
|
+
All notable changes to this project are documented in this file.\n
|
|
13
|
+
"""
|
|
14
|
+
body = """
|
|
15
|
+
{% if version %}\
|
|
16
|
+
## [{{ version | trim_start_matches(pat="chrono_forge-dashboard-v") | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
|
|
17
|
+
{% else %}\
|
|
18
|
+
## [Unreleased]
|
|
19
|
+
{% endif %}\
|
|
20
|
+
{% for group, commits in commits | group_by(attribute="group") %}
|
|
21
|
+
### {{ group | upper_first }}
|
|
22
|
+
{% for commit in commits %}
|
|
23
|
+
- {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }}\
|
|
24
|
+
{% endfor %}
|
|
25
|
+
{% endfor %}\n
|
|
26
|
+
"""
|
|
27
|
+
trim = true
|
|
28
|
+
footer = """
|
|
29
|
+
<!-- generated by git-cliff -->
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
[git]
|
|
33
|
+
conventional_commits = true
|
|
34
|
+
filter_unconventional = true
|
|
35
|
+
split_commits = false
|
|
36
|
+
commit_preprocessors = [
|
|
37
|
+
{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/radioactive-labs/chrono_forge/issues/${2}))" },
|
|
38
|
+
]
|
|
39
|
+
commit_parsers = [
|
|
40
|
+
{ message = "^feat", group = "Features" },
|
|
41
|
+
{ message = "^fix", group = "Bug Fixes" },
|
|
42
|
+
{ message = "^doc", group = "Documentation" },
|
|
43
|
+
{ message = "^perf", group = "Performance" },
|
|
44
|
+
{ message = "^refactor", group = "Refactoring" },
|
|
45
|
+
{ message = "^style", group = "Styling" },
|
|
46
|
+
{ message = "^test", group = "Testing" },
|
|
47
|
+
{ message = "^chore\\(release\\):", skip = true },
|
|
48
|
+
{ message = "^chore", group = "Miscellaneous Tasks" },
|
|
49
|
+
{ body = ".*security", group = "Security" },
|
|
50
|
+
]
|
|
51
|
+
protect_breaking_commits = false
|
|
52
|
+
filter_commits = false
|
|
53
|
+
tag_pattern = "^v[0-9]"
|
|
54
|
+
topo_order = false
|
|
55
|
+
sort_commits = "oldest"
|
|
56
|
+
|
|
57
|
+
[bump]
|
|
58
|
+
# Pre-1.0 semver: both gems are on 0.x. A feature bumps the minor, and a
|
|
59
|
+
# breaking change ALSO bumps the minor (not the major) while we're on 0.x.
|
|
60
|
+
# Fixes bump the patch. Revisit once a gem reaches 1.0.
|
|
61
|
+
features_always_bump_minor = true
|
|
62
|
+
breaking_always_bump_major = false
|