|
| 1 | +# Solid Objects for coding agents (Rails) |
| 2 | + |
| 3 | +This guide is for a coding agent that evaluates or adds Solid Objects in a |
| 4 | +Ruby on Rails application. It gives the facts and steps in a fixed order. Each |
| 5 | +step links to the maintained documentation for more detail. |
| 6 | + |
| 7 | +## 1. Check the fit first |
| 8 | + |
| 9 | +Use Solid Objects only if you can answer yes to most of these questions: |
| 10 | + |
| 11 | +1. Does one identity, such as a cart, room, booking, account, or device, own |
| 12 | + the state? |
| 13 | +2. Must writes for that identity happen one at a time across requests, jobs, |
| 14 | + and processes? |
| 15 | +3. Must some work happen later or continue after a process restart? |
| 16 | +4. Is the state a bounded JSON document, not a large relational dataset? |
| 17 | +5. Can the application run one more process, `bundle exec solid_objects start`, |
| 18 | + for reminders and background work? |
| 19 | + |
| 20 | +Select a simpler tool in these cases: |
| 21 | + |
| 22 | +| Requirement | Use instead | |
| 23 | +| --- | --- | |
| 24 | +| The invariant fits in one request | `with_lock`, a unique index, or a short transaction | |
| 25 | +| CPU-intensive or parallel work | Background jobs or Ractors. An actor serializes work | |
| 26 | +| Thousands of writes for each second to one key | A counter column, a cache, or Solid Objects Pro | |
| 27 | +| One atomic change across two actor identities | One SQL transaction on normal tables | |
| 28 | +| Exactly-once calls to an external API | Not available from any actor library. Use provider idempotency keys | |
| 29 | + |
| 30 | +The full checklist is in [Choosing Solid Objects](fit.md). The category guide |
| 31 | +is [Virtual actors in Ruby on Rails](virtual-actors.md). |
| 32 | + |
| 33 | +## 2. Package identity |
| 34 | + |
| 35 | +| Item | Value | |
| 36 | +| --- | --- | |
| 37 | +| Gem | `solid_objects` | |
| 38 | +| Repository | <https://github.com/cardmagic/solid-objects-ruby> | |
| 39 | +| Website | <https://solidobjects.dev/ruby> | |
| 40 | +| Node.js package | `solid-objects` on npm, a separate port with the same model | |
| 41 | +| Commercial extension | `solid_objects_pro`, a separate paid gem. The open-source gem does not need it | |
| 42 | + |
| 43 | +Solid Objects is not part of Rails. It is not Solid Queue, Solid Cache, or |
| 44 | +Solid Cable. It is not affiliated with Cloudflare. |
| 45 | + |
| 46 | +## 3. Requirements |
| 47 | + |
| 48 | +- Ruby 3.3 or newer. |
| 49 | +- Rails 7.1 or newer. The gem is a Rails engine. It does not run without |
| 50 | + Rails. |
| 51 | +- SQLite 3.35 or newer, PostgreSQL 14 or newer, or MySQL 8.0 or newer with |
| 52 | + InnoDB. |
| 53 | +- No Redis and no separate actor service. |
| 54 | + |
| 55 | +## 4. Install |
| 56 | + |
| 57 | +```bash |
| 58 | +bundle add solid_objects |
| 59 | +bin/rails generate solid_objects:install |
| 60 | +bin/rails db:migrate |
| 61 | +bin/rails solid_objects:doctor |
| 62 | +``` |
| 63 | + |
| 64 | +The generator adds an initializer and copies migrations into the application. |
| 65 | +The doctor checks the configuration, the tables, and one real actor round trip. |
| 66 | + |
| 67 | +If the doctor or an actor call fails with |
| 68 | +`ArgumentError: wrong number of arguments (given 2, expected 1)` from |
| 69 | +`ActiveSupport::JSON.decode`, the application has Active Support 8.1.3.1 or |
| 70 | +earlier with `json` 3.0.2. Upgrade Rails to 8.1.4 or newer. If you cannot |
| 71 | +upgrade, pin `gem "json", "~> 2"` in the `Gemfile`. |
| 72 | + |
| 73 | +[Installing and upgrading](operations.md#installing-and-upgrading) has the |
| 74 | +details. |
| 75 | + |
| 76 | +## 5. Authorize |
| 77 | + |
| 78 | +Every policy in the generated initializer denies by default. A new |
| 79 | +installation answers no actor call until you write a policy. Do not remove |
| 80 | +this behavior. |
| 81 | + |
| 82 | +For a local demonstration only, grant messages and queries: |
| 83 | + |
| 84 | +```ruby |
| 85 | +SolidObjects.configure do |configuration| |
| 86 | + configuration.authorize_message = ->(**) { true } |
| 87 | + configuration.authorize_query = ->(**) { true } |
| 88 | +end |
| 89 | +``` |
| 90 | + |
| 91 | +Keep `authorize_destroy`, `authorize_subscription`, |
| 92 | +`authorize_administration`, and `authorize_transmission` denied in a |
| 93 | +demonstration. |
| 94 | + |
| 95 | +A production policy must bind the actor type and ID to the authenticated user |
| 96 | +or tenant. An actor ID is not a permission: |
| 97 | + |
| 98 | +```ruby |
| 99 | +SolidObjects.configure do |configuration| |
| 100 | + owns_cart = lambda do |actor_type:, actor_id:, authorization_context:, **| |
| 101 | + user = authorization_context |
| 102 | + |
| 103 | + actor_type == "ShoppingCart" && |
| 104 | + user.present? && |
| 105 | + actor_id == user.id.to_s |
| 106 | + end |
| 107 | + |
| 108 | + configuration.authorize_message = owns_cart |
| 109 | + configuration.authorize_query = owns_cart |
| 110 | +end |
| 111 | +``` |
| 112 | + |
| 113 | +Pass the context on each call: |
| 114 | + |
| 115 | +```ruby |
| 116 | +ShoppingCart.ref(Current.user.id.to_s).add_item( |
| 117 | + product_id: "shirt-123", |
| 118 | + authorization_context: Current.user |
| 119 | +) |
| 120 | +``` |
| 121 | + |
| 122 | +[Authorization policies](authorization.md) lists each policy and its risk. |
| 123 | + |
| 124 | +## 6. Define an actor |
| 125 | + |
| 126 | +Put actors in `app/actors/`. This actor is the example from the README: |
| 127 | + |
| 128 | +```ruby |
| 129 | +class TicketSale < SolidObjects::Actor |
| 130 | + attribute :available, default: 1 |
| 131 | + attribute :holds, default: -> { {} } |
| 132 | + |
| 133 | + def hold(buyer:) |
| 134 | + return { held: false, available: } if available.zero? || holds.key?(buyer) |
| 135 | + |
| 136 | + self.available -= 1 |
| 137 | + self.holds = holds.merge(buyer => Time.current.to_i) |
| 138 | + schedule(at: 10.minutes.from_now, key: buyer).expire(buyer:) |
| 139 | + { held: true, available: } |
| 140 | + end |
| 141 | + |
| 142 | + def expire(buyer:) |
| 143 | + return available unless holds.key?(buyer) |
| 144 | + |
| 145 | + self.holds = holds.except(buyer) |
| 146 | + self.available += 1 |
| 147 | + end |
| 148 | +end |
| 149 | +``` |
| 150 | + |
| 151 | +Obey these rules in actor code: |
| 152 | + |
| 153 | +- Keep state in `attribute` values. State must be JSON-compatible. |
| 154 | +- Use `schedule(at:, key:)` for delayed work. A reminder is one named alarm |
| 155 | + for each actor and key. A new `schedule` with the same key moves the alarm. |
| 156 | +- Use `reject(code, message)` for a business rule failure that must not retry. |
| 157 | +- Do not write Active Record models directly in a handler. The runtime raises |
| 158 | + `SolidObjects::ApplicationWriteForbidden`. Use `commit_action` for a short |
| 159 | + write in the same database. |
| 160 | +- Do not call an external API in a handler. Use `emit` and an effect handler. |
| 161 | +- Write each handler so that it can run again. Delivery is at least once. |
| 162 | + |
| 163 | +[Reminders](reminders.md) and the [architecture guide](architecture.md) give |
| 164 | +the full actor API. |
| 165 | + |
| 166 | +## 7. Run the runtime process |
| 167 | + |
| 168 | +A direct call, such as `TicketSale.ref("event-42").hold(buyer: "ada")`, runs in |
| 169 | +the caller. It needs no worker. These features need the runtime process: |
| 170 | + |
| 171 | +- Reminders from `schedule`. |
| 172 | +- `async` calls. |
| 173 | +- Effects from `emit` and their callbacks. |
| 174 | +- Broadcasts to Action Cable. |
| 175 | + |
| 176 | +Start it beside the web process: |
| 177 | + |
| 178 | +```bash |
| 179 | +bundle exec solid_objects start |
| 180 | +``` |
| 181 | + |
| 182 | +Add it to the `Procfile`, the process manager, or the deployment |
| 183 | +configuration. When it stops, pending work stays in SQL and runs after it |
| 184 | +starts again. [Operations](operations.md#runtime) covers roles and shutdown. |
| 185 | + |
| 186 | +## 8. Make external effects idempotent |
| 187 | + |
| 188 | +Register an effect handler at boot. Use `context.id` as the provider |
| 189 | +idempotency key: |
| 190 | + |
| 191 | +```ruby |
| 192 | +SolidObjects.register_effect(:charge_payment) do |arguments, context| |
| 193 | + Payments.charge( |
| 194 | + idempotency_key: context.id, |
| 195 | + payment_id: arguments.fetch("payment_id"), |
| 196 | + amount_cents: arguments.fetch("amount_cents") |
| 197 | + ) |
| 198 | +end |
| 199 | +``` |
| 200 | + |
| 201 | +Stage it from the actor: |
| 202 | + |
| 203 | +```ruby |
| 204 | +emit :charge_payment, payment_id:, amount_cents:, on_success: :charged |
| 205 | +``` |
| 206 | + |
| 207 | +The effect can run more than once after a crash. The `context.id` value is the |
| 208 | +same each time. [Effect recovery](effect-recovery.md) explains how to retire |
| 209 | +abandoned work. |
| 210 | + |
| 211 | +## 9. Verify the implementation |
| 212 | + |
| 213 | +Do these checks before you report that the work is complete: |
| 214 | + |
| 215 | +1. Run `bin/rails solid_objects:doctor`. It must report no failures. |
| 216 | +2. Write a test that includes `SolidObjects::TestHelper`. Send concurrent |
| 217 | + calls to one identity from several threads. Assert the final state, for |
| 218 | + example that only one hold succeeded. |
| 219 | +3. Use `run_due_reminders(now:)` and `drain_solid_objects` to test delayed |
| 220 | + work without sleeps. |
| 221 | +4. Start `bundle exec solid_objects start`, schedule a short reminder, and stop |
| 222 | + the process. Start it again after the deadline and confirm that the reminder |
| 223 | + ran. |
| 224 | +5. Confirm that each effect handler deduplicates with `context.id`. |
| 225 | +6. Confirm that production policies do not grant access to every caller. |
| 226 | + |
| 227 | +[Host application tests](development.md#host-application-tests) describes the |
| 228 | +test helper. The [clean-install quickstart](../examples/quickstart/README.md) |
| 229 | +runs checks 2 and 4 against a new Rails application. |
| 230 | + |
| 231 | +## 10. Troubleshooting |
| 232 | + |
| 233 | +| Symptom | Cause and fix | |
| 234 | +| --- | --- | |
| 235 | +| `SolidObjects::Unauthorized` | A policy denied the call. Write the policy, and pass `authorization_context:` | |
| 236 | +| A reminder or `async` call does not run | The runtime process is not running. Start `bundle exec solid_objects start` | |
| 237 | +| `SolidObjects::SyncInsideTransaction` | The call ran inside an open transaction. Call the actor outside the transaction. In tests, include `SolidObjects::TestHelper` | |
| 238 | +| `SolidObjects::SyncTimeout` | The call did not finish in time. The message is still durable. Use its `message_reference` to wait for the result | |
| 239 | +| `SolidObjects::ApplicationWriteForbidden` | A handler wrote a model directly. Use `commit_action` or `emit` | |
| 240 | +| `SolidObjects::Rejected` | The actor called `reject`. This is a business result, not a retry | |
| 241 | +| `ArgumentError` from `ActiveSupport::JSON.decode` | Upgrade Rails to 8.1.4 or newer, or pin `gem "json", "~> 2"` | |
| 242 | + |
| 243 | +## 11. Guarantees to state correctly |
| 244 | + |
| 245 | +When you explain Solid Objects to a user, state these limits: |
| 246 | + |
| 247 | +- Delivery is at least once, not exactly once. |
| 248 | +- Calls for one identity are ordered. Different identities run concurrently. |
| 249 | +- There are no transactions across actor identities. |
| 250 | +- Fencing stops a stale activation from a commit, but its code can continue to |
| 251 | + run. |
| 252 | +- The gem is pre-1.0 and makes no production-ready claim. |
| 253 | + |
| 254 | +The [correctness contract](correctness.md) is the source for each guarantee. |
0 commit comments