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
data/README.md ADDED
@@ -0,0 +1,744 @@
1
+ # Solid Objects
2
+
3
+ [![CI](https://github.com/cardmagic/solid_objects/actions/workflows/ci.yml/badge.svg)](https://github.com/cardmagic/solid_objects/actions/workflows/ci.yml)
4
+
5
+ **Cloudflare Durable Objects, ported to Rails.**
6
+
7
+ Solid Objects brings the Durable Objects programming model—addressable objects,
8
+ durable state, serialized turns, alarms, and live clients—to ordinary Rails
9
+ applications. It runs on the MySQL, PostgreSQL, or SQLite database the
10
+ application already has, following the database-backed operating model of the
11
+ Solid family. No Redis, Cloudflare account, or separate actor service is
12
+ required.
13
+
14
+ ```ruby
15
+ class Counter < SolidObjects::Actor
16
+ attribute :value, default: 0
17
+
18
+ def increment(amount: 1)
19
+ self.value += amount
20
+ end
21
+ end
22
+
23
+ # from anywhere in your app — addressed by name:
24
+ Counter.ref("global").increment(amount: 5)
25
+ ```
26
+
27
+ `Counter / global` is a logical identity. Like a Durable Object named with
28
+ `idFromName`, it can be addressed from anywhere without first creating or
29
+ locating a Ruby object. Solid Objects activates it when work arrives, commits
30
+ its ordered turns one at a time, persists its state, and deactivates it when
31
+ idle. Different identities can run concurrently.
32
+
33
+ This is a port of the programming model, not Cloudflare's edge runtime or
34
+ platform. Read the conceptual overview at [solidobjects.dev](https://solidobjects.dev/)
35
+ and the exact Rails guarantees in [Correctness and delivery semantics](docs/correctness.md).
36
+
37
+ Version 0.1 is an early release. Its correctness core is implemented and tested,
38
+ but the project does not yet claim production readiness. See
39
+ [Status](#status) and the [roadmap](docs/roadmap.md).
40
+
41
+ ## Table of contents
42
+
43
+ - [Cloudflare Durable Objects for Rails](#cloudflare-durable-objects-for-rails)
44
+ - [Reactive ERB](#reactive-erb)
45
+ - [Installation](#installation)
46
+ - [Defining an actor](#defining-an-actor)
47
+ - [Actor identity](#actor-identity)
48
+ - [Messages and queries](#messages-and-queries)
49
+ - [Effects](#effects)
50
+ - [Reminders](#reminders)
51
+ - [Destroying an object](#destroying-an-object)
52
+ - [State migrations](#state-migrations)
53
+ - [Configuration](#configuration)
54
+ - [Workers and operations](#workers-and-operations)
55
+ - [Database support](#database-support)
56
+ - [Guarantees](#guarantees)
57
+ - [When to use it](#when-to-use-it)
58
+ - [Comparisons](#comparisons)
59
+ - [Development](#development)
60
+ - [Status](#status)
61
+ - [License](#license)
62
+
63
+ ## Cloudflare Durable Objects for Rails
64
+
65
+ Cloudflare Durable Objects combine a name, durable storage, serialized
66
+ execution, alarms, and live connections in one stateful object. Solid Objects
67
+ maps those ideas into Rails:
68
+
69
+ | Cloudflare Durable Objects | Solid Objects |
70
+ | --- | --- |
71
+ | Namespace plus `idFromName("id")` | Actor class plus `.ref("id")` |
72
+ | RPC method on a stub | Public Ruby method on a reference |
73
+ | Per-object transactional storage | Declared attributes in native JSON |
74
+ | Single-threaded input handling | Ordered mailbox plus fenced activation |
75
+ | Alarms API | Per-object `schedule` |
76
+ | WebSockets | Reactive ERB over Action Cable and Turbo Streams |
77
+ | Hibernation when idle | Idle activation deactivation |
78
+ | Storage deletion | Authorized `reference.destroy` |
79
+ | Cloudflare Workers platform | Your Rails processes and SQL database |
80
+
81
+ Rails already has excellent tools for jobs, records, and realtime transport.
82
+ None of those primitives alone provides this complete stateful-object shape.
83
+ Solid Objects adds five capabilities:
84
+
85
+ ### Ordered delivery per identity
86
+
87
+ Every enqueue locks the actor instance and allocates an explicit, monotonically
88
+ increasing sequence number. An activation always takes the lowest live sequence
89
+ for that actor. A retryable failure keeps later messages blocked until the
90
+ failed message succeeds or reaches its dead letter.
91
+
92
+ This is stronger than a concurrency limit. Solid Queue's
93
+ [`limits_concurrency`](https://github.com/rails/solid_queue#concurrency-controls)
94
+ caps simultaneous executions sharing a key, but explicitly does not guarantee
95
+ their execution order. Solid Objects turns each actor identity into an ordered
96
+ mailbox.
97
+
98
+ ### Fenced activation
99
+
100
+ A lease expiration by itself cannot stop a paused worker from resuming with
101
+ stale state. Solid Objects combines the lease owner with a monotonically
102
+ increasing activation generation. Every state commit verifies the current
103
+ owner, generation, unexpired database-time lease, and claimed-message
104
+ membership.
105
+
106
+ A stale worker may finish running Ruby code, but it cannot commit stale state,
107
+ complete the message, or publish outbox entries.
108
+
109
+ ### Addressable objects with durable state
110
+
111
+ An actor is addressed by `(actor_type, actor_id)`, not by a process, thread, or
112
+ database row ID. Code anywhere in the application can refer to the same logical
113
+ cart, room, device, or workflow. Its JSON state survives worker restarts and
114
+ idle deactivation.
115
+
116
+ ### Per-object alarms
117
+
118
+ Cloudflare Durable Objects give each object an alarm. Rails recurring schedules
119
+ are normally global task definitions. Solid Objects ports per-object alarms as
120
+ durable reminders owned by one logical identity:
121
+
122
+ ```ruby
123
+ def schedule_expiration
124
+ schedule :expire, at: 30.minutes.from_now, arguments: {}
125
+ end
126
+ ```
127
+
128
+ When due, a reminder becomes an ordinary mailbox message and follows the same
129
+ ordering, retry, lease, and fencing rules as every other turn.
130
+
131
+ ### Durable Objects that render themselves
132
+
133
+ Cloudflare Durable Objects can coordinate WebSocket clients. Solid Objects adds
134
+ a Rails-native extension: an actor observable becomes a live Turbo target with
135
+ one helper call. The actor commit and durable broadcast outbox are atomic, so a
136
+ rolled-back state change cannot leak into the page.
137
+
138
+ ## Reactive ERB
139
+
140
+ Define an observable:
141
+
142
+ ```ruby
143
+ class ShoppingCart < SolidObjects::Actor
144
+ attribute :items, default: -> { [] }
145
+
146
+ observable :items_count do
147
+ items.sum { |item| item.fetch("quantity") }
148
+ end
149
+ end
150
+ ```
151
+
152
+ Render it:
153
+
154
+ ```erb
155
+ <%= solid_object current_cart do |cart| %>
156
+ Cart items: <%= cart.items_count %>
157
+ <% end %>
158
+ ```
159
+
160
+ That template provides initial server rendering, a stable opaque DOM target,
161
+ and live Turbo replacements after committed actor turns. One `solid_object`
162
+ block makes one Action Cable subscription for all values inside it, and Action
163
+ Cable multiplexes subscriptions over the browser's WebSocket.
164
+
165
+ No client-side state store, custom Stimulus controller, channel class, manual
166
+ broadcast, or one-WebSocket-per-value setup is required. Signed stream tokens
167
+ protect integrity, an application policy authorizes every subscription,
168
+ broadcasts are delivered from a durable outbox, and reconnecting clients
169
+ refresh from current actor state.
170
+
171
+ `cart.component(:summary)` supports initial rendering of
172
+ `actors/shopping_cart/_summary`. Durable live component replacement and
173
+ Turbo append actions are roadmap work; observable replacement is the live path
174
+ implemented in 0.1.
175
+
176
+ Reactive views require `turbo-rails` and a working Action Cable adapter in the
177
+ host application. They are optional; the actor runtime itself does not depend
178
+ on Turbo.
179
+
180
+ ## Installation
181
+
182
+ Solid Objects requires Ruby 3.3 or newer and Rails 8.0 or newer.
183
+
184
+ Add the gem, install its initializer and migration, then migrate:
185
+
186
+ ```bash
187
+ bundle add solid_objects
188
+ bin/rails generate solid_objects:install
189
+ bin/rails db:migrate
190
+ ```
191
+
192
+ The generated initializer denies all externally initiated operations. Replace
193
+ the policy blocks with application-specific authorization before sending
194
+ messages, querying state, destroying actors, subscribing to streams, or
195
+ mounting administration routes:
196
+
197
+ ```ruby
198
+ SolidObjects.configure do |configuration|
199
+ configuration.authorize_message = ->(**) { false }
200
+ configuration.authorize_query = ->(**) { false }
201
+ configuration.authorize_destroy = ->(**) { false }
202
+ configuration.authorize_subscription = ->(**) { false }
203
+ configuration.authorize_administration = ->(**) { false }
204
+ end
205
+ ```
206
+
207
+ Knowledge of an actor ID or signed stream token is never authorization.
208
+
209
+ Start the runtime:
210
+
211
+ ```bash
212
+ bundle exec solid_objects start
213
+ ```
214
+
215
+ The engine uses the application's primary Active Record connection by default.
216
+ See [Database support](#database-support) for a separate database configuration.
217
+
218
+ ## Defining an actor
219
+
220
+ The Durable Object class becomes an ordinary Ruby class:
221
+
222
+ ```ruby
223
+ class ShoppingCart < SolidObjects::Actor
224
+ attribute :items, default: -> { [] }
225
+ attribute :checkout_status, default: "open"
226
+
227
+ def add_item(product_id:, quantity: 1)
228
+ item = items.find do |candidate|
229
+ candidate.fetch("product_id") == product_id
230
+ end
231
+
232
+ if item
233
+ item["quantity"] += quantity
234
+ else
235
+ items << {
236
+ "product_id" => product_id,
237
+ "quantity" => quantity
238
+ }
239
+ end
240
+ end
241
+
242
+ observable :items_count do
243
+ items.sum { |item| item.fetch("quantity") }
244
+ end
245
+ end
246
+ ```
247
+
248
+ Class-level `attribute` declarations are the per-object durable storage schema
249
+ and generate actor instance readers and writers. Public instance methods
250
+ declared on the actor are durable message handlers. They can use `items`,
251
+ `self.checkout_status = "pending"`, or the lower-level `state` object. Declare
252
+ helper methods as private or protected so they are not exposed as messages.
253
+
254
+ Attributes also become ordered read queries on a reference. Declared messages
255
+ become asynchronous methods:
256
+
257
+ ```ruby
258
+ cart = ShoppingCart.ref("alice")
259
+ message = cart.add_item(product_id: "shirt-123", quantity: 2)
260
+ items = cart.items
261
+ ```
262
+
263
+ `message` is a `SolidObjects::MessageReference`. `items` is a deeply frozen
264
+ JSON snapshot; mutating it cannot bypass the actor mailbox. State changes must
265
+ go through public actor message methods.
266
+
267
+ State, arguments, results, effects, and reminder arguments accept
268
+ JSON-compatible values. Solid Objects never deserializes Ruby `Marshal` data.
269
+
270
+ Lifecycle hooks are also available:
271
+
272
+ ```ruby
273
+ class DeviceActor < SolidObjects::Actor
274
+ on_activate do
275
+ end
276
+
277
+ on_deactivate do
278
+ end
279
+ end
280
+ ```
281
+
282
+ Hooks should be deterministic and must not perform slow network I/O. See the
283
+ [architecture](docs/architecture.md) for their persistence semantics.
284
+
285
+ ## Actor identity
286
+
287
+ The durable identity is:
288
+
289
+ ```text
290
+ actor_type + actor_id
291
+ ```
292
+
293
+ `actor_type` is inferred from the Ruby class name, so the normal API needs no
294
+ declaration. The pair plays the role of a Durable Objects namespace and object
295
+ name:
296
+
297
+ ```ruby
298
+ ShoppingCart.ref("alice")
299
+ ```
300
+
301
+ Use an explicit stable type when the persisted name should be independent of a
302
+ future Ruby constant rename:
303
+
304
+ ```ruby
305
+ class ShoppingCart < SolidObjects::Actor
306
+ actor_type "shopping_cart"
307
+ end
308
+ ```
309
+
310
+ Actor types resolve only through the explicit registry. Solid Objects never
311
+ constantizes a type supplied by a client.
312
+
313
+ ## Messages and queries
314
+
315
+ As with a Durable Object stub, declared actor operations are available directly
316
+ on a reference:
317
+
318
+ ```ruby
319
+ class Counter < SolidObjects::Actor
320
+ attribute :value, default: 0
321
+
322
+ def increment(amount: 1)
323
+ self.value += amount
324
+ end
325
+ end
326
+
327
+ counter = Counter.ref("global")
328
+ message = counter.increment(amount: 5)
329
+ value = counter.value
330
+ ```
331
+
332
+ Public actor methods are messages and become asynchronous syntax over `tell`,
333
+ returning a durable `MessageReference`. Declared queries and attribute readers
334
+ are synchronous syntax over `ask`. Unlike Cloudflare RPC, the initial `ask`
335
+ implementation waits by polling the durable database row.
336
+ The `message(:name) { ... }` DSL remains available for dynamic definitions.
337
+ The explicit `tell` and `ask` forms remain available for dynamic operation
338
+ names or names that collide with Ruby or reference methods.
339
+
340
+ ### `tell`
341
+
342
+ `tell` durably enqueues work and immediately returns a message reference:
343
+
344
+ ```ruby
345
+ message = order.tell(
346
+ :submit,
347
+ idempotency_key: "submit-order-123"
348
+ )
349
+ ```
350
+
351
+ Use `available_at:` to spread bulk work or delay one message:
352
+
353
+ ```ruby
354
+ order.tell(:evaluate, available_at: 10.minutes.from_now)
355
+ ```
356
+
357
+ ### `ask`
358
+
359
+ `ask` durably enqueues a query or message and waits for its result:
360
+
361
+ ```ruby
362
+ status = order.ask(:status, timeout: 5.seconds)
363
+ ```
364
+
365
+ The initial cross-process implementation polls the durable message row. It is
366
+ intended for background callers and control paths, not latency-sensitive HTTP
367
+ request handling. A timeout does not cancel the actor message.
368
+
369
+ Actor code cannot call `ask`; synchronous actor-to-actor waits can deadlock in
370
+ cycles. Use `tell` or `send_to` and a result message.
371
+
372
+ ### Redelivery
373
+
374
+ Sequential does not mean once. A handler can run again after a process crash or
375
+ lease loss, so guard logical transitions in durable actor state:
376
+
377
+ ```ruby
378
+ def launch
379
+ return if status == "launched"
380
+
381
+ self.status = "launched"
382
+ emit :launch_vehicle, launch_id: actor_id
383
+ end
384
+ ```
385
+
386
+ External systems must also deduplicate effects using the stable effect ID.
387
+
388
+ ## Effects
389
+
390
+ Cloudflare Durable Objects can call external services directly. Solid Objects
391
+ does not hold a Rails database transaction across slow external I/O. `emit`
392
+ creates a transactional outbox entry alongside state and message completion:
393
+
394
+ ```ruby
395
+ def checkout(payment_id:, amount_cents:)
396
+ return unless checkout_status == "open"
397
+
398
+ self.checkout_status = "pending"
399
+ emit(
400
+ :charge_payment,
401
+ payment_id:,
402
+ amount_cents:,
403
+ on_success: :payment_succeeded,
404
+ on_failure: :payment_failed
405
+ )
406
+ end
407
+
408
+ def payment_succeeded(effect_id:, result:)
409
+ self.checkout_status = "paid"
410
+ end
411
+
412
+ def payment_failed(effect_id:, error:)
413
+ self.checkout_status = "failed"
414
+ end
415
+ ```
416
+
417
+ Register an effect handler during application boot:
418
+
419
+ ```ruby
420
+ SolidObjects.register_effect(:charge_payment) do |arguments, context|
421
+ Payments.charge(
422
+ idempotency_key: context.id,
423
+ payment_id: arguments.fetch("payment_id"),
424
+ amount_cents: arguments.fetch("amount_cents")
425
+ )
426
+ end
427
+ ```
428
+
429
+ The provider call can repeat if a process dies after external success but
430
+ before recording completion. The stable effect ID is the idempotency key.
431
+
432
+ ## Reminders
433
+
434
+ Reminders are Solid Objects' durable equivalent of the Durable Objects Alarms
435
+ API. One-shot and recurring alarms are actor-owned database records:
436
+
437
+ ```ruby
438
+ def schedule_evaluation
439
+ schedule :evaluate, at: 1.hour.from_now, every: 1.hour, missed: :latest
440
+ end
441
+ ```
442
+
443
+ Use `missed: :latest` to coalesce missed occurrences or `missed: :all` to
444
+ enqueue each one.
445
+
446
+ Self-scheduling actors should also have a low-frequency application reconciler.
447
+ It may read `SolidObjects::Instance.states_for`, `.without_pending_work`, and
448
+ `.orphaned`, but every repair must go through `tell`. Never bulk-update actor
449
+ state around the lease and fencing checks.
450
+
451
+ ## Destroying an object
452
+
453
+ Destroy an actor incarnation through its reference:
454
+
455
+ ```ruby
456
+ Counter.ref("global").destroy
457
+ ```
458
+
459
+ `destroy` is synchronous and idempotent. It returns `true` when it deletes an
460
+ existing incarnation and `false` when none exists. In one transaction it locks
461
+ and deletes the actor instance; cascading foreign keys remove state, message
462
+ history, ready and claimed mailbox rows, dead letters, reminders, effects, and
463
+ broadcasts.
464
+
465
+ Destruction has its own deny-by-default `authorize_destroy` policy and cannot be
466
+ called synchronously from actor code. It does not run `on_deactivate`. A stale
467
+ activation cannot commit after deletion because its fenced write targets the
468
+ deleted instance primary key. Addressing the same type and ID later creates a
469
+ fresh incarnation with default state and message sequence 1.
470
+
471
+ Pending outboxes are deleted. An external effect, actor-to-actor delivery, or
472
+ broadcast that already started cannot be recalled, but its stale completion
473
+ cannot enqueue a callback or recreate the source actor. See
474
+ [destruction semantics](docs/correctness.md#destruction) before using deletion
475
+ as application workflow.
476
+
477
+ ## State migrations
478
+
479
+ Actor state has an independent schema version:
480
+
481
+ ```ruby
482
+ class ShoppingCart < SolidObjects::Actor
483
+ state_version 2
484
+
485
+ migrate_state from: 1, to: 2 do |state|
486
+ state["currency"] ||= "USD"
487
+ state
488
+ end
489
+ end
490
+ ```
491
+
492
+ An actor refuses activation when stored state is newer than the running code.
493
+ Published migration blocks cannot be squashed because a long-idle actor may
494
+ still hold an old representation. Destructive changes need an expand/contract
495
+ rolling deployment. Read the [state migration guide](docs/state-migrations.md)
496
+ before changing persisted state.
497
+
498
+ ## Configuration
499
+
500
+ Configure Solid Objects in `config/initializers/solid_objects.rb`:
501
+
502
+ ```ruby
503
+ SolidObjects.configure do |configuration|
504
+ configuration.worker_count = 4
505
+ configuration.lease_duration = 30.seconds
506
+ configuration.lease_renewal_interval = 10.seconds
507
+ configuration.max_messages_per_activation_pass = 50
508
+ configuration.max_activation_duration = 5.seconds
509
+ end
510
+ ```
511
+
512
+ Important defaults:
513
+
514
+ | Setting | Default |
515
+ | --- | ---: |
516
+ | `polling_interval` | 0.1 seconds |
517
+ | `ask_polling_interval` | 0.05 seconds |
518
+ | `lease_duration` | 30 seconds |
519
+ | `lease_renewal_interval` | 10 seconds |
520
+ | `idle_deactivation_timeout` | 30 seconds |
521
+ | `max_messages_per_activation_pass` | 50 |
522
+ | `max_activation_duration` | 5 seconds |
523
+ | `max_mailbox_length` | 10,000 |
524
+ | `max_attempts` | 5 |
525
+ | `process_heartbeat_interval` | 15 seconds |
526
+ | `process_alive_threshold` | 60 seconds |
527
+ | `worker_count` | 1 |
528
+ | `effect_worker_count` | 1 |
529
+ | `broadcast_worker_count` | 1 |
530
+ | `reminder_scheduler_count` | 1 |
531
+
532
+ Payload, state, and result limits; retry delay; table prefix; logging; wake-up;
533
+ broadcast; database; and authorization adapters are also configurable. Invalid
534
+ lease intervals, component counts, and size limits fail fast at boot.
535
+
536
+ ## Workers and operations
537
+
538
+ `solid_objects start` runs actor, effect, reminder, and broadcast roles under
539
+ one supervisor:
540
+
541
+ ```bash
542
+ bundle exec solid_objects start
543
+ ```
544
+
545
+ Worker and outbox counts can be overridden:
546
+
547
+ ```bash
548
+ bundle exec solid_objects start \
549
+ --workers 4 \
550
+ --effect-workers 2 \
551
+ --broadcast-workers 2 \
552
+ --reminder-schedulers 1
553
+ ```
554
+
555
+ Administration commands require the administration policy:
556
+
557
+ ```bash
558
+ bundle exec solid_objects status
559
+ bundle exec solid_objects cleanup
560
+ bundle exec solid_objects dead_letters
561
+ bundle exec solid_objects retry_dead_letter 123
562
+ ```
563
+
564
+ The supervisor stops new claims, drains active loops, releases cached leases,
565
+ and marks process rows stopped on graceful shutdown. A hard-killed worker's
566
+ claimed turn is recovered after its process heartbeat or activation lease
567
+ becomes stale.
568
+
569
+ See the [operations guide](docs/operations.md) for monitoring, reconciliation,
570
+ shutdown, retention, and backup guidance.
571
+
572
+ ## Database support
573
+
574
+ Solid Objects supports:
575
+
576
+ - PostgreSQL 14 or newer
577
+ - MySQL 8.0 or newer using InnoDB
578
+ - SQLite 3.35 or newer
579
+
580
+ PostgreSQL and MySQL use `FOR UPDATE SKIP LOCKED` when claiming hot-table rows.
581
+ SQLite uses its serialized writer behavior. All three adapters run the same
582
+ locking, fencing, mailbox, outbox, and engine integration test suite.
583
+
584
+ No Redis or Kafka service is required.
585
+
586
+ By default, actor tables use the application's Active Record connection. A
587
+ separate database role is optional:
588
+
589
+ ```ruby
590
+ SolidObjects.configure do |configuration|
591
+ configuration.connects_to = {
592
+ database: {
593
+ writing: :actors,
594
+ reading: :actors
595
+ }
596
+ }
597
+ end
598
+ ```
599
+
600
+ Every table participating in an actor commit must share one database.
601
+
602
+ Completed message history lives in `solid_objects_messages`, while ready and
603
+ claimed work lives in small membership tables. Polling indexes stay
604
+ proportional to live work, and no partial indexes are required.
605
+
606
+ ## Guarantees
607
+
608
+ For one actor identity, messages are:
609
+
610
+ - durably enqueued with explicit sequence numbers;
611
+ - processed sequentially in sequence order;
612
+ - delivered at least once; and
613
+ - committed by at most one valid activation owner and fencing generation.
614
+
615
+ Different actor identities may execute concurrently.
616
+
617
+ Actor destruction is authorized, synchronous, and linearized by the instance
618
+ row lock. It removes the current incarnation and all actor-owned durable rows.
619
+ A later message may create a fresh incarnation of the same logical identity.
620
+
621
+ The following writes are atomic for one successful turn:
622
+
623
+ - actor state and state version;
624
+ - message result and completion;
625
+ - effect outbox entries;
626
+ - reminder changes;
627
+ - actor-to-actor messages; and
628
+ - observable broadcast entries.
629
+
630
+ Solid Objects does not promise:
631
+
632
+ - exactly-once handler or effect execution;
633
+ - global order across actors;
634
+ - distributed transactions;
635
+ - bounded end-to-end latency;
636
+ - cancellation when an `ask` caller times out; or
637
+ - that a lease prevents stale Ruby code from continuing to run.
638
+
639
+ The fencing generation prevents stale code from committing.
640
+
641
+ Read [Correctness and delivery semantics](docs/correctness.md) for the full
642
+ contract and crash matrix.
643
+
644
+ ## When to use it
645
+
646
+ Solid Objects fits the same coordination-heavy domains that lead developers to
647
+ Cloudflare Durable Objects, when the application belongs in Rails and its
648
+ existing database:
649
+
650
+ - shopping carts;
651
+ - chat rooms and presence;
652
+ - device twins;
653
+ - user-specific schedules;
654
+ - long-lived workflows;
655
+ - collaborative sessions; and
656
+ - game rooms.
657
+
658
+ Do not use it for stateless work, bulk pipelines, CPU-heavy computation,
659
+ cross-actor transactions, slow network calls inside handlers, or domains that
660
+ are clearer as normalized Active Record models and direct service objects.
661
+
662
+ ## Comparisons
663
+
664
+ | Tool | What Solid Objects adds or changes |
665
+ | --- | --- |
666
+ | Cloudflare Durable Objects | Solid Objects ports the named, stateful, serialized-object model to Ruby and Rails. It uses your SQL database and Rails workers rather than Cloudflare's globally distributed serverless runtime, placement, and storage APIs. |
667
+ | Active Job | Jobs are independent work units. Solid Objects adds addressable identity, durable state, explicit per-identity order, activation leases, and fencing. |
668
+ | Solid Queue | Solid Queue is an excellent database backend for Active Job. Its concurrency controls cap overlap but do not guarantee order. Solid Objects provides actor mailboxes, state, fencing, per-identity reminders, and state-driven views. |
669
+ | Action Cable | Cable transports transient realtime messages. Solid Objects owns durable state and work; Cable is an optional delivery path for committed observable projections. |
670
+ | Orleans | Orleans provides the virtual-actor lineage behind the model, with grains, reminders, and activation lifecycle. Solid Objects is a smaller Rails-native runtime and does not match Orleans clustering or placement breadth. |
671
+ | Active Record service object | A service object runs directly against records. Solid Objects adds durable asynchronous ordering, retries, activation fencing, reminders, and outboxes at greater operational cost. |
672
+
673
+ ## Development
674
+
675
+ Solid Objects uses Minitest and follows Solid Queue's test organization and
676
+ RuboCop policy. Ruby source carries inline RBS annotations.
677
+
678
+ Run the full SQLite suite and static checks:
679
+
680
+ ```bash
681
+ bundle install
682
+ bundle exec rake
683
+ ```
684
+
685
+ Run the database integration suite against PostgreSQL or MySQL:
686
+
687
+ ```bash
688
+ SOLID_OBJECTS_DATABASE_URL=postgresql://localhost/solid_objects_test \
689
+ bundle exec rake test
690
+
691
+ SOLID_OBJECTS_DATABASE_URL=mysql2://localhost/solid_objects_test \
692
+ bundle exec rake test
693
+ ```
694
+
695
+ Concurrency tests use real database locks and deterministic synchronization,
696
+ not mocked locking behavior.
697
+
698
+ See the [development guide](docs/development.md) and
699
+ [local benchmarks](docs/benchmarks.md).
700
+
701
+ ## Status
702
+
703
+ Implemented and tested in 0.1:
704
+
705
+ - Rails engine, install generator, migrations, and `solid_objects` executable;
706
+ - actor registry, references, JSON state, and state migrations;
707
+ - durable message history plus ready and claimed membership tables;
708
+ - concurrent sequence allocation and actor creation;
709
+ - activation leases, renewal, fencing generations, and stale-write rejection;
710
+ - bounded activation passes, idle activation cache, and hot-actor fairness;
711
+ - retries, strict poison ordering, dead letters, and retry tooling;
712
+ - transactional effects and asynchronous actor-to-actor messages;
713
+ - one-shot and recurring per-actor reminders;
714
+ - authorized actor destruction with fenced stale-write rejection and cascading
715
+ durable-work cleanup;
716
+ - durable observable broadcasts and authorized Action Cable refresh;
717
+ - process registration, heartbeats, cleanup, and graceful shutdown; and
718
+ - SQLite, PostgreSQL, and MySQL integration tests.
719
+
720
+ Partially implemented:
721
+
722
+ - the supervisor starts and drains roles but does not replace a crashed role or
723
+ run periodic maintenance automatically;
724
+ - cross-process wake-up uses polling; PostgreSQL notifications and optional
725
+ Redis acceleration are not implemented;
726
+ - live observable replacement works, while live component replacement and
727
+ Turbo append actions remain future work;
728
+ - local admission limits exist, but distributed rate limits and global
729
+ admission control do not; and
730
+ - administration views exist, but retention automation and richer audit tools
731
+ do not.
732
+
733
+ Production readiness requires hardening and operational soak evidence. The
734
+ [roadmap](docs/roadmap.md) tracks that work.
735
+
736
+ ## License
737
+
738
+ Solid Objects is MIT Licensed by Lucas Carlson. See
739
+ [MIT-LICENSE](MIT-LICENSE).
740
+
741
+ Solid Objects is an independent open-source project. It is not affiliated with,
742
+ sponsored by, or endorsed by Cloudflare, Inc. “Cloudflare” and “Durable
743
+ Objects” are trademarks of Cloudflare, Inc. and are used here to identify the
744
+ programming model this gem ports to Rails.