active_saga 0.1.0 → 0.1.3
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/ADAPTERS.md +6 -6
- data/CHANGELOG.md +1 -1
- data/GUIDE.md +10 -10
- data/LICENSE +1 -1
- data/README.md +51 -51
- data/lib/{active_workflow → active_saga}/backoff.rb +1 -1
- data/lib/{active_workflow → active_saga}/configuration.rb +3 -3
- data/lib/{active_workflow → active_saga}/context.rb +1 -1
- data/lib/{active_workflow → active_saga}/dsl/options.rb +8 -8
- data/lib/{active_workflow → active_saga}/dsl/signals.rb +5 -5
- data/lib/{active_workflow → active_saga}/dsl/steps.rb +11 -11
- data/lib/{active_workflow → active_saga}/errors.rb +1 -1
- data/lib/{active_workflow → active_saga}/execution.rb +5 -5
- data/lib/{active_workflow → active_saga}/jobs/runner_job.rb +8 -8
- data/lib/active_saga/railtie.rb +17 -0
- data/lib/{active_workflow → active_saga}/serializers/json.rb +1 -1
- data/lib/{active_workflow → active_saga}/stores/active_record.rb +34 -34
- data/lib/{active_workflow → active_saga}/stores/base.rb +4 -4
- data/lib/{active_workflow → active_saga}/task.rb +5 -5
- data/lib/active_saga/version.rb +5 -0
- data/lib/{active_workflow → active_saga}/workflow.rb +5 -5
- data/lib/{active_workflow.rb → active_saga.rb} +21 -21
- data/lib/generators/{active_workflow → active_saga}/install/install_generator.rb +3 -13
- data/lib/generators/active_saga/install/templates/initializer.rb +8 -0
- data/lib/generators/{active_workflow/install/templates/migrations/create_active_workflow_tables.rb → active_saga/install/templates/migrations/create_active_saga_tables.rb} +14 -14
- data/lib/generators/{active_workflow → active_saga}/install/templates/sample_workflow.rb +2 -2
- data/lib/generators/{active_workflow → active_saga}/workflow/templates/workflow.rb +1 -1
- data/lib/generators/{active_workflow → active_saga}/workflow/workflow_generator.rb +1 -1
- metadata +26 -26
- data/lib/active_workflow/railtie.rb +0 -17
- data/lib/active_workflow/version.rb +0 -5
- data/lib/generators/active_workflow/install/templates/initializer.rb +0 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f22bd87e089cbb7d4ccd08fe763e561743824bb335131d37a41e2b6b78f8da45
|
|
4
|
+
data.tar.gz: 8ba648a0deac1b45cd719fef2c23501d5f5e2ba9f098938ae75f302b6b72b2c8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 72361a30caa2c5d3bf49474f10e0cfbf97a926e6280b57429c786e6fd27a801a1541488db02140a274dc425b7941e726145d5e2891123318ed3893b37d81dd96
|
|
7
|
+
data.tar.gz: 88ffd609d73f55490aea49d7d60871697b9e49fad0a8347cb01cc5fb3138cab8bd8635791b569b1bbfa62ccd3809c93c5d19226272e2c0098a0b54950a1c1566
|
data/ADAPTERS.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# Store Adapters
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
ActiveSaga persists workflow state through a strategy interface. This document explains the
|
|
4
4
|
contract adapters must implement and the behaviours the engine expects.
|
|
5
5
|
|
|
6
6
|
## Base Contract
|
|
7
7
|
|
|
8
|
-
All adapters inherit from `
|
|
8
|
+
All adapters inherit from `ActiveSaga::Stores::Base` and must implement:
|
|
9
9
|
|
|
10
10
|
- `start_execution(workflow_class:, context:, steps:, idempotency_key:, timeout:, metadata:)`
|
|
11
11
|
- `load_execution(id)`
|
|
@@ -41,9 +41,9 @@ Adapters are responsible for:
|
|
|
41
41
|
|
|
42
42
|
The built-in adapter is designed for relational databases and uses three tables:
|
|
43
43
|
|
|
44
|
-
- `
|
|
45
|
-
- `
|
|
46
|
-
- `
|
|
44
|
+
- `as_executions` – workflow metadata and context.
|
|
45
|
+
- `as_steps` – per-step state machine and retry/timeout metadata.
|
|
46
|
+
- `as_events` – signal payloads awaiting consumption.
|
|
47
47
|
|
|
48
48
|
If you need to customise the schema (prefixed table names, auditing columns), pass your own models or
|
|
49
49
|
subclass the adapter. Ensure indexes that protect idempotency keys remain unique.
|
|
@@ -54,7 +54,7 @@ Use the acceptance spec suite as a smoke test for new adapters. You can swap the
|
|
|
54
54
|
`spec_helper.rb` to your implementation and assert the scenarios continue to pass:
|
|
55
55
|
|
|
56
56
|
```ruby
|
|
57
|
-
|
|
57
|
+
ActiveSaga.configure do |config|
|
|
58
58
|
config.store = MyRedisStore.new
|
|
59
59
|
end
|
|
60
60
|
```
|
data/CHANGELOG.md
CHANGED
|
@@ -4,4 +4,4 @@ All notable changes to this project will be documented here.
|
|
|
4
4
|
|
|
5
5
|
## [0.1.0] - 2025-12-10
|
|
6
6
|
|
|
7
|
-
- Initial release of
|
|
7
|
+
- Initial release of ActiveSaga with ActiveRecord store, async steps, signals, retries, compensations, cancellation API, idempotency, and generators.
|
data/GUIDE.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ActiveSaga Guide
|
|
2
2
|
|
|
3
3
|
This guide expands on the README and focuses on advanced topics you will likely encounter when
|
|
4
|
-
operating
|
|
4
|
+
operating ActiveSaga in production.
|
|
5
5
|
|
|
6
6
|
- [Security & Webhooks](#security--webhooks)
|
|
7
7
|
- [Correlation Patterns](#correlation-patterns)
|
|
@@ -10,8 +10,8 @@ operating ActiveWorkflow in production.
|
|
|
10
10
|
|
|
11
11
|
## Security & Webhooks
|
|
12
12
|
|
|
13
|
-
Async workflows commonly rely on webhook callbacks to call `
|
|
14
|
-
`
|
|
13
|
+
Async workflows commonly rely on webhook callbacks to call `ActiveSaga.complete_step!` or
|
|
14
|
+
`ActiveSaga.fail_step!`. Treat these endpoints with the same rigor as payment webhooks:
|
|
15
15
|
|
|
16
16
|
1. **Authenticate** inbound requests. Sign responses from your external system and verify via HMAC
|
|
17
17
|
in the controller before mutating workflow state.
|
|
@@ -29,11 +29,11 @@ class ExportsController < ApplicationController
|
|
|
29
29
|
before_action :verify_hmac!
|
|
30
30
|
|
|
31
31
|
def ready
|
|
32
|
-
|
|
32
|
+
ActiveSaga.complete_step!(params[:execution_id], :export,
|
|
33
33
|
payload: params.require(:payload).permit(:url),
|
|
34
34
|
idempotency_key: params[:event_id])
|
|
35
35
|
head :ok
|
|
36
|
-
rescue
|
|
36
|
+
rescue ActiveSaga::Errors::AsyncCompletionConflict
|
|
37
37
|
head :unprocessable_entity
|
|
38
38
|
end
|
|
39
39
|
end
|
|
@@ -41,7 +41,7 @@ end
|
|
|
41
41
|
|
|
42
42
|
## Correlation Patterns
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
ActiveSaga does not impose a single correlation strategy. Some battle-tested options:
|
|
45
45
|
|
|
46
46
|
- **Execution scoped** – `"#{execution.id}:#{step_name}"` works well when the external system does
|
|
47
47
|
not need to know about your domain objects.
|
|
@@ -54,11 +54,11 @@ timeouts, heartbeats, and compensations.
|
|
|
54
54
|
|
|
55
55
|
## Extending Stores
|
|
56
56
|
|
|
57
|
-
`
|
|
57
|
+
`ActiveSaga::Stores::Base` defines the contract for persistence. To write a custom adapter
|
|
58
58
|
(Redis, DynamoDB…), inherit from the base class and implement:
|
|
59
59
|
|
|
60
60
|
- `start_execution` – create an execution with initial steps and enqueue the runner job.
|
|
61
|
-
- `load_execution` – return an `
|
|
61
|
+
- `load_execution` – return an `ActiveSaga::Execution` snapshot.
|
|
62
62
|
- `process_execution` – pop and execute the next step atomically.
|
|
63
63
|
- Async APIs – `complete_step!`, `fail_step!`, `extend_timeout!`, `heartbeat!`, `signal!`.
|
|
64
64
|
|
|
@@ -71,7 +71,7 @@ The store emits `ActiveSupport::Notifications` for every significant transition.
|
|
|
71
71
|
app and ship them to OpenTelemetry, Datadog, or logs. A simple logger subscriber looks like this:
|
|
72
72
|
|
|
73
73
|
```ruby
|
|
74
|
-
ActiveSupport::Notifications.subscribe(/
|
|
74
|
+
ActiveSupport::Notifications.subscribe(/active_saga\./) do |event|
|
|
75
75
|
Rails.logger.info({ name: event.name, payload: event.payload, duration: event.duration }.to_json)
|
|
76
76
|
end
|
|
77
77
|
```
|
data/LICENSE
CHANGED
data/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ActiveSaga
|
|
2
2
|
|
|
3
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
4
|
|
|
@@ -6,7 +6,7 @@ A workflow engine with durable steps, automatic retries with backoff and jitter,
|
|
|
6
6
|
|
|
7
7
|
## Table of Contents
|
|
8
8
|
|
|
9
|
-
- [Why
|
|
9
|
+
- [Why ActiveSaga?](#why-activesaga)
|
|
10
10
|
- [Features](#features)
|
|
11
11
|
- [Installation](#installation)
|
|
12
12
|
- [Quick Start](#quick-start)
|
|
@@ -36,9 +36,9 @@ A workflow engine with durable steps, automatic retries with backoff and jitter,
|
|
|
36
36
|
|
|
37
37
|
---
|
|
38
38
|
|
|
39
|
-
## Why
|
|
39
|
+
## Why ActiveSaga?
|
|
40
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. **
|
|
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. **ActiveSaga** gives you that on plain Rails + Active Job.
|
|
42
42
|
|
|
43
43
|
---
|
|
44
44
|
|
|
@@ -60,16 +60,16 @@ Most apps need reliable business workflows (sagas) with retries, compensations a
|
|
|
60
60
|
|
|
61
61
|
```bash
|
|
62
62
|
# Gemfile
|
|
63
|
-
gem "
|
|
63
|
+
gem "active_saga"
|
|
64
64
|
|
|
65
65
|
bundle install
|
|
66
|
-
rails g
|
|
66
|
+
rails g active_saga:install
|
|
67
67
|
rails db:migrate
|
|
68
68
|
```
|
|
69
69
|
|
|
70
70
|
The installer creates:
|
|
71
71
|
|
|
72
|
-
- `config/initializers/
|
|
72
|
+
- `config/initializers/active_saga.rb`
|
|
73
73
|
- ActiveRecord migrations
|
|
74
74
|
- Optional sample workflow
|
|
75
75
|
|
|
@@ -78,9 +78,9 @@ The installer creates:
|
|
|
78
78
|
## Quick Start
|
|
79
79
|
|
|
80
80
|
```ruby
|
|
81
|
-
# config/initializers/
|
|
82
|
-
|
|
83
|
-
c.store =
|
|
81
|
+
# config/initializers/active_saga.rb
|
|
82
|
+
ActiveSaga.configure do |c|
|
|
83
|
+
c.store = ActiveSaga::Stores::ActiveRecord.new
|
|
84
84
|
c.logger = Rails.logger
|
|
85
85
|
c.serializer = :json
|
|
86
86
|
c.clock = -> { Time.now.utc }
|
|
@@ -89,7 +89,7 @@ end
|
|
|
89
89
|
|
|
90
90
|
```ruby
|
|
91
91
|
# app/workflows/checkout_flow.rb
|
|
92
|
-
class CheckoutFlow <
|
|
92
|
+
class CheckoutFlow < ActiveSaga::Workflow
|
|
93
93
|
idempotency_key { "checkout:#{ctx[:order_id]}" }
|
|
94
94
|
defaults retry: { max: 5, backoff: :exponential, jitter: true, first_delay: 1.second }
|
|
95
95
|
timeout 30.minutes
|
|
@@ -130,7 +130,7 @@ All step forms accept the **same options**:
|
|
|
130
130
|
### A) Method step
|
|
131
131
|
|
|
132
132
|
```ruby
|
|
133
|
-
class ExampleFlow <
|
|
133
|
+
class ExampleFlow < ActiveSaga::Workflow
|
|
134
134
|
step :do_work, compensate: :undo_work
|
|
135
135
|
|
|
136
136
|
def do_work
|
|
@@ -147,7 +147,7 @@ end
|
|
|
147
147
|
### B) Task class
|
|
148
148
|
|
|
149
149
|
```ruby
|
|
150
|
-
class ReserveStockTask <
|
|
150
|
+
class ReserveStockTask < ActiveSaga::Task
|
|
151
151
|
def call(ctx)
|
|
152
152
|
res = Inventory.reserve!(ctx[:order_id])
|
|
153
153
|
{ reservation_id: res.id } # merged into ctx
|
|
@@ -158,7 +158,7 @@ class ReserveStockTask < ActiveWorkflow::Task
|
|
|
158
158
|
end
|
|
159
159
|
end
|
|
160
160
|
|
|
161
|
-
class CheckoutFlow <
|
|
161
|
+
class CheckoutFlow < ActiveSaga::Workflow
|
|
162
162
|
task :reserve_stock, ReserveStockTask
|
|
163
163
|
end
|
|
164
164
|
```
|
|
@@ -166,7 +166,7 @@ end
|
|
|
166
166
|
### C) Inline block
|
|
167
167
|
|
|
168
168
|
```ruby
|
|
169
|
-
class NotifyFlow <
|
|
169
|
+
class NotifyFlow < ActiveSaga::Workflow
|
|
170
170
|
task :send_email do |ctx|
|
|
171
171
|
Mailers::Notice.deliver_later(ctx[:user_id])
|
|
172
172
|
nil
|
|
@@ -189,7 +189,7 @@ end
|
|
|
189
189
|
task :arrange_fulfillment, FulfillmentTask, async: true, timeout: 15.minutes
|
|
190
190
|
|
|
191
191
|
# Class-level declaration
|
|
192
|
-
class FulfillmentTask <
|
|
192
|
+
class FulfillmentTask < ActiveSaga::Task
|
|
193
193
|
async! timeout: 15.minutes
|
|
194
194
|
def call(ctx)
|
|
195
195
|
job = FulfillmentAPI.create_job!(order_id: ctx[:order_id]) # initiate
|
|
@@ -209,19 +209,19 @@ end
|
|
|
209
209
|
|
|
210
210
|
```ruby
|
|
211
211
|
# Mark an async step as completed and advance the flow
|
|
212
|
-
|
|
212
|
+
ActiveSaga.complete_step!(execution_id, :arrange_fulfillment,
|
|
213
213
|
payload: { tracking: "XYZ" }, idempotency_key: params[:event_id])
|
|
214
214
|
|
|
215
215
|
# Explicit failure path while waiting
|
|
216
|
-
|
|
216
|
+
ActiveSaga.fail_step!(execution_id, :arrange_fulfillment,
|
|
217
217
|
error_class: "RemoteError", message: "Timeout from vendor", details: { ... },
|
|
218
218
|
idempotency_key: params[:event_id])
|
|
219
219
|
|
|
220
220
|
# Optional: extend waiting timeout
|
|
221
|
-
|
|
221
|
+
ActiveSaga.extend_timeout!(execution_id, :arrange_fulfillment, by: 10.minutes)
|
|
222
222
|
|
|
223
223
|
# Optional: heartbeat for monitoring
|
|
224
|
-
|
|
224
|
+
ActiveSaga.heartbeat!(execution_id, :arrange_fulfillment)
|
|
225
225
|
```
|
|
226
226
|
|
|
227
227
|
**Payload storage:**
|
|
@@ -261,14 +261,14 @@ ActiveWorkflow.heartbeat!(execution_id, :arrange_fulfillment)
|
|
|
261
261
|
Pause the workflow until a signal arrives.
|
|
262
262
|
|
|
263
263
|
```ruby
|
|
264
|
-
class ApprovalFlow <
|
|
264
|
+
class ApprovalFlow < ActiveSaga::Workflow
|
|
265
265
|
task :prepare, PrepareTask
|
|
266
266
|
wait_for_signal :approval, as: :approval
|
|
267
267
|
task :finalize, FinalizeTask, args: ->(ctx) { [ctx[:approval]] }
|
|
268
268
|
end
|
|
269
269
|
|
|
270
270
|
# Controller/webhook sending a signal:
|
|
271
|
-
|
|
271
|
+
ActiveSaga.signal!(params[:execution_id], :approval,
|
|
272
272
|
payload: { approved_by: current_user.id, decision: "approved" })
|
|
273
273
|
```
|
|
274
274
|
|
|
@@ -282,7 +282,7 @@ Stop a running workflow (for example, when the user backs out).
|
|
|
282
282
|
|
|
283
283
|
```ruby
|
|
284
284
|
# Cancel via execution id (runs compensations, cancels remaining steps)
|
|
285
|
-
|
|
285
|
+
ActiveSaga.cancel!(execution.id, reason: "user_request")
|
|
286
286
|
|
|
287
287
|
# Or on the execution instance
|
|
288
288
|
execution.cancel!(reason: "user_request")
|
|
@@ -293,7 +293,7 @@ Cancellation:
|
|
|
293
293
|
- Runs compensations for completed steps in reverse order.
|
|
294
294
|
- Marks pending/running/waiting steps as `cancelled` and clears scheduled timeouts.
|
|
295
295
|
- Transitions the execution to the terminal `cancelled` state (`cancelled_at` timestamp set).
|
|
296
|
-
- Emits `
|
|
296
|
+
- Emits `active_saga.execution.cancelled` for observability.
|
|
297
297
|
|
|
298
298
|
Repeated calls are safe (idempotent).
|
|
299
299
|
|
|
@@ -326,8 +326,8 @@ step :slow_call, retry: { max: 10, backoff: :fixed, delay: 5.seconds }
|
|
|
326
326
|
## Configuration
|
|
327
327
|
|
|
328
328
|
```ruby
|
|
329
|
-
|
|
330
|
-
c.store =
|
|
329
|
+
ActiveSaga.configure do |c|
|
|
330
|
+
c.store = ActiveSaga::Stores::ActiveRecord.new
|
|
331
331
|
c.logger = Rails.logger
|
|
332
332
|
c.serializer = :json # or a custom object responding to dump/load
|
|
333
333
|
c.clock = -> { Time.now.utc }
|
|
@@ -338,25 +338,25 @@ end
|
|
|
338
338
|
|
|
339
339
|
## Persistence (Adapters)
|
|
340
340
|
|
|
341
|
-
|
|
341
|
+
ActiveSaga uses a **Store** strategy.
|
|
342
342
|
|
|
343
343
|
**ActiveRecord store** (ships with gem):
|
|
344
344
|
|
|
345
|
-
- Tables (prefix `
|
|
346
|
-
- `
|
|
347
|
-
- `
|
|
348
|
-
- `
|
|
349
|
-
- (Optional) `
|
|
345
|
+
- Tables (prefix `as_`):
|
|
346
|
+
- `as_executions`: workflow metadata (`workflow_class`, `state`, `ctx` jsonb, `cursor_step`, `idempotency_key`, `cancelled_at`, timestamps)
|
|
347
|
+
- `as_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
|
+
- `as_events`: signals etc. (`name`, `payload`, `consumed_at`)
|
|
349
|
+
- (Optional) `as_timers`: scheduled timeouts/backoffs (or embed in steps)
|
|
350
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
351
|
- Concurrency: transactional updates + row locks / SKIP LOCKED.
|
|
352
352
|
|
|
353
|
-
You can implement other stores (e.g., Redis) by following the `
|
|
353
|
+
You can implement other stores (e.g., Redis) by following the `ActiveSaga::Store` interface.
|
|
354
354
|
|
|
355
355
|
---
|
|
356
356
|
|
|
357
357
|
## Active Job Integration
|
|
358
358
|
|
|
359
|
-
- A single internal job (e.g., `
|
|
359
|
+
- A single internal job (e.g., `ActiveSaga::Jobs::RunnerJob`) receives execution/step IDs and performs the next transition safely.
|
|
360
360
|
- **No dependency** on specific backends (Sidekiq, Solid Queue, etc.)—any Active Job adapter works.
|
|
361
361
|
- Timers/backoffs are scheduled via `set(wait_until: ...)`.
|
|
362
362
|
|
|
@@ -368,16 +368,16 @@ Subscribe with `ActiveSupport::Notifications`:
|
|
|
368
368
|
|
|
369
369
|
Events include:
|
|
370
370
|
|
|
371
|
-
- `
|
|
372
|
-
- `
|
|
373
|
-
- `
|
|
374
|
-
- `
|
|
375
|
-
- `
|
|
376
|
-
- `
|
|
377
|
-
- `
|
|
378
|
-
- `
|
|
379
|
-
- `
|
|
380
|
-
- `
|
|
371
|
+
- `active_saga.step.started`
|
|
372
|
+
- `active_saga.step.completed`
|
|
373
|
+
- `active_saga.step.failed`
|
|
374
|
+
- `active_saga.step.waiting` _(async initiation successful)_
|
|
375
|
+
- `active_saga.step.completed_async` _(after `complete_step!`)_
|
|
376
|
+
- `active_saga.step.failed_async` _(after `fail_step!`)_
|
|
377
|
+
- `active_saga.step.timeout`
|
|
378
|
+
- `active_saga.retry.scheduled`
|
|
379
|
+
- `active_saga.signal.received`
|
|
380
|
+
- `active_saga.execution.cancelled`
|
|
381
381
|
|
|
382
382
|
Each event carries identifiers (execution_id, workflow, step), timings, attempts, and error details (when applicable).
|
|
383
383
|
|
|
@@ -386,8 +386,8 @@ Each event carries identifiers (execution_id, workflow, step), timings, attempts
|
|
|
386
386
|
## Generators
|
|
387
387
|
|
|
388
388
|
```bash
|
|
389
|
-
rails g
|
|
390
|
-
rails g
|
|
389
|
+
rails g active_saga:install # initializer + migrations + sample
|
|
390
|
+
rails g active_saga:workflow CheckoutFlow
|
|
391
391
|
```
|
|
392
392
|
|
|
393
393
|
The workflow generator scaffolds a class under `app/workflows/`.
|
|
@@ -404,7 +404,7 @@ The workflow generator scaffolds a class under `app/workflows/`.
|
|
|
404
404
|
```
|
|
405
405
|
- Simulate async completion:
|
|
406
406
|
```ruby
|
|
407
|
-
|
|
407
|
+
ActiveSaga.complete_step!(flow.id, :export, payload: { url: "..." }, idempotency_key: "evt-1")
|
|
408
408
|
```
|
|
409
409
|
- Cover:
|
|
410
410
|
- retries/backoff behavior,
|
|
@@ -422,7 +422,7 @@ The workflow generator scaffolds a class under `app/workflows/`.
|
|
|
422
422
|
**Signal workflow**
|
|
423
423
|
|
|
424
424
|
```ruby
|
|
425
|
-
class SignalWorkflow <
|
|
425
|
+
class SignalWorkflow < ActiveSaga::Workflow
|
|
426
426
|
task :prepare, PrepareTask
|
|
427
427
|
wait_for_signal :approval, as: :approval
|
|
428
428
|
task :finalize, FinalizeTask, args: ->(ctx) { [ctx[:approval]] }
|
|
@@ -432,14 +432,14 @@ end
|
|
|
432
432
|
**Async export + notify**
|
|
433
433
|
|
|
434
434
|
```ruby
|
|
435
|
-
class ExportFlow <
|
|
435
|
+
class ExportFlow < ActiveSaga::Workflow
|
|
436
436
|
task :request_export, ExporterTask, async: true, timeout: 30.minutes
|
|
437
437
|
task :notify_user do |ctx|
|
|
438
438
|
Mailers::ExportReady.deliver_later(ctx[:user_id], ctx[:request_export][:download_url])
|
|
439
439
|
end
|
|
440
440
|
end
|
|
441
441
|
|
|
442
|
-
class ExporterTask <
|
|
442
|
+
class ExporterTask < ActiveSaga::Task
|
|
443
443
|
async! timeout: 30.minutes
|
|
444
444
|
def call(ctx)
|
|
445
445
|
req = ExportAPI.create!(user_id: ctx[:user_id], request_id: "#{ctx[:execution_id]}:request_export")
|
|
@@ -452,7 +452,7 @@ Webhook:
|
|
|
452
452
|
|
|
453
453
|
```ruby
|
|
454
454
|
def export_ready
|
|
455
|
-
|
|
455
|
+
ActiveSaga.complete_step!(params[:exec_id], :request_export,
|
|
456
456
|
payload: { download_url: params[:url] }, idempotency_key: params[:event_id])
|
|
457
457
|
head :ok
|
|
458
458
|
end
|
|
@@ -2,20 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
require "active_support/logger"
|
|
4
4
|
|
|
5
|
-
module
|
|
5
|
+
module ActiveSaga
|
|
6
6
|
# Holds gem-level configuration.
|
|
7
7
|
class Configuration
|
|
8
8
|
attr_accessor :store, :serializer, :logger, :clock
|
|
9
9
|
|
|
10
10
|
def initialize
|
|
11
|
-
@serializer =
|
|
11
|
+
@serializer = ActiveSaga::Serializers::Json.new
|
|
12
12
|
@logger = ActiveSupport::Logger.new($stdout, level: :info)
|
|
13
13
|
@clock = -> { Time.now.utc }
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
# Ensures store is set before usage.
|
|
17
17
|
def store!
|
|
18
|
-
raise
|
|
18
|
+
raise ActiveSaga::Errors::Configuration, "ActiveSaga.store is not configured" unless store
|
|
19
19
|
|
|
20
20
|
store
|
|
21
21
|
end
|
|
@@ -1,29 +1,29 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
3
|
+
module ActiveSaga
|
|
4
4
|
module DSL
|
|
5
5
|
# Configuration helpers shared by workflows.
|
|
6
6
|
module Options
|
|
7
7
|
def self.extended(base)
|
|
8
|
-
base.class_attribute :
|
|
9
|
-
base.class_attribute :
|
|
10
|
-
base.class_attribute :
|
|
8
|
+
base.class_attribute :_as_defaults, instance_writer: false, default: {}
|
|
9
|
+
base.class_attribute :_as_timeout, instance_writer: false, default: nil
|
|
10
|
+
base.class_attribute :_as_idempotency_block, instance_writer: false, default: nil
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def defaults(opts = nil)
|
|
14
14
|
if opts
|
|
15
|
-
self.
|
|
15
|
+
self._as_defaults = (_as_defaults.deep_dup || {}).deep_merge(opts.deep_symbolize_keys)
|
|
16
16
|
else
|
|
17
|
-
|
|
17
|
+
_as_defaults.deep_dup || {}
|
|
18
18
|
end
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def timeout(value = nil)
|
|
22
|
-
value ? self.
|
|
22
|
+
value ? self._as_timeout = value : _as_timeout
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def idempotency_key(&block)
|
|
26
|
-
block ? self.
|
|
26
|
+
block ? self._as_idempotency_block = block : _as_idempotency_block
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def resolve_defaults(step_options)
|
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
3
|
+
module ActiveSaga
|
|
4
4
|
module DSL
|
|
5
5
|
module Signals
|
|
6
6
|
def self.extended(base)
|
|
7
|
-
base.class_attribute :
|
|
7
|
+
base.class_attribute :_as_signal_handlers, instance_writer: false, default: {} # name => method
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
def inherited(subclass)
|
|
11
11
|
super
|
|
12
|
-
subclass.
|
|
12
|
+
subclass._as_signal_handlers = _as_signal_handlers.dup
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def signal(name, to:)
|
|
16
|
-
self.
|
|
16
|
+
self._as_signal_handlers = _as_signal_handlers.merge(name.to_sym => to.to_sym)
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def signal_handler_for(name)
|
|
20
|
-
|
|
20
|
+
_as_signal_handlers[name.to_sym]
|
|
21
21
|
end
|
|
22
22
|
end
|
|
23
23
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
3
|
+
module ActiveSaga
|
|
4
4
|
module DSL
|
|
5
5
|
module Steps
|
|
6
6
|
StepDefinition = Struct.new(:name, :style, :callable, :options, keyword_init: true) do
|
|
@@ -67,21 +67,21 @@ module ActiveWorkflow
|
|
|
67
67
|
end
|
|
68
68
|
|
|
69
69
|
def self.extended(base)
|
|
70
|
-
base.class_attribute :
|
|
70
|
+
base.class_attribute :_as_steps, instance_writer: false, default: []
|
|
71
71
|
end
|
|
72
72
|
|
|
73
73
|
def inherited(subclass)
|
|
74
74
|
super
|
|
75
|
-
subclass.
|
|
75
|
+
subclass._as_steps = _as_steps.map(&:dup)
|
|
76
76
|
end
|
|
77
77
|
|
|
78
78
|
def steps
|
|
79
|
-
|
|
79
|
+
_as_steps
|
|
80
80
|
end
|
|
81
81
|
|
|
82
82
|
def step_definition(name)
|
|
83
83
|
steps.find { |definition| definition.name == name.to_sym } ||
|
|
84
|
-
raise(
|
|
84
|
+
raise(ActiveSaga::Errors::InvalidStep, "Unknown step: #{name}")
|
|
85
85
|
end
|
|
86
86
|
|
|
87
87
|
def step(name, **options)
|
|
@@ -90,21 +90,21 @@ module ActiveWorkflow
|
|
|
90
90
|
|
|
91
91
|
def task(name, handler = nil, **options, &block)
|
|
92
92
|
if handler && block
|
|
93
|
-
raise
|
|
93
|
+
raise ActiveSaga::Errors::InvalidStep, "Provide a Task class or a block, not both"
|
|
94
94
|
end
|
|
95
95
|
|
|
96
96
|
callable = handler || block
|
|
97
97
|
style = if block
|
|
98
98
|
:block
|
|
99
|
-
elsif handler.is_a?(Class) && handler <=
|
|
99
|
+
elsif handler.is_a?(Class) && handler <= ActiveSaga::Task
|
|
100
100
|
:task
|
|
101
101
|
elsif handler.respond_to?(:call)
|
|
102
102
|
:callable
|
|
103
103
|
else
|
|
104
|
-
raise
|
|
104
|
+
raise ActiveSaga::Errors::InvalidStep, "Task handler must be a Task subclass, callable, or block"
|
|
105
105
|
end
|
|
106
106
|
|
|
107
|
-
step_options = if handler.is_a?(Class) && handler <=
|
|
107
|
+
step_options = if handler.is_a?(Class) && handler <= ActiveSaga::Task
|
|
108
108
|
handler.async_options.deep_merge(options)
|
|
109
109
|
else
|
|
110
110
|
options
|
|
@@ -123,8 +123,8 @@ module ActiveWorkflow
|
|
|
123
123
|
name = name.to_sym
|
|
124
124
|
step_options = resolve_defaults((options || {}).deep_symbolize_keys)
|
|
125
125
|
definition = StepDefinition.new(name:, style:, callable:, options: step_options)
|
|
126
|
-
remaining =
|
|
127
|
-
self.
|
|
126
|
+
remaining = _as_steps.reject { |step| step.name == name }
|
|
127
|
+
self._as_steps = remaining + [definition]
|
|
128
128
|
end
|
|
129
129
|
end
|
|
130
130
|
end
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
3
|
+
module ActiveSaga
|
|
4
4
|
# Lightweight value object representing a workflow execution.
|
|
5
5
|
class Execution
|
|
6
6
|
attr_reader :id, :workflow_class, :state, :ctx, :cursor_step, :created_at, :updated_at, :cancelled_at
|
|
7
7
|
|
|
8
|
-
def initialize(id:, workflow_class:, state:, ctx:, cursor_step:, created_at:, updated_at:, cancelled_at: nil, store:
|
|
8
|
+
def initialize(id:, workflow_class:, state:, ctx:, cursor_step:, created_at:, updated_at:, cancelled_at: nil, store: ActiveSaga.store)
|
|
9
9
|
@id = id
|
|
10
10
|
@workflow_class = workflow_class
|
|
11
11
|
@state = state
|
|
12
|
-
@ctx =
|
|
12
|
+
@ctx = ActiveSaga::Context.new(ctx)
|
|
13
13
|
@cursor_step = cursor_step&.to_sym
|
|
14
14
|
@created_at = created_at
|
|
15
15
|
@updated_at = updated_at
|
|
@@ -35,7 +35,7 @@ module ActiveWorkflow
|
|
|
35
35
|
# @param timeout [Numeric, nil] seconds to wait, nil for indefinite
|
|
36
36
|
# @param interval [Numeric] polling interval
|
|
37
37
|
def await(timeout: nil, interval: 0.5)
|
|
38
|
-
clock =
|
|
38
|
+
clock = ActiveSaga.configuration.clock
|
|
39
39
|
deadline = timeout && clock.call + timeout
|
|
40
40
|
loop do
|
|
41
41
|
break if terminal?
|
|
@@ -58,7 +58,7 @@ module ActiveWorkflow
|
|
|
58
58
|
end
|
|
59
59
|
|
|
60
60
|
def cancel!(reason: nil)
|
|
61
|
-
|
|
61
|
+
ActiveSaga.cancel!(id, reason: reason)
|
|
62
62
|
reload!
|
|
63
63
|
end
|
|
64
64
|
|