chrono_forge 0.11.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 +10 -0
- data/README.md +58 -14
- data/docs/fanout-scale-test.md +1 -2
- data/docs/superpowers/specs/2026-07-09-chrono-forge-reaper-design.md +175 -0
- data/lib/chrono_forge/configuration.rb +26 -0
- data/lib/chrono_forge/executor.rb +1 -1
- data/lib/chrono_forge/version.rb +1 -1
- data/lib/chrono_forge/workflow.rb +43 -0
- metadata +3 -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
data/README.md
CHANGED
|
@@ -90,7 +90,7 @@ A few deliberate choices behind that table:
|
|
|
90
90
|
- **ActiveJob Integration**: Compatible with all ActiveJob backends, though database-backed processors (like Solid Queue) provide the most reliable experience for long-running workflows
|
|
91
91
|
- **Retention & Cleanup**: A schedulable job to prune finished workflows and the unbounded logs that periodic tasks accumulate (see [Cleanup & Retention](#-cleanup--retention))
|
|
92
92
|
|
|
93
|
-
##
|
|
93
|
+
## 🖥 Dashboard
|
|
94
94
|
|
|
95
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
96
|
|
|
@@ -344,7 +344,15 @@ end
|
|
|
344
344
|
- **Idempotent**: Same operation won't be executed twice during replays
|
|
345
345
|
- **Automatic Retries**: Failed executions retry per a unified `RetryPolicy` (exponential backoff with jitter; the step default caps at 30s over 3 attempts)
|
|
346
346
|
- **Error Tracking**: All failures are logged with detailed error information
|
|
347
|
-
- **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.
|
|
348
356
|
|
|
349
357
|
#### 🔁 Retry Policies
|
|
350
358
|
|
|
@@ -918,6 +926,7 @@ stateDiagram-v2
|
|
|
918
926
|
- **Identifiers**: Has locked_at and locked_by values set
|
|
919
927
|
- **Behavior**: Protected against concurrent execution
|
|
920
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).
|
|
921
930
|
|
|
922
931
|
#### Completed
|
|
923
932
|
- **Description**: The workflow has successfully executed all steps
|
|
@@ -966,25 +975,60 @@ ChronoForge::Workflow.failed.find_each(&:retry_later)
|
|
|
966
975
|
The class-level form (`MyWorkflow.retry_now(key)` / `retry_later(key)`) still
|
|
967
976
|
works if you have the class and key rather than the record.
|
|
968
977
|
|
|
969
|
-
####
|
|
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.
|
|
970
988
|
|
|
971
|
-
|
|
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:
|
|
972
992
|
|
|
973
993
|
```ruby
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
.where('locked_at < ?', 30.minutes.ago)
|
|
994
|
+
ChronoForge::Workflow.reap_stalled
|
|
995
|
+
# => 3 (also logs "ChronoForge reaped 3 stalled workflow(s)")
|
|
977
996
|
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
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)
|
|
985
1025
|
end
|
|
986
1026
|
```
|
|
987
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
|
+
|
|
988
1032
|
## 🧹 Cleanup & Retention
|
|
989
1033
|
|
|
990
1034
|
ChronoForge keeps every workflow and execution-log row indefinitely so that
|
data/docs/fanout-scale-test.md
CHANGED
|
@@ -64,8 +64,7 @@ degradation as the tables grew. The path has a **stable steady-state ceiling**.
|
|
|
64
64
|
|
|
65
65
|
## Part 2 — Benchmark: baseline vs commit-consolidation
|
|
66
66
|
|
|
67
|
-
**Baseline** is
|
|
68
|
-
unreleased patch targeted for **v0.12** that cuts per-child write cost without
|
|
67
|
+
**Baseline** is (v0.10). **Consolidation** is the patch released in **v0.11** that cuts per-child write cost without
|
|
69
68
|
changing behaviour. It (a) folds `started_at` into the lock-acquire
|
|
70
69
|
transaction, (b) collapses `complete_workflow!`'s three separate commits — marker
|
|
71
70
|
INSERT + `workflow.completed!` UPDATE + marker UPDATE — into one transaction, and
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# Design: Stalled-Workflow Reaper for ChronoForge
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-07-09
|
|
4
|
+
**Gem:** `chrono_forge` (with `chrono_forge-dashboard`)
|
|
5
|
+
**Status:** Approved for planning
|
|
6
|
+
|
|
7
|
+
## Problem
|
|
8
|
+
|
|
9
|
+
A workflow that is mid-pass when its worker is **hard-killed** (SIGKILL: deploy/rollout,
|
|
10
|
+
OOM, node eviction, SolidQueue heartbeat prune) is stranded permanently in `state: running`
|
|
11
|
+
with a stale lock, and nothing ever resumes it.
|
|
12
|
+
|
|
13
|
+
The mechanism, confirmed against the code:
|
|
14
|
+
|
|
15
|
+
- `Executor#perform` releases the lock and publishes the resume continuation in its `ensure`
|
|
16
|
+
block (`lib/chrono_forge/executor.rb:178-197`): `release_lock` first, then
|
|
17
|
+
`flush_continuation!`.
|
|
18
|
+
- `enqueue_continuation` (`executor.rb:369-371`) only records `@continuation` in memory;
|
|
19
|
+
`flush_continuation!` (`executor.rb:376-382`) is what actually enqueues the ActiveJob.
|
|
20
|
+
- On SIGKILL, Ruby's `ensure` does not run. So (1) `release_lock` never runs — the row stays
|
|
21
|
+
`state: running` with a past `locked_at`; and (2) `flush_continuation!` never runs — no job
|
|
22
|
+
is scheduled to resume the workflow.
|
|
23
|
+
|
|
24
|
+
The workflow is fully *resumable* — `Workflow#executable?` is `idle? || running?`
|
|
25
|
+
(`workflow.rb:48-50`) and `LockStrategy.acquire_lock` steals any lock older than
|
|
26
|
+
`max_duration` (10 min) (`lock_strategy.rb:17-21`) — but nothing re-enqueues it. Existing
|
|
27
|
+
recovery mechanisms do not cover this:
|
|
28
|
+
|
|
29
|
+
- **`BranchMergeJob` rekick** (`branch_merge_job.rb:232-273`) only re-enqueues children that
|
|
30
|
+
are `state: idle, started_at: nil` (never-started dropped dispatches). A child hard-killed
|
|
31
|
+
**mid-pass** is `state: running` with `started_at` set — explicitly excluded (see the
|
|
32
|
+
comment at `branch_merge_job.rb:226-231`). So branch children have the identical gap.
|
|
33
|
+
- **Workflow-level retry** rides on the same process that must survive to its `ensure`; a
|
|
34
|
+
killed process never reaches it.
|
|
35
|
+
- **`retry_now`/`retry_later`** require `retryable?` (`stalled? || failed?`,
|
|
36
|
+
`workflow.rb:53-55`); a stranded workflow is `running`, so they refuse it.
|
|
37
|
+
- **Dashboard "Force unlock"** clears the lock but enqueues nothing.
|
|
38
|
+
|
|
39
|
+
Real-world impact (reported): 735 workflows stranded over ~8.5 months in one production app,
|
|
40
|
+
still accruing ~8/day.
|
|
41
|
+
|
|
42
|
+
## Solution Overview
|
|
43
|
+
|
|
44
|
+
Add a reconciliation entry point, `ChronoForge::Workflow.reap_stalled`, that finds workflows
|
|
45
|
+
stuck in `running` past a safety threshold and re-enqueues them. Re-enqueue is safe:
|
|
46
|
+
`acquire_lock` steals the stale lock and completed durable steps replay as no-ops.
|
|
47
|
+
|
|
48
|
+
### Decisions
|
|
49
|
+
|
|
50
|
+
| Decision | Choice | Rationale |
|
|
51
|
+
|---|---|---|
|
|
52
|
+
| Shipping | **Class method only** | No Railtie/rake task/self-scheduling job. Host apps call it from their own cron/recurring job. Smallest surface; no new gem infrastructure. |
|
|
53
|
+
| Scope | **All stranded `running` workflows** (top-level AND branch children) | Strictly more correct — rekick misses mid-pass-killed children. Re-enqueue of a child directly is the proven-safe pattern rekick already uses. |
|
|
54
|
+
| Threshold | **Config + per-call override** | `ChronoForge.config.reap_stale_after` (default `30.minutes`), overridable via `stale_after:` arg. Parity with the existing `branch_merge_queue` config. |
|
|
55
|
+
| Deliverable | **Code + tests + docs** | Implementation, tests (incl. simulated hard-kill strand), and docs covering the reaper + step-idempotency expectations. |
|
|
56
|
+
|
|
57
|
+
## Component
|
|
58
|
+
|
|
59
|
+
`ChronoForge::Workflow.reap_stalled` — a class method on the AR model. It only queries and
|
|
60
|
+
re-enqueues; it holds no lock and performs no replay itself (the re-enqueued job does the
|
|
61
|
+
replay, under the normal lock protocol).
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
# ChronoForge::Workflow
|
|
65
|
+
def self.reap_stalled(stale_after: ChronoForge.config.reap_stale_after)
|
|
66
|
+
reaped = 0
|
|
67
|
+
where(state: states[:running])
|
|
68
|
+
.where("locked_at < ?", stale_after.ago)
|
|
69
|
+
.find_each do |wf|
|
|
70
|
+
wf.job_klass.perform_later(wf.key, **wf.kwargs.symbolize_keys)
|
|
71
|
+
reaped += 1
|
|
72
|
+
rescue => e
|
|
73
|
+
Rails.logger.error { "ChronoForge reap failed for workflow(#{wf.key}): #{e.class}: #{e.message}" }
|
|
74
|
+
end
|
|
75
|
+
Rails.logger.info { "ChronoForge reaped #{reaped} stalled workflow(s)" } if reaped.positive?
|
|
76
|
+
reaped
|
|
77
|
+
end
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Design points:
|
|
81
|
+
|
|
82
|
+
- **Target = `running` + stale `locked_at`.** The strand happens precisely because the
|
|
83
|
+
`ensure` never ran, so `release_lock` never flipped the row off `running`. A `NULL locked_at`
|
|
84
|
+
cannot match `locked_at < ?` and is naturally excluded (an anomalous running-without-lock row
|
|
85
|
+
is not our target).
|
|
86
|
+
- **Sweeps children too.** No `parent_execution_log_id` filter. A re-enqueued child runs,
|
|
87
|
+
replays, completes, and updates its `BranchProbe` state so the parent's merge poller observes
|
|
88
|
+
completion.
|
|
89
|
+
- **Re-enqueue via the public guarded `perform_later`.** `wf.kwargs` is a JSON column (string
|
|
90
|
+
keys); `.symbolize_keys` for `**`, matching `setup_workflow!` and `rekick_dropped_jobs`. The
|
|
91
|
+
guard rejects `RESERVED_KWARGS`, which stored user kwargs never contain. Fresh `attempt: 0`
|
|
92
|
+
— a full workflow-retry budget, exactly like a normal resume.
|
|
93
|
+
- **Per-row rescue** so one bad row (e.g. cross-version kwarg drift tripping the enqueue guard)
|
|
94
|
+
never aborts the sweep — mirrors `rekick_dropped_jobs` (`branch_merge_job.rb:265-270`).
|
|
95
|
+
- **Returns the reaped count** for cron visibility; logs via `Rails.logger` block syntax.
|
|
96
|
+
|
|
97
|
+
## Configuration
|
|
98
|
+
|
|
99
|
+
`max_duration` (previously a hardcoded `10.minutes` in the executor) becomes a config value,
|
|
100
|
+
and `reap_stale_after` **derives from it** so the "reap threshold must exceed the lock-steal
|
|
101
|
+
threshold" invariant is automatic rather than documented-and-hoped.
|
|
102
|
+
|
|
103
|
+
Add to `ChronoForge::Configuration`:
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
# How long a single pass may hold its lock before it's stealable (feeds
|
|
107
|
+
# LockStrategy.acquire_lock via the executor's #max_duration). Default 10 minutes.
|
|
108
|
+
attr_accessor :max_duration
|
|
109
|
+
|
|
110
|
+
# Age past which a :running workflow is considered stranded and re-enqueued by
|
|
111
|
+
# Workflow.reap_stalled. Defaults to 3x max_duration (30 min out of the box) so it always
|
|
112
|
+
# clears the lock-steal threshold; an explicit value overrides the derived default.
|
|
113
|
+
def reap_stale_after
|
|
114
|
+
@reap_stale_after || max_duration * 3
|
|
115
|
+
end
|
|
116
|
+
attr_writer :reap_stale_after
|
|
117
|
+
|
|
118
|
+
# in #initialize:
|
|
119
|
+
@max_duration = 10.minutes
|
|
120
|
+
@reap_stale_after = nil
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The executor's `#max_duration` now reads `ChronoForge.config.max_duration` instead of
|
|
124
|
+
returning a literal. Deriving `reap_stale_after` from `max_duration` means raising one raises
|
|
125
|
+
the other, so the reaper can never be configured below the lock-steal threshold by accident.
|
|
126
|
+
|
|
127
|
+
## Idempotency & Safety (documented behavior)
|
|
128
|
+
|
|
129
|
+
- **Concurrency-safe.** Overlapping cron runs, or a re-enqueue landing while the old stale
|
|
130
|
+
lock still shows, at worst enqueue a duplicate job — the second loses the `acquire_lock`
|
|
131
|
+
race and no-ops via `ConcurrentExecutionError`. Mild, harmless churn. Recommend a cron
|
|
132
|
+
interval comfortably longer than a typical resume pass.
|
|
133
|
+
- **Step idempotency.** Reaping replays the interrupted pass. A `durably_execute` step only
|
|
134
|
+
short-circuits when `completed`, so a step whose side effect committed but whose log never
|
|
135
|
+
reached `completed` will re-run on replay. Steps with external side effects must be
|
|
136
|
+
idempotent (natural/unique key + `create_or_find_by`/rescue). Documented alongside
|
|
137
|
+
`wait`/`durably_execute`.
|
|
138
|
+
|
|
139
|
+
## Non-Goals (conscious boundaries)
|
|
140
|
+
|
|
141
|
+
- **Dropped `BranchMergeJob` poller → parent stranded `idle`.** A parent parked on a merge
|
|
142
|
+
whose poller was hard-killed sits `idle`, not `running`, so this reaper won't catch it. A
|
|
143
|
+
distinct failure mode; the code already hints (`branch_merge_job.rb:181-184`) that a
|
|
144
|
+
"`next_poll_at` far in the past with work still pending" detector is the right fix. Out of
|
|
145
|
+
scope here.
|
|
146
|
+
- **Micro-window between `release_lock` and `flush_continuation!`.** A process dying between
|
|
147
|
+
those two statements yields `idle` + no continuation. Theoretically possible, not seen in the
|
|
148
|
+
reported evidence (all strands were `running`), not covered.
|
|
149
|
+
|
|
150
|
+
## Testing
|
|
151
|
+
|
|
152
|
+
- **Query selection (unit):** selects only `running` + stale-lock rows; excludes
|
|
153
|
+
fresh-lock `running`, `idle`, `completed`, `failed`, `stalled`, and `NULL locked_at`.
|
|
154
|
+
- **Strand-and-recover (behavioral):** create a workflow, force it into `running` with an old
|
|
155
|
+
`locked_at` and no scheduled resume job (simulating the hard-kill), run `reap_stalled`,
|
|
156
|
+
assert a job is enqueued and that draining the queue resumes and completes the workflow.
|
|
157
|
+
Cover both a top-level workflow and a branch child.
|
|
158
|
+
- **Per-row rescue:** a row that raises on re-enqueue does not abort the sweep; the returned
|
|
159
|
+
count reflects only successful re-enqueues.
|
|
160
|
+
- **Config:** default `reap_stale_after` honored; per-call `stale_after:` override honored.
|
|
161
|
+
|
|
162
|
+
## Documentation
|
|
163
|
+
|
|
164
|
+
- Document `Workflow.reap_stalled` and the recommended cron wiring (host-owned; no shipped
|
|
165
|
+
task), including the `reap_stale_after > max_duration` constraint.
|
|
166
|
+
- Document the step-idempotency expectation alongside `wait`/`durably_execute`.
|
|
167
|
+
|
|
168
|
+
## Alternatives Considered
|
|
169
|
+
|
|
170
|
+
- **Railtie + `chrono_forge:reap` rake task** (angarium `angarium:reap` parity) — rejected;
|
|
171
|
+
heavier, would add the gem's first Railtie.
|
|
172
|
+
- **Self-scheduling reaper ActiveJob** — rejected; adds an always-on job and scheduling
|
|
173
|
+
assumptions the host may not want.
|
|
174
|
+
- **Top-level-only sweep** (`parent_execution_log_id IS NULL`) — rejected; leaves
|
|
175
|
+
mid-pass-killed branch children stranded, which rekick does not recover.
|
|
@@ -18,8 +18,34 @@ module ChronoForge
|
|
|
18
18
|
# runs promptly throughout the drain.
|
|
19
19
|
attr_accessor :branch_merge_queue
|
|
20
20
|
|
|
21
|
+
# How long a single workflow pass may hold its lock before another job is
|
|
22
|
+
# allowed to steal it (LockStrategy.acquire_lock treats a lock older than this
|
|
23
|
+
# as stale). It bounds the assumed maximum duration of one execution pass.
|
|
24
|
+
# Defaults to 10 minutes.
|
|
25
|
+
attr_accessor :max_duration
|
|
26
|
+
|
|
21
27
|
def initialize
|
|
22
28
|
@branch_merge_queue = :default
|
|
29
|
+
@max_duration = 10.minutes
|
|
30
|
+
@reap_stale_after = nil
|
|
23
31
|
end
|
|
32
|
+
|
|
33
|
+
# Age past which a workflow still in :running is treated as stranded and
|
|
34
|
+
# re-enqueued by ChronoForge::Workflow.reap_stalled. A workflow reaches this
|
|
35
|
+
# state when its worker is hard-killed (SIGKILL/OOM/eviction) mid-pass, before
|
|
36
|
+
# the executor's `ensure` block could release the lock and publish the resume
|
|
37
|
+
# continuation — so it stays locked in :running with nothing scheduled to wake it.
|
|
38
|
+
#
|
|
39
|
+
# Defaults to 3x max_duration (30 min out of the box), so it always comfortably
|
|
40
|
+
# exceeds the lock-steal threshold: acquire_lock only steals locks older than
|
|
41
|
+
# max_duration, so a shorter reap threshold would just enqueue resume jobs that
|
|
42
|
+
# immediately no-op via ConcurrentExecutionError. Deriving from max_duration keeps
|
|
43
|
+
# that invariant automatic — raise max_duration and the reaper backs off with it.
|
|
44
|
+
# An explicit value (set via the writer) overrides the derived default.
|
|
45
|
+
def reap_stale_after
|
|
46
|
+
@reap_stale_after || max_duration * 3
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
attr_writer :reap_stale_after
|
|
24
50
|
end
|
|
25
51
|
end
|
data/lib/chrono_forge/version.rb
CHANGED
|
@@ -45,6 +45,49 @@ module ChronoForge
|
|
|
45
45
|
stalled
|
|
46
46
|
]
|
|
47
47
|
|
|
48
|
+
# Reconcile workflows stranded in :running by a hard-killed worker. When a
|
|
49
|
+
# worker is SIGKILLed (deploy/rollout, OOM, node eviction, SolidQueue heartbeat
|
|
50
|
+
# prune) mid-pass, the executor's `ensure` block never runs, so the lock is
|
|
51
|
+
# never released (the row stays :running with a stale locked_at) and the resume
|
|
52
|
+
# continuation is never published — nothing is left to wake the workflow. No
|
|
53
|
+
# other mechanism recovers this: workflow-level retry rides on the same process
|
|
54
|
+
# that must reach its `ensure`; retry_now/retry_later require stalled?/failed?;
|
|
55
|
+
# and BranchMergeJob rekick only re-drives never-started idle children.
|
|
56
|
+
#
|
|
57
|
+
# This sweeps every workflow in :running whose lock is older than `stale_after`
|
|
58
|
+
# (top-level AND branch children) and re-enqueues it. Re-enqueue is safe:
|
|
59
|
+
# acquire_lock steals the stale lock and completed durable steps replay as
|
|
60
|
+
# no-ops. Overlapping sweeps (or a re-enqueue landing while the old stale lock
|
|
61
|
+
# still shows) at worst enqueue a duplicate, which loses the acquire_lock race
|
|
62
|
+
# and no-ops via ConcurrentExecutionError.
|
|
63
|
+
#
|
|
64
|
+
# Intended to be run periodically by the host app (e.g. a SolidQueue recurring
|
|
65
|
+
# task or cron). Returns the number of workflows re-enqueued.
|
|
66
|
+
#
|
|
67
|
+
# NOTE: replaying an interrupted pass re-runs any durably_execute step whose
|
|
68
|
+
# side effect committed but whose log never reached :completed. Steps with
|
|
69
|
+
# external side effects must be idempotent (natural/unique key +
|
|
70
|
+
# create_or_find_by/rescue).
|
|
71
|
+
def self.reap_stalled(stale_after: ChronoForge.config.reap_stale_after)
|
|
72
|
+
reaped = 0
|
|
73
|
+
where(state: states[:running])
|
|
74
|
+
.where("locked_at < ?", stale_after.ago)
|
|
75
|
+
.find_each do |workflow|
|
|
76
|
+
# Guarded per row: one bad workflow (e.g. a since-deleted job class that
|
|
77
|
+
# no longer constantizes, or cross-version kwarg drift tripping the
|
|
78
|
+
# enqueue guard) must never abort the sweep and strand every healthy
|
|
79
|
+
# sibling. Mirrors BranchMergeJob#rekick_dropped_jobs.
|
|
80
|
+
workflow.job_klass.perform_later(workflow.key, **workflow.kwargs.symbolize_keys)
|
|
81
|
+
reaped += 1
|
|
82
|
+
rescue => e
|
|
83
|
+
Rails.logger.error do
|
|
84
|
+
"ChronoForge reap failed for workflow(#{workflow.key}): #{e.class}: #{e.message}"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
Rails.logger.info { "ChronoForge reaped #{reaped} stalled workflow(s)" } if reaped.positive?
|
|
88
|
+
reaped
|
|
89
|
+
end
|
|
90
|
+
|
|
48
91
|
def executable?
|
|
49
92
|
idle? || running?
|
|
50
93
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: chrono_forge
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.12.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Stefan Froelich
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07-
|
|
11
|
+
date: 2026-07-09 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activerecord
|
|
@@ -234,6 +234,7 @@ files:
|
|
|
234
234
|
- docs/superpowers/specs/2026-06-26-dashboard-branch-view-design.md
|
|
235
235
|
- docs/superpowers/specs/2026-06-26-deferral-continuation-race-and-catchup-design.md
|
|
236
236
|
- docs/superpowers/specs/2026-07-01-workflow-definition-dag-design.md
|
|
237
|
+
- docs/superpowers/specs/2026-07-09-chrono-forge-reaper-design.md
|
|
237
238
|
- examples/continue_if_webhook_example.rb
|
|
238
239
|
- gemfiles/rails_7.1.gemfile
|
|
239
240
|
- gemfiles/rails_7.1.gemfile.lock
|