solid_objects 0.7.3 → 0.9.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 +4 -4
- data/CHANGELOG.md +47 -0
- data/docs/benchmarks.md +15 -0
- data/docs/local-testing.md +91 -0
- data/docs/realtime.md +43 -0
- data/docs/roadmap.md +35 -26
- data/lib/generators/solid_objects/templates/solid_objects.rb +8 -0
- data/lib/solid_objects/configuration.rb +10 -0
- data/lib/solid_objects/database_adapter.rb +37 -0
- data/lib/solid_objects/database_adapters/mysql.rb +28 -0
- data/lib/solid_objects/database_adapters/postgresql.rb +15 -0
- data/lib/solid_objects/database_adapters/sqlite.rb +11 -4
- data/lib/solid_objects/doctor.rb +16 -0
- data/lib/solid_objects/supervisor.rb +125 -6
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects/wake_up_adapters/postgresql.rb +136 -0
- data/lib/solid_objects/wake_up_adapters/redis.rb +183 -0
- data/lib/solid_objects/wake_up_adapters.rb +23 -0
- data/lib/solid_objects.rb +3 -0
- data/sig/generated/lib/solid_objects/configuration.rbs +10 -2
- data/sig/generated/lib/solid_objects/database_adapter.rbs +17 -0
- data/sig/generated/lib/solid_objects/database_adapters/mysql.rbs +11 -0
- data/sig/generated/lib/solid_objects/database_adapters/postgresql.rbs +8 -0
- data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +5 -0
- data/sig/generated/lib/solid_objects/doctor.rbs +3 -0
- data/sig/generated/lib/solid_objects/supervisor.rbs +45 -2
- data/sig/generated/lib/solid_objects/wake_up_adapters/postgresql.rbs +75 -0
- data/sig/generated/lib/solid_objects/wake_up_adapters/redis.rbs +96 -0
- data/sig/generated/lib/solid_objects/wake_up_adapters.rbs +17 -0
- metadata +9 -2
|
@@ -4,7 +4,10 @@ module SolidObjects
|
|
|
4
4
|
class Supervisor
|
|
5
5
|
# @rbs @components: Array[Worker | EffectExecutor | ReminderScheduler | BroadcastExecutor]
|
|
6
6
|
# @rbs @threads: Array[Thread]
|
|
7
|
+
# @rbs @monitor: Thread?
|
|
7
8
|
# @rbs @started: bool
|
|
9
|
+
# @rbs @cleaned_up_at: Float
|
|
10
|
+
# @rbs @lifecycle: Thread::Mutex
|
|
8
11
|
|
|
9
12
|
# @rbs (?worker_count: Integer, ?effect_worker_count: Integer, ?broadcast_worker_count: Integer, ?reminder_scheduler_count: Integer) -> void
|
|
10
13
|
def initialize(
|
|
@@ -20,7 +23,10 @@ module SolidObjects
|
|
|
20
23
|
reminder_scheduler_count:
|
|
21
24
|
)
|
|
22
25
|
@threads = []
|
|
26
|
+
@monitor = nil
|
|
23
27
|
@started = false
|
|
28
|
+
@cleaned_up_at = nil
|
|
29
|
+
@lifecycle = Thread::Mutex.new
|
|
24
30
|
end
|
|
25
31
|
|
|
26
32
|
# @rbs () -> void
|
|
@@ -36,7 +42,8 @@ module SolidObjects
|
|
|
36
42
|
return if @started
|
|
37
43
|
|
|
38
44
|
@started = true
|
|
39
|
-
@threads = components.map { |component|
|
|
45
|
+
@threads = components.map { |component| supervise(component) }
|
|
46
|
+
@monitor = Thread.new { monitor_loop }
|
|
40
47
|
SolidObjects.instrument(:"supervisor.started", component_count: components.length)
|
|
41
48
|
end
|
|
42
49
|
|
|
@@ -44,17 +51,129 @@ module SolidObjects
|
|
|
44
51
|
def stop
|
|
45
52
|
return unless @started
|
|
46
53
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
54
|
+
begin
|
|
55
|
+
# Flipping the flag under the same lock replacement takes means a
|
|
56
|
+
# replacement either completes before shutdown reads the component
|
|
57
|
+
# list, or never starts.
|
|
58
|
+
@lifecycle.synchronize { @started = false }
|
|
59
|
+
stop_monitor
|
|
60
|
+
components.each(&:request_shutdown)
|
|
61
|
+
join_until_timeout
|
|
62
|
+
components.reject(&:stopped?).each(&:stop)
|
|
63
|
+
ensure
|
|
64
|
+
# Connections held outside the pool must be released even when a
|
|
65
|
+
# component fails to stop, or they accumulate across restarts.
|
|
66
|
+
release_wake_up
|
|
67
|
+
@monitor = nil
|
|
68
|
+
SolidObjects.instrument(:"supervisor.stopped", component_count: components.length)
|
|
69
|
+
end
|
|
52
70
|
end
|
|
53
71
|
|
|
54
72
|
private
|
|
55
73
|
|
|
56
74
|
attr_reader :components, :threads
|
|
57
75
|
|
|
76
|
+
# A role that raises leaves its thread dead. Without replacement the
|
|
77
|
+
# process keeps running while quietly doing less work, so the supervisor
|
|
78
|
+
# watches its threads and restarts any that stopped before shutdown.
|
|
79
|
+
# A failing pass must not stop supervision, and must not retry without
|
|
80
|
+
# pacing either: a persistently failing database would otherwise spin.
|
|
81
|
+
# @rbs () -> void
|
|
82
|
+
def monitor_loop
|
|
83
|
+
while @started
|
|
84
|
+
begin
|
|
85
|
+
replace_dead_roles
|
|
86
|
+
cleanup_dead_processes
|
|
87
|
+
rescue => error
|
|
88
|
+
SolidObjects.instrument(
|
|
89
|
+
:"supervisor.monitor_failed",
|
|
90
|
+
error_class: error.class.name,
|
|
91
|
+
error_message: error.message
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
sleep SolidObjects.configuration.supervisor_monitor_interval
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# A role that raises runs its own shutdown cleanup on the way out, so a
|
|
99
|
+
# crashed component reports itself stopped exactly like one that was asked
|
|
100
|
+
# to stop. While the supervisor is still running, a dead thread can only
|
|
101
|
+
# mean a crash, so replacement keys on the supervisor rather than on the
|
|
102
|
+
# component. The crashed instance has already released its process record,
|
|
103
|
+
# so a fresh one takes its place.
|
|
104
|
+
# @rbs () -> void
|
|
105
|
+
def replace_dead_roles
|
|
106
|
+
components.each_with_index do |component, index|
|
|
107
|
+
thread = threads[index]
|
|
108
|
+
next if thread&.alive?
|
|
109
|
+
|
|
110
|
+
replaced = @lifecycle.synchronize do
|
|
111
|
+
next false unless @started
|
|
112
|
+
|
|
113
|
+
replacement = component.class.new
|
|
114
|
+
components[index] = replacement
|
|
115
|
+
threads[index] = supervise(replacement)
|
|
116
|
+
replacement
|
|
117
|
+
end
|
|
118
|
+
break unless replaced
|
|
119
|
+
|
|
120
|
+
SolidObjects.instrument(
|
|
121
|
+
:"supervisor.role_replaced",
|
|
122
|
+
role: replaced.class.name,
|
|
123
|
+
error_class: thread_error(thread)
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @rbs (Thread?) -> String?
|
|
129
|
+
def thread_error(thread)
|
|
130
|
+
thread&.join
|
|
131
|
+
nil
|
|
132
|
+
rescue => error
|
|
133
|
+
error.class.name
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# @rbs () -> void
|
|
137
|
+
def cleanup_dead_processes
|
|
138
|
+
interval = SolidObjects.configuration.dead_process_cleanup_interval
|
|
139
|
+
return unless interval.positive?
|
|
140
|
+
return if @cleaned_up_at && monotonic_now - @cleaned_up_at < interval
|
|
141
|
+
|
|
142
|
+
@cleaned_up_at = monotonic_now
|
|
143
|
+
ProcessRegistry.cleanup_dead
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# @rbs (untyped) -> Thread
|
|
147
|
+
def supervise(component)
|
|
148
|
+
Thread.new { component.run }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The monitor only performs maintenance, so shutdown must never return while
|
|
152
|
+
# it is still alive: a pass blocked on the database would otherwise outlive
|
|
153
|
+
# the supervisor that owns it.
|
|
154
|
+
# @rbs () -> void
|
|
155
|
+
def stop_monitor
|
|
156
|
+
monitor = @monitor
|
|
157
|
+
@monitor = nil
|
|
158
|
+
return unless monitor
|
|
159
|
+
|
|
160
|
+
monitor.join(SolidObjects.configuration.shutdown_timeout)
|
|
161
|
+
monitor.kill if monitor.alive?
|
|
162
|
+
monitor.join(SolidObjects.configuration.supervisor_monitor_interval)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# A wake-up adapter may hold connections outside the pool, which would
|
|
166
|
+
# otherwise accumulate across restarts in one process.
|
|
167
|
+
# @rbs () -> void
|
|
168
|
+
def release_wake_up
|
|
169
|
+
wake_up = SolidObjects.wake_up
|
|
170
|
+
return unless wake_up.respond_to?(:stop)
|
|
171
|
+
|
|
172
|
+
wake_up.stop
|
|
173
|
+
rescue
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
176
|
+
|
|
58
177
|
# @rbs (worker_count: Integer, effect_worker_count: Integer, broadcast_worker_count: Integer, reminder_scheduler_count: Integer) -> Array[Worker | EffectExecutor | ReminderScheduler | BroadcastExecutor]
|
|
59
178
|
def build_components(
|
|
60
179
|
worker_count:,
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
module WakeUpAdapters
|
|
5
|
+
# Wakes runtime roles across processes using PostgreSQL notifications.
|
|
6
|
+
#
|
|
7
|
+
# The in-process wake-up cannot reach another process, so a commit in a web
|
|
8
|
+
# process leaves a worker waiting out its polling interval. This adapter
|
|
9
|
+
# keeps that polling interval as the upper bound and delivers a notification
|
|
10
|
+
# when one is available, so a missed or failed notification costs latency
|
|
11
|
+
# rather than correctness.
|
|
12
|
+
class Postgresql
|
|
13
|
+
CHANNEL = "solid_objects_wake_up"
|
|
14
|
+
FAILED_WAIT_INTERVAL = 0.05
|
|
15
|
+
|
|
16
|
+
# @rbs @channel: String
|
|
17
|
+
# @rbs @mutex: Thread::Mutex
|
|
18
|
+
# @rbs @connections: Array[untyped]
|
|
19
|
+
|
|
20
|
+
attr_reader :channel
|
|
21
|
+
|
|
22
|
+
# @rbs (?channel: String) -> void
|
|
23
|
+
def initialize(channel: CHANNEL)
|
|
24
|
+
@channel = channel
|
|
25
|
+
@mutex = Thread::Mutex.new
|
|
26
|
+
@connections = []
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @rbs () -> bool
|
|
30
|
+
def signal
|
|
31
|
+
notify_channel
|
|
32
|
+
true
|
|
33
|
+
rescue => error
|
|
34
|
+
instrument_failure(:signal, error)
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @rbs (timeout: Numeric) -> bool
|
|
39
|
+
def wait(timeout:)
|
|
40
|
+
connection = listening_connection
|
|
41
|
+
!connection.raw_connection.wait_for_notify(timeout.to_f).nil?
|
|
42
|
+
rescue => error
|
|
43
|
+
instrument_failure(:wait, error)
|
|
44
|
+
pace_after_failure(timeout)
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Starts listening before a caller blocks, so a notification sent between
|
|
49
|
+
# startup and the first wait is not missed.
|
|
50
|
+
# @rbs () -> bool
|
|
51
|
+
def listen
|
|
52
|
+
listening_connection
|
|
53
|
+
true
|
|
54
|
+
rescue => error
|
|
55
|
+
instrument_failure(:listen, error)
|
|
56
|
+
false
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @rbs () -> bool
|
|
60
|
+
def stop
|
|
61
|
+
open = mutex.synchronize do
|
|
62
|
+
listening = connections.dup
|
|
63
|
+
connections.clear
|
|
64
|
+
listening
|
|
65
|
+
end
|
|
66
|
+
Thread.current[thread_key] = nil
|
|
67
|
+
open.each { |connection| disconnect(connection) }
|
|
68
|
+
open.any?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
attr_reader :mutex, :connections
|
|
74
|
+
|
|
75
|
+
# @rbs () -> void
|
|
76
|
+
def notify_channel
|
|
77
|
+
Record.connection_pool.with_connection do |connection|
|
|
78
|
+
connection.execute("NOTIFY #{connection.quote_table_name(channel)}")
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# A listening connection is dedicated and per thread. `LISTEN` is per
|
|
83
|
+
# connection, a blocking wait must not hold a connection the rest of the
|
|
84
|
+
# runtime needs, and one connection cannot serve concurrent waiters: the
|
|
85
|
+
# supervisor shares one adapter across roles, and a notification consumed
|
|
86
|
+
# by one waiter would leave the others asleep until their poll expired.
|
|
87
|
+
# @rbs () -> untyped
|
|
88
|
+
def listening_connection
|
|
89
|
+
connection = Thread.current[thread_key]
|
|
90
|
+
return connection if connection&.active?
|
|
91
|
+
|
|
92
|
+
open_listening_connection
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @rbs () -> untyped
|
|
96
|
+
def open_listening_connection
|
|
97
|
+
connection = Record.connection_pool.send(:new_connection)
|
|
98
|
+
connection.execute("LISTEN #{connection.quote_table_name(channel)}")
|
|
99
|
+
Thread.current[thread_key] = connection
|
|
100
|
+
mutex.synchronize { connections << connection }
|
|
101
|
+
connection
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# @rbs () -> Symbol
|
|
105
|
+
def thread_key
|
|
106
|
+
:"solid_objects_wake_up_#{object_id}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @rbs (untyped) -> void
|
|
110
|
+
def disconnect(connection)
|
|
111
|
+
connection.disconnect!
|
|
112
|
+
rescue
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# @rbs (Numeric) -> void
|
|
117
|
+
def pace_after_failure(timeout)
|
|
118
|
+
interval = [ timeout.to_f, FAILED_WAIT_INTERVAL ].min
|
|
119
|
+
return unless interval.positive?
|
|
120
|
+
|
|
121
|
+
sleep interval
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# @rbs (Symbol, Exception) -> void
|
|
125
|
+
def instrument_failure(operation, error)
|
|
126
|
+
SolidObjects.instrument(
|
|
127
|
+
:"wake_up.failed",
|
|
128
|
+
adapter: "postgresql",
|
|
129
|
+
operation: operation.to_s,
|
|
130
|
+
error_class: error.class.name,
|
|
131
|
+
error_message: error.message
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
module SolidObjects
|
|
6
|
+
module WakeUpAdapters
|
|
7
|
+
# Wakes runtime roles across processes using Redis publish/subscribe.
|
|
8
|
+
#
|
|
9
|
+
# MySQL has no notification primitive, so this is the cross-process option
|
|
10
|
+
# for applications that cannot use PostgreSQL notifications. It is optional
|
|
11
|
+
# in every sense: the `redis` gem is not a dependency of this gem, and the
|
|
12
|
+
# polling interval remains the upper bound, so a missed or failed
|
|
13
|
+
# notification costs latency rather than correctness.
|
|
14
|
+
class Redis
|
|
15
|
+
CHANNEL = "solid_objects_wake_up"
|
|
16
|
+
FAILED_WAIT_INTERVAL = 0.05
|
|
17
|
+
SUBSCRIBE_TIMEOUT = 5.0
|
|
18
|
+
|
|
19
|
+
# @rbs @channel: String
|
|
20
|
+
# @rbs @url: String?
|
|
21
|
+
# @rbs @client: untyped
|
|
22
|
+
# @rbs @mutex: Thread::Mutex
|
|
23
|
+
# @rbs @condition: Thread::ConditionVariable
|
|
24
|
+
# @rbs @subscriber: Thread?
|
|
25
|
+
# @rbs @subscription: untyped
|
|
26
|
+
# @rbs @signalled: Integer
|
|
27
|
+
|
|
28
|
+
attr_reader :channel
|
|
29
|
+
|
|
30
|
+
# @rbs (?channel: String, ?url: String?, ?client: untyped) -> void
|
|
31
|
+
def initialize(channel: CHANNEL, url: nil, client: nil)
|
|
32
|
+
@channel = channel
|
|
33
|
+
@url = url
|
|
34
|
+
@client = client
|
|
35
|
+
@mutex = Thread::Mutex.new
|
|
36
|
+
@condition = Thread::ConditionVariable.new
|
|
37
|
+
@subscriber = nil
|
|
38
|
+
@subscription = nil
|
|
39
|
+
@signalled = 0
|
|
40
|
+
validate_client!
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @rbs () -> bool
|
|
44
|
+
def signal
|
|
45
|
+
publisher.publish(channel, "1")
|
|
46
|
+
true
|
|
47
|
+
rescue => error
|
|
48
|
+
instrument_failure(:signal, error)
|
|
49
|
+
false
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The counter is snapshotted before subscribing, and re-checked before
|
|
53
|
+
# blocking, so a signal delivered while this caller was still getting
|
|
54
|
+
# ready is observed rather than absorbed into the new baseline.
|
|
55
|
+
# @rbs (timeout: Numeric) -> bool
|
|
56
|
+
def wait(timeout:)
|
|
57
|
+
signalled = mutex.synchronize { @signalled }
|
|
58
|
+
return paced_failure(timeout) unless listen
|
|
59
|
+
|
|
60
|
+
mutex.synchronize do
|
|
61
|
+
return true unless @signalled == signalled
|
|
62
|
+
|
|
63
|
+
condition.wait(mutex, timeout.to_f)
|
|
64
|
+
@signalled != signalled
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Redis delivers to a subscribed connection only, and a subscribed
|
|
69
|
+
# connection cannot serve other callers, so one background subscription
|
|
70
|
+
# per process fans out to every waiting role in memory. Subscribing
|
|
71
|
+
# eagerly also closes the window where a signal sent during startup would
|
|
72
|
+
# be missed.
|
|
73
|
+
# @rbs () -> bool
|
|
74
|
+
def listen
|
|
75
|
+
mutex.synchronize do
|
|
76
|
+
return true if @subscriber&.alive?
|
|
77
|
+
|
|
78
|
+
ready = Queue.new
|
|
79
|
+
@subscriber = Thread.new { subscribe_loop(ready) }
|
|
80
|
+
Timeout.timeout(SUBSCRIBE_TIMEOUT) { ready.pop } == :subscribed
|
|
81
|
+
end
|
|
82
|
+
rescue => error
|
|
83
|
+
instrument_failure(:listen, error)
|
|
84
|
+
false
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @rbs () -> bool
|
|
88
|
+
def stop
|
|
89
|
+
subscriber = mutex.synchronize do
|
|
90
|
+
thread = @subscriber
|
|
91
|
+
@subscriber = nil
|
|
92
|
+
thread
|
|
93
|
+
end
|
|
94
|
+
return false unless subscriber
|
|
95
|
+
|
|
96
|
+
disconnect(@subscription)
|
|
97
|
+
subscriber.join(SUBSCRIBE_TIMEOUT)
|
|
98
|
+
subscriber.kill if subscriber.alive?
|
|
99
|
+
true
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
attr_reader :mutex, :condition, :url
|
|
105
|
+
|
|
106
|
+
# @rbs (Queue) -> void
|
|
107
|
+
def subscribe_loop(ready)
|
|
108
|
+
connection = build_client
|
|
109
|
+
@subscription = connection
|
|
110
|
+
connection.subscribe(channel) do |on|
|
|
111
|
+
on.subscribe { ready << :subscribed }
|
|
112
|
+
on.message { broadcast }
|
|
113
|
+
end
|
|
114
|
+
rescue => error
|
|
115
|
+
instrument_failure(:subscribe, error)
|
|
116
|
+
ready << :failed
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @rbs () -> void
|
|
120
|
+
def broadcast
|
|
121
|
+
mutex.synchronize do
|
|
122
|
+
@signalled += 1
|
|
123
|
+
condition.broadcast
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @rbs (Numeric) -> bool
|
|
128
|
+
def paced_failure(timeout)
|
|
129
|
+
pace_after_failure(timeout)
|
|
130
|
+
false
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# @rbs () -> untyped
|
|
134
|
+
def publisher
|
|
135
|
+
@publisher ||= build_client
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# @rbs () -> untyped
|
|
139
|
+
def build_client
|
|
140
|
+
return @client.call if @client.respond_to?(:call)
|
|
141
|
+
|
|
142
|
+
require "redis"
|
|
143
|
+
url ? ::Redis.new(url:) : ::Redis.new
|
|
144
|
+
rescue LoadError
|
|
145
|
+
raise ArgumentError,
|
|
146
|
+
"the redis gem is required for SolidObjects::WakeUpAdapters::Redis"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# @rbs () -> void
|
|
150
|
+
def validate_client!
|
|
151
|
+
return if @client.nil? || @client.respond_to?(:call)
|
|
152
|
+
|
|
153
|
+
raise ArgumentError, "client must respond to call and return a Redis client"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# @rbs (untyped) -> void
|
|
157
|
+
def disconnect(connection)
|
|
158
|
+
connection&.close
|
|
159
|
+
rescue
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# @rbs (Numeric) -> void
|
|
164
|
+
def pace_after_failure(timeout)
|
|
165
|
+
interval = [ timeout.to_f, FAILED_WAIT_INTERVAL ].min
|
|
166
|
+
return unless interval.positive?
|
|
167
|
+
|
|
168
|
+
sleep interval
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# @rbs (Symbol, Exception) -> void
|
|
172
|
+
def instrument_failure(operation, error)
|
|
173
|
+
SolidObjects.instrument(
|
|
174
|
+
:"wake_up.failed",
|
|
175
|
+
adapter: "redis",
|
|
176
|
+
operation: operation.to_s,
|
|
177
|
+
error_class: error.class.name,
|
|
178
|
+
error_message: error.message
|
|
179
|
+
)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
module WakeUpAdapters
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
# Returns the best wake-up strategy for a connection: cross-process
|
|
8
|
+
# notifications where the database provides them, and the in-process
|
|
9
|
+
# default everywhere else.
|
|
10
|
+
#
|
|
11
|
+
# This is deliberately not the default. A notification adapter opens a
|
|
12
|
+
# connection per waiting thread outside the pool, and `LISTEN` does not
|
|
13
|
+
# survive a transaction-pooling proxy such as PgBouncer, so adopting it is
|
|
14
|
+
# a deployment decision rather than an upgrade side effect.
|
|
15
|
+
#
|
|
16
|
+
# @rbs (?untyped) -> untyped
|
|
17
|
+
def for(connection = Record.connection)
|
|
18
|
+
return Postgresql.new if connection.adapter_name.match?(/postgres/i)
|
|
19
|
+
|
|
20
|
+
WakeUp.new
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
data/lib/solid_objects.rb
CHANGED
|
@@ -46,6 +46,9 @@ require "solid_objects/actor_view"
|
|
|
46
46
|
require "solid_objects/actor_channel"
|
|
47
47
|
require "solid_objects/action_cable_broadcast_adapter"
|
|
48
48
|
require "solid_objects/wake_up"
|
|
49
|
+
require "solid_objects/wake_up_adapters/postgresql"
|
|
50
|
+
require "solid_objects/wake_up_adapters/redis"
|
|
51
|
+
require "solid_objects/wake_up_adapters"
|
|
49
52
|
require "solid_objects/effect_registry"
|
|
50
53
|
require "solid_objects/commit_action_registry"
|
|
51
54
|
require "solid_objects/lease"
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
module SolidObjects
|
|
4
4
|
class Configuration
|
|
5
|
-
@process_alive_threshold: Float
|
|
6
|
-
|
|
7
5
|
@shutdown_timeout: Float
|
|
8
6
|
|
|
7
|
+
@supervisor_monitor_interval: Float
|
|
8
|
+
|
|
9
|
+
@dead_process_cleanup_interval: Float
|
|
10
|
+
|
|
9
11
|
@message_retention: Numeric
|
|
10
12
|
|
|
11
13
|
@message_retention_by_actor_type: Hash[String, Numeric]
|
|
@@ -82,6 +84,8 @@ module SolidObjects
|
|
|
82
84
|
|
|
83
85
|
@process_heartbeat_interval: Float
|
|
84
86
|
|
|
87
|
+
@process_alive_threshold: Float
|
|
88
|
+
|
|
85
89
|
attr_accessor table_name_prefix: untyped
|
|
86
90
|
|
|
87
91
|
attr_accessor polling_interval: untyped
|
|
@@ -120,6 +124,10 @@ module SolidObjects
|
|
|
120
124
|
|
|
121
125
|
attr_accessor shutdown_timeout: untyped
|
|
122
126
|
|
|
127
|
+
attr_accessor supervisor_monitor_interval: untyped
|
|
128
|
+
|
|
129
|
+
attr_accessor dead_process_cleanup_interval: untyped
|
|
130
|
+
|
|
123
131
|
attr_accessor message_retention: untyped
|
|
124
132
|
|
|
125
133
|
attr_accessor message_retention_by_actor_type: untyped
|
|
@@ -16,6 +16,23 @@ module SolidObjects
|
|
|
16
16
|
# @rbs (untyped) -> void
|
|
17
17
|
def initialize: (untyped) -> void
|
|
18
18
|
|
|
19
|
+
# The oldest server the adapter has been exercised against. Reported rather
|
|
20
|
+
# than enforced: refusing to boot on an untested server would be a worse
|
|
21
|
+
# failure than running on one.
|
|
22
|
+
# @rbs () -> Gem::Version?
|
|
23
|
+
def minimum_server_version: () -> Gem::Version?
|
|
24
|
+
|
|
25
|
+
# @rbs () -> Gem::Version
|
|
26
|
+
def server_version: () -> Gem::Version
|
|
27
|
+
|
|
28
|
+
# One observed version decides both the status and the message. Reading it
|
|
29
|
+
# again could let a transient failure replace an already determined result.
|
|
30
|
+
# @rbs (?Gem::Version?) -> Array[String]
|
|
31
|
+
def unsupported_server_reasons: (?Gem::Version?) -> Array[String]
|
|
32
|
+
|
|
33
|
+
# @rbs () -> Array[String]
|
|
34
|
+
def additional_server_reasons: () -> Array[String]
|
|
35
|
+
|
|
19
36
|
# @rbs () -> bool
|
|
20
37
|
def supports_skip_locked?: () -> bool
|
|
21
38
|
|
|
@@ -9,6 +9,17 @@ module SolidObjects
|
|
|
9
9
|
# @rbs () -> String
|
|
10
10
|
def claim_lock: () -> String
|
|
11
11
|
|
|
12
|
+
# A non-transactional engine would silently break fenced commits, so the
|
|
13
|
+
# storage engine is verified rather than assumed.
|
|
14
|
+
# @rbs () -> Array[String]
|
|
15
|
+
def additional_server_reasons: () -> Array[String]
|
|
16
|
+
|
|
17
|
+
# @rbs () -> Array[String]
|
|
18
|
+
def non_innodb_tables: () -> Array[String]
|
|
19
|
+
|
|
20
|
+
# @rbs () -> Gem::Version?
|
|
21
|
+
def minimum_server_version: () -> Gem::Version?
|
|
22
|
+
|
|
12
23
|
# @rbs () -> String
|
|
13
24
|
def current_time_expression: () -> String
|
|
14
25
|
|
|
@@ -3,6 +3,14 @@
|
|
|
3
3
|
module SolidObjects
|
|
4
4
|
module DatabaseAdapters
|
|
5
5
|
class Postgresql < DatabaseAdapter
|
|
6
|
+
# @rbs () -> Gem::Version?
|
|
7
|
+
def minimum_server_version: () -> Gem::Version?
|
|
8
|
+
|
|
9
|
+
# PostgreSQL reports a packed integer, 170010 for 17.10, so comparing it
|
|
10
|
+
# directly would make every server look newer than any minimum.
|
|
11
|
+
# @rbs () -> Gem::Version
|
|
12
|
+
def server_version: () -> Gem::Version
|
|
13
|
+
|
|
6
14
|
# @rbs () -> bool
|
|
7
15
|
def supports_skip_locked?: () -> bool
|
|
8
16
|
|
|
@@ -11,6 +11,9 @@ module SolidObjects
|
|
|
11
11
|
|
|
12
12
|
LOCK_RETRY_CONDITION: untyped
|
|
13
13
|
|
|
14
|
+
# @rbs () -> Gem::Version?
|
|
15
|
+
def minimum_server_version: () -> Gem::Version?
|
|
16
|
+
|
|
14
17
|
# @rbs () -> String
|
|
15
18
|
def current_time_expression: () -> String
|
|
16
19
|
|
|
@@ -52,6 +55,8 @@ module SolidObjects
|
|
|
52
55
|
# @rbs (Integer) -> void
|
|
53
56
|
def wait_before_busy_retry: (Integer) -> void
|
|
54
57
|
|
|
58
|
+
# The deadline can expire between the check above and this wait, which
|
|
59
|
+
# would otherwise ask for a negative interval.
|
|
55
60
|
# @rbs () -> void
|
|
56
61
|
def wait_before_retry: () -> void
|
|
57
62
|
end
|