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,883 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'sequel'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
|
|
7
|
+
module Shikibu
|
|
8
|
+
module Storage
|
|
9
|
+
# Sequel-based storage implementation
|
|
10
|
+
class SequelStorage
|
|
11
|
+
attr_reader :db
|
|
12
|
+
attr_accessor :notify_enabled
|
|
13
|
+
|
|
14
|
+
DEFAULT_LOCK_TIMEOUT = 300 # 5 minutes
|
|
15
|
+
|
|
16
|
+
def initialize(database_url, auto_migrate: false)
|
|
17
|
+
@database_url = database_url
|
|
18
|
+
@db = Sequel.connect(database_url)
|
|
19
|
+
@notify_enabled = false
|
|
20
|
+
|
|
21
|
+
# Enable extensions based on database type
|
|
22
|
+
configure_database
|
|
23
|
+
|
|
24
|
+
# Apply migrations if requested
|
|
25
|
+
Migrations.apply(@db) if auto_migrate
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def close
|
|
29
|
+
@db.disconnect
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# ============================================
|
|
33
|
+
# Transaction Management
|
|
34
|
+
# ============================================
|
|
35
|
+
|
|
36
|
+
def transaction(&)
|
|
37
|
+
@db.transaction(&)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def in_transaction?
|
|
41
|
+
@db.in_transaction?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# ============================================
|
|
45
|
+
# Workflow Definitions
|
|
46
|
+
# ============================================
|
|
47
|
+
|
|
48
|
+
def save_workflow_definition(workflow_name:, source_hash:, source_code:)
|
|
49
|
+
if mysql?
|
|
50
|
+
# MySQL: Use INSERT IGNORE + UPDATE
|
|
51
|
+
@db[:workflow_definitions].insert_ignore.insert(
|
|
52
|
+
workflow_name: workflow_name,
|
|
53
|
+
source_hash: source_hash,
|
|
54
|
+
source_code: source_code,
|
|
55
|
+
created_at: current_timestamp
|
|
56
|
+
)
|
|
57
|
+
else
|
|
58
|
+
# PostgreSQL/SQLite: Use ON CONFLICT
|
|
59
|
+
@db[:workflow_definitions].insert_conflict(
|
|
60
|
+
target: %i[workflow_name source_hash],
|
|
61
|
+
update: { source_code: source_code }
|
|
62
|
+
).insert(
|
|
63
|
+
workflow_name: workflow_name,
|
|
64
|
+
source_hash: source_hash,
|
|
65
|
+
source_code: source_code,
|
|
66
|
+
created_at: current_timestamp
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def get_workflow_definition(workflow_name:, source_hash:)
|
|
72
|
+
@db[:workflow_definitions]
|
|
73
|
+
.where(workflow_name: workflow_name, source_hash: source_hash)
|
|
74
|
+
.first
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# ============================================
|
|
78
|
+
# Workflow Instances
|
|
79
|
+
# ============================================
|
|
80
|
+
|
|
81
|
+
def create_instance(
|
|
82
|
+
instance_id:,
|
|
83
|
+
workflow_name:,
|
|
84
|
+
source_hash:,
|
|
85
|
+
owner_service:,
|
|
86
|
+
input_data:,
|
|
87
|
+
status: Status::RUNNING
|
|
88
|
+
)
|
|
89
|
+
@db[:workflow_instances].insert(
|
|
90
|
+
instance_id: instance_id,
|
|
91
|
+
workflow_name: workflow_name,
|
|
92
|
+
source_hash: source_hash,
|
|
93
|
+
owner_service: owner_service,
|
|
94
|
+
framework: FRAMEWORK,
|
|
95
|
+
status: status,
|
|
96
|
+
input_data: serialize_json(input_data),
|
|
97
|
+
started_at: current_timestamp,
|
|
98
|
+
updated_at: current_timestamp
|
|
99
|
+
)
|
|
100
|
+
instance_id
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def get_instance(instance_id)
|
|
104
|
+
row = @db[:workflow_instances].where(instance_id: instance_id).first
|
|
105
|
+
return nil unless row
|
|
106
|
+
|
|
107
|
+
deserialize_instance(row)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def update_instance_status(instance_id, status, output_data: nil, current_activity_id: nil)
|
|
111
|
+
updates = {
|
|
112
|
+
status: status,
|
|
113
|
+
updated_at: current_timestamp
|
|
114
|
+
}
|
|
115
|
+
updates[:output_data] = serialize_json(output_data) if output_data
|
|
116
|
+
updates[:current_activity_id] = current_activity_id if current_activity_id
|
|
117
|
+
|
|
118
|
+
@db[:workflow_instances]
|
|
119
|
+
.where(instance_id: instance_id)
|
|
120
|
+
.update(updates)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def list_instances(limit: 100, offset: 0, status_filter: nil, workflow_name: nil)
|
|
124
|
+
query = @db[:workflow_instances].order(Sequel.desc(:updated_at))
|
|
125
|
+
query = query.where(status: status_filter) if status_filter
|
|
126
|
+
query = query.where(workflow_name: workflow_name) if workflow_name
|
|
127
|
+
query = query.limit(limit).offset(offset)
|
|
128
|
+
|
|
129
|
+
query.map { |row| deserialize_instance(row) }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def find_resumable_workflows(limit: 10)
|
|
133
|
+
@db[:workflow_instances]
|
|
134
|
+
.where(status: Status::RUNNING)
|
|
135
|
+
.where(locked_by: nil)
|
|
136
|
+
.where(framework: FRAMEWORK)
|
|
137
|
+
.order(:updated_at)
|
|
138
|
+
.limit(limit)
|
|
139
|
+
.select(:instance_id, :workflow_name, :source_hash)
|
|
140
|
+
.map(&:to_h)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def find_stale_locked_workflows(stale_threshold_seconds: 300)
|
|
144
|
+
threshold = Time.now - stale_threshold_seconds
|
|
145
|
+
@db[:workflow_instances]
|
|
146
|
+
.where(status: Status::RUNNING)
|
|
147
|
+
.where { lock_expires_at < threshold }
|
|
148
|
+
.where(framework: FRAMEWORK)
|
|
149
|
+
.select(:instance_id, :workflow_name, :locked_by)
|
|
150
|
+
.map(&:to_h)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# ============================================
|
|
154
|
+
# Distributed Locking
|
|
155
|
+
# ============================================
|
|
156
|
+
|
|
157
|
+
def try_acquire_lock(instance_id, worker_id, timeout: DEFAULT_LOCK_TIMEOUT)
|
|
158
|
+
now = Time.now
|
|
159
|
+
expires_at = now + timeout
|
|
160
|
+
|
|
161
|
+
if supports_skip_locked?
|
|
162
|
+
# PostgreSQL/MySQL: Use SELECT FOR UPDATE SKIP LOCKED
|
|
163
|
+
# This prevents blocking when another worker holds the lock
|
|
164
|
+
@db.transaction do
|
|
165
|
+
row = @db[:workflow_instances]
|
|
166
|
+
.where(instance_id: instance_id)
|
|
167
|
+
.for_update
|
|
168
|
+
.skip_locked
|
|
169
|
+
.first
|
|
170
|
+
|
|
171
|
+
# Row is locked by another transaction, skip
|
|
172
|
+
return false unless row
|
|
173
|
+
|
|
174
|
+
# Check if lock is available (not locked or expired)
|
|
175
|
+
return false if row[:locked_by] && row[:lock_expires_at] && row[:lock_expires_at] > now
|
|
176
|
+
|
|
177
|
+
@db[:workflow_instances]
|
|
178
|
+
.where(instance_id: instance_id)
|
|
179
|
+
.update(
|
|
180
|
+
locked_by: worker_id,
|
|
181
|
+
locked_at: now,
|
|
182
|
+
lock_expires_at: expires_at,
|
|
183
|
+
lock_timeout_seconds: timeout
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
true
|
|
187
|
+
end
|
|
188
|
+
else
|
|
189
|
+
# SQLite: Use atomic UPDATE (table-level locking)
|
|
190
|
+
affected = @db[:workflow_instances]
|
|
191
|
+
.where(instance_id: instance_id)
|
|
192
|
+
.where(
|
|
193
|
+
Sequel.|(
|
|
194
|
+
{ locked_by: nil },
|
|
195
|
+
Sequel.lit('lock_expires_at < ?', now)
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
.update(
|
|
199
|
+
locked_by: worker_id,
|
|
200
|
+
locked_at: now,
|
|
201
|
+
lock_expires_at: expires_at,
|
|
202
|
+
lock_timeout_seconds: timeout
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
affected.positive?
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def release_lock(instance_id, worker_id)
|
|
210
|
+
@db[:workflow_instances]
|
|
211
|
+
.where(instance_id: instance_id, locked_by: worker_id)
|
|
212
|
+
.update(
|
|
213
|
+
locked_by: nil,
|
|
214
|
+
locked_at: nil,
|
|
215
|
+
lock_expires_at: nil,
|
|
216
|
+
lock_timeout_seconds: nil
|
|
217
|
+
)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def refresh_lock(instance_id, worker_id, timeout: DEFAULT_LOCK_TIMEOUT)
|
|
221
|
+
now = Time.now
|
|
222
|
+
expires_at = now + timeout
|
|
223
|
+
|
|
224
|
+
affected = @db[:workflow_instances]
|
|
225
|
+
.where(instance_id: instance_id, locked_by: worker_id)
|
|
226
|
+
.update(
|
|
227
|
+
locked_at: now,
|
|
228
|
+
lock_expires_at: expires_at
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
affected.positive?
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def lock_held_by?(instance_id, worker_id)
|
|
235
|
+
row = @db[:workflow_instances]
|
|
236
|
+
.where(instance_id: instance_id)
|
|
237
|
+
.select(:locked_by, :lock_expires_at)
|
|
238
|
+
.first
|
|
239
|
+
|
|
240
|
+
return false unless row
|
|
241
|
+
return false if row[:locked_by] != worker_id
|
|
242
|
+
return false if row[:lock_expires_at] && row[:lock_expires_at] < Time.now
|
|
243
|
+
|
|
244
|
+
true
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# ============================================
|
|
248
|
+
# Workflow History
|
|
249
|
+
# ============================================
|
|
250
|
+
|
|
251
|
+
def append_history(instance_id:, activity_id:, event_type:, event_data:, data_type: DataType::JSON)
|
|
252
|
+
if data_type == DataType::BINARY
|
|
253
|
+
@db[:workflow_history].insert(
|
|
254
|
+
instance_id: instance_id,
|
|
255
|
+
activity_id: activity_id,
|
|
256
|
+
event_type: event_type,
|
|
257
|
+
data_type: data_type,
|
|
258
|
+
event_data_binary: Sequel.blob(event_data),
|
|
259
|
+
created_at: current_timestamp
|
|
260
|
+
)
|
|
261
|
+
else
|
|
262
|
+
@db[:workflow_history].insert(
|
|
263
|
+
instance_id: instance_id,
|
|
264
|
+
activity_id: activity_id,
|
|
265
|
+
event_type: event_type,
|
|
266
|
+
data_type: data_type,
|
|
267
|
+
event_data: serialize_json(event_data),
|
|
268
|
+
created_at: current_timestamp
|
|
269
|
+
)
|
|
270
|
+
end
|
|
271
|
+
rescue Sequel::UniqueConstraintViolation
|
|
272
|
+
# Activity already recorded (idempotent)
|
|
273
|
+
nil
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def get_history(instance_id)
|
|
277
|
+
@db[:workflow_history]
|
|
278
|
+
.where(instance_id: instance_id)
|
|
279
|
+
.order(:id)
|
|
280
|
+
.map { |row| deserialize_history_event(row) }
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def archive_history(instance_id)
|
|
284
|
+
now = current_timestamp
|
|
285
|
+
|
|
286
|
+
# Copy to archive
|
|
287
|
+
@db[:workflow_history_archive].insert(
|
|
288
|
+
@db[:workflow_history]
|
|
289
|
+
.where(instance_id: instance_id)
|
|
290
|
+
.select(
|
|
291
|
+
:instance_id, :activity_id, :event_type, :data_type,
|
|
292
|
+
:event_data, :event_data_binary, :created_at,
|
|
293
|
+
Sequel.lit("'#{now}'").as(:archived_at)
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# Delete from main history
|
|
298
|
+
@db[:workflow_history].where(instance_id: instance_id).delete
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# ============================================
|
|
302
|
+
# Compensations
|
|
303
|
+
# ============================================
|
|
304
|
+
|
|
305
|
+
def push_compensation(instance_id:, activity_id:, activity_name:, args:)
|
|
306
|
+
@db[:workflow_compensations].insert(
|
|
307
|
+
instance_id: instance_id,
|
|
308
|
+
activity_id: activity_id,
|
|
309
|
+
activity_name: activity_name,
|
|
310
|
+
args: serialize_json(args),
|
|
311
|
+
created_at: current_timestamp
|
|
312
|
+
)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def get_compensations(instance_id)
|
|
316
|
+
@db[:workflow_compensations]
|
|
317
|
+
.where(instance_id: instance_id)
|
|
318
|
+
.order(Sequel.desc(:created_at))
|
|
319
|
+
.map do |row|
|
|
320
|
+
{
|
|
321
|
+
id: row[:id],
|
|
322
|
+
instance_id: row[:instance_id],
|
|
323
|
+
activity_id: row[:activity_id],
|
|
324
|
+
activity_name: row[:activity_name],
|
|
325
|
+
args: parse_json(row[:args]),
|
|
326
|
+
created_at: row[:created_at]
|
|
327
|
+
}
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def clear_compensations(instance_id)
|
|
332
|
+
@db[:workflow_compensations].where(instance_id: instance_id).delete
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# ============================================
|
|
336
|
+
# Timer Subscriptions
|
|
337
|
+
# ============================================
|
|
338
|
+
|
|
339
|
+
def register_timer(instance_id:, timer_id:, expires_at:, activity_id: nil)
|
|
340
|
+
if mysql?
|
|
341
|
+
# MySQL: Use INSERT ... ON DUPLICATE KEY UPDATE
|
|
342
|
+
@db[:workflow_timer_subscriptions].on_duplicate_key_update(
|
|
343
|
+
expires_at: expires_at,
|
|
344
|
+
activity_id: activity_id
|
|
345
|
+
).insert(
|
|
346
|
+
instance_id: instance_id,
|
|
347
|
+
timer_id: timer_id,
|
|
348
|
+
expires_at: expires_at,
|
|
349
|
+
activity_id: activity_id,
|
|
350
|
+
created_at: current_timestamp
|
|
351
|
+
)
|
|
352
|
+
else
|
|
353
|
+
@db[:workflow_timer_subscriptions].insert_conflict(
|
|
354
|
+
target: %i[instance_id timer_id],
|
|
355
|
+
update: { expires_at: expires_at, activity_id: activity_id }
|
|
356
|
+
).insert(
|
|
357
|
+
instance_id: instance_id,
|
|
358
|
+
timer_id: timer_id,
|
|
359
|
+
expires_at: expires_at,
|
|
360
|
+
activity_id: activity_id,
|
|
361
|
+
created_at: current_timestamp
|
|
362
|
+
)
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def find_expired_timers(limit: 100)
|
|
367
|
+
now = Time.now
|
|
368
|
+
@db[:workflow_timer_subscriptions]
|
|
369
|
+
.where { expires_at <= now }
|
|
370
|
+
.order(:expires_at)
|
|
371
|
+
.limit(limit)
|
|
372
|
+
.map(&:to_h)
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def remove_timer(instance_id:, timer_id:)
|
|
376
|
+
@db[:workflow_timer_subscriptions]
|
|
377
|
+
.where(instance_id: instance_id, timer_id: timer_id)
|
|
378
|
+
.delete
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# ============================================
|
|
382
|
+
# Channel Messages
|
|
383
|
+
# ============================================
|
|
384
|
+
|
|
385
|
+
def publish_message(channel:, data:, metadata: nil, data_type: DataType::JSON)
|
|
386
|
+
message_id = SecureRandom.uuid
|
|
387
|
+
|
|
388
|
+
if data_type == DataType::BINARY
|
|
389
|
+
@db[:channel_messages].insert(
|
|
390
|
+
channel: channel,
|
|
391
|
+
message_id: message_id,
|
|
392
|
+
data_type: data_type,
|
|
393
|
+
data_binary: Sequel.blob(data),
|
|
394
|
+
metadata: metadata ? serialize_json(metadata) : nil,
|
|
395
|
+
published_at: current_timestamp
|
|
396
|
+
)
|
|
397
|
+
else
|
|
398
|
+
@db[:channel_messages].insert(
|
|
399
|
+
channel: channel,
|
|
400
|
+
message_id: message_id,
|
|
401
|
+
data_type: data_type,
|
|
402
|
+
data: serialize_json(data),
|
|
403
|
+
metadata: metadata ? serialize_json(metadata) : nil,
|
|
404
|
+
published_at: current_timestamp
|
|
405
|
+
)
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Send NOTIFY for new message
|
|
409
|
+
send_notify(Notify::Channel::CHANNEL_MESSAGE, { ch: channel, msg_id: message_id })
|
|
410
|
+
|
|
411
|
+
message_id
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def subscribe_to_channel(instance_id:, channel:, mode:, activity_id: nil, timeout_at: nil)
|
|
415
|
+
if mysql?
|
|
416
|
+
@db[:channel_subscriptions].on_duplicate_key_update(
|
|
417
|
+
mode: mode,
|
|
418
|
+
activity_id: activity_id,
|
|
419
|
+
timeout_at: timeout_at
|
|
420
|
+
).insert(
|
|
421
|
+
instance_id: instance_id,
|
|
422
|
+
channel: channel,
|
|
423
|
+
mode: mode,
|
|
424
|
+
activity_id: activity_id,
|
|
425
|
+
timeout_at: timeout_at,
|
|
426
|
+
subscribed_at: current_timestamp
|
|
427
|
+
)
|
|
428
|
+
else
|
|
429
|
+
@db[:channel_subscriptions].insert_conflict(
|
|
430
|
+
target: %i[instance_id channel],
|
|
431
|
+
update: { mode: mode, activity_id: activity_id, timeout_at: timeout_at }
|
|
432
|
+
).insert(
|
|
433
|
+
instance_id: instance_id,
|
|
434
|
+
channel: channel,
|
|
435
|
+
mode: mode,
|
|
436
|
+
activity_id: activity_id,
|
|
437
|
+
timeout_at: timeout_at,
|
|
438
|
+
subscribed_at: current_timestamp
|
|
439
|
+
)
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def unsubscribe_from_channel(instance_id:, channel:)
|
|
444
|
+
@db[:channel_subscriptions]
|
|
445
|
+
.where(instance_id: instance_id, channel: channel)
|
|
446
|
+
.delete
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def get_subscription(instance_id:, channel:)
|
|
450
|
+
@db[:channel_subscriptions]
|
|
451
|
+
.where(instance_id: instance_id, channel: channel)
|
|
452
|
+
.first
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# Get the mode for a channel (from first subscription)
|
|
456
|
+
# @param channel [String] Channel name
|
|
457
|
+
# @return [String, nil] The mode or nil if no subscriptions exist
|
|
458
|
+
def get_channel_mode(channel)
|
|
459
|
+
row = @db[:channel_subscriptions]
|
|
460
|
+
.where(channel: channel)
|
|
461
|
+
.select(:mode)
|
|
462
|
+
.first
|
|
463
|
+
row&.dig(:mode)
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def find_waiting_subscriptions(channel:, limit: 100)
|
|
467
|
+
@db[:channel_subscriptions]
|
|
468
|
+
.where(channel: channel)
|
|
469
|
+
.where { Sequel.~(activity_id: nil) }
|
|
470
|
+
.limit(limit)
|
|
471
|
+
.map(&:to_h)
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def find_timed_out_subscriptions(limit: 100)
|
|
475
|
+
now = Time.now
|
|
476
|
+
@db[:channel_subscriptions]
|
|
477
|
+
.where { timeout_at <= now }
|
|
478
|
+
.where { Sequel.~(timeout_at: nil) }
|
|
479
|
+
.limit(limit)
|
|
480
|
+
.map(&:to_h)
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def claim_message(message_id:, instance_id:)
|
|
484
|
+
@db[:channel_message_claims].insert(
|
|
485
|
+
message_id: message_id,
|
|
486
|
+
instance_id: instance_id,
|
|
487
|
+
claimed_at: current_timestamp
|
|
488
|
+
)
|
|
489
|
+
true
|
|
490
|
+
rescue Sequel::UniqueConstraintViolation
|
|
491
|
+
false
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def get_next_message(channel:, mode:, instance_id:, cursor_id: nil)
|
|
495
|
+
case mode
|
|
496
|
+
when ChannelMode::COMPETING
|
|
497
|
+
get_next_competing_message(channel, instance_id)
|
|
498
|
+
when ChannelMode::BROADCAST
|
|
499
|
+
get_next_broadcast_message(channel, instance_id, cursor_id)
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# Deliver a channel message directly to a specific workflow instance
|
|
504
|
+
# Used for Point-to-Point delivery (e.g., shikibuinstanceid targeting)
|
|
505
|
+
# @param instance_id [String] Target workflow instance ID
|
|
506
|
+
# @param channel [String] Channel name (event type)
|
|
507
|
+
# @param message_id [String] Message ID
|
|
508
|
+
# @param data [Object] Message data
|
|
509
|
+
# @param metadata [Hash] Message metadata
|
|
510
|
+
# @param worker_id [String] Worker ID for locking
|
|
511
|
+
# @return [Hash, nil] Delivery result or nil if delivery failed
|
|
512
|
+
def deliver_channel_message(instance_id:, channel:, message_id:, data:, metadata:, worker_id:)
|
|
513
|
+
# Try to acquire lock on the workflow instance
|
|
514
|
+
return nil unless try_acquire_lock(instance_id, worker_id)
|
|
515
|
+
|
|
516
|
+
begin
|
|
517
|
+
result = @db.transaction do
|
|
518
|
+
# Find waiting subscription for this channel
|
|
519
|
+
sub = @db[:channel_subscriptions]
|
|
520
|
+
.where(instance_id: instance_id, channel: channel)
|
|
521
|
+
.where { Sequel.~(activity_id: nil) }
|
|
522
|
+
.first
|
|
523
|
+
|
|
524
|
+
# No waiting subscription found
|
|
525
|
+
next nil unless sub
|
|
526
|
+
|
|
527
|
+
activity_id = sub[:activity_id]
|
|
528
|
+
|
|
529
|
+
# Record message received in history (for deterministic replay)
|
|
530
|
+
append_history(
|
|
531
|
+
instance_id: instance_id,
|
|
532
|
+
activity_id: activity_id,
|
|
533
|
+
event_type: EventType::CHANNEL_MESSAGE_RECEIVED,
|
|
534
|
+
event_data: {
|
|
535
|
+
channel: channel,
|
|
536
|
+
message_id: message_id,
|
|
537
|
+
data: data,
|
|
538
|
+
metadata: metadata,
|
|
539
|
+
published_at: Time.now.iso8601
|
|
540
|
+
}
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
# Clear activity_id from subscription (no longer waiting)
|
|
544
|
+
@db[:channel_subscriptions]
|
|
545
|
+
.where(instance_id: instance_id, channel: channel)
|
|
546
|
+
.update(activity_id: nil, timeout_at: nil)
|
|
547
|
+
|
|
548
|
+
# Update workflow instance status to running
|
|
549
|
+
update_instance_status(instance_id, Status::RUNNING)
|
|
550
|
+
|
|
551
|
+
{ instance_id: instance_id, activity_id: activity_id }
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
# Send NOTIFY for workflow resumable (outside transaction for PostgreSQL)
|
|
555
|
+
send_notify(Notify::Channel::WORKFLOW_RESUMABLE, { wf_id: instance_id }) if result
|
|
556
|
+
|
|
557
|
+
result
|
|
558
|
+
ensure
|
|
559
|
+
# Always release the lock
|
|
560
|
+
release_lock(instance_id, worker_id)
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
# ============================================
|
|
565
|
+
# Outbox
|
|
566
|
+
# ============================================
|
|
567
|
+
|
|
568
|
+
def add_outbox_event(event_id:, event_type:, event_source:, event_data:, data_type: DataType::JSON)
|
|
569
|
+
if data_type == DataType::BINARY
|
|
570
|
+
@db[:outbox_events].insert(
|
|
571
|
+
event_id: event_id,
|
|
572
|
+
event_type: event_type,
|
|
573
|
+
event_source: event_source,
|
|
574
|
+
data_type: data_type,
|
|
575
|
+
event_data_binary: Sequel.blob(event_data),
|
|
576
|
+
status: OutboxStatus::PENDING,
|
|
577
|
+
created_at: current_timestamp
|
|
578
|
+
)
|
|
579
|
+
else
|
|
580
|
+
@db[:outbox_events].insert(
|
|
581
|
+
event_id: event_id,
|
|
582
|
+
event_type: event_type,
|
|
583
|
+
event_source: event_source,
|
|
584
|
+
data_type: data_type,
|
|
585
|
+
event_data: serialize_json(event_data),
|
|
586
|
+
status: OutboxStatus::PENDING,
|
|
587
|
+
created_at: current_timestamp
|
|
588
|
+
)
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
# Send NOTIFY for new outbox event
|
|
592
|
+
send_notify(Notify::Channel::OUTBOX_PENDING, { evt_id: event_id, evt_type: event_type })
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
def get_pending_outbox_events(limit: 100)
|
|
596
|
+
if supports_skip_locked?
|
|
597
|
+
# PostgreSQL/MySQL: Use SELECT FOR UPDATE SKIP LOCKED
|
|
598
|
+
# This allows multiple workers to fetch different events without blocking
|
|
599
|
+
@db.transaction do
|
|
600
|
+
rows = @db[:outbox_events]
|
|
601
|
+
.where(status: [OutboxStatus::PENDING, OutboxStatus::FAILED])
|
|
602
|
+
.order(:created_at)
|
|
603
|
+
.limit(limit)
|
|
604
|
+
.for_update
|
|
605
|
+
.skip_locked
|
|
606
|
+
.all
|
|
607
|
+
|
|
608
|
+
return [] if rows.empty?
|
|
609
|
+
|
|
610
|
+
# Mark as processing to prevent duplicate fetches
|
|
611
|
+
event_ids = rows.map { |r| r[:event_id] }
|
|
612
|
+
@db[:outbox_events]
|
|
613
|
+
.where(event_id: event_ids)
|
|
614
|
+
.update(status: OutboxStatus::PROCESSING)
|
|
615
|
+
|
|
616
|
+
rows.map { |row| deserialize_outbox_event(row) }
|
|
617
|
+
end
|
|
618
|
+
else
|
|
619
|
+
# SQLite: Simple query (table-level locking)
|
|
620
|
+
@db[:outbox_events]
|
|
621
|
+
.where(status: [OutboxStatus::PENDING, OutboxStatus::FAILED])
|
|
622
|
+
.order(:created_at)
|
|
623
|
+
.limit(limit)
|
|
624
|
+
.map { |row| deserialize_outbox_event(row) }
|
|
625
|
+
end
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
def mark_outbox_published(event_id)
|
|
629
|
+
@db[:outbox_events]
|
|
630
|
+
.where(event_id: event_id)
|
|
631
|
+
.update(
|
|
632
|
+
status: OutboxStatus::PUBLISHED,
|
|
633
|
+
published_at: current_timestamp
|
|
634
|
+
)
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
def mark_outbox_failed(event_id, error_message)
|
|
638
|
+
@db[:outbox_events]
|
|
639
|
+
.where(event_id: event_id)
|
|
640
|
+
.update(
|
|
641
|
+
status: OutboxStatus::FAILED,
|
|
642
|
+
last_error: error_message,
|
|
643
|
+
retry_count: Sequel[:retry_count] + 1
|
|
644
|
+
)
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
def mark_outbox_expired(event_id, error_message)
|
|
648
|
+
@db[:outbox_events]
|
|
649
|
+
.where(event_id: event_id)
|
|
650
|
+
.update(
|
|
651
|
+
status: OutboxStatus::EXPIRED,
|
|
652
|
+
last_error: error_message
|
|
653
|
+
)
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
def mark_outbox_invalid(event_id, error_message)
|
|
657
|
+
@db[:outbox_events]
|
|
658
|
+
.where(event_id: event_id)
|
|
659
|
+
.update(
|
|
660
|
+
status: OutboxStatus::INVALID,
|
|
661
|
+
last_error: error_message
|
|
662
|
+
)
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
# ============================================
|
|
666
|
+
# System Locks
|
|
667
|
+
# ============================================
|
|
668
|
+
|
|
669
|
+
def try_acquire_system_lock(lock_name, worker_id, timeout: 30)
|
|
670
|
+
now = Time.now
|
|
671
|
+
expires_at = now + timeout
|
|
672
|
+
|
|
673
|
+
# Try to insert new lock
|
|
674
|
+
begin
|
|
675
|
+
@db[:system_locks].insert(
|
|
676
|
+
lock_name: lock_name,
|
|
677
|
+
locked_by: worker_id,
|
|
678
|
+
locked_at: now,
|
|
679
|
+
lock_expires_at: expires_at
|
|
680
|
+
)
|
|
681
|
+
return true
|
|
682
|
+
rescue Sequel::UniqueConstraintViolation
|
|
683
|
+
# Lock exists, try to acquire if expired
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
# Try to take over expired lock
|
|
687
|
+
affected = @db[:system_locks]
|
|
688
|
+
.where(lock_name: lock_name)
|
|
689
|
+
.where { lock_expires_at < now }
|
|
690
|
+
.update(
|
|
691
|
+
locked_by: worker_id,
|
|
692
|
+
locked_at: now,
|
|
693
|
+
lock_expires_at: expires_at
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
affected.positive?
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
def release_system_lock(lock_name, worker_id)
|
|
700
|
+
@db[:system_locks]
|
|
701
|
+
.where(lock_name: lock_name, locked_by: worker_id)
|
|
702
|
+
.delete
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
def refresh_system_lock(lock_name, worker_id, timeout: 30)
|
|
706
|
+
now = Time.now
|
|
707
|
+
expires_at = now + timeout
|
|
708
|
+
|
|
709
|
+
affected = @db[:system_locks]
|
|
710
|
+
.where(lock_name: lock_name, locked_by: worker_id)
|
|
711
|
+
.update(
|
|
712
|
+
locked_at: now,
|
|
713
|
+
lock_expires_at: expires_at
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
affected.positive?
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
# ============================================
|
|
720
|
+
# Notifications (PostgreSQL LISTEN/NOTIFY)
|
|
721
|
+
# ============================================
|
|
722
|
+
|
|
723
|
+
# Send workflow resumable notification
|
|
724
|
+
# @param instance_id [String] Workflow instance ID
|
|
725
|
+
# @param workflow_name [String, nil] Optional workflow name
|
|
726
|
+
def notify_workflow_resumable(instance_id, workflow_name = nil)
|
|
727
|
+
send_notify(Notify::Channel::WORKFLOW_RESUMABLE, { wf_id: instance_id, wf_name: workflow_name })
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
# Cleanup old channel messages older than retention_days
|
|
731
|
+
# @param retention_days [Integer] Number of days to retain messages
|
|
732
|
+
# @return [Integer] Number of deleted messages
|
|
733
|
+
def cleanup_old_channel_messages(retention_days:)
|
|
734
|
+
cutoff_time = Time.now - (retention_days * 24 * 60 * 60)
|
|
735
|
+
@db[:channel_messages]
|
|
736
|
+
.where { published_at < cutoff_time }
|
|
737
|
+
.delete
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
private
|
|
741
|
+
|
|
742
|
+
def configure_database
|
|
743
|
+
case @db.database_type
|
|
744
|
+
when :postgres
|
|
745
|
+
@db.extension :pg_json
|
|
746
|
+
end
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
# Send PostgreSQL NOTIFY (PostgreSQL-only, no-op for other databases)
|
|
750
|
+
# @param channel [String] Notification channel name
|
|
751
|
+
# @param payload [Hash] Payload to send as JSON
|
|
752
|
+
def send_notify(channel, payload)
|
|
753
|
+
return unless @notify_enabled && @db.database_type == :postgres
|
|
754
|
+
|
|
755
|
+
payload_json = JSON.generate(payload, ascii_only: true)
|
|
756
|
+
@db.run(Sequel.lit('SELECT pg_notify(?, ?)', channel, payload_json))
|
|
757
|
+
rescue Sequel::DatabaseError => e
|
|
758
|
+
warn "[Shikibu::Storage] Failed to send NOTIFY on #{channel}: #{e.message}"
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
def current_timestamp
|
|
762
|
+
# Return Time object - Sequel handles DB-specific formatting
|
|
763
|
+
Time.now.utc
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
def mysql?
|
|
767
|
+
@db.database_type == :mysql
|
|
768
|
+
end
|
|
769
|
+
|
|
770
|
+
def supports_skip_locked?
|
|
771
|
+
%i[postgres mysql].include?(@db.database_type)
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
def serialize_json(data)
|
|
775
|
+
return nil if data.nil?
|
|
776
|
+
|
|
777
|
+
JSON.generate(data)
|
|
778
|
+
end
|
|
779
|
+
|
|
780
|
+
def parse_json(json_str)
|
|
781
|
+
return nil if json_str.nil?
|
|
782
|
+
|
|
783
|
+
JSON.parse(json_str, symbolize_names: true)
|
|
784
|
+
end
|
|
785
|
+
|
|
786
|
+
def deserialize_instance(row)
|
|
787
|
+
{
|
|
788
|
+
instance_id: row[:instance_id],
|
|
789
|
+
workflow_name: row[:workflow_name],
|
|
790
|
+
source_hash: row[:source_hash],
|
|
791
|
+
owner_service: row[:owner_service],
|
|
792
|
+
framework: row[:framework],
|
|
793
|
+
status: row[:status],
|
|
794
|
+
current_activity_id: row[:current_activity_id],
|
|
795
|
+
continued_from: row[:continued_from],
|
|
796
|
+
started_at: row[:started_at],
|
|
797
|
+
updated_at: row[:updated_at],
|
|
798
|
+
input_data: parse_json(row[:input_data]),
|
|
799
|
+
output_data: row[:output_data] ? parse_json(row[:output_data]) : nil,
|
|
800
|
+
locked_by: row[:locked_by],
|
|
801
|
+
locked_at: row[:locked_at],
|
|
802
|
+
lock_expires_at: row[:lock_expires_at]
|
|
803
|
+
}
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
def deserialize_history_event(row)
|
|
807
|
+
data = if row[:data_type] == DataType::BINARY
|
|
808
|
+
row[:event_data_binary]
|
|
809
|
+
else
|
|
810
|
+
parse_json(row[:event_data])
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
{
|
|
814
|
+
id: row[:id],
|
|
815
|
+
instance_id: row[:instance_id],
|
|
816
|
+
activity_id: row[:activity_id],
|
|
817
|
+
event_type: row[:event_type],
|
|
818
|
+
data_type: row[:data_type],
|
|
819
|
+
data: data,
|
|
820
|
+
created_at: row[:created_at]
|
|
821
|
+
}
|
|
822
|
+
end
|
|
823
|
+
|
|
824
|
+
def deserialize_outbox_event(row)
|
|
825
|
+
data = if row[:data_type] == DataType::BINARY
|
|
826
|
+
row[:event_data_binary]
|
|
827
|
+
else
|
|
828
|
+
parse_json(row[:event_data])
|
|
829
|
+
end
|
|
830
|
+
|
|
831
|
+
{
|
|
832
|
+
event_id: row[:event_id],
|
|
833
|
+
event_type: row[:event_type],
|
|
834
|
+
event_source: row[:event_source],
|
|
835
|
+
data_type: row[:data_type],
|
|
836
|
+
data: data,
|
|
837
|
+
status: row[:status],
|
|
838
|
+
retry_count: row[:retry_count],
|
|
839
|
+
created_at: row[:created_at]
|
|
840
|
+
}
|
|
841
|
+
end
|
|
842
|
+
|
|
843
|
+
def get_next_competing_message(channel, _instance_id)
|
|
844
|
+
# Find unclaimed message
|
|
845
|
+
row = @db[:channel_messages]
|
|
846
|
+
.left_join(:channel_message_claims, message_id: :message_id)
|
|
847
|
+
.where(Sequel[:channel_messages][:channel] => channel)
|
|
848
|
+
.where(Sequel[:channel_message_claims][:message_id] => nil)
|
|
849
|
+
.order(Sequel[:channel_messages][:published_at])
|
|
850
|
+
.select(Sequel[:channel_messages].*)
|
|
851
|
+
.first
|
|
852
|
+
deserialize_channel_message(row)
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
def get_next_broadcast_message(channel, _instance_id, cursor_id)
|
|
856
|
+
query = @db[:channel_messages].where(channel: channel)
|
|
857
|
+
query = query.where { id > cursor_id } if cursor_id
|
|
858
|
+
row = query.order(:id).first
|
|
859
|
+
deserialize_channel_message(row)
|
|
860
|
+
end
|
|
861
|
+
|
|
862
|
+
def deserialize_channel_message(row)
|
|
863
|
+
return nil unless row
|
|
864
|
+
|
|
865
|
+
data = if row[:data_type] == DataType::BINARY
|
|
866
|
+
row[:data_binary]
|
|
867
|
+
else
|
|
868
|
+
parse_json(row[:data])
|
|
869
|
+
end
|
|
870
|
+
metadata = parse_json(row[:metadata])
|
|
871
|
+
|
|
872
|
+
{
|
|
873
|
+
id: row[:id],
|
|
874
|
+
message_id: row[:message_id],
|
|
875
|
+
channel: row[:channel],
|
|
876
|
+
data: data,
|
|
877
|
+
metadata: metadata,
|
|
878
|
+
published_at: row[:published_at]
|
|
879
|
+
}
|
|
880
|
+
end
|
|
881
|
+
end
|
|
882
|
+
end
|
|
883
|
+
end
|