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,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