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
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
|
+
[](https://github.com/durax-io/shikibu/actions/workflows/ci.yml)
|
|
8
|
+
[](https://opensource.org/licenses/MIT)
|
|
9
|
+
[](https://www.ruby-lang.org/)
|
|
10
|
+
[](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
|