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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6d241ad341f0963dd1fe64cb61dc2a25a95fd8d0f01d12aef81c0372075dbcba
4
+ data.tar.gz: d287f1ee1f8f58a6a4b6f44565e54ad848792ec3005931fe77a55a187c7c7a6c
5
+ SHA512:
6
+ metadata.gz: 39e4b6bbabb954eb6fd11ec715e91afff3b61ba2737c836332ed5161b6f5f9cbef2d30b72f3b08b96d76b96e05018e86127c88fffb8952a0aece2724e643a979
7
+ data.tar.gz: bc4cacdb7f83dc73a80468d0daa92af3bc23f69315b07836e12d195b48d46c4bbe5ed18374609cefe7fc8ba03cd3fde8fff9316b147646b85ff57bad99d637df
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Yasushi Itoh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,487 @@
1
+ # Shikibu
2
+
3
+ **Shikibu** (紫式部) - Named after Lady Murasaki Shikibu, author of The Tale of Genji
4
+
5
+ > Lightweight durable execution framework for Ruby - no separate server required
6
+
7
+ [![CI](https://github.com/durax-io/shikibu/actions/workflows/ci.yml/badge.svg)](https://github.com/durax-io/shikibu/actions/workflows/ci.yml)
8
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
9
+ [![Ruby 3.3+](https://img.shields.io/badge/ruby-3.3+-CC342D.svg)](https://www.ruby-lang.org/)
10
+ [![GitHub](https://img.shields.io/badge/github-shikibu-blue.svg)](https://github.com/durax-io/shikibu)
11
+
12
+ ## Overview
13
+
14
+ Shikibu is a lightweight durable execution framework for Ruby that runs as a **library** in your application - no separate workflow server required. It provides automatic crash recovery through deterministic replay, allowing **long-running workflows** to survive process restarts and failures without losing progress.
15
+
16
+ **Perfect for**: Order processing, distributed transactions (Saga pattern), and any workflow that must survive crashes.
17
+
18
+ Shikibu is a Ruby port of [Edda](https://github.com/i2y/edda) (Python), providing the same core concepts and patterns in idiomatic Ruby.
19
+
20
+ ## Key Features
21
+
22
+ - ✨ **Lightweight Library**: Runs in your application process - no separate server infrastructure
23
+ - 🔄 **Durable Execution**: Deterministic replay with workflow history for automatic crash recovery
24
+ - 🎯 **Workflow & Activity**: Clear separation between orchestration logic and business logic
25
+ - 🔁 **Saga Pattern**: Automatic compensation on failure with `on_failure` blocks
26
+ - 🌐 **Multi-worker Execution**: Run workflows safely across multiple servers or containers
27
+ - 📦 **Transactional Outbox**: Reliable event publishing with guaranteed delivery
28
+ - ☁️ **CloudEvents Support**: Native support for CloudEvents protocol via Rack middleware
29
+ - ⏱️ **Event & Timer Waiting**: Free up worker resources while waiting for events or timers
30
+ - 📬 **Channel-based Messaging**: Actor-model style communication with competing and broadcast modes
31
+ - 📡 **PostgreSQL LISTEN/NOTIFY**: Real-time event delivery without polling
32
+ - 🌍 **Rack Integration**: Works with Rails, Sinatra, Hanami, and any Rack-compatible framework
33
+ - 🔧 **Sidekiq/ActiveJob**: Background worker integration for Rails applications
34
+
35
+ ## Architecture
36
+
37
+ Shikibu runs as a lightweight library in your applications, with all workflow state stored in a shared database:
38
+
39
+ ```
40
+ ┌─────────────────────────────────────────────────────────────────────┐
41
+ │ Your Ruby Applications │
42
+ ├──────────────────────┬──────────────────────┬──────────────────────┤
43
+ │ order-service-1 │ order-service-2 │ order-service-3 │
44
+ │ ┌──────────────┐ │ ┌──────────────┐ │ ┌──────────────┐ │
45
+ │ │ Shikibu │ │ │ Shikibu │ │ │ Shikibu │ │
46
+ │ │ Workflow │ │ │ Workflow │ │ │ Workflow │ │
47
+ │ └──────────────┘ │ └──────────────┘ │ └──────────────┘ │
48
+ └──────────┬───────────┴──────────┬───────────┴──────────┬───────────┘
49
+ │ │ │
50
+ └──────────────────────┼──────────────────────┘
51
+
52
+ ┌────────▼────────┐
53
+ │ Shared Database │
54
+ │ (SQLite/PG/MySQL)│
55
+ └─────────────────┘
56
+ ```
57
+
58
+ **Key Points**:
59
+
60
+ - Multiple workers can run simultaneously across different pods/servers
61
+ - Each workflow instance runs on only one worker at a time (automatic coordination)
62
+ - `wait_event` and `sleep` free up worker resources while waiting
63
+ - Automatic crash recovery with stale lock cleanup and workflow auto-resume
64
+
65
+ ## Quick Start
66
+
67
+ ```ruby
68
+ require 'shikibu'
69
+
70
+ # Register compensation functions (global registry)
71
+ Shikibu.register_compensation(:refund_payment) do |ctx, order_id:|
72
+ PaymentService.refund(order_id)
73
+ end
74
+
75
+ class OrderSaga < Shikibu::Workflow
76
+ workflow_name 'order_saga'
77
+
78
+ def execute(order_id:, amount:)
79
+ # Activity results are recorded in history
80
+ result = activity :process_payment do
81
+ PaymentService.charge(order_id, amount)
82
+ end
83
+
84
+ # Compensation on failure (Saga pattern)
85
+ on_failure :refund_payment, order_id: order_id
86
+
87
+ { status: 'completed', order_id: order_id, payment: result }
88
+ end
89
+ end
90
+
91
+ # Configure Shikibu
92
+ Shikibu.configure do |config|
93
+ config.database_url = 'sqlite://workflow.db'
94
+ config.service_name = 'order-service'
95
+ end
96
+
97
+ # Start workflow
98
+ result = Shikibu.run(OrderSaga, order_id: 'ORD-123', amount: 99.99)
99
+ ```
100
+
101
+ **What happens on crash?**
102
+
103
+ 1. Activities already executed return cached results from history
104
+ 2. Workflow resumes from the last checkpoint
105
+ 3. No manual intervention required
106
+
107
+ ## Installation
108
+
109
+ Add to your Gemfile:
110
+
111
+ ```ruby
112
+ gem 'shikibu'
113
+ ```
114
+
115
+ Then run:
116
+
117
+ ```bash
118
+ bundle install
119
+ ```
120
+
121
+ Or install directly:
122
+
123
+ ```bash
124
+ gem install shikibu
125
+ ```
126
+
127
+ ### Database Support
128
+
129
+ | Database | Use Case | Multi-Pod Support | Production Ready |
130
+ |----------|----------|-------------------|------------------|
131
+ | **SQLite** | Development, testing, single-process | ⚠️ Limited | ⚠️ Limited |
132
+ | **PostgreSQL** | Production, multi-process/multi-pod | ✅ Yes | ✅ Recommended |
133
+ | **MySQL** | Production, multi-process/multi-pod | ✅ Yes | ✅ Yes (8.0+) |
134
+
135
+ **Important**: For multi-process or multi-pod deployments (K8s, Docker Compose with multiple replicas), use PostgreSQL or MySQL.
136
+
137
+ ### Database Drivers
138
+
139
+ ```ruby
140
+ # Gemfile
141
+
142
+ # SQLite (included by default)
143
+ gem 'sqlite3', '~> 2.0'
144
+
145
+ # PostgreSQL
146
+ gem 'pg', '~> 1.5'
147
+
148
+ # MySQL
149
+ gem 'mysql2', '~> 0.5'
150
+ ```
151
+
152
+ ### Database Migrations
153
+
154
+ Shikibu **automatically applies database migrations** on startup:
155
+
156
+ ```ruby
157
+ # Default: auto-migration enabled
158
+ storage = Shikibu::Storage::SequelStorage.new(database_url, auto_migrate: true)
159
+
160
+ # Or via App configuration
161
+ app = Shikibu::App.new(database_url: 'postgres://...', auto_migrate: true)
162
+ ```
163
+
164
+ **Features**:
165
+ - **Automatic**: Migrations run during initialization
166
+ - **dbmate-compatible**: Uses the same `schema_migrations` table as dbmate CLI
167
+ - **Multi-worker safe**: Safe for concurrent startup across multiple pods/processes
168
+
169
+ The database schema is managed in the [durax-io/schema](https://github.com/durax-io/schema) repository, shared between Shikibu (Ruby), [Edda](https://github.com/i2y/edda) (Python), and [Romancy](https://github.com/i2y/romancy) (Go).
170
+
171
+ ## Core Concepts
172
+
173
+ ### Workflows and Activities
174
+
175
+ **Activity**: A unit of work that performs business logic. Activity results are recorded in history.
176
+
177
+ **Workflow**: Orchestration logic that coordinates activities. Workflows can be replayed from history after crashes.
178
+
179
+ ```ruby
180
+ class UserOnboarding < Shikibu::Workflow
181
+ workflow_name 'user_onboarding'
182
+
183
+ def execute(email:)
184
+ # Activity - results are recorded
185
+ user = activity :create_user do
186
+ UserService.create(email: email)
187
+ end
188
+
189
+ # Another activity
190
+ activity :send_welcome_email do
191
+ EmailService.send_welcome(user[:id])
192
+ end
193
+
194
+ { status: 'completed', user_id: user[:id] }
195
+ end
196
+ end
197
+ ```
198
+
199
+ **Activity IDs**: Activities are automatically identified with IDs like `"create_user:1"` for deterministic replay.
200
+
201
+ ### Durable Execution
202
+
203
+ Shikibu ensures workflow progress is never lost through **deterministic replay**:
204
+
205
+ 1. **Activity results are recorded** in a history table
206
+ 2. **On crash recovery**, workflows resume from the last checkpoint
207
+ 3. **Already-executed activities** return cached results from history
208
+ 4. **New activities** continue from where the workflow left off
209
+
210
+ **Key guarantees**:
211
+ - Activities execute **exactly once** (results cached in history)
212
+ - Workflows can survive **arbitrary crashes**
213
+ - No manual checkpoint management required
214
+
215
+ ### Compensation (Saga Pattern)
216
+
217
+ When a workflow fails, Shikibu automatically executes compensation functions for **already-executed activities in reverse order**:
218
+
219
+ ```ruby
220
+ # Register compensation functions (supports crash recovery)
221
+ Shikibu.register_compensation(:cancel_reservation) do |ctx, order_id:|
222
+ InventoryService.cancel_reservation(order_id)
223
+ end
224
+
225
+ Shikibu.register_compensation(:refund_payment) do |ctx, order_id:|
226
+ PaymentService.refund(order_id)
227
+ end
228
+
229
+ class OrderSaga < Shikibu::Workflow
230
+ workflow_name 'order_saga'
231
+
232
+ def execute(order_id:, amount:)
233
+ # Step 1: Reserve inventory
234
+ activity :reserve_inventory do
235
+ InventoryService.reserve(order_id)
236
+ end
237
+ on_failure :cancel_reservation, order_id: order_id
238
+
239
+ # Step 2: Process payment
240
+ activity :process_payment do
241
+ PaymentService.charge(order_id, amount)
242
+ end
243
+ on_failure :refund_payment, order_id: order_id
244
+
245
+ # Step 3: If this fails, compensations run in reverse order:
246
+ # → refund payment → cancel reservation
247
+ activity :confirm_order do
248
+ OrderService.confirm(order_id)
249
+ end
250
+
251
+ { status: 'completed' }
252
+ end
253
+ end
254
+ ```
255
+
256
+ ### Event & Timer Waiting
257
+
258
+ Workflows can wait for external events or timers without consuming worker resources:
259
+
260
+ ```ruby
261
+ class PaymentWorkflow < Shikibu::Workflow
262
+ workflow_name 'payment_workflow'
263
+
264
+ def execute(order_id:)
265
+ # Wait for payment completion event
266
+ event = wait_event('payment.completed', timeout: 3600)
267
+
268
+ { order_id: order_id, payment: event[:data] }
269
+ end
270
+ end
271
+ ```
272
+
273
+ **Timer waiting with sleep**:
274
+
275
+ ```ruby
276
+ class ReminderWorkflow < Shikibu::Workflow
277
+ workflow_name 'reminder_workflow'
278
+
279
+ def execute(user_id:)
280
+ # Wait 3 days
281
+ sleep(3 * 24 * 60 * 60)
282
+
283
+ # Check if user completed onboarding
284
+ unless UserService.completed_onboarding?(user_id)
285
+ EmailService.send_reminder(user_id)
286
+ end
287
+ end
288
+ end
289
+ ```
290
+
291
+ **Key behavior**:
292
+ - `wait_event` and `sleep` release the workflow lock
293
+ - Workflow resumes on any available worker when event arrives or timer expires
294
+ - No worker is blocked while waiting
295
+
296
+ ### Channel-based Messaging
297
+
298
+ Shikibu provides channel-based messaging for workflow-to-workflow communication:
299
+
300
+ ```ruby
301
+ class JobWorker < Shikibu::Workflow
302
+ workflow_name 'job_worker'
303
+
304
+ def execute(worker_id:)
305
+ # Subscribe with competing mode - each job goes to ONE worker only
306
+ subscribe('jobs', mode: :competing)
307
+
308
+ loop do
309
+ job = receive('jobs')
310
+ process_job(job.data)
311
+ recur(worker_id: worker_id) # Continue processing
312
+ end
313
+ end
314
+ end
315
+
316
+ class NotificationHandler < Shikibu::Workflow
317
+ workflow_name 'notification_handler'
318
+
319
+ def execute(handler_id:)
320
+ # Subscribe with broadcast mode - ALL handlers receive each message
321
+ subscribe('notifications', mode: :broadcast)
322
+
323
+ loop do
324
+ msg = receive('notifications')
325
+ send_notification(msg.data)
326
+ recur(handler_id: handler_id)
327
+ end
328
+ end
329
+ end
330
+ ```
331
+
332
+ **Delivery modes**:
333
+ - **`competing`**: Each message goes to exactly ONE subscriber (job queue/task distribution)
334
+ - **`broadcast`**: Each message goes to ALL subscribers (notifications/fan-out)
335
+
336
+ **Publishing messages**:
337
+
338
+ ```ruby
339
+ # Publish to channel (all subscribers or one competing subscriber)
340
+ publish('jobs', { task: 'send_report', user_id: 123 })
341
+
342
+ # Direct message to specific workflow instance
343
+ send_to(target_instance_id, 'approval', { approved: true })
344
+ ```
345
+
346
+ ### PostgreSQL LISTEN/NOTIFY
347
+
348
+ When using PostgreSQL, Shikibu can use LISTEN/NOTIFY for real-time event delivery instead of polling:
349
+
350
+ ```ruby
351
+ # Automatically enabled for PostgreSQL URLs
352
+ app = Shikibu::App.new(database_url: 'postgres://localhost/workflows')
353
+
354
+ # Explicitly enable/disable
355
+ app = Shikibu::App.new(
356
+ database_url: 'postgres://localhost/workflows',
357
+ use_listen_notify: true # or false to disable
358
+ )
359
+ ```
360
+
361
+ **Benefits**:
362
+ - Near-instant workflow resumption (vs polling intervals)
363
+ - Reduced database load
364
+ - Works transparently with existing code
365
+
366
+ For SQLite/MySQL, Shikibu falls back to polling-based updates.
367
+
368
+ ## Rack Integration
369
+
370
+ Shikibu provides Rack middleware for CloudEvents endpoints:
371
+
372
+ ```ruby
373
+ # config.ru
374
+ require 'shikibu'
375
+
376
+ Shikibu.configure do |config|
377
+ config.database_url = ENV['DATABASE_URL']
378
+ config.service_name = 'order-service'
379
+ end
380
+
381
+ # Mount Shikibu middleware
382
+ use Shikibu::Middleware::RackApp
383
+
384
+ run MyApp
385
+ ```
386
+
387
+ ### Rails Integration
388
+
389
+ ```ruby
390
+ # config/initializers/shikibu.rb
391
+ Shikibu.configure do |config|
392
+ config.database_url = ENV['DATABASE_URL']
393
+ config.service_name = Rails.application.class.module_parent_name.underscore
394
+ end
395
+
396
+ # config/routes.rb
397
+ Rails.application.routes.draw do
398
+ mount Shikibu::Middleware::RackApp.new => '/workflows'
399
+ end
400
+ ```
401
+
402
+ ### Sidekiq Integration
403
+
404
+ ```ruby
405
+ # app/jobs/workflow_job.rb
406
+ class WorkflowJob
407
+ include Sidekiq::Job
408
+
409
+ def perform(workflow_class, input)
410
+ klass = workflow_class.constantize
411
+ Shikibu.run(klass, **input.symbolize_keys)
412
+ end
413
+ end
414
+
415
+ # Usage
416
+ WorkflowJob.perform_async('OrderSaga', { order_id: 'ORD-123', amount: 99.99 })
417
+ ```
418
+
419
+ ## Multi-worker Execution
420
+
421
+ Multiple workers can safely process workflows using database-based exclusive control:
422
+
423
+ ```ruby
424
+ app = Shikibu::App.new(
425
+ database_url: 'postgresql://localhost/workflows',
426
+ service_name: 'order-service',
427
+ worker_id: "worker-#{Process.pid}"
428
+ )
429
+ ```
430
+
431
+ **Features**:
432
+ - Each workflow instance runs on only one worker at a time
433
+ - Automatic stale lock cleanup (5-minute timeout)
434
+ - Crashed workflows automatically resume on any available worker
435
+
436
+ ## Cross-Framework Compatibility
437
+
438
+ Shikibu shares the same database schema with:
439
+
440
+ - **[Edda](https://github.com/i2y/edda)** (Python)
441
+ - **[Romancy](https://github.com/i2y/romancy)** (Go)
442
+
443
+ This means you can:
444
+ - Use multiple languages in the same system
445
+ - Migrate workflows between frameworks
446
+ - Share workflow state across services
447
+
448
+ Each framework identifies its workflows via the `framework` column (`ruby`, `python`, `go`).
449
+
450
+ ## Development
451
+
452
+ This project uses [just](https://github.com/casey/just) as a command runner.
453
+
454
+ ```bash
455
+ just # Show available commands
456
+ just install # Install dependencies
457
+ just test # Run unit tests
458
+ just test-file FILE # Run specific test file
459
+ just lint # Run RuboCop
460
+ just fix # Auto-fix lint issues
461
+ just check # Run lint + tests
462
+
463
+ # Integration tests (requires Docker)
464
+ just test-integration # Run all integration tests
465
+ just test-pg # PostgreSQL only
466
+ just test-mysql # MySQL only
467
+ ```
468
+
469
+ ## Requirements
470
+
471
+ - Ruby 3.3+
472
+ - SQLite3, PostgreSQL, or MySQL
473
+
474
+ ## License
475
+
476
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
477
+
478
+ ## Support
479
+
480
+ - GitHub Issues: https://github.com/durax-io/shikibu/issues
481
+ - Documentation: https://github.com/durax-io/shikibu#readme
482
+
483
+ ## Related Projects
484
+
485
+ - **[Edda](https://github.com/i2y/edda)** - Python version
486
+ - **[Romancy](https://github.com/i2y/romancy)** - Go version
487
+ - **[durax-io/schema](https://github.com/durax-io/schema)** - Shared database schema
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shikibu
4
+ # Standalone activity definition
5
+ # Use this for reusable activities that can be shared across workflows
6
+ #
7
+ # @example
8
+ # ProcessPayment = Shikibu::Activity.new(:process_payment) do |ctx, order_id:, amount:|
9
+ # PaymentService.charge(order_id, amount)
10
+ # end
11
+ #
12
+ # # In a workflow:
13
+ # result = ProcessPayment.call(ctx, order_id: '123', amount: 99.99)
14
+ #
15
+ class Activity
16
+ attr_reader :name, :retry_policy
17
+
18
+ # Create a new activity
19
+ # @param name [Symbol, String] Activity name
20
+ # @param retry_policy [RetryPolicy] Retry policy
21
+ # @param block [Proc] Activity logic (receives context and kwargs)
22
+ def initialize(name, retry_policy: nil, &block)
23
+ @name = name.to_s
24
+ @retry_policy = retry_policy || RetryPolicy.default
25
+ @block = block
26
+ end
27
+
28
+ # Execute the activity within a workflow context
29
+ # @param ctx [WorkflowContext] Workflow context
30
+ # @param kwargs [Hash] Activity arguments
31
+ # @return [Object] Activity result
32
+ def call(ctx, **kwargs)
33
+ activity_id = ctx.generate_activity_id(@name)
34
+ ctx.current_activity_id = activity_id
35
+
36
+ # Check for cached result during replay
37
+ if ctx.replaying? && ctx.cached_result?(activity_id)
38
+ cached = ctx.get_cached_result(activity_id)
39
+ return cached[:result] if cached[:event_type] == EventType::ACTIVITY_COMPLETED
40
+
41
+ # Re-raise cached error
42
+ raise reconstruct_error(cached)
43
+ end
44
+
45
+ # Execute with retry
46
+ execute_with_retry(ctx, activity_id, kwargs)
47
+ ensure
48
+ ctx.current_activity_id = nil
49
+ end
50
+
51
+ private
52
+
53
+ def execute_with_retry(ctx, activity_id, kwargs)
54
+ attempt = 0
55
+ started_at = Time.now
56
+ last_error = nil
57
+
58
+ loop do
59
+ attempt += 1
60
+
61
+ begin
62
+ # Call hooks
63
+ ctx.hooks&.on_activity_start&.call(ctx.instance_id, activity_id, @name, attempt)
64
+
65
+ result = @block.call(ctx, **kwargs)
66
+
67
+ # Record successful result
68
+ ctx.storage.append_history(
69
+ instance_id: ctx.instance_id,
70
+ activity_id: activity_id,
71
+ event_type: EventType::ACTIVITY_COMPLETED,
72
+ event_data: { result: result }
73
+ )
74
+
75
+ # Cache for replay
76
+ ctx.cache_result(activity_id, {
77
+ event_type: EventType::ACTIVITY_COMPLETED,
78
+ result: result
79
+ })
80
+
81
+ # Call hooks
82
+ ctx.hooks&.on_activity_complete&.call(ctx.instance_id, activity_id, @name, result, false)
83
+
84
+ return result
85
+ rescue StandardError => e
86
+ last_error = e
87
+
88
+ # Check if retryable
89
+ unless @retry_policy.retryable?(e) && @retry_policy.should_retry?(attempt, started_at)
90
+ # Record failure
91
+ ctx.storage.append_history(
92
+ instance_id: ctx.instance_id,
93
+ activity_id: activity_id,
94
+ event_type: EventType::ACTIVITY_FAILED,
95
+ event_data: {
96
+ error_type: e.class.name,
97
+ error_message: e.message,
98
+ attempts: attempt
99
+ }
100
+ )
101
+
102
+ # Cache for replay
103
+ ctx.cache_result(activity_id, {
104
+ event_type: EventType::ACTIVITY_FAILED,
105
+ error_type: e.class.name,
106
+ error_message: e.message
107
+ })
108
+
109
+ # Call hooks
110
+ ctx.hooks&.on_activity_failed&.call(ctx.instance_id, activity_id, @name, e, attempt)
111
+
112
+ raise
113
+ end
114
+
115
+ # Call retry hook
116
+ delay = @retry_policy.delay_for(attempt)
117
+ ctx.hooks&.on_activity_retry&.call(ctx.instance_id, activity_id, @name, e, attempt, delay)
118
+
119
+ # Wait before retry
120
+ Kernel.sleep(delay)
121
+ end
122
+ end
123
+ end
124
+
125
+ def reconstruct_error(cached)
126
+ error_class = begin
127
+ Object.const_get(cached[:error_type])
128
+ rescue StandardError
129
+ StandardError
130
+ end
131
+
132
+ error_class.new(cached[:error_message])
133
+ end
134
+ end
135
+ end