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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -1
  3. data/README.md +246 -119
  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 +246 -0
  8. data/docs/superpowers/plans/2026-06-30-poller-rekick-and-eta-cadence.md +205 -0
  9. data/docs/superpowers/plans/2026-06-30-poller-rekick-and-eta-cadence.md.tasks.json +33 -0
  10. data/docs/superpowers/plans/2026-07-01-workflow-definition-dag.md +1373 -0
  11. data/docs/superpowers/plans/2026-07-01-workflow-definition-dag.md.tasks.json +68 -0
  12. data/docs/superpowers/specs/2026-07-01-workflow-definition-dag-design.md +203 -0
  13. data/docs/superpowers/specs/2026-07-09-chrono-forge-reaper-design.md +175 -0
  14. data/lib/chrono_forge/branch_merge_job.rb +158 -21
  15. data/lib/chrono_forge/branch_probe.rb +44 -0
  16. data/lib/chrono_forge/configuration.rb +51 -0
  17. data/lib/chrono_forge/definition.rb +37 -0
  18. data/lib/chrono_forge/definition_analyzer.rb +501 -0
  19. data/lib/chrono_forge/executor/context.rb +23 -0
  20. data/lib/chrono_forge/executor/lock_strategy.rb +10 -3
  21. data/lib/chrono_forge/executor/methods/continue_if.rb +15 -6
  22. data/lib/chrono_forge/executor/methods/durably_execute.rb +15 -7
  23. data/lib/chrono_forge/executor/methods/durably_repeat.rb +30 -14
  24. data/lib/chrono_forge/executor/methods/merge_branches.rb +5 -4
  25. data/lib/chrono_forge/executor/methods/workflow_states.rb +35 -47
  26. data/lib/chrono_forge/executor.rb +35 -10
  27. data/lib/chrono_forge/version.rb +1 -1
  28. data/lib/chrono_forge/workflow.rb +43 -0
  29. data/lib/chrono_forge.rb +8 -0
  30. data/lib/tasks/release.rake +212 -0
  31. metadata +29 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f03445b6275e345beb34505d4d59a01d8450df220e94f07bc909c8c69059ab8d
4
- data.tar.gz: 9ba7aaa7364736f66778da68af4f21d7c944ac01270c7b92ca76bf09bf880738
3
+ metadata.gz: fd4d0c02357d2d185a6d82034d21a6e09c034c0ae04a9b2cbd5cfcb5bb29b8f0
4
+ data.tar.gz: 981e5d7208c8a80dacb9a13a439e28a896037980364b9a810f976daa93597d65
5
5
  SHA512:
6
- metadata.gz: f761f180b4e8323721cfffc0a7c2569f30ea8e5b8e085cc52ab32aeefa64ed2a45caac13dff38c168ae497c437816201cdf5c2946a85ab987814eecd852d97c6
7
- data.tar.gz: 22ca2b2ca99188b5117c06e2d9b313e726e0087ed48d3635d1064bcb82eee69b9f649a56a441a42e603c3b32ceb98c1aa788782dc01c5d2066f09dae0593900b
6
+ metadata.gz: 7a4ccac2097e6c808103327201f00c1e79341bbd433f1564c5a2ffc306c3740509e195825e613781d62d27844e230ce12b3a048d116a2d44ba3b61ea11f16f7c
7
+ data.tar.gz: e974b5cfd5aaf41045b84947993cb1052a19503bab85e2309b8b5ea13563340fac5136800c30d1a63ee34408c47262ed67922dc21166a6f2361d3742d021be18
data/CHANGELOG.md CHANGED
@@ -1,4 +1,47 @@
1
- ## [Unreleased]
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: **a workflow is just a Ruby method.** Conditionals, iteration, early returns, and helper methods all work the way they normally do.
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
- There is a real trade-off. Because the flow is ordinary code, ChronoForge can show the steps that **have run** (a replay/history view), but not a roadmap of steps that *haven't* run yet, which a declarative engine can. For workflows whose path isn't fixed in advance, that's a trade worth making; for a simple, fixed sequence ("send email, wait 2 days, send another"), a declarative DSL may read more cleanly, and that's a fine reason to reach for one.
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
- | Pending-step visibility | ✗ (procedural) | | | ✗ (procedural) |
37
- | Extra infrastructure | none (DB + ActiveJob)| none | none | server required |
38
- | License | MIT | LGPL / commercial | MIT | MIT |
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
- ## 🖥️ Dashboard
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
  [![ChronoForge dashboard](chrono_forge-dashboard/docs/screenshots/workflows.png)](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
+ [![Definition graph](chrono_forge-dashboard/docs/screenshots/definition-graph-scheduled-payment.png)](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
- The upgrade migration is idempotent (`if_not_exists`), so it is safe to run even
118
- if your schema already has the index. Fresh installs get the index from the
119
- 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.
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](#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
- #### Monitoring Running Workflows
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
- Long-running workflows might indicate issues:
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
- # Find workflows running for too long
813
- long_running = ChronoForge::Workflow.where(state: :running)
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
- long_running.each do |workflow|
817
- # Log potential issues for investigation
818
- Rails.logger.warn "Workflow #{workflow.key} has been running for >30 minutes"
819
-
820
- # Optionally force unlock if you suspect deadlock
821
- # CAUTION: Only do this if you're certain the job is stuck
822
- # workflow.update!(locked_at: nil, locked_by: nil, state: :idle)
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