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,271 @@
1
+ # rbs_inline: enabled
2
+
3
+ require "active_record"
4
+ require "benchmark"
5
+ require "fileutils"
6
+ require "solid_objects"
7
+
8
+ module SolidObjectsBenchmark
9
+ DATABASE_PATH = File.expand_path("../tmp/solid_objects_benchmark.sqlite3", __dir__)
10
+
11
+ class CounterActor < SolidObjects::Actor
12
+ actor_type "benchmark-counter"
13
+
14
+ attribute :count, default: 0
15
+
16
+ message :increment do
17
+ state.count += 1
18
+ end
19
+
20
+ query :count do
21
+ state.count
22
+ end
23
+ end
24
+
25
+ class << self
26
+ # @rbs () -> Integer
27
+ def count
28
+ Integer(ENV.fetch("COUNT", "500"))
29
+ end
30
+
31
+ # @rbs () -> Integer
32
+ def concurrency
33
+ Integer(ENV.fetch("CONCURRENCY", "4"))
34
+ end
35
+
36
+ # @rbs () -> void
37
+ def setup
38
+ establish_connection
39
+ migrate
40
+ load_models
41
+ SolidObjects.configuration.logger = Logger.new(nil)
42
+ SolidObjects.configuration.authorize_message = ->(**) { true }
43
+ SolidObjects.configuration.authorize_query = ->(**) { true }
44
+ end
45
+
46
+ # @rbs () -> void
47
+ def teardown
48
+ ActiveRecord::Base.connection_pool.disconnect!
49
+ FileUtils.rm_f(DATABASE_PATH) unless ENV["SOLID_OBJECTS_DATABASE_URL"]
50
+ end
51
+
52
+ # @rbs (String) { () -> untyped } -> Float
53
+ def measure(name)
54
+ elapsed = Benchmark.realtime { yield }
55
+ throughput = count / elapsed
56
+ puts "#{name}: #{format("%.3f", elapsed)}s, #{format("%.1f", throughput)} operations/s"
57
+ elapsed
58
+ end
59
+
60
+ # @rbs () -> void
61
+ def enqueue
62
+ reference = CounterActor.ref("enqueue")
63
+ measure("enqueue #{count} messages") do
64
+ count.times { reference.tell(:increment) }
65
+ end
66
+ end
67
+
68
+ # @rbs () -> void
69
+ def claim
70
+ count.times { |index| CounterActor.ref("claim-#{index}").tell(:increment) }
71
+ process_registry = SolidObjects::ProcessRegistry.new
72
+ owner_id = process_registry.register.id
73
+ activation_manager = SolidObjects::ActivationManager.new(owner_id:)
74
+ claimed_instance_ids = {}
75
+
76
+ measure("claim #{count} actors") do
77
+ count.times do
78
+ activation = activation_manager.claim_next
79
+ raise "actor was not claimable" unless activation
80
+ instance_id = activation.lease.instance_id
81
+ raise "actor was claimed twice" if claimed_instance_ids[instance_id]
82
+
83
+ claimed_instance_ids[instance_id] = true
84
+ SolidObjects::ReadyMessage.where(instance_id:).delete_all
85
+ activation.deactivate
86
+ end
87
+ end
88
+ ensure
89
+ process_registry&.stop
90
+ end
91
+
92
+ # @rbs () -> void
93
+ def processing
94
+ enqueue_round_robin
95
+ worker = SolidObjects::Worker.new
96
+ measure("process #{count} messages") { drain(worker) }
97
+ ensure
98
+ worker&.stop
99
+ end
100
+
101
+ # @rbs () -> void
102
+ def cold_actors
103
+ count.times { |index| CounterActor.ref("cold-#{index}").tell(:increment) }
104
+ worker = SolidObjects::Worker.new
105
+ measure("process #{count} cold actors") { drain(worker) }
106
+ ensure
107
+ worker&.stop
108
+ end
109
+
110
+ # @rbs () -> void
111
+ def hot_actor
112
+ reference = CounterActor.ref("hot")
113
+ count.times { reference.tell(:increment) }
114
+ worker = SolidObjects::Worker.new
115
+ measure("process #{count} messages for one hot actor") { drain(worker) }
116
+ ensure
117
+ worker&.stop
118
+ end
119
+
120
+ # @rbs () -> void
121
+ def concurrent_actors
122
+ enqueue_round_robin
123
+ workers = Array.new(concurrency) { SolidObjects::Worker.new }
124
+ measure("process #{count} messages with #{concurrency} workers") do
125
+ threads = workers.map { |worker| Thread.new { drain(worker) } }
126
+ threads.each(&:join)
127
+ end
128
+ ensure
129
+ workers&.each(&:stop)
130
+ end
131
+
132
+ # @rbs () -> void
133
+ def ask_latency
134
+ worker = SolidObjects::Worker.new
135
+ worker_thread = Thread.new { worker.run }
136
+ samples = []
137
+
138
+ count.times do |index|
139
+ started_at = monotonic_now
140
+ CounterActor.ref("ask-#{index}").ask(:count, timeout: 5)
141
+ samples << monotonic_now - started_at
142
+ end
143
+
144
+ sorted = samples.sort
145
+ puts "ask #{count} calls: p50=#{milliseconds(percentile(sorted, 0.50))}ms " \
146
+ "p95=#{milliseconds(percentile(sorted, 0.95))}ms " \
147
+ "p99=#{milliseconds(percentile(sorted, 0.99))}ms"
148
+ ensure
149
+ worker&.request_shutdown
150
+ worker_thread&.join
151
+ end
152
+
153
+ # @rbs () -> void
154
+ def activation_cache
155
+ reference = CounterActor.ref("cache")
156
+ count.times { reference.tell(:increment) }
157
+ activations = 0
158
+ subscriber = ActiveSupport::Notifications.subscribe("solid_objects.activation.started") do
159
+ activations += 1
160
+ end
161
+ worker = SolidObjects::Worker.new
162
+ processed = drain(worker)
163
+ hit_rate = processed.zero? ? 0.0 : 1.0 - (activations.to_f / processed)
164
+ puts "activation cache: #{processed} messages, #{activations} activations, " \
165
+ "#{format("%.1f", hit_rate * 100)}% reuse"
166
+ ensure
167
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
168
+ worker&.stop
169
+ end
170
+
171
+ # @rbs () -> void
172
+ def query_count
173
+ CounterActor.ref("queries").tell(:increment)
174
+ worker = SolidObjects::Worker.new
175
+ queries = 0
176
+ subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |event|
177
+ next if %w[SCHEMA TRANSACTION].include?(event.payload[:name])
178
+ next if event.payload[:cached]
179
+
180
+ queries += 1
181
+ end
182
+ processed = worker.run_once
183
+ puts "database queries: #{queries} for #{processed} message"
184
+ ensure
185
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
186
+ worker&.stop
187
+ end
188
+
189
+ private
190
+
191
+ # @rbs () -> void
192
+ def establish_connection
193
+ database_url = ENV["SOLID_OBJECTS_DATABASE_URL"]
194
+ FileUtils.mkdir_p(File.dirname(DATABASE_PATH))
195
+ FileUtils.rm_f(DATABASE_PATH) unless database_url
196
+ ActiveRecord::Base.establish_connection(
197
+ database_url || {
198
+ adapter: "sqlite3",
199
+ database: DATABASE_PATH,
200
+ pool: concurrency + 5,
201
+ timeout: 5_000
202
+ }
203
+ )
204
+ ActiveRecord::Migration.verbose = false
205
+ end
206
+
207
+ # @rbs () -> void
208
+ def migrate
209
+ require_relative "../db/migrate/20260805000000_create_solid_objects_tables"
210
+ CreateSolidObjectsTables.new.migrate(:up)
211
+ end
212
+
213
+ # @rbs () -> void
214
+ def load_models
215
+ %w[
216
+ record
217
+ process
218
+ instance
219
+ message
220
+ ready_message
221
+ claimed_message
222
+ reminder
223
+ effect
224
+ broadcast
225
+ dead_letter
226
+ ].each do |model|
227
+ require_relative "../app/models/solid_objects/#{model}"
228
+ end
229
+ end
230
+
231
+ # @rbs () -> void
232
+ def enqueue_round_robin
233
+ actor_count = [ concurrency * 10, count ].min
234
+ references = Array.new(actor_count) { |index| CounterActor.ref("actor-#{index}") }
235
+ count.times { |index| references[index % actor_count].tell(:increment) }
236
+ end
237
+
238
+ # @rbs (SolidObjects::Worker) -> Integer
239
+ def drain(worker)
240
+ processed = 0
241
+ idle_passes = 0
242
+
243
+ while SolidObjects::ReadyMessage.exists? || SolidObjects::ClaimedMessage.exists?
244
+ pass_count = worker.run_once
245
+ processed += pass_count
246
+ idle_passes = pass_count.zero? ? idle_passes + 1 : 0
247
+ raise "benchmark made no progress" if idle_passes >= 1_000
248
+ end
249
+
250
+ processed
251
+ end
252
+
253
+ # @rbs () -> Float
254
+ def monotonic_now
255
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
256
+ end
257
+
258
+ # @rbs (Array[Float], Float) -> Float
259
+ def percentile(samples, fraction)
260
+ samples.fetch(((samples.length - 1) * fraction).ceil)
261
+ end
262
+
263
+ # @rbs (Float) -> String
264
+ def milliseconds(seconds)
265
+ format("%.1f", seconds * 1_000)
266
+ end
267
+ end
268
+ end
269
+
270
+ SolidObjectsBenchmark.setup
271
+ at_exit { SolidObjectsBenchmark.teardown }
data/config/routes.rb ADDED
@@ -0,0 +1,8 @@
1
+ # rbs_inline: enabled
2
+
3
+ SolidObjects::Engine.routes.draw do
4
+ resources :instances, only: %i[index show]
5
+ resources :dead_letters, only: %i[index] do
6
+ post :retry, on: :member
7
+ end
8
+ end
@@ -0,0 +1,319 @@
1
+ # rbs_inline: enabled
2
+
3
+ class CreateSolidObjectsTables < ActiveRecord::Migration[8.0]
4
+ # @rbs () -> void
5
+ def change
6
+ create_processes
7
+ create_instances
8
+ create_messages
9
+ create_message_memberships
10
+ create_reminders
11
+ create_effects
12
+ create_broadcasts
13
+ create_dead_letters
14
+ end
15
+
16
+ private
17
+
18
+ # @rbs (Symbol) -> String
19
+ def table(name)
20
+ SolidObjects.table_name(name)
21
+ end
22
+
23
+ # @rbs () -> Symbol
24
+ def json_column_type
25
+ connection.adapter_name.match?(/postgres/i) ? :jsonb : :json
26
+ end
27
+
28
+ # @rbs (untyped, Symbol, ?null: bool) -> void
29
+ def json_column(definition, name, null: true)
30
+ definition.public_send(json_column_type, name, null:)
31
+ end
32
+
33
+ # @rbs () -> void
34
+ def create_processes
35
+ create_table table(:processes), id: :string, limit: 36 do |definition|
36
+ definition.string :kind, null: false, limit: 64
37
+ definition.string :hostname, null: false, limit: 255
38
+ definition.bigint :pid, null: false
39
+ definition.datetime :started_at, null: false, precision: 6
40
+ definition.datetime :last_heartbeat_at, null: false, precision: 6
41
+ json_column definition, :metadata, null: false
42
+ definition.string :shutdown_state, null: false, default: "running", limit: 32
43
+ definition.datetime :shutdown_requested_at, precision: 6
44
+ definition.datetime :stopped_at, precision: 6
45
+ definition.timestamps precision: 6, null: false
46
+
47
+ definition.index [ :shutdown_state, :last_heartbeat_at ], name: "idx_so_processes_liveness"
48
+ definition.index [ :kind, :shutdown_state ], name: "idx_so_processes_kind"
49
+ definition.check_constraint "shutdown_state IN ('running', 'draining', 'stopped')",
50
+ name: "chk_so_process_shutdown"
51
+ end
52
+ end
53
+
54
+ # @rbs () -> void
55
+ def create_instances
56
+ create_table table(:instances) do |definition|
57
+ definition.string :actor_type, null: false, limit: 191
58
+ definition.string :actor_id, null: false, limit: 191
59
+ json_column definition, :state, null: false
60
+ definition.integer :state_version, null: false, default: 1
61
+ definition.bigint :next_message_sequence, null: false, default: 1
62
+ definition.string :activation_owner_id, limit: 36
63
+ definition.datetime :activation_expires_at, precision: 6
64
+ definition.bigint :activation_generation, null: false, default: 0
65
+ definition.datetime :activated_at, precision: 6
66
+ definition.datetime :last_used_at, precision: 6
67
+ definition.datetime :last_claimed_at, precision: 6
68
+ definition.datetime :paused_at, precision: 6
69
+ definition.timestamps precision: 6, null: false
70
+
71
+ definition.index [ :actor_type, :actor_id ], unique: true, name: "idx_so_instances_identity"
72
+ definition.index [ :activation_expires_at, :last_claimed_at, :id ], name: "idx_so_instances_lease"
73
+ definition.index :activation_owner_id, name: "idx_so_instances_owner"
74
+ definition.index [ :last_used_at, :id ], name: "idx_so_instances_cleanup"
75
+ definition.check_constraint "state_version > 0", name: "chk_so_instances_state_version"
76
+ definition.check_constraint "next_message_sequence > 0", name: "chk_so_instances_sequence"
77
+ definition.check_constraint "activation_generation >= 0", name: "chk_so_instances_generation"
78
+ end
79
+
80
+ add_foreign_key table(:instances),
81
+ table(:processes),
82
+ column: :activation_owner_id,
83
+ on_delete: :nullify,
84
+ name: "fk_so_instances_owner"
85
+ end
86
+
87
+ # @rbs () -> void
88
+ def create_messages
89
+ create_table table(:messages) do |definition|
90
+ definition.references :instance,
91
+ null: false,
92
+ foreign_key: { to_table: table(:instances), on_delete: :cascade, name: "fk_so_messages_instance" }
93
+ definition.string :actor_type, null: false, limit: 191
94
+ definition.string :actor_id, null: false, limit: 191
95
+ definition.string :message_name, null: false, limit: 191
96
+ definition.string :message_kind, null: false, limit: 32
97
+ json_column definition, :arguments, null: false
98
+ definition.bigint :sequence, null: false
99
+ definition.integer :attempt_count, null: false, default: 0
100
+ definition.integer :max_attempts, null: false
101
+ definition.string :request_id, null: false, limit: 36
102
+ definition.string :idempotency_key, limit: 191
103
+ json_column definition, :result
104
+ json_column definition, :error
105
+ definition.datetime :enqueued_at, null: false, precision: 6
106
+ definition.datetime :available_at, null: false, precision: 6
107
+ definition.datetime :started_at, precision: 6
108
+ definition.datetime :completed_at, precision: 6
109
+ definition.datetime :last_failed_at, precision: 6
110
+ definition.timestamps precision: 6, null: false
111
+
112
+ definition.index [ :instance_id, :sequence ], unique: true, name: "idx_so_messages_instance_sequence"
113
+ definition.index [ :actor_type, :actor_id, :sequence ], unique: true, name: "idx_so_messages_actor_sequence"
114
+ definition.index :request_id, unique: true, name: "idx_so_messages_request"
115
+ definition.index [ :instance_id, :idempotency_key ], unique: true, name: "idx_so_messages_idempotency"
116
+ definition.index [ :completed_at, :id ], name: "idx_so_messages_cleanup"
117
+ definition.check_constraint "sequence > 0", name: "chk_so_messages_sequence"
118
+ definition.check_constraint "attempt_count >= 0", name: "chk_so_messages_attempt"
119
+ definition.check_constraint "max_attempts > 0", name: "chk_so_messages_max_attempts"
120
+ definition.check_constraint "message_kind IN ('tell', 'ask', 'internal')", name: "chk_so_messages_kind"
121
+ end
122
+ end
123
+
124
+ # @rbs () -> void
125
+ def create_message_memberships
126
+ create_table table(:ready_messages) do |definition|
127
+ definition.references :message,
128
+ null: false,
129
+ foreign_key: { to_table: table(:messages), on_delete: :cascade, name: "fk_so_ready_message" }
130
+ definition.references :instance,
131
+ null: false,
132
+ foreign_key: { to_table: table(:instances), on_delete: :cascade, name: "fk_so_ready_instance" }
133
+ definition.bigint :sequence, null: false
134
+ definition.datetime :available_at, null: false, precision: 6
135
+ definition.datetime :created_at, null: false, precision: 6
136
+
137
+ definition.index :message_id, unique: true, name: "idx_so_ready_message"
138
+ definition.index [ :instance_id, :sequence ], unique: true, name: "idx_so_ready_instance_sequence"
139
+ definition.index [ :available_at, :instance_id, :sequence ], name: "idx_so_ready_poll"
140
+ definition.check_constraint "sequence > 0", name: "chk_so_ready_sequence"
141
+ end
142
+
143
+ create_table table(:claimed_messages) do |definition|
144
+ definition.references :message,
145
+ null: false,
146
+ foreign_key: { to_table: table(:messages), on_delete: :cascade, name: "fk_so_claimed_message" }
147
+ definition.references :instance,
148
+ null: false,
149
+ foreign_key: { to_table: table(:instances), on_delete: :cascade, name: "fk_so_claimed_instance" }
150
+ definition.string :process_id, limit: 36
151
+ definition.bigint :activation_generation, null: false
152
+ definition.datetime :claimed_at, null: false, precision: 6
153
+
154
+ definition.index :message_id, unique: true, name: "idx_so_claimed_message"
155
+ definition.index :instance_id, unique: true, name: "idx_so_claimed_instance"
156
+ definition.index [ :process_id, :claimed_at ], name: "idx_so_claimed_process"
157
+ definition.check_constraint "activation_generation > 0", name: "chk_so_claimed_generation"
158
+ end
159
+
160
+ add_foreign_key table(:claimed_messages),
161
+ table(:processes),
162
+ column: :process_id,
163
+ on_delete: :nullify,
164
+ name: "fk_so_claimed_process"
165
+ end
166
+
167
+ # @rbs () -> void
168
+ def create_reminders
169
+ create_table table(:reminders) do |definition|
170
+ definition.references :instance,
171
+ null: false,
172
+ foreign_key: { to_table: table(:instances), on_delete: :cascade, name: "fk_so_reminders_instance" }
173
+ definition.string :actor_type, null: false, limit: 191
174
+ definition.string :actor_id, null: false, limit: 191
175
+ definition.string :name, null: false, limit: 191
176
+ definition.string :message_name, null: false, limit: 191
177
+ json_column definition, :arguments, null: false
178
+ definition.datetime :next_run_at, null: false, precision: 6
179
+ definition.decimal :interval_seconds, precision: 20, scale: 6
180
+ definition.bigint :occurrence, null: false, default: 0
181
+ definition.string :missed_policy, null: false, default: "latest", limit: 32
182
+ definition.string :status, null: false, default: "scheduled", limit: 32
183
+ definition.string :claimed_by, limit: 36
184
+ definition.datetime :claimed_at, precision: 6
185
+ definition.timestamps precision: 6, null: false
186
+
187
+ definition.index [ :instance_id, :name ], unique: true, name: "idx_so_reminders_name"
188
+ definition.index [ :status, :next_run_at, :id ], name: "idx_so_reminders_due"
189
+ definition.check_constraint "occurrence >= 0", name: "chk_so_reminders_occurrence"
190
+ definition.check_constraint "interval_seconds IS NULL OR interval_seconds > 0",
191
+ name: "chk_so_reminders_interval"
192
+ definition.check_constraint "missed_policy IN ('latest', 'all')",
193
+ name: "chk_so_reminders_missed"
194
+ definition.check_constraint "status IN ('scheduled', 'paused', 'completed')", name: "chk_so_reminders_status"
195
+ end
196
+
197
+ add_foreign_key table(:reminders),
198
+ table(:processes),
199
+ column: :claimed_by,
200
+ on_delete: :nullify,
201
+ name: "fk_so_reminders_process"
202
+ end
203
+
204
+ # @rbs () -> void
205
+ def create_effects
206
+ create_table table(:effects) do |definition|
207
+ definition.references :message,
208
+ null: false,
209
+ foreign_key: { to_table: table(:messages), on_delete: :cascade, name: "fk_so_effects_message" }
210
+ definition.references :instance,
211
+ null: false,
212
+ foreign_key: { to_table: table(:instances), on_delete: :cascade, name: "fk_so_effects_instance" }
213
+ definition.string :effect_id, null: false, limit: 36
214
+ definition.string :name, null: false, limit: 191
215
+ json_column definition, :arguments, null: false
216
+ definition.string :success_message_name, limit: 191
217
+ definition.string :failure_message_name, limit: 191
218
+ definition.string :status, null: false, default: "pending", limit: 32
219
+ definition.integer :attempt_count, null: false, default: 0
220
+ definition.integer :max_attempts, null: false
221
+ definition.datetime :available_at, null: false, precision: 6
222
+ definition.string :claimed_by, limit: 36
223
+ definition.datetime :claimed_at, precision: 6
224
+ json_column definition, :result
225
+ json_column definition, :error
226
+ definition.datetime :completed_at, precision: 6
227
+ definition.timestamps precision: 6, null: false
228
+
229
+ definition.index :effect_id, unique: true, name: "idx_so_effects_effect_id"
230
+ definition.index [ :status, :available_at, :id ], name: "idx_so_effects_poll"
231
+ definition.index [ :completed_at, :id ], name: "idx_so_effects_cleanup"
232
+ definition.check_constraint "attempt_count >= 0", name: "chk_so_effects_attempt"
233
+ definition.check_constraint "max_attempts > 0", name: "chk_so_effects_max_attempts"
234
+ definition.check_constraint "status IN ('pending', 'processing', 'completed', 'dead')", name: "chk_so_effects_status"
235
+ end
236
+
237
+ add_foreign_key table(:effects),
238
+ table(:processes),
239
+ column: :claimed_by,
240
+ on_delete: :nullify,
241
+ name: "fk_so_effects_process"
242
+ end
243
+
244
+ # @rbs () -> void
245
+ def create_broadcasts
246
+ create_table table(:broadcasts) do |definition|
247
+ definition.references :message,
248
+ null: false,
249
+ foreign_key: { to_table: table(:messages), on_delete: :cascade, name: "fk_so_broadcasts_message" }
250
+ definition.references :instance,
251
+ null: false,
252
+ foreign_key: { to_table: table(:instances), on_delete: :cascade, name: "fk_so_broadcasts_instance" }
253
+ definition.string :broadcast_id, null: false, limit: 36
254
+ definition.string :observable_name, null: false, limit: 191
255
+ json_column definition, :value, null: false
256
+ definition.integer :state_version, null: false
257
+ definition.bigint :activation_generation, null: false
258
+ definition.string :status, null: false, default: "pending", limit: 32
259
+ definition.integer :attempt_count, null: false, default: 0
260
+ definition.datetime :available_at, null: false, precision: 6
261
+ definition.string :claimed_by, limit: 36
262
+ definition.datetime :claimed_at, precision: 6
263
+ json_column definition, :error
264
+ definition.datetime :delivered_at, precision: 6
265
+ definition.timestamps precision: 6, null: false
266
+
267
+ definition.index :broadcast_id, unique: true, name: "idx_so_broadcasts_id"
268
+ definition.index [ :message_id, :observable_name ], unique: true, name: "idx_so_broadcasts_observable"
269
+ definition.index [ :status, :available_at, :id ], name: "idx_so_broadcasts_poll"
270
+ definition.index [ :claimed_by, :claimed_at ], name: "idx_so_broadcasts_claim"
271
+ definition.index [ :delivered_at, :id ], name: "idx_so_broadcasts_cleanup"
272
+ definition.check_constraint "state_version > 0", name: "chk_so_broadcasts_version"
273
+ definition.check_constraint "activation_generation > 0", name: "chk_so_broadcasts_generation"
274
+ definition.check_constraint "attempt_count >= 0", name: "chk_so_broadcasts_attempt"
275
+ definition.check_constraint "status IN ('pending', 'processing', 'delivered', 'dead')", name: "chk_so_broadcasts_status"
276
+ end
277
+
278
+ add_foreign_key table(:broadcasts),
279
+ table(:processes),
280
+ column: :claimed_by,
281
+ on_delete: :nullify,
282
+ name: "fk_so_broadcast_process"
283
+ end
284
+
285
+ # @rbs () -> void
286
+ def create_dead_letters
287
+ create_table table(:dead_letters) do |definition|
288
+ definition.references :message,
289
+ null: false,
290
+ foreign_key: { to_table: table(:messages), on_delete: :cascade, name: "fk_so_dead_letters_message" }
291
+ definition.references :instance,
292
+ null: false,
293
+ foreign_key: { to_table: table(:instances), on_delete: :cascade, name: "fk_so_dead_letters_instance" }
294
+ definition.string :actor_type, null: false, limit: 191
295
+ definition.string :actor_id, null: false, limit: 191
296
+ definition.string :message_name, null: false, limit: 191
297
+ json_column definition, :arguments, null: false
298
+ definition.integer :attempts, null: false
299
+ definition.string :exception_class, null: false, limit: 255
300
+ definition.text :exception_message, null: false
301
+ json_column definition, :backtrace, null: false
302
+ definition.datetime :first_failed_at, null: false, precision: 6
303
+ definition.datetime :last_failed_at, null: false, precision: 6
304
+ definition.bigint :retried_message_id
305
+ definition.timestamps precision: 6, null: false
306
+
307
+ definition.index :message_id, unique: true, name: "idx_so_dead_letters_message"
308
+ definition.index [ :actor_type, :actor_id, :last_failed_at ], name: "idx_so_dead_letters_actor"
309
+ definition.index [ :last_failed_at, :id ], name: "idx_so_dead_letters_cleanup"
310
+ definition.check_constraint "attempts > 0", name: "chk_so_dead_letters_attempts"
311
+ end
312
+
313
+ add_foreign_key table(:dead_letters),
314
+ table(:messages),
315
+ column: :retried_message_id,
316
+ on_delete: :nullify,
317
+ name: "fk_so_dead_letters_retry"
318
+ end
319
+ end
@@ -0,0 +1,21 @@
1
+ # ADR 0001: PostgreSQL Is the Initial Backend
2
+
3
+ - Status: Superseded by ADR 0013
4
+ - Date: 2026-08-05
5
+
6
+ ## Context
7
+
8
+ The runtime needs durable mailboxes, atomic state/message/outbox commits, concurrent worker claiming, small execution-membership tables, safe row locks, and fencing predicates. Supporting the least-common SQL feature set would weaken or complicate those guarantees.
9
+
10
+ ## Decision
11
+
12
+ Solid Objects originally planned to support PostgreSQL only. It would use the host application's PostgreSQL environment by default and could use a separate Rails database configuration. Every table participating in one actor commit would be in the same database.
13
+
14
+ This decision was superseded when the supported backend scope expanded to MySQL, PostgreSQL, and SQLite. PostgreSQL remains the optimized reference backend.
15
+
16
+ ## Consequences
17
+
18
+ - Redis, Kafka, and a separate database server are not required.
19
+ - SQLite and MySQL require their own verified coordination implementations.
20
+ - PostgreSQL integration tests are part of correctness, not an optional adapter test.
21
+ - Cross-database transactions are unsupported.
@@ -0,0 +1,21 @@
1
+ # ADR 0002: Actor State Uses Native JSON Storage
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-08-05
5
+
6
+ ## Context
7
+
8
+ Actor state must be durable, inspectable, safely serialized, and evolvable without creating a table per actor type.
9
+
10
+ ## Decision
11
+
12
+ Each actor instance stores one native JSON state document and an integer state version. PostgreSQL uses JSONB; MySQL uses JSON; SQLite uses the Rails JSON type over SQLite storage. State and all message-related values are limited to JSON-compatible primitives, arrays, and objects with string keys. Serialization is validated before persistence.
13
+
14
+ Actors declare attributes, defaults, a current state version, and explicit one-step migrations. Migrations run in memory before a message executes and persist only with a successful fenced message commit.
15
+
16
+ ## Consequences
17
+
18
+ - No `Marshal` or arbitrary object deserialization is allowed.
19
+ - Large or query-heavy domain data should remain in normalized application tables, not actor state.
20
+ - State migrations are application code and must remain compatible during rolling deployments.
21
+ - Serializer extension points must still produce trusted JSON-compatible representations.
@@ -0,0 +1,30 @@
1
+ # ADR 0003: Explicit Per-Actor Mailbox Sequences
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-08-05
5
+
6
+ ## Context
7
+
8
+ Timestamps do not provide a unique order under concurrency. Independent message claims can also let later messages overtake an earlier retry.
9
+
10
+ ## Decision
11
+
12
+ Every actor instance owns `next_message_sequence`. Enqueue runs in one transaction that locks or atomically updates the instance, allocates the next number, inserts the durable message, and inserts its ready-membership row. The database enforces uniqueness of actor identity plus sequence.
13
+
14
+ Execution state is represented by table membership, not a status column:
15
+
16
+ - `solid_objects_messages` is durable message history and contains the envelope, attempts, timestamps, result, and latest error.
17
+ - `solid_objects_ready_messages` is the small hot set of messages eligible at `available_at`.
18
+ - `solid_objects_claimed_messages` is the small set currently assigned to an activation owner and fencing generation.
19
+ - `solid_objects_dead_letters` is the terminal failed set and diagnostic record.
20
+
21
+ The earliest nonterminal sequence is the only eligible message for an actor. Moving a message between ready, claimed, and dead-letter membership occurs in short transactions that lock the durable message. A retryable failure moves the message back to ready with a later availability time and blocks later messages. A dead letter no longer blocks the mailbox.
22
+
23
+ ## Consequences
24
+
25
+ - Concurrent enqueue has a deterministic committed order.
26
+ - Sequence allocation is a per-actor serialization point.
27
+ - Hot actors cannot increase enqueue throughput by adding workers.
28
+ - Polling and claiming indexes stay proportional to live executable work, not completed history.
29
+ - Cross-table membership is an application invariant maintained under message and activation locks because SQL cannot express one unique constraint across several tables.
30
+ - Administrative retry of a dead letter creates a new mailbox message with a new sequence and links it to the dead letter.