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.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +151 -0
  3. data/README.md +1 -1
  4. data/app/models/solid_objects/administration_event.rb +7 -0
  5. data/app/models/solid_objects/redrive.rb +7 -0
  6. data/benchmark/support.rb +2 -8
  7. data/db/migrate/20260922000000_add_solid_objects_administration_events.rb +26 -0
  8. data/db/migrate/20260922000001_add_solid_objects_redrives.rb +32 -0
  9. data/db/migrate/20260923000000_add_solid_objects_completed_idempotency_keys.rb +10 -0
  10. data/docs/adr/0011-wake-up-strategy.md +11 -1
  11. data/docs/architecture.md +41 -5
  12. data/docs/dashboard.md +10 -0
  13. data/docs/fit.md +6 -0
  14. data/docs/operations.md +153 -7
  15. data/docs/realtime.md +32 -26
  16. data/docs/roadmap.md +60 -32
  17. data/examples/at_least_once/boot.rb +2 -8
  18. data/lib/solid_objects/activation.rb +13 -6
  19. data/lib/solid_objects/actor.rb +134 -8
  20. data/lib/solid_objects/actor_snapshot.rb +8 -5
  21. data/lib/solid_objects/administration_audit.rb +30 -0
  22. data/lib/solid_objects/client.rb +81 -0
  23. data/lib/solid_objects/configuration.rb +47 -2
  24. data/lib/solid_objects/context.rb +10 -4
  25. data/lib/solid_objects/dead_letter_manager.rb +42 -6
  26. data/lib/solid_objects/dead_letter_scope.rb +143 -0
  27. data/lib/solid_objects/dead_row.rb +15 -0
  28. data/lib/solid_objects/doctor.rb +24 -5
  29. data/lib/solid_objects/errors.rb +11 -0
  30. data/lib/solid_objects/executor.rb +41 -3
  31. data/lib/solid_objects/message_reference.rb +29 -9
  32. data/lib/solid_objects/outcome.rb +31 -0
  33. data/lib/solid_objects/process_registry.rb +3 -1
  34. data/lib/solid_objects/redrive_manager.rb +130 -0
  35. data/lib/solid_objects/redrive_runner.rb +60 -0
  36. data/lib/solid_objects/redrive_task.rb +14 -0
  37. data/lib/solid_objects/reference.rb +5 -0
  38. data/lib/solid_objects/schema_bootstrap.rb +27 -0
  39. data/lib/solid_objects/supervisor.rb +43 -0
  40. data/lib/solid_objects/test_helper.rb +2 -0
  41. data/lib/solid_objects/version.rb +1 -1
  42. data/lib/solid_objects/wake_up.rb +12 -0
  43. data/lib/solid_objects/wake_up_adapters/postgresql.rb +12 -0
  44. data/lib/solid_objects/wake_up_adapters/redis.rb +12 -0
  45. data/lib/solid_objects/wake_up_adapters.rb +206 -11
  46. data/lib/solid_objects/wake_up_capability.rb +15 -0
  47. data/lib/solid_objects.rb +46 -3
  48. data/sig/generated/lib/solid_objects/activation.rbs +7 -0
  49. data/sig/generated/lib/solid_objects/actor.rbs +93 -2
  50. data/sig/generated/lib/solid_objects/administration_audit.rbs +11 -0
  51. data/sig/generated/lib/solid_objects/client.rbs +12 -0
  52. data/sig/generated/lib/solid_objects/configuration.rbs +54 -28
  53. data/sig/generated/lib/solid_objects/context.rbs +11 -6
  54. data/sig/generated/lib/solid_objects/dead_letter_manager.rbs +9 -0
  55. data/sig/generated/lib/solid_objects/dead_letter_scope.rbs +58 -0
  56. data/sig/generated/lib/solid_objects/dead_row.rbs +30 -0
  57. data/sig/generated/lib/solid_objects/doctor.rbs +3 -0
  58. data/sig/generated/lib/solid_objects/errors.rbs +8 -0
  59. data/sig/generated/lib/solid_objects/executor.rbs +9 -0
  60. data/sig/generated/lib/solid_objects/message_reference.rbs +8 -0
  61. data/sig/generated/lib/solid_objects/outcome.rbs +52 -0
  62. data/sig/generated/lib/solid_objects/redrive_manager.rbs +43 -0
  63. data/sig/generated/lib/solid_objects/redrive_runner.rbs +22 -0
  64. data/sig/generated/lib/solid_objects/redrive_task.rbs +33 -0
  65. data/sig/generated/lib/solid_objects/reference.rbs +5 -2
  66. data/sig/generated/lib/solid_objects/schema_bootstrap.rbs +11 -0
  67. data/sig/generated/lib/solid_objects/supervisor.rbs +15 -0
  68. data/sig/generated/lib/solid_objects/wake_up.rbs +5 -0
  69. data/sig/generated/lib/solid_objects/wake_up_adapters/postgresql.rbs +5 -0
  70. data/sig/generated/lib/solid_objects/wake_up_adapters/redis.rbs +5 -0
  71. data/sig/generated/lib/solid_objects/wake_up_adapters.rbs +75 -9
  72. data/sig/generated/lib/solid_objects/wake_up_capability.rbs +28 -0
  73. data/sig/generated/lib/solid_objects.rbs +14 -2
  74. data/sig/generated/models/solid_objects/administration_event.rbs +6 -0
  75. data/sig/generated/models/solid_objects/redrive.rbs +6 -0
  76. data/sig/public/reminder_payload.rbs +3 -0
  77. 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
@@ -31,6 +31,8 @@ module SolidObjects
31
31
  # @rbs () -> Array[Class]
32
32
  def actor_owned_models
33
33
  [
34
+ AdministrationEvent,
35
+ Redrive,
34
36
  DeadLetter,
35
37
  ClaimedMessage,
36
38
  ReadyMessage,
@@ -1,5 +1,5 @@
1
1
  # rbs_inline: enabled
2
2
 
3
3
  module SolidObjects
4
- VERSION = "0.15.2"
4
+ VERSION = "0.16.0"
5
5
  end
@@ -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
- # 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
+ NAMES = %i[automatic in_process postgresql redis].freeze
17
+
16
18
  # @rbs (?untyped) -> untyped
17
19
  def for(connection = Record.connection)
18
- return Postgresql.new if DatabaseAdapter.family(connection) == :postgresql
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
- WakeUp.new
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