From 3d615c5860d6b36eee369cc1ec7df2142d7402d2 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Wed, 7 Oct 2026 23:18:19 -0700 Subject: [PATCH 1/4] docs: name Solid Objects a virtual actor library An agent asked "does Ruby have a virtual actor library" answered no. The architecture guide already used that category, but the README, the gem metadata, and the website did not. A baseline of 80 agent runs found the project in 15 of 64 suitable runs. Name the category where people and agents look first: - The README opening and the gemspec summary and description call Solid Objects a SQL-backed virtual actor library for Ruby on Rails. - The gem homepage links to https://solidobjects.dev/ruby. The site root redirects to the Node page. - docs/virtual-actors.md answers the category question. It has the definition, an example, fit and poor-fit criteria, comparisons with checked sources, and an Orleans concept map. - docs/agents.md gives coding agents setup, authorization, effect idempotency, verification, and troubleshooting steps. - context7.json limits Context7 to the consumer documentation. Add examples/quickstart and `rake quickstart`. The check builds the gem, installs it into a new Rails app from vendor/cache, and proves by checksum and load path that the app loads the built gem. It then proves that eight concurrent holds give one winner, and that a due reminder waits while the runtime is stopped and runs after a restart. It fails when a TicketSale sample in the README or docs/ drifts from the actor that it runs. CI runs it, and the release job waits for it. Correct docs/operations.md: json 3.0.2 breaks only Active Support 8.1.3.1 and earlier. Active Support 8.1.4 decodes correctly. --- .github/workflows/ci.yml | 12 + AGENTS.md | 7 + CHANGELOG.md | 31 ++ README.md | 8 + Rakefile | 5 + context7.json | 17 + docs/agents.md | 254 +++++++++++ docs/operations.md | 12 +- docs/virtual-actors.md | 226 ++++++++++ examples/quickstart/README.md | 246 ++++++++++ examples/quickstart/app/actors/ticket_sale.rb | 22 + examples/quickstart/smoke.rb | 421 ++++++++++++++++++ solid_objects.gemspec | 6 +- test/unit/gem_specification_test.rb | 27 ++ 14 files changed, 1286 insertions(+), 8 deletions(-) create mode 100644 context7.json create mode 100644 docs/agents.md create mode 100644 docs/virtual-actors.md create mode 100644 examples/quickstart/README.md create mode 100644 examples/quickstart/app/actors/ticket_sale.rb create mode 100644 examples/quickstart/smoke.rb create mode 100644 test/unit/gem_specification_test.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d3470d..c7fd656 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,17 @@ jobs: - run: bundle exec rake - run: bundle exec rake at_least_once + quickstart: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1 + with: + ruby-version: "3.3" + bundler-cache: true + - run: bundle exec rake quickstart + javascript: runs-on: ubuntu-latest timeout-minutes: 15 @@ -148,6 +159,7 @@ jobs: if: startsWith(github.ref, 'refs/tags/v') needs: - sqlite + - quickstart - postgresql - mysql - static diff --git a/AGENTS.md b/AGENTS.md index 357ddb7..ed53cbb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,13 @@ git push origin v0.5.0 CI validates the tag and publishes through RubyGems trusted publishing. +Before you tag, read `docs/virtual-actors.md` and `docs/agents.md` against the +release and correct any requirement, compatibility, or guarantee statement that +changed. After the tag publishes, refresh the solidobjects.dev documentation +snapshot from the tag and redeploy the site; its `check:release` step refuses a +snapshot that is not the latest published tag. Then trigger a Context7 refresh +for this repository. + ## Security & Configuration Preserve deny-by-default authorization. Never treat actor IDs, stream names, or signed tokens as authorization, and never commit secrets or unsafe deserialization paths. diff --git a/CHANGELOG.md b/CHANGELOG.md index a57ed07..0a8cc88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog +## Unreleased + +- Name the category in the gem metadata and the README: Solid Objects is a + SQL-backed virtual actor library for Ruby on Rails. The gem homepage now + links to `https://solidobjects.dev/ruby` instead of the site root, which + redirects to the Node page. +- Add `docs/virtual-actors.md`, a category guide with the definition, a small + example, fit and poor-fit criteria, comparisons, and an Orleans concept map. +- Add `docs/agents.md`, a consumer guide for coding agents with setup, + authorization, effect idempotency, verification, and troubleshooting steps. + Both guides ship in the gem. +- Add `context7.json` so that Context7 indexes the consumer documentation and + skips maintainer files. +- Add a Rails quickstart in `examples/quickstart/` and a `rake quickstart` + check that runs it against the built gem. The check builds the gem, creates + a new SQLite Rails application, installs the gem from `vendor/cache` with + `bundle install --local`, and confirms by checksum and load path that the + application loads the built gem. It runs the install generator, the + migrations, and the doctor, and grants only the message and query policies. + It sends eight concurrent holds from separate processes to the README's + `TicketSale` actor and confirms that exactly one hold commits. It stops the + runtime, waits until a reminder is past due, confirms that the reminder did + not run, restarts the runtime, and confirms that the reminder released the + hold once. The check also fails when a `TicketSale` sample in the README or + in `docs/` differs from the actor that it runs. A new `quickstart` CI job runs + the check, and the release job waits for it. +- Correct the `json` 3.0.2 note in `docs/operations.md`. Only Active Support + 8.1.3.1 and earlier 8.1 releases fail with it; Active Support 8.1.4 decodes + correctly. Upgrade Rails, or pin `json` to 2.x when an upgrade is not + possible. + ## 0.17.0 - 2026-10-03 - Publish RBS types for portable events, metric samples, actor diagnostics, and diff --git a/README.md b/README.md index 118cc09..d63e75a 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,12 @@ **Open Source Durable Objects in your Rails app.** +Solid Objects is a SQL-backed virtual actor library for Ruby on Rails, with +durable state, ordered operations, and automatic activation. Each actor has a +stable identity, and its state lives in the SQL database that your app already +uses. [Virtual actors in Ruby on Rails](docs/virtual-actors.md) explains the +model and when to use it. + In a shopping cart, paying twice at the same time is a big problem. The payment provider might time out, and your Rails site could be restarting before recovery finishes. To deal with this safely, you often need logic scattered between 7-10 files like database row locks, Redis locks, delayed jobs, retries, and cleanup code to keep that process straight. They are not all large, but they must agree about the same payment state and failure rules. That coordination is the difficult part. @@ -160,6 +166,8 @@ Exactly once is not hiding in a more advanced configuration. Read the ## Read more - [Five-minute Rails guide](https://solidobjects.dev/5min/rails) +- [Virtual actors in Ruby on Rails](docs/virtual-actors.md) +- [Guide for coding agents](docs/agents.md) - [Choosing Solid Objects](docs/fit.md) - [Operations and recovery](docs/operations.md) - [Observability and diagnostics](docs/observability.md) diff --git a/Rakefile b/Rakefile index 5c3671b..ae5eea4 100644 --- a/Rakefile +++ b/Rakefile @@ -37,6 +37,11 @@ task :at_least_once do sh "bundle exec ruby examples/at_least_once/demo.rb" end +desc "Install the built gem into a new Rails app and prove ordering and restart recovery" +task :quickstart do + sh "bundle exec ruby examples/quickstart/smoke.rb" +end + desc "Scan the Rails engine for security warnings" task :security do sh "bundle exec brakeman --force --no-pager -q ." diff --git a/context7.json b/context7.json new file mode 100644 index 0000000..c3bb94d --- /dev/null +++ b/context7.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://context7.com/schema/context7.json", + "projectTitle": "Solid Objects for Rails (solid_objects)", + "description": "SQL-backed virtual actor library for Ruby on Rails. Actors have stable identities, durable JSON state in SQLite, PostgreSQL, or MySQL, ordered per-identity mailboxes, fenced activation, durable reminders, and transactional effects. Requires Ruby 3.3+ and Rails 7.1+.", + "folders": ["docs", "examples"], + "excludeFolders": ["./docs/adr", "./docs/research"], + "excludeFiles": ["AGENTS.md", "CLAUDE.md", "CONTRIBUTING.md", "implementation-plan.md"], + "rules": [ + "Solid Objects requires Rails 7.1 or newer and Ruby 3.3 or newer. It is a Rails engine, not a framework-independent Ruby library.", + "Install with bundle add solid_objects, bin/rails generate solid_objects:install, bin/rails db:migrate, and bin/rails solid_objects:doctor.", + "The generated authorization policies deny every operation. Production policies must bind the actor type and ID to the authenticated user or tenant, and callers pass authorization_context:.", + "Run bundle exec solid_objects start for reminders, async calls, effects, and broadcasts. Direct synchronous calls do not need it.", + "Delivery is at least once. Effect handlers registered with SolidObjects.register_effect must use context.id as the idempotency key.", + "Actor handlers must not write Active Record models directly. Use commit_action for a short same-database write and emit for external I/O.", + "There are no transactions across actor identities. One hot identity is sequential." + ] +} diff --git a/docs/agents.md b/docs/agents.md new file mode 100644 index 0000000..bda1f37 --- /dev/null +++ b/docs/agents.md @@ -0,0 +1,254 @@ +# Solid Objects for coding agents (Rails) + +This guide is for a coding agent that evaluates or adds Solid Objects in a +Ruby on Rails application. It gives the facts and steps in a fixed order. Each +step links to the maintained documentation for more detail. + +## 1. Check the fit first + +Use Solid Objects only if you can answer yes to most of these questions: + +1. Does one identity, such as a cart, room, booking, account, or device, own + the state? +2. Must writes for that identity happen one at a time across requests, jobs, + and processes? +3. Must some work happen later or continue after a process restart? +4. Is the state a bounded JSON document, not a large relational dataset? +5. Can the application run one more process, `bundle exec solid_objects start`, + for reminders and background work? + +Select a simpler tool in these cases: + +| Requirement | Use instead | +| --- | --- | +| The invariant fits in one request | `with_lock`, a unique index, or a short transaction | +| CPU-intensive or parallel work | Background jobs or Ractors. An actor serializes work | +| Thousands of writes for each second to one key | A counter column, a cache, or Solid Objects Pro | +| One atomic change across two actor identities | One SQL transaction on normal tables | +| Exactly-once calls to an external API | Not available from any actor library. Use provider idempotency keys | + +The full checklist is in [Choosing Solid Objects](fit.md). The category guide +is [Virtual actors in Ruby on Rails](virtual-actors.md). + +## 2. Package identity + +| Item | Value | +| --- | --- | +| Gem | `solid_objects` | +| Repository | | +| Website | | +| Node.js package | `solid-objects` on npm, a separate port with the same model | +| Commercial extension | `solid_objects_pro`, a separate paid gem. The open-source gem does not need it | + +Solid Objects is not part of Rails. It is not Solid Queue, Solid Cache, or +Solid Cable. It is not affiliated with Cloudflare. + +## 3. Requirements + +- Ruby 3.3 or newer. +- Rails 7.1 or newer. The gem is a Rails engine. It does not run without + Rails. +- SQLite 3.35 or newer, PostgreSQL 14 or newer, or MySQL 8.0 or newer with + InnoDB. +- No Redis and no separate actor service. + +## 4. Install + +```bash +bundle add solid_objects +bin/rails generate solid_objects:install +bin/rails db:migrate +bin/rails solid_objects:doctor +``` + +The generator adds an initializer and copies migrations into the application. +The doctor checks the configuration, the tables, and one real actor round trip. + +If the doctor or an actor call fails with +`ArgumentError: wrong number of arguments (given 2, expected 1)` from +`ActiveSupport::JSON.decode`, the application has Active Support 8.1.3.1 or +earlier with `json` 3.0.2. Upgrade Rails to 8.1.4 or newer. If you cannot +upgrade, pin `gem "json", "~> 2"` in the `Gemfile`. + +[Installing and upgrading](operations.md#installing-and-upgrading) has the +details. + +## 5. Authorize + +Every policy in the generated initializer denies by default. A new +installation answers no actor call until you write a policy. Do not remove +this behavior. + +For a local demonstration only, grant messages and queries: + +```ruby +SolidObjects.configure do |configuration| + configuration.authorize_message = ->(**) { true } + configuration.authorize_query = ->(**) { true } +end +``` + +Keep `authorize_destroy`, `authorize_subscription`, +`authorize_administration`, and `authorize_transmission` denied in a +demonstration. + +A production policy must bind the actor type and ID to the authenticated user +or tenant. An actor ID is not a permission: + +```ruby +SolidObjects.configure do |configuration| + owns_cart = lambda do |actor_type:, actor_id:, authorization_context:, **| + user = authorization_context + + actor_type == "ShoppingCart" && + user.present? && + actor_id == user.id.to_s + end + + configuration.authorize_message = owns_cart + configuration.authorize_query = owns_cart +end +``` + +Pass the context on each call: + +```ruby +ShoppingCart.ref(Current.user.id.to_s).add_item( + product_id: "shirt-123", + authorization_context: Current.user +) +``` + +[Authorization policies](authorization.md) lists each policy and its risk. + +## 6. Define an actor + +Put actors in `app/actors/`. This actor is the example from the README: + +```ruby +class TicketSale < SolidObjects::Actor + attribute :available, default: 1 + attribute :holds, default: -> { {} } + + def hold(buyer:) + return { held: false, available: } if available.zero? || holds.key?(buyer) + + self.available -= 1 + self.holds = holds.merge(buyer => Time.current.to_i) + schedule(at: 10.minutes.from_now, key: buyer).expire(buyer:) + { held: true, available: } + end + + def expire(buyer:) + return available unless holds.key?(buyer) + + self.holds = holds.except(buyer) + self.available += 1 + end +end +``` + +Obey these rules in actor code: + +- Keep state in `attribute` values. State must be JSON-compatible. +- Use `schedule(at:, key:)` for delayed work. A reminder is one named alarm + for each actor and key. A new `schedule` with the same key moves the alarm. +- Use `reject(code, message)` for a business rule failure that must not retry. +- Do not write Active Record models directly in a handler. The runtime raises + `SolidObjects::ApplicationWriteForbidden`. Use `commit_action` for a short + write in the same database. +- Do not call an external API in a handler. Use `emit` and an effect handler. +- Write each handler so that it can run again. Delivery is at least once. + +[Reminders](reminders.md) and the [architecture guide](architecture.md) give +the full actor API. + +## 7. Run the runtime process + +A direct call, such as `TicketSale.ref("event-42").hold(buyer: "ada")`, runs in +the caller. It needs no worker. These features need the runtime process: + +- Reminders from `schedule`. +- `async` calls. +- Effects from `emit` and their callbacks. +- Broadcasts to Action Cable. + +Start it beside the web process: + +```bash +bundle exec solid_objects start +``` + +Add it to the `Procfile`, the process manager, or the deployment +configuration. When it stops, pending work stays in SQL and runs after it +starts again. [Operations](operations.md#runtime) covers roles and shutdown. + +## 8. Make external effects idempotent + +Register an effect handler at boot. Use `context.id` as the provider +idempotency key: + +```ruby +SolidObjects.register_effect(:charge_payment) do |arguments, context| + Payments.charge( + idempotency_key: context.id, + payment_id: arguments.fetch("payment_id"), + amount_cents: arguments.fetch("amount_cents") + ) +end +``` + +Stage it from the actor: + +```ruby +emit :charge_payment, payment_id:, amount_cents:, on_success: :charged +``` + +The effect can run more than once after a crash. The `context.id` value is the +same each time. [Effect recovery](effect-recovery.md) explains how to retire +abandoned work. + +## 9. Verify the implementation + +Do these checks before you report that the work is complete: + +1. Run `bin/rails solid_objects:doctor`. It must report no failures. +2. Write a test that includes `SolidObjects::TestHelper`. Send concurrent + calls to one identity from several threads. Assert the final state, for + example that only one hold succeeded. +3. Use `run_due_reminders(now:)` and `drain_solid_objects` to test delayed + work without sleeps. +4. Start `bundle exec solid_objects start`, schedule a short reminder, and stop + the process. Start it again after the deadline and confirm that the reminder + ran. +5. Confirm that each effect handler deduplicates with `context.id`. +6. Confirm that production policies do not grant access to every caller. + +[Host application tests](development.md#host-application-tests) describes the +test helper. The [clean-install quickstart](../examples/quickstart/README.md) +runs checks 2 and 4 against a new Rails application. + +## 10. Troubleshooting + +| Symptom | Cause and fix | +| --- | --- | +| `SolidObjects::Unauthorized` | A policy denied the call. Write the policy, and pass `authorization_context:` | +| A reminder or `async` call does not run | The runtime process is not running. Start `bundle exec solid_objects start` | +| `SolidObjects::SyncInsideTransaction` | The call ran inside an open transaction. Call the actor outside the transaction. In tests, include `SolidObjects::TestHelper` | +| `SolidObjects::SyncTimeout` | The call did not finish in time. The message is still durable. Use its `message_reference` to wait for the result | +| `SolidObjects::ApplicationWriteForbidden` | A handler wrote a model directly. Use `commit_action` or `emit` | +| `SolidObjects::Rejected` | The actor called `reject`. This is a business result, not a retry | +| `ArgumentError` from `ActiveSupport::JSON.decode` | Upgrade Rails to 8.1.4 or newer, or pin `gem "json", "~> 2"` | + +## 11. Guarantees to state correctly + +When you explain Solid Objects to a user, state these limits: + +- Delivery is at least once, not exactly once. +- Calls for one identity are ordered. Different identities run concurrently. +- There are no transactions across actor identities. +- Fencing stops a stale activation from a commit, but its code can continue to + run. +- The gem is pre-1.0 and makes no production-ready claim. + +The [correctness contract](correctness.md) is the source for each guarantee. diff --git a/docs/operations.md b/docs/operations.md index df585c4..e552c37 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -26,22 +26,24 @@ reports a failed or warned check rather than raising out of the command. ## Installing and upgrading Solid Objects keeps actor state, message arguments, results, and the remembered -idempotency keys in JSON columns. Active Support decodes every one of them, and -`ActiveSupport::JSON.decode` raises with the `json` gem at 3.0.2: +idempotency keys in JSON columns. Active Support decodes every one of them. +Active Support 8.1.3.1 and earlier 8.1 releases fail with the `json` gem at +3.0.2. `ActiveSupport::JSON.decode` raises: ``` ArgumentError: wrong number of arguments (given 2, expected 1) ``` The failure is in Active Support rather than in Solid Objects, and it reaches -every JSON column in a Rails application. A new Rails 8.1 application resolves -`json` 3.0.2 today, so pin the 2.x series until Rails ships a fix: +every JSON column in a Rails application. Active Support 8.1.4 decodes +correctly with `json` 3.0.2. Upgrade Rails to 8.1.4 or newer. If you cannot +upgrade, pin the 2.x series of `json`: ```ruby gem "json", "~> 2" ``` -Review [CHANGELOG.md](CHANGELOG.md) for compatibility and deployment-order +Review [CHANGELOG.md](../CHANGELOG.md) for compatibility and deployment-order notes, then update the gem: ```bash diff --git a/docs/virtual-actors.md b/docs/virtual-actors.md new file mode 100644 index 0000000..6097796 --- /dev/null +++ b/docs/virtual-actors.md @@ -0,0 +1,226 @@ +# Virtual actors in Ruby on Rails + +## Short answer + +Yes. Solid Objects is a SQL-backed virtual actor library for Ruby on Rails. +The gem is `solid_objects`. It gives each actor a stable identity, durable +state, ordered operations, and automatic activation. + +Solid Objects requires Rails. It is a Rails engine for Ruby 3.3 or newer and +Rails 7.1 or newer. It is not a framework-independent Ruby actor runtime. +State and mailboxes live in the SQLite, PostgreSQL, or MySQL database that the +Rails application already uses. Redis and a separate actor service are not +necessary. + +Solid Objects is a pre-1.0 release. It makes no production-ready claim. Read +[Compatibility and maturity](#compatibility-and-maturity) before you choose it. + +## What a virtual actor is + +A virtual actor is a logical object that always exists by name. The caller +does not create it, start it, or stop it. The runtime loads it when a message +arrives and releases it when it is idle. Microsoft Orleans made this model +known as "virtual actors". + +Solid Objects implements four properties of that model: + +| Property | What it means | How Solid Objects does it | +| --- | --- | --- | +| Stable identity | An actor is addressed by type and ID, for example one cart per user. | `ShoppingCart.ref(user.id)` returns a cheap reference. The reference does not load the actor. | +| Automatic activation | The first message activates the actor. An idle actor is released. | A process claims a fenced activation lease when work arrives. The lease is released after the idle timeout. | +| Durable state | State survives process restarts and deploys. | Actor attributes are a JSON document in the application database. | +| Ordered turns | One identity runs one operation at a time, in a fixed order. | Each call is a durable mailbox message with a per-actor sequence number. | + +Different identities run concurrently. One identity is a serialization point +on purpose. + +## A small example + +This actor holds one ticket for a buyer and releases the hold after ten +minutes. Put it in `app/actors/ticket_sale.rb`: + +```ruby +class TicketSale < SolidObjects::Actor + attribute :available, default: 1 + attribute :holds, default: -> { {} } + + def hold(buyer:) + return { held: false, available: } if available.zero? || holds.key?(buyer) + + self.available -= 1 + self.holds = holds.merge(buyer => Time.current.to_i) + schedule(at: 10.minutes.from_now, key: buyer).expire(buyer:) + { held: true, available: } + end + + def expire(buyer:) + return available unless holds.key?(buyer) + + self.holds = holds.except(buyer) + self.available += 1 + end +end +``` + +Call it from a controller, a job, or the console: + +```ruby +TicketSale.ref("event-42").hold(buyer: "ada") +``` + +Two concurrent `hold` calls for `event-42` enter the same mailbox. They commit +one at a time, so only one buyer gets the ticket. The hold and its reminder +commit in one transaction. The reminder runs in the Solid Objects runtime +process: + +```bash +bundle exec solid_objects start +``` + +If that process stops, the reminder stays in SQL. It runs when the process +starts again. + +The example needs installation, migrations, and an authorization policy. The +generated policies deny every operation by default. For the complete setup, +use one of these guides: + +- [Installation](../README.md#installation) in the README. +- [The clean-install quickstart](../examples/quickstart/README.md), which a + smoke check runs against the built gem in a new Rails application. +- [The agent guide](agents.md), which gives setup and verification steps for + coding agents. + +## When to use it + +Solid Objects is a good candidate when most of these conditions are true: + +- State belongs to one durable identity, such as a cart, room, booking, + device, account, or workflow. +- Writes for that identity must be serialized across requests, jobs, and + processes. +- The work must happen later, survive a restart, or stay ordered across more + than one request. +- The state is a bounded JSON document. +- The identity needs reminders, external effects, or reactive Rails views that + follow its committed state. + +## When to use something else + +Do not use an actor when a simpler tool enforces the invariant: + +- One short transaction, `with_lock`, or a database constraint is enough. A row + lock is often the clearest answer. +- The work is CPU-intensive or data-parallel. An actor serializes work. It does + not add CPU parallelism. +- One hot identity must accept many writes for each second, for example a + request-path rate limiter or a page-view counter. +- The state is large, relational, or query-heavy. Keep that data in normal + tables. +- The operation must change two actor identities in one atomic transaction. + Solid Objects has no cross-actor transactions. +- You need exactly-once calls to an external API. No actor library can promise + that through every network failure. Solid Objects gives at-least-once + delivery and stable effect IDs for idempotency keys. +- You need replay of named workflow steps from a step log. That is a durable + execution engine, not an actor. + +[Choosing Solid Objects](fit.md) has the full checklist and the cost model. + +## How it compares + +This table compares coordination models for a Rails application. It does not +rank the projects. Facts about other projects were checked on October 7, 2026, +against the sources in [Primary references](#primary-references). + +| Approach | Unit of order | Durable state | Delayed work | Extra service | Good for | +| --- | --- | --- | --- | --- | --- | +| `with_lock` or a short SQL transaction | Rows in one transaction | Application tables | None | No | An invariant that fits in one request | +| Active Job with Solid Queue | None. `limits_concurrency` limits overlap for each key, but it does not set an order | Application tables, owned by the application | Scheduled jobs | No; Solid Queue uses the database | Background work that does not own entity state | +| Sidekiq | None in the open-source gem. Unique jobs and rate limits are Sidekiq Enterprise features | Application tables, owned by the application | Scheduled jobs | Redis | High-volume background jobs | +| In-process concurrency: Ractor, `concurrent-ruby-edge` actors, Async | One Ruby object in one process | None. State is lost when the process stops | In process only | No | Parallel or concurrent work inside one process. Ractor is experimental in Ruby 4.0 | +| Solid Objects | Actor type and ID | JSON state in the application database | Durable per-actor reminders | No; the `solid_objects start` process runs in the app | Durable per-identity state with ordered operations | +| Dapr actors | Actor type and ID, one turn at a time | A transactional Dapr state store | Durable reminders through the Dapr Scheduler service | A Dapr sidecar, plus the placement and Scheduler services. Dapr has no official Ruby SDK | Polyglot services on a Dapr platform | +| Temporal (`temporalio` gem) | A workflow execution | Temporal event history | Durable timers | A Temporal Service, self-hosted or Temporal Cloud | Long-running workflows that replay deterministic code | + +### Orleans concept map + +Orleans is the reference design for virtual actors on .NET. Solid Objects +uses the same programming model on a SQL database. It does not copy the +Orleans cluster, placement, or feature set. + +| Orleans | Solid Objects | Difference | +| --- | --- | --- | +| Grain class | `SolidObjects::Actor` subclass | None in concept | +| Grain identity (key) | Actor type and actor ID | None in concept | +| Activation on first call | Activation lease on first claimed message | Solid Objects fences each activation with a database generation | +| Turn-based execution | Ordered mailbox, one turn at a time | Orleans can enable reentrancy. Solid Objects turns for one identity never interleave | +| Grain persistence | JSON attributes in the application database | An Orleans grain calls `WriteStateAsync`. Solid Objects persists state with the turn that changed it | +| Reminders | `schedule` | Both are durable. Orleans skips a tick that falls due while the cluster is down. A due Solid Objects reminder runs when a runtime process starts. Solid Objects has no non-durable timers | +| Silos and cluster membership | Any Rails process that runs `solid_objects start` | Solid Objects has no placement, directory, or cluster membership. The database is the coordination point | +| Streams | Observables, broadcasts, and reactive ERB | Solid Objects delivers committed revisions to Action Cable | + +Solid Objects delivery is at least once. Write each handler so that it can run +again without harm. + +## Guarantees and boundaries + +- Calls are durably ordered per identity. Different identities can run + concurrently. +- Delivery is at least once, not exactly once. A handler can start again after + a crash or a lost lease. +- Ordered turns do not cancel stale Ruby code. Fencing stops a stale activation + from a commit, but that code can continue to run. +- External effects can run more than once. Use the stable effect ID, or + another durable key, as the idempotency key at the provider. +- There are no transactions across actor identities, and there is no replay of + durable function steps. +- Reminders, `async` calls, effects, and broadcasts need the + `bundle exec solid_objects start` process. When that process stops, the work + waits in SQL. The gem does not supply a hosted worker. +- One hot identity is sequential. The core gem is not a high-throughput + request-path rate limiter. +- The guarantees apply only to changes made through the actor APIs. Actor + fencing does not protect direct writes to the same data or other external + requests. + +The [correctness contract](correctness.md) states each guarantee and the crash +matrix. + +## Compatibility and maturity + +- Ruby 3.3 or newer and Rails 7.1 or newer. CI runs Ruby 3.3, 3.4, and 4.0 + against Rails 7.1, 7.2, 8.0, and 8.1. +- SQLite 3.35 or newer, PostgreSQL 14 or newer, or MySQL 8.0 or newer with + InnoDB. MySQL works through `mysql2` or `trilogy`. +- SQLite is correct for the same contract, but it is best for development and + modest single-host workloads. +- Pre-1.0. Expect changes that break compatibility. The correctness core has tests against all + three databases. The project has no production-ready claim and no measured + scale claim. + +The [roadmap](roadmap.md) records what is tested, what is partial, and what is +next. For TypeScript and Node.js, use the +[solid-objects](https://github.com/cardmagic/solid-objects-js) package. + +## Primary references + +- Orleans: [Overview](https://learn.microsoft.com/en-us/dotnet/orleans/overview), + [Request scheduling](https://learn.microsoft.com/en-us/dotnet/orleans/grains/request-scheduling), + [Timers and reminders](https://learn.microsoft.com/en-us/dotnet/orleans/grains/timers-and-reminders), + [Grain persistence](https://learn.microsoft.com/en-us/dotnet/orleans/grains/grain-persistence/), + and [Grain placement](https://learn.microsoft.com/en-us/dotnet/orleans/grains/grain-placement). +- Ruby: [Ractor](https://docs.ruby-lang.org/en/4.0/Ractor.html) and the + [Ruby 4.0.0 release notes](https://www.ruby-lang.org/en/news/2025/12/25/ruby-4-0-0-released/). +- concurrent-ruby: [repository](https://github.com/ruby-concurrency/concurrent-ruby) + and [`Concurrent::Actor`](https://ruby-concurrency.github.io/concurrent-ruby/master/Concurrent/Actor.html). +- Solid Queue: [concurrency controls](https://github.com/rails/solid_queue#concurrency-controls). +- Sidekiq: [Enterprise unique jobs](https://github.com/sidekiq/sidekiq/wiki/Ent-Unique-Jobs) + and [Enterprise rate limiting](https://github.com/sidekiq/sidekiq/wiki/Ent-Rate-Limiting). +- Dapr: [Actors overview](https://docs.dapr.io/developing-applications/building-blocks/actors/actors-overview/), + [Actor timers and reminders](https://docs.dapr.io/developing-applications/building-blocks/actors/actors-timers-reminders/), + and [SDKs](https://docs.dapr.io/developing-applications/sdks/). +- Temporal: [Ruby SDK](https://github.com/temporalio/sdk-ruby) and + [Event History](https://docs.temporal.io/encyclopedia/event-history). + +Other projects change. Check these sources again before you base an +architecture decision on one row. diff --git a/examples/quickstart/README.md b/examples/quickstart/README.md new file mode 100644 index 0000000..40e76bc --- /dev/null +++ b/examples/quickstart/README.md @@ -0,0 +1,246 @@ +# Rails quickstart + +This recipe puts Solid Objects into a new Rails application. It uses SQLite, +one actor, and one reminder. A person or a coding agent can follow it on a +clean machine. + +The recipe shows three behaviors: + +- Concurrent calls to one actor identity commit one at a time. +- The actor state stays in the application's SQL database. +- A reminder that is due while the runtime is stopped runs after the runtime + starts again. + +`bundle exec rake quickstart` runs the same recipe against a gem that it builds +from this repository. See [What the check proves](#what-the-check-proves). + +## Requirements + +- Ruby 3.3 or newer +- Rails 7.1 or newer +- SQLite 3.35 or newer + +Solid Objects needs Rails. It is not a Ruby library that you can use without +Rails. + +## 1. Create the application + +```bash +rails new ticket_demo +cd ticket_demo +``` + +Active Support 8.1.3.1 and the `json` gem 3.0.2 do not work together: +`ActiveSupport::JSON.decode` raises an `ArgumentError`, and Solid Objects +decodes its JSON columns with it. Active Support 8.1.4 does not have this +problem. If `Gemfile.lock` shows both of the incompatible versions, read +[Installing and upgrading](../../docs/operations.md#installing-and-upgrading) +before you continue. + +## 2. Install Solid Objects + +```bash +bundle add solid_objects +bin/rails generate solid_objects:install +bin/rails db:migrate +bin/rails solid_objects:doctor +``` + +The generator writes `config/initializers/solid_objects.rb` and copies the +migrations. The migrations add the Solid Objects tables to the application's +existing database. + +The generated initializer denies every operation. The doctor reports this +condition as a warning: + +```text +WARN authorization: all five policies denied a neutral context; review the generated initializer before use +``` + +On SQLite, the doctor also warns that the runtime polls for work. SQLite has no +notification channel, so a commit in one process cannot wake another process. +The runtime finds the work at the next poll. + +## 3. Grant only what the local demo needs + +The demo calls the actor and reads its state from a console. It needs the +message policy and the query policy. In +`config/initializers/solid_objects.rb`, change these two lines: + +```ruby +configuration.authorize_message = ->(**) { false } +configuration.authorize_query = ->(**) { false } +``` + +to: + +```ruby +configuration.authorize_message = ->(**) { true } +configuration.authorize_query = ->(**) { true } +``` + +Do not change `authorize_destroy`, `authorize_subscription`, +`authorize_administration`, or `authorize_transmission`. They stay denied. + +These two grants are for a local demo only. They let any caller send any +message to any actor and read any actor state. A production policy must bind +the actor type, the actor ID, and the operation to the authenticated user or +tenant. Pass that principal at each call site: + +```ruby +TicketSale.ref(event.id.to_s).hold( + buyer: Current.user.id.to_s, + authorization_context: Current.user +) +``` + +Then check it in the policy. `can_buy_tickets_for?` is an example method of +your application: + +```ruby +configuration.authorize_message = lambda do |actor_type:, actor_id:, operation:, authorization_context:, **| + user = authorization_context + + actor_type == "TicketSale" && + operation == "hold" && + user.present? && + user.can_buy_tickets_for?(actor_id) +end +``` + +The reminder that calls `expire` comes from a committed runtime row. It does +not go through `authorize_message` again. + +[Authorization](../../docs/authorization.md) describes every policy and its +arguments. + +## 4. Add the actor + +Put this class in `app/actors/ticket_sale.rb`: + +```ruby +class TicketSale < SolidObjects::Actor + attribute :available, default: 1 + attribute :holds, default: -> { {} } + + def hold(buyer:) + return { held: false, available: } if available.zero? || holds.key?(buyer) + + self.available -= 1 + self.holds = holds.merge(buyer => Time.current.to_i) + schedule(at: 10.minutes.from_now, key: buyer).expire(buyer:) + { held: true, available: } + end + + def expire(buyer:) + return available unless holds.key?(buyer) + + self.holds = holds.except(buyer) + self.available += 1 + end +end +``` + +One actor owns the state of one event. The event ID is the actor ID. A +successful `hold` stores the hold and a keyed ten-minute reminder in the same +commit as the state change. The `expire` method checks that the hold still +exists, so a second delivery of the same reminder does not add a ticket. + +## 5. Start the runtime + +Direct calls do not need a runtime process. The caller runs the turn. Reminders, +`async` messages, effects, and broadcasts need the runtime: + +```bash +bundle exec solid_objects start +``` + +When the runtime is stopped, due work stays in the database. Nothing else runs +it. Run the runtime as a separate process in every environment that uses +reminders. + +## 6. Hold a ticket + +Open a second terminal: + +```bash +bin/rails console +``` + +```ruby +sale = TicketSale.ref("concert") +sale.hold(buyer: "ada") +sale.hold(buyer: "grace") +sale.available +``` + +The first call returns `{"held" => true, "available" => 0}`. The second call +returns `{"held" => false, "available" => 0}`. A synchronous call returns the +committed result as JSON data, so the keys are strings. + +## 7. Restart the runtime before the reminder runs + +1. Stop the runtime with Ctrl-C before ten minutes pass. +2. Wait until the ten minutes pass. +3. In the console, read `sale.available`. The value is still `0`, because no + process ran the reminder. +4. Start the runtime again with `bundle exec solid_objects start`. +5. Read `sale.available` again. The runtime runs the due reminder, and the value + is `1`. + +## Guarantees in this demo + +- Calls to one actor ID commit in order, one at a time. Different actor IDs can + run concurrently. +- Delivery is at least once. A handler can run again after a crash. The guard + in `expire` makes a second delivery harmless. +- Each turn commits the state and the reminder together, or commits neither. +- The demo has no external effects. An external effect, such as an email or a + payment, can repeat. It must use the stable effect ID or another durable + idempotency key. +- One actor ID is a sequential bottleneck. Do not use one actor ID for the + whole application. + +[Correctness](../../docs/correctness.md) gives the full contract. + +## What the check proves + +`bundle exec rake quickstart` runs [`smoke.rb`](smoke.rb). The check does these +steps: + +1. It confirms that this recipe and every `TicketSale` sample in the README and + in `docs/` show the exact actor in + [`app/actors/ticket_sale.rb`](app/actors/ticket_sale.rb). +2. It builds the gem with `gem build`. +3. It runs `rails new --minimal` with SQLite in a temporary directory and runs + `bundle install` into a temporary bundle path. +4. It copies the built gem into `vendor/cache`, adds + `gem "solid_objects", "= "` to the `Gemfile`, and runs + `bundle install --local`. The application cannot get the gem from the + repository or from rubygems.org. +5. It confirms which gem the application loads: + - the `Gemfile` has no `path:` option for `solid_objects`; + - the `Gemfile.lock` checksum is the checksum of the built gem; + - the loaded gem files are in the temporary bundle path, not in the + repository; + - the installed gem file has the checksum of the built gem. +6. It runs the install generator, the migrations, and the doctor. Then it + writes the actor and the two demo grants. It confirms that the other + policies stay denied. +7. It starts `bundle exec solid_objects start`. Eight separate `bin/rails runner` + processes wait at a barrier and then hold the same event at the same time. + The check confirms that exactly one buyer holds the only ticket and that the + durable state agrees. +8. It stops the runtime. One more process places a hold for a second event. That + process shifts its own clock back with `travel_to`, so the ten-minute + reminder is due eight seconds later. The reminder scheduler compares due + times with the database clock, so the runtime does not need a shifted + clock. +9. It waits until the reminder is past due and confirms that the hold is still + there while no runtime runs. Then it starts the runtime again and confirms + that the reminder released the hold exactly once. +10. It stops every child process and removes the temporary directory, also + when a step fails. + +The check needs network access for `rails new` and `bundle install`. It takes +one to three minutes. The `quickstart` CI job runs it on every push. diff --git a/examples/quickstart/app/actors/ticket_sale.rb b/examples/quickstart/app/actors/ticket_sale.rb new file mode 100644 index 0000000..559ae34 --- /dev/null +++ b/examples/quickstart/app/actors/ticket_sale.rb @@ -0,0 +1,22 @@ +# rbs_inline: enabled + +class TicketSale < SolidObjects::Actor + attribute :available, default: 1 + attribute :holds, default: -> { {} } + + def hold(buyer:) + return { held: false, available: } if available.zero? || holds.key?(buyer) + + self.available -= 1 + self.holds = holds.merge(buyer => Time.current.to_i) + schedule(at: 10.minutes.from_now, key: buyer).expire(buyer:) + { held: true, available: } + end + + def expire(buyer:) + return available unless holds.key?(buyer) + + self.holds = holds.except(buyer) + self.available += 1 + end +end diff --git a/examples/quickstart/smoke.rb b/examples/quickstart/smoke.rb new file mode 100644 index 0000000..8c0b942 --- /dev/null +++ b/examples/quickstart/smoke.rb @@ -0,0 +1,421 @@ +# rbs_inline: enabled + +require "bundler" +require "digest" +require "fileutils" +require "json" +require "rbconfig" +require "rubygems/package" +require "timeout" +require "tmpdir" + +class QuickstartSmoke + class Failure < StandardError; end + + HoldProcess = Data.define(:buyer, :pid, :input, :output) + + REPOSITORY_ROOT = File.expand_path("../..", __dir__) + QUICKSTART_ROOT = __dir__ || raise(Failure, "the quickstart directory is unknown") + ACTOR_PATH = File.join(QUICKSTART_ROOT, "app/actors/ticket_sale.rb") + RECIPE_PATH = File.join(QUICKSTART_ROOT, "README.md") + INITIALIZER = "config/initializers/solid_objects.rb" + DEMO_GRANTS = { + "configuration.authorize_message = ->(**) { false }" => "configuration.authorize_message = ->(**) { true }", + "configuration.authorize_query = ->(**) { false }" => "configuration.authorize_query = ->(**) { true }" + }.freeze + DENIED_POLICIES = %w[authorize_destroy authorize_subscription authorize_administration].freeze + CONCURRENT_BUYERS = 8 + RESTART_DEADLINE_SECONDS = 8 + COMMAND_TIMEOUT_SECONDS = 600 + RECOVERY_TIMEOUT_SECONDS = 60 + + HOLD_PROGRAM = <<~RUBY + $stdout.sync = true + puts "ready" + $stdin.gets + puts JSON.generate(TicketSale.ref(ARGV.fetch(0)).hold(buyer: ARGV.fetch(1))) + RUBY + + SHIFTED_CLOCK_HOLD_PROGRAM = <<~RUBY + require "active_support/testing/time_helpers" + include ActiveSupport::Testing::TimeHelpers + travel_to(Time.current - 10.minutes + Integer(ARGV.fetch(2)).seconds) do + puts JSON.generate(TicketSale.ref(ARGV.fetch(0)).hold(buyer: ARGV.fetch(1))) + end + RUBY + + STATE_PROGRAM = <<~RUBY + puts JSON.generate(TicketSale.ref(ARGV.fetch(0)).snapshot.to_h) + RUBY + + RECOVERY_PROGRAM = <<~RUBY + sale = TicketSale.ref(ARGV.fetch(0)) + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + Integer(ARGV.fetch(1)) + state = sale.snapshot.to_h + until state.fetch("holds").empty? || Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline + sleep 0.25 + state = sale.snapshot.to_h + end + puts JSON.generate(state) + RUBY + + RESOLUTION_PROGRAM = <<~RUBY + specification = Gem.loaded_specs.fetch("solid_objects") + puts JSON.generate( + version: SolidObjects::VERSION, + rails: Rails.version, + json: JSON::VERSION, + full_gem_path: specification.full_gem_path, + cache_file: specification.cache_file, + loaded_feature: $LOADED_FEATURES.find { |feature| feature.end_with?("/solid_objects/version.rb") } + ) + RUBY + + # @rbs @root: String + # @rbs @log_directory: String + # @rbs @application_path: String + # @rbs @bundle_path: String + # @rbs @child_pids: Array[Integer] + # @rbs @started_at: Float + + # @rbs () -> void + def initialize + @root = Dir.mktmpdir("solid_objects_quickstart_") + @log_directory = File.join(@root, "logs") + @application_path = File.join(@root, "ticket_demo") + @bundle_path = File.join(@root, "bundle") + @child_pids = [] + @started_at = monotonic_now + end + + # @rbs () -> void + def call + actor_source = verify_recipe + artifact = build_gem + generate_application + install_bundle(artifact) + resolution = verify_resolution(artifact) + install_solid_objects(actor_source) + concurrency = prove_concurrent_holds + restart = prove_restart_recovery + puts JSON.pretty_generate( + artifact: { version: artifact.fetch(:version), sha256: artifact.fetch(:sha256), resolved_from: resolution.fetch("full_gem_path") }, + application: { rails: resolution.fetch("rails"), json: resolution.fetch("json") }, + concurrency:, + restart:, + seconds: (monotonic_now - @started_at).round(1) + ) + ensure + stop_children + FileUtils.remove_entry(@root) if @root && File.exist?(@root) + end + + private + + # @rbs () -> String + def verify_recipe + prove("examples/quickstart/README.md exists") { File.file?(RECIPE_PATH) } + prove("examples/quickstart/app/actors/ticket_sale.rb exists") { File.file?(ACTOR_PATH) } + actor_source = File.read(ACTOR_PATH).delete_prefix("# rbs_inline: enabled\n\n") + recipe = File.read(RECIPE_PATH) + prove("the recipe shows the exact actor that the check runs") { recipe.include?(actor_source) } + DEMO_GRANTS.each_value do |grant| + prove("the recipe shows the demo grant #{grant}") { recipe.include?(grant) } + end + published_samples.each do |path, sample| + prove("#{path} shows the same TicketSale actor") { sample == actor_source.strip } + end + actor_source + end + + # @rbs () -> Array[[String, String]] + def published_samples + paths = Dir[File.join(REPOSITORY_ROOT, "{README.md,docs/**/*.md,examples/**/*.md}")] + paths.flat_map do |path| + relative_path = path.delete_prefix("#{REPOSITORY_ROOT}/") + File.read(path).scan(/```ruby\n(class TicketSale < SolidObjects::Actor\n.*?\nend)\n```/m).map do |(sample)| + [ relative_path, sample ] + end + end + end + + # @rbs () -> Hash[Symbol, String] + def build_gem + artifact_directory = File.join(@root, "artifact") + FileUtils.mkdir_p(artifact_directory) + version = File.read(File.join(REPOSITORY_ROOT, "lib/solid_objects/version.rb"))[/VERSION = "([^"]+)"/, 1] || + raise(Failure, "lib/solid_objects/version.rb declares no version") + path = File.join(artifact_directory, "solid_objects-#{version}.gem") + run_in_repository("gem-build", RbConfig.ruby, gem_executable, "build", "solid_objects.gemspec", "--output", path) + prove("the built gem declares version #{version}") { Gem::Package.new(path).spec.version.to_s == version } + { path:, version:, sha256: Digest::SHA256.file(path).hexdigest } + end + + # @rbs () -> void + def generate_application + run_in_repository( + "rails-new", + RbConfig.ruby, Gem.bin_path("railties", "rails"), "new", @application_path, + "--minimal", "--skip-bundle", "--skip-git", "--skip-test", "--skip-system-test", "--quiet" + ) + end + + # @rbs (Hash[Symbol, String]) -> void + def install_bundle(artifact) + gemfile = File.join(@application_path, "Gemfile") + run_in_application("bundle-install", RbConfig.ruby, bundle_executable, "install") + cache_directory = File.join(@application_path, "vendor/cache") + FileUtils.mkdir_p(cache_directory) + FileUtils.cp(artifact.fetch(:path), cache_directory) + File.write(gemfile, "#{File.read(gemfile)}\ngem \"solid_objects\", \"= #{artifact.fetch(:version)}\"\n") + run_in_application("bundle-install-local", RbConfig.ruby, bundle_executable, "install", "--local") + end + + # @rbs (Hash[Symbol, String]) -> Hash[String, String] + def verify_resolution(artifact) + gemfile = File.read(File.join(@application_path, "Gemfile")) + prove("the Gemfile names no path dependency") { !gemfile.match?(/^\s*gem\s+"solid_objects".*path:/) } + lockfile = File.read(File.join(@application_path, "Gemfile.lock")) + prove("Gemfile.lock records the checksum of the built gem") do + lockfile.include?("solid_objects (#{artifact.fetch(:version)}) sha256=#{artifact.fetch(:sha256)}") + end + resolution = JSON.parse(rails_runner("resolution", RESOLUTION_PROGRAM).lines.last) + prove("the application loads the built version") { resolution.fetch("version") == artifact.fetch(:version) } + prove("the gem resolves inside the isolated bundle") { resolution.fetch("full_gem_path").start_with?(@bundle_path) } + loaded_feature = resolution.fetch("loaded_feature").to_s + prove("the gem loads its files from the isolated bundle") { loaded_feature.start_with?(@bundle_path) } + prove("the gem does not resolve to the repository") { !loaded_feature.start_with?(REPOSITORY_ROOT) } + prove("the installed gem is the built artifact") do + Digest::SHA256.file(resolution.fetch("cache_file")).hexdigest == artifact.fetch(:sha256) + end + resolution + end + + # @rbs (String) -> void + def install_solid_objects(actor_source) + run_in_application("generate", RbConfig.ruby, "bin/rails", "generate", "solid_objects:install") + run_in_application("migrate", RbConfig.ruby, "bin/rails", "db:migrate") + doctor = run_in_application("doctor", RbConfig.ruby, "bin/rails", "solid_objects:doctor") + prove("the doctor warns that every policy denies by default") { doctor.include?("all five policies denied") } + actor_path = File.join(@application_path, "app/actors/ticket_sale.rb") + FileUtils.mkdir_p(File.dirname(actor_path)) + File.write(actor_path, actor_source) + grant_demo_policies + end + + # @rbs () -> void + def grant_demo_policies + path = File.join(@application_path, INITIALIZER) + initializer = File.read(path) + DEMO_GRANTS.each do |denial, grant| + prove("the generated initializer denies with #{denial}") { initializer.include?(denial) } + initializer = initializer.sub(denial, grant) + end + DENIED_POLICIES.each do |policy| + prove("#{policy} stays denied") { initializer.include?("configuration.#{policy} = ->(**) { false }") } + end + File.write(path, initializer) + end + + # @rbs () -> Hash[Symbol, untyped] + def prove_concurrent_holds + runtime = start_runtime("runtime-concurrency") + buyers = (1..CONCURRENT_BUYERS).map { |number| "buyer-#{number}" } + holders = buyers.map { |buyer| spawn_hold(event: "concert", buyer:) } + holders.each { |holder| await_ready(holder) } + holders.each do |holder| + holder.input.puts("go") + holder.input.close + end + results = holders.to_h { |holder| [ holder.buyer, read_hold(holder) ] } + prove("the runtime kept running during the concurrent holds") { alive?(runtime) } + stop_runtime(runtime) + winners = results.select { |_, result| result.fetch("held") }.keys + state = read_state("concert") + prove("exactly one concurrent buyer held the only ticket") { winners.length == 1 } + prove("no concurrent update was lost") do + state.fetch("available").zero? && state.fetch("holds").keys == winners + end + { calls: buyers.length, held: winners.length, available: state.fetch("available") } + end + + # @rbs () -> Hash[Symbol, untyped] + def prove_restart_recovery + hold = JSON.parse( + rails_runner("shifted-hold", SHIFTED_CLOCK_HOLD_PROGRAM, "matinee", "ada", RESTART_DEADLINE_SECONDS.to_s).lines.last + ) + due_at = Time.now + RESTART_DEADLINE_SECONDS + prove("the restart hold succeeded while the runtime was stopped") { hold.fetch("held") } + sleep [ due_at + 2 - Time.now, 0 ].max + stopped = read_state("matinee") + prove("the due reminder waited while no runtime ran") do + stopped.fetch("available").zero? && stopped.fetch("holds").key?("ada") + end + runtime = start_runtime("runtime-recovery") + recovered = JSON.parse( + rails_runner("recovery", RECOVERY_PROGRAM, "matinee", RECOVERY_TIMEOUT_SECONDS.to_s).lines.last + ) + stop_runtime(runtime) + prove("the reminder ran after the restart and released the hold once") do + recovered.fetch("available") == 1 && recovered.fetch("holds").empty? + end + { available_while_stopped: stopped.fetch("available"), available_after_restart: recovered.fetch("available") } + end + + # @rbs (event: String, buyer: String) -> HoldProcess + def spawn_hold(event:, buyer:) + input_reader, input_writer = IO.pipe + output_reader, output_writer = IO.pipe + pid = spawn_in_application( + "hold-#{buyer}", RbConfig.ruby, "bin/rails", "runner", HOLD_PROGRAM, event, buyer, + in: input_reader, out: output_writer + ) + input_reader.close + output_writer.close + HoldProcess.new(buyer:, pid:, input: input_writer, output: output_reader) + end + + # @rbs (HoldProcess) -> void + def await_ready(holder) + Timeout.timeout(COMMAND_TIMEOUT_SECONDS) do + line = holder.output.gets + raise Failure, "#{holder.buyer} exited before it was ready\n#{log_tail("hold-#{holder.buyer}")}" if line.nil? + raise Failure, "#{holder.buyer} printed #{line.inspect} before it was ready" unless line == "ready\n" + end + end + + # @rbs (HoldProcess) -> Hash[String, untyped] + def read_hold(holder) + output = Timeout.timeout(COMMAND_TIMEOUT_SECONDS) { holder.output.read } + holder.output.close + status = wait(holder.pid) + raise Failure, "#{holder.buyer} failed\n#{log_tail("hold-#{holder.buyer}")}" unless status.success? + JSON.parse(output.lines.last) + end + + # @rbs (String) -> Hash[String, untyped] + def read_state(event) + JSON.parse(rails_runner("state-#{event}", STATE_PROGRAM, event).lines.last) + end + + # @rbs (String) -> Integer + def start_runtime(name) + spawn_in_application(name, RbConfig.ruby, bundle_executable, "exec", "solid_objects", "start") + end + + # @rbs (Integer) -> void + def stop_runtime(pid) + ::Process.kill("TERM", pid) + status = Timeout.timeout(COMMAND_TIMEOUT_SECONDS) { wait(pid) } + raise Failure, "the runtime did not stop cleanly: #{status.inspect}" unless status.success? + end + + # @rbs (String, String, *String) -> String + def rails_runner(name, program, *arguments) + run_in_application(name, RbConfig.ruby, "bin/rails", "runner", program, *arguments) + end + + # @rbs (String, *String) -> String + def run_in_repository(name, *command) + pid = spawn_logged(name, command, environment: {}, chdir: REPOSITORY_ROOT) + finish(name, pid) + end + + # @rbs (String, *String) -> String + def run_in_application(name, *command) + pid = Bundler.with_unbundled_env do + spawn_logged(name, command, environment: application_environment, chdir: @application_path) + end + finish(name, pid) + end + + # @rbs (String, *untyped, **untyped) -> Integer + def spawn_in_application(name, *command, **options) + Bundler.with_unbundled_env do + spawn_logged(name, command, environment: application_environment, chdir: @application_path, **options) + end + end + + # @rbs (String, Array[String], environment: Hash[String, String], chdir: String, **untyped) -> Integer + def spawn_logged(name, command, environment:, chdir:, **options) + FileUtils.mkdir_p(@log_directory) + log_path = File.join(@log_directory, "#{name}.log") + pid = ::Process.spawn(environment, *command, { chdir:, out: log_path, err: log_path }.merge(options)) + @child_pids << pid + pid + end + + # @rbs (String, Integer) -> String + def finish(name, pid) + status = Timeout.timeout(COMMAND_TIMEOUT_SECONDS) { wait(pid) } + raise Failure, "#{name} failed with #{status.inspect}\n#{log_tail(name)}" unless status.success? + File.read(File.join(@log_directory, "#{name}.log")) + end + + # @rbs (Integer) -> Process::Status + def wait(pid) + _pid, status = ::Process.wait2(pid) + @child_pids.delete(pid) + status + end + + # @rbs (Integer) -> bool + def alive?(pid) + ::Process.waitpid(pid, ::Process::WNOHANG).nil? + end + + # @rbs () -> void + def stop_children + @child_pids.dup.each do |pid| + ::Process.kill("TERM", pid) + Timeout.timeout(20) { wait(pid) } + rescue Errno::ESRCH, Errno::ECHILD + @child_pids.delete(pid) + rescue Timeout::Error + ::Process.kill("KILL", pid) + wait(pid) + end + end + + # @rbs () -> Hash[String, String] + def application_environment + { + "BUNDLE_GEMFILE" => File.join(@application_path, "Gemfile"), + "BUNDLE_PATH" => @bundle_path, + "BUNDLE_DEPLOYMENT" => "false", + "BUNDLE_FROZEN" => "false", + "BUNDLE_JOBS" => "4", + "RAILS_ENV" => "development" + } + end + + # @rbs (String) -> String + def log_tail(name) + path = File.join(@log_directory, "#{name}.log") + return "(no log)" unless File.file?(path) + + File.readlines(path).last(40).join + end + + # @rbs () -> String + def gem_executable + File.join(RbConfig::CONFIG.fetch("bindir"), "gem") + end + + # @rbs () -> String + def bundle_executable + Gem.bin_path("bundler", "bundle") + end + + # @rbs () -> Float + def monotonic_now + ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + end + + # @rbs (String) { () -> boolish } -> void + def prove(claim) + raise Failure, "quickstart proof failed: #{claim}" unless yield + end +end + +QuickstartSmoke.new.call diff --git a/solid_objects.gemspec b/solid_objects.gemspec index 330c683..312054d 100644 --- a/solid_objects.gemspec +++ b/solid_objects.gemspec @@ -8,9 +8,9 @@ Gem::Specification.new do |spec| spec.name = "solid_objects" spec.version = SolidObjects::VERSION spec.authors = [ "Lucas Carlson" ] - spec.summary = "Cloudflare Durable Objects, ported to Rails" - spec.description = "The Cloudflare Durable Objects programming model for Rails: addressable objects with durable state, ordered mailboxes, fenced activation, per-object alarms, transactional effects, and reactive ERB. It runs on MySQL, PostgreSQL, and SQLite without requiring Redis." - spec.homepage = "https://solidobjects.dev" + spec.summary = "SQL-backed virtual actors for Ruby on Rails" + spec.description = "Solid Objects is a SQL-backed virtual actor library for Ruby on Rails, with durable state, ordered operations, and automatic activation. It brings the Cloudflare Durable Objects programming model to Rails: addressable objects with ordered mailboxes, fenced activation, per-object reminders, transactional effects, and reactive ERB. It runs on MySQL, PostgreSQL, and SQLite without Redis." + spec.homepage = "https://solidobjects.dev/ruby" spec.license = "MIT" spec.metadata = { "allowed_push_host" => "https://rubygems.org", diff --git a/test/unit/gem_specification_test.rb b/test/unit/gem_specification_test.rb new file mode 100644 index 0000000..3857238 --- /dev/null +++ b/test/unit/gem_specification_test.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "test_helper" + +class GemSpecificationTest < ActiveSupport::TestCase + setup do + @specification = Gem::Specification.load( + File.expand_path("../../solid_objects.gemspec", __dir__) + ) + end + + test "links the homepage to the Rails page" do + assert_equal "https://solidobjects.dev/ruby", @specification.homepage + assert_equal @specification.homepage, @specification.metadata.fetch("homepage_uri") + end + + test "names the virtual actor category for Rails" do + assert_match(/virtual actors?/i, @specification.summary) + assert_match(/Rails/, @specification.summary) + assert_match(/SQL-backed virtual actor library for Ruby on Rails/, @specification.description) + end + + test "packages the agent and category guides" do + assert_includes @specification.files, "docs/agents.md" + assert_includes @specification.files, "docs/virtual-actors.md" + end +end From 143297ca171890d78ecf4fb0f6d8bdb26239f386 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Wed, 7 Oct 2026 23:21:25 -0700 Subject: [PATCH 2/4] chore: prepare version 0.17.1 --- CHANGELOG.md | 2 +- Gemfile.lock | 4 ++-- lib/solid_objects/version.rb | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a8cc88..3b97a4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 0.17.1 - 2026-10-08 - Name the category in the gem metadata and the README: Solid Objects is a SQL-backed virtual actor library for Ruby on Rails. The gem homepage now diff --git a/Gemfile.lock b/Gemfile.lock index 6d74bda..29a71f2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - solid_objects (0.17.0) + solid_objects (0.17.1) actioncable (>= 7.1) actionpack (>= 7.1) actionview (>= 7.1) @@ -384,7 +384,7 @@ CHECKSUMS rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 - solid_objects (0.17.0) + solid_objects (0.17.1) sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b diff --git a/lib/solid_objects/version.rb b/lib/solid_objects/version.rb index cfbcbd8..fa193c1 100644 --- a/lib/solid_objects/version.rb +++ b/lib/solid_objects/version.rb @@ -1,5 +1,5 @@ # rbs_inline: enabled module SolidObjects - VERSION = "0.17.0" + VERSION = "0.17.1" end From 7d2202fa99843a2d16e22568c2a6448cfaf25cf7 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Wed, 7 Oct 2026 23:30:21 -0700 Subject: [PATCH 3/4] ci: pin json 2 for Rails before 8.1 The compatibility matrix runs bundle lock --update. On October 3 it still resolved json 2.21.2, because rubocop 1.88.2 and standard 1.56.0 held json below 3. rubocop 1.91.0 and standard 1.57.0 allow json 3, so the matrix now resolves json 3.0.2, and every Rails 7.1, 7.2, and 8.0 job fails with "unknown keyword: quirks_mode". main fails the same way today. The break is in Active Support, not in Solid Objects. Local checks with json 3.0.2 show that Active Support 7.1.6 and 8.0.5.1 fail to encode and decode, 8.1.3.1 fails to decode, and only 8.1.4 works. An application on those Rails lines must pin json 2.x, so the matrix now pins it too and tests the configuration that the guide prescribes. The Rails 7.1 suite passes with the pin: 873 runs, 0 failures. Correct the json note in docs/operations.md, docs/agents.md, the quickstart README, and context7.json. The earlier note named only the 8.1 releases before 8.1.4. --- CHANGELOG.md | 10 ++++++---- Gemfile | 1 + context7.json | 18 +++++++++++++++--- docs/agents.md | 11 +++++------ docs/operations.md | 18 +++++++++--------- examples/quickstart/README.md | 8 ++++---- 6 files changed, 40 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b97a4c..5760f87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,10 +26,12 @@ hold once. The check also fails when a `TicketSale` sample in the README or in `docs/` differs from the actor that it runs. A new `quickstart` CI job runs the check, and the release job waits for it. -- Correct the `json` 3.0.2 note in `docs/operations.md`. Only Active Support - 8.1.3.1 and earlier 8.1 releases fail with it; Active Support 8.1.4 decodes - correctly. Upgrade Rails, or pin `json` to 2.x when an upgrade is not - possible. +- Correct the `json` 3.x note in `docs/operations.md`. The `json` gem 3.x works + only with Active Support 8.1.4 or newer. Active Support 7.1, 7.2, and 8.0 + raise `unknown keyword: quirks_mode`, and Active Support 8.1.3.1 and earlier + 8.1 releases fail to decode. Upgrade Rails to 8.1.4 or newer, or pin `json` + to 2.x. The compatibility CI matrix now pins `json` 2.x for Rails 7.1, 7.2, + and 8.0, the configuration that the guide prescribes. ## 0.17.0 - 2026-10-03 diff --git a/Gemfile b/Gemfile index 1601421..4877c89 100644 --- a/Gemfile +++ b/Gemfile @@ -10,6 +10,7 @@ if rails_version %w[actioncable actionpack actionview activerecord activesupport railties].each do |library| gem library, constraint end + gem "json", "~> 2" if Gem::Version.new(rails_version) < Gem::Version.new("8.1") end group :development, :test do diff --git a/context7.json b/context7.json index c3bb94d..e3dd132 100644 --- a/context7.json +++ b/context7.json @@ -2,12 +2,24 @@ "$schema": "https://context7.com/schema/context7.json", "projectTitle": "Solid Objects for Rails (solid_objects)", "description": "SQL-backed virtual actor library for Ruby on Rails. Actors have stable identities, durable JSON state in SQLite, PostgreSQL, or MySQL, ordered per-identity mailboxes, fenced activation, durable reminders, and transactional effects. Requires Ruby 3.3+ and Rails 7.1+.", - "folders": ["docs", "examples"], - "excludeFolders": ["./docs/adr", "./docs/research"], - "excludeFiles": ["AGENTS.md", "CLAUDE.md", "CONTRIBUTING.md", "implementation-plan.md"], + "folders": [ + "docs", + "examples" + ], + "excludeFolders": [ + "./docs/adr", + "./docs/research" + ], + "excludeFiles": [ + "AGENTS.md", + "CLAUDE.md", + "CONTRIBUTING.md", + "implementation-plan.md" + ], "rules": [ "Solid Objects requires Rails 7.1 or newer and Ruby 3.3 or newer. It is a Rails engine, not a framework-independent Ruby library.", "Install with bundle add solid_objects, bin/rails generate solid_objects:install, bin/rails db:migrate, and bin/rails solid_objects:doctor.", + "The json gem 3.x works only with Active Support 8.1.4 or newer. On Rails 7.1, 7.2, 8.0, or 8.1 before 8.1.4, pin gem \"json\", \"~> 2\".", "The generated authorization policies deny every operation. Production policies must bind the actor type and ID to the authenticated user or tenant, and callers pass authorization_context:.", "Run bundle exec solid_objects start for reminders, async calls, effects, and broadcasts. Direct synchronous calls do not need it.", "Delivery is at least once. Effect handlers registered with SolidObjects.register_effect must use context.id as the idempotency key.", diff --git a/docs/agents.md b/docs/agents.md index bda1f37..36bbda2 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -64,11 +64,10 @@ bin/rails solid_objects:doctor The generator adds an initializer and copies migrations into the application. The doctor checks the configuration, the tables, and one real actor round trip. -If the doctor or an actor call fails with -`ArgumentError: wrong number of arguments (given 2, expected 1)` from -`ActiveSupport::JSON.decode`, the application has Active Support 8.1.3.1 or -earlier with `json` 3.0.2. Upgrade Rails to 8.1.4 or newer. If you cannot -upgrade, pin `gem "json", "~> 2"` in the `Gemfile`. +The `json` gem 3.x works only with Active Support 8.1.4 or newer. On Rails +7.1, 7.2, 8.0, or 8.1 before 8.1.4, pin `gem "json", "~> 2"` in the +`Gemfile`. Without the pin, Active Support raises an `ArgumentError`, such as +`unknown keyword: quirks_mode`, for every JSON column. [Installing and upgrading](operations.md#installing-and-upgrading) has the details. @@ -238,7 +237,7 @@ runs checks 2 and 4 against a new Rails application. | `SolidObjects::SyncTimeout` | The call did not finish in time. The message is still durable. Use its `message_reference` to wait for the result | | `SolidObjects::ApplicationWriteForbidden` | A handler wrote a model directly. Use `commit_action` or `emit` | | `SolidObjects::Rejected` | The actor called `reject`. This is a business result, not a retry | -| `ArgumentError` from `ActiveSupport::JSON.decode` | Upgrade Rails to 8.1.4 or newer, or pin `gem "json", "~> 2"` | +| `ArgumentError` from `ActiveSupport::JSON`, such as `unknown keyword: quirks_mode` | `json` 3.x with Active Support before 8.1.4. Upgrade Rails to 8.1.4 or newer, or pin `gem "json", "~> 2"` | ## 11. Guarantees to state correctly diff --git a/docs/operations.md b/docs/operations.md index e552c37..12aaaf6 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -26,18 +26,18 @@ reports a failed or warned check rather than raising out of the command. ## Installing and upgrading Solid Objects keeps actor state, message arguments, results, and the remembered -idempotency keys in JSON columns. Active Support decodes every one of them. -Active Support 8.1.3.1 and earlier 8.1 releases fail with the `json` gem at -3.0.2. `ActiveSupport::JSON.decode` raises: +idempotency keys in JSON columns. Active Support encodes and decodes every one +of them. The `json` gem 3.x works only with Active Support 8.1.4 or newer: -``` -ArgumentError: wrong number of arguments (given 2, expected 1) -``` +- Active Support 7.1, 7.2, and 8.0 raise + `ArgumentError: unknown keyword: quirks_mode` when they encode or decode. +- Active Support 8.1.3.1 and earlier 8.1 releases raise + `ArgumentError: wrong number of arguments (given 2, expected 1)` when they + decode. The failure is in Active Support rather than in Solid Objects, and it reaches -every JSON column in a Rails application. Active Support 8.1.4 decodes -correctly with `json` 3.0.2. Upgrade Rails to 8.1.4 or newer. If you cannot -upgrade, pin the 2.x series of `json`: +every JSON column in a Rails application. Upgrade Rails to 8.1.4 or newer. On +an older Rails release, pin the 2.x series of `json`: ```ruby gem "json", "~> 2" diff --git a/examples/quickstart/README.md b/examples/quickstart/README.md index 40e76bc..ecc3d82 100644 --- a/examples/quickstart/README.md +++ b/examples/quickstart/README.md @@ -30,10 +30,10 @@ rails new ticket_demo cd ticket_demo ``` -Active Support 8.1.3.1 and the `json` gem 3.0.2 do not work together: -`ActiveSupport::JSON.decode` raises an `ArgumentError`, and Solid Objects -decodes its JSON columns with it. Active Support 8.1.4 does not have this -problem. If `Gemfile.lock` shows both of the incompatible versions, read +The `json` gem 3.x works only with Active Support 8.1.4 or newer. Solid +Objects stores its state in JSON columns, so an older Active Support with +`json` 3.x fails on every actor call. A new Rails application resolves Rails +8.1.4 or newer, which works. For an older Rails application, read [Installing and upgrading](../../docs/operations.md#installing-and-upgrading) before you continue. From 9f11cdf21e4073abc88351384222bbcf1bc9c343 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Wed, 7 Oct 2026 23:36:17 -0700 Subject: [PATCH 4/4] test: check that the transmission policy stays denied Review feedback on #83. The recipe promises that four policies stay denied, but the smoke check verified only three. Add authorize_transmission to DENIED_POLICIES. With the generator template changed to grant transmissions, rake quickstart now fails with "authorize_transmission stays denied"; with the real template it passes. Remove two guards that cannot fire: __dir__ is never nil when Ruby loads the file, and initialize sets @root before call runs. Name the Integer values that the two proof methods return instead of untyped. --- examples/quickstart/smoke.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/quickstart/smoke.rb b/examples/quickstart/smoke.rb index 8c0b942..b5c2190 100644 --- a/examples/quickstart/smoke.rb +++ b/examples/quickstart/smoke.rb @@ -15,7 +15,7 @@ class Failure < StandardError; end HoldProcess = Data.define(:buyer, :pid, :input, :output) REPOSITORY_ROOT = File.expand_path("../..", __dir__) - QUICKSTART_ROOT = __dir__ || raise(Failure, "the quickstart directory is unknown") + QUICKSTART_ROOT = __dir__ ACTOR_PATH = File.join(QUICKSTART_ROOT, "app/actors/ticket_sale.rb") RECIPE_PATH = File.join(QUICKSTART_ROOT, "README.md") INITIALIZER = "config/initializers/solid_objects.rb" @@ -23,7 +23,7 @@ class Failure < StandardError; end "configuration.authorize_message = ->(**) { false }" => "configuration.authorize_message = ->(**) { true }", "configuration.authorize_query = ->(**) { false }" => "configuration.authorize_query = ->(**) { true }" }.freeze - DENIED_POLICIES = %w[authorize_destroy authorize_subscription authorize_administration].freeze + DENIED_POLICIES = %w[authorize_destroy authorize_subscription authorize_administration authorize_transmission].freeze CONCURRENT_BUYERS = 8 RESTART_DEADLINE_SECONDS = 8 COMMAND_TIMEOUT_SECONDS = 600 @@ -107,7 +107,7 @@ def call ) ensure stop_children - FileUtils.remove_entry(@root) if @root && File.exist?(@root) + FileUtils.remove_entry(@root) if File.exist?(@root) end private @@ -217,7 +217,7 @@ def grant_demo_policies File.write(path, initializer) end - # @rbs () -> Hash[Symbol, untyped] + # @rbs () -> Hash[Symbol, Integer] def prove_concurrent_holds runtime = start_runtime("runtime-concurrency") buyers = (1..CONCURRENT_BUYERS).map { |number| "buyer-#{number}" } @@ -239,7 +239,7 @@ def prove_concurrent_holds { calls: buyers.length, held: winners.length, available: state.fetch("available") } end - # @rbs () -> Hash[Symbol, untyped] + # @rbs () -> Hash[Symbol, Integer] def prove_restart_recovery hold = JSON.parse( rails_runner("shifted-hold", SHIFTED_CLOCK_HOLD_PROGRAM, "matinee", "ada", RESTART_DEADLINE_SECONDS.to_s).lines.last