- Accept a
key:onschedule, naming a reminder for the item it is waiting on rather than for its operation, so one actor can hold an alarm per queued item. Scheduling the same key again moves that item's alarm and leaves the others alone. Without a key the name is still the operation, so existing reminders keep their names and their coalescing behaviour. A reminder operation may no longer hold the colon that separates a key, which keeps keyed and unkeyed names disjoint, and the length is checked on the composed name rather than the key alone. - Add an authorized
SolidObjects.administration.processesquery for inspecting live and stale process rows through the runtime database adapter. - Document rolling-deployment overlap as a reason for the polling-only warning.
- Back idle actor, effect, reminder, and broadcast polling off exponentially from the configured fast interval to a new one-second idle ceiling. Any processed work or wake-up resets the role immediately, and actor polling remains capped by the lease-renewal interval.
- Expose each role's
current_polling_intervaland emitsolid_objects.polling.interval_changedinstrumentation for every idle, work, and wake-up transition. - Warn once when live processes share the database without a configured cross-process wake-up adapter.
- Make the in-process wake-up generation-aware so a signal committed between an empty claim and the wait is not missed. PostgreSQL and Redis adapters now expose the same watch contract.
- Add a reproducible four-role SQLite idle benchmark and repair the benchmark schema setup for the current operation columns.
- Behavior change:
polling_intervalis now the fast interval after activity, not a constant idle cadence. Existing explicit values back off toidle_polling_interval, which defaults to one second. Set both options to the same value to preserve a fixed cadence. Existing custom wake-up adapters that returnnilremain at the fast cadence until they returnfalsefor a timeout andtruefor a notification.
- Breaking: make observables invalidation-only by default. An ordinary
observable :statuscontinues to detect changes and refresh reactive components, but persists{}and sends no scalar value over Action Cable. Declareobservable :status, broadcast: :valueto deliberately store and share the projection with every authorized actor subscriber. Applications upgrading from 0.12.x must add that opt-in to observables rendered as scalar targets. - Add
SolidObjects::Web, a mountable Rack dashboard for the actor runtime. It covers instances and their committed state, the ready and claimed mailbox, reminders, effects, broadcasts, dead letters, and processes, with actor-type and actor-id filtering, status filters, paging, and a polledGET /statsendpoint. Mount it withmount SolidObjects::Web => "/solid_objects/dashboard"afterrequire "solid_objects/web"; requiring the gem does not load it, so a worker process carries no web stack. - Authorize every dashboard route through
authorize_administration. Each route declares its ownactionandresource, and a route declared without a policy raises at load time. The policy receives a context that answersrequest,session, andenv. - Add two dashboard actions: an idempotent dead letter retry through
SolidObjects.dead_letters.retry, and instance pause/resume, which sets and clearspaused_atso the activation manager stops claiming that identity. A retry the mailbox refuses, such as an actor class that no longer exists, renders the reason with a 422 rather than failing the request. - Draw instances per actor type, mailbox depth, and outbox and reminder status
with Chart.js, loaded from a CDN with a subresource integrity hash. The CDN
host is the only external origin the content security policy names. Point
SolidObjects::Web.chart_library_urlat a vendored copy for a deployment with no outbound network access, or set it to nil to render without charts. - Add
SolidObjects::Web.registerfor extension tabs, routes, and view directories, andSolidObjects::Web.usefor Rack middleware in front of the dashboard. - Add
rackas an explicit dependency at>= 3.1, and package theweb/directory in the gem.
- Add invalidation-only observables with
broadcast: :invalidation. They still detect changes and refresh reactive components, but persist{}and send no scalar value over Action Cable. Document that ordinary observable values are shared with every authorized actor subscriber and that subscriber-specific state belongs in a payload projection. - Breaking: include the originally staged
arguments:in effect success and failure callbacks so actors can correlate concurrent effects. - Accept identifier-style rejection codes, including camelCase and symbols,
and fail malformed codes once with non-retryable
SolidObjects::InvalidRejectionCodediagnostics. - Add
run_due_reminders(now:)toSolidObjects::TestHelperfor deterministic reminder tests without sleeping or mutating runtime rows.
- Replace positional actor dispatch with fluent operation selection. Direct
committed calls remain
account.disable(...); configured committed calls useaccount.sync(timeout: ...).status; asynchronous calls useaccount.async(...).disable(...); actor outbox delivery usessend_to(account, ...).disable(...); and reminders useschedule(at: ...).evaluate(...). Delivery and reminder options are now unambiguously separate from actor message arguments. The former positionalasync,sync,send_to, andscheduleforms are removed. - Validate fluent operations before enqueueing or staging them. Direct and
configured synchronous calls accept messages and queries, while
async,send_to, andscheduleaccept public actor messages only. - Use operation terminology throughout invocation persistence and diagnostics.
The new migration renames stored message names to
operation, message kind todelivery_mode, and effect callback message names to operation names. - Make internal methods with more than two arguments keyword-only so dispatch, persistence, component refresh, and diagnostics call sites name every value.
- Keep SQLite reconnect failures inside a synchronous lock deadline. Active
Record can reconnect after a lock error while the lock is still held, and
configuring WAL then raises
SQLite3::CantOpenException; it now retries within the original deadline and surfaces the documented sync timeout.
-
Add
SolidObjects.configuration.register_component. An extension gem can now register a long running component, and the supervisor runs it beside the workers, the effect executors, the broadcast executors, and the reminder schedulers. The component joins the same supervision, replacement, and shutdown timeout. Without it, an extension has to ask an operator to run and monitor a second process for work that belongs to the same runtime. A registered component must answerrun,request_shutdown,stopped?, andstop. The supervisor checks that contract when it builds the component and raisesArgumentErrorwhen a method is missing. Registration never calls the block, so a component may need a database connection that the application does not have while it boots. -
Stop the components already built when a later one fails. The supervisor builds its components one after another, so a factory that raised, or a component that failed the contract check, left the earlier ones constructed and unreachable while they still held whatever their constructors took. Each one now receives
stop, and a failure inside that cleanup never replaces the failure that caused it. -
Replace a crashed component through the builder that made it. The supervisor called
component.class.new, which discards every constructor argument, so a component built with arguments returned with its defaults after a crash. Each component now keeps its builder. The built in components take no constructor arguments, so their behavior does not change.
- Delete every actor-owned row in
SolidObjects::TestHelper#reset_actors!. It deleted actor instances and processes and left the other seven tables to the database cascade. That cascade is not enforced everywhere: SQLite has to be asked for foreign keys, MySQL has to be on InnoDB, and a host application may have stripped the constraints out of the copied migration. Where it does not fire, messages, ready and claimed mailbox rows, reminders, effects, broadcasts, and dead letters all survived into the next test with aninstance_idpointing at nothing, so a test reading any of them saw another test's rows and failed depending on order. Reported as reminders leaking, which is where it surfaces first because reminders outlive the message that created them. - Document that a reminder is one named alarm per actor.
scheduleis keyed by actor and reminder name, so scheduling a name that is already armed moves that alarm rather than adding a second. The behaviour is deliberate and matches Orleans and Durable Objects, but it was stated nowhere: an actor that armed one reminder per queued item silently kept only the last, and the earlier wake-ups never happened. The reminders guide now states the uniqueness key and shows the one-alarm-many-items pattern to use instead. - Add
solid_objects.reminder.replaced, reported when aschedulecall moves an alarm already armed under the same name to a different time. It carries the actor identity, reminder name, previous run time, and next run time, and no arguments. Rescheduling to the same time reports nothing. The replacement was previously indistinguishable from a first schedule.
- Load the mailbox when the gem is required.
SolidObjects::Mailboxwas reachable only through the caller path, which loads it as a side effect ofSolidObjects.client. The reminder scheduler and the effect executor enqueue through it directly and run insolid_objects start, a process that never calls the client, so both raisedNameError: uninitialized constant SolidObjects::ReminderScheduler::Mailbox. Reminders never fired and effect result messages never delivered, while the supervisor replaced the dying role over and over. Nothing caught it because every test process has already loaded the constant through some other path. - Add a load contract test that asks a fresh process what
require "solid_objects"actually defines, and fails when a file stops being loaded unless it is listed as deliberately deferred with a reason. This is the class of bug that only appears in the standalone worker. - Run a due reminder through a real
solid_objects startworker in the test suite, rather than only in process.
- Support Trilogy. Adapter selection matched the client name rather than the
protocol, and Trilogy reports
"Trilogy", so every Solid Objects call raisedUnsupportedDatabase: unsupported database adapter "Trilogy"on a database the gem fully supports. Adapter names now resolve through one table of families,DatabaseAdapter.family, used by adapter selection, owner-id casting, and wake-up adapter selection alike, so a client cannot be accepted in one place and rejected in another. - Compare reconciliation owner ids in the column's own collation.
Instance.orphanedcast owner primary keys toCHAR, and a cast result carries the connection collation rather than the column's. MySQL refuses to compare two collations, so the query raisedIllegal mix of collationswhenever the two differed. That is a property of the client rather than the schema: mysql2 negotiates the database default while Trilogy negotiatesutf8mb4_general_ci. A mysql2 application that setcollation:indatabase.ymlcould already hit this. - Recognise a statement interruption from any MySQL client. A synchronous
deadline is enforced by asking the server to interrupt the statement, and the
interruption was matched only through mysql2's
error_number. Trilogy names iterror_code, so a deadline surfaced as a rawActiveRecord::StatementTimeoutinstead ofSyncEnqueueTimeout. Both names are read, and Active Record's own classification is trusted first. - Run the MySQL suite against both mysql2 and Trilogy in CI, and key adapter-specific test skips to the database family rather than the client name, so a Trilogy run no longer silently skips every MySQL test.
-
Report a denied CLI command as a policy decision rather than a crash. Administration denies by default, so an unconfigured host met a thirty-line Ruby backtrace on its first
solid_objectscommand. The executable now prints the refusal and the setting that grants access, and exits 1. -
Measure the query count for a synchronous call.
benchmark/query_count.rbonly measured a worker turn, so the documented synchronous number had no script behind it. It reports three now: a message turn costs 26 queries rather than the documented 29, the caller of a synchronous call costs 49, and a synchronous call in total costs 75, being a caller plus the turn it waits on. Counting is scoped to the measuring thread, since a worker loop polls whether or not a call is in flight and a process-wide count folds those polls into the result. -
Run payload broadcast blocks against the actor instance, like every other block in the actor DSL.
selfwas the actor class, so an actor instance method called from a payload block raisedNoMethodError: undefined method 'x' for class PlaymatRoom. Blocks keep receiving the actor and the authorization context as arguments, so the documented signature is unaffected. A block that relied on the class receiver now raisesInvalidPayloadBroadcastnaming the method and the change instead of an unexplainedNameError. -
Add
payload_authorization_context, the payload counterpart tocomponent_authorization_context. Payloads are computed inside the channel, so without a resolver the payload block and itsauthorize_querycall received the raw Action Cable connection while a controller render passed an application object, and the authorization hook had to tell them apart. The resolver may also acceptpayload_name:. It defaults to returning the connection unchanged. -
Confine a failing payload to itself. A raising payload block propagated out of the channel: on subscribe it rejected the subscription, and on a broadcast it abandoned the remaining payload names, which showed up in the browser only as reactive updates that stopped arriving. A failure is now reported as
solid_objects.payload_broadcast_failedwith the actor type, actor id, payload name, and exception class, and delivery continues. The exception message is deliberately excluded so subscriber state cannot leak into logs. A revision with a failed payload does not advance the delivery watermark, so a transient failure is retried on the next broadcast instead of being recorded as delivered and deduplicated away. -
Run retention on the supervisor rather than leaving it configured but unscheduled. Every actor call writes a durable message row, so a policy that nothing invokes let history grow without bound until an application scheduled its own job.
retention_intervaldefaults to one hour, and zero disables it. Retention runs on its own thread, so a slow pass cannot delay replacing a crashed role, and a failed pass retries at monitor cadence with a doubling backoff rather than deferring for the whole interval. -
Batch component refreshes on reconnect. A reconnecting subscription refreshed every stale component individually, ignoring the batches those components declared, so a page with twenty batched components issued twenty requests instead of one. That happens at the worst moment: a server restart reconnects every client at once. Reconnect now shares the batching the live invalidation path uses.
-
Cover the reconnect burst in the browser suite: convergence of batched and unbatched components, an inert replay of an already-applied revision, cancellation of the request left in flight by the drop, incarnation ordering after a destroy and recreate, and payload delivery exactly once per revision.
-
Add Ruby 4.0 to the compatibility matrix, which now covers Ruby 3.3, 3.4, and 4.0 against Rails 8.0 and 8.1.
- Add a browser test suite running the refresh modules against real Chromium and
a real Turbo build, covering
component_refresh.js, which previously had no tests at all. Every batching defect that reached production passed the jsdom suite, because jsdom cannot model Turbo applying a morph, task boundaries between socket deliveries, or abort semantics. - Verify the database server. Each adapter reports its version against the
oldest one Solid Objects is exercised against, PostgreSQL 13, MySQL 8.0, and
SQLite 3.35, and MySQL additionally confirms that Solid Objects tables use
InnoDB, since a non-transactional engine would silently break fenced commits.
The doctor reports this as
database_serverand warns rather than failing: refusing to run on an untested server would be a worse failure than running on one. - Add
SolidObjects::WakeUpAdapters::Redis, an optional cross-process wake-up using Redis publish and subscribe. This is the option for MySQL, which has no notification primitive. Measured cross-process wake-up latency drops from 103.8 ms to 5.7 ms at p50. One background subscription per process fans out to every waiting role in memory. Theredisgem is not a dependency of this gem, andWakeUpAdapters.fordoes not select it, so adopting Redis stays explicit.
- Replace a supervised role whose thread died. A role that raised left its
thread dead while the process kept running and quietly did less work; the
supervisor now restarts it until shutdown is requested. Prune dead process
records on an interval as part of the same monitor. Both intervals are
configurable through
supervisor_monitor_intervalanddead_process_cleanup_interval. - Run compatibility CI across the span the gemspec advertises: Ruby 3.3 and 3.4
against Rails 8.0 and 8.1. The suite previously ran on one combination, so
>= 8.0was a claim rather than a tested guarantee. SetRAILS_VERSIONto pin a Rails line locally. - Stop a synchronous lock retry from asking for a negative wait when its
deadline expires between the check and the wait, which raised
ArgumentError: time interval must not be negativeinstead of the timeout the caller expected. Found by the new compatibility matrix. - Add
SolidObjects::WakeUpAdapters::Postgresql, an optional cross-process wake-up using PostgreSQL notifications. In-process signalling cannot reach a worker process, so reactive delivery waited outpolling_interval. With the adapter configured, measured cross-process wake-up latency drops from 103.7 ms to 2.9 ms at p50. The polling interval remains the upper bound, and neither signalling nor waiting raises into its caller.WakeUpAdapters.forselects notifications on PostgreSQL and the in-process default elsewhere; it is not the default, because the adapter opens a connection per waiting thread outside the pool andLISTENdoes not survive a transaction-pooling proxy.
- Coordinate batched component refreshes by revision as well as scope and batch name. Invalidations for one revision arrive as separate WebSocket messages, so the microtask merge could not see them all, and each request aborted the one before it. Only the last component updated. Same-revision requests now run alongside each other and every frame is applied; only a strictly newer revision supersedes an in-flight request. Frames already applied at a revision are not applied twice.
- Render batched component partials as HTML regardless of the request format.
The batch endpoint is requested with a JSON
Acceptheader, so Rails looked for JSON templates, raisedActionView::MissingTemplate, and the batch returned 404 for applications whose components are ordinary.html.erbpartials. The outer response is still JSON. Single-component refresh was never affected and is unchanged. - Pass
registrations:tocomponent_authorization_context: one registration for a single refresh, all of them for a batch, so applications no longer have to inspectparams[:tokens]. Callbacks accepting onlycontroller:keep working unchanged.
- Retry a contended SQLite write outside a synchronous deadline. Asynchronous
enqueue had no Ruby-level retry budget, so it depended entirely on SQLite's
busy handler and raised
SQLite3::BusyExceptiononce concurrent writers exhausted it. Bounded by the newlock_retry_attemptssetting. - Pin every GitHub Actions reference to a commit SHA.
- Add a benchmark comparing individual, batched, and payload delivery for one mutation that changes three components.
- Add
batch:to reactive components. Components sharing a batch in one actor scope collapse into a single browser request per revision instead of one request per component. The newGET /solid_objects/components/batchendpoint returns HTML frames inside a documented JSON envelope, so Turbo morph and ERB rendering are unchanged while the contract stays machine readable. Duplicate notifications for the same batch and revision coalesce in the browser, unchanged components are never requested, and stale frames cannot overwrite a newer target. Components withoutbatch:behave exactly as before. - Add a JavaScript test suite for the browser modules, run in CI with Node's test runner and jsdom.
- Add
broadcast_payload, an actor DSL for sending one personalized JSON state payload over the actor stream a page already has open. The block runs once per subscriber with that subscriber's authorization context, so private state never crosses sessions. Payloads carry actor identity and the monotonic state revision, and both the channel and the browser drop stale revisions. Subscribe withsolid_object room, payloads: :playmat_stateand handle thesolid-objects:payloadDOM event. ERB component refreshes remain the default and are unchanged. A mutation that changes payload state without changing a declared observable still invalidates subscribers, through a revision-only broadcast that carries no observable value to the browser.
- Read the database clock once per transaction instead of once per step, and resolve the SQLite busy wait from configuration instead of querying the connection for it. A synchronous call now issues 49 database queries instead of 66, which matters most on PostgreSQL and MySQL where every query is a network round trip.
- Apply every migration in the benchmark harness. It applied only the initial
migration, so the
state_revisioncolumn added in 0.4.0 was missing, every message failed at commit, and the synchronous benchmarks timed out.
-
Restore the SQLite busy wait that a synchronous invocation suspends for its deadline. Rails installs the busy wait as a Ruby busy handler through the sqlite3
timeoutconfiguration, whichPRAGMA busy_timeoutreports as zero and silently replaces, so the previous save and restore left pooled connections with no busy handler at all. Every later writer on that connection, inside or outside Solid Objects, then failed immediately withSQLite3::BusyExceptioninstead of waiting for the lock. Suspend the busy wait only when the adapter can identify how to restore it, so an Active Record release that stops exposing the configured timeout loosens synchronous deadline bounds instead of stripping lock waiting from a shared pooled connection. -
Run the doctor round-trip probe on a dedicated caller process, and accept an explicit process registry in
SynchronousInvocation, so the probe can no longer stop and delete a shared application caller process, release its activations, and unclaim its messages. -
Report doctor probe cleanup failures as a failed or warned check instead of raising a database lock error out of the command and leaking the probe caller process.
-
Instrument component refreshes with actor identity, component name, key, dependencies, refresh method, revision, and outcome, excluding locals.
- Add repeatable reactive components with signed string or integer keys and JSON-compatible partial locals.
- Add opt-in Turbo morph refreshes with superseded-request cancellation and browser-side actor revision fencing.
- Pass signed component keys and locals through request-time query authorization without broadcasting personalized HTML.
- Bound SQLite caller-process registration, reuse, heartbeat, and synchronous result observation retries by the original invocation deadline.
- Load host application actors from
app/actorsbefore CLI workers start, including development environments with eager loading disabled.
- Decode Action Cable broadcast payloads before parsing observable invalidations so scalar updates and component refreshes transmit as raw Turbo Stream HTML.
- Load
SolidObjects::ActorChannelwith the gem and pass stream and component subscription tokens through Turbo-compatibledata-*attributes.
- Add dependency-driven live ERB components with request-time authorization, conventional partial resolution, revision fencing, refresh coalescing, and reconnect convergence without broadcasting personalized HTML.
- Persist a monotonic state revision for secure component refresh ordering.
- Retry SQLite synchronous lock contention in Ruby so a native busy wait cannot starve the thread holding the database lock.
- Allow maintainers to dispatch CI manually when a push webhook is dropped.
- Reject application-record writes from actor handlers and provide registered same-database commit actions for fenced atomic changes.
- Reject synchronous invocation inside an open Solid Objects transaction and add adapter database deadlines, durable diagnostics, and recoverable results to sync timeouts.
- Guard handlers, observables, lifecycle hooks, and state migrations from direct application-record writes.
- Add dry-run-first bounded message, process, and opt-in actor-instance pruning, configurable retention, and graceful caller-process shutdown.
- Add authorized committed state snapshots, mutable JSON copies, commit-action instrumentation, and deterministic full-runtime Minitest draining.
- Add
solid_objects:doctorfor configuration, schema, policy, runtime, and workerless synchronous round-trip verification. - Add onboarding guidance for fit decisions, worker requirements, authorization, performance and row growth, retention, Sorbet, RuboCop, and migrations from existing state stores.
- Make the early-Action View engine boot regression explicit.
- Make direct actor methods synchronous Durable Object-style RPC.
- Add explicit
syncandasyncinvocation modes. - Let synchronous callers assist execution through the ordered mailbox using the same activation leases and fencing checks as workers.
- Add terminal domain rejections that roll back actor state without retrying or creating dead letters.
- Give each activation a unique token so concurrent callers in one process cannot share lease ownership.
- Fix Action View helper loading when Action View initializes before engine autoload paths.
- Introduce the Rails engine, actor API, and
solid_objectsexecutable. - Register public actor methods as durable messages and add method-style reference calls, ordered attribute reads, actor attribute accessors, and observable view helpers.
- Add ordered durable mailboxes with ready and claimed membership tables.
- Add renewable activation leases with monotonically increasing fencing generations.
- Add JSON actor state, versioned migrations, retries, and dead letters.
- Add transactional effects, actor-to-actor messages, and durable reminders.
- Add observable Turbo replacements through a durable broadcast outbox.
- Add authorized, fenced actor destruction with cascading mailbox, reminder, effect, broadcast, and dead-letter cleanup.
- Support SQLite, PostgreSQL, and MySQL.