solid_objects 0.15.2 → 0.16.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 +151 -0
- data/README.md +1 -1
- data/app/models/solid_objects/administration_event.rb +7 -0
- data/app/models/solid_objects/redrive.rb +7 -0
- data/benchmark/support.rb +2 -8
- data/db/migrate/20260922000000_add_solid_objects_administration_events.rb +26 -0
- data/db/migrate/20260922000001_add_solid_objects_redrives.rb +32 -0
- data/db/migrate/20260923000000_add_solid_objects_completed_idempotency_keys.rb +10 -0
- data/docs/adr/0011-wake-up-strategy.md +11 -1
- data/docs/architecture.md +41 -5
- data/docs/dashboard.md +10 -0
- data/docs/fit.md +6 -0
- data/docs/operations.md +153 -7
- data/docs/realtime.md +32 -26
- data/docs/roadmap.md +60 -32
- data/examples/at_least_once/boot.rb +2 -8
- data/lib/solid_objects/activation.rb +13 -6
- data/lib/solid_objects/actor.rb +134 -8
- data/lib/solid_objects/actor_snapshot.rb +8 -5
- data/lib/solid_objects/administration_audit.rb +30 -0
- data/lib/solid_objects/client.rb +81 -0
- data/lib/solid_objects/configuration.rb +47 -2
- data/lib/solid_objects/context.rb +10 -4
- data/lib/solid_objects/dead_letter_manager.rb +42 -6
- data/lib/solid_objects/dead_letter_scope.rb +143 -0
- data/lib/solid_objects/dead_row.rb +15 -0
- data/lib/solid_objects/doctor.rb +24 -5
- data/lib/solid_objects/errors.rb +11 -0
- data/lib/solid_objects/executor.rb +41 -3
- data/lib/solid_objects/message_reference.rb +29 -9
- data/lib/solid_objects/outcome.rb +31 -0
- data/lib/solid_objects/process_registry.rb +3 -1
- data/lib/solid_objects/redrive_manager.rb +130 -0
- data/lib/solid_objects/redrive_runner.rb +60 -0
- data/lib/solid_objects/redrive_task.rb +14 -0
- data/lib/solid_objects/reference.rb +5 -0
- data/lib/solid_objects/schema_bootstrap.rb +27 -0
- data/lib/solid_objects/supervisor.rb +43 -0
- data/lib/solid_objects/test_helper.rb +2 -0
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects/wake_up.rb +12 -0
- data/lib/solid_objects/wake_up_adapters/postgresql.rb +12 -0
- data/lib/solid_objects/wake_up_adapters/redis.rb +12 -0
- data/lib/solid_objects/wake_up_adapters.rb +206 -11
- data/lib/solid_objects/wake_up_capability.rb +15 -0
- data/lib/solid_objects.rb +46 -3
- data/sig/generated/lib/solid_objects/activation.rbs +7 -0
- data/sig/generated/lib/solid_objects/actor.rbs +93 -2
- data/sig/generated/lib/solid_objects/administration_audit.rbs +11 -0
- data/sig/generated/lib/solid_objects/client.rbs +12 -0
- data/sig/generated/lib/solid_objects/configuration.rbs +54 -28
- data/sig/generated/lib/solid_objects/context.rbs +11 -6
- data/sig/generated/lib/solid_objects/dead_letter_manager.rbs +9 -0
- data/sig/generated/lib/solid_objects/dead_letter_scope.rbs +58 -0
- data/sig/generated/lib/solid_objects/dead_row.rbs +30 -0
- data/sig/generated/lib/solid_objects/doctor.rbs +3 -0
- data/sig/generated/lib/solid_objects/errors.rbs +8 -0
- data/sig/generated/lib/solid_objects/executor.rbs +9 -0
- data/sig/generated/lib/solid_objects/message_reference.rbs +8 -0
- data/sig/generated/lib/solid_objects/outcome.rbs +52 -0
- data/sig/generated/lib/solid_objects/redrive_manager.rbs +43 -0
- data/sig/generated/lib/solid_objects/redrive_runner.rbs +22 -0
- data/sig/generated/lib/solid_objects/redrive_task.rbs +33 -0
- data/sig/generated/lib/solid_objects/reference.rbs +5 -2
- data/sig/generated/lib/solid_objects/schema_bootstrap.rbs +11 -0
- data/sig/generated/lib/solid_objects/supervisor.rbs +15 -0
- data/sig/generated/lib/solid_objects/wake_up.rbs +5 -0
- data/sig/generated/lib/solid_objects/wake_up_adapters/postgresql.rbs +5 -0
- data/sig/generated/lib/solid_objects/wake_up_adapters/redis.rbs +5 -0
- data/sig/generated/lib/solid_objects/wake_up_adapters.rbs +75 -9
- data/sig/generated/lib/solid_objects/wake_up_capability.rbs +28 -0
- data/sig/generated/lib/solid_objects.rbs +14 -2
- data/sig/generated/models/solid_objects/administration_event.rbs +6 -0
- data/sig/generated/models/solid_objects/redrive.rbs +6 -0
- data/sig/public/reminder_payload.rbs +3 -0
- metadata +28 -2
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module SolidObjects
|
|
6
|
+
class RedriveManager
|
|
7
|
+
RUNNING = "running"
|
|
8
|
+
COMPLETED = "completed"
|
|
9
|
+
CANCELLED = "cancelled"
|
|
10
|
+
|
|
11
|
+
# @rbs (scope: DeadLetterScope, filters: Hash[String, untyped], authorization_context: untyped) -> RedriveTask
|
|
12
|
+
def start(scope:, filters:, authorization_context:)
|
|
13
|
+
scope.authorize!(:redrive, authorization_context:)
|
|
14
|
+
active_scope = "#{scope.kind}:#{Digest::SHA256.hexdigest(filters.to_json)}"
|
|
15
|
+
running = Redrive.find_by(active_scope:)
|
|
16
|
+
return task_for(running) if running
|
|
17
|
+
|
|
18
|
+
task_for(open_task(scope:, filters:, active_scope:, authorization_context:))
|
|
19
|
+
rescue ActiveRecord::RecordNotUnique
|
|
20
|
+
task_for(Redrive.find_by!(active_scope:))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @rbs (String, ?authorization_context: untyped) -> RedriveTask
|
|
24
|
+
def find(id, authorization_context: nil)
|
|
25
|
+
authorize!(:inspect, authorization_context:, resource_id: id)
|
|
26
|
+
task_for(Redrive.find(id))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @rbs (?status: Symbol | String | nil, ?authorization_context: untyped) -> Array[RedriveTask]
|
|
30
|
+
def all(status: nil, authorization_context: nil)
|
|
31
|
+
authorize!(:inspect, authorization_context:)
|
|
32
|
+
relation = Redrive.order(started_at: :desc, id: :desc)
|
|
33
|
+
relation = relation.where(status: status.to_s) if status
|
|
34
|
+
relation.map { |record| task_for(record) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @rbs (String, ?authorization_context: untyped) -> RedriveTask
|
|
38
|
+
def cancel(id, authorization_context: nil)
|
|
39
|
+
authorize!(:cancel, authorization_context:, resource_id: id)
|
|
40
|
+
record = Redrive.find(id)
|
|
41
|
+
return task_for(record) unless record.status == RUNNING
|
|
42
|
+
|
|
43
|
+
audit(record, action: "redrive.cancel") if close(record, status: CANCELLED)
|
|
44
|
+
task_for(record)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @rbs (Redrive, status: String) -> bool
|
|
48
|
+
def close(record, status:)
|
|
49
|
+
changed = Redrive.where(id: record.id, status: RUNNING).update_all(
|
|
50
|
+
status:,
|
|
51
|
+
active_scope: nil,
|
|
52
|
+
finished_at: SolidObjects.database_adapter.database_now,
|
|
53
|
+
updated_at: SolidObjects.database_adapter.database_now
|
|
54
|
+
)
|
|
55
|
+
record.reload if changed.positive?
|
|
56
|
+
changed.positive?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @rbs (Redrive, action: String) -> void
|
|
60
|
+
def audit(record, action:)
|
|
61
|
+
AdministrationAudit.record(
|
|
62
|
+
action:,
|
|
63
|
+
kind: record.kind,
|
|
64
|
+
subject_id: record.id,
|
|
65
|
+
filters: record.filters,
|
|
66
|
+
actor: record.actor
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @rbs (Redrive) -> RedriveTask
|
|
71
|
+
def task_for(record)
|
|
72
|
+
RedriveTask.new(
|
|
73
|
+
id: record.id,
|
|
74
|
+
kind: record.kind,
|
|
75
|
+
filters: Serialization.readonly_copy(record.filters),
|
|
76
|
+
status: record.status,
|
|
77
|
+
moved: record.moved,
|
|
78
|
+
remaining: remaining_for(record),
|
|
79
|
+
started_at: record.started_at,
|
|
80
|
+
finished_at: record.finished_at
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
# @rbs (scope: DeadLetterScope, filters: Hash[String, untyped], active_scope: String, authorization_context: untyped) -> Redrive
|
|
87
|
+
def open_task(scope:, filters:, active_scope:, authorization_context:)
|
|
88
|
+
record = Redrive.create!(
|
|
89
|
+
id: "redrive_#{SecureRandom.uuid}",
|
|
90
|
+
kind: scope.kind,
|
|
91
|
+
filters:,
|
|
92
|
+
status: RUNNING,
|
|
93
|
+
active_scope:,
|
|
94
|
+
moved: 0,
|
|
95
|
+
move_limit: filters["limit"],
|
|
96
|
+
actor: AdministrationAudit.identity(authorization_context),
|
|
97
|
+
started_at: SolidObjects.database_adapter.database_now
|
|
98
|
+
)
|
|
99
|
+
audit(record, action: "redrive.start")
|
|
100
|
+
record
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @rbs (Redrive) -> Integer
|
|
104
|
+
def remaining_for(record)
|
|
105
|
+
return 0 unless record.status == RUNNING
|
|
106
|
+
|
|
107
|
+
matching = DeadLetterScope
|
|
108
|
+
.for_kind(record.kind)
|
|
109
|
+
.matching(record.filters, dead_before: record.started_at)
|
|
110
|
+
.count
|
|
111
|
+
limit = record.move_limit
|
|
112
|
+
return matching unless limit
|
|
113
|
+
|
|
114
|
+
[ matching, limit - record.moved ].min
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# @rbs (Symbol, authorization_context: untyped, ?resource_id: String?) -> void
|
|
118
|
+
def authorize!(action, authorization_context:, resource_id: nil)
|
|
119
|
+
authorized = SolidObjects.configuration.authorize_administration.call(
|
|
120
|
+
action: action.to_s,
|
|
121
|
+
resource: "redrives",
|
|
122
|
+
resource_id:,
|
|
123
|
+
authorization_context:
|
|
124
|
+
)
|
|
125
|
+
return if authorized
|
|
126
|
+
|
|
127
|
+
raise Unauthorized, "actor administration is not authorized"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class RedriveRunner
|
|
5
|
+
# @rbs () -> bool
|
|
6
|
+
def run_once
|
|
7
|
+
SolidObjects.database_adapter.transaction do
|
|
8
|
+
record = claim
|
|
9
|
+
next false unless record
|
|
10
|
+
|
|
11
|
+
advance(record)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
# @rbs () -> Redrive?
|
|
18
|
+
def claim
|
|
19
|
+
relation = Redrive.where(status: RedriveManager::RUNNING).order(:started_at, :id)
|
|
20
|
+
SolidObjects.database_adapter.lock_candidates(relation).first
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @rbs (Redrive) -> bool
|
|
24
|
+
def advance(record)
|
|
25
|
+
moved = move_batch(record)
|
|
26
|
+
return true if moved.positive?
|
|
27
|
+
|
|
28
|
+
finish(record)
|
|
29
|
+
false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @rbs (Redrive) -> Integer
|
|
33
|
+
def move_batch(record)
|
|
34
|
+
scope = DeadLetterScope.for_kind(record.kind)
|
|
35
|
+
configured = SolidObjects.configuration.redrive_batch_size
|
|
36
|
+
limit = record.move_limit
|
|
37
|
+
size = limit ? [ configured, limit - record.moved ].min : configured
|
|
38
|
+
return 0 unless size.positive?
|
|
39
|
+
|
|
40
|
+
identifiers = scope
|
|
41
|
+
.matching(record.filters, dead_before: record.started_at)
|
|
42
|
+
.order(:id)
|
|
43
|
+
.limit(size)
|
|
44
|
+
.pluck(:id)
|
|
45
|
+
return 0 if identifiers.empty?
|
|
46
|
+
|
|
47
|
+
revived = scope.revive_all(identifiers)
|
|
48
|
+
record.update!(moved: record.moved + revived)
|
|
49
|
+
revived
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @rbs (Redrive) -> void
|
|
53
|
+
def finish(record)
|
|
54
|
+
manager = SolidObjects.redrives
|
|
55
|
+
return unless manager.close(record, status: RedriveManager::COMPLETED)
|
|
56
|
+
|
|
57
|
+
manager.audit(record, action: "redrive.finish")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
RedriveTask = Data.define(
|
|
5
|
+
:id, :kind, :filters, :status, :moved, :remaining, :started_at, :finished_at
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
class RedriveTask
|
|
9
|
+
# @rbs (?authorization_context: untyped) -> RedriveTask
|
|
10
|
+
def cancel(authorization_context: nil)
|
|
11
|
+
SolidObjects.redrives.cancel(id, authorization_context:)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -67,6 +67,11 @@ module SolidObjects
|
|
|
67
67
|
SolidObjects.client.destroy(self, authorization_context:)
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
+
# @rbs (idempotency_key: String, ?authorization_context: untyped) -> MessageReference?
|
|
71
|
+
def find_by(idempotency_key:, authorization_context: nil)
|
|
72
|
+
SolidObjects.client.find_by(reference: self, idempotency_key:, authorization_context:)
|
|
73
|
+
end
|
|
74
|
+
|
|
70
75
|
# @rbs (?authorization_context: untyped) -> StateSnapshot
|
|
71
76
|
def snapshot(authorization_context: nil)
|
|
72
77
|
SolidObjects.client.snapshot(self, authorization_context:)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
require "active_support/core_ext/string/inflections"
|
|
5
|
+
|
|
6
|
+
module SolidObjects
|
|
7
|
+
module SchemaBootstrap
|
|
8
|
+
class << self
|
|
9
|
+
# @rbs (?connection: untyped) -> void
|
|
10
|
+
def install(connection: nil)
|
|
11
|
+
migrations.each do |migration_class|
|
|
12
|
+
migration = migration_class.new
|
|
13
|
+
migration.define_singleton_method(:connection) { connection } if connection
|
|
14
|
+
migration.migrate(:up)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @rbs () -> Array[Class]
|
|
19
|
+
def migrations
|
|
20
|
+
Dir[File.expand_path("../../db/migrate/*.rb", __dir__)].sort.map do |file|
|
|
21
|
+
require file
|
|
22
|
+
Object.const_get(File.basename(file, ".rb").sub(/\A\d+_/, "").camelize)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -31,6 +31,7 @@ module SolidObjects
|
|
|
31
31
|
@started = false
|
|
32
32
|
@cleaned_up_at = nil
|
|
33
33
|
@retention = nil
|
|
34
|
+
@redrive = nil
|
|
34
35
|
@lifecycle = Thread::Mutex.new
|
|
35
36
|
end
|
|
36
37
|
|
|
@@ -50,6 +51,7 @@ module SolidObjects
|
|
|
50
51
|
@threads = components.map { |component| supervise(component) }
|
|
51
52
|
@monitor = Thread.new { monitor_loop }
|
|
52
53
|
@retention = Thread.new { retention_loop }
|
|
54
|
+
@redrive = Thread.new { redrive_loop }
|
|
53
55
|
SolidObjects.instrument(:"supervisor.started", component_count: components.length)
|
|
54
56
|
end
|
|
55
57
|
|
|
@@ -64,6 +66,7 @@ module SolidObjects
|
|
|
64
66
|
@lifecycle.synchronize { @started = false }
|
|
65
67
|
stop_monitor
|
|
66
68
|
stop_retention
|
|
69
|
+
stop_redrive
|
|
67
70
|
components.each(&:request_shutdown)
|
|
68
71
|
join_until_timeout
|
|
69
72
|
components.reject(&:stopped?).each(&:stop)
|
|
@@ -204,6 +207,46 @@ module SolidObjects
|
|
|
204
207
|
[ backoff, interval ].min
|
|
205
208
|
end
|
|
206
209
|
|
|
210
|
+
# @rbs () -> void
|
|
211
|
+
def redrive_loop
|
|
212
|
+
while @started
|
|
213
|
+
advance_redrive ? pause_between_batches : wait_for_next_redrive
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# @rbs () -> bool
|
|
218
|
+
def advance_redrive
|
|
219
|
+
RedriveRunner.new.run_once
|
|
220
|
+
rescue => error
|
|
221
|
+
SolidObjects.instrument(
|
|
222
|
+
:"supervisor.redrive_failed",
|
|
223
|
+
error_class: error.class.name,
|
|
224
|
+
error_message: error.message
|
|
225
|
+
)
|
|
226
|
+
false
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# @rbs () -> void
|
|
230
|
+
def pause_between_batches
|
|
231
|
+
pause = SolidObjects.configuration.redrive_batch_pause
|
|
232
|
+
sleep pause if pause.positive?
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# @rbs () -> void
|
|
236
|
+
def wait_for_next_redrive
|
|
237
|
+
sleep SolidObjects.configuration.supervisor_monitor_interval
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# @rbs () -> void
|
|
241
|
+
def stop_redrive
|
|
242
|
+
redrive = @redrive
|
|
243
|
+
@redrive = nil
|
|
244
|
+
return unless redrive
|
|
245
|
+
|
|
246
|
+
redrive.join(SolidObjects.configuration.shutdown_timeout)
|
|
247
|
+
redrive.kill if redrive.alive?
|
|
248
|
+
end
|
|
249
|
+
|
|
207
250
|
# @rbs () -> void
|
|
208
251
|
def stop_retention
|
|
209
252
|
retention = @retention
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module SolidObjects
|
|
4
4
|
class WakeUp
|
|
5
|
+
include ReportsWakeUpCapability
|
|
6
|
+
|
|
5
7
|
class Watch
|
|
6
8
|
# @rbs @wake_up: WakeUp
|
|
7
9
|
# @rbs @generation: Integer
|
|
@@ -29,6 +31,16 @@ module SolidObjects
|
|
|
29
31
|
@generation = 0
|
|
30
32
|
end
|
|
31
33
|
|
|
34
|
+
# @rbs () -> WakeUpCapability
|
|
35
|
+
def default_capability
|
|
36
|
+
WakeUpCapability.new(
|
|
37
|
+
adapter: :in_process,
|
|
38
|
+
crosses_processes: false,
|
|
39
|
+
measured_floor_ms: nil,
|
|
40
|
+
reason: "in-process signalling, which a commit in another process cannot reach"
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
32
44
|
# @rbs () -> void
|
|
33
45
|
def signal
|
|
34
46
|
mutex.synchronize do
|
|
@@ -10,6 +10,8 @@ module SolidObjects
|
|
|
10
10
|
# when one is available, so a missed or failed notification costs latency
|
|
11
11
|
# rather than correctness.
|
|
12
12
|
class Postgresql
|
|
13
|
+
include ReportsWakeUpCapability
|
|
14
|
+
|
|
13
15
|
CHANNEL = "solid_objects_wake_up"
|
|
14
16
|
FAILED_WAIT_INTERVAL = 0.05
|
|
15
17
|
|
|
@@ -19,6 +21,16 @@ module SolidObjects
|
|
|
19
21
|
|
|
20
22
|
attr_reader :channel
|
|
21
23
|
|
|
24
|
+
# @rbs () -> WakeUpCapability
|
|
25
|
+
def default_capability
|
|
26
|
+
WakeUpCapability.new(
|
|
27
|
+
adapter: :postgresql_notify,
|
|
28
|
+
crosses_processes: true,
|
|
29
|
+
measured_floor_ms: 2.9,
|
|
30
|
+
reason: "PostgreSQL LISTEN carries the signal between processes"
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
22
34
|
# @rbs (?channel: String) -> void
|
|
23
35
|
def initialize(channel: CHANNEL)
|
|
24
36
|
@channel = channel
|
|
@@ -12,6 +12,8 @@ module SolidObjects
|
|
|
12
12
|
# polling interval remains the upper bound, so a missed or failed
|
|
13
13
|
# notification costs latency rather than correctness.
|
|
14
14
|
class Redis
|
|
15
|
+
include ReportsWakeUpCapability
|
|
16
|
+
|
|
15
17
|
class Watch
|
|
16
18
|
# @rbs @adapter: Redis
|
|
17
19
|
# @rbs @generation: Integer
|
|
@@ -43,6 +45,16 @@ module SolidObjects
|
|
|
43
45
|
|
|
44
46
|
attr_reader :channel
|
|
45
47
|
|
|
48
|
+
# @rbs () -> WakeUpCapability
|
|
49
|
+
def default_capability
|
|
50
|
+
WakeUpCapability.new(
|
|
51
|
+
adapter: :redis,
|
|
52
|
+
crosses_processes: true,
|
|
53
|
+
measured_floor_ms: 5.7,
|
|
54
|
+
reason: "Redis carries the signal between processes"
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
46
58
|
# @rbs (?channel: String, ?url: String?, ?client: untyped) -> void
|
|
47
59
|
def initialize(channel: CHANNEL, url: nil, client: nil)
|
|
48
60
|
@channel = channel
|
|
@@ -2,22 +2,217 @@
|
|
|
2
2
|
|
|
3
3
|
module SolidObjects
|
|
4
4
|
module WakeUpAdapters
|
|
5
|
+
POSTGRESQL_FLOOR_MS = 2.9
|
|
6
|
+
REDIS_FLOOR_MS = 5.7
|
|
7
|
+
REDIS_URL_VARIABLE = "SOLID_OBJECTS_REDIS_URL"
|
|
8
|
+
PROBE_TIMEOUT_SECONDS = 2.0
|
|
9
|
+
PROBE_CHANNEL = "solid_objects_wake_up_probe"
|
|
10
|
+
|
|
11
|
+
@pooled_warning_mutex = Thread::Mutex.new
|
|
12
|
+
@pooled_warning_emitted = false
|
|
13
|
+
|
|
5
14
|
module_function
|
|
6
15
|
|
|
7
|
-
|
|
8
|
-
|
|
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
|
+
NAMES = %i[automatic in_process postgresql redis].freeze
|
|
17
|
+
|
|
16
18
|
# @rbs (?untyped) -> untyped
|
|
17
19
|
def for(connection = Record.connection)
|
|
18
|
-
|
|
20
|
+
select(connection)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @rbs (untyped) -> untyped
|
|
24
|
+
def build(setting)
|
|
25
|
+
return select if setting.nil? || setting == :automatic
|
|
26
|
+
return named(setting) if setting.is_a?(Symbol)
|
|
27
|
+
|
|
28
|
+
configured(setting)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @rbs (Symbol) -> untyped
|
|
32
|
+
def named(name)
|
|
33
|
+
case name
|
|
34
|
+
when :in_process then labelled(WakeUp.new, :in_process, false, nil, "in-process signalling was requested")
|
|
35
|
+
when :postgresql then requested_postgresql
|
|
36
|
+
when :redis then requested_redis
|
|
37
|
+
else
|
|
38
|
+
raise ArgumentError, "unknown wake_up_adapter #{name.inspect}, expected one of #{NAMES.join(", ")} or an adapter"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @rbs () -> untyped
|
|
43
|
+
def requested_postgresql
|
|
44
|
+
family = DatabaseAdapter.family(Record.connection)
|
|
45
|
+
unless family == :postgresql
|
|
46
|
+
return unavailable_selection(
|
|
47
|
+
"wake_up_adapter :postgresql needs a database with a notification " \
|
|
48
|
+
"channel, and #{family || "this database"} provides none"
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
labelled(Postgresql.new, :postgresql_notify, true, POSTGRESQL_FLOOR_MS, "PostgreSQL LISTEN was requested")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @rbs () -> untyped
|
|
56
|
+
def requested_redis
|
|
57
|
+
url = redis_url
|
|
58
|
+
unless url
|
|
59
|
+
return unavailable_selection(
|
|
60
|
+
"wake_up_adapter :redis needs #{REDIS_URL_VARIABLE}, which is not set"
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
redis_adapter(url, "Redis was requested")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @rbs (String, String) -> untyped
|
|
68
|
+
def redis_adapter(url, reason)
|
|
69
|
+
return unavailable_selection("#{REDIS_URL_VARIABLE} is set, and the redis gem is not installed") unless redis_installed?
|
|
70
|
+
|
|
71
|
+
labelled(Redis.new(url:), :redis, true, REDIS_FLOOR_MS, reason)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @rbs () -> bool
|
|
75
|
+
def redis_installed?
|
|
76
|
+
require "redis"
|
|
77
|
+
true
|
|
78
|
+
rescue LoadError
|
|
79
|
+
false
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @rbs (String) -> untyped
|
|
83
|
+
def unavailable_selection(reason)
|
|
84
|
+
SolidObjects.configuration.logger.warn(
|
|
85
|
+
event: "solid_objects.wake_up.unavailable",
|
|
86
|
+
reason:
|
|
87
|
+
)
|
|
88
|
+
polling_adapter(reason)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @rbs (untyped) -> untyped
|
|
92
|
+
def configured(adapter)
|
|
93
|
+
return adapter unless adapter.respond_to?(:capability=)
|
|
94
|
+
return adapter if adapter.respond_to?(:default_capability)
|
|
95
|
+
|
|
96
|
+
labelled(adapter, :configured, true, nil, "an adapter was configured, so selection did not run")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @rbs (untyped, Symbol, bool, Numeric?, String) -> untyped
|
|
100
|
+
def labelled(adapter, name, crosses_processes, floor, reason)
|
|
101
|
+
adapter.capability = WakeUpCapability.new(
|
|
102
|
+
adapter: name,
|
|
103
|
+
crosses_processes:,
|
|
104
|
+
measured_floor_ms: floor,
|
|
105
|
+
reason:
|
|
106
|
+
)
|
|
107
|
+
adapter
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# @rbs (?untyped) -> untyped
|
|
111
|
+
def select(connection = Record.connection)
|
|
112
|
+
url = redis_url
|
|
113
|
+
return redis_selection(url) if url
|
|
114
|
+
|
|
115
|
+
family = DatabaseAdapter.family(connection)
|
|
116
|
+
return postgresql_selection if family == :postgresql
|
|
117
|
+
|
|
118
|
+
polling_selection(family)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# @rbs () -> bool
|
|
122
|
+
def notifications_deliver?
|
|
123
|
+
probe = Postgresql.new(channel: PROBE_CHANNEL)
|
|
124
|
+
return false unless probe.listen
|
|
125
|
+
return false unless notify_probe_channel
|
|
126
|
+
|
|
127
|
+
probe.wait(timeout: PROBE_TIMEOUT_SECONDS)
|
|
128
|
+
rescue
|
|
129
|
+
false
|
|
130
|
+
ensure
|
|
131
|
+
probe&.stop
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @rbs () -> bool
|
|
135
|
+
def notify_probe_channel
|
|
136
|
+
connection = Record.connection_pool.send(:new_connection)
|
|
137
|
+
connection.execute("NOTIFY #{connection.quote_table_name(PROBE_CHANNEL)}")
|
|
138
|
+
true
|
|
139
|
+
ensure
|
|
140
|
+
disconnect_probe(connection)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# @rbs (untyped) -> void
|
|
144
|
+
def disconnect_probe(connection)
|
|
145
|
+
connection&.disconnect!
|
|
146
|
+
rescue
|
|
147
|
+
nil
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# @rbs () -> void
|
|
151
|
+
def reset_pooled_warning!
|
|
152
|
+
@pooled_warning_mutex.synchronize { @pooled_warning_emitted = false }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# @rbs () -> String?
|
|
156
|
+
def redis_url
|
|
157
|
+
value = ENV[REDIS_URL_VARIABLE].to_s
|
|
158
|
+
value.empty? ? nil : value
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# @rbs (String) -> untyped
|
|
162
|
+
def redis_selection(url)
|
|
163
|
+
redis_adapter(
|
|
164
|
+
url,
|
|
165
|
+
"#{REDIS_URL_VARIABLE} is set, so Redis carries the signal between processes"
|
|
166
|
+
)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# @rbs () -> untyped
|
|
170
|
+
def postgresql_selection
|
|
171
|
+
return pooled_selection unless notifications_deliver?
|
|
172
|
+
|
|
173
|
+
labelled(
|
|
174
|
+
Postgresql.new, :postgresql_notify, true, POSTGRESQL_FLOOR_MS,
|
|
175
|
+
"a probe notification arrived, so PostgreSQL LISTEN carries the signal between processes"
|
|
176
|
+
)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# @rbs () -> untyped
|
|
180
|
+
def pooled_selection
|
|
181
|
+
warn_pooled_session_once
|
|
182
|
+
polling_adapter(
|
|
183
|
+
"a probe notification did not arrive, so LISTEN cannot carry the signal " \
|
|
184
|
+
"between processes; a transaction pooler such as PgBouncer is the usual cause"
|
|
185
|
+
)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# @rbs (Symbol?) -> untyped
|
|
189
|
+
def polling_selection(family)
|
|
190
|
+
polling_adapter(
|
|
191
|
+
"#{family || "this database"} has no notification channel and " \
|
|
192
|
+
"#{REDIS_URL_VARIABLE} is not set"
|
|
193
|
+
)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# @rbs (String) -> untyped
|
|
197
|
+
def polling_adapter(reason)
|
|
198
|
+
labelled(
|
|
199
|
+
WakeUp.new, :polling, false,
|
|
200
|
+
SolidObjects.configuration.idle_polling_interval * 1_000, reason
|
|
201
|
+
)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# @rbs () -> void
|
|
205
|
+
def warn_pooled_session_once
|
|
206
|
+
@pooled_warning_mutex.synchronize do
|
|
207
|
+
return if @pooled_warning_emitted
|
|
19
208
|
|
|
20
|
-
|
|
209
|
+
SolidObjects.configuration.logger.warn(
|
|
210
|
+
event: "solid_objects.wake_up.pooled_session",
|
|
211
|
+
reason: "PostgreSQL notifications were not selected because a probe " \
|
|
212
|
+
"notification did not arrive"
|
|
213
|
+
)
|
|
214
|
+
@pooled_warning_emitted = true
|
|
215
|
+
end
|
|
21
216
|
end
|
|
22
217
|
end
|
|
23
218
|
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
WakeUpCapability = Data.define(:adapter, :crosses_processes, :measured_floor_ms, :reason)
|
|
5
|
+
|
|
6
|
+
module ReportsWakeUpCapability
|
|
7
|
+
# @rbs (WakeUpCapability) -> void
|
|
8
|
+
attr_writer :capability
|
|
9
|
+
|
|
10
|
+
# @rbs () -> WakeUpCapability
|
|
11
|
+
def capability
|
|
12
|
+
@capability ||= default_capability
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|