durable_flow 0.1.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 +7 -0
- data/MIT-LICENSE +19 -0
- data/README.md +552 -0
- data/app/controllers/durable_flow/workflow_runs_controller.rb +71 -0
- data/app/views/durable_flow/workflow_runs/index.html.erb +74 -0
- data/app/views/durable_flow/workflow_runs/show.html.erb +180 -0
- data/app/views/layouts/durable_flow/application.html.erb +464 -0
- data/config/routes.rb +6 -0
- data/lib/durable_flow/dispatcher.rb +26 -0
- data/lib/durable_flow/engine.rb +7 -0
- data/lib/durable_flow/errors.rb +38 -0
- data/lib/durable_flow/event_subscriber.rb +31 -0
- data/lib/durable_flow/live.rb +67 -0
- data/lib/durable_flow/models/application_record.rb +7 -0
- data/lib/durable_flow/models/workflow_event.rb +55 -0
- data/lib/durable_flow/models/workflow_log.rb +36 -0
- data/lib/durable_flow/models/workflow_run.rb +101 -0
- data/lib/durable_flow/models/workflow_step.rb +48 -0
- data/lib/durable_flow/models/workflow_wait.rb +38 -0
- data/lib/durable_flow/railtie.rb +11 -0
- data/lib/durable_flow/schema.rb +144 -0
- data/lib/durable_flow/serializer.rb +15 -0
- data/lib/durable_flow/step_proxy.rb +22 -0
- data/lib/durable_flow/test_helper.rb +217 -0
- data/lib/durable_flow/version.rb +5 -0
- data/lib/durable_flow/workflow.rb +361 -0
- data/lib/durable_flow/workflow_logger.rb +42 -0
- data/lib/durable_flow/workflow_timeline.rb +188 -0
- data/lib/durable_flow.rb +146 -0
- data/lib/generators/durable_flow/install_generator.rb +22 -0
- data/lib/generators/durable_flow/templates/create_durable_flow_tables.rb +93 -0
- metadata +216 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 43d876d7d9d09c0c0c883d510fb18634f6a6b7f81445287b1414353294181bd4
|
|
4
|
+
data.tar.gz: ed01f4612ff81e58afb7cbdbf2a2a8ab8aa236973d5062fca60726d59db25261
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 9d24efb9075c6755a7f4cc67408c1c79b42adc1656134d468af2e6a1c7c259707819c6f7771843e145daed37777ead0577e990a048d8940aff2477eadc28df06
|
|
7
|
+
data.tar.gz: 81ca1ec109939a621565d94bd858dde30091ab87e275263db52bdb6a37ad15b589cfa5606200aab9bbcac1671657bcd2c128f8e0102488597e7a4a388163f222
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Copyright (c) 2026 DurableFlow contributors
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
# DurableFlow
|
|
2
|
+
|
|
3
|
+
DurableFlow is an Inngest-style workflow runtime for Rails, built on `ActiveJob::Continuable`, Active Record, Solid Queue, and `Rails.event`.
|
|
4
|
+
|
|
5
|
+
It lets you write long-running business workflows as normal Ruby methods. Side effects live in named `step` blocks. Step return values are persisted, so after a crash, deploy, sleep, event wakeup, or retry, the workflow replays from the top and completed steps return their stored values without running again.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
class WelcomeWorkflow < DurableFlow::Workflow
|
|
9
|
+
def perform(user_id:, trial_id:)
|
|
10
|
+
user = step(:load_user) { User.find(user_id) }
|
|
11
|
+
|
|
12
|
+
step(:send_welcome) { UserMailer.welcome(user).deliver_now }
|
|
13
|
+
|
|
14
|
+
step.sleep(:trial_delay, 1.day)
|
|
15
|
+
|
|
16
|
+
trial = step(:start_trial) { Billing.start_trial!(user, trial_id:) }
|
|
17
|
+
|
|
18
|
+
event = step.wait_for_event(:trial_confirmed, timeout: 7.days, match: { trial_id: trial.id })
|
|
19
|
+
|
|
20
|
+
step(:finalize) { user.update!(onboarded_at: Time.current, confirmed_at: event[:confirmed_at]) }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The goal is durable, observable workflows without a separate workflow server, Redis dependency, or external control plane.
|
|
26
|
+
|
|
27
|
+
## Alpha Notice
|
|
28
|
+
|
|
29
|
+
DurableFlow is alpha software. The API and storage model will likely change as the design is exercised in real applications, and this gem has not yet been used in a production setting.
|
|
30
|
+
|
|
31
|
+
## UI
|
|
32
|
+
|
|
33
|
+
DurableFlow ships with a small mountable Rails engine for inspecting workflow runs, step timelines, waits, workflow logs, arguments, and errors.
|
|
34
|
+
|
|
35
|
+

|
|
36
|
+
|
|
37
|
+

|
|
38
|
+
|
|
39
|
+
## Live Updates
|
|
40
|
+
|
|
41
|
+
DurableFlow emits committed lifecycle changes for workflow runs, steps, waits, events, and workflow logs. The default broadcaster is a no-op, so live UI is opt-in.
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
# config/initializers/durable_flow.rb
|
|
45
|
+
DurableFlow.live_broadcaster = ->(change) do
|
|
46
|
+
next unless change.run_id
|
|
47
|
+
|
|
48
|
+
ActionCable.server.broadcast(
|
|
49
|
+
"durable_flow:run:#{change.run_id}",
|
|
50
|
+
change.payload
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Each change includes:
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
change.type # "workflow_run.updated", "workflow_step.created", "workflow_log.created", ...
|
|
59
|
+
change.run_id # nil for standalone workflow_event.created changes
|
|
60
|
+
change.workflow_class
|
|
61
|
+
change.record_class
|
|
62
|
+
change.record_id
|
|
63
|
+
change.record # Active Record object for in-process renderers
|
|
64
|
+
change.snapshot # small serializable hash
|
|
65
|
+
change.payload # snapshot plus type/run metadata
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Workflow logs are emitted the same way:
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
step(:create_refund) do
|
|
72
|
+
log.info("Creating refund", refund_id: refund.id)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Broadcasts:
|
|
76
|
+
change.type # "workflow_log.created"
|
|
77
|
+
change.payload.fetch(:level) # "info"
|
|
78
|
+
change.payload.fetch(:message) # "Creating refund"
|
|
79
|
+
change.payload.fetch(:workflow_step_id)
|
|
80
|
+
change.record.data_value # { refund_id: ... } for in-process renderers
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
You can also register subscribers:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
DurableFlow.on_change do |change|
|
|
87
|
+
Rails.logger.info("[DurableFlow] #{change.type} #{change.run_id}")
|
|
88
|
+
end
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
For Turbo Streams, keep authorization in the host app and render whatever partial fits your UI:
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
DurableFlow.live_broadcaster = ->(change) do
|
|
95
|
+
next unless change.run_id
|
|
96
|
+
|
|
97
|
+
Turbo::StreamsChannel.broadcast_replace_to(
|
|
98
|
+
"durable_flow:run:#{change.run_id}",
|
|
99
|
+
target: "workflow_run",
|
|
100
|
+
partial: "workflow_runs/live_run",
|
|
101
|
+
locals: { run_id: change.run_id }
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Broadcasts run from `after_commit`, and broadcaster errors are reported but do not fail workflow execution.
|
|
107
|
+
|
|
108
|
+
## Timeline Data
|
|
109
|
+
|
|
110
|
+
Use `WorkflowRun#timeline` when building a custom UI. It preloads and groups the run's steps, waits, matched events, and logs so applications do not need to recreate the join logic.
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
run = DurableFlow::WorkflowRun.find_by!(run_id: params[:run_id])
|
|
114
|
+
timeline = run.timeline
|
|
115
|
+
|
|
116
|
+
timeline.step_entries.each do |entry|
|
|
117
|
+
entry.step # DurableFlow::WorkflowStep
|
|
118
|
+
entry.logs # logs written inside this step
|
|
119
|
+
entry.waits # waits created by this step
|
|
120
|
+
entry.events # events that matched those waits
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
timeline.run_logs # logs written outside a step
|
|
124
|
+
timeline.items # flat chronological items: :step, :wait, :event, :log
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Demo App
|
|
128
|
+
|
|
129
|
+
The repo includes a small live Rails demo app at `examples/live_demo`.
|
|
130
|
+
|
|
131
|
+
```sh
|
|
132
|
+
mise exec ruby@3.4 -- bundle exec ruby examples/live_demo/server.rb
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Open `http://127.0.0.1:4568/live`, start one or more review workflows, then approve or reject the latest waiting run. The page updates from `DurableFlow.live_broadcaster` using Server-Sent Events and links to the mounted engine UI at `/durable_flow`. Use **Reset demo** to clear the local demo database.
|
|
136
|
+
|
|
137
|
+
## Status
|
|
138
|
+
|
|
139
|
+
This is a working prototype targeted at Rails 8.1+ continuation and structured event APIs.
|
|
140
|
+
|
|
141
|
+
Verified behavior:
|
|
142
|
+
|
|
143
|
+
- Rails 8.1.3 compatibility.
|
|
144
|
+
- Step return-value memoization across replays.
|
|
145
|
+
- Dynamic string step names.
|
|
146
|
+
- Cursor-based `ActiveJob::Continuable` loops.
|
|
147
|
+
- Durable sleep through `perform_later(wait_until:)`.
|
|
148
|
+
- Event waits through `Rails.event`.
|
|
149
|
+
- Parent workflows waiting for child workflow completion.
|
|
150
|
+
- Solid Queue `1.1.2` integration.
|
|
151
|
+
- Database-backed workflow execution leases to prevent concurrent execution of the same run.
|
|
152
|
+
- Opt-in live lifecycle broadcasts through `DurableFlow.live_broadcaster`.
|
|
153
|
+
- Explicit workflow logs through `log.info`, `log.warn`, `log.error`, and `log.debug`.
|
|
154
|
+
|
|
155
|
+
## Install
|
|
156
|
+
|
|
157
|
+
Use the current GitHub `main` branch:
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
# Gemfile
|
|
161
|
+
gem "durable_flow", git: "https://github.com/skorfmann/durableflow.git", branch: "main"
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
For a less moving target, pin a commit SHA with `ref:` instead of `branch:`.
|
|
165
|
+
|
|
166
|
+
For local development before publishing the gem:
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
# Gemfile
|
|
170
|
+
gem "durable_flow", path: "../durableflow"
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
For a published gem:
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
# Gemfile
|
|
177
|
+
gem "durable_flow"
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Then install the tables:
|
|
181
|
+
|
|
182
|
+
```sh
|
|
183
|
+
bundle install
|
|
184
|
+
bin/rails generate durable_flow:install
|
|
185
|
+
bin/rails db:migrate
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
DurableFlow runs through Active Job. Solid Queue is the recommended adapter:
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
# config/application.rb or config/environments/production.rb
|
|
192
|
+
config.active_job.queue_adapter = :solid_queue
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Mount the optional workflow run viewer:
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
# config/routes.rb
|
|
199
|
+
mount DurableFlow::Engine => "/durable_flow"
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Configure
|
|
203
|
+
|
|
204
|
+
Workflow runs use a database-backed execution lease so concurrent workers do not execute the same run at the same time. The lease is refreshed as steps start and continuations checkpoint.
|
|
205
|
+
|
|
206
|
+
The default TTL is 10 minutes. Set it longer than your longest single step that does not checkpoint:
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
# config/initializers/durable_flow.rb
|
|
210
|
+
DurableFlow.execution_lock_ttl = 15.minutes
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
What that means:
|
|
214
|
+
|
|
215
|
+
```ruby
|
|
216
|
+
step(:sync_big_account) do
|
|
217
|
+
SomeApi.sync_everything(account) # If this can take 25 minutes, use a TTL over 25 minutes.
|
|
218
|
+
end
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
If you process records in a cursor loop and call `advance!` or `checkpoint!`, the lease refreshes automatically as the loop progresses:
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
step :sync_accounts, start: 0 do |s|
|
|
225
|
+
Account.find_each(start: s.cursor) do |account|
|
|
226
|
+
SyncAccount.call(account)
|
|
227
|
+
s.advance!(from: account.id)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## Write a Workflow
|
|
233
|
+
|
|
234
|
+
Create an application base class if you want shared defaults:
|
|
235
|
+
|
|
236
|
+
```ruby
|
|
237
|
+
# app/workflows/application_workflow.rb
|
|
238
|
+
class ApplicationWorkflow < DurableFlow::Workflow
|
|
239
|
+
queue_as :default
|
|
240
|
+
end
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Then write workflows as plain Ruby:
|
|
244
|
+
|
|
245
|
+
```ruby
|
|
246
|
+
# app/workflows/trial_onboarding_workflow.rb
|
|
247
|
+
class TrialOnboardingWorkflow < ApplicationWorkflow
|
|
248
|
+
def perform(user_id:, trial_id:)
|
|
249
|
+
user = step(:load_user) { User.find(user_id) }
|
|
250
|
+
|
|
251
|
+
step(:send_welcome_email) do
|
|
252
|
+
UserMailer.with(user: user).welcome.deliver_now
|
|
253
|
+
true
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
step.sleep(:wait_for_trial_activity, 3.days)
|
|
257
|
+
|
|
258
|
+
event = step.wait_for_event(
|
|
259
|
+
:trial_activated,
|
|
260
|
+
timeout: 4.days,
|
|
261
|
+
match: { trial_id: trial_id },
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
step(:mark_onboarded) do
|
|
265
|
+
user.update!(onboarded_at: Time.current, onboarding_source: event[:source])
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Start it like any Active Job:
|
|
272
|
+
|
|
273
|
+
```ruby
|
|
274
|
+
TrialOnboardingWorkflow.perform_later(user_id: user.id, trial_id: trial.id)
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Wake it with a Rails event:
|
|
278
|
+
|
|
279
|
+
```ruby
|
|
280
|
+
Rails.event.notify(:trial_activated, trial_id: trial.id, source: "checkout")
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
## Step API
|
|
284
|
+
|
|
285
|
+
Memoized side-effect step:
|
|
286
|
+
|
|
287
|
+
```ruby
|
|
288
|
+
order = step(:create_order) { Order.create!(cart:) }
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Durable sleep:
|
|
292
|
+
|
|
293
|
+
```ruby
|
|
294
|
+
step.sleep(:retry_tomorrow, 1.day)
|
|
295
|
+
step.sleep(:wait_until_send_at, until: campaign.send_at)
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Wait for a Rails event:
|
|
299
|
+
|
|
300
|
+
```ruby
|
|
301
|
+
event = step.wait_for_event(:payment_received, timeout: 2.days, match: { invoice_id: invoice.id })
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
Use a different event name than the step name:
|
|
305
|
+
|
|
306
|
+
```ruby
|
|
307
|
+
event = step.wait_for_event(:wait_for_charge, event: :stripe_charge_succeeded, match: { charge_id: charge.id })
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Wait for a child workflow:
|
|
311
|
+
|
|
312
|
+
```ruby
|
|
313
|
+
child_run_id = step(:start_child) { SendInvoiceWorkflow.perform_later(invoice.id).job_id }
|
|
314
|
+
completion = step.wait_for_workflow(:child_finished, child_run_id, timeout: 1.hour)
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
Write structured workflow logs:
|
|
318
|
+
|
|
319
|
+
```ruby
|
|
320
|
+
step(:create_refund) do
|
|
321
|
+
log.info("Creating refund", refund_id: refund.id, amount_cents: refund.amount_cents)
|
|
322
|
+
Refunds.create!(refund)
|
|
323
|
+
end
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
Logs are persisted with the current workflow run and, when called inside a step, the current workflow step. They also emit `workflow_log.created` live changes.
|
|
327
|
+
|
|
328
|
+
## Iteration Patterns
|
|
329
|
+
|
|
330
|
+
Small bounded list: one memoized step per item.
|
|
331
|
+
|
|
332
|
+
```ruby
|
|
333
|
+
item_ids.each do |item_id|
|
|
334
|
+
step("notify-#{item_id}") { Notifier.item_ready(item_id).deliver_now }
|
|
335
|
+
end
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
Large cursor loop: one continuation step with cursor checkpoints.
|
|
339
|
+
|
|
340
|
+
```ruby
|
|
341
|
+
step :process_orders, start: 0 do |s|
|
|
342
|
+
Order.pending.find_each(start: s.cursor) do |order|
|
|
343
|
+
ProcessOrder.call(order)
|
|
344
|
+
s.advance!(from: order.id)
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Parallel or high-cardinality work: fan out to child workflows.
|
|
350
|
+
|
|
351
|
+
```ruby
|
|
352
|
+
child_run_ids = step(:start_children) do
|
|
353
|
+
account.users.find_each.map { |user| SyncUserWorkflow.perform_later(user.id).job_id }
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
child_run_ids.each do |run_id|
|
|
357
|
+
step.wait_for_workflow("child-#{run_id}", run_id, timeout: 30.minutes)
|
|
358
|
+
end
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
## Rules
|
|
362
|
+
|
|
363
|
+
- Put side effects inside `step` blocks.
|
|
364
|
+
- Keep code outside `step` blocks deterministic and cheap. It runs on every replay.
|
|
365
|
+
- Step names must be stable across replays. Use `"notify-#{record.id}"`, not `SecureRandom.uuid`.
|
|
366
|
+
- Step return values must be Active Job serializable.
|
|
367
|
+
- Steps should still be idempotent where practical. If a process crashes after a side effect runs but before the step result commits, that step can run again.
|
|
368
|
+
- Set `DurableFlow.execution_lock_ttl` longer than your longest non-checkpointing step.
|
|
369
|
+
|
|
370
|
+
## Tables
|
|
371
|
+
|
|
372
|
+
The install generator creates:
|
|
373
|
+
|
|
374
|
+
- `durable_flow_workflow_runs`
|
|
375
|
+
- `durable_flow_workflow_steps`
|
|
376
|
+
- `durable_flow_workflow_events`
|
|
377
|
+
- `durable_flow_workflow_waits`
|
|
378
|
+
- `durable_flow_workflow_logs`
|
|
379
|
+
|
|
380
|
+
Step results, workflow arguments, and log data are serialized through Active Job. Event payloads are stored from `Rails.event` notifications and matched against pending waits.
|
|
381
|
+
|
|
382
|
+
## Testing
|
|
383
|
+
|
|
384
|
+
Executable examples live in `examples/workflows.rb` and are covered by `test/durable_flow/examples_test.rb`.
|
|
385
|
+
|
|
386
|
+
DurableFlow also ships test helpers for application test suites:
|
|
387
|
+
|
|
388
|
+
```ruby
|
|
389
|
+
# test/test_helper.rb
|
|
390
|
+
require "durable_flow/test_helper"
|
|
391
|
+
|
|
392
|
+
class ActiveSupport::TestCase
|
|
393
|
+
include DurableFlow::TestHelper
|
|
394
|
+
|
|
395
|
+
setup { clear_durable_flow! }
|
|
396
|
+
end
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
Example workflow test:
|
|
400
|
+
|
|
401
|
+
```ruby
|
|
402
|
+
class TrialOnboardingWorkflowTest < ActiveSupport::TestCase
|
|
403
|
+
test "waits for trial activation and finishes" do
|
|
404
|
+
freeze_time do
|
|
405
|
+
changes = capture_durable_flow_changes do
|
|
406
|
+
TrialOnboardingWorkflow.perform_later(user_id: users(:ada).id, trial_id: trials(:pending).id)
|
|
407
|
+
perform_durable_flow_jobs(at: Time.current)
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
run = durable_flow_run_for(TrialOnboardingWorkflow)
|
|
411
|
+
|
|
412
|
+
assert_step_succeeded run, :send_welcome_email
|
|
413
|
+
assert_workflow_sleeping run, step: :wait_for_trial_activity
|
|
414
|
+
assert_durable_flow_change changes, "workflow_run.created"
|
|
415
|
+
|
|
416
|
+
travel_to_next_workflow_wake run
|
|
417
|
+
perform_durable_flow_jobs(at: Time.current)
|
|
418
|
+
|
|
419
|
+
assert_workflow_waiting_for run, :trial_activated, match: { trial_id: trials(:pending).id }
|
|
420
|
+
|
|
421
|
+
resume_workflows_for :trial_activated, trial_id: trials(:pending).id, source: "checkout"
|
|
422
|
+
|
|
423
|
+
assert_workflow_completed run
|
|
424
|
+
assert_step_result run, :mark_onboarded, true
|
|
425
|
+
assert_workflow_log run, level: :info, message: "Trial activated"
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
Useful helpers include `perform_durable_flow_jobs`, `resume_workflows_for`, `travel_to_next_workflow_wake`, `durable_flow_run_for`, `durable_flow_timeline_for`, `assert_workflow_completed`, `assert_workflow_sleeping`, `assert_workflow_waiting_for`, `assert_step_succeeded`, `assert_step_result`, `assert_step_attempts`, `assert_workflow_log`, `assert_step_log`, `capture_durable_flow_changes`, and `assert_durable_flow_change`.
|
|
432
|
+
|
|
433
|
+
Run the suite against the vendored Rails copy:
|
|
434
|
+
|
|
435
|
+
```sh
|
|
436
|
+
mise exec ruby@3.4 -- bundle exec rake test
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
Run it against released Rails 8.1:
|
|
440
|
+
|
|
441
|
+
```sh
|
|
442
|
+
RAILS_VERSION=8.1.3 mise exec ruby@3.4 -- bundle exec rake test
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
Current suite:
|
|
446
|
+
|
|
447
|
+
```text
|
|
448
|
+
25 runs, 212 assertions, 0 failures, 0 errors, 0 skips
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
## Publishing
|
|
452
|
+
|
|
453
|
+
Gem releases are published manually from GitHub Actions using pre-1.0 SemVer versions such as `0.1.0`, `0.1.1`, and `0.2.0`.
|
|
454
|
+
|
|
455
|
+
Open **Actions -> Release gem -> Run workflow**, select `main`, and enter the version to publish.
|
|
456
|
+
|
|
457
|
+
The workflow validates the `x.y.z` version input, writes that version into `lib/durable_flow/version.rb` inside the CI checkout, runs the test suite, builds the gem, and pushes it to RubyGems.
|
|
458
|
+
|
|
459
|
+
## Copyable App Prompt
|
|
460
|
+
|
|
461
|
+
Paste this into Codex, Claude Code, or another coding agent inside a Rails app:
|
|
462
|
+
|
|
463
|
+
```text
|
|
464
|
+
Add DurableFlow workflows to this Rails application.
|
|
465
|
+
|
|
466
|
+
Use the DurableFlow gem from:
|
|
467
|
+
- GitHub main: gem "durable_flow", git: "https://github.com/skorfmann/durableflow.git", branch: "main"
|
|
468
|
+
- local path: gem "durable_flow", path: "../durableflow"
|
|
469
|
+
- or published gem: gem "durable_flow"
|
|
470
|
+
|
|
471
|
+
Tasks:
|
|
472
|
+
1. Add the gem to the Gemfile and run bundle install.
|
|
473
|
+
2. Run bin/rails generate durable_flow:install and migrate the database.
|
|
474
|
+
3. Ensure Active Job uses Solid Queue in the relevant environment.
|
|
475
|
+
4. Mount DurableFlow::Engine at /durable_flow.
|
|
476
|
+
5. Add config/initializers/durable_flow.rb with DurableFlow.execution_lock_ttl set longer than the app's longest non-checkpointing workflow step.
|
|
477
|
+
6. Create app/workflows/application_workflow.rb inheriting from DurableFlow::Workflow.
|
|
478
|
+
7. Convert this business process into a DurableFlow workflow:
|
|
479
|
+
[Describe the business process here.]
|
|
480
|
+
8. Put all side effects inside named step blocks.
|
|
481
|
+
9. Use step.sleep for durable delays.
|
|
482
|
+
10. Use step.wait_for_event for external callbacks or user actions, and emit matching Rails.event.notify calls from the app code that receives those callbacks.
|
|
483
|
+
11. Add log.info/log.warn/log.error calls for important workflow milestones, using structured fields such as model ids, tokens, and external request ids.
|
|
484
|
+
12. Add tests using DurableFlow::TestHelper that prove:
|
|
485
|
+
- completed steps do not run twice on replay,
|
|
486
|
+
- sleep resumes correctly,
|
|
487
|
+
- event waits resume only for matching payloads,
|
|
488
|
+
- timeout behavior is explicit.
|
|
489
|
+
|
|
490
|
+
Important constraints:
|
|
491
|
+
- Step names must be stable across replays.
|
|
492
|
+
- Step return values must be Active Job serializable.
|
|
493
|
+
- Code outside step blocks must be deterministic and cheap.
|
|
494
|
+
- Workflow log data must be Active Job serializable.
|
|
495
|
+
- If a single step can run longer than DurableFlow.execution_lock_ttl without checkpointing, increase the TTL or split the work into checkpointed chunks.
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
## Copyable Human-in-the-Loop Prompt
|
|
499
|
+
|
|
500
|
+
DurableFlow does not ship a generic human task system. For most apps, the right implementation depends on your existing users, permissions, admin UI, notifications, and domain models.
|
|
501
|
+
|
|
502
|
+
Paste this into a coding agent inside your Rails app when a workflow needs a human decision:
|
|
503
|
+
|
|
504
|
+
```text
|
|
505
|
+
Add a human-in-the-loop step to this DurableFlow workflow.
|
|
506
|
+
|
|
507
|
+
Use app-native Rails models and UI. Do not add a generic DurableFlow task framework.
|
|
508
|
+
|
|
509
|
+
Workflow:
|
|
510
|
+
[Name or paste the workflow here.]
|
|
511
|
+
|
|
512
|
+
Human decision needed:
|
|
513
|
+
[Describe the decision, for example "finance approves or rejects a refund".]
|
|
514
|
+
|
|
515
|
+
Build this using DurableFlow's existing event wait primitive:
|
|
516
|
+
1. Add an app model for the pending decision if one does not already exist. Keep it domain-specific, for example RefundApproval, AccountReview, DeploymentApproval, or KycReview.
|
|
517
|
+
2. In the workflow, create that record inside a named step.
|
|
518
|
+
3. Pause the workflow with step.wait_for_event, matching on that decision record's id or public token.
|
|
519
|
+
4. Add the app UI/controller action that lets an authorized human submit the decision.
|
|
520
|
+
5. In that controller action, persist the decision and emit Rails.event.notify with the event name and matching id/token.
|
|
521
|
+
6. Resume the workflow and branch on the event payload.
|
|
522
|
+
7. Add tests for approve, reject, and timeout paths.
|
|
523
|
+
|
|
524
|
+
Example shape:
|
|
525
|
+
|
|
526
|
+
approval = step(:create_refund_approval) { RefundApproval.create!(refund: refund, status: "pending") }
|
|
527
|
+
|
|
528
|
+
decision = step.wait_for_event(
|
|
529
|
+
:refund_approval_decided,
|
|
530
|
+
timeout: 2.days,
|
|
531
|
+
match: { refund_approval_id: approval.id }
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
Controller completion should emit:
|
|
535
|
+
|
|
536
|
+
Rails.event.notify(
|
|
537
|
+
:refund_approval_decided,
|
|
538
|
+
refund_approval_id: approval.id,
|
|
539
|
+
decision: "approved",
|
|
540
|
+
decided_by_id: Current.user.id
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
Constraints:
|
|
544
|
+
- Use the app's existing authentication and authorization.
|
|
545
|
+
- Use a public token instead of a sequential database id for public links or external callbacks.
|
|
546
|
+
- Keep the decision record domain-specific and easy to query from the app's admin UI.
|
|
547
|
+
- Put workflow side effects inside named step blocks.
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
## What It Is Not
|
|
551
|
+
|
|
552
|
+
DurableFlow is not trying to replace Temporal or Inngest Cloud. It is an in-app Rails workflow runtime for teams that want durable, observable, multi-step business logic while staying inside Rails, Active Job, and their database.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DurableFlow
|
|
4
|
+
class WorkflowRunsController < ActionController::Base
|
|
5
|
+
layout "durable_flow/application"
|
|
6
|
+
|
|
7
|
+
helper_method :durable_flow_duration,
|
|
8
|
+
:durable_flow_format_time,
|
|
9
|
+
:durable_flow_json,
|
|
10
|
+
:durable_flow_status_class
|
|
11
|
+
|
|
12
|
+
def index
|
|
13
|
+
@workflow_runs = WorkflowRun.order(created_at: :desc).limit(100)
|
|
14
|
+
@status_counts = @workflow_runs.group_by(&:status).transform_values(&:count)
|
|
15
|
+
@active_count = @workflow_runs.count { |run| !run.terminal? }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def show
|
|
19
|
+
@workflow_run = WorkflowRun.find_by!(run_id: params[:run_id])
|
|
20
|
+
@workflow_timeline = @workflow_run.timeline
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
def durable_flow_status_class(status)
|
|
25
|
+
case status.to_s
|
|
26
|
+
when "completed", "succeeded", "matched"
|
|
27
|
+
"success"
|
|
28
|
+
when "failed", "timed_out"
|
|
29
|
+
"danger"
|
|
30
|
+
when "waiting", "sleeping", "pending"
|
|
31
|
+
"waiting"
|
|
32
|
+
when "running", "ready", "retrying", "enqueued", "info"
|
|
33
|
+
"active"
|
|
34
|
+
when "warn"
|
|
35
|
+
"waiting"
|
|
36
|
+
when "error"
|
|
37
|
+
"danger"
|
|
38
|
+
else
|
|
39
|
+
"neutral"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def durable_flow_format_time(value)
|
|
44
|
+
return "—" unless value
|
|
45
|
+
|
|
46
|
+
value.in_time_zone.strftime("%b %-d, %H:%M:%S %Z")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def durable_flow_duration(started_at, finished_at = nil)
|
|
50
|
+
return "—" unless started_at
|
|
51
|
+
|
|
52
|
+
seconds = ((finished_at || Time.current) - started_at).to_i
|
|
53
|
+
return "#{seconds}s" if seconds < 60
|
|
54
|
+
|
|
55
|
+
minutes = seconds / 60
|
|
56
|
+
return "#{minutes}m" if minutes < 60
|
|
57
|
+
|
|
58
|
+
hours = minutes / 60
|
|
59
|
+
remaining_minutes = minutes % 60
|
|
60
|
+
remaining_minutes.zero? ? "#{hours}h" : "#{hours}h #{remaining_minutes}m"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def durable_flow_json(value)
|
|
64
|
+
return "" if value.blank?
|
|
65
|
+
|
|
66
|
+
JSON.pretty_generate(value)
|
|
67
|
+
rescue JSON::GeneratorError
|
|
68
|
+
value.inspect
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|