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,33 @@
1
+ # rbs_inline: enabled
2
+
3
+ require_relative "../../app/models/solid_objects/record"
4
+
5
+ module SolidObjects
6
+ class Engine < ::Rails::Engine
7
+ isolate_namespace SolidObjects
8
+
9
+ config.generators do |generators|
10
+ generators.test_framework :minitest
11
+ end
12
+
13
+ initializer "solid_objects.configuration" do
14
+ SolidObjects.configuration.validate!
15
+ SolidObjects::LogSubscriber.install
16
+ end
17
+
18
+ initializer "solid_objects.database", after: :load_config_initializers do
19
+ SolidObjects::Record.configure_connection
20
+ end
21
+
22
+ initializer "solid_objects.helpers" do
23
+ ActiveSupport.on_load(:action_view) do
24
+ include SolidObjects::ActorHelper
25
+ end
26
+ end
27
+
28
+ rake_tasks do
29
+ tasks_path = File.expand_path("../tasks", __dir__)
30
+ Dir[File.join(tasks_path, "**/*.rake")].sort.each { |task| load task }
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,65 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class Error < StandardError
5
+ end
6
+
7
+ class UnsupportedDatabase < Error
8
+ end
9
+
10
+ class InvalidActor < Error
11
+ end
12
+
13
+ class UnknownActorType < Error
14
+ end
15
+
16
+ class UnknownMessage < Error
17
+ end
18
+
19
+ class InvalidPayload < Error
20
+ end
21
+
22
+ class PayloadTooLarge < InvalidPayload
23
+ end
24
+
25
+ class MailboxFull < Error
26
+ end
27
+
28
+ class IdempotencyConflict < Error
29
+ end
30
+
31
+ class Unauthorized < Error
32
+ end
33
+
34
+ class InvalidStreamToken < Error
35
+ end
36
+
37
+ class LostActivation < Error
38
+ end
39
+
40
+ class ActorDestroyed < LostActivation
41
+ end
42
+
43
+ class AskTimeout < Error
44
+ end
45
+
46
+ class MessageFailed < Error
47
+ # @rbs @message_id: Integer
48
+ # @rbs @details: Hash[String, untyped]
49
+
50
+ attr_reader :message_id, :details
51
+
52
+ # @rbs (String, message_id: Integer, details: Hash[String, untyped]) -> void
53
+ def initialize(message, message_id:, details:)
54
+ @message_id = message_id
55
+ @details = details
56
+ super(message)
57
+ end
58
+ end
59
+
60
+ class StateMigrationError < Error
61
+ end
62
+
63
+ class ActorCallCycle < Error
64
+ end
65
+ end
@@ -0,0 +1,290 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class Executor
5
+ # @rbs @activation: Activation
6
+ # @rbs @message: Message
7
+
8
+ # @rbs (activation: Activation, message: Message) -> void
9
+ def initialize(activation:, message:)
10
+ @activation = activation
11
+ @message = message
12
+ end
13
+
14
+ # @rbs () -> bool
15
+ def call
16
+ state_before = actor.state.to_h
17
+ observables_before = actor.observable_values
18
+ message_context = MessageContext.new(
19
+ id: message.id,
20
+ attempt: message.attempt_count,
21
+ enqueued_at: message.enqueued_at,
22
+ idempotency_key: message.idempotency_key,
23
+ request_id: message.request_id
24
+ )
25
+
26
+ SolidObjects.instrument(:"message.started", **instrumentation_payload)
27
+ result = Context.with(actor:, message: message_context) do
28
+ actor.invoke(message.message_name, message.arguments)
29
+ end
30
+ ensure_query_did_not_mutate_state!(state_before)
31
+ observable_changes = changed_observables(observables_before, actor.observable_values)
32
+ complete(result, observable_changes)
33
+ true
34
+ rescue LostActivation
35
+ raise
36
+ rescue => error
37
+ activation.restore_state(state_before) if state_before
38
+ actor.discard_intents
39
+ fail_message(error)
40
+ false
41
+ end
42
+
43
+ private
44
+
45
+ attr_reader :activation, :message
46
+
47
+ # @rbs () -> Actor
48
+ def actor
49
+ activation.actor
50
+ end
51
+
52
+ # @rbs (Hash[String, untyped]) -> void
53
+ def ensure_query_did_not_mutate_state!(state_before)
54
+ return unless message.message_kind == "ask"
55
+ return unless actor.class.definition.queries.key?(message.message_name.to_sym)
56
+ return if actor.state.to_h == state_before
57
+
58
+ raise InvalidActor, "query #{message.message_name.inspect} mutated actor state"
59
+ end
60
+
61
+ # @rbs (Hash[String, untyped], Hash[String, untyped]) -> Hash[String, untyped]
62
+ def changed_observables(before, after)
63
+ after.each_with_object({}) do |(name, value), changes|
64
+ changes[name] = value unless before[name] == value
65
+ end
66
+ end
67
+
68
+ # @rbs (untyped, Hash[String, untyped]) -> void
69
+ def complete(result, observable_changes)
70
+ serialized_state = Serialization.dump(
71
+ actor.state.to_h,
72
+ max_bytes: SolidObjects.configuration.max_state_bytes
73
+ )
74
+ serialized_result = Serialization.dump(
75
+ (message.message_kind == "ask") ? result : nil,
76
+ max_bytes: SolidObjects.configuration.max_result_bytes
77
+ )
78
+ effect_intents = actor.drain_effect_intents
79
+ reminder_intents = actor.drain_reminder_intents
80
+ outbound_message_intents = actor.drain_outbound_message_intents
81
+ enqueued_effects = []
82
+
83
+ activation.lease.fenced_transaction do |instance|
84
+ claimed_message = matching_claim!
85
+ locked_message = Message.lock.find(message.id)
86
+ instance.update!(
87
+ state: serialized_state,
88
+ state_version: actor.class.state_version,
89
+ last_used_at: SolidObjects.database_adapter.database_now
90
+ )
91
+ locked_message.update!(
92
+ result: serialized_result,
93
+ error: nil,
94
+ completed_at: SolidObjects.database_adapter.database_now
95
+ )
96
+ enqueued_effects.concat(enqueue_effects(locked_message, instance, effect_intents))
97
+ schedule_reminders(instance, reminder_intents)
98
+ enqueued_effects.concat(
99
+ enqueue_actor_messages(locked_message, instance, outbound_message_intents)
100
+ )
101
+ enqueue_broadcasts(locked_message, instance, observable_changes)
102
+ claimed_message.destroy!
103
+ end
104
+
105
+ observable_changes.each_key do |observable_name|
106
+ SolidObjects.instrument(
107
+ :"broadcast.enqueued",
108
+ **instrumentation_payload,
109
+ observable_name:
110
+ )
111
+ end
112
+ enqueued_effects.each do |effect|
113
+ SolidObjects.instrument(
114
+ :"effect.enqueued",
115
+ effect_id: effect.effect_id,
116
+ effect_name: effect.name,
117
+ message_id: effect.message_id,
118
+ actor_type: message.actor_type,
119
+ actor_id: message.actor_id
120
+ )
121
+ end
122
+ SolidObjects.instrument(:"message.completed", **instrumentation_payload)
123
+ SolidObjects.wake_up.signal
124
+ end
125
+
126
+ # @rbs (Message, Instance, Array[Actor::EffectIntent]) -> Array[Effect]
127
+ def enqueue_effects(locked_message, instance, intents)
128
+ intents.map do |intent|
129
+ Effect.create!(
130
+ message: locked_message,
131
+ instance:,
132
+ effect_id: SecureRandom.uuid,
133
+ name: intent.name,
134
+ arguments: intent.arguments,
135
+ success_message_name: intent.success_message_name,
136
+ failure_message_name: intent.failure_message_name,
137
+ status: "pending",
138
+ max_attempts: SolidObjects.configuration.max_attempts,
139
+ available_at: SolidObjects.database_adapter.database_now
140
+ )
141
+ end
142
+ end
143
+
144
+ # @rbs (Instance, Array[Actor::ReminderIntent]) -> void
145
+ def schedule_reminders(instance, intents)
146
+ intents.each do |intent|
147
+ reminder = Reminder.find_or_initialize_by(instance:, name: intent.name)
148
+ reminder.assign_attributes(
149
+ actor_type: instance.actor_type,
150
+ actor_id: instance.actor_id,
151
+ message_name: intent.name,
152
+ arguments: intent.arguments,
153
+ next_run_at: intent.at,
154
+ interval_seconds: intent.interval_seconds,
155
+ missed_policy: intent.missed_policy,
156
+ status: "scheduled",
157
+ claimed_by: nil,
158
+ claimed_at: nil
159
+ )
160
+ reminder.save!
161
+ end
162
+ end
163
+
164
+ # @rbs (Message, Instance, Array[Actor::OutboundMessageIntent]) -> Array[Effect]
165
+ def enqueue_actor_messages(locked_message, instance, intents)
166
+ intents.map do |intent|
167
+ Effect.create!(
168
+ message: locked_message,
169
+ instance:,
170
+ effect_id: SecureRandom.uuid,
171
+ name: EffectExecutor::ACTOR_MESSAGE_EFFECT,
172
+ arguments: {
173
+ "actor_type" => intent.actor_type,
174
+ "actor_id" => intent.actor_id,
175
+ "message_name" => intent.message_name,
176
+ "arguments" => intent.arguments,
177
+ "available_at" => intent.available_at&.iso8601(6),
178
+ "idempotency_key" => intent.idempotency_key
179
+ },
180
+ status: "pending",
181
+ max_attempts: SolidObjects.configuration.max_attempts,
182
+ available_at: SolidObjects.database_adapter.database_now
183
+ )
184
+ end
185
+ end
186
+
187
+ # @rbs (Message, Instance, Hash[String, untyped]) -> void
188
+ def enqueue_broadcasts(locked_message, instance, observable_changes)
189
+ observable_changes.each do |observable_name, value|
190
+ Broadcast.create!(
191
+ message: locked_message,
192
+ instance:,
193
+ broadcast_id: SecureRandom.uuid,
194
+ observable_name:,
195
+ value:,
196
+ state_version: actor.class.state_version,
197
+ activation_generation: activation.lease.generation,
198
+ status: "pending",
199
+ available_at: SolidObjects.database_adapter.database_now
200
+ )
201
+ end
202
+ end
203
+
204
+ # @rbs (Exception) -> void
205
+ def fail_message(error)
206
+ error_details = serialized_error(error)
207
+ dead = false
208
+
209
+ activation.lease.fenced_transaction do
210
+ claimed_message = matching_claim!
211
+ locked_message = Message.lock.find(message.id)
212
+ now = SolidObjects.database_adapter.database_now
213
+ locked_message.update!(error: error_details, last_failed_at: now)
214
+ claimed_message.destroy!
215
+
216
+ if locked_message.attempt_count >= locked_message.max_attempts
217
+ create_dead_letter(locked_message, error_details, now)
218
+ dead = true
219
+ else
220
+ ReadyMessage.create!(
221
+ message: locked_message,
222
+ instance: locked_message.instance,
223
+ sequence: locked_message.sequence,
224
+ available_at: now + SolidObjects.configuration.retry_delay.call(locked_message.attempt_count)
225
+ )
226
+ end
227
+ end
228
+
229
+ SolidObjects.instrument(
230
+ :"message.failed",
231
+ **instrumentation_payload,
232
+ error_class: error.class.name,
233
+ dead:
234
+ )
235
+ SolidObjects.wake_up.signal
236
+ end
237
+
238
+ # @rbs () -> ClaimedMessage
239
+ def matching_claim!
240
+ ClaimedMessage.lock.find_by!(
241
+ message_id: message.id,
242
+ process_id: activation.lease.owner_id,
243
+ activation_generation: activation.lease.generation
244
+ )
245
+ rescue ActiveRecord::RecordNotFound
246
+ raise LostActivation, "message claim changed"
247
+ end
248
+
249
+ # @rbs (Exception) -> Hash[String, untyped]
250
+ def serialized_error(error)
251
+ Serialization.dump(
252
+ {
253
+ "class" => error.class.name,
254
+ "message" => error.message.to_s.byteslice(0, 8_192),
255
+ "backtrace" => Array(error.backtrace).first(50)
256
+ }
257
+ )
258
+ end
259
+
260
+ # @rbs (Message, Hash[String, untyped], Time) -> DeadLetter
261
+ def create_dead_letter(locked_message, error_details, now)
262
+ DeadLetter.create!(
263
+ message: locked_message,
264
+ instance: locked_message.instance,
265
+ actor_type: locked_message.actor_type,
266
+ actor_id: locked_message.actor_id,
267
+ message_name: locked_message.message_name,
268
+ arguments: locked_message.arguments,
269
+ attempts: locked_message.attempt_count,
270
+ exception_class: error_details.fetch("class"),
271
+ exception_message: error_details.fetch("message"),
272
+ backtrace: error_details.fetch("backtrace"),
273
+ first_failed_at: locked_message.last_failed_at || now,
274
+ last_failed_at: now
275
+ )
276
+ end
277
+
278
+ # @rbs () -> Hash[Symbol, untyped]
279
+ def instrumentation_payload
280
+ {
281
+ message_id: message.id,
282
+ actor_type: message.actor_type,
283
+ actor_id: message.actor_id,
284
+ sequence: message.sequence,
285
+ attempt: message.attempt_count,
286
+ request_id: message.request_id
287
+ }
288
+ end
289
+ end
290
+ end
@@ -0,0 +1,10 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ module Instrumentation
5
+ # @rbs (Symbol, **untyped) { (Hash[Symbol, untyped]) -> untyped } -> untyped
6
+ def instrument(event, **payload, &block)
7
+ ActiveSupport::Notifications.instrument("solid_objects.#{event}", payload, &block)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,172 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class Lease
5
+ class << self
6
+ # @rbs (instance: Instance, owner_id: String, now: Time, ?duration: Numeric, database_adapter: DatabaseAdapter) -> Lease?
7
+ def claim(
8
+ instance:,
9
+ owner_id:,
10
+ now:,
11
+ database_adapter:, duration: SolidObjects.configuration.lease_duration
12
+ )
13
+ return if held_by_another_live_owner?(instance, owner_id, now)
14
+
15
+ generation = instance.activation_generation + 1
16
+ expires_at = now + duration
17
+ instance.update!(
18
+ activation_owner_id: owner_id,
19
+ activation_expires_at: expires_at,
20
+ activation_generation: generation,
21
+ activated_at: now,
22
+ last_claimed_at: now
23
+ )
24
+ new(
25
+ instance_id: instance.id,
26
+ owner_id:,
27
+ generation:,
28
+ expires_at:,
29
+ database_adapter:
30
+ )
31
+ end
32
+
33
+ # @rbs (instance_id: Integer, owner_id: String, ?duration: Numeric, ?database_adapter: DatabaseAdapter) -> Lease?
34
+ def acquire(
35
+ instance_id:,
36
+ owner_id:,
37
+ duration: SolidObjects.configuration.lease_duration,
38
+ database_adapter: SolidObjects.database_adapter
39
+ )
40
+ lease = database_adapter.transaction do
41
+ instance = Instance.lock.find(instance_id)
42
+ now = database_adapter.database_now
43
+ claim(instance:, owner_id:, now:, duration:, database_adapter:)
44
+ end
45
+
46
+ if lease
47
+ SolidObjects.instrument(
48
+ :"activation.claimed",
49
+ instance_id:,
50
+ owner_id:,
51
+ generation: lease.generation
52
+ )
53
+ end
54
+
55
+ lease
56
+ end
57
+
58
+ private
59
+
60
+ # @rbs (Instance, String, Time) -> bool
61
+ def held_by_another_live_owner?(instance, owner_id, now)
62
+ instance.activation_owner_id.present? &&
63
+ instance.activation_owner_id != owner_id &&
64
+ instance.activation_expires_at.present? &&
65
+ instance.activation_expires_at > now
66
+ end
67
+ end
68
+
69
+ # @rbs @instance_id: Integer
70
+ # @rbs @owner_id: String
71
+ # @rbs @generation: Integer
72
+ # @rbs @expires_at: Time
73
+ # @rbs @database_adapter: DatabaseAdapter
74
+
75
+ attr_reader :instance_id, :owner_id, :generation, :expires_at
76
+
77
+ # @rbs (instance_id: Integer, owner_id: String, generation: Integer, expires_at: Time, database_adapter: DatabaseAdapter) -> void
78
+ def initialize(instance_id:, owner_id:, generation:, expires_at:, database_adapter:)
79
+ @instance_id = instance_id
80
+ @owner_id = owner_id
81
+ @generation = generation
82
+ @expires_at = expires_at
83
+ @database_adapter = database_adapter
84
+ end
85
+
86
+ # @rbs (?duration: Numeric) -> Lease
87
+ def renew(duration: SolidObjects.configuration.lease_duration)
88
+ renewed_lease = database_adapter.transaction do
89
+ instance = locked_owned_instance!
90
+ now = database_adapter.database_now
91
+ verify_unexpired!(instance, now)
92
+ renewed_expiration = now + duration
93
+ instance.update!(activation_expires_at: renewed_expiration)
94
+
95
+ self.class.new(
96
+ instance_id:,
97
+ owner_id:,
98
+ generation:,
99
+ expires_at: renewed_expiration,
100
+ database_adapter:
101
+ )
102
+ end
103
+ SolidObjects.instrument(
104
+ :"activation.renewed",
105
+ instance_id:,
106
+ owner_id:,
107
+ generation:,
108
+ expires_at: renewed_lease.expires_at
109
+ )
110
+ renewed_lease
111
+ end
112
+
113
+ # @rbs () -> bool
114
+ def release
115
+ released = database_adapter.transaction do
116
+ instance = Instance.lock.find_by(id: instance_id)
117
+ next false unless instance
118
+ next false unless owned_generation?(instance)
119
+
120
+ instance.update!(activation_owner_id: nil, activation_expires_at: nil)
121
+ true
122
+ end
123
+
124
+ if released
125
+ SolidObjects.instrument(
126
+ :"activation.released",
127
+ instance_id:,
128
+ owner_id:,
129
+ generation:
130
+ )
131
+ end
132
+
133
+ released
134
+ end
135
+
136
+ # @rbs () { (Instance) -> untyped } -> untyped
137
+ def fenced_transaction
138
+ database_adapter.transaction do
139
+ instance = locked_owned_instance!
140
+ verify_unexpired!(instance, database_adapter.database_now)
141
+ yield instance
142
+ end
143
+ end
144
+
145
+ private
146
+
147
+ attr_reader :database_adapter
148
+
149
+ # @rbs () -> Instance
150
+ def locked_owned_instance!
151
+ instance = Instance.lock.find_by(id: instance_id)
152
+ raise ActorDestroyed, "actor instance no longer exists" unless instance
153
+
154
+ instance.tap do
155
+ raise LostActivation, "activation owner or generation changed" unless owned_generation?(instance)
156
+ end
157
+ end
158
+
159
+ # @rbs (Instance) -> bool
160
+ def owned_generation?(instance)
161
+ instance.activation_owner_id == owner_id &&
162
+ instance.activation_generation == generation
163
+ end
164
+
165
+ # @rbs (Instance, Time) -> void
166
+ def verify_unexpired!(instance, now)
167
+ return if instance.activation_expires_at && instance.activation_expires_at > now
168
+
169
+ raise LostActivation, "activation lease expired"
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,70 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class LeaseRenewer
5
+ # @rbs @activation: Activation
6
+ # @rbs @process_registry: ProcessRegistry
7
+ # @rbs @mutex: Thread::Mutex
8
+ # @rbs @condition: Thread::ConditionVariable
9
+ # @rbs @stopped: bool
10
+ # @rbs @thread: Thread?
11
+
12
+ # @rbs (activation: Activation, process_registry: ProcessRegistry) -> void
13
+ def initialize(activation:, process_registry:)
14
+ @activation = activation
15
+ @process_registry = process_registry
16
+ @mutex = Thread::Mutex.new
17
+ @condition = Thread::ConditionVariable.new
18
+ @stopped = false
19
+ @thread = nil
20
+ end
21
+
22
+ # @rbs () { () -> untyped } -> untyped
23
+ def around
24
+ start
25
+ yield
26
+ ensure
27
+ stop
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :activation, :process_registry, :mutex, :condition
33
+
34
+ # @rbs () -> void
35
+ def start
36
+ @thread = Thread.new do
37
+ loop do
38
+ break if wait_for_interval
39
+
40
+ activation.renew_lease
41
+ process_registry.heartbeat
42
+ rescue LostActivation
43
+ break
44
+ end
45
+ end
46
+ end
47
+
48
+ # @rbs () -> bool
49
+ def wait_for_interval
50
+ mutex.synchronize do
51
+ unless @stopped
52
+ condition.wait(
53
+ mutex,
54
+ SolidObjects.configuration.lease_renewal_interval
55
+ )
56
+ end
57
+ @stopped
58
+ end
59
+ end
60
+
61
+ # @rbs () -> void
62
+ def stop
63
+ mutex.synchronize do
64
+ @stopped = true
65
+ condition.broadcast
66
+ end
67
+ @thread&.join
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,29 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ module LogSubscriber
5
+ class << self
6
+ # @rbs () -> void
7
+ def install
8
+ return if @subscription
9
+
10
+ @subscription = ActiveSupport::Notifications.subscribe(/\Asolid_objects\./) do |event|
11
+ SolidObjects.configuration.logger.info(
12
+ {
13
+ event: event.name,
14
+ duration_ms: event.duration.round(2)
15
+ }.merge(event.payload)
16
+ )
17
+ end
18
+ end
19
+
20
+ # @rbs () -> void
21
+ def uninstall
22
+ return unless @subscription
23
+
24
+ ActiveSupport::Notifications.unsubscribe(@subscription)
25
+ @subscription = nil
26
+ end
27
+ end
28
+ end
29
+ end