shikibu 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +487 -0
- data/lib/shikibu/activity.rb +135 -0
- data/lib/shikibu/app.rb +299 -0
- data/lib/shikibu/channels.rb +360 -0
- data/lib/shikibu/constants.rb +70 -0
- data/lib/shikibu/context.rb +208 -0
- data/lib/shikibu/errors.rb +137 -0
- data/lib/shikibu/integrations/active_job.rb +95 -0
- data/lib/shikibu/integrations/sidekiq.rb +104 -0
- data/lib/shikibu/locking.rb +110 -0
- data/lib/shikibu/middleware/rack_app.rb +197 -0
- data/lib/shikibu/notify/notify_base.rb +67 -0
- data/lib/shikibu/notify/pg_notify.rb +217 -0
- data/lib/shikibu/notify/wake_event.rb +56 -0
- data/lib/shikibu/outbox/relayer.rb +227 -0
- data/lib/shikibu/replay.rb +361 -0
- data/lib/shikibu/retry_policy.rb +81 -0
- data/lib/shikibu/storage/migrations.rb +179 -0
- data/lib/shikibu/storage/sequel_storage.rb +883 -0
- data/lib/shikibu/version.rb +5 -0
- data/lib/shikibu/worker.rb +389 -0
- data/lib/shikibu/workflow.rb +398 -0
- data/lib/shikibu.rb +152 -0
- data/schema/LICENSE +21 -0
- data/schema/README.md +57 -0
- data/schema/db/migrations/mysql/20251217000000_initial_schema.sql +284 -0
- data/schema/db/migrations/postgresql/20251217000000_initial_schema.sql +284 -0
- data/schema/db/migrations/sqlite/20251217000000_initial_schema.sql +284 -0
- data/schema/docs/column-values.md +91 -0
- metadata +231 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module Shikibu
|
|
7
|
+
module Outbox
|
|
8
|
+
# Background relayer for publishing outbox events to external message brokers.
|
|
9
|
+
#
|
|
10
|
+
# The relayer polls the database for pending events and publishes them
|
|
11
|
+
# as CloudEvents to a configured HTTP endpoint. It implements exponential
|
|
12
|
+
# backoff for retries and graceful shutdown.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# relayer = Shikibu::Outbox::Relayer.new(
|
|
16
|
+
# storage: storage,
|
|
17
|
+
# broker_url: 'http://broker-ingress.default.svc.cluster.local'
|
|
18
|
+
# )
|
|
19
|
+
# relayer.start
|
|
20
|
+
# # ... later ...
|
|
21
|
+
# relayer.stop
|
|
22
|
+
#
|
|
23
|
+
class Relayer
|
|
24
|
+
DEFAULT_POLL_INTERVAL = 1.0
|
|
25
|
+
DEFAULT_MAX_RETRIES = 3
|
|
26
|
+
DEFAULT_BATCH_SIZE = 10
|
|
27
|
+
MAX_BACKOFF = 30.0
|
|
28
|
+
HTTP_OPEN_TIMEOUT = 10
|
|
29
|
+
HTTP_READ_TIMEOUT = 30
|
|
30
|
+
|
|
31
|
+
attr_reader :storage, :broker_url, :poll_interval, :max_retries, :batch_size, :max_age_hours
|
|
32
|
+
|
|
33
|
+
def initialize(storage:, broker_url:, wake_event: nil, poll_interval: DEFAULT_POLL_INTERVAL,
|
|
34
|
+
max_retries: DEFAULT_MAX_RETRIES, batch_size: DEFAULT_BATCH_SIZE, max_age_hours: nil)
|
|
35
|
+
@storage = storage
|
|
36
|
+
@broker_url = URI.parse(broker_url)
|
|
37
|
+
@wake_event = wake_event
|
|
38
|
+
@poll_interval = poll_interval
|
|
39
|
+
@max_retries = max_retries
|
|
40
|
+
@batch_size = batch_size
|
|
41
|
+
@max_age_hours = max_age_hours
|
|
42
|
+
@running = false
|
|
43
|
+
@thread = nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def start
|
|
47
|
+
return if @running
|
|
48
|
+
|
|
49
|
+
@running = true
|
|
50
|
+
@thread = Thread.new { poll_loop }
|
|
51
|
+
log_info("started (broker=#{@broker_url}, poll_interval=#{@poll_interval}s)")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def stop
|
|
55
|
+
return unless @running
|
|
56
|
+
|
|
57
|
+
@running = false
|
|
58
|
+
@wake_event&.signal # Wake up if waiting
|
|
59
|
+
@thread&.join(5)
|
|
60
|
+
log_info('stopped')
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def running?
|
|
64
|
+
@running
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def poll_loop
|
|
70
|
+
consecutive_empty = 0
|
|
71
|
+
|
|
72
|
+
while @running
|
|
73
|
+
begin
|
|
74
|
+
count = poll_and_publish
|
|
75
|
+
consecutive_empty = count.zero? ? consecutive_empty + 1 : 0
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
log_error('poll_loop', e)
|
|
78
|
+
consecutive_empty = 0 # Reset on error to avoid long backoffs
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
backoff = calculate_backoff(consecutive_empty)
|
|
82
|
+
wait_with_wake(backoff)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def poll_and_publish
|
|
87
|
+
events = @storage.get_pending_outbox_events(limit: @batch_size)
|
|
88
|
+
return 0 if events.empty?
|
|
89
|
+
|
|
90
|
+
log_debug("processing #{events.size} pending outbox events")
|
|
91
|
+
|
|
92
|
+
events.each do |event|
|
|
93
|
+
break unless @running
|
|
94
|
+
|
|
95
|
+
publish_event(event)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
events.size
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def publish_event(event)
|
|
102
|
+
event_id = event[:event_id]
|
|
103
|
+
retry_count = event[:retry_count] || 0
|
|
104
|
+
|
|
105
|
+
# Check max age
|
|
106
|
+
if expired?(event)
|
|
107
|
+
@storage.mark_outbox_expired(event_id, "Exceeded max age (#{@max_age_hours} hours)")
|
|
108
|
+
log_warn("event #{event_id} exceeded max age, marking as expired")
|
|
109
|
+
return
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Check max retries
|
|
113
|
+
if retry_count >= @max_retries
|
|
114
|
+
@storage.mark_outbox_invalid(event_id, "Exceeded max retries (#{@max_retries})")
|
|
115
|
+
log_warn("event #{event_id} exceeded max retries, marking as invalid")
|
|
116
|
+
return
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Build and send CloudEvent
|
|
120
|
+
response = send_cloud_event(event)
|
|
121
|
+
|
|
122
|
+
case response
|
|
123
|
+
when Net::HTTPSuccess
|
|
124
|
+
@storage.mark_outbox_published(event_id)
|
|
125
|
+
log_info("published event #{event_id}")
|
|
126
|
+
when Net::HTTPClientError
|
|
127
|
+
# 4xx errors are permanent failures
|
|
128
|
+
@storage.mark_outbox_invalid(event_id, "HTTP #{response.code}: #{response.message}")
|
|
129
|
+
log_error_msg("permanent error for event #{event_id}: HTTP #{response.code}")
|
|
130
|
+
else
|
|
131
|
+
# 5xx or other errors are retryable
|
|
132
|
+
@storage.mark_outbox_failed(event_id, "HTTP #{response.code}: #{response.message}")
|
|
133
|
+
retry_msg = "retry #{retry_count + 1}/#{@max_retries}"
|
|
134
|
+
log_warn("server error for event #{event_id} (#{retry_msg}): HTTP #{response.code}")
|
|
135
|
+
end
|
|
136
|
+
rescue SocketError, Errno::ECONNREFUSED, Errno::ETIMEDOUT, Net::OpenTimeout, Net::ReadTimeout => e
|
|
137
|
+
# Network errors are retryable
|
|
138
|
+
@storage.mark_outbox_failed(event_id, "#{e.class}: #{e.message}")
|
|
139
|
+
log_warn("network error for event #{event_id} (retry #{retry_count + 1}/#{@max_retries}): #{e.message}")
|
|
140
|
+
rescue StandardError => e
|
|
141
|
+
# Unknown errors are retryable (safety net)
|
|
142
|
+
@storage.mark_outbox_failed(event_id, "#{e.class}: #{e.message}")
|
|
143
|
+
log_error('publish_event', e)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def send_cloud_event(event)
|
|
147
|
+
http = Net::HTTP.new(@broker_url.host, @broker_url.port)
|
|
148
|
+
http.use_ssl = @broker_url.scheme == 'https'
|
|
149
|
+
http.open_timeout = HTTP_OPEN_TIMEOUT
|
|
150
|
+
http.read_timeout = HTTP_READ_TIMEOUT
|
|
151
|
+
|
|
152
|
+
path = @broker_url.path.empty? ? '/' : @broker_url.path
|
|
153
|
+
request = Net::HTTP::Post.new(path)
|
|
154
|
+
request['Content-Type'] = 'application/cloudevents+json'
|
|
155
|
+
|
|
156
|
+
cloud_event = {
|
|
157
|
+
specversion: '1.0',
|
|
158
|
+
id: event[:event_id],
|
|
159
|
+
type: event[:event_type],
|
|
160
|
+
source: event[:event_source],
|
|
161
|
+
datacontenttype: event[:content_type] || 'application/json',
|
|
162
|
+
time: format_time(event[:created_at]),
|
|
163
|
+
data: event[:event_data]
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
request.body = cloud_event.to_json
|
|
167
|
+
http.request(request)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def format_time(time)
|
|
171
|
+
return time.iso8601 if time.respond_to?(:iso8601)
|
|
172
|
+
|
|
173
|
+
time.to_s
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def expired?(event)
|
|
177
|
+
return false unless @max_age_hours
|
|
178
|
+
|
|
179
|
+
created_at = event[:created_at]
|
|
180
|
+
return false unless created_at
|
|
181
|
+
|
|
182
|
+
age_hours = (Time.now - created_at) / 3600.0
|
|
183
|
+
age_hours > @max_age_hours
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def calculate_backoff(consecutive_empty)
|
|
187
|
+
return @poll_interval if consecutive_empty.zero?
|
|
188
|
+
|
|
189
|
+
# Exponential backoff: 2s, 4s, 8s, 16s, max 30s
|
|
190
|
+
exp = [consecutive_empty, 4].min
|
|
191
|
+
backoff = @poll_interval * (2**exp)
|
|
192
|
+
jitter = rand * backoff * 0.3
|
|
193
|
+
[backoff + jitter, MAX_BACKOFF].min
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def wait_with_wake(backoff)
|
|
197
|
+
if @wake_event
|
|
198
|
+
@wake_event.wait(backoff)
|
|
199
|
+
else
|
|
200
|
+
sleep(backoff)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def log_info(message)
|
|
205
|
+
warn "[Shikibu::Outbox::Relayer] #{message}"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def log_warn(message)
|
|
209
|
+
warn "[Shikibu::Outbox::Relayer] WARNING: #{message}"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def log_debug(message)
|
|
213
|
+
# Only log in debug mode if needed
|
|
214
|
+
# warn "[Shikibu::Outbox::Relayer] DEBUG: #{message}"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def log_error(context, error)
|
|
218
|
+
warn "[Shikibu::Outbox::Relayer] ERROR in #{context}: #{error.class}: #{error.message}"
|
|
219
|
+
warn error.backtrace.first(5).join("\n") if error.backtrace
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def log_error_msg(message)
|
|
223
|
+
warn "[Shikibu::Outbox::Relayer] ERROR: #{message}"
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shikibu
|
|
4
|
+
# Orchestrates workflow execution with deterministic replay
|
|
5
|
+
class ReplayEngine
|
|
6
|
+
attr_reader :storage, :worker_id, :hooks
|
|
7
|
+
|
|
8
|
+
def initialize(storage:, worker_id:, hooks: nil)
|
|
9
|
+
@storage = storage
|
|
10
|
+
@worker_id = worker_id
|
|
11
|
+
@hooks = hooks
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Start a new workflow instance
|
|
15
|
+
# @param workflow_class [Class] Workflow class
|
|
16
|
+
# @param instance_id [String] Instance ID
|
|
17
|
+
# @param input [Hash] Input parameters
|
|
18
|
+
# @return [Object, nil] Workflow result or nil if suspended
|
|
19
|
+
def start_workflow(workflow_class, instance_id:, **input)
|
|
20
|
+
# Save workflow definition
|
|
21
|
+
storage.save_workflow_definition(
|
|
22
|
+
workflow_name: workflow_class.workflow_name,
|
|
23
|
+
source_hash: workflow_class.source_hash,
|
|
24
|
+
source_code: workflow_class.source_code
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Create instance record
|
|
28
|
+
storage.create_instance(
|
|
29
|
+
instance_id: instance_id,
|
|
30
|
+
workflow_name: workflow_class.workflow_name,
|
|
31
|
+
source_hash: workflow_class.source_hash,
|
|
32
|
+
owner_service: 'default',
|
|
33
|
+
input_data: input,
|
|
34
|
+
status: Status::RUNNING
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Execute the workflow
|
|
38
|
+
execute_workflow(instance_id, workflow_class, input, replaying: false)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Resume a workflow from its current state
|
|
42
|
+
# @param instance_id [String] Instance ID
|
|
43
|
+
# @return [Object, nil] Workflow result or nil if suspended
|
|
44
|
+
def resume_workflow(instance_id)
|
|
45
|
+
instance = storage.get_instance(instance_id)
|
|
46
|
+
raise WorkflowNotFoundError, instance_id unless instance
|
|
47
|
+
|
|
48
|
+
# Handle crash recovery for compensating workflows (Romancy/Edda compatible)
|
|
49
|
+
return resume_compensating_workflow(instance_id) if instance[:status] == Status::COMPENSATING
|
|
50
|
+
|
|
51
|
+
workflow_class = Shikibu.get_workflow(instance[:workflow_name])
|
|
52
|
+
raise WorkflowNotRegisteredError, instance[:workflow_name] unless workflow_class
|
|
53
|
+
|
|
54
|
+
# Load history and build cache
|
|
55
|
+
history = storage.get_history(instance_id)
|
|
56
|
+
history_cache = build_history_cache(history)
|
|
57
|
+
|
|
58
|
+
execute_workflow(
|
|
59
|
+
instance_id,
|
|
60
|
+
workflow_class,
|
|
61
|
+
instance[:input_data],
|
|
62
|
+
replaying: true,
|
|
63
|
+
history_cache: history_cache
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Resume a workflow that was in compensating state when it crashed
|
|
68
|
+
# This executes remaining compensations from DB using the global registry
|
|
69
|
+
# @param instance_id [String] Instance ID
|
|
70
|
+
# @return [nil]
|
|
71
|
+
def resume_compensating_workflow(instance_id)
|
|
72
|
+
# Acquire lock
|
|
73
|
+
raise LockNotAcquiredError, instance_id unless storage.try_acquire_lock(instance_id, worker_id, timeout: 300)
|
|
74
|
+
|
|
75
|
+
begin
|
|
76
|
+
execute_compensations_from_db(instance_id)
|
|
77
|
+
|
|
78
|
+
# Clear compensations and update status
|
|
79
|
+
storage.clear_compensations(instance_id)
|
|
80
|
+
storage.update_instance_status(instance_id, Status::FAILED)
|
|
81
|
+
|
|
82
|
+
nil
|
|
83
|
+
ensure
|
|
84
|
+
storage.release_lock(instance_id, worker_id)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Execute compensations from DB (for crash recovery)
|
|
89
|
+
# Uses global registry to find compensation functions
|
|
90
|
+
# @param instance_id [String] Instance ID
|
|
91
|
+
def execute_compensations_from_db(instance_id)
|
|
92
|
+
compensations = storage.get_compensations(instance_id)
|
|
93
|
+
|
|
94
|
+
# Get already executed compensation IDs from history (idempotency)
|
|
95
|
+
history = storage.get_history(instance_id)
|
|
96
|
+
executed_ids = history
|
|
97
|
+
.select { |e| e[:event_type] == EventType::COMPENSATION_EXECUTED }
|
|
98
|
+
.map { |e| e[:data]&.dig(:compensation_id) }
|
|
99
|
+
.compact
|
|
100
|
+
.to_set
|
|
101
|
+
|
|
102
|
+
# Execute each compensation in order (already LIFO from DB)
|
|
103
|
+
compensations.each do |comp|
|
|
104
|
+
next if executed_ids.include?(comp[:id])
|
|
105
|
+
|
|
106
|
+
execute_compensation_from_registry(instance_id, comp)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Execute a single compensation from registry
|
|
111
|
+
# @param instance_id [String] Instance ID
|
|
112
|
+
# @param comp [Hash] Compensation record from DB
|
|
113
|
+
def execute_compensation_from_registry(instance_id, comp)
|
|
114
|
+
compensation_fn = Shikibu.get_compensation(comp[:activity_name])
|
|
115
|
+
|
|
116
|
+
if compensation_fn.nil?
|
|
117
|
+
# Inline block or unregistered compensation - cannot recover, skip with warning
|
|
118
|
+
record_compensation_skipped(instance_id, comp)
|
|
119
|
+
return
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Execute the compensation
|
|
123
|
+
args = comp[:args] || {}
|
|
124
|
+
symbolized_args = args.transform_keys(&:to_sym)
|
|
125
|
+
compensation_fn.call(nil, **symbolized_args)
|
|
126
|
+
|
|
127
|
+
# Record success
|
|
128
|
+
storage.append_history(
|
|
129
|
+
instance_id: instance_id,
|
|
130
|
+
activity_id: "compensation:#{comp[:id]}",
|
|
131
|
+
event_type: EventType::COMPENSATION_EXECUTED,
|
|
132
|
+
event_data: {
|
|
133
|
+
compensation_id: comp[:id],
|
|
134
|
+
activity_id: comp[:activity_id],
|
|
135
|
+
activity_name: comp[:activity_name]
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
rescue StandardError => e
|
|
139
|
+
# Record failure but continue
|
|
140
|
+
storage.append_history(
|
|
141
|
+
instance_id: instance_id,
|
|
142
|
+
activity_id: "compensation:#{comp[:id]}",
|
|
143
|
+
event_type: EventType::COMPENSATION_FAILED,
|
|
144
|
+
event_data: {
|
|
145
|
+
compensation_id: comp[:id],
|
|
146
|
+
activity_id: comp[:activity_id],
|
|
147
|
+
activity_name: comp[:activity_name],
|
|
148
|
+
error_type: e.class.name,
|
|
149
|
+
error_message: e.message
|
|
150
|
+
}
|
|
151
|
+
)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Record that a compensation was skipped (inline or unregistered)
|
|
155
|
+
def record_compensation_skipped(instance_id, comp)
|
|
156
|
+
storage.append_history(
|
|
157
|
+
instance_id: instance_id,
|
|
158
|
+
activity_id: "compensation:#{comp[:id]}",
|
|
159
|
+
event_type: EventType::COMPENSATION_FAILED,
|
|
160
|
+
event_data: {
|
|
161
|
+
compensation_id: comp[:id],
|
|
162
|
+
activity_id: comp[:activity_id],
|
|
163
|
+
activity_name: comp[:activity_name],
|
|
164
|
+
error_type: 'CompensationNotFound',
|
|
165
|
+
error_message: "Compensation '#{comp[:activity_name]}' not found in registry " \
|
|
166
|
+
'(inline blocks cannot be recovered after crash)'
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
def execute_workflow(instance_id, workflow_class, input, replaying:, history_cache: {})
|
|
174
|
+
# Acquire lock
|
|
175
|
+
lock_timeout = workflow_class.lock_timeout
|
|
176
|
+
unless storage.try_acquire_lock(instance_id, worker_id, timeout: lock_timeout)
|
|
177
|
+
raise LockNotAcquiredError, instance_id
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
begin
|
|
181
|
+
# Create context
|
|
182
|
+
ctx = WorkflowContext.new(
|
|
183
|
+
instance_id: instance_id,
|
|
184
|
+
workflow_name: workflow_class.workflow_name,
|
|
185
|
+
worker_id: worker_id,
|
|
186
|
+
storage: storage,
|
|
187
|
+
hooks: hooks,
|
|
188
|
+
history_cache: history_cache,
|
|
189
|
+
replaying: replaying
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Call hooks
|
|
193
|
+
hooks&.on_workflow_start&.call(instance_id, workflow_class.workflow_name, input)
|
|
194
|
+
|
|
195
|
+
# Create and execute workflow
|
|
196
|
+
workflow = workflow_class.allocate
|
|
197
|
+
workflow.instance_variable_set(:@pending_compensations, [])
|
|
198
|
+
workflow.context = ctx
|
|
199
|
+
|
|
200
|
+
# Symbolize input keys
|
|
201
|
+
symbolized_input = symbolize_keys(input)
|
|
202
|
+
result = workflow.execute(**symbolized_input)
|
|
203
|
+
|
|
204
|
+
# Mark completed
|
|
205
|
+
storage.update_instance_status(instance_id, Status::COMPLETED, output_data: result)
|
|
206
|
+
storage.clear_compensations(instance_id)
|
|
207
|
+
|
|
208
|
+
# Cleanup direct subscriptions
|
|
209
|
+
cleanup_subscriptions(ctx)
|
|
210
|
+
|
|
211
|
+
# Call hooks
|
|
212
|
+
hooks&.on_workflow_complete&.call(instance_id, workflow_class.workflow_name, result)
|
|
213
|
+
|
|
214
|
+
result
|
|
215
|
+
rescue WaitForTimerSignal => e
|
|
216
|
+
handle_timer_suspend(instance_id, e)
|
|
217
|
+
nil
|
|
218
|
+
rescue WaitForChannelSignal => e
|
|
219
|
+
handle_channel_suspend(instance_id, e)
|
|
220
|
+
nil
|
|
221
|
+
rescue RecurSignal => e
|
|
222
|
+
handle_recur(instance_id, workflow_class, e)
|
|
223
|
+
nil
|
|
224
|
+
rescue WorkflowCancelledError
|
|
225
|
+
storage.update_instance_status(instance_id, Status::CANCELLED)
|
|
226
|
+
hooks&.on_workflow_cancelled&.call(instance_id, workflow_class.workflow_name)
|
|
227
|
+
raise
|
|
228
|
+
rescue StandardError => e
|
|
229
|
+
handle_failure(instance_id, workflow_class, workflow, e)
|
|
230
|
+
raise
|
|
231
|
+
ensure
|
|
232
|
+
storage.release_lock(instance_id, worker_id)
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def handle_timer_suspend(instance_id, signal)
|
|
237
|
+
# Update status
|
|
238
|
+
storage.update_instance_status(
|
|
239
|
+
instance_id,
|
|
240
|
+
Status::WAITING_FOR_TIMER,
|
|
241
|
+
current_activity_id: signal.activity_id
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# Register timer
|
|
245
|
+
storage.register_timer(
|
|
246
|
+
instance_id: instance_id,
|
|
247
|
+
timer_id: signal.timer_id,
|
|
248
|
+
expires_at: signal.expires_at,
|
|
249
|
+
activity_id: signal.activity_id
|
|
250
|
+
)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def handle_channel_suspend(instance_id, signal)
|
|
254
|
+
# Update status
|
|
255
|
+
storage.update_instance_status(
|
|
256
|
+
instance_id,
|
|
257
|
+
Status::WAITING_FOR_MESSAGE,
|
|
258
|
+
current_activity_id: signal.activity_id
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# Update subscription with activity_id and timeout
|
|
262
|
+
storage.subscribe_to_channel(
|
|
263
|
+
instance_id: instance_id,
|
|
264
|
+
channel: signal.channel,
|
|
265
|
+
mode: signal.mode,
|
|
266
|
+
activity_id: signal.activity_id,
|
|
267
|
+
timeout_at: signal.timeout_at
|
|
268
|
+
)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def handle_recur(instance_id, workflow_class, signal)
|
|
272
|
+
# Archive history
|
|
273
|
+
storage.archive_history(instance_id)
|
|
274
|
+
|
|
275
|
+
# Update instance with new input and reset status
|
|
276
|
+
storage.update_instance_status(instance_id, Status::RECURRED)
|
|
277
|
+
|
|
278
|
+
# Create new instance with continued_from link
|
|
279
|
+
new_instance_id = SecureRandom.uuid
|
|
280
|
+
storage.create_instance(
|
|
281
|
+
instance_id: new_instance_id,
|
|
282
|
+
workflow_name: workflow_class.workflow_name,
|
|
283
|
+
source_hash: workflow_class.source_hash,
|
|
284
|
+
owner_service: 'default',
|
|
285
|
+
input_data: signal.new_input,
|
|
286
|
+
status: Status::RUNNING
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
# The new instance will be picked up by the worker
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def handle_failure(instance_id, workflow_class, workflow, error)
|
|
293
|
+
# Record failure in history
|
|
294
|
+
storage.append_history(
|
|
295
|
+
instance_id: instance_id,
|
|
296
|
+
activity_id: 'workflow_failed',
|
|
297
|
+
event_type: EventType::WORKFLOW_FAILED,
|
|
298
|
+
event_data: {
|
|
299
|
+
error_type: error.class.name,
|
|
300
|
+
error_message: error.message
|
|
301
|
+
}
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
# Update status to compensating
|
|
305
|
+
storage.update_instance_status(instance_id, Status::COMPENSATING)
|
|
306
|
+
|
|
307
|
+
# Execute compensations via workflow instance (LIFO order)
|
|
308
|
+
workflow.run_compensations
|
|
309
|
+
|
|
310
|
+
# Clear compensation records from database
|
|
311
|
+
storage.clear_compensations(instance_id)
|
|
312
|
+
|
|
313
|
+
# Update final status
|
|
314
|
+
storage.update_instance_status(instance_id, Status::FAILED)
|
|
315
|
+
|
|
316
|
+
# Call hooks
|
|
317
|
+
hooks&.on_workflow_failed&.call(instance_id, workflow_class.workflow_name, error)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def cleanup_subscriptions(ctx)
|
|
321
|
+
ctx.direct_subscriptions.each do |channel|
|
|
322
|
+
storage.unsubscribe_from_channel(
|
|
323
|
+
instance_id: ctx.instance_id,
|
|
324
|
+
channel: channel
|
|
325
|
+
)
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def build_history_cache(history)
|
|
330
|
+
cache = {}
|
|
331
|
+
history.each do |event|
|
|
332
|
+
event_type = event[:event_type]
|
|
333
|
+
data = event[:data]
|
|
334
|
+
|
|
335
|
+
cache[event[:activity_id]] = case event_type
|
|
336
|
+
when EventType::CHANNEL_MESSAGE_RECEIVED, EventType::MESSAGE_TIMEOUT,
|
|
337
|
+
EventType::TIMER_EXPIRED
|
|
338
|
+
# For channel messages and timer events, preserve the full data structure
|
|
339
|
+
{ event_type: event_type, data: data }
|
|
340
|
+
else
|
|
341
|
+
# For activity completed/failed
|
|
342
|
+
{
|
|
343
|
+
event_type: event_type,
|
|
344
|
+
result: data&.dig(:result),
|
|
345
|
+
error_type: data&.dig(:error_type),
|
|
346
|
+
error_message: data&.dig(:error_message)
|
|
347
|
+
}
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
cache
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def symbolize_keys(hash)
|
|
354
|
+
return hash unless hash.is_a?(Hash)
|
|
355
|
+
|
|
356
|
+
hash.transform_keys do |key|
|
|
357
|
+
key.is_a?(String) ? key.to_sym : key
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shikibu
|
|
4
|
+
# Configuration for retry behavior on activity failures
|
|
5
|
+
class RetryPolicy
|
|
6
|
+
attr_reader :max_attempts, :base_delay, :max_delay, :backoff_coefficient,
|
|
7
|
+
:max_duration, :retryable_errors, :non_retryable_errors
|
|
8
|
+
|
|
9
|
+
# @param max_attempts [Integer, nil] Maximum number of attempts (nil = infinite)
|
|
10
|
+
# @param base_delay [Float] Initial delay between retries in seconds
|
|
11
|
+
# @param max_delay [Float] Maximum delay between retries in seconds
|
|
12
|
+
# @param backoff_coefficient [Float] Multiplier for exponential backoff
|
|
13
|
+
# @param max_duration [Float, nil] Maximum total duration for all retries in seconds
|
|
14
|
+
# @param retryable_errors [Array<Class>] Error classes that should be retried
|
|
15
|
+
# @param non_retryable_errors [Array<Class>] Error classes that should not be retried
|
|
16
|
+
def initialize(
|
|
17
|
+
max_attempts: 5,
|
|
18
|
+
base_delay: 1.0,
|
|
19
|
+
max_delay: 60.0,
|
|
20
|
+
backoff_coefficient: 2.0,
|
|
21
|
+
max_duration: 300.0,
|
|
22
|
+
retryable_errors: [StandardError],
|
|
23
|
+
non_retryable_errors: []
|
|
24
|
+
)
|
|
25
|
+
@max_attempts = max_attempts
|
|
26
|
+
@base_delay = base_delay.to_f
|
|
27
|
+
@max_delay = max_delay.to_f
|
|
28
|
+
@backoff_coefficient = backoff_coefficient.to_f
|
|
29
|
+
@max_duration = max_duration&.to_f
|
|
30
|
+
@retryable_errors = Array(retryable_errors)
|
|
31
|
+
@non_retryable_errors = Array(non_retryable_errors)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Check if an error should be retried
|
|
35
|
+
# @param error [Exception] The error to check
|
|
36
|
+
# @return [Boolean]
|
|
37
|
+
def retryable?(error)
|
|
38
|
+
# TerminalError is never retried
|
|
39
|
+
return false if error.is_a?(TerminalError)
|
|
40
|
+
|
|
41
|
+
# Non-retryable errors take precedence
|
|
42
|
+
return false if @non_retryable_errors.any? { |klass| error.is_a?(klass) }
|
|
43
|
+
|
|
44
|
+
# Check if error matches retryable classes
|
|
45
|
+
@retryable_errors.any? { |klass| error.is_a?(klass) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Check if we should continue retrying
|
|
49
|
+
# @param attempt [Integer] Current attempt number (1-based)
|
|
50
|
+
# @param started_at [Time] When retries started
|
|
51
|
+
# @return [Boolean]
|
|
52
|
+
def should_retry?(attempt, started_at = nil)
|
|
53
|
+
return false if @max_attempts && attempt >= @max_attempts
|
|
54
|
+
|
|
55
|
+
if @max_duration && started_at
|
|
56
|
+
elapsed = Time.now - started_at
|
|
57
|
+
return false if elapsed >= @max_duration
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
true
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Calculate delay for a given attempt
|
|
64
|
+
# @param attempt [Integer] Current attempt number (1-based)
|
|
65
|
+
# @return [Float] Delay in seconds
|
|
66
|
+
def delay_for(attempt)
|
|
67
|
+
delay = @base_delay * (@backoff_coefficient**(attempt - 1))
|
|
68
|
+
[delay, @max_delay].min
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Default retry policy
|
|
72
|
+
def self.default
|
|
73
|
+
@default ||= new
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# No retry policy (single attempt)
|
|
77
|
+
def self.none
|
|
78
|
+
@none ||= new(max_attempts: 1)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|