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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +17 -0
- data/MIT-LICENSE +19 -0
- data/README.md +744 -0
- data/Rakefile +40 -0
- data/app/controllers/solid_objects/application_controller.rb +23 -0
- data/app/controllers/solid_objects/dead_letters_controller.rb +23 -0
- data/app/controllers/solid_objects/instances_controller.rb +29 -0
- data/app/helpers/solid_objects/actor_helper.rb +25 -0
- data/app/models/solid_objects/broadcast.rb +10 -0
- data/app/models/solid_objects/claimed_message.rb +14 -0
- data/app/models/solid_objects/dead_letter.rb +13 -0
- data/app/models/solid_objects/effect.rb +10 -0
- data/app/models/solid_objects/instance.rb +93 -0
- data/app/models/solid_objects/message.rb +53 -0
- data/app/models/solid_objects/process.rb +13 -0
- data/app/models/solid_objects/ready_message.rb +10 -0
- data/app/models/solid_objects/record.rb +17 -0
- data/app/models/solid_objects/reminder.rb +9 -0
- data/app/views/solid_objects/dead_letters/index.html.erb +26 -0
- data/app/views/solid_objects/instances/index.html.erb +24 -0
- data/app/views/solid_objects/instances/show.html.erb +35 -0
- data/benchmark/activation_cache.rb +5 -0
- data/benchmark/ask_latency.rb +5 -0
- data/benchmark/claim.rb +5 -0
- data/benchmark/cold_actors.rb +5 -0
- data/benchmark/concurrent_actors.rb +5 -0
- data/benchmark/enqueue.rb +5 -0
- data/benchmark/hot_actor.rb +5 -0
- data/benchmark/processing.rb +5 -0
- data/benchmark/query_count.rb +5 -0
- data/benchmark/support.rb +271 -0
- data/config/routes.rb +8 -0
- data/db/migrate/20260805000000_create_solid_objects_tables.rb +319 -0
- data/docs/adr/0001-postgresql-backend.md +21 -0
- data/docs/adr/0002-jsonb-actor-state.md +21 -0
- data/docs/adr/0003-mailbox-ordering.md +30 -0
- data/docs/adr/0004-activation-leasing.md +21 -0
- data/docs/adr/0005-fencing-tokens.md +25 -0
- data/docs/adr/0006-at-least-once-delivery.md +24 -0
- data/docs/adr/0007-transactional-outbox.md +21 -0
- data/docs/adr/0008-actor-communication.md +21 -0
- data/docs/adr/0009-realtime-updates.md +21 -0
- data/docs/adr/0010-state-versioning.md +29 -0
- data/docs/adr/0011-wake-up-strategy.md +34 -0
- data/docs/adr/0012-not-active-jobs.md +21 -0
- data/docs/adr/0013-database-adapters.md +48 -0
- data/docs/architecture.md +615 -0
- data/docs/benchmarks.md +26 -0
- data/docs/correctness.md +124 -0
- data/docs/database-schema.md +111 -0
- data/docs/development.md +87 -0
- data/docs/implementation-plan.md +518 -0
- data/docs/operations.md +123 -0
- data/docs/realtime.md +51 -0
- data/docs/research/solid_queue.md +545 -0
- data/docs/roadmap.md +53 -0
- data/docs/security.md +61 -0
- data/docs/state-migrations.md +46 -0
- data/examples/application/README.md +16 -0
- data/examples/application/app/actors/chat_room_actor.rb +34 -0
- data/examples/application/app/actors/shopping_cart_actor.rb +79 -0
- data/examples/application/app/controllers/cart_controller.rb +54 -0
- data/examples/application/app/controllers/chat_rooms_controller.rb +44 -0
- data/examples/application/app/views/actors/chat_room_actor/_messages.html.erb +8 -0
- data/examples/application/app/views/actors/shopping_cart_actor/_summary.html.erb +10 -0
- data/examples/application/app/views/cart/show.html.erb +13 -0
- data/examples/application/app/views/chat_rooms/show.html.erb +8 -0
- data/examples/application/config/initializers/solid_objects.rb +23 -0
- data/examples/application/config/routes.rb +20 -0
- data/exe/solid_objects +9 -0
- data/lib/generators/solid_objects/install_generator.rb +21 -0
- data/lib/generators/solid_objects/templates/solid_objects.rb +13 -0
- data/lib/solid_objects/action_cable_broadcast_adapter.rb +19 -0
- data/lib/solid_objects/activation.rb +183 -0
- data/lib/solid_objects/activation_manager.rb +102 -0
- data/lib/solid_objects/actor.rb +271 -0
- data/lib/solid_objects/actor_channel.rb +29 -0
- data/lib/solid_objects/actor_definition.rb +212 -0
- data/lib/solid_objects/actor_registry.rb +65 -0
- data/lib/solid_objects/actor_snapshot.rb +42 -0
- data/lib/solid_objects/actor_view.rb +117 -0
- data/lib/solid_objects/broadcast_executor.rb +162 -0
- data/lib/solid_objects/cli.rb +118 -0
- data/lib/solid_objects/client.rb +153 -0
- data/lib/solid_objects/configuration.rb +168 -0
- data/lib/solid_objects/context.rb +41 -0
- data/lib/solid_objects/database_adapter.rb +82 -0
- data/lib/solid_objects/database_adapters/mysql.rb +22 -0
- data/lib/solid_objects/database_adapters/postgresql.rb +17 -0
- data/lib/solid_objects/database_adapters/sqlite.rb +12 -0
- data/lib/solid_objects/dead_letter_manager.rb +47 -0
- data/lib/solid_objects/dom_identity.rb +38 -0
- data/lib/solid_objects/effect_executor.rb +235 -0
- data/lib/solid_objects/effect_registry.rb +34 -0
- data/lib/solid_objects/engine.rb +33 -0
- data/lib/solid_objects/errors.rb +65 -0
- data/lib/solid_objects/executor.rb +290 -0
- data/lib/solid_objects/instrumentation.rb +10 -0
- data/lib/solid_objects/lease.rb +172 -0
- data/lib/solid_objects/lease_renewer.rb +70 -0
- data/lib/solid_objects/log_subscriber.rb +29 -0
- data/lib/solid_objects/mailbox.rb +178 -0
- data/lib/solid_objects/message_reference.rb +52 -0
- data/lib/solid_objects/process_registry.rb +143 -0
- data/lib/solid_objects/reference.rb +96 -0
- data/lib/solid_objects/reminder_scheduler.rb +168 -0
- data/lib/solid_objects/serialization.rb +99 -0
- data/lib/solid_objects/state.rb +111 -0
- data/lib/solid_objects/stream_name.rb +29 -0
- data/lib/solid_objects/stream_token.rb +59 -0
- data/lib/solid_objects/supervisor.rb +87 -0
- data/lib/solid_objects/turbo_stream_renderer.rb +35 -0
- data/lib/solid_objects/version.rb +5 -0
- data/lib/solid_objects/wake_up.rb +28 -0
- data/lib/solid_objects/worker.rb +139 -0
- data/lib/solid_objects.rb +118 -0
- data/sig/generated/controllers/solid_objects/application_controller.rbs +10 -0
- data/sig/generated/controllers/solid_objects/dead_letters_controller.rbs +11 -0
- data/sig/generated/controllers/solid_objects/instances_controller.rbs +11 -0
- data/sig/generated/helpers/solid_objects/actor_helper.rbs +8 -0
- data/sig/generated/lib/generators/solid_objects/install_generator.rbs +13 -0
- data/sig/generated/lib/solid_objects/action_cable_broadcast_adapter.rbs +8 -0
- data/sig/generated/lib/solid_objects/activation.rbs +65 -0
- data/sig/generated/lib/solid_objects/activation_manager.rbs +36 -0
- data/sig/generated/lib/solid_objects/actor.rbs +183 -0
- data/sig/generated/lib/solid_objects/actor_channel.rbs +8 -0
- data/sig/generated/lib/solid_objects/actor_definition.rbs +117 -0
- data/sig/generated/lib/solid_objects/actor_registry.rbs +36 -0
- data/sig/generated/lib/solid_objects/actor_snapshot.rbs +28 -0
- data/sig/generated/lib/solid_objects/actor_view.rbs +56 -0
- data/sig/generated/lib/solid_objects/broadcast_executor.rbs +55 -0
- data/sig/generated/lib/solid_objects/cli.rbs +31 -0
- data/sig/generated/lib/solid_objects/client.rbs +35 -0
- data/sig/generated/lib/solid_objects/configuration.rbs +147 -0
- data/sig/generated/lib/solid_objects/context.rbs +56 -0
- data/sig/generated/lib/solid_objects/database_adapter.rbs +42 -0
- data/sig/generated/lib/solid_objects/database_adapters/mysql.rbs +16 -0
- data/sig/generated/lib/solid_objects/database_adapters/postgresql.rbs +13 -0
- data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +10 -0
- data/sig/generated/lib/solid_objects/dead_letter_manager.rbs +16 -0
- data/sig/generated/lib/solid_objects/dom_identity.rbs +20 -0
- data/sig/generated/lib/solid_objects/effect_executor.rbs +82 -0
- data/sig/generated/lib/solid_objects/effect_registry.rbs +24 -0
- data/sig/generated/lib/solid_objects/engine.rbs +7 -0
- data/sig/generated/lib/solid_objects/errors.rbs +64 -0
- data/sig/generated/lib/solid_objects/executor.rbs +60 -0
- data/sig/generated/lib/solid_objects/instrumentation.rbs +8 -0
- data/sig/generated/lib/solid_objects/lease.rbs +57 -0
- data/sig/generated/lib/solid_objects/lease_renewer.rbs +42 -0
- data/sig/generated/lib/solid_objects/log_subscriber.rbs +11 -0
- data/sig/generated/lib/solid_objects/mailbox.rbs +46 -0
- data/sig/generated/lib/solid_objects/message_reference.rbs +37 -0
- data/sig/generated/lib/solid_objects/process_registry.rbs +46 -0
- data/sig/generated/lib/solid_objects/reference.rbs +45 -0
- data/sig/generated/lib/solid_objects/reminder_scheduler.rbs +55 -0
- data/sig/generated/lib/solid_objects/serialization.rbs +31 -0
- data/sig/generated/lib/solid_objects/state.rbs +72 -0
- data/sig/generated/lib/solid_objects/stream_name.rbs +11 -0
- data/sig/generated/lib/solid_objects/stream_token.rbs +19 -0
- data/sig/generated/lib/solid_objects/supervisor.rbs +38 -0
- data/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +14 -0
- data/sig/generated/lib/solid_objects/version.rbs +5 -0
- data/sig/generated/lib/solid_objects/wake_up.rbs +24 -0
- data/sig/generated/lib/solid_objects/worker.rbs +56 -0
- data/sig/generated/lib/solid_objects.rbs +41 -0
- data/sig/generated/models/solid_objects/broadcast.rbs +6 -0
- data/sig/generated/models/solid_objects/claimed_message.rbs +6 -0
- data/sig/generated/models/solid_objects/dead_letter.rbs +6 -0
- data/sig/generated/models/solid_objects/effect.rbs +6 -0
- data/sig/generated/models/solid_objects/instance.rbs +25 -0
- data/sig/generated/models/solid_objects/message.rbs +22 -0
- data/sig/generated/models/solid_objects/process.rbs +6 -0
- data/sig/generated/models/solid_objects/ready_message.rbs +6 -0
- data/sig/generated/models/solid_objects/record.rbs +8 -0
- data/sig/generated/models/solid_objects/reminder.rbs +6 -0
- data/sig/support/framework.rbs +37 -0
- 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,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.
|