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,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+ require 'securerandom'
5
+
6
+ module Shikibu
7
+ # Utilities for distributed locking
8
+ module Locking
9
+ class << self
10
+ # Generate a unique worker ID
11
+ # @param service_name [String] Service name prefix
12
+ # @return [String] Worker ID in format "service-pid-uuid"
13
+ def generate_worker_id(service_name = 'shikibu')
14
+ hostname = Socket.gethostname.gsub(/[^a-zA-Z0-9]/, '')[0, 8]
15
+ pid = Process.pid
16
+ uuid = SecureRandom.uuid[0, 8]
17
+ "#{service_name}-#{hostname}-#{pid}-#{uuid}"
18
+ end
19
+
20
+ # Acquire lock with retry and exponential backoff
21
+ # @param storage [Storage::SequelStorage] Storage instance
22
+ # @param instance_id [String] Workflow instance ID
23
+ # @param worker_id [String] Worker ID
24
+ # @param timeout [Integer] Lock timeout in seconds
25
+ # @param max_attempts [Integer] Maximum acquire attempts
26
+ # @param base_delay [Float] Initial delay between attempts
27
+ # @return [Boolean] Whether lock was acquired
28
+ def acquire_with_retry(storage, instance_id, worker_id, timeout: 300, max_attempts: 5, base_delay: 0.1)
29
+ attempts = 0
30
+
31
+ loop do
32
+ attempts += 1
33
+
34
+ return true if storage.try_acquire_lock(instance_id, worker_id, timeout: timeout)
35
+
36
+ return false if attempts >= max_attempts
37
+
38
+ # Exponential backoff with jitter
39
+ delay = base_delay * (2**(attempts - 1))
40
+ delay += rand * delay * 0.3 # Add up to 30% jitter
41
+ sleep(delay)
42
+ end
43
+ end
44
+
45
+ # Ensure we still hold a lock, raise if not
46
+ # @param storage [Storage::SequelStorage] Storage instance
47
+ # @param instance_id [String] Workflow instance ID
48
+ # @param worker_id [String] Worker ID
49
+ # @raise [LockNotAcquiredError] If lock is not held
50
+ def ensure_lock_held!(storage, instance_id, worker_id)
51
+ return if storage.lock_held_by?(instance_id, worker_id)
52
+
53
+ raise LockNotAcquiredError, instance_id
54
+ end
55
+
56
+ # Refresh a lock to extend its timeout
57
+ # @param storage [Storage::SequelStorage] Storage instance
58
+ # @param instance_id [String] Workflow instance ID
59
+ # @param worker_id [String] Worker ID
60
+ # @param timeout [Integer] New timeout in seconds
61
+ # @return [Boolean] Whether refresh was successful
62
+ def refresh_lock(storage, instance_id, worker_id, timeout: 300)
63
+ storage.refresh_lock(instance_id, worker_id, timeout: timeout)
64
+ end
65
+ end
66
+
67
+ # Lock guard for automatic release
68
+ class LockGuard
69
+ attr_reader :storage, :instance_id, :worker_id
70
+
71
+ def initialize(storage, instance_id, worker_id)
72
+ @storage = storage
73
+ @instance_id = instance_id
74
+ @worker_id = worker_id
75
+ @acquired = false
76
+ end
77
+
78
+ # Acquire the lock
79
+ # @param timeout [Integer] Lock timeout
80
+ # @return [Boolean]
81
+ def acquire(timeout: 300)
82
+ @acquired = storage.try_acquire_lock(instance_id, worker_id, timeout: timeout)
83
+ end
84
+
85
+ # Release the lock
86
+ def release
87
+ return unless @acquired
88
+
89
+ storage.release_lock(instance_id, worker_id)
90
+ @acquired = false
91
+ end
92
+
93
+ # Check if lock is held
94
+ def acquired?
95
+ @acquired
96
+ end
97
+
98
+ # Execute block with lock
99
+ def with_lock(timeout: 300)
100
+ return unless acquire(timeout: timeout)
101
+
102
+ begin
103
+ yield
104
+ ensure
105
+ release
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rack'
4
+ require 'json'
5
+
6
+ module Shikibu
7
+ module Middleware
8
+ # Rack application for handling CloudEvents
9
+ #
10
+ # @example config.ru
11
+ # require 'shikibu'
12
+ #
13
+ # Shikibu.configure do |config|
14
+ # config.database_url = ENV['DATABASE_URL']
15
+ # config.auto_migrate = true
16
+ # end
17
+ #
18
+ # Shikibu.app.register OrderSaga
19
+ # Shikibu.app.start
20
+ #
21
+ # run Shikibu::Middleware::RackApp.new
22
+ #
23
+ # @example Rails middleware
24
+ # # config/application.rb
25
+ # config.middleware.use Shikibu::Middleware::RackApp
26
+ #
27
+ class RackApp
28
+ CONTENT_TYPE_JSON = 'application/json'
29
+ CONTENT_TYPE_CLOUDEVENTS = 'application/cloudevents+json'
30
+
31
+ def initialize(app = nil)
32
+ @app = app
33
+ end
34
+
35
+ def call(env)
36
+ request = Rack::Request.new(env)
37
+ path = request.path_info
38
+
39
+ case path
40
+ when '/shikibu/events', '/events'
41
+ handle_event(request)
42
+ when '/shikibu/health', '/health'
43
+ health_check
44
+ when '/shikibu/health/live', '/health/live'
45
+ liveness_check
46
+ when '/shikibu/health/ready', '/health/ready'
47
+ readiness_check
48
+ when %r{^/shikibu/workflows/([^/]+)/status$}
49
+ handle_status(::Regexp.last_match(1))
50
+ when %r{^/shikibu/workflows/([^/]+)/result$}
51
+ handle_result(::Regexp.last_match(1))
52
+ when %r{^/shikibu/workflows/([^/]+)/cancel$}
53
+ handle_cancel(request, ::Regexp.last_match(1))
54
+ else
55
+ # Pass to next middleware if available
56
+ @app ? @app.call(env) : not_found
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def handle_event(request)
63
+ return method_not_allowed unless request.post?
64
+
65
+ body = request.body.read
66
+ event = parse_cloudevent(request, body)
67
+
68
+ return bad_request('Invalid CloudEvent') unless event
69
+
70
+ # Route event to workflow
71
+ event_type = event[:type]
72
+ target_instance_id = event[:shikibuinstanceid]
73
+
74
+ # Send event to waiting workflows (Point-to-Point or Broadcast)
75
+ Shikibu.app.send_event(
76
+ event_type,
77
+ event[:data],
78
+ metadata: event,
79
+ target_instance_id: target_instance_id
80
+ )
81
+
82
+ # Check if there's a workflow registered for this event type (only for broadcast)
83
+ unless target_instance_id
84
+ workflow_class = find_event_handler(event_type)
85
+ Shikibu.run(workflow_class, **(event[:data] || {})) if workflow_class
86
+ end
87
+
88
+ accepted
89
+ rescue JSON::ParserError
90
+ bad_request('Invalid JSON')
91
+ rescue StandardError => e
92
+ internal_error(e.message)
93
+ end
94
+
95
+ def handle_status(instance_id)
96
+ status = Shikibu.status(instance_id)
97
+ json_response(200, { instance_id: instance_id, status: status })
98
+ rescue WorkflowNotFoundError
99
+ not_found
100
+ end
101
+
102
+ def handle_result(instance_id)
103
+ result = Shikibu.result(instance_id)
104
+ json_response(200, { instance_id: instance_id, **result })
105
+ rescue WorkflowNotFoundError
106
+ not_found
107
+ end
108
+
109
+ def handle_cancel(request, instance_id)
110
+ return method_not_allowed unless request.post?
111
+
112
+ body = request.body.read
113
+ data = body.empty? ? {} : JSON.parse(body, symbolize_names: true)
114
+ reason = data[:reason]
115
+
116
+ success = Shikibu.app.cancel_workflow(instance_id, reason: reason)
117
+
118
+ if success
119
+ json_response(200, { instance_id: instance_id, status: 'cancelled' })
120
+ else
121
+ json_response(409, { error: 'Cannot cancel workflow in terminal state' })
122
+ end
123
+ rescue WorkflowNotFoundError
124
+ not_found
125
+ end
126
+
127
+ def parse_cloudevent(request, body)
128
+ content_type = request.content_type
129
+
130
+ if content_type&.include?(CONTENT_TYPE_CLOUDEVENTS)
131
+ # Structured format
132
+ JSON.parse(body, symbolize_names: true)
133
+ else
134
+ # Binary format - headers contain CloudEvents attributes
135
+ {
136
+ id: request.get_header('HTTP_CE_ID'),
137
+ source: request.get_header('HTTP_CE_SOURCE'),
138
+ type: request.get_header('HTTP_CE_TYPE'),
139
+ specversion: request.get_header('HTTP_CE_SPECVERSION') || '1.0',
140
+ shikibuinstanceid: request.get_header('HTTP_CE_SHIKIBUINSTANCEID'),
141
+ data: JSON.parse(body, symbolize_names: true)
142
+ }
143
+ end
144
+ end
145
+
146
+ def find_event_handler(event_type)
147
+ Shikibu.workflow_registry.values.find do |wf|
148
+ wf.event_handler && wf.workflow_name == event_type
149
+ end
150
+ end
151
+
152
+ def health_check
153
+ json_response(200, {
154
+ status: 'ok',
155
+ service: 'shikibu',
156
+ running: Shikibu.app&.running? || false
157
+ })
158
+ end
159
+
160
+ def liveness_check
161
+ json_response(200, { status: 'ok' })
162
+ end
163
+
164
+ def readiness_check
165
+ if Shikibu.app&.running?
166
+ json_response(200, { status: 'ready' })
167
+ else
168
+ json_response(503, { status: 'not_ready' })
169
+ end
170
+ end
171
+
172
+ def json_response(status, body)
173
+ [status, { 'Content-Type' => CONTENT_TYPE_JSON }, [JSON.generate(body)]]
174
+ end
175
+
176
+ def accepted
177
+ json_response(202, { status: 'accepted' })
178
+ end
179
+
180
+ def bad_request(message)
181
+ json_response(400, { error: message })
182
+ end
183
+
184
+ def not_found
185
+ json_response(404, { error: 'Not found' })
186
+ end
187
+
188
+ def method_not_allowed
189
+ json_response(405, { error: 'Method not allowed' })
190
+ end
191
+
192
+ def internal_error(message)
193
+ json_response(500, { error: message })
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shikibu
4
+ # PostgreSQL LISTEN/NOTIFY support for event-driven notifications.
5
+ module Notify
6
+ # Notification channel constants (shared with Python/Go frameworks)
7
+ module Channel
8
+ WORKFLOW_RESUMABLE = 'workflow_resumable'
9
+ CHANNEL_MESSAGE = 'workflow_channel_message'
10
+ OUTBOX_PENDING = 'workflow_outbox_pending'
11
+
12
+ ALL = [WORKFLOW_RESUMABLE, CHANNEL_MESSAGE, OUTBOX_PENDING].freeze
13
+ end
14
+
15
+ # Base class defining the notify listener interface
16
+ class NotifyListener
17
+ def start; end
18
+ def stop; end
19
+ def subscribe(_channel, &); end
20
+ def unsubscribe(_channel); end
21
+ def connected? = false
22
+ end
23
+
24
+ # No-op implementation for SQLite/MySQL
25
+ # All methods are no-ops - polling-based updates only
26
+ class NoopNotifyListener < NotifyListener
27
+ def initialize
28
+ super
29
+ @connected = false
30
+ end
31
+
32
+ def start
33
+ @connected = true
34
+ end
35
+
36
+ def stop
37
+ @connected = false
38
+ end
39
+
40
+ def subscribe(_channel, &)
41
+ # No-op: callbacks will never be called
42
+ end
43
+
44
+ def unsubscribe(_channel)
45
+ # No-op
46
+ end
47
+
48
+ def connected?
49
+ @connected
50
+ end
51
+ end
52
+
53
+ class << self
54
+ # Factory method to create appropriate listener based on database URL
55
+ # @param database_url [String] Database connection URL
56
+ # @return [NotifyListener] PostgresNotifyListener for PostgreSQL, NoopNotifyListener otherwise
57
+ def create_listener(database_url)
58
+ if database_url&.start_with?('postgres')
59
+ require_relative 'pg_notify'
60
+ PostgresNotifyListener.new(database_url)
61
+ else
62
+ NoopNotifyListener.new
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pg'
4
+ require 'json'
5
+ require 'concurrent'
6
+ require_relative 'notify_base'
7
+
8
+ module Shikibu
9
+ module Notify
10
+ # PostgreSQL LISTEN/NOTIFY listener using pg gem
11
+ # Uses a dedicated connection separate from the Sequel connection pool
12
+ #
13
+ # @example
14
+ # listener = PostgresNotifyListener.new('postgres://localhost/mydb')
15
+ # listener.subscribe('workflow_resumable') { |payload| puts payload }
16
+ # listener.start
17
+ # # ... later
18
+ # listener.stop
19
+ class PostgresNotifyListener < NotifyListener
20
+ DEFAULT_RECONNECT_INTERVAL = 5
21
+ MAX_RECONNECT_INTERVAL = 60
22
+
23
+ # @param database_url [String] PostgreSQL connection URL
24
+ # @param reconnect_interval [Numeric] Seconds between reconnection attempts
25
+ def initialize(database_url, reconnect_interval: DEFAULT_RECONNECT_INTERVAL)
26
+ super()
27
+ @database_url = database_url
28
+ @reconnect_interval = reconnect_interval
29
+ @connection = nil
30
+ @callbacks = {} # channel => [callbacks]
31
+ @running = false
32
+ @listen_thread = nil
33
+ @mutex = Mutex.new
34
+ @reconnect_count = 0
35
+ @executor = Concurrent::ThreadPoolExecutor.new(
36
+ min_threads: 1,
37
+ max_threads: 4,
38
+ max_queue: 100,
39
+ fallback_policy: :discard
40
+ )
41
+ end
42
+
43
+ # Start the notification listener
44
+ # Establishes connection and begins listening for notifications
45
+ def start
46
+ return if @running
47
+
48
+ @running = true
49
+ establish_connection
50
+ start_listen_thread
51
+ log_info('PostgresNotifyListener started')
52
+ end
53
+
54
+ # Stop the notification listener
55
+ # Closes connection and stops the listener thread
56
+ def stop
57
+ @running = false
58
+ @executor.shutdown
59
+ @executor.wait_for_termination(5)
60
+ @listen_thread&.join(5)
61
+ @listen_thread = nil
62
+ close_connection
63
+ @callbacks.clear
64
+ log_info('PostgresNotifyListener stopped')
65
+ end
66
+
67
+ # Subscribe to notifications on a channel
68
+ # @param channel [String] PostgreSQL channel name
69
+ # @yield [payload] Block called when notification received
70
+ # @yieldparam payload [String] Notification payload (JSON string)
71
+ def subscribe(channel, &callback)
72
+ @mutex.synchronize do
73
+ @callbacks[channel] ||= []
74
+ @callbacks[channel] << callback
75
+
76
+ listen_to_channel(channel) if connected?
77
+ end
78
+ end
79
+
80
+ # Unsubscribe from notifications on a channel
81
+ # @param channel [String] PostgreSQL channel name
82
+ def unsubscribe(channel)
83
+ @mutex.synchronize do
84
+ @callbacks.delete(channel)
85
+ unlisten_from_channel(channel) if connected?
86
+ end
87
+ end
88
+
89
+ # Check if listener is connected to PostgreSQL
90
+ # @return [Boolean]
91
+ def connected?
92
+ !@connection.nil? && !@connection.finished?
93
+ end
94
+
95
+ private
96
+
97
+ def establish_connection
98
+ @connection = PG.connect(@database_url)
99
+ @connection.setnonblocking(true)
100
+
101
+ # Subscribe to all registered channels
102
+ @callbacks.each_key { |channel| listen_to_channel(channel) }
103
+
104
+ @reconnect_count = 0
105
+ log_info('Connected to PostgreSQL')
106
+ rescue PG::Error => e
107
+ log_error("Failed to connect: #{e.message}")
108
+ @connection = nil
109
+ raise
110
+ end
111
+
112
+ def close_connection
113
+ return unless @connection
114
+
115
+ begin
116
+ @connection.close
117
+ rescue StandardError
118
+ nil
119
+ end
120
+ @connection = nil
121
+ end
122
+
123
+ def listen_to_channel(channel)
124
+ return unless connected?
125
+
126
+ # Use identifier quoting for safety
127
+ escaped_channel = @connection.escape_identifier(channel)
128
+ @connection.exec("LISTEN #{escaped_channel}")
129
+ log_debug("Listening on channel: #{channel}")
130
+ rescue PG::Error => e
131
+ log_error("Failed to LISTEN on #{channel}: #{e.message}")
132
+ end
133
+
134
+ def unlisten_from_channel(channel)
135
+ return unless connected?
136
+
137
+ escaped_channel = @connection.escape_identifier(channel)
138
+ @connection.exec("UNLISTEN #{escaped_channel}")
139
+ log_debug("Unlistened from channel: #{channel}")
140
+ rescue PG::Error => e
141
+ log_error("Failed to UNLISTEN from #{channel}: #{e.message}")
142
+ end
143
+
144
+ def start_listen_thread
145
+ @listen_thread = Thread.new do
146
+ Thread.current.name = 'shikibu-pg-notify'
147
+ reconnect_loop
148
+ end
149
+ end
150
+
151
+ def reconnect_loop
152
+ while @running
153
+ begin
154
+ establish_connection unless connected?
155
+ listen_for_notifications
156
+ rescue PG::Error => e
157
+ handle_connection_error(e)
158
+ rescue StandardError => e
159
+ log_error("Unexpected error: #{e.message}")
160
+ sleep(@reconnect_interval) if @running
161
+ end
162
+ end
163
+ end
164
+
165
+ def listen_for_notifications
166
+ while @running && connected?
167
+ # Wait for notifications with 1 second timeout
168
+ # This allows checking @running flag periodically
169
+ @connection.wait_for_notify(1) do |channel, _pid, payload|
170
+ dispatch_notification(channel, payload)
171
+ end
172
+ end
173
+ end
174
+
175
+ def dispatch_notification(channel, payload)
176
+ callbacks = @mutex.synchronize { @callbacks[channel]&.dup }
177
+ return unless callbacks
178
+
179
+ callbacks.each do |callback|
180
+ @executor.post do
181
+ callback.call(payload)
182
+ rescue StandardError => e
183
+ log_error("Error in notification callback for #{channel}: #{e.message}")
184
+ end
185
+ end
186
+ end
187
+
188
+ def handle_connection_error(error)
189
+ log_error("Connection error: #{error.message}, reconnecting...")
190
+ close_connection
191
+
192
+ @reconnect_count += 1
193
+ delay = calculate_reconnect_delay
194
+
195
+ sleep(delay) if @running
196
+ end
197
+
198
+ def calculate_reconnect_delay
199
+ # Exponential backoff: 5, 10, 20, 40, 60, 60, ...
200
+ exp = [@reconnect_count - 1, 5].min
201
+ [@reconnect_interval * (2**exp), MAX_RECONNECT_INTERVAL].min
202
+ end
203
+
204
+ def log_info(message)
205
+ warn "[Shikibu::PostgresNotifyListener] #{message}"
206
+ end
207
+
208
+ def log_debug(message)
209
+ # No-op in production
210
+ end
211
+
212
+ def log_error(message)
213
+ warn "[Shikibu::PostgresNotifyListener] ERROR: #{message}"
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shikibu
4
+ module Notify
5
+ # Thread-safe wake event for interrupting sleep with NOTIFY signals
6
+ # Uses ConditionVariable for efficient signaling between threads
7
+ class WakeEvent
8
+ def initialize
9
+ @mutex = Mutex.new
10
+ @condition = ConditionVariable.new
11
+ @signaled = false
12
+ end
13
+
14
+ # Signal the wake event (non-blocking)
15
+ # Wakes up any thread waiting on this event
16
+ def signal
17
+ @mutex.synchronize do
18
+ @signaled = true
19
+ @condition.signal
20
+ end
21
+ end
22
+
23
+ # Wait for signal with timeout
24
+ # @param timeout_seconds [Numeric] Maximum time to wait in seconds
25
+ # @return [Boolean] true if signaled, false if timeout
26
+ def wait(timeout_seconds)
27
+ @mutex.synchronize do
28
+ if @signaled
29
+ @signaled = false
30
+ return true
31
+ end
32
+
33
+ @condition.wait(@mutex, timeout_seconds)
34
+
35
+ if @signaled
36
+ @signaled = false
37
+ true
38
+ else
39
+ false
40
+ end
41
+ end
42
+ end
43
+
44
+ # Clear any pending signal without waiting
45
+ def clear
46
+ @mutex.synchronize { @signaled = false }
47
+ end
48
+
49
+ # Check if there is a pending signal
50
+ # @return [Boolean] true if signaled
51
+ def signaled?
52
+ @mutex.synchronize { @signaled }
53
+ end
54
+ end
55
+ end
56
+ end