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.
@@ -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