diff --git a/README.md b/README.md index 2c24d1c..12314dd 100644 --- a/README.md +++ b/README.md @@ -48,15 +48,17 @@ sale = TicketSale.ref("event-42") sale.reserve(buyer: current_user.id) ``` -That example wants three things from the same number. It must never go below -zero. It must give the seat back if the buyer does not pay within ten minutes. -It must show the current count to everyone watching the page. +The example needs three behaviors from the same number: -The first is one line of SQL. The second is an `expires_at` column plus a cron -job that sweeps it. The third is a broadcast on every code path that changes -the number. The combination is what costs, not any one of them. Here the guard, -the ten-minute alarm, and the live count are one class, and they commit -together. +- the count must never go below zero; +- the seat must return if the buyer does not pay within ten minutes; and +- every open page must show the current count. + +Written by hand, the first needs one line of SQL, the second needs an +`expires_at` column and a cron job that sweeps it, and the third needs a +broadcast on every code path that changes the number. These three must agree +with each other. In the example above the guard, the alarm, and the live count +are one class, and they commit in one transaction. `TicketSale / event-42` is a logical identity. Like a Durable Object named with `idFromName`, it can be addressed from anywhere without first creating or @@ -65,11 +67,10 @@ its ordered turns one at a time, persists its state, and deactivates it when idle. Different identities run concurrently, so two events never wait on each other. -The invocation model is the first adoption decision. A direct call or `sync` -needs no worker fleet, because the Rails caller helps execute the actor through -the same mailbox, lease, and fencing path a worker would use. `async` only -enqueues and returns a `MessageReference`, so a runtime process handles it -later. +There are two ways to invoke an actor. A direct call or `sync` needs no worker +fleet, because the Rails caller helps execute the actor through the same +mailbox, lease, and fencing path a worker would use. `async` only enqueues and +returns a `MessageReference`, so a runtime process handles it later. | Call | Returns | Worker fleet required? | | --- | --- | --- | @@ -110,22 +111,21 @@ are rejected so they cannot escape a later actor failure. Use a same-database ## Why not just use transactions? -Often you should. If the whole job is read a row, decide, write it back, and -answer the user, then `with_lock` does that and you need nothing else -installed. Reach for it first. +Often you should. If the whole job is to read a row, decide, write it back, and +answer the user, then `with_lock` does that, and you install nothing else. -The argument for an actor is scope, not discipline. A lock is scoped to one -transaction, on one connection, in one process. The ticket sale above leaves -that scope on one line: the hold expires in ten minutes, and no transaction -stays open for ten minutes. +An actor helps when the work goes outside the scope of a lock. A lock holds for +one transaction, on one connection, in one process. The ticket sale above goes +outside that scope on one line: the hold expires in ten minutes, and no +transaction stays open for ten minutes. -Any column named `expires_at`, `scheduled_at`, or `next_run_at` is evidence -that the critical section already outlived the lock that was supposed to cover -it. What follows such a column is a sweeper that looks for due rows, and then a -race between that sweeper and the next writer of the same row. The column, the -sweeper, and the race are what a Solid Objects actor replaces. +A column named `expires_at`, `scheduled_at`, or `next_run_at` shows that the +critical section already outlived the lock that was supposed to cover it. Such +a column usually comes with a sweeper that looks for due rows, and the sweeper +can race the next writer of the same row. A Solid Objects actor replaces the +column, the sweeper, and the race. -Three things a lock cannot reach: +A lock cannot reach three cases: - work that fires at a future moment, when no transaction of yours is open; - work that must survive a process restart, which rules out an in-process @@ -133,31 +133,32 @@ Three things a lock cannot reach: - a fan-in whose critical section spans many jobs over minutes, such as an import that counts its own chunks as each one finishes. -If it all happens inside one request, use a lock. +If all the work happens inside one request, use a lock. ## Is it worth installing here? -Worth it when several requests, jobs, or processes act on the same cart, chat +Install it when several requests, jobs, or processes act on the same cart, chat room, device twin, game room, long-lived workflow, or refillable quota, and -each next action needs the last committed state. Worth it when that same thing -also owns work that fires later, or a number a live page must show. - -Two of those have a limit. A workflow fits when one entity owns the mutable -state and its mailbox holds the step order. A durable execution engine that -replays named steps from a step log is a different tool, because Solid Objects -redelivers an ordered message and retries it. A quota fits when one identity -checks it a few times per minute, because each check is one durable ordered -message with a retained history row. A limiter that every request to that -identity touches does not fit here. - -Not worth it for a plain counter, a single-row update inside one transaction, a -stateless job, bulk ingestion or a data-parallel pipeline, CPU-heavy work, a -large JSON document that belongs in normalized rows, high-QPS request reads, or -a rate-limit counter that every request touches. One hot identity is serialized -on purpose, so making everything one identity makes a queue. - -High-QPS reads and hot identities are where this runtime stops being the right -tool on its own. [Solid Objects Pro](https://solidobjects.pro/) is a commercial +each next action needs the last committed state. It also helps when that same +thing owns work that fires later, or a number that a live page must show. + +Two of those cases have a limit. A workflow fits when one entity owns the +mutable state and its mailbox holds the step order. A durable execution engine +that replays named steps from a step log is a different tool, because Solid +Objects redelivers an ordered message and retries it. A quota fits when one +identity checks it a few times per minute, because each check is one durable +ordered message with a retained history row. A limiter that every request to +that identity touches does not fit here. + +Do not install it for a plain counter, a single-row update inside one +transaction, a stateless job, bulk ingestion or a data-parallel pipeline, +CPU-heavy work, a large JSON document that belongs in normalized rows, high-QPS +request reads, or a rate-limit counter that every request touches. Solid +Objects serializes one identity on purpose. If you put all the work in one +identity, you get a queue. + +This runtime alone does not handle high-QPS reads or hot identities well. +[Solid Objects Pro](https://solidobjects.pro/) is a commercial performance layer for this gem that adds grouped commits, which coalesce concurrent writes into fewer database commits; optional ephemeral operations, which take loss-tolerant calls out of the durable journal; and materialized @@ -167,10 +168,10 @@ mailbox work. Before moving an existing surface, read the [fit and anti-pattern guide](docs/fit.md), the [measured costs](docs/benchmarks.md), and the -[migration cookbook](docs/migrating-existing-state.md). This ports the -programming model, not Cloudflare's edge runtime; the exact Rails guarantees -are in [correctness](docs/correctness.md), and this is an early release with no -production-readiness claim. +[migration cookbook](docs/migrating-existing-state.md). This gem ports the +programming model. It does not port Cloudflare's edge runtime. The +[correctness guide](docs/correctness.md) gives the exact Rails guarantees. This +is an early release, and it makes no production-readiness claim. ## Cloudflare Durable Objects for Rails @@ -202,20 +203,20 @@ worker can finish running Ruby but cannot commit. ## Reactive ERB For a comment count or a dashboard number, lock the row, update it, and call -`broadcast_replace_to`. That is less code than this gem and it works. +`broadcast_replace_to`. That needs less code than this gem, and it is +sufficient for that case. -It gets harder when several people write to the same record at once. Each -request renders the fragment in its own process and pushes it. The lock decided -who wrote first, but it has no say over which push arrives last, so a viewer -can be left looking at the older number. The second gap is that the push is not -part of the save: if the process dies after the database commits and before the -push goes out, the browser keeps a wrong number and nothing corrects it. +Two problems occur when several people write to the same record at once. First, +each request renders the fragment in its own process and pushes it. The lock +sets the write order, but it does not set the order in which the pushes arrive, +so a viewer can keep the older number. Second, the push is not part of the save. +If the process stops after the database commits and before the push goes out, +the browser keeps a wrong number, and nothing corrects it. -An observable is the alternative. The state change and the broadcast row commit -together, a worker delivers that row and retries until it succeeds, and Cable -ignores an older `(instance_id, state_revision)` pair after a newer one. A -viewer cannot end up on an older number, though delivery itself is still at -least once. +An observable prevents both problems. The state change and the broadcast row +commit together, a worker delivers that row and retries until it succeeds, and +Cable ignores an older `(instance_id, state_revision)` pair after a newer one. A +viewer cannot end up on an older number. Delivery is still at least once. ```erb <%= solid_object @sale, authorization_context: current_user do |sale| %> @@ -224,15 +225,15 @@ least once. <% end %> ``` -The two observables in the ticket sale are what make that template live: a -committed turn that changes `remaining` replaces the span, and one that changes -`holds` re-renders the component from `actors/ticket_sale/_buyers`. Observables -are invalidation-only unless declared `broadcast: :value`, which is why -`remaining` carries it and `holds` does not: only an opted-in scalar sends its -value to every authorized subscriber, and rendering an invalidation-only -observable as a span raises. Per-viewer state belongs in `broadcast_payload`. -Signed tokens protect integrity, not access: rendering, Cable, and every -refresh each authorize again. +The two observables in the ticket sale make that template live. A committed +turn that changes `remaining` replaces the span, and a turn that changes `holds` +re-renders the component from `actors/ticket_sale/_buyers`. An observable only +invalidates unless you declare `broadcast: :value`, so `remaining` carries that +option and `holds` does not. Only an opted-in scalar sends its value to every +authorized subscriber, and a template that renders an invalidation-only +observable as a span raises an error. Put per-viewer state in +`broadcast_payload`. Signed tokens protect integrity. They do not grant access: +rendering, Cable, and every refresh authorize again. Reactive views require `turbo-rails`, an Action Cable adapter, and `mount SolidObjects::Engine => "/solid_objects"`. They are optional; the actor @@ -256,18 +257,18 @@ The doctor validates configuration and schema shape, reports authorization posture and live roles, and completes a real synchronous actor round-trip without a worker. -The generated initializer is intentionally inert: all five policies deny by -default. Replace them before sending messages, querying state, destroying -actors, subscribing to streams, or mounting administration routes. Knowledge of -an actor ID or a signed stream token is never authorization. Read the +In the generated initializer, all five policies deny by default. Replace them +before you send messages, query state, destroy actors, subscribe to streams, or +mount administration routes. An actor ID or a signed stream token is not +authorization. Read the [policy reference](docs/authorization.md) first. Upgrades, the RuboCop exclusion for engine migrations, and Sorbet RBI generation are in the [operations guide](docs/operations.md#installing-and-upgrading). ## Worker requirements -Synchronous actors can be adopted without adding a long-running process. Start -the runtime when the feature introduces asynchronous delivery or outboxes: +You can adopt synchronous actors without a long-running process. Start the +runtime when the feature adds asynchronous delivery or outboxes: | Feature | Runtime roles required | | --- | --- | @@ -286,12 +287,12 @@ run beside the built-in roles; see the ## Defining an actor -`TicketSale` above is the whole shape. Class-level `attribute` declarations are -the per-object durable storage schema. Public instance methods are durable -message handlers, so declare helpers private. Attributes also become ordered -read queries on a reference: `sale.remaining` goes through the mailbox, while -`sale.snapshot.remaining` reads the most recently committed state without one -and does not activate a missing actor. +The `TicketSale` class above shows the full structure. Class-level `attribute` +declarations are the per-object durable storage schema. Public instance methods +are durable message handlers, so declare helpers private. Attributes also +become ordered read queries on a reference: `sale.remaining` goes through the +mailbox, while `sale.snapshot.remaining` reads the most recently committed +state without one and does not activate a missing actor. State, arguments, results, effects, and reminder arguments accept JSON-compatible values only, and Solid Objects never deserializes Ruby @@ -356,9 +357,9 @@ durable for audit, actor state rolls back, and no later turn is blocked. A code must match `\A[A-Za-z_][A-Za-z0-9_]*\z`, and an invalid one raises `SolidObjects::InvalidRejectionCode`. -Sequential does not mean once. A handler can run again after a crash or lease -loss, so guard logical transitions in durable actor state and deduplicate -external effects on the stable effect ID. See +Ordered execution does not prevent repeated execution. A handler can run again +after a crash or lease loss, so guard logical transitions in durable actor state +and deduplicate external effects on the stable effect ID. See [handler idempotency](docs/correctness.md#handler-idempotency). ## Application database writes @@ -538,7 +539,7 @@ Solid Objects does not promise: - cancellation when a synchronous caller times out; or - that a lease stops stale Ruby code from running. -The fencing generation is what stops stale code from committing. Read +The fencing generation stops stale code from committing. Read [correctness](docs/correctness.md) for the full contract. ## Comparisons diff --git a/docs/adr/0006-at-least-once-delivery.md b/docs/adr/0006-at-least-once-delivery.md index 84d0213..5e6da83 100644 --- a/docs/adr/0006-at-least-once-delivery.md +++ b/docs/adr/0006-at-least-once-delivery.md @@ -13,7 +13,7 @@ Mailbox delivery is at least once. State mutation, message completion, result pe Actor code receives message ID, request ID, attempt, enqueue time, and idempotency key. Documentation requires idempotency for effects outside the actor commit. -Message handlers themselves can run more than once. Sequential execution means one valid activation runs one turn at a time; it does not mean a handler runs once. Handlers for transitions such as `launch`, `checkout`, or `submit` must inspect durable actor state and return safely when the transition already happened. External calls belong in an outbox and still require downstream idempotency. +Message handlers themselves can run more than once. Sequential execution means that one valid activation runs one turn at a time. It does not guarantee that a handler runs only once. Handlers for transitions such as `launch`, `checkout`, or `submit` must inspect durable actor state and return safely when the transition already happened. External calls belong in an outbox and still require downstream idempotency. ## Consequences diff --git a/docs/architecture.md b/docs/architecture.md index 6070126..4f885c4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -7,8 +7,9 @@ It is a database-backed virtual actor runtime for MySQL, PostgreSQL, and SQLite. A virtual actor is a logical object addressed by type and ID whose in-memory activation is created on demand, processes one mailbox turn at a time, persists JSON state, and can disappear when idle without losing its -identity or state. This ports the programming model, not Cloudflare's -serverless runtime, global placement, storage API, or platform guarantees. +identity or state. This gem ports the programming model. It does not port +Cloudflare's serverless runtime, global placement, storage API, or platform +guarantees. The runtime contract is: @@ -128,8 +129,8 @@ instance first prevents a claimed reminder from recreating a destroyed actor. The broadcast worker claims committed observable-change rows, renders idempotent scalar Turbo replacements with component invalidation metadata, broadcasts to a signed actor stream, and records delivery. It never renders -personalized component HTML. Current actor state remains the reconnect and -request-time component source of truth. +personalized component HTML. On reconnect, and on a request-time component +render, Solid Objects reads the current actor state. ### Process registry @@ -482,7 +483,7 @@ A reminder record contains actor identity, a reminder name, target message, JSON schedule(at: 30.minutes.from_now).expire ``` -Reminders are keyed by `(actor, reminder name)`, enforced by a unique index on `(instance_id, name)`. `schedule` is therefore an upsert: scheduling a name that is already armed moves that alarm instead of adding another, which is what makes re-arming safe from a handler that may run more than once. An actor needing several pending items should arm one alarm for the earliest and drain everything due when it fires, rather than one alarm per item; the [reminders guide](reminders.md#one-alarm-for-a-whole-queue) shows that pattern. A move that changes `next_run_at` emits `solid_objects.reminder.replaced`, because the replacement is otherwise indistinguishable from a first schedule. +Reminders are keyed by `(actor, reminder name)`, enforced by a unique index on `(instance_id, name)`. `schedule` is therefore an upsert: scheduling a name that is already armed moves that alarm instead of adding another, so re-arming is safe from a handler that may run more than once. An actor needing several pending items should arm one alarm for the earliest and drain everything due when it fires, rather than one alarm per item; the [reminders guide](reminders.md#one-alarm-for-a-whole-queue) shows that pattern. A move that changes `next_run_at` emits `solid_objects.reminder.replaced`, because the replacement is otherwise indistinguishable from a first schedule. When due, the scheduler locks the source instance and creates a normal mailbox row with an idempotency key derived from reminder ID and occurrence. The @@ -698,7 +699,7 @@ Backoff and a retry limit prevent tight loops. The poison message blocks its act ### Handler redelivery -Sequential processing does not mean single execution. A handler can run, lose its lease before commit, and run again. Logical transitions must guard on durable state: +Ordered processing does not prevent repeated execution. A handler can run, lose its lease before commit, and run again. Logical transitions must guard on durable state: ```ruby def launch diff --git a/docs/authorization.md b/docs/authorization.md index da1df7d..461dc27 100644 --- a/docs/authorization.md +++ b/docs/authorization.md @@ -1,9 +1,9 @@ # Authorization policies -Solid Objects treats actor identities as identifiers, never capabilities. -Knowing an actor ID, message ID, or signed stream token grants no permission. -All five policies deny by default, so a generated installation is -intentionally inert until the host application defines its trust boundary. +Solid Objects treats actor identities as identifiers. They are not +capabilities. An actor ID, a message ID, or a signed stream token grants no +permission. All five policies deny by default, so a generated installation +answers nothing until the host application defines its trust boundary. ## Policy reference diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 59cf623..04d6898 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -1,8 +1,8 @@ # Performance and storage costs -These numbers are development measurements, not universal capacity guarantees. -They include the runtime's Active Record and database query overhead and will -vary with hardware, schema size, connection pools, durability settings, and +These numbers are development measurements. They do not guarantee capacity. +They include the runtime's Active Record and database query overhead, and they +change with hardware, schema size, connection pools, durability settings, and contention. ## Idle SQLite polling diff --git a/docs/correctness.md b/docs/correctness.md index c8bb2e3..665ef93 100644 --- a/docs/correctness.md +++ b/docs/correctness.md @@ -78,8 +78,8 @@ Destruction is synchronous, forbidden from actor context, authorized by ## Handler idempotency -Sequential does not mean once. A message such as `launch` still needs a durable -guard: +Ordered execution does not prevent repeated execution. A message such as +`launch` still needs a durable guard: ```ruby def launch diff --git a/docs/fit.md b/docs/fit.md index 0e4958e..84ff868 100644 --- a/docs/fit.md +++ b/docs/fit.md @@ -57,8 +57,8 @@ request-critical, and often expires rather than requiring permanent message history. A low-rate quota is the case that does fit, such as five password resets an hour for one account, where a reminder refills the bucket and each check is one durable ordered message. An impressions pipeline is also a poor -actor: its value is high-throughput append and aggregation, not serialized -mutable state. +actor, because its value comes from high-throughput append and aggregation. It +does not need serialized mutable state. [Solid Objects Pro](https://solidobjects.pro/) is the commercial scaling layer for the high-QPS cases in this section. Grouped operations coalesce concurrent @@ -111,5 +111,5 @@ Before adopting an actor, answer: 10. How will existing state be cut over and rolled back? Benchmark the actual host database and deployment topology before committing a -latency-sensitive surface. Local benchmark results are evidence about query -shape, not universal capacity guarantees. +latency-sensitive surface. Local benchmark results show query shape. They do +not guarantee capacity. diff --git a/docs/local-testing.md b/docs/local-testing.md index 93491fc..7b7cfd9 100644 --- a/docs/local-testing.md +++ b/docs/local-testing.md @@ -24,9 +24,9 @@ SOLID_OBJECTS_DATABASE_URL=postgresql://solid_objects:solid_objects@127.0.0.1:54 bundle exec rake test ``` -Running this locally is worth the setup: it is what caught the PostgreSQL -version comparison reading a packed integer, where `170010` compared greater -than any minimum and made the check useless on the adapter it mattered most for. +The setup is worth the effort. This local suite caught a PostgreSQL version +comparison that read a packed integer, where `170010` compared greater than any +minimum and made the check useless on the adapter that needed it most. ## MySQL and Redis in Docker diff --git a/docs/migrating-existing-state.md b/docs/migrating-existing-state.md index b5624fe..8e0f244 100644 --- a/docs/migrating-existing-state.md +++ b/docs/migrating-existing-state.md @@ -2,7 +2,7 @@ Moving an existing Redis, cache, or key-value state machine into Solid Objects is a data migration and a coordination cutover. Treat it as a staged production -change, not a rewrite that switches storage in one deploy. +change. Do not switch the storage in one deploy. ## 1. Write down the existing contract diff --git a/docs/operations.md b/docs/operations.md index 2fbdc54..87b6f87 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -143,9 +143,9 @@ loader participates in Rails preparation callbacks so a development reload can replace a registered actor class without loading unrelated application code. An actor registers itself as its class loads, and a web process resolves -actors by name for Cable subscriptions and component renders. Loading them in -every process is what lets a freshly booted web process serve a live card for -an actor no request in that process has rendered yet. +actors by name for Cable subscriptions and component renders. Solid Objects +loads the actors in every process, so a freshly booted web process can serve a +live card for an actor that no request in that process has rendered yet. Worker and outbox counts can be overridden on the command line: @@ -278,7 +278,7 @@ Use: Spread large repairs with `available_at:`. Report at least bootstrapped, reconfigured, revived, suspended, and orphaned counts. A nonzero revived count -is evidence that alarms are being lost. +shows that alarms are lost. Never bulk-update actor state. That bypasses lease ownership and fencing. diff --git a/docs/realtime.md b/docs/realtime.md index 0cbdd30..5b14ee2 100644 --- a/docs/realtime.md +++ b/docs/realtime.md @@ -421,8 +421,8 @@ configuration.component_authorization_context = ->(controller:) { controller.cur configuration.payload_authorization_context = ->(connection:) { connection.current_account } ``` -The resolved value is what the payload block receives as its second argument and -what `authorize_query` receives as `authorization_context`. A resolver may also +The payload block receives the resolved value as its second argument, and +`authorize_query` receives it as `authorization_context`. A resolver may also accept `payload_name:` when the subject depends on which payload was requested. The default returns the connection unchanged, so an application that has not configured one is unaffected. diff --git a/docs/reminders.md b/docs/reminders.md index d190e13..9a1a33b 100644 --- a/docs/reminders.md +++ b/docs/reminders.md @@ -22,9 +22,9 @@ The uniqueness key is `(actor, reminder name)`. Scheduling a name that is already armed **moves the existing alarm** rather than adding a second one. The database enforces this with a unique index on `(instance_id, name)`. -This is the same model as Orleans reminders and Durable Objects alarms, and it -is what makes a reminder safe to re-arm from a handler that may run more than -once. Without a key the name is the operation, so this is a data-loss bug: +This is the same model as Orleans reminders and Durable Objects alarms. It +makes a reminder safe to re-arm from a handler that may run more than once. +Without a key the name is the operation, so this is a data-loss bug: ```ruby # Wrong. Every entry overwrites the previous entry's alarm. @@ -51,9 +51,9 @@ end ``` Two entries now leave two reminders. Scheduling the same key again moves that -item's alarm and leaves the others alone, which is what makes a keyed reminder -as safe to re-arm as an unkeyed one. The operation still decides which handler -runs; the key only decides which alarm is which. +item's alarm and leaves the others alone, so a keyed reminder is as safe to +re-arm as an unkeyed one. The operation still decides which handler runs; the +key only decides which alarm is which. A key must be non-empty, and the name it becomes must fit the 191-character column, which is checked on the composed name rather than the key alone so a diff --git a/docs/research/solid_queue.md b/docs/research/solid_queue.md index 17e4046..73d8362 100644 --- a/docs/research/solid_queue.md +++ b/docs/research/solid_queue.md @@ -472,7 +472,7 @@ The current `cardmagic/classifier` source uses RBS::Inline directly in Ruby file - [`.github/workflows/ruby.yml`](https://github.com/cardmagic/classifier/blob/48cdfa63f3efdba8149c8f47dd053ceebce5dfc1/.github/workflows/ruby.yml) generates signatures, validates them with RBS, and runs Steep. - [`Steepfile`](https://github.com/cardmagic/classifier/blob/48cdfa63f3efdba8149c8f47dd053ceebce5dfc1/Steepfile) enables strict diagnostics for the typed library while explicitly isolating incompatible extension files. -Solid Objects will use the same source-adjacent convention. Every owned Ruby source file starts with `# rbs_inline: enabled`, declares its instance variables, and annotates public and private methods. Generated signatures are checked rather than hand-maintained as a competing source of truth. +Solid Objects will use the same source-adjacent convention. Every owned Ruby source file starts with `# rbs_inline: enabled`, declares its instance variables, and annotates public and private methods. The build checks the generated signatures. Nobody maintains them by hand as a second authority. ## Related primary-source findings