active_saga 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a2bf52fe5b851576cd054d43f636cb86410c395d57737b6291c7bace1d6a9a43
4
+ data.tar.gz: c3c6ff1b642992fe0f70dc163ff6535f0fe62477fb854d810ff2de19207fc1e4
5
+ SHA512:
6
+ metadata.gz: ccec4ca4cbc4b6e85a7a63dc667f0621a818046430b0772988e6785f84e5ffee49b081debf18a87536116725989cb5f296f85efc6dcfc436769f7b79638dc049
7
+ data.tar.gz: ae5f9a4576295244f9a8d2c9b09100b8bba352c41df97d2ea7637e014a4922513849be049476230a46f720b503e7d8052e99dc08123c9e3a8662b35c31bfb7e3
data/ADAPTERS.md ADDED
@@ -0,0 +1,63 @@
1
+ # Store Adapters
2
+
3
+ ActiveWorkflow persists workflow state through a strategy interface. This document explains the
4
+ contract adapters must implement and the behaviours the engine expects.
5
+
6
+ ## Base Contract
7
+
8
+ All adapters inherit from `ActiveWorkflow::Stores::Base` and must implement:
9
+
10
+ - `start_execution(workflow_class:, context:, steps:, idempotency_key:, timeout:, metadata:)`
11
+ - `load_execution(id)`
12
+ - `process_execution(execution_id)`
13
+ - `complete_step!(execution_id, step_name, payload:, idempotency_key:)`
14
+ - `fail_step!(execution_id, step_name, error_class:, message:, details:, idempotency_key:)`
15
+ - `extend_timeout!(execution_id, step_name, by:)`
16
+ - `heartbeat!(execution_id, step_name, at:)`
17
+ - `signal!(execution_id, name, payload:)`
18
+ - `enqueue_runner(execution_id, run_at: nil)` – adapters may simply enqueue the internal runner job.
19
+ - `cancel_execution!(execution_id, reason: nil)` – run compensations and cancel remaining steps.
20
+
21
+ The methods must be **atomic**: never run business code outside a transaction or lock that protects
22
+ against concurrent runners.
23
+
24
+ ## Step Lifecycle Responsibilities
25
+
26
+ Adapters are responsible for:
27
+
28
+ 1. **Idempotency** – enforce unique workflow `idempotency_key` presence and step completion keys.
29
+ 2. **Exactly-once** step completion – step must transition `pending → running → completed` without
30
+ double execution when jobs retry.
31
+ 3. **Wait semantics** – async steps transition to `waiting` with timeout scheduling and resume only
32
+ via completion, failure, or timeout.
33
+ 4. **Retry bookkeeping** – store attempts, backoff, jitter, and next scheduled run.
34
+ 5. **Compensation triggering** – when execution fails terminally, invoke compensations in reverse
35
+ order.
36
+ 6. **Cancellation** – honour `cancel_execution!` requests by stopping progression, running
37
+ compensations, and marking remaining work cancelled.
38
+ 7. **Observability** – emit `ActiveSupport::Notifications` listed in the README.
39
+
40
+ ## Extending the ActiveRecord Store
41
+
42
+ The built-in adapter is designed for relational databases and uses three tables:
43
+
44
+ - `aw_executions` – workflow metadata and context.
45
+ - `aw_steps` – per-step state machine and retry/timeout metadata.
46
+ - `aw_events` – signal payloads awaiting consumption.
47
+
48
+ If you need to customise the schema (prefixed table names, auditing columns), pass your own models or
49
+ subclass the adapter. Ensure indexes that protect idempotency keys remain unique.
50
+
51
+ ## Testing Adapters
52
+
53
+ Use the acceptance spec suite as a smoke test for new adapters. You can swap the store in
54
+ `spec_helper.rb` to your implementation and assert the scenarios continue to pass:
55
+
56
+ ```ruby
57
+ ActiveWorkflow.configure do |config|
58
+ config.store = MyRedisStore.new
59
+ end
60
+ ```
61
+
62
+ Because workflows rely on precise concurrency guarantees, add stress tests around jobs retrying,
63
+ timeouts, and simulated crashes before deploying alternative stores to production.
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented here.
4
+
5
+ ## [0.1.0] - 2025-12-10
6
+
7
+ - Initial release of ActiveWorkflow with ActiveRecord store, async steps, signals, retries, compensations, cancellation API, idempotency, and generators.
data/GUIDE.md ADDED
@@ -0,0 +1,80 @@
1
+ # ActiveWorkflow Guide
2
+
3
+ This guide expands on the README and focuses on advanced topics you will likely encounter when
4
+ operating ActiveWorkflow in production.
5
+
6
+ - [Security & Webhooks](#security--webhooks)
7
+ - [Correlation Patterns](#correlation-patterns)
8
+ - [Extending Stores](#extending-stores)
9
+ - [Observability](#observability)
10
+
11
+ ## Security & Webhooks
12
+
13
+ Async workflows commonly rely on webhook callbacks to call `ActiveWorkflow.complete_step!` or
14
+ `ActiveWorkflow.fail_step!`. Treat these endpoints with the same rigor as payment webhooks:
15
+
16
+ 1. **Authenticate** inbound requests. Sign responses from your external system and verify via HMAC
17
+ in the controller before mutating workflow state.
18
+ 2. **Use correlation IDs**. The gem exposes `execution.id` and the step name. Pass a
19
+ deterministic correlation ID to your external service (for example `"#{execution.id}:export"`).
20
+ 3. **Enforce idempotency**. Always forward a business-level idempotency key (event id) and supply it
21
+ when calling `complete_step!`. The ActiveRecord store keeps the last key and will reject
22
+ conflicting attempts.
23
+ 4. **Rate-limit**. Protect the endpoints that mutate workflow state and monitor for abuse.
24
+
25
+ Example controller snippet:
26
+
27
+ ```ruby
28
+ class ExportsController < ApplicationController
29
+ before_action :verify_hmac!
30
+
31
+ def ready
32
+ ActiveWorkflow.complete_step!(params[:execution_id], :export,
33
+ payload: params.require(:payload).permit(:url),
34
+ idempotency_key: params[:event_id])
35
+ head :ok
36
+ rescue ActiveWorkflow::Errors::AsyncCompletionConflict
37
+ head :unprocessable_entity
38
+ end
39
+ end
40
+ ```
41
+
42
+ ## Correlation Patterns
43
+
44
+ ActiveWorkflow does not impose a single correlation strategy. Some battle-tested options:
45
+
46
+ - **Execution scoped** – `"#{execution.id}:#{step_name}"` works well when the external system does
47
+ not need to know about your domain objects.
48
+ - **Business scoped** – `"export:#{ctx[:user_id]}"` if a user can only have one export at a time.
49
+ - **Composite** – combine execution and external ids to allow reconciliation jobs to find the
50
+ correct workflow.
51
+
52
+ Whatever you choose, store the value either in `ctx` or the step's `init_result` and reuse it for
53
+ timeouts, heartbeats, and compensations.
54
+
55
+ ## Extending Stores
56
+
57
+ `ActiveWorkflow::Stores::Base` defines the contract for persistence. To write a custom adapter
58
+ (Redis, DynamoDB…), inherit from the base class and implement:
59
+
60
+ - `start_execution` – create an execution with initial steps and enqueue the runner job.
61
+ - `load_execution` – return an `ActiveWorkflow::Execution` snapshot.
62
+ - `process_execution` – pop and execute the next step atomically.
63
+ - Async APIs – `complete_step!`, `fail_step!`, `extend_timeout!`, `heartbeat!`, `signal!`.
64
+
65
+ The ActiveRecord store is heavily documented and can act as a reference implementation. When porting
66
+ it ensure you preserve transactional guarantees around step dedupe, retries, and compensations.
67
+
68
+ ## Observability
69
+
70
+ The store emits `ActiveSupport::Notifications` for every significant transition. Subscribe in your
71
+ app and ship them to OpenTelemetry, Datadog, or logs. A simple logger subscriber looks like this:
72
+
73
+ ```ruby
74
+ ActiveSupport::Notifications.subscribe(/active_workflow\./) do |event|
75
+ Rails.logger.info({ name: event.name, payload: event.payload, duration: event.duration }.to_json)
76
+ end
77
+ ```
78
+
79
+ Use the events to drive SLIs (time-to-signal, retry depth, compensation rate) and to alert on stuck
80
+ workflows (no heartbeat before timeout).
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 ActiveWorkflow
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,473 @@
1
+ # ActiveWorkflow
2
+
3
+ A workflow engine with durable steps, automatic retries with backoff and jitter, compensations, async waits, signals, timeouts, idempotency, and pluggable persistence (ActiveRecord included).
4
+
5
+ ---
6
+
7
+ ## Table of Contents
8
+
9
+ - [Why ActiveWorkflow?](#why-activeworkflow)
10
+ - [Features](#features)
11
+ - [Installation](#installation)
12
+ - [Quick Start](#quick-start)
13
+ - [Defining Workflows (3 equivalent styles)](#defining-workflows-3-equivalent-styles)
14
+ - [A) Method step](#a-method-step)
15
+ - [B) Task class](#b-task-class)
16
+ - [C) Inline block](#c-inline-block)
17
+ - [Async Steps (vs Sync) – Concept & Lifecycle](#async-steps-vs-sync--concept--lifecycle)
18
+ - [Declaring async steps](#declaring-async-steps)
19
+ - [Completion/Failure/Timeout APIs](#completionfailuretimeout-apis)
20
+ - [Timeouts, retries, and re-initiation](#timeouts-retries-and-re-initiation)
21
+ - [Security, idempotency & race-safety](#security-idempotency--race-safety)
22
+ - [Sync vs Async comparison](#sync-vs-async-comparison)
23
+ - [Signals & Waits](#signals--waits)
24
+ - [Cancellation](#cancellation)
25
+ - [Retries, Backoff & Jitter](#retries-backoff--jitter)
26
+ - [Idempotency & Deduplication](#idempotency--deduplication)
27
+ - [Configuration](#configuration)
28
+ - [Persistence (Adapters)](#persistence-adapters)
29
+ - [Active Job Integration](#active-job-integration)
30
+ - [Observability (Events & Logging)](#observability-events--logging)
31
+ - [Generators](#generators)
32
+ - [Testing](#testing)
33
+ - [Examples](#examples)
34
+ - [Design Notes](#design-notes)
35
+ - [License](#license)
36
+
37
+ ---
38
+
39
+ ## Why ActiveWorkflow?
40
+
41
+ Most apps need reliable business workflows (sagas) with retries, compensations and “wait for an external thing (webhook/job)”—without operating a heavyweight orchestration service. **ActiveWorkflow** gives you that on plain Rails + Active Job.
42
+
43
+ ---
44
+
45
+ ## Features
46
+
47
+ - **Flexible step definitions**: define workflow steps as methods, task classes, or inline blocks with identical semantics.
48
+ - **Async steps**: start external work, persist correlation, **wait** for completion via API.
49
+ - **Signals**: pause workflows until external events or triggers resume execution.
50
+ - **Retries**: fixed/exponential backoff with jitter, per-step timeouts.
51
+ - **Compensations**: reverse-order undo on cancel/failure.
52
+ - **Cancellation**: explicit API to stop workflows, run compensations, and mark remaining work cancelled.
53
+ - **Idempotency**: workflow-level keys + step dedupe + idempotent completions.
54
+ - **Pluggable stores**: strategy interface; ships with **ActiveRecord**.
55
+ - **Observability**: `ActiveSupport::Notifications` for steps, signals, retries, timeouts.
56
+
57
+ ---
58
+
59
+ ## Installation
60
+
61
+ ```bash
62
+ # Gemfile
63
+ gem "active_workflow"
64
+
65
+ bundle install
66
+ rails g active_workflow:install
67
+ rails db:migrate
68
+ ```
69
+
70
+ The installer creates:
71
+
72
+ - `config/initializers/active_workflow.rb`
73
+ - ActiveRecord migrations
74
+ - Optional sample workflow
75
+
76
+ ---
77
+
78
+ ## Quick Start
79
+
80
+ ```ruby
81
+ # config/initializers/active_workflow.rb
82
+ ActiveWorkflow.configure do |c|
83
+ c.store = ActiveWorkflow::Stores::ActiveRecord.new
84
+ c.logger = Rails.logger
85
+ c.serializer = :json
86
+ c.clock = -> { Time.now.utc }
87
+ end
88
+ ```
89
+
90
+ ```ruby
91
+ # app/workflows/checkout_flow.rb
92
+ class CheckoutFlow < ActiveWorkflow::Workflow
93
+ idempotency_key { "checkout:#{ctx[:order_id]}" }
94
+ defaults retry: { max: 5, backoff: :exponential, jitter: true, first_delay: 1.second }
95
+ timeout 30.minutes
96
+
97
+ step :charge_card, compensate: :refund_payment, retry: { max: 6, first_delay: 2.seconds }
98
+ task :reserve_stock, ReserveStockTask, dedupe: true
99
+ task :send_receipt, fire_and_forget: true do |ctx|
100
+ Mailers::Receipt.deliver_later(ctx[:order_id])
101
+ end
102
+
103
+ def charge_card
104
+ payment = PSP.charge!(order_id: ctx[:order_id], token: ctx[:payment_token])
105
+ ctx[:payment_id] = payment.id
106
+ end
107
+
108
+ def refund_payment
109
+ PSP.refund!(ctx[:payment_id]) if ctx[:payment_id]
110
+ end
111
+ end
112
+ ```
113
+
114
+ ```ruby
115
+ # Run it
116
+ flow = CheckoutFlow.start(order_id: 42, payment_token: "tok_123")
117
+ result = flow.await # optional: block until terminal state
118
+
119
+ # cancel if user aborts checkout
120
+ flow.cancel!(reason: "user_aborted") if user_cancelled?
121
+ ```
122
+
123
+ ---
124
+
125
+ ## Defining Workflows (3 equivalent styles)
126
+
127
+ All step forms accept the **same options**:
128
+ `retry:, compensate:, timeout:, async:, dedupe:, if:, unless:, args:, store_result_as:, fire_and_forget:`.
129
+
130
+ ### A) Method step
131
+
132
+ ```ruby
133
+ class ExampleFlow < ActiveWorkflow::Workflow
134
+ step :do_work, compensate: :undo_work
135
+
136
+ def do_work
137
+ res = Service.call!(ctx[:input])
138
+ ctx[:output] = res
139
+ end
140
+
141
+ def undo_work
142
+ Service.undo!(ctx[:output]) if ctx[:output]
143
+ end
144
+ end
145
+ ```
146
+
147
+ ### B) Task class
148
+
149
+ ```ruby
150
+ class ReserveStockTask < ActiveWorkflow::Task
151
+ def call(ctx)
152
+ res = Inventory.reserve!(ctx[:order_id])
153
+ { reservation_id: res.id } # merged into ctx
154
+ end
155
+
156
+ def compensate(ctx, result:)
157
+ Inventory.release!(result[:reservation_id]) if result&.dig(:reservation_id)
158
+ end
159
+ end
160
+
161
+ class CheckoutFlow < ActiveWorkflow::Workflow
162
+ task :reserve_stock, ReserveStockTask
163
+ end
164
+ ```
165
+
166
+ ### C) Inline block
167
+
168
+ ```ruby
169
+ class NotifyFlow < ActiveWorkflow::Workflow
170
+ task :send_email do |ctx|
171
+ Mailers::Notice.deliver_later(ctx[:user_id])
172
+ nil
173
+ end
174
+ end
175
+ ```
176
+
177
+ ---
178
+
179
+ ## Async Steps (vs Sync) – Concept & Lifecycle
180
+
181
+ **Sync step:** runs to completion within one worker execution. Returns value → advance; raises → retry/fail.
182
+
183
+ **Async step:** **initiates** external work and **returns immediately**. The engine persists the step in `waiting` state (with correlation/timeout). Later, an external caller resumes the step via `complete_step!` (or `fail_step!`). No worker thread is held while waiting.
184
+
185
+ ### Declaring async steps
186
+
187
+ ```ruby
188
+ # Per-step option
189
+ task :arrange_fulfillment, FulfillmentTask, async: true, timeout: 15.minutes
190
+
191
+ # Class-level declaration
192
+ class FulfillmentTask < ActiveWorkflow::Task
193
+ async! timeout: 15.minutes
194
+ def call(ctx)
195
+ job = FulfillmentAPI.create_job!(order_id: ctx[:order_id]) # initiate
196
+ { fulfillment_job_id: job.id } # merge into ctx
197
+ end
198
+ end
199
+ ```
200
+
201
+ **Engine behavior when async:**
202
+
203
+ - Runs `call` once to initiate work.
204
+ - If `call` returns, **persist** step → `waiting`, set `waiting_since`, compute `timeout_at`.
205
+ - If `call` raises, treat like sync failure (retry policy).
206
+ - Does **not** advance the cursor until a completion arrives.
207
+
208
+ ### Completion/Failure/Timeout APIs
209
+
210
+ ```ruby
211
+ # Mark an async step as completed and advance the flow
212
+ ActiveWorkflow.complete_step!(execution_id, :arrange_fulfillment,
213
+ payload: { tracking: "XYZ" }, idempotency_key: params[:event_id])
214
+
215
+ # Explicit failure path while waiting
216
+ ActiveWorkflow.fail_step!(execution_id, :arrange_fulfillment,
217
+ error_class: "RemoteError", message: "Timeout from vendor", details: { ... },
218
+ idempotency_key: params[:event_id])
219
+
220
+ # Optional: extend waiting timeout
221
+ ActiveWorkflow.extend_timeout!(execution_id, :arrange_fulfillment, by: 10.minutes)
222
+
223
+ # Optional: heartbeat for monitoring
224
+ ActiveWorkflow.heartbeat!(execution_id, :arrange_fulfillment)
225
+ ```
226
+
227
+ **Payload storage:**
228
+
229
+ - Initiation return (from `call`) is merged into `ctx` immediately.
230
+ - Completion payload is stored under `ctx[step_name]` (or `store_result_as:` key).
231
+
232
+ ### Timeouts, retries, and re-initiation
233
+
234
+ - `timeout:` caps **waiting** time. On timeout:
235
+ - If `retry` configured → **re-initiate** the step (re-run `call`) with backoff+jitter.
236
+ - Else mark step `failed`; apply flow failure/compensation policy.
237
+ - Re-initiation must be **idempotent**—use business idempotency keys (e.g., `execution_id:step_name`) for external calls.
238
+
239
+ ### Security, idempotency & race-safety
240
+
241
+ - `complete_step!` / `fail_step!` are **idempotent** (accept `idempotency_key`).
242
+ - Optionally verify a **correlation_id** created during initiation before accepting completion.
243
+ - Use DB transactional updates and row locks so only one runner transitions a step.
244
+ - If a step isn’t `waiting`, completion/failure is a safe no-op.
245
+
246
+ ### Sync vs Async comparison
247
+
248
+ | Aspect | Sync step | Async step |
249
+ | --------------------- | -------------------------------------- | ------------------------------------------------------------------------ |
250
+ | Worker time | Runs to completion in one job | Initiates work, returns immediately; later resumed via API |
251
+ | State progression | `pending → running → completed/failed` | `pending → running → waiting → (completed/failed/timed_out)` |
252
+ | Cursor advancement | Immediately after success | After `complete_step!` |
253
+ | Failure while waiting | N/A | `fail_step!` or timeout triggers retry/fail |
254
+ | Timeouts | Caps execution time | Caps waiting time; can `extend_timeout!` or `heartbeat!` |
255
+ | Idempotency | Step-level `dedupe` | Idempotent completions with `idempotency_key`; re-init safe with backoff |
256
+
257
+ ---
258
+
259
+ ## Signals & Waits
260
+
261
+ Pause the workflow until a signal arrives.
262
+
263
+ ```ruby
264
+ class ApprovalFlow < ActiveWorkflow::Workflow
265
+ task :prepare, PrepareTask
266
+ wait_for_signal :approval, as: :approval
267
+ task :finalize, FinalizeTask, args: ->(ctx) { [ctx[:approval]] }
268
+ end
269
+
270
+ # Controller/webhook sending a signal:
271
+ ActiveWorkflow.signal!(params[:execution_id], :approval,
272
+ payload: { approved_by: current_user.id, decision: "approved" })
273
+ ```
274
+
275
+ Signals are persisted events, consumed once, and idempotent.
276
+
277
+ ---
278
+
279
+ ## Cancellation
280
+
281
+ Stop a running workflow (for example, when the user backs out).
282
+
283
+ ```ruby
284
+ # Cancel via execution id (runs compensations, cancels remaining steps)
285
+ ActiveWorkflow.cancel!(execution.id, reason: "user_request")
286
+
287
+ # Or on the execution instance
288
+ execution.cancel!(reason: "user_request")
289
+ ```
290
+
291
+ Cancellation:
292
+
293
+ - Runs compensations for completed steps in reverse order.
294
+ - Marks pending/running/waiting steps as `cancelled` and clears scheduled timeouts.
295
+ - Transitions the execution to the terminal `cancelled` state (`cancelled_at` timestamp set).
296
+ - Emits `active_workflow.execution.cancelled` for observability.
297
+
298
+ Repeated calls are safe (idempotent).
299
+
300
+ ---
301
+
302
+ ## Retries, Backoff & Jitter
303
+
304
+ Specify per step or via `defaults`:
305
+
306
+ ```ruby
307
+ defaults retry: { max: 5, backoff: :exponential, first_delay: 1.second, jitter: true }
308
+ step :slow_call, retry: { max: 10, backoff: :fixed, delay: 5.seconds }
309
+ ```
310
+
311
+ - `backoff:` `:fixed` or `:exponential`
312
+ - `first_delay:` initial wait before first retry
313
+ - `delay:` (for fixed)
314
+ - `jitter: true` to randomize and reduce thundering herd
315
+
316
+ ---
317
+
318
+ ## Idempotency & Deduplication
319
+
320
+ - **Workflow level**: `idempotency_key { "checkout:#{ctx[:order_id]}" }` ensures only one logical execution per business key.
321
+ - **Step level**: `dedupe: true` skips re-executing a step that already completed successfully.
322
+ - **Async completion**: `complete_step!(..., idempotency_key:)` prevents duplicate completions.
323
+
324
+ ---
325
+
326
+ ## Configuration
327
+
328
+ ```ruby
329
+ ActiveWorkflow.configure do |c|
330
+ c.store = ActiveWorkflow::Stores::ActiveRecord.new
331
+ c.logger = Rails.logger
332
+ c.serializer = :json # or a custom object responding to dump/load
333
+ c.clock = -> { Time.now.utc }
334
+ end
335
+ ```
336
+
337
+ ---
338
+
339
+ ## Persistence (Adapters)
340
+
341
+ ActiveWorkflow uses a **Store** strategy.
342
+
343
+ **ActiveRecord store** (ships with gem):
344
+
345
+ - Tables (prefix `aw_`):
346
+ - `aw_executions`: workflow metadata (`workflow_class`, `state`, `ctx` jsonb, `cursor_step`, `idempotency_key`, `cancelled_at`, timestamps)
347
+ - `aw_steps`: per-step status (`pending|running|waiting|completed|failed|timed_out|compensating|compensated`), attempts, backoff data, `init_result`, `completion_payload`, `timeout_at`, `waiting_since`, `heartbeat_at`, `correlation_id`, `completion_idempotency_key`
348
+ - `aw_events`: signals etc. (`name`, `payload`, `consumed_at`)
349
+ - (Optional) `aw_timers`: scheduled timeouts/backoffs (or embed in steps)
350
+ - Indexing: unique on `idempotency_key`; partial unique on `(execution_id, step_name, completion_idempotency_key) WHERE completion_idempotency_key IS NOT NULL`; JSONB GIN on payloads if needed.
351
+ - Concurrency: transactional updates + row locks / SKIP LOCKED.
352
+
353
+ You can implement other stores (e.g., Redis) by following the `ActiveWorkflow::Store` interface.
354
+
355
+ ---
356
+
357
+ ## Active Job Integration
358
+
359
+ - A single internal job (e.g., `ActiveWorkflow::Jobs::RunnerJob`) receives execution/step IDs and performs the next transition safely.
360
+ - **No dependency** on specific backends (Sidekiq, Solid Queue, etc.)—any Active Job adapter works.
361
+ - Timers/backoffs are scheduled via `set(wait_until: ...)`.
362
+
363
+ ---
364
+
365
+ ## Observability (Events & Logging)
366
+
367
+ Subscribe with `ActiveSupport::Notifications`:
368
+
369
+ Events include:
370
+
371
+ - `active_workflow.step.started`
372
+ - `active_workflow.step.completed`
373
+ - `active_workflow.step.failed`
374
+ - `active_workflow.step.waiting` _(async initiation successful)_
375
+ - `active_workflow.step.completed_async` _(after `complete_step!`)_
376
+ - `active_workflow.step.failed_async` _(after `fail_step!`)_
377
+ - `active_workflow.step.timeout`
378
+ - `active_workflow.retry.scheduled`
379
+ - `active_workflow.signal.received`
380
+ - `active_workflow.execution.cancelled`
381
+
382
+ Each event carries identifiers (execution_id, workflow, step), timings, attempts, and error details (when applicable).
383
+
384
+ ---
385
+
386
+ ## Generators
387
+
388
+ ```bash
389
+ rails g active_workflow:install # initializer + migrations + sample
390
+ rails g active_workflow:workflow CheckoutFlow
391
+ ```
392
+
393
+ The workflow generator scaffolds a class under `app/workflows/`.
394
+
395
+ ---
396
+
397
+ ## Testing
398
+
399
+ - RSpec helpers (recommended):
400
+ - Start a flow and assert final state:
401
+ ```ruby
402
+ flow = MyFlow.start(foo: 1)
403
+ expect(flow.await(timeout: 10.seconds).state).to eq("completed")
404
+ ```
405
+ - Simulate async completion:
406
+ ```ruby
407
+ ActiveWorkflow.complete_step!(flow.id, :export, payload: { url: "..." }, idempotency_key: "evt-1")
408
+ ```
409
+ - Cover:
410
+ - retries/backoff behavior,
411
+ - dedupe (no double execution),
412
+ - signals,
413
+ - timeouts (including re-initiation),
414
+ - compensation reverse order,
415
+ - idempotent completions,
416
+ - crash-safety (re-enqueue after failure mid-step).
417
+
418
+ ---
419
+
420
+ ## Examples
421
+
422
+ **Signal workflow**
423
+
424
+ ```ruby
425
+ class SignalWorkflow < ActiveWorkflow::Workflow
426
+ task :prepare, PrepareTask
427
+ wait_for_signal :approval, as: :approval
428
+ task :finalize, FinalizeTask, args: ->(ctx) { [ctx[:approval]] }
429
+ end
430
+ ```
431
+
432
+ **Async export + notify**
433
+
434
+ ```ruby
435
+ class ExportFlow < ActiveWorkflow::Workflow
436
+ task :request_export, ExporterTask, async: true, timeout: 30.minutes
437
+ task :notify_user do |ctx|
438
+ Mailers::ExportReady.deliver_later(ctx[:user_id], ctx[:request_export][:download_url])
439
+ end
440
+ end
441
+
442
+ class ExporterTask < ActiveWorkflow::Task
443
+ async! timeout: 30.minutes
444
+ def call(ctx)
445
+ req = ExportAPI.create!(user_id: ctx[:user_id], request_id: "#{ctx[:execution_id]}:request_export")
446
+ { export_job_id: req.id, correlation: req.token }
447
+ end
448
+ end
449
+ ```
450
+
451
+ Webhook:
452
+
453
+ ```ruby
454
+ def export_ready
455
+ ActiveWorkflow.complete_step!(params[:exec_id], :request_export,
456
+ payload: { download_url: params[:url] }, idempotency_key: params[:event_id])
457
+ head :ok
458
+ end
459
+ ```
460
+
461
+ ---
462
+
463
+ ## Design Notes
464
+
465
+ - Prefer **Task classes** for reusable/complex steps and DI; use **method** or **block** steps for quick wiring.
466
+ - Use **business idempotency keys** in external systems (`execution_id:step_name`) for safe re-init.
467
+ - Keep ctx small (IDs and small payloads). Store large blobs elsewhere and pass references.
468
+
469
+ ---
470
+
471
+ ## License
472
+
473
+ MIT © You and contributors.
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveWorkflow
4
+ module Backoff
5
+ module_function
6
+
7
+ def calculate(config, attempts:)
8
+ normalized = (config[:strategy] || config[:backoff] || :exponential).to_sym
9
+ base = base_delay(config, attempts, normalized)
10
+ config[:jitter] ? apply_jitter(base) : base
11
+ end
12
+
13
+ def base_delay(config, attempts, strategy)
14
+ first = config[:first_delay]&.to_f || config[:delay]&.to_f || 5.0
15
+ delay = config[:delay]&.to_f || first
16
+
17
+ case strategy
18
+ when :fixed
19
+ attempts <= 1 ? first : delay
20
+ else
21
+ return first if attempts <= 1
22
+
23
+ exponent = attempts - 1
24
+ delay * (2**exponent)
25
+ end
26
+ end
27
+ private_class_method :base_delay
28
+
29
+ def apply_jitter(value)
30
+ low = value * 0.5
31
+ high = value * 1.5
32
+ rand(low..high)
33
+ end
34
+ private_class_method :apply_jitter
35
+ end
36
+ end