rigid_workflow 1.0.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/LICENSE.txt +648 -0
- data/README.md +427 -0
- data/app/assets/stylesheets/rigid_workflow/application.css +68 -0
- data/app/controllers/rigid_workflow/application_controller.rb +90 -0
- data/app/controllers/rigid_workflow/runs_controller.rb +133 -0
- data/app/controllers/rigid_workflow/workflows_controller.rb +11 -0
- data/app/helpers/rigid_workflow/runs_helper.rb +63 -0
- data/app/helpers/rigid_workflow/workflows_helper.rb +110 -0
- data/app/javascript/rigid_workflow/application.js +9 -0
- data/app/javascript/rigid_workflow/controllers/application.js +7 -0
- data/app/javascript/rigid_workflow/controllers/index.js +4 -0
- data/app/javascript/rigid_workflow/controllers/selection_controller.js +91 -0
- data/app/javascript/rigid_workflow/controllers/workflow_viz_controller.js +160 -0
- data/app/jobs/rigid_workflow/activity_job.rb +14 -0
- data/app/jobs/rigid_workflow/timer_job.rb +13 -0
- data/app/jobs/rigid_workflow/workflow_job.rb +17 -0
- data/app/models/rigid_workflow/run.rb +209 -0
- data/app/models/rigid_workflow/signal.rb +77 -0
- data/app/models/rigid_workflow/step.rb +182 -0
- data/app/models/rigid_workflow/step_attempt.rb +48 -0
- data/app/views/rigid_workflow/layouts/application.html.erb +77 -0
- data/app/views/rigid_workflow/runs/index.html.erb +113 -0
- data/app/views/rigid_workflow/runs/show.html.erb +130 -0
- data/app/views/rigid_workflow/workflows/index.html.erb +82 -0
- data/config/importmap.rb +11 -0
- data/config/routes.rb +17 -0
- data/lib/generators/rigid_workflow/activity_generator.rb +15 -0
- data/lib/generators/rigid_workflow/install_generator.rb +58 -0
- data/lib/generators/rigid_workflow/templates/activity.rb.erb +6 -0
- data/lib/generators/rigid_workflow/templates/initializer.rb.erb +24 -0
- data/lib/generators/rigid_workflow/templates/migration.rb.erb +54 -0
- data/lib/generators/rigid_workflow/templates/workflow.rb.erb +6 -0
- data/lib/generators/rigid_workflow/workflow_generator.rb +15 -0
- data/lib/rigid_workflow/activity.rb +54 -0
- data/lib/rigid_workflow/engine.rb +35 -0
- data/lib/rigid_workflow/id_generator.rb +21 -0
- data/lib/rigid_workflow/orchestrator.rb +83 -0
- data/lib/rigid_workflow/step_result.rb +50 -0
- data/lib/rigid_workflow/version.rb +5 -0
- data/lib/rigid_workflow/workflow.rb +59 -0
- data/lib/rigid_workflow/workflow_runner.rb +334 -0
- data/lib/rigid_workflow.rb +65 -0
- metadata +204 -0
data/README.md
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
# Rigid Workflow
|
|
2
|
+
|
|
3
|
+
A durable workflow orchestration engine for Rails applications. Built on top of ActiveJob and Solid Queue.
|
|
4
|
+
|
|
5
|
+
## Why Rigid Workflow
|
|
6
|
+
|
|
7
|
+
In mature Rails applications, background jobs often become complex chains of inter-dependent tasks. Managing this complexity with raw `ActiveJob` calls is error-prone. Rigid Workflow solves this by providing:
|
|
8
|
+
|
|
9
|
+
- **Explicit State**: Workflows are defined as code, with state persisted automatically in your database.
|
|
10
|
+
- **Observability**: A built-in Admin UI to visualize, monitor, and retry workflows.
|
|
11
|
+
- **Complexity Management**: Native support for parallel execution, race conditions, signals/waits, and compensation (Saga pattern) without external dependencies (like Redis/Temporal) beyond what Rails already provides (ActiveJob).
|
|
12
|
+
|
|
13
|
+
## Admin UI
|
|
14
|
+
|
|
15
|
+
<p align="center">
|
|
16
|
+
<a href="screenshot_rw.png"><img src="screenshot_rw.png" width="640" alt="Rigid Workflow Admin UI"></a>
|
|
17
|
+
</p>
|
|
18
|
+
|
|
19
|
+
## Use Cases
|
|
20
|
+
|
|
21
|
+
**Order Fulfillment** — Reserve inventory, charge payment, ship. Compensate on failure
|
|
22
|
+
→ Saga pattern via `rescue`/`raise` to release inventory if payment fails
|
|
23
|
+
|
|
24
|
+
**Content Moderation** — AI-powered content scanning with human-in-the-loop review
|
|
25
|
+
→ `race` between auto-approve, auto-reject, and manual review signals
|
|
26
|
+
|
|
27
|
+
**Video Processing** — Parallel transcoding across formats with thumbnail generation
|
|
28
|
+
→ `parallel` for fan-out execution, `loop` for thumbnail extraction at timestamps
|
|
29
|
+
|
|
30
|
+
**Subscription Billing** — Payment with retries and fallback to downgrade
|
|
31
|
+
→ `step` with `max_attempts` and `retry_delay` for payment gateways
|
|
32
|
+
|
|
33
|
+
**Employee Offboarding** — Sequential multi-step business process across systems
|
|
34
|
+
→ `step` chaining with persistent state across job enqueues
|
|
35
|
+
|
|
36
|
+
**User Onboarding** — Trial provisioning with time-gated auto-teardown
|
|
37
|
+
→ `wait` with `timeout`, conditional branching per plan tier
|
|
38
|
+
|
|
39
|
+
**AI Pipelines** — Scrape websites, chunk content, generate embeddings, summarize with LLMs. Each step retries independently when APIs flake.
|
|
40
|
+
→ `step` with per-step `max_attempts` and `retry_delay`
|
|
41
|
+
|
|
42
|
+
**RAG Pipelines** — Chunk documents, generate embeddings, index content. Multi-step LLM chains with persistent state.
|
|
43
|
+
→ `step` chaining, `loop` over document collections, `memo` for caching
|
|
44
|
+
|
|
45
|
+
## Features
|
|
46
|
+
|
|
47
|
+
- **DSL-based workflow definitions** - Define workflows with a clear, expressive syntax
|
|
48
|
+
- **Sequential and parallel execution** - Run steps in sequence or concurrently
|
|
49
|
+
- **Controlled race conditions** - First to complete wins (signal vs. timer)
|
|
50
|
+
- **Wait states and signals** - Pause workflows for external events, with optional timeouts
|
|
51
|
+
- **Retry logic with exponential backoff** - Automatic retries with configurable delay and jitter
|
|
52
|
+
- **Saga/Compensation pattern** - Auto-compensate completed steps in reverse order when an activity exhausts retries
|
|
53
|
+
- **Workflow versioning** - Track versions of workflow definitions; runs resume safely across version changes
|
|
54
|
+
- **Memoization** - Persist non-deterministic values across workflow resumptions
|
|
55
|
+
- **Looping over collections** - Iterate with persistent state tracking per item
|
|
56
|
+
- **Persistent state** - All workflow state is stored in the database
|
|
57
|
+
- **Admin UI** - Built-in dashboard with overview stats, filtering, pagination, Gantt chart visualization, and bulk actions
|
|
58
|
+
- **Event instrumentation** - `ActiveSupport::Notifications` integration for all key lifecycle events
|
|
59
|
+
- **Generators** - Rails generators for install, workflow, and activity scaffolding
|
|
60
|
+
|
|
61
|
+
## Requirements
|
|
62
|
+
|
|
63
|
+
- Ruby >= 3.1
|
|
64
|
+
- Rails >= 7.0
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Security Requirements
|
|
69
|
+
|
|
70
|
+
⚠️ **Authentication Required**: Rigid Workflow's admin UI has NO built-in authentication. You MUST secure it:
|
|
71
|
+
|
|
72
|
+
### Option 1: Use `admin_controller` config (Recommended)
|
|
73
|
+
|
|
74
|
+
Set `admin_controller` to a controller that already has authentication (e.g., from Devise):
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
# config/initializers/rigid_workflow.rb
|
|
78
|
+
RigidWorkflow.configure do |config|
|
|
79
|
+
config.admin_controller = "Admin::BaseController"
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The engine's `ApplicationController` will inherit from this class, automatically applying your authentication.
|
|
84
|
+
|
|
85
|
+
### Option 2: Wrap routes in host app
|
|
86
|
+
|
|
87
|
+
Authenticate at the route level in your `config/routes.rb`:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
# For Devise:
|
|
91
|
+
authenticate :user do
|
|
92
|
+
mount RigidWorkflow::Engine => "/admin/rigid_workflow"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# For custom auth:
|
|
96
|
+
authenticate :admin_user do
|
|
97
|
+
mount RigidWorkflow::Engine => "/admin/rigid_workflow"
|
|
98
|
+
end
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
**Consequence of skipping authentication**: All workflow data (runs, steps, signals, job details) will be publicly accessible to anyone who can reach the mounted route.
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Installation
|
|
106
|
+
|
|
107
|
+
Add to your Gemfile:
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
gem "rigid_workflow"
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Run the installer:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
rails generate rigid_workflow:install
|
|
117
|
+
rails db:migrate
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The install generator creates:
|
|
121
|
+
|
|
122
|
+
- `config/initializers/rigid_workflow.rb` - Configuration file
|
|
123
|
+
- Mounts the engine in `config/routes.rb`
|
|
124
|
+
- A migration to create the 4 required tables (`rigid_workflow_runs`, `rigid_workflow_steps`, `rigid_workflow_step_attempts`, `rigid_workflow_signals`)
|
|
125
|
+
|
|
126
|
+
### Additional Generators
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
# Generate a workflow class
|
|
130
|
+
rails generate rigid_workflow:workflow OrderProcessing
|
|
131
|
+
|
|
132
|
+
# Generate an activity class
|
|
133
|
+
rails generate rigid_workflow:activity ChargeCustomer
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Quick Start
|
|
137
|
+
|
|
138
|
+
### Define a Workflow
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
# app/workflows/order_processing_workflow.rb
|
|
142
|
+
class OrderProcessingWorkflow < RigidWorkflow::Workflow
|
|
143
|
+
class ValidateOrder < RigidWorkflow::Activity
|
|
144
|
+
def perform(order_id:, **)
|
|
145
|
+
order = Order.find(order_id)
|
|
146
|
+
raise "Invalid order" unless order.valid?
|
|
147
|
+
{ order_id: order_id, validated: true }
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
class ProcessPayment < RigidWorkflow::Activity
|
|
152
|
+
def perform(order_id:, **)
|
|
153
|
+
# Process payment...
|
|
154
|
+
{ payment_id: "pay_123", order_id: order_id }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Called if a later step fails (Saga pattern)
|
|
158
|
+
def compensate
|
|
159
|
+
refund_payment(output[:payment_id])
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
class SendConfirmation < RigidWorkflow::Activity
|
|
164
|
+
def perform(order_id:, **)
|
|
165
|
+
OrderMailer.confirmation(order_id).deliver_later
|
|
166
|
+
{ confirmed: true }
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
version 2 # Optional: track workflow definition versions
|
|
171
|
+
|
|
172
|
+
def run
|
|
173
|
+
step :validate, ValidateOrder, input: { order_id: params[:order_id] }
|
|
174
|
+
step :payment, ProcessPayment, input: { order_id: params[:order_id] }
|
|
175
|
+
step :confirm, SendConfirmation, input: { order_id: params[:order_id] }
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Start a Workflow
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
run = OrderProcessingWorkflow.start!(order_id: 123)
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Emit a Signal
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
run.emit_signal(:payment_received, method: "applepay")
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## DSL Reference
|
|
193
|
+
|
|
194
|
+
### Steps
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
step :name, ActivityClass, input: { key: "value" }, async: true
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Options:
|
|
201
|
+
|
|
202
|
+
| Option | Description | Default |
|
|
203
|
+
| --------------- | --------------------------------------------- | --------------- |
|
|
204
|
+
| `input:` | Hash of input data passed to the activity | Workflow params |
|
|
205
|
+
| `async:` | Run asynchronously (enqueued as a job) | `false` |
|
|
206
|
+
| `wait:` | Delay before executing (e.g., `wait: 1.hour`) | `nil` |
|
|
207
|
+
| `wait_until:` | Execute at a specific time | `nil` |
|
|
208
|
+
| `max_attempts:` | Number of retry attempts | `3` |
|
|
209
|
+
| `retry_delay:` | Base delay for exponential backoff | `15.seconds` |
|
|
210
|
+
|
|
211
|
+
**Async restriction**: `async: true` steps will suspend the workflow. Use them inside `parallel` or `race` blocks to avoid suspension, or mark the activity class with `force_async`:
|
|
212
|
+
|
|
213
|
+
```ruby
|
|
214
|
+
class SlowActivity < RigidWorkflow::Activity
|
|
215
|
+
force_async true
|
|
216
|
+
|
|
217
|
+
def perform(**)
|
|
218
|
+
# This step always runs asynchronously, even outside parallel/race
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### Loops
|
|
224
|
+
|
|
225
|
+
```ruby
|
|
226
|
+
loop :items, collection do |item, index|
|
|
227
|
+
step :process_item, ProcessItemActivity, input: { item: item }
|
|
228
|
+
end
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
The loop index is persisted in workflow memory. If the workflow is interrupted and resumes, it picks up where it left off.
|
|
232
|
+
|
|
233
|
+
### Parallel Execution
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
parallel :notifications do
|
|
237
|
+
step :email, SendEmailActivity
|
|
238
|
+
step :sms, SendSmsActivity
|
|
239
|
+
step :push, SendPushActivity
|
|
240
|
+
end
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
All steps within a `parallel` block run concurrently via ActiveJob. The workflow suspends until all complete.
|
|
244
|
+
|
|
245
|
+
### Controlled Race Conditions
|
|
246
|
+
|
|
247
|
+
```ruby
|
|
248
|
+
race :approval do
|
|
249
|
+
wait :manual_approval
|
|
250
|
+
wait :auto_approval, timeout: 24.hours
|
|
251
|
+
end
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
The first signal to arrive wins. The other branches are canceled.
|
|
255
|
+
|
|
256
|
+
### Wait States
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
# Wait indefinitely for a signal
|
|
260
|
+
wait :payment_received
|
|
261
|
+
|
|
262
|
+
# Wait with timeout
|
|
263
|
+
wait :payment_received, timeout: 1.hour
|
|
264
|
+
|
|
265
|
+
# Emit a signal elsewhere to resume that workflow
|
|
266
|
+
run.emit_signal(:payment_received, method: "applepay")
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
### Saga / Compensation
|
|
270
|
+
|
|
271
|
+
When an activity exhausts all retries, the workflow automatically compensates all previously completed steps in reverse order:
|
|
272
|
+
|
|
273
|
+
```ruby
|
|
274
|
+
class ReserveInventory < RigidWorkflow::Activity
|
|
275
|
+
def perform(product_id:, quantity:, **)
|
|
276
|
+
{ reserved: true }
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def compensate
|
|
280
|
+
inventory.release(output[:product_id], output[:quantity])
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Compensation is called on each completed step's activity. If compensation itself fails, the run stays in `compensating` status for manual intervention.
|
|
286
|
+
|
|
287
|
+
### Memoization
|
|
288
|
+
|
|
289
|
+
```ruby
|
|
290
|
+
def run
|
|
291
|
+
user = memo(:current_user) { User.find(params[:user_id]) }
|
|
292
|
+
# User is cached in workflow memory across resumptions
|
|
293
|
+
end
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
### Versioning
|
|
297
|
+
|
|
298
|
+
```ruby
|
|
299
|
+
class OrderWorkflow < RigidWorkflow::Workflow
|
|
300
|
+
version 2
|
|
301
|
+
|
|
302
|
+
def run
|
|
303
|
+
if @run_version < 2
|
|
304
|
+
# Legacy path for runs started before v2
|
|
305
|
+
else
|
|
306
|
+
# Current logic
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
Set `version` in the workflow class. Each run captures the version at start time. Access it via `@run_version` for conditional logic.
|
|
313
|
+
|
|
314
|
+
## Admin UI
|
|
315
|
+
|
|
316
|
+
Mount the engine in your routes:
|
|
317
|
+
|
|
318
|
+
```ruby
|
|
319
|
+
# config/routes.rb
|
|
320
|
+
mount RigidWorkflow::Engine => "/admin/rigid_workflow"
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
### Pages
|
|
324
|
+
|
|
325
|
+
- **Overview** (`/admin/rigid_workflow`) - Stats table per workflow class: completed/active/pending/failed counts, success percentage, P50 duration
|
|
326
|
+
- **All Runs** (`/admin/rigid_workflow/runs`) - Paginated list with filtering by status (pending/active/completed/failed), clickable rows, select-all checkboxes, bulk action bar (retry/cancel selected runs)
|
|
327
|
+
- **Run Detail** (`/admin/rigid_workflow/runs/:id`) - Run metadata, step attempt history, and an interactive vis-timeline Gantt chart
|
|
328
|
+
|
|
329
|
+
### Tech Stack
|
|
330
|
+
|
|
331
|
+
- Tailwind CSS v4 (CDN)
|
|
332
|
+
- Hotwire Turbo + Stimulus (importmap-managed)
|
|
333
|
+
- vis-timeline for interactive Gantt charts
|
|
334
|
+
- LocalTime for client-side time formatting
|
|
335
|
+
- Kaminari for pagination
|
|
336
|
+
|
|
337
|
+
## Event Instrumentation
|
|
338
|
+
|
|
339
|
+
All key lifecycle events emit via `ActiveSupport::Notifications`:
|
|
340
|
+
|
|
341
|
+
```ruby
|
|
342
|
+
# Subscribe to events
|
|
343
|
+
RigidWorkflow.on("workflow.complete") do |payload|
|
|
344
|
+
payload # => { run_id: "..." }
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
RigidWorkflow.on("step.fail") do |payload|
|
|
348
|
+
payload # => { run_id: "...", step_id: "..." }
|
|
349
|
+
end
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
Available events: `workflow.start`, `workflow.complete`, `workflow.fail`, `step.complete`, `step.fail`, `step.retry`, `step.canceled`
|
|
353
|
+
|
|
354
|
+
## Configuration
|
|
355
|
+
|
|
356
|
+
```ruby
|
|
357
|
+
# config/initializers/rigid_workflow.rb
|
|
358
|
+
RigidWorkflow.configure do |config|
|
|
359
|
+
# REQUIRED for admin UI security. Controller class the admin UI inherits from.
|
|
360
|
+
config.admin_controller = "MyAdminController"
|
|
361
|
+
|
|
362
|
+
# Maximum retry attempts for failed activities (default: 3)
|
|
363
|
+
config.max_attempts = 3
|
|
364
|
+
|
|
365
|
+
# Base delay for exponential backoff retries in seconds (default: 15)
|
|
366
|
+
config.retry_delay = 15.seconds
|
|
367
|
+
|
|
368
|
+
# Enable logging output in all environments including test (default: false)
|
|
369
|
+
config.logging = true
|
|
370
|
+
end
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
| Option | Default | Description |
|
|
374
|
+
| ------------------ | ------------ | ------------------------------------------------------------- |
|
|
375
|
+
| `admin_controller` | `nil` | Controller class for admin UI inheritance (required for auth) |
|
|
376
|
+
| `max_attempts` | `3` | Maximum retry attempts for failed activities |
|
|
377
|
+
| `retry_delay` | `15.seconds` | Base delay for exponential backoff (±20% jitter) |
|
|
378
|
+
| `logging` | `nil` | Enable workflow/activity log output even in test |
|
|
379
|
+
|
|
380
|
+
## FAQ
|
|
381
|
+
|
|
382
|
+
### 1. How does Rigid Workflow handle "zombie" processes?
|
|
383
|
+
|
|
384
|
+
If a workflow fails or crashes, the last known state remains in the database. When the next worker picks up the job, it continues exactly where it left off. If one of the activities fails, they will be retried automatically by the underlying job queue. Upon success or max attempts reached, the workflow will continue or fail respectively.
|
|
385
|
+
|
|
386
|
+
### 2. What happens if my database goes down?
|
|
387
|
+
|
|
388
|
+
Since all state transitions are transactional within your Rails database, if the database is down, the workflow jobs will fail and be retried by the job queue. Once the database is back up, the workers will retry processing the enqueued jobs.
|
|
389
|
+
|
|
390
|
+
### 3. How do I handle versioning of workflows?
|
|
391
|
+
|
|
392
|
+
Workflows are code. If you change a workflow definition while a run is in progress:
|
|
393
|
+
|
|
394
|
+
- **New steps**: Will be picked up as the workflow advances.
|
|
395
|
+
- **Removed steps**: If already completed, their results remain in history. If not yet reached, they are skipped.
|
|
396
|
+
- **Changed logic**: Will apply to all future steps of the currently running workflow.
|
|
397
|
+
For breaking changes, we recommend creating a new workflow class (e.g., `OrderWorkflowV2`).
|
|
398
|
+
|
|
399
|
+
### 4. Can I use this for workflows that take months?
|
|
400
|
+
|
|
401
|
+
Yes. Because state is persisted in the database and execution is driven by job scheduling, signals, and timers, a workflow can sit in a waiting state indefinitely without consuming CPU or memory.
|
|
402
|
+
|
|
403
|
+
### 5. How do I test these workflows?
|
|
404
|
+
|
|
405
|
+
You can test individual `Activity` classes in isolation or use integration tests to verify the entire `Workflow` flow. The project itself uses RSpec with an in-memory SQLite database, DatabaseCleaner, and automatic job performance for testing. See the `spec/` directory for examples.
|
|
406
|
+
|
|
407
|
+
### 6. Is there a performance overhead?
|
|
408
|
+
|
|
409
|
+
Definitely, every `step` results in at least one database write to persist the state. This is the trade-off for durability. For high-throughput, sub-millisecond tasks, raw `ActiveJob` with Redis might be faster, but for business-critical processes where you cannot afford to lose state, the overhead is acceptable.
|
|
410
|
+
|
|
411
|
+
### 7. How does it compare to Temporal, Sidekiq, or State Machines?
|
|
412
|
+
|
|
413
|
+
**Temporal**: Temporal is a separate system (Go/Java server) that requires a heavy infrastructure setup. Rigid Workflow is built on top of Rails and ActiveJob. It lives in your existing DB and uses your existing workers infrastructure.
|
|
414
|
+
|
|
415
|
+
**Sidekiq**: Sidekiq is a job queue. While you can chain jobs in Sidekiq, managing state, retries, and compensation across chains is manual and complex. Rigid Workflow provides a durable workflow orchestration engine. It is ready to go.
|
|
416
|
+
|
|
417
|
+
**State Machines (AASM)**: State machines track the status of a model (e.g., an Order). Rigid Workflow tracks the _process_ of a business flow. They are often used together: a Workflow might update an Order's state machine.
|
|
418
|
+
|
|
419
|
+
**DBOS**: is a multi-language (TypeScript, Python, Java, Kotlin) durable execution library that checkpoints workflow state to Postgres via decorators/annotations. Rigid Workflow is Rails-only and uses ActiveJob + your Rails database.
|
|
420
|
+
|
|
421
|
+
**When to choose Rigid Workflow**: You're building a Rails app and want a full-featured workflow engine with Saga, signals, race conditions, and an admin dashboard out of the box — all within your existing database and worker infrastructure.
|
|
422
|
+
|
|
423
|
+
## License
|
|
424
|
+
|
|
425
|
+
[AGPL-3.0-or-later](./LICENSE.txt)
|
|
426
|
+
|
|
427
|
+
Rigid Workflow is licensed under the GNU Affero General Public License (AGPL) v3.0 or later. This means that you are allowed to modify the code and/or provide it as a Software-as-a-Service, but you are required to make your modifications available to the users of that service.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
button {
|
|
2
|
+
cursor: pointer;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/* vis-timeline */
|
|
6
|
+
|
|
7
|
+
html .vis-timeline {
|
|
8
|
+
border: 1px solid var(--color-gray-200);
|
|
9
|
+
border-radius: 0.5rem;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
html .vis-timeline .vis-top {
|
|
13
|
+
background-color: color-mix(in srgb, var(--color-gray-100), transparent 50%);
|
|
14
|
+
font-size: 0.9rem;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
html .vis-timeline .vis-panel {
|
|
18
|
+
border-color: var(--color-gray-200);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
html .vis-timeline .vis-top .vis-major {
|
|
22
|
+
font-weight: 500;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
html .vis-timeline .vis-center {
|
|
26
|
+
background-image:
|
|
27
|
+
linear-gradient(to right, transparent 39px, #f1f5f9 1px),
|
|
28
|
+
linear-gradient(to bottom, transparent 39px, #f1f5f9 1px);
|
|
29
|
+
background-size: 40px 40px;
|
|
30
|
+
background-color: #ffffff;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
html .vis-item .vis-item-overflow {
|
|
34
|
+
overflow: visible;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
html .vis-item .vis-item-content {
|
|
38
|
+
font-size: 0.8rem;
|
|
39
|
+
position: relative;
|
|
40
|
+
padding-right: 14px;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
html .vis-item .vis-item-content::after {
|
|
44
|
+
content: "";
|
|
45
|
+
position: absolute;
|
|
46
|
+
right: 4px;
|
|
47
|
+
top: 50%;
|
|
48
|
+
transform: translateY(-50%);
|
|
49
|
+
width: 5px;
|
|
50
|
+
height: 5px;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
html .vis-item.timeline-status-completed .vis-item-content::after {
|
|
54
|
+
background-color: #16a34a;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
html .vis-item.timeline-status-failed .vis-item-content::after {
|
|
58
|
+
background-color: #dc2626;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
html .vis-item.timeline-status-pending .vis-item-content::after,
|
|
62
|
+
html .vis-item.timeline-status-running .vis-item-content::after {
|
|
63
|
+
background-color: #9ca3af;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
html .vis-item.timeline-status-canceled .vis-item-content::after {
|
|
67
|
+
background-color: #6b7280;
|
|
68
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Namespace for RigidWorkflow controllers.
|
|
4
|
+
module RigidWorkflow
|
|
5
|
+
parent_class =
|
|
6
|
+
begin
|
|
7
|
+
RigidWorkflow.config.admin_controller.constantize
|
|
8
|
+
rescue NameError
|
|
9
|
+
raise ArgumentError,
|
|
10
|
+
"Please set config.admin_controller in RigidWorkflow initializer."
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Base controller for RigidWorkflow admin interface.
|
|
14
|
+
# Inherits from the configured admin controller and provides shared functionality.
|
|
15
|
+
class ApplicationController < parent_class
|
|
16
|
+
include Rails.application.routes.url_helpers
|
|
17
|
+
include LocalTimeHelper
|
|
18
|
+
|
|
19
|
+
protect_from_forgery with: :exception
|
|
20
|
+
|
|
21
|
+
layout "rigid_workflow/layouts/application"
|
|
22
|
+
|
|
23
|
+
def default_url_options
|
|
24
|
+
{
|
|
25
|
+
host: ENV.fetch("HOST", "localhost"),
|
|
26
|
+
protocol: ENV.fetch("PROTOCOL", "http")
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
before_action :set_admin_sidebar
|
|
33
|
+
|
|
34
|
+
def set_admin_sidebar
|
|
35
|
+
@sidebar = [
|
|
36
|
+
{
|
|
37
|
+
heading: "Rigid Workflow",
|
|
38
|
+
items: [
|
|
39
|
+
{ name: "Overview", path: overview_path },
|
|
40
|
+
{
|
|
41
|
+
name: "Completed runs",
|
|
42
|
+
count: RigidWorkflow::Run.where(status: %i[completed]).count,
|
|
43
|
+
path: completed_runs_path
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: "Active runs",
|
|
47
|
+
count: RigidWorkflow::Run.where(status: %i[running]).count,
|
|
48
|
+
path: active_runs_path
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: "Pending runs",
|
|
52
|
+
count: RigidWorkflow::Run.where(status: %i[pending]).count,
|
|
53
|
+
path: pending_runs_path
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: "Failed runs",
|
|
57
|
+
count: RigidWorkflow::Run.where(status: %i[failed]).count,
|
|
58
|
+
path: failed_runs_path
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.stats_for_ever
|
|
66
|
+
ActiveRecord::Base.connection.execute(<<-SQL)
|
|
67
|
+
SELECT
|
|
68
|
+
r.workflow_class,
|
|
69
|
+
COUNT(DISTINCT CASE WHEN r.status = 'completed' THEN r.id END) as completed_runs,
|
|
70
|
+
COUNT(DISTINCT CASE WHEN r.status = 'running' THEN r.id END) as active_runs,
|
|
71
|
+
COUNT(DISTINCT CASE WHEN r.status = 'pending' THEN r.id END) as pending_runs,
|
|
72
|
+
COUNT(DISTINCT CASE WHEN r.status = 'failed' THEN r.id END) as failed_runs,
|
|
73
|
+
100.0 * COUNT(DISTINCT CASE WHEN r.status = 'completed' THEN r.id END) /
|
|
74
|
+
NULLIF(COUNT(DISTINCT CASE WHEN r.status IN ('completed', 'failed') THEN r.id END), 0) as success_rate,
|
|
75
|
+
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY (r.finished_at - r.started_at)) as p50_duration
|
|
76
|
+
FROM rigid_workflow_runs r
|
|
77
|
+
GROUP BY r.workflow_class
|
|
78
|
+
SQL
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.unique_workflows_in_ever
|
|
82
|
+
ActiveRecord::Base.connection.execute(<<-SQL)
|
|
83
|
+
SELECT
|
|
84
|
+
DISTINCT(r.workflow_class)
|
|
85
|
+
FROM rigid_workflow_runs r
|
|
86
|
+
GROUP BY r.workflow_class
|
|
87
|
+
SQL
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|