solid_objects 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.
Files changed (178) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +17 -0
  3. data/MIT-LICENSE +19 -0
  4. data/README.md +744 -0
  5. data/Rakefile +40 -0
  6. data/app/controllers/solid_objects/application_controller.rb +23 -0
  7. data/app/controllers/solid_objects/dead_letters_controller.rb +23 -0
  8. data/app/controllers/solid_objects/instances_controller.rb +29 -0
  9. data/app/helpers/solid_objects/actor_helper.rb +25 -0
  10. data/app/models/solid_objects/broadcast.rb +10 -0
  11. data/app/models/solid_objects/claimed_message.rb +14 -0
  12. data/app/models/solid_objects/dead_letter.rb +13 -0
  13. data/app/models/solid_objects/effect.rb +10 -0
  14. data/app/models/solid_objects/instance.rb +93 -0
  15. data/app/models/solid_objects/message.rb +53 -0
  16. data/app/models/solid_objects/process.rb +13 -0
  17. data/app/models/solid_objects/ready_message.rb +10 -0
  18. data/app/models/solid_objects/record.rb +17 -0
  19. data/app/models/solid_objects/reminder.rb +9 -0
  20. data/app/views/solid_objects/dead_letters/index.html.erb +26 -0
  21. data/app/views/solid_objects/instances/index.html.erb +24 -0
  22. data/app/views/solid_objects/instances/show.html.erb +35 -0
  23. data/benchmark/activation_cache.rb +5 -0
  24. data/benchmark/ask_latency.rb +5 -0
  25. data/benchmark/claim.rb +5 -0
  26. data/benchmark/cold_actors.rb +5 -0
  27. data/benchmark/concurrent_actors.rb +5 -0
  28. data/benchmark/enqueue.rb +5 -0
  29. data/benchmark/hot_actor.rb +5 -0
  30. data/benchmark/processing.rb +5 -0
  31. data/benchmark/query_count.rb +5 -0
  32. data/benchmark/support.rb +271 -0
  33. data/config/routes.rb +8 -0
  34. data/db/migrate/20260805000000_create_solid_objects_tables.rb +319 -0
  35. data/docs/adr/0001-postgresql-backend.md +21 -0
  36. data/docs/adr/0002-jsonb-actor-state.md +21 -0
  37. data/docs/adr/0003-mailbox-ordering.md +30 -0
  38. data/docs/adr/0004-activation-leasing.md +21 -0
  39. data/docs/adr/0005-fencing-tokens.md +25 -0
  40. data/docs/adr/0006-at-least-once-delivery.md +24 -0
  41. data/docs/adr/0007-transactional-outbox.md +21 -0
  42. data/docs/adr/0008-actor-communication.md +21 -0
  43. data/docs/adr/0009-realtime-updates.md +21 -0
  44. data/docs/adr/0010-state-versioning.md +29 -0
  45. data/docs/adr/0011-wake-up-strategy.md +34 -0
  46. data/docs/adr/0012-not-active-jobs.md +21 -0
  47. data/docs/adr/0013-database-adapters.md +48 -0
  48. data/docs/architecture.md +615 -0
  49. data/docs/benchmarks.md +26 -0
  50. data/docs/correctness.md +124 -0
  51. data/docs/database-schema.md +111 -0
  52. data/docs/development.md +87 -0
  53. data/docs/implementation-plan.md +518 -0
  54. data/docs/operations.md +123 -0
  55. data/docs/realtime.md +51 -0
  56. data/docs/research/solid_queue.md +545 -0
  57. data/docs/roadmap.md +53 -0
  58. data/docs/security.md +61 -0
  59. data/docs/state-migrations.md +46 -0
  60. data/examples/application/README.md +16 -0
  61. data/examples/application/app/actors/chat_room_actor.rb +34 -0
  62. data/examples/application/app/actors/shopping_cart_actor.rb +79 -0
  63. data/examples/application/app/controllers/cart_controller.rb +54 -0
  64. data/examples/application/app/controllers/chat_rooms_controller.rb +44 -0
  65. data/examples/application/app/views/actors/chat_room_actor/_messages.html.erb +8 -0
  66. data/examples/application/app/views/actors/shopping_cart_actor/_summary.html.erb +10 -0
  67. data/examples/application/app/views/cart/show.html.erb +13 -0
  68. data/examples/application/app/views/chat_rooms/show.html.erb +8 -0
  69. data/examples/application/config/initializers/solid_objects.rb +23 -0
  70. data/examples/application/config/routes.rb +20 -0
  71. data/exe/solid_objects +9 -0
  72. data/lib/generators/solid_objects/install_generator.rb +21 -0
  73. data/lib/generators/solid_objects/templates/solid_objects.rb +13 -0
  74. data/lib/solid_objects/action_cable_broadcast_adapter.rb +19 -0
  75. data/lib/solid_objects/activation.rb +183 -0
  76. data/lib/solid_objects/activation_manager.rb +102 -0
  77. data/lib/solid_objects/actor.rb +271 -0
  78. data/lib/solid_objects/actor_channel.rb +29 -0
  79. data/lib/solid_objects/actor_definition.rb +212 -0
  80. data/lib/solid_objects/actor_registry.rb +65 -0
  81. data/lib/solid_objects/actor_snapshot.rb +42 -0
  82. data/lib/solid_objects/actor_view.rb +117 -0
  83. data/lib/solid_objects/broadcast_executor.rb +162 -0
  84. data/lib/solid_objects/cli.rb +118 -0
  85. data/lib/solid_objects/client.rb +153 -0
  86. data/lib/solid_objects/configuration.rb +168 -0
  87. data/lib/solid_objects/context.rb +41 -0
  88. data/lib/solid_objects/database_adapter.rb +82 -0
  89. data/lib/solid_objects/database_adapters/mysql.rb +22 -0
  90. data/lib/solid_objects/database_adapters/postgresql.rb +17 -0
  91. data/lib/solid_objects/database_adapters/sqlite.rb +12 -0
  92. data/lib/solid_objects/dead_letter_manager.rb +47 -0
  93. data/lib/solid_objects/dom_identity.rb +38 -0
  94. data/lib/solid_objects/effect_executor.rb +235 -0
  95. data/lib/solid_objects/effect_registry.rb +34 -0
  96. data/lib/solid_objects/engine.rb +33 -0
  97. data/lib/solid_objects/errors.rb +65 -0
  98. data/lib/solid_objects/executor.rb +290 -0
  99. data/lib/solid_objects/instrumentation.rb +10 -0
  100. data/lib/solid_objects/lease.rb +172 -0
  101. data/lib/solid_objects/lease_renewer.rb +70 -0
  102. data/lib/solid_objects/log_subscriber.rb +29 -0
  103. data/lib/solid_objects/mailbox.rb +178 -0
  104. data/lib/solid_objects/message_reference.rb +52 -0
  105. data/lib/solid_objects/process_registry.rb +143 -0
  106. data/lib/solid_objects/reference.rb +96 -0
  107. data/lib/solid_objects/reminder_scheduler.rb +168 -0
  108. data/lib/solid_objects/serialization.rb +99 -0
  109. data/lib/solid_objects/state.rb +111 -0
  110. data/lib/solid_objects/stream_name.rb +29 -0
  111. data/lib/solid_objects/stream_token.rb +59 -0
  112. data/lib/solid_objects/supervisor.rb +87 -0
  113. data/lib/solid_objects/turbo_stream_renderer.rb +35 -0
  114. data/lib/solid_objects/version.rb +5 -0
  115. data/lib/solid_objects/wake_up.rb +28 -0
  116. data/lib/solid_objects/worker.rb +139 -0
  117. data/lib/solid_objects.rb +118 -0
  118. data/sig/generated/controllers/solid_objects/application_controller.rbs +10 -0
  119. data/sig/generated/controllers/solid_objects/dead_letters_controller.rbs +11 -0
  120. data/sig/generated/controllers/solid_objects/instances_controller.rbs +11 -0
  121. data/sig/generated/helpers/solid_objects/actor_helper.rbs +8 -0
  122. data/sig/generated/lib/generators/solid_objects/install_generator.rbs +13 -0
  123. data/sig/generated/lib/solid_objects/action_cable_broadcast_adapter.rbs +8 -0
  124. data/sig/generated/lib/solid_objects/activation.rbs +65 -0
  125. data/sig/generated/lib/solid_objects/activation_manager.rbs +36 -0
  126. data/sig/generated/lib/solid_objects/actor.rbs +183 -0
  127. data/sig/generated/lib/solid_objects/actor_channel.rbs +8 -0
  128. data/sig/generated/lib/solid_objects/actor_definition.rbs +117 -0
  129. data/sig/generated/lib/solid_objects/actor_registry.rbs +36 -0
  130. data/sig/generated/lib/solid_objects/actor_snapshot.rbs +28 -0
  131. data/sig/generated/lib/solid_objects/actor_view.rbs +56 -0
  132. data/sig/generated/lib/solid_objects/broadcast_executor.rbs +55 -0
  133. data/sig/generated/lib/solid_objects/cli.rbs +31 -0
  134. data/sig/generated/lib/solid_objects/client.rbs +35 -0
  135. data/sig/generated/lib/solid_objects/configuration.rbs +147 -0
  136. data/sig/generated/lib/solid_objects/context.rbs +56 -0
  137. data/sig/generated/lib/solid_objects/database_adapter.rbs +42 -0
  138. data/sig/generated/lib/solid_objects/database_adapters/mysql.rbs +16 -0
  139. data/sig/generated/lib/solid_objects/database_adapters/postgresql.rbs +13 -0
  140. data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +10 -0
  141. data/sig/generated/lib/solid_objects/dead_letter_manager.rbs +16 -0
  142. data/sig/generated/lib/solid_objects/dom_identity.rbs +20 -0
  143. data/sig/generated/lib/solid_objects/effect_executor.rbs +82 -0
  144. data/sig/generated/lib/solid_objects/effect_registry.rbs +24 -0
  145. data/sig/generated/lib/solid_objects/engine.rbs +7 -0
  146. data/sig/generated/lib/solid_objects/errors.rbs +64 -0
  147. data/sig/generated/lib/solid_objects/executor.rbs +60 -0
  148. data/sig/generated/lib/solid_objects/instrumentation.rbs +8 -0
  149. data/sig/generated/lib/solid_objects/lease.rbs +57 -0
  150. data/sig/generated/lib/solid_objects/lease_renewer.rbs +42 -0
  151. data/sig/generated/lib/solid_objects/log_subscriber.rbs +11 -0
  152. data/sig/generated/lib/solid_objects/mailbox.rbs +46 -0
  153. data/sig/generated/lib/solid_objects/message_reference.rbs +37 -0
  154. data/sig/generated/lib/solid_objects/process_registry.rbs +46 -0
  155. data/sig/generated/lib/solid_objects/reference.rbs +45 -0
  156. data/sig/generated/lib/solid_objects/reminder_scheduler.rbs +55 -0
  157. data/sig/generated/lib/solid_objects/serialization.rbs +31 -0
  158. data/sig/generated/lib/solid_objects/state.rbs +72 -0
  159. data/sig/generated/lib/solid_objects/stream_name.rbs +11 -0
  160. data/sig/generated/lib/solid_objects/stream_token.rbs +19 -0
  161. data/sig/generated/lib/solid_objects/supervisor.rbs +38 -0
  162. data/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +14 -0
  163. data/sig/generated/lib/solid_objects/version.rbs +5 -0
  164. data/sig/generated/lib/solid_objects/wake_up.rbs +24 -0
  165. data/sig/generated/lib/solid_objects/worker.rbs +56 -0
  166. data/sig/generated/lib/solid_objects.rbs +41 -0
  167. data/sig/generated/models/solid_objects/broadcast.rbs +6 -0
  168. data/sig/generated/models/solid_objects/claimed_message.rbs +6 -0
  169. data/sig/generated/models/solid_objects/dead_letter.rbs +6 -0
  170. data/sig/generated/models/solid_objects/effect.rbs +6 -0
  171. data/sig/generated/models/solid_objects/instance.rbs +25 -0
  172. data/sig/generated/models/solid_objects/message.rbs +22 -0
  173. data/sig/generated/models/solid_objects/process.rbs +6 -0
  174. data/sig/generated/models/solid_objects/ready_message.rbs +6 -0
  175. data/sig/generated/models/solid_objects/record.rbs +8 -0
  176. data/sig/generated/models/solid_objects/reminder.rbs +6 -0
  177. data/sig/support/framework.rbs +37 -0
  178. metadata +467 -0
@@ -0,0 +1,178 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class Mailbox
5
+ INSTANCE_RETRY_LIMIT = 3
6
+
7
+ # @rbs @database_adapter: DatabaseAdapter
8
+
9
+ # @rbs (?database_adapter: DatabaseAdapter) -> void
10
+ def initialize(database_adapter: SolidObjects.database_adapter)
11
+ @database_adapter = database_adapter
12
+ end
13
+
14
+ # @rbs (Reference, Symbol | String, Hash[Symbol | String, untyped], kind: String, ?available_at: Time?, idempotency_key: String?) -> MessageReference
15
+ def enqueue(reference, message_name, arguments, kind:, available_at: nil, idempotency_key: nil)
16
+ actor_class = SolidObjects.registry.fetch(reference.actor_type)
17
+ normalized_arguments = Serialization.dump(
18
+ arguments,
19
+ max_bytes: SolidObjects.configuration.max_payload_bytes
20
+ )
21
+ message = with_instance_retry do
22
+ database_adapter.transaction do
23
+ enqueue_in_transaction(
24
+ reference,
25
+ message_name,
26
+ normalized_arguments,
27
+ kind:,
28
+ available_at:,
29
+ idempotency_key:,
30
+ actor_class:
31
+ )
32
+ end
33
+ end
34
+
35
+ announce(message)
36
+ MessageReference.from_message(message)
37
+ end
38
+
39
+ # @rbs (Reference, Symbol | String, Hash[Symbol | String, untyped], kind: String, ?available_at: Time?, idempotency_key: String?, ?actor_class: Class?) -> Message
40
+ def enqueue_in_transaction(
41
+ reference,
42
+ message_name,
43
+ arguments,
44
+ kind:,
45
+ available_at: nil,
46
+ idempotency_key: nil,
47
+ actor_class: nil
48
+ )
49
+ actor_class ||= SolidObjects.registry.fetch(reference.actor_type)
50
+ normalized_arguments = Serialization.dump(
51
+ arguments,
52
+ max_bytes: SolidObjects.configuration.max_payload_bytes
53
+ )
54
+ instance = find_or_create_instance(reference, actor_class)
55
+ instance.lock!
56
+
57
+ existing = find_idempotent_message(instance, idempotency_key)
58
+ return validate_idempotent_message!(existing, message_name, kind, normalized_arguments) if existing
59
+
60
+ enforce_mailbox_limit!(instance)
61
+ create_message(
62
+ instance,
63
+ reference,
64
+ message_name,
65
+ normalized_arguments,
66
+ kind:,
67
+ available_at:,
68
+ idempotency_key:
69
+ )
70
+ end
71
+
72
+ # @rbs (Message) -> void
73
+ def announce(message)
74
+ SolidObjects.instrument(
75
+ :"message.enqueued",
76
+ message_id: message.id,
77
+ actor_type: message.actor_type,
78
+ actor_id: message.actor_id,
79
+ sequence: message.sequence,
80
+ request_id: message.request_id
81
+ )
82
+ SolidObjects.wake_up.signal
83
+ end
84
+
85
+ private
86
+
87
+ attr_reader :database_adapter
88
+
89
+ # @rbs () { () -> Message } -> Message
90
+ def with_instance_retry
91
+ attempts = 0
92
+ begin
93
+ yield
94
+ rescue ActiveRecord::RecordNotFound
95
+ attempts += 1
96
+ retry if attempts < INSTANCE_RETRY_LIMIT
97
+
98
+ raise ActorDestroyed, "actor changed repeatedly while enqueueing"
99
+ end
100
+ end
101
+
102
+ # @rbs (Reference, Class) -> Instance
103
+ def find_or_create_instance(reference, actor_class)
104
+ Instance.create_or_find_by!(
105
+ actor_type: reference.actor_type,
106
+ actor_id: reference.actor_id
107
+ ) do |instance|
108
+ instance.state = {}
109
+ instance.state_version = actor_class.state_version
110
+ end
111
+ end
112
+
113
+ # @rbs (Instance, String?) -> Message?
114
+ def find_idempotent_message(instance, idempotency_key)
115
+ return unless idempotency_key
116
+
117
+ Message.find_by(instance_id: instance.id, idempotency_key:)
118
+ end
119
+
120
+ # @rbs (Message, Symbol | String, String, untyped) -> Message
121
+ def validate_idempotent_message!(message, message_name, kind, arguments)
122
+ matches = message.message_name == message_name.to_s &&
123
+ message.message_kind == kind &&
124
+ message.arguments == arguments
125
+ return message if matches
126
+
127
+ raise IdempotencyConflict, "idempotency key belongs to a different actor invocation"
128
+ end
129
+
130
+ # @rbs (Instance) -> void
131
+ def enforce_mailbox_limit!(instance)
132
+ live_count = ReadyMessage.where(instance_id: instance.id).count +
133
+ ClaimedMessage.where(instance_id: instance.id).count
134
+ return if live_count < SolidObjects.configuration.max_mailbox_length
135
+
136
+ raise MailboxFull, "mailbox is full for #{instance.actor_type}(#{instance.actor_id.inspect})"
137
+ end
138
+
139
+ # @rbs (Instance, Reference, Symbol | String, untyped, kind: String, available_at: Time?, idempotency_key: String?) -> Message
140
+ def create_message(instance, reference, message_name, arguments, kind:, available_at:, idempotency_key:)
141
+ sequence = instance.next_message_sequence
142
+ now = database_adapter.database_now
143
+ scheduled_at = normalize_availability(available_at, now)
144
+ instance.update!(next_message_sequence: sequence + 1)
145
+ message = Message.create!(
146
+ instance:,
147
+ actor_type: reference.actor_type,
148
+ actor_id: reference.actor_id,
149
+ message_name: message_name.to_s,
150
+ message_kind: kind,
151
+ arguments:,
152
+ sequence:,
153
+ max_attempts: SolidObjects.configuration.max_attempts,
154
+ request_id: SecureRandom.uuid,
155
+ idempotency_key:,
156
+ enqueued_at: now,
157
+ available_at: scheduled_at
158
+ )
159
+ ReadyMessage.create!(
160
+ message:,
161
+ instance:,
162
+ sequence:,
163
+ available_at: scheduled_at
164
+ )
165
+ message
166
+ end
167
+
168
+ # @rbs (Time?, Time) -> Time
169
+ def normalize_availability(available_at, now)
170
+ return now unless available_at
171
+
172
+ time = available_at.respond_to?(:to_time) ? available_at.to_time : available_at
173
+ raise ArgumentError, "available_at must be a time" unless time.is_a?(Time)
174
+
175
+ [ time, now ].max
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,52 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class MessageReference
5
+ class << self
6
+ # @rbs (Message) -> MessageReference
7
+ def from_message(message)
8
+ new(
9
+ id: message.id,
10
+ request_id: message.request_id,
11
+ actor_type: message.actor_type,
12
+ actor_id: message.actor_id,
13
+ sequence: message.sequence
14
+ )
15
+ end
16
+ end
17
+
18
+ # @rbs @id: Integer
19
+ # @rbs @request_id: String
20
+ # @rbs @actor_type: String
21
+ # @rbs @actor_id: String
22
+ # @rbs @sequence: Integer
23
+
24
+ attr_reader :id, :request_id, :actor_type, :actor_id, :sequence
25
+
26
+ # @rbs (id: Integer, request_id: String, actor_type: String, actor_id: String, sequence: Integer) -> void
27
+ def initialize(id:, request_id:, actor_type:, actor_id:, sequence:)
28
+ @id = id
29
+ @request_id = request_id
30
+ @actor_type = actor_type
31
+ @actor_id = actor_id
32
+ @sequence = sequence
33
+ freeze
34
+ end
35
+
36
+ # @rbs () -> String
37
+ def status
38
+ message = Message.find(id)
39
+ return "completed" if message.completed?
40
+ return "dead" if message.dead?
41
+ return "claimed" if message.claimed?
42
+ return "ready" if message.ready?
43
+
44
+ "unknown"
45
+ end
46
+
47
+ # @rbs () -> untyped
48
+ def result
49
+ Message.find(id).result
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,143 @@
1
+ # rbs_inline: enabled
2
+
3
+ require "socket"
4
+
5
+ module SolidObjects
6
+ class ProcessRegistry
7
+ class << self
8
+ # @rbs (?now: Time) -> Integer
9
+ def cleanup_dead(now: SolidObjects.database_adapter.database_now)
10
+ stale_at = now - SolidObjects.configuration.process_alive_threshold
11
+ dead_processes = Process
12
+ .where.not(shutdown_state: "stopped")
13
+ .where(last_heartbeat_at: ..stale_at)
14
+ .to_a
15
+
16
+ dead_processes.each { |process_record| cleanup_process(process_record, now) }
17
+ dead_processes.length
18
+ end
19
+
20
+ # @rbs (Process, ?now: Time) -> bool
21
+ def deregister(process_record, now: SolidObjects.database_adapter.database_now)
22
+ SolidObjects.database_adapter.transaction do
23
+ Instance.where(activation_owner_id: process_record.id).update_all(
24
+ activation_owner_id: nil,
25
+ activation_expires_at: nil
26
+ )
27
+ ClaimedMessage.where(process_id: process_record.id).update_all(process_id: nil)
28
+ Effect.where(claimed_by: process_record.id).update_all(
29
+ status: "pending",
30
+ claimed_by: nil,
31
+ claimed_at: nil,
32
+ available_at: now
33
+ )
34
+ Reminder.where(claimed_by: process_record.id).update_all(
35
+ claimed_by: nil,
36
+ claimed_at: nil
37
+ )
38
+ Broadcast.where(claimed_by: process_record.id).update_all(
39
+ status: "pending",
40
+ claimed_by: nil,
41
+ claimed_at: nil,
42
+ available_at: now
43
+ )
44
+ process_record.update!(
45
+ shutdown_state: "stopped",
46
+ stopped_at: now
47
+ )
48
+ end
49
+ true
50
+ end
51
+
52
+ private
53
+
54
+ # @rbs (Process, Time) -> void
55
+ def cleanup_process(process_record, now)
56
+ deregister(process_record, now:)
57
+ SolidObjects.instrument(
58
+ :"process.pruned",
59
+ process_id: process_record.id,
60
+ process_kind: process_record.kind,
61
+ last_heartbeat_at: process_record.last_heartbeat_at
62
+ )
63
+ end
64
+ end
65
+
66
+ # @rbs @process_record: Process?
67
+ # @rbs @last_heartbeat_at: Float?
68
+
69
+ attr_reader :process_record
70
+
71
+ # @rbs () -> void
72
+ def initialize
73
+ @process_record = nil
74
+ @last_heartbeat_at = nil
75
+ end
76
+
77
+ # @rbs (?kind: String, ?metadata: Hash[String | Symbol, untyped]) -> Process
78
+ def register(kind: "worker", metadata: {})
79
+ now = SolidObjects.database_adapter.database_now
80
+ @process_record = Process.create!(
81
+ id: SecureRandom.uuid,
82
+ kind:,
83
+ hostname: Socket.gethostname,
84
+ pid: ::Process.pid,
85
+ started_at: now,
86
+ last_heartbeat_at: now,
87
+ metadata: Serialization.dump(default_metadata.merge(metadata))
88
+ )
89
+ @last_heartbeat_at = monotonic_now
90
+ @process_record
91
+ end
92
+
93
+ # @rbs () -> bool
94
+ def heartbeat
95
+ return false unless process_record
96
+ return false if heartbeat_recent?
97
+
98
+ process_record.update(
99
+ last_heartbeat_at: SolidObjects.database_adapter.database_now
100
+ ).tap { @last_heartbeat_at = monotonic_now }
101
+ end
102
+
103
+ # @rbs () -> bool
104
+ def start_draining
105
+ return false unless process_record
106
+
107
+ process_record.update(
108
+ shutdown_state: "draining",
109
+ shutdown_requested_at: SolidObjects.database_adapter.database_now
110
+ )
111
+ end
112
+
113
+ # @rbs () -> bool
114
+ def stop
115
+ return false unless process_record
116
+
117
+ self.class.deregister(process_record)
118
+ end
119
+
120
+ private
121
+
122
+ # @rbs () -> Hash[Symbol, String]
123
+ def default_metadata
124
+ {
125
+ solid_objects_version: SolidObjects::VERSION,
126
+ ruby_version: RUBY_VERSION
127
+ }
128
+ end
129
+
130
+ # @rbs () -> bool
131
+ def heartbeat_recent?
132
+ return false unless @last_heartbeat_at
133
+
134
+ monotonic_now - @last_heartbeat_at <
135
+ SolidObjects.configuration.process_heartbeat_interval
136
+ end
137
+
138
+ # @rbs () -> Float
139
+ def monotonic_now
140
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,96 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class Reference
5
+ # @rbs @actor_type: String
6
+ # @rbs @actor_id: String
7
+
8
+ attr_reader :actor_type, :actor_id
9
+
10
+ # @rbs (actor_type: String, actor_id: String) -> void
11
+ def initialize(actor_type:, actor_id:)
12
+ raise InvalidActor, "actor ID cannot be empty" if actor_id.empty?
13
+
14
+ @actor_type = actor_type
15
+ @actor_id = actor_id
16
+ freeze
17
+ end
18
+
19
+ # @rbs (Symbol | String, ?available_at: Time?, ?idempotency_key: String?, ?authorization_context: untyped, **untyped) -> (MessageReference | Actor::OutboundMessageIntent)
20
+ def tell(message_name, available_at: nil, idempotency_key: nil, authorization_context: nil, **arguments)
21
+ if (actor = Context.current_actor)
22
+ return actor.stage_outbound_message(
23
+ self,
24
+ message_name,
25
+ arguments,
26
+ available_at:,
27
+ idempotency_key:
28
+ )
29
+ end
30
+
31
+ SolidObjects.client.tell(
32
+ self,
33
+ message_name,
34
+ arguments,
35
+ available_at:,
36
+ idempotency_key:,
37
+ authorization_context:
38
+ )
39
+ end
40
+
41
+ # @rbs (Symbol | String, ?timeout: Numeric, ?idempotency_key: String?, ?authorization_context: untyped, **untyped) -> untyped
42
+ def ask(message_name, timeout: 5.seconds, idempotency_key: nil, authorization_context: nil, **arguments)
43
+ raise ActorCallCycle, "actors cannot synchronously wait for another actor" if Context.current_actor
44
+
45
+ SolidObjects.client.ask(
46
+ self,
47
+ message_name,
48
+ arguments,
49
+ timeout:,
50
+ idempotency_key:,
51
+ authorization_context:
52
+ )
53
+ end
54
+
55
+ # @rbs (?authorization_context: untyped) -> bool
56
+ def destroy(authorization_context: nil)
57
+ SolidObjects.client.destroy(self, authorization_context:)
58
+ end
59
+
60
+ # @rbs (Symbol, *untyped, **untyped) -> untyped
61
+ def method_missing(name, *arguments, **keywords)
62
+ return super if arguments.any? || block_given?
63
+ return tell(name, **keywords) if actor_message?(name)
64
+ return Serialization.readonly_copy(ask(name, **keywords)) if actor_query?(name)
65
+
66
+ super
67
+ end
68
+
69
+ # @rbs (Symbol, bool) -> bool
70
+ def respond_to_missing?(name, include_private = false)
71
+ actor_message?(name) || actor_query?(name) || super
72
+ end
73
+
74
+ # @rbs () -> String
75
+ def to_s
76
+ "#{actor_type}(#{actor_id.inspect})"
77
+ end
78
+
79
+ private
80
+
81
+ # @rbs (Symbol | String) -> bool
82
+ def actor_message?(name)
83
+ actor_definition.messages.key?(name.to_sym)
84
+ end
85
+
86
+ # @rbs (Symbol | String) -> bool
87
+ def actor_query?(name)
88
+ actor_definition.queries.key?(name.to_sym)
89
+ end
90
+
91
+ # @rbs () -> ActorDefinition
92
+ def actor_definition
93
+ SolidObjects.registry.fetch(actor_type).definition
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,168 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class ReminderScheduler
5
+ # @rbs @process_registry: ProcessRegistry
6
+ # @rbs @database_adapter: DatabaseAdapter
7
+ # @rbs @stopped: bool
8
+ # @rbs @shutdown_requested: bool
9
+
10
+ # @rbs (?process_registry: ProcessRegistry, ?database_adapter: DatabaseAdapter) -> void
11
+ def initialize(
12
+ process_registry: ProcessRegistry.new,
13
+ database_adapter: SolidObjects.database_adapter
14
+ )
15
+ @process_registry = process_registry
16
+ @database_adapter = database_adapter
17
+ process_registry.register(kind: "reminder")
18
+ @stopped = false
19
+ @shutdown_requested = false
20
+ end
21
+
22
+ # @rbs () -> bool
23
+ def run_once
24
+ return false if stopped?
25
+
26
+ process_registry.heartbeat
27
+ reminder = claim_next
28
+ return false unless reminder
29
+
30
+ enqueue(reminder).present?
31
+ rescue
32
+ release(reminder) if reminder
33
+ raise
34
+ end
35
+
36
+ # @rbs () -> void
37
+ def stop
38
+ return if stopped?
39
+
40
+ @stopped = true
41
+ process_registry.start_draining
42
+ process_registry.stop
43
+ end
44
+
45
+ # @rbs () -> void
46
+ def run
47
+ until shutdown_requested?
48
+ worked = run_once
49
+ next if worked
50
+
51
+ SolidObjects.wake_up.wait(timeout: SolidObjects.configuration.polling_interval)
52
+ end
53
+ ensure
54
+ stop
55
+ end
56
+
57
+ # @rbs () -> void
58
+ def request_shutdown
59
+ @shutdown_requested = true
60
+ SolidObjects.wake_up.signal
61
+ end
62
+
63
+ # @rbs () -> bool
64
+ def stopped?
65
+ @stopped
66
+ end
67
+
68
+ # @rbs () -> bool
69
+ def shutdown_requested?
70
+ @shutdown_requested
71
+ end
72
+
73
+ private
74
+
75
+ attr_reader :process_registry, :database_adapter
76
+
77
+ # @rbs () -> Reminder?
78
+ def claim_next
79
+ database_adapter.transaction do
80
+ now = database_adapter.database_now
81
+ stale_at = now - SolidObjects.configuration.process_alive_threshold
82
+ relation = Reminder
83
+ .where(status: "scheduled", next_run_at: ..now)
84
+ .where("claimed_by IS NULL OR claimed_at <= ?", stale_at)
85
+ .order(:next_run_at, :id)
86
+ reminder = database_adapter.lock_candidates(relation).first
87
+ next unless reminder
88
+
89
+ reminder.update!(
90
+ claimed_by: process_registry.process_record.id,
91
+ claimed_at: now
92
+ )
93
+ reminder
94
+ end
95
+ end
96
+
97
+ # @rbs (Reminder) -> MessageReference?
98
+ def enqueue(reminder)
99
+ actor_class = SolidObjects.registry.fetch(reminder.actor_type)
100
+ unless actor_class.definition.messages.key?(reminder.message_name.to_sym)
101
+ raise UnknownMessage, "unknown reminder message #{reminder.message_name.inspect}"
102
+ end
103
+
104
+ mailbox = Mailbox.new(database_adapter:)
105
+ message = database_adapter.transaction do
106
+ instance = Instance.lock.find_by(id: reminder.instance_id)
107
+ next unless instance
108
+
109
+ locked_reminder = Reminder.lock.find_by(id: reminder.id)
110
+ next unless locked_reminder
111
+ verify_claim!(locked_reminder)
112
+ now = database_adapter.database_now
113
+ message = mailbox.enqueue_in_transaction(
114
+ Reference.new(actor_type: instance.actor_type, actor_id: instance.actor_id),
115
+ locked_reminder.message_name,
116
+ locked_reminder.arguments,
117
+ kind: "internal",
118
+ idempotency_key: "reminder:#{locked_reminder.id}:#{locked_reminder.occurrence}",
119
+ actor_class:
120
+ )
121
+ recurring = locked_reminder.interval_seconds.present?
122
+ locked_reminder.update!(
123
+ status: recurring ? "scheduled" : "completed",
124
+ occurrence: locked_reminder.occurrence + 1,
125
+ next_run_at: recurring ? next_run_at(locked_reminder, now) : locked_reminder.next_run_at,
126
+ claimed_by: nil,
127
+ claimed_at: nil
128
+ )
129
+ message
130
+ end
131
+ return unless message
132
+
133
+ mailbox.announce(message)
134
+ SolidObjects.instrument(
135
+ :"reminder.enqueued",
136
+ reminder_id: reminder.id,
137
+ actor_type: reminder.actor_type,
138
+ actor_id: reminder.actor_id,
139
+ occurrence: reminder.occurrence
140
+ )
141
+ MessageReference.from_message(message)
142
+ end
143
+
144
+ # @rbs (Reminder) -> void
145
+ def release(reminder)
146
+ Reminder
147
+ .where(id: reminder.id, claimed_by: process_registry.process_record.id)
148
+ .update_all(claimed_by: nil, claimed_at: nil)
149
+ end
150
+
151
+ # @rbs (Reminder) -> void
152
+ def verify_claim!(reminder)
153
+ return if reminder.claimed_by == process_registry.process_record.id
154
+
155
+ raise LostActivation, "reminder claim changed"
156
+ end
157
+
158
+ # @rbs (Reminder, Time) -> Time
159
+ def next_run_at(reminder, now)
160
+ interval = reminder.interval_seconds.to_f
161
+ next_run = reminder.next_run_at + interval
162
+ return next_run if reminder.missed_policy == "all" || next_run > now
163
+
164
+ missed_intervals = ((now - next_run) / interval).floor + 1
165
+ next_run + (missed_intervals * interval)
166
+ end
167
+ end
168
+ end