chrono_forge 0.6.0 → 0.7.0.rc1
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/README.md +269 -13
- data/examples/continue_if_webhook_example.rb +134 -0
- data/lib/chrono_forge/executor/methods/continue_if.rb +159 -0
- data/lib/chrono_forge/executor/methods/durably_execute.rb +63 -10
- data/lib/chrono_forge/executor/methods/durably_repeat.rb +298 -0
- data/lib/chrono_forge/executor/methods/wait.rb +74 -3
- data/lib/chrono_forge/executor/methods/wait_until.rb +92 -18
- data/lib/chrono_forge/executor/methods/workflow_states.rb +161 -0
- data/lib/chrono_forge/executor/methods.rb +2 -0
- data/lib/chrono_forge/version.rb +1 -1
- metadata +7 -6
- data/export.json +0 -118
- data/export.rb +0 -48
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 249a16358802f143b744130ac2807cea606fe1c3d4c4f2fc20b6ab83e4aaba9b
|
4
|
+
data.tar.gz: 4fe9d09279388146179f6f636f4b9a0601aaa758e7d2c0fccf035b278d7e9288
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 7fc5b49ed0beb6082b98a16e8ca764d6430a17a67e717e48350b416e6d2a5482cc05a0bc1f97a5b274c4f232eadfc3b1f9d8298b3e6bf05b9a85cfcd5e33dd41
|
7
|
+
data.tar.gz: 5b6a0c8ab5147b689563f2ab7516e78a93159437d10c8adab6de91a851abb1259e93a26413aa17617f17809ca30aec5ac14583bc51d9f7461de3848967a70f3a
|
data/README.md
CHANGED
@@ -146,30 +146,262 @@ OrderProcessingWorkflow.perform_later(
|
|
146
146
|
|
147
147
|
#### ⚡ Durable Execution
|
148
148
|
|
149
|
-
The `durably_execute` method ensures operations are executed exactly once
|
149
|
+
The `durably_execute` method ensures operations are executed exactly once with automatic retry logic and fault tolerance:
|
150
150
|
|
151
151
|
```ruby
|
152
|
-
#
|
153
|
-
durably_execute
|
152
|
+
# Basic execution
|
153
|
+
durably_execute :send_welcome_email
|
154
154
|
|
155
|
-
#
|
156
|
-
durably_execute
|
157
|
-
|
158
|
-
|
155
|
+
# With custom retry attempts
|
156
|
+
durably_execute :critical_payment_processing, max_attempts: 5
|
157
|
+
|
158
|
+
# With custom name for tracking multiple calls to same method
|
159
|
+
durably_execute :upload_file, name: "profile_image_upload"
|
160
|
+
|
161
|
+
# Complex example with error-prone operation
|
162
|
+
class FileProcessingWorkflow < ApplicationJob
|
163
|
+
prepend ChronoForge::Executor
|
164
|
+
|
165
|
+
def perform(file_id:)
|
166
|
+
@file_id = file_id
|
167
|
+
|
168
|
+
# This might fail due to network issues, rate limits, etc.
|
169
|
+
durably_execute :upload_to_s3, max_attempts: 5
|
170
|
+
|
171
|
+
# Process file after successful upload
|
172
|
+
durably_execute :generate_thumbnails, max_attempts: 3
|
173
|
+
end
|
174
|
+
|
175
|
+
private
|
176
|
+
|
177
|
+
def upload_to_s3
|
178
|
+
file = File.find(@file_id)
|
179
|
+
S3Client.upload(file.path, bucket: 'my-bucket')
|
180
|
+
Rails.logger.info "Successfully uploaded file #{@file_id} to S3"
|
181
|
+
end
|
182
|
+
|
183
|
+
def generate_thumbnails
|
184
|
+
ThumbnailService.generate(@file_id)
|
185
|
+
end
|
186
|
+
end
|
159
187
|
```
|
160
188
|
|
189
|
+
**Key Features:**
|
190
|
+
- **Idempotent**: Same operation won't be executed twice during replays
|
191
|
+
- **Automatic Retries**: Failed executions retry with exponential backoff (2^attempt seconds, capped at 32s)
|
192
|
+
- **Error Tracking**: All failures are logged with detailed error information
|
193
|
+
- **Configurable**: Customize retry attempts and step naming
|
194
|
+
|
161
195
|
#### ⏱️ Wait States
|
162
196
|
|
163
|
-
ChronoForge supports
|
197
|
+
ChronoForge supports three types of wait states, each optimized for different use cases:
|
198
|
+
|
199
|
+
**1. Time-based Waits (`wait`)**
|
200
|
+
|
201
|
+
For simple delays and scheduled pauses:
|
202
|
+
|
203
|
+
```ruby
|
204
|
+
# Simple delays
|
205
|
+
wait 30.minutes, "cooling_period"
|
206
|
+
wait 1.day, "daily_batch_interval"
|
207
|
+
|
208
|
+
# Complex workflow with multiple waits
|
209
|
+
def user_onboarding_flow
|
210
|
+
send_welcome_email
|
211
|
+
wait 1.hour, "welcome_delay"
|
212
|
+
|
213
|
+
send_tutorial_email
|
214
|
+
wait 2.days, "tutorial_followup"
|
215
|
+
|
216
|
+
send_feedback_request
|
217
|
+
end
|
218
|
+
```
|
219
|
+
|
220
|
+
**2. Automated Condition Waits (`wait_until`)**
|
221
|
+
|
222
|
+
For conditions that can be automatically polled at regular intervals:
|
164
223
|
|
165
224
|
```ruby
|
166
|
-
# Wait for
|
167
|
-
|
225
|
+
# Wait for external API
|
226
|
+
wait_until :external_api_ready?,
|
227
|
+
timeout: 30.minutes,
|
228
|
+
check_interval: 1.minute
|
229
|
+
|
230
|
+
# Wait with retry on specific errors
|
231
|
+
wait_until :database_migration_complete?,
|
232
|
+
timeout: 2.hours,
|
233
|
+
check_interval: 30.seconds,
|
234
|
+
retry_on: [ActiveRecord::ConnectionNotEstablished, Net::TimeoutError]
|
235
|
+
|
236
|
+
# Complex condition example
|
237
|
+
def third_party_service_ready?
|
238
|
+
response = HTTParty.get("https://api.example.com/health")
|
239
|
+
response.code == 200 && response.body.include?("healthy")
|
240
|
+
rescue Net::TimeoutError, Net::HTTPClientException
|
241
|
+
false # Will be retried at next check interval
|
242
|
+
end
|
168
243
|
|
169
|
-
|
170
|
-
wait_until :payment_processed,
|
244
|
+
wait_until :third_party_service_ready?,
|
171
245
|
timeout: 1.hour,
|
172
|
-
check_interval:
|
246
|
+
check_interval: 2.minutes,
|
247
|
+
retry_on: [Net::TimeoutError, Net::HTTPClientException]
|
248
|
+
```
|
249
|
+
|
250
|
+
**3. Event-driven Waits (`continue_if`)**
|
251
|
+
|
252
|
+
For conditions that depend on external events like webhooks, requiring manual workflow continuation:
|
253
|
+
|
254
|
+
```ruby
|
255
|
+
# Basic usage - wait for webhook-driven state change
|
256
|
+
continue_if :payment_confirmed?
|
257
|
+
|
258
|
+
# With custom name for better tracking
|
259
|
+
continue_if :payment_confirmed?, name: "stripe_webhook"
|
260
|
+
|
261
|
+
# Wait for manual approval
|
262
|
+
continue_if :document_approved?
|
263
|
+
|
264
|
+
# Wait for external file processing
|
265
|
+
continue_if :processing_complete?
|
266
|
+
|
267
|
+
# Multiple waits with same condition but different contexts
|
268
|
+
continue_if :external_system_ready?, name: "payment_gateway"
|
269
|
+
# ... other steps ...
|
270
|
+
continue_if :external_system_ready?, name: "inventory_system"
|
271
|
+
|
272
|
+
# Complete workflow example
|
273
|
+
class PaymentWorkflow < ApplicationJob
|
274
|
+
prepend ChronoForge::Executor
|
275
|
+
|
276
|
+
def perform(order_id:)
|
277
|
+
@order_id = order_id
|
278
|
+
|
279
|
+
# Initialize payment
|
280
|
+
durably_execute :create_payment_request
|
281
|
+
|
282
|
+
# Wait for external payment confirmation (webhook-driven)
|
283
|
+
continue_if :payment_confirmed?, name: "stripe_confirmation"
|
284
|
+
|
285
|
+
# Complete order after payment
|
286
|
+
durably_execute :fulfill_order
|
287
|
+
end
|
288
|
+
|
289
|
+
private
|
290
|
+
|
291
|
+
def payment_confirmed?
|
292
|
+
PaymentService.confirmed?(@order_id)
|
293
|
+
end
|
294
|
+
end
|
295
|
+
|
296
|
+
# Later, when webhook arrives:
|
297
|
+
PaymentService.mark_confirmed(order_id)
|
298
|
+
PaymentWorkflow.perform_later("order-#{order_id}", order_id: order_id)
|
299
|
+
```
|
300
|
+
|
301
|
+
**When to Use Each Wait Type:**
|
302
|
+
|
303
|
+
| Wait Type | Use Case | Polling | Resource Usage | Response Time |
|
304
|
+
|-----------|----------|---------|----------------|---------------|
|
305
|
+
| `wait` | Fixed delays, rate limiting | None | Minimal | Exact timing |
|
306
|
+
| `wait_until` | API readiness, data processing | Automatic | Medium | Check interval |
|
307
|
+
| `continue_if` | Webhooks, user actions, file uploads | Manual only | Minimal | Immediate |
|
308
|
+
|
309
|
+
**Key Differences:**
|
310
|
+
|
311
|
+
- **`wait`**: Time-based, no condition checking, resumes automatically
|
312
|
+
- **`wait_until`**: Condition-based with automatic polling, resumes when condition becomes true or timeout
|
313
|
+
- **`continue_if`**: Condition-based without polling, requires manual workflow retry when condition might have changed
|
314
|
+
|
315
|
+
#### 🔄 Periodic Tasks
|
316
|
+
|
317
|
+
The `durably_repeat` method enables robust periodic task execution within workflows. Tasks are scheduled at regular intervals until a specified condition is met, with automatic catch-up for missed executions and configurable error handling.
|
318
|
+
|
319
|
+
```ruby
|
320
|
+
class NotificationWorkflow < ApplicationJob
|
321
|
+
prepend ChronoForge::Executor
|
322
|
+
|
323
|
+
def perform(user_id:)
|
324
|
+
@user_id = user_id
|
325
|
+
|
326
|
+
# Send reminders every 3 days until user completes onboarding
|
327
|
+
durably_repeat :send_reminder_email,
|
328
|
+
every: 3.days,
|
329
|
+
till: :user_onboarded?
|
330
|
+
|
331
|
+
# Critical payment processing every hour - fail workflow if it fails
|
332
|
+
durably_repeat :process_pending_payments,
|
333
|
+
every: 1.hour,
|
334
|
+
till: :all_payments_processed?,
|
335
|
+
on_error: :fail_workflow
|
336
|
+
end
|
337
|
+
|
338
|
+
private
|
339
|
+
|
340
|
+
def send_reminder_email(scheduled_time = nil)
|
341
|
+
# Optional parameter receives the scheduled execution time
|
342
|
+
if scheduled_time
|
343
|
+
lateness = Time.current - scheduled_time
|
344
|
+
Rails.logger.info "Reminder scheduled for #{scheduled_time}, running #{lateness.to_i}s late"
|
345
|
+
end
|
346
|
+
|
347
|
+
UserMailer.onboarding_reminder(@user_id).deliver_now
|
348
|
+
end
|
349
|
+
|
350
|
+
def user_onboarded?
|
351
|
+
User.find(@user_id).onboarded?
|
352
|
+
end
|
353
|
+
|
354
|
+
def process_pending_payments
|
355
|
+
PaymentProcessor.process_pending_for_user(@user_id)
|
356
|
+
end
|
357
|
+
|
358
|
+
def all_payments_processed?
|
359
|
+
Payment.where(user_id: @user_id, status: :pending).empty?
|
360
|
+
end
|
361
|
+
end
|
362
|
+
```
|
363
|
+
|
364
|
+
**Key Features:**
|
365
|
+
|
366
|
+
- **Idempotent Execution**: Each repetition gets a unique execution log, preventing duplicates during replays
|
367
|
+
- **Automatic Catch-up**: Missed executions due to downtime are automatically skipped using timeout-based fast-forwarding
|
368
|
+
- **Flexible Timing**: Support for custom start times and precise interval scheduling
|
369
|
+
- **Error Resilience**: Individual execution failures don't break the periodic schedule
|
370
|
+
- **Configurable Error Handling**: Choose between continuing despite failures or failing the entire workflow
|
371
|
+
|
372
|
+
**Advanced Options:**
|
373
|
+
|
374
|
+
```ruby
|
375
|
+
durably_repeat :generate_daily_report,
|
376
|
+
every: 1.day, # Execution interval
|
377
|
+
till: :reports_complete?, # Stop condition
|
378
|
+
start_at: Date.tomorrow.beginning_of_day, # Custom start time (optional)
|
379
|
+
max_attempts: 5, # Retries per execution (default: 3)
|
380
|
+
timeout: 2.hours, # Catch-up timeout (default: 1.hour)
|
381
|
+
on_error: :fail_workflow, # Error handling (:continue or :fail_workflow)
|
382
|
+
name: "daily_reports" # Custom task name (optional)
|
383
|
+
```
|
384
|
+
|
385
|
+
**Method Parameters:**
|
386
|
+
|
387
|
+
Your periodic methods can optionally receive the scheduled execution time as their first argument:
|
388
|
+
|
389
|
+
```ruby
|
390
|
+
# Without scheduled time parameter
|
391
|
+
def cleanup_files
|
392
|
+
FileCleanupService.perform
|
393
|
+
end
|
394
|
+
|
395
|
+
# With scheduled time parameter
|
396
|
+
def cleanup_files(scheduled_time)
|
397
|
+
# Use scheduled time for business logic
|
398
|
+
cleanup_date = scheduled_time.to_date
|
399
|
+
FileCleanupService.perform(date: cleanup_date)
|
400
|
+
|
401
|
+
# Log timing information
|
402
|
+
delay = Time.current - scheduled_time
|
403
|
+
Rails.logger.info "Cleanup was #{delay.to_i} seconds late"
|
404
|
+
end
|
173
405
|
```
|
174
406
|
|
175
407
|
#### 🔄 Workflow Context
|
@@ -413,3 +645,27 @@ Please include tests for any new features or bug fixes.
|
|
413
645
|
## 📜 License
|
414
646
|
|
415
647
|
This gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
648
|
+
|
649
|
+
## 📚 API Reference
|
650
|
+
|
651
|
+
### Core Workflow Methods
|
652
|
+
|
653
|
+
| Method | Purpose | Key Parameters |
|
654
|
+
|--------|---------|----------------|
|
655
|
+
| `durably_execute` | Execute method with retry logic | `method`, `max_attempts: 3`, `name: nil` |
|
656
|
+
| `wait` | Time-based pause | `duration`, `name` |
|
657
|
+
| `wait_until` | Condition-based waiting | `condition`, `timeout: 1.hour`, `check_interval: 15.minutes`, `retry_on: []` |
|
658
|
+
| `continue_if` | Manual continuation wait | `condition`, `name: nil` |
|
659
|
+
| `durably_repeat` | Periodic task execution | `method`, `every:`, `till:`, `start_at: nil`, `max_attempts: 3`, `timeout: 1.hour`, `on_error: :continue` |
|
660
|
+
|
661
|
+
### Context Methods
|
662
|
+
|
663
|
+
| Method | Purpose | Example |
|
664
|
+
|--------|---------|---------|
|
665
|
+
| `context[:key] = value` | Set context value | `context[:user_id] = 123` |
|
666
|
+
| `context[:key]` | Get context value | `user_id = context[:user_id]` |
|
667
|
+
| `context.set(key, value)` | Set context value (alias) | `context.set(:status, "active")` |
|
668
|
+
| `context.set_once(key, value)` | Set only if key doesn't exist | `context.set_once(:created_at, Time.current)` |
|
669
|
+
| `context.fetch(key, default)` | Get with default value | `context.fetch(:count, 0)` |
|
670
|
+
| `context.key?(key)` | Check if key exists | `context.key?(:user_id)` |
|
671
|
+
|
@@ -0,0 +1,134 @@
|
|
1
|
+
# Example: Webhook-driven Payment Processing Workflow
|
2
|
+
#
|
3
|
+
# This example demonstrates how to use continue_if for workflows that need to wait
|
4
|
+
# for external events like webhook notifications without consuming resources on polling.
|
5
|
+
|
6
|
+
class PaymentProcessingWorkflow < ApplicationJob
|
7
|
+
prepend ChronoForge::Executor
|
8
|
+
|
9
|
+
def perform(order_id:)
|
10
|
+
@order_id = order_id
|
11
|
+
|
12
|
+
# Store order details in durable context
|
13
|
+
context[:order_id] = @order_id
|
14
|
+
context[:created_at] = Time.current.iso8601
|
15
|
+
|
16
|
+
# Step 1: Initialize payment request
|
17
|
+
durably_execute :initialize_payment
|
18
|
+
|
19
|
+
# Step 2: Wait for payment confirmation (webhook-driven)
|
20
|
+
# This will halt the workflow until manually retried when webhook arrives
|
21
|
+
continue_if :payment_confirmed?, name: "stripe_payment_webhook"
|
22
|
+
|
23
|
+
# Step 3: Process successful payment
|
24
|
+
durably_execute :process_payment_success
|
25
|
+
|
26
|
+
# Step 4: Send confirmation to customer
|
27
|
+
durably_execute :send_confirmation
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def initialize_payment
|
33
|
+
# Create payment request with external payment provider
|
34
|
+
payment_request = PaymentService.create_payment_request(
|
35
|
+
order_id: @order_id,
|
36
|
+
amount: Order.find(@order_id).total_amount
|
37
|
+
)
|
38
|
+
|
39
|
+
context[:payment_request_id] = payment_request.id
|
40
|
+
context[:payment_status] = "pending"
|
41
|
+
|
42
|
+
Rails.logger.info "Payment request created for order #{@order_id}: #{payment_request.id}"
|
43
|
+
end
|
44
|
+
|
45
|
+
def payment_confirmed?
|
46
|
+
# Check if payment has been confirmed by webhook
|
47
|
+
payment_id = context[:payment_request_id]
|
48
|
+
payment = PaymentService.find_payment(payment_id)
|
49
|
+
|
50
|
+
confirmed = payment&.status == "confirmed"
|
51
|
+
|
52
|
+
if confirmed
|
53
|
+
context[:payment_status] = "confirmed"
|
54
|
+
context[:confirmed_at] = Time.current.iso8601
|
55
|
+
end
|
56
|
+
|
57
|
+
Rails.logger.debug "Payment confirmation check for #{payment_id}: #{confirmed}"
|
58
|
+
confirmed
|
59
|
+
end
|
60
|
+
|
61
|
+
def process_payment_success
|
62
|
+
# Update order status and inventory
|
63
|
+
order = Order.find(@order_id)
|
64
|
+
order.mark_as_paid!
|
65
|
+
|
66
|
+
# Update inventory
|
67
|
+
InventoryService.reserve_items(order.items)
|
68
|
+
|
69
|
+
context[:processed_at] = Time.current.iso8601
|
70
|
+
Rails.logger.info "Payment processed successfully for order #{@order_id}"
|
71
|
+
end
|
72
|
+
|
73
|
+
def send_confirmation
|
74
|
+
# Send confirmation email to customer
|
75
|
+
order = Order.find(@order_id)
|
76
|
+
OrderMailer.payment_confirmation(order).deliver_now
|
77
|
+
|
78
|
+
context[:confirmation_sent_at] = Time.current.iso8601
|
79
|
+
Rails.logger.info "Confirmation email sent for order #{@order_id}"
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
# Webhook handler that resumes the workflow
|
84
|
+
class PaymentWebhookController < ApplicationController
|
85
|
+
def receive
|
86
|
+
payment_id = params[:payment_id]
|
87
|
+
status = params[:status]
|
88
|
+
|
89
|
+
if status == "confirmed"
|
90
|
+
# Update payment status in your system
|
91
|
+
PaymentService.update_payment_status(payment_id, "confirmed")
|
92
|
+
|
93
|
+
# Find and continue the corresponding workflow
|
94
|
+
order_id = PaymentService.find_payment(payment_id).order_id
|
95
|
+
workflow_key = "payment-#{order_id}"
|
96
|
+
|
97
|
+
# Continue the workflow from where it left off (continue_if)
|
98
|
+
PaymentProcessingWorkflow.perform_later(workflow_key, order_id: order_id)
|
99
|
+
|
100
|
+
Rails.logger.info "Payment confirmed via webhook, continuing workflow #{workflow_key}"
|
101
|
+
end
|
102
|
+
|
103
|
+
head :ok
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
# Usage example:
|
108
|
+
#
|
109
|
+
# 1. Start the workflow:
|
110
|
+
# PaymentProcessingWorkflow.perform_later("payment-order-123", order_id: "order-123")
|
111
|
+
#
|
112
|
+
# 2. Workflow will:
|
113
|
+
# - Create payment request
|
114
|
+
# - Check if payment is confirmed (initially false)
|
115
|
+
# - Halt execution and wait in idle state
|
116
|
+
#
|
117
|
+
# 3. When webhook arrives:
|
118
|
+
# - PaymentWebhookController#receive processes webhook
|
119
|
+
# - Updates payment status to "confirmed"
|
120
|
+
# - Calls PaymentProcessingWorkflow.perform_later("payment-order-123", order_id: "order-123")
|
121
|
+
#
|
122
|
+
# 4. Workflow resumes:
|
123
|
+
# - Re-evaluates payment_confirmed? (now returns true)
|
124
|
+
# - Continues with processing payment success
|
125
|
+
# - Sends confirmation email
|
126
|
+
# - Completes workflow
|
127
|
+
|
128
|
+
# Key benefits of continue_if vs wait_until:
|
129
|
+
#
|
130
|
+
# 1. No resource consumption: No background polling jobs
|
131
|
+
# 2. Instant response: Resumes immediately when condition changes
|
132
|
+
# 3. Webhook-friendly: Perfect for external event-driven workflows
|
133
|
+
# 4. Durable: Survives system restarts and deployments
|
134
|
+
# 5. Traceable: All state changes are logged in execution logs
|
@@ -0,0 +1,159 @@
|
|
1
|
+
module ChronoForge
|
2
|
+
module Executor
|
3
|
+
module Methods
|
4
|
+
module ContinueIf
|
5
|
+
# Waits until a specified condition becomes true, without any automatic polling or time-based checks.
|
6
|
+
#
|
7
|
+
# This method provides a durable pause state that can only be resumed by manually retrying
|
8
|
+
# the workflow (typically triggered by external events like webhooks). Unlike wait_until,
|
9
|
+
# this method does not automatically poll the condition - it simply evaluates the condition
|
10
|
+
# once and either proceeds (if true) or halts execution (if false) until manually retried.
|
11
|
+
#
|
12
|
+
# @param condition [Symbol] The name of the instance method to evaluate as the condition.
|
13
|
+
# The method should return a truthy value when the condition is met.
|
14
|
+
# @param name [String, nil] Optional custom name for this step. If not provided, uses the condition name.
|
15
|
+
# Useful for tracking multiple calls to the same condition or providing more descriptive names.
|
16
|
+
#
|
17
|
+
# @return [true] When the condition is met
|
18
|
+
#
|
19
|
+
# @example Basic usage
|
20
|
+
# continue_if :payment_confirmed?
|
21
|
+
#
|
22
|
+
# @example With custom name
|
23
|
+
# continue_if :payment_confirmed?, name: "stripe_payment_confirmation"
|
24
|
+
#
|
25
|
+
# @example Waiting for external webhook
|
26
|
+
# continue_if :webhook_received?
|
27
|
+
#
|
28
|
+
# @example Waiting for manual approval
|
29
|
+
# continue_if :approval_granted?
|
30
|
+
#
|
31
|
+
# @example Multiple continue_if with same condition but different names
|
32
|
+
# continue_if :external_system_ready?, name: "payment_system_ready"
|
33
|
+
# # ... other workflow steps ...
|
34
|
+
# continue_if :external_system_ready?, name: "inventory_system_ready"
|
35
|
+
#
|
36
|
+
# @example Complete workflow with manual continuation
|
37
|
+
# def perform(order_id:)
|
38
|
+
# @order_id = order_id
|
39
|
+
#
|
40
|
+
# # Process initial order
|
41
|
+
# durably_execute :initialize_order
|
42
|
+
#
|
43
|
+
# # Wait for external payment confirmation (webhook-driven)
|
44
|
+
# continue_if :payment_confirmed?, name: "stripe_webhook"
|
45
|
+
#
|
46
|
+
# # Complete order processing
|
47
|
+
# durably_execute :complete_order
|
48
|
+
# end
|
49
|
+
#
|
50
|
+
# private
|
51
|
+
#
|
52
|
+
# def payment_confirmed?
|
53
|
+
# PaymentService.confirmed?(@order_id)
|
54
|
+
# end
|
55
|
+
#
|
56
|
+
# # Later, when webhook arrives:
|
57
|
+
# # PaymentService.mark_confirmed(order_id)
|
58
|
+
# # OrderProcessingWorkflow.perform_later("order-#{order_id}", order_id: order_id)
|
59
|
+
#
|
60
|
+
# == Behavior
|
61
|
+
#
|
62
|
+
# === Condition Evaluation
|
63
|
+
# The condition method is called once per workflow execution:
|
64
|
+
# - If truthy, execution continues immediately
|
65
|
+
# - If falsy, workflow execution halts until manually retried
|
66
|
+
# - No automatic polling or retry attempts are made
|
67
|
+
#
|
68
|
+
# === Manual Retry Required
|
69
|
+
# Unlike other wait states, continue_if requires external intervention:
|
70
|
+
# - Call Workflow.perform_later(key, **kwargs) to continue the workflow
|
71
|
+
# - Typically triggered by webhooks, background jobs, or manual intervention
|
72
|
+
# - No timeout or automatic resumption
|
73
|
+
#
|
74
|
+
# === Error Handling
|
75
|
+
# - Exceptions during condition evaluation cause workflow failure
|
76
|
+
# - No automatic retry on condition evaluation errors
|
77
|
+
# - Use try/catch in condition methods for error handling
|
78
|
+
#
|
79
|
+
# === Persistence and Resumability
|
80
|
+
# - Wait state is persisted in execution logs
|
81
|
+
# - Workflow can be stopped/restarted without losing wait progress
|
82
|
+
# - Condition evaluation state persists across restarts
|
83
|
+
# - Safe for system interruptions and deployments
|
84
|
+
#
|
85
|
+
# === Execution Logs
|
86
|
+
# Creates execution log with step name: `continue_if$#{name || condition}`
|
87
|
+
# - Tracks attempt count and execution times
|
88
|
+
# - Records final result (true for success)
|
89
|
+
#
|
90
|
+
# === Use Cases
|
91
|
+
# Perfect for workflows that depend on:
|
92
|
+
# - External webhook notifications
|
93
|
+
# - Manual approval processes
|
94
|
+
# - File uploads or external processing completion
|
95
|
+
# - Third-party system state changes
|
96
|
+
# - User actions or form submissions
|
97
|
+
#
|
98
|
+
def continue_if(condition, name: nil)
|
99
|
+
step_name = "continue_if$#{name || condition}"
|
100
|
+
|
101
|
+
# Find or create execution log
|
102
|
+
execution_log = ExecutionLog.create_or_find_by!(
|
103
|
+
workflow: @workflow,
|
104
|
+
step_name: step_name
|
105
|
+
) do |log|
|
106
|
+
log.started_at = Time.current
|
107
|
+
log.metadata = {
|
108
|
+
condition: condition.to_s,
|
109
|
+
name: name
|
110
|
+
}
|
111
|
+
end
|
112
|
+
|
113
|
+
# Return if already completed
|
114
|
+
if execution_log.completed?
|
115
|
+
return execution_log.metadata["result"]
|
116
|
+
end
|
117
|
+
|
118
|
+
# Evaluate condition once
|
119
|
+
begin
|
120
|
+
execution_log.update!(
|
121
|
+
attempts: execution_log.attempts + 1,
|
122
|
+
last_executed_at: Time.current
|
123
|
+
)
|
124
|
+
|
125
|
+
condition_met = send(condition)
|
126
|
+
rescue HaltExecutionFlow
|
127
|
+
raise
|
128
|
+
rescue => e
|
129
|
+
# Log the error and fail the execution
|
130
|
+
Rails.logger.error { "Error evaluating condition #{condition}: #{e.message}" }
|
131
|
+
self.class::ExecutionTracker.track_error(workflow, e)
|
132
|
+
|
133
|
+
execution_log.update!(
|
134
|
+
state: :failed,
|
135
|
+
error_message: e.message,
|
136
|
+
error_class: e.class.name
|
137
|
+
)
|
138
|
+
raise ExecutionFailedError, "#{step_name} failed with an error: #{e.message}"
|
139
|
+
end
|
140
|
+
|
141
|
+
# Handle condition met
|
142
|
+
if condition_met
|
143
|
+
execution_log.update!(
|
144
|
+
state: :completed,
|
145
|
+
completed_at: Time.current,
|
146
|
+
metadata: execution_log.metadata.merge("result" => true)
|
147
|
+
)
|
148
|
+
return true
|
149
|
+
end
|
150
|
+
|
151
|
+
# Condition not met - halt execution without scheduling any retry
|
152
|
+
# Workflow will remain in idle state until manually retried
|
153
|
+
Rails.logger.debug { "Condition not met for #{step_name}, workflow will wait for manual retry" }
|
154
|
+
halt_execution!
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|