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