From c944eb1ae0329793d4631aeb1bd896dc96b3b8c6 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Tue, 13 Oct 2026 13:31:42 -0700 Subject: [PATCH 1/5] feat!: fail a schedule call with no operation A turn that called schedule and then called no operation on the result stored no reminder and gave no error. The hold or the timeout that the author intended did not exist, and nothing reported it. Three fresh agent sessions found this mistake in a published sample. The turn now fails with ScheduleWithoutOperation. The error is not retryable, because a retry cannot change the actor code. The turn can keep the result and call the operation later in the same turn. BREAKING CHANGE: a turn with a schedule call that has no operation after it becomes a dead letter. Correct code does not change. --- docs/reminders.md | 17 ++++ lib/solid_objects/actor.rb | 21 ++++- lib/solid_objects/errors.rb | 3 + lib/solid_objects/executor.rb | 1 + sig/generated/lib/solid_objects/actor.rbs | 20 ++++ sig/generated/lib/solid_objects/errors.rbs | 3 + .../schedule_without_operation_test.rb | 92 +++++++++++++++++++ 7 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 test/integration/schedule_without_operation_test.rb diff --git a/docs/reminders.md b/docs/reminders.md index 32c56d7..0d1910c 100644 --- a/docs/reminders.md +++ b/docs/reminders.md @@ -16,6 +16,23 @@ end Use `missed: :latest` to coalesce missed occurrences or `missed: :all` to enqueue each one. +## A schedule call needs an operation + +`schedule` returns an object, and the reminder exists only after you call an +operation on that object. A turn that calls `schedule` and then calls no +operation on the result fails with `SolidObjects::ScheduleWithoutOperation`. +The runtime does not retry that turn, and the message becomes a dead letter. + +```ruby +# Wrong. The turn fails. +schedule(at: 10.minutes.from_now, key: buyer) + +# Correct. +schedule(at: 10.minutes.from_now, key: buyer).expire(buyer:) +``` + +You can keep the result and call the operation later in the same turn. + ## A reminder is one named alarm per actor The uniqueness key is `(actor, reminder name)`. Scheduling a name that is diff --git a/lib/solid_objects/actor.rb b/lib/solid_objects/actor.rb index d921aaa..c416275 100644 --- a/lib/solid_objects/actor.rb +++ b/lib/solid_objects/actor.rb @@ -13,6 +13,7 @@ class Actor REMINDER_HANDLE_KEY = "reminder_name" ReminderIntent = Data.define(:name, :operation, :at, :arguments, :interval_seconds, :missed_policy) + ScheduleCall = Data.define(:at, :key) UnscheduleIntent = Data.define(:name) UnscheduleAllIntent = Data.define(:operation) ReminderStatus = Data.define(:name, :operation, :key, :next_run_at, :interval_seconds, @@ -154,6 +155,7 @@ def default_actor_type # @rbs @effect_recovery_intents: Array[EffectRecoveryIntent] # @rbs @commit_action_intents: Array[CommitActionIntent] # @rbs @reminder_intents: Array[ReminderIntent] + # @rbs @open_schedule_calls: Array[ScheduleCall] # @rbs @outbound_message_intents: Array[OutboundMessageIntent] attr_reader :actor_id, :state @@ -167,6 +169,7 @@ def initialize(actor_id:, state:, instance_id: nil) @effect_recovery_intents = [] @commit_action_intents = [] @reminder_intents = [] + @open_schedule_calls = [] @outbound_message_intents = [] end @@ -257,11 +260,14 @@ def schedule(at:, every: nil, missed: :latest, key: nil) raise ArgumentError, "missed reminder policy must be all or latest" end reminder_key = validated_reminder_key(key) + call = ScheduleCall.new(at:, key: reminder_key) + open_schedule_calls << call OperationDispatcher.new( actor_type: self.class.actor_type, handlers: self.class.definition.messages ) do |operation, arguments| + open_schedule_calls.delete_if { |open_call| open_call.equal?(call) } name = reminder_name(operation:, key: reminder_key) reminder_intents << ReminderIntent.new( name:, @@ -537,6 +543,18 @@ def discard_intents commit_action_intents.clear reminder_intents.clear outbound_message_intents.clear + open_schedule_calls.clear + end + + # @rbs () -> void + def ensure_schedule_calls_have_operations! + call = open_schedule_calls.first + return unless call + + arguments = [ "at: #{call.at.inspect}", ("key: #{call.key.inspect}" if call.key) ].compact.join(", ") + raise ScheduleWithoutOperation, + "schedule(#{arguments}) has no operation after it, so it stores no reminder. " \ + "Call an operation on the result, for example schedule(at: time).expire" end # @rbs () -> Integer @@ -563,7 +581,8 @@ def read_projection(operation) :effect_recovery_intents, :commit_action_intents, :reminder_intents, - :outbound_message_intents + :outbound_message_intents, + :open_schedule_calls # @rbs () -> Array[Array[Hash[Symbol, Object]]] def intent_snapshot diff --git a/lib/solid_objects/errors.rb b/lib/solid_objects/errors.rb index ed05099..902a211 100644 --- a/lib/solid_objects/errors.rb +++ b/lib/solid_objects/errors.rb @@ -10,6 +10,9 @@ class NonRetryableError < Error class QueryMutatedState < NonRetryableError end + class ScheduleWithoutOperation < NonRetryableError + end + class UnsupportedDatabase < Error end diff --git a/lib/solid_objects/executor.rb b/lib/solid_objects/executor.rb index bb92af8..fcfcb0e 100644 --- a/lib/solid_objects/executor.rb +++ b/lib/solid_objects/executor.rb @@ -41,6 +41,7 @@ def execute SolidObjects.instrument(:"recovery.reclaimed", **instrumentation_payload) if message.attempt_count > 1 && message.error.nil? SolidObjects.instrument(:"message.started", **instrumentation_payload) result = invoke_actor(message_context) + actor.ensure_schedule_calls_have_operations! observable_changes = changed_observables(observables_before, actor.observable_values) state_after = actor.state.to_h_with_byte_size ensure_query_is_read_only!(state_before, state_after.value) diff --git a/sig/generated/lib/solid_objects/actor.rbs b/sig/generated/lib/solid_objects/actor.rbs index ed9b5cf..6b4b459 100644 --- a/sig/generated/lib/solid_objects/actor.rbs +++ b/sig/generated/lib/solid_objects/actor.rbs @@ -81,6 +81,19 @@ module SolidObjects def members: () -> [ :name, :operation, :at, :arguments, :interval_seconds, :missed_policy ] end + class ScheduleCall < Data + attr_reader at(): untyped + + attr_reader key(): untyped + + def self.new: (untyped at, untyped key) -> instance + | (at: untyped, key: untyped) -> instance + + def self.members: () -> [ :at, :key ] + + def members: () -> [ :at, :key ] + end + class UnscheduleIntent < Data attr_reader name(): untyped @@ -207,6 +220,8 @@ module SolidObjects @outbound_message_intents: Array[OutboundMessageIntent] + @open_schedule_calls: Array[ScheduleCall] + @reminder_intents: Array[ReminderIntent] @commit_action_intents: Array[CommitActionIntent] @@ -348,6 +363,9 @@ module SolidObjects # @rbs () -> void def discard_intents: () -> void + # @rbs () -> void + def ensure_schedule_calls_have_operations!: () -> void + # @rbs () -> Integer def intent_count: () -> Integer @@ -366,6 +384,8 @@ module SolidObjects attr_reader outbound_message_intents: untyped + attr_reader open_schedule_calls: untyped + # @rbs () -> Array[Array[Hash[Symbol, Object]]] def intent_snapshot: () -> Array[Array[Hash[Symbol, Object]]] diff --git a/sig/generated/lib/solid_objects/errors.rbs b/sig/generated/lib/solid_objects/errors.rbs index 93ea002..eca4c74 100644 --- a/sig/generated/lib/solid_objects/errors.rbs +++ b/sig/generated/lib/solid_objects/errors.rbs @@ -10,6 +10,9 @@ module SolidObjects class QueryMutatedState < NonRetryableError end + class ScheduleWithoutOperation < NonRetryableError + end + class UnsupportedDatabase < Error end diff --git a/test/integration/schedule_without_operation_test.rb b/test/integration/schedule_without_operation_test.rb new file mode 100644 index 0000000..75faf60 --- /dev/null +++ b/test/integration/schedule_without_operation_test.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require "database_test_helper" + +class ScheduleWithoutOperationTest < ActiveSupport::TestCase + class Holder < SolidObjects::Actor + actor_type "schedule-without-operation" + + attribute :status, default: "open" + + def hold + self.status = "held" + schedule(at: 10.minutes.from_now, key: "ada").expire + end + + def hold_without_operation + self.status = "held" + schedule(at: 10.minutes.from_now, key: "ada") + true + end + + def hold_with_one_operation_missing + self.status = "held" + schedule(at: 10.minutes.from_now, key: "ada").expire + schedule(at: 20.minutes.from_now, key: "grace") + true + end + + def hold_with_a_late_operation + reminder = schedule(at: 10.minutes.from_now, key: "ada") + self.status = "held" + reminder.expire + end + + def expire + self.status = "open" + end + end + + setup do + SolidObjects.configuration.max_attempts = 3 + SolidObjects.configuration.retry_delay = ->(_) { 0 } + end + + test "a schedule call with no operation after it fails the message" do + error = assert_raises(SolidObjects::MessageFailed) { Holder.ref("one").sync.hold_without_operation } + + assert_equal "SolidObjects::ScheduleWithoutOperation", error.details.fetch("class") + assert_match(/schedule\(at: .*key: "ada"\)/, error.details.fetch("message")) + assert_match(/call an operation on the result/i, error.details.fetch("message")) + end + + test "the failed turn commits no state and no reminder, and it does not retry" do + assert_raises(SolidObjects::MessageFailed) { Holder.ref("one").sync.hold_without_operation } + + assert_empty SolidObjects::Instance.find_by!(actor_id: "one").state + assert_empty SolidObjects::Reminder.all + assert_equal 1, SolidObjects::Message.sole.attempt_count + assert_equal 1, SolidObjects::DeadLetter.count + end + + test "one schedule call without an operation fails a turn that also has a complete call" do + error = assert_raises(SolidObjects::MessageFailed) { Holder.ref("one").sync.hold_with_one_operation_missing } + + assert_equal "SolidObjects::ScheduleWithoutOperation", error.details.fetch("class") + assert_match(/key: "grace"/, error.details.fetch("message")) + assert_no_match(/key: "ada"/, error.details.fetch("message")) + assert_empty SolidObjects::Reminder.all + end + + test "a schedule call with an operation stores its reminder" do + Holder.ref("one").sync.hold + + assert_equal "held", SolidObjects::Instance.find_by!(actor_id: "one").state.fetch("status") + assert_equal [ "expire" ], SolidObjects::Reminder.pluck(:operation) + end + + test "a turn can keep the schedule result and call the operation later" do + Holder.ref("one").sync.hold_with_a_late_operation + + assert_equal [ "expire" ], SolidObjects::Reminder.pluck(:operation) + end + + test "a failed turn does not make the next turn of the same actor fail" do + reference = Holder.ref("one") + assert_raises(SolidObjects::MessageFailed) { reference.sync.hold_without_operation } + + reference.sync.hold + + assert_equal [ "expire" ], SolidObjects::Reminder.pluck(:operation) + end +end From 0d99e6eb0dda3da0279dca18bf86c6f8e75c5251 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Tue, 13 Oct 2026 13:31:42 -0700 Subject: [PATCH 2/5] feat: report a json gem that Rails cannot use The json gem 3.x removed an option that Active Support older than 8.1.4 passes to the decoder. Each JSON column then raises "unknown keyword: quirks_mode", and the cause is not clear from the error. The doctor now has a json check, and the boot prints a warning. Both decode one document with Active Support. They do not compare version numbers, so they stay correct when Rails or json changes. The gemspec does not pin json, because a pin would stop applications on a Rails release that supports json 3. --- docs/operations.md | 5 ++ lib/solid_objects.rb | 1 + lib/solid_objects/doctor.rb | 18 ++++++-- lib/solid_objects/engine.rb | 4 ++ lib/solid_objects/json_compatibility.rb | 36 +++++++++++++++ sig/generated/lib/solid_objects/doctor.rbs | 11 ++++- .../lib/solid_objects/json_compatibility.rbs | 22 +++++++++ test/integration/doctor_test.rb | 19 ++++++++ test/unit/json_compatibility_test.rb | 46 +++++++++++++++++++ 9 files changed, 157 insertions(+), 5 deletions(-) create mode 100644 lib/solid_objects/json_compatibility.rb create mode 100644 sig/generated/lib/solid_objects/json_compatibility.rbs create mode 100644 test/unit/json_compatibility_test.rb diff --git a/docs/operations.md b/docs/operations.md index 137e9b1..adf3b76 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -43,6 +43,11 @@ an older Rails release, pin the 2.x series of `json`: gem "json", "~> 2" ``` +`bin/rails solid_objects:doctor` reports this problem as a failure of its +`json` check, and the boot prints a warning that starts with +`[solid_objects]`. Both decode one JSON document with Active Support. They do +not compare version numbers. + Review [CHANGELOG.md](../CHANGELOG.md) for compatibility and deployment-order notes, then update the gem: diff --git a/lib/solid_objects.rb b/lib/solid_objects.rb index d6259a5..f255db7 100644 --- a/lib/solid_objects.rb +++ b/lib/solid_objects.rb @@ -20,6 +20,7 @@ require "solid_objects/diagnostics" require "solid_objects/log_subscriber" require "solid_objects/serialization" +require "solid_objects/json_compatibility" require "solid_objects/context" require "solid_objects/actor_registry" require "solid_objects/state" diff --git a/lib/solid_objects/doctor.rb b/lib/solid_objects/doctor.rb index 6864d50..0610c35 100644 --- a/lib/solid_objects/doctor.rb +++ b/lib/solid_objects/doctor.rb @@ -99,14 +99,17 @@ def ping(value:) # @rbs @connection: untyped # @rbs @configuration: Configuration + # @rbs @json_compatibility: JsonCompatibility - # @rbs (?connection: untyped, ?configuration: Configuration) -> void + # @rbs (?connection: untyped, ?configuration: Configuration, ?json_compatibility: JsonCompatibility) -> void def initialize( connection: SolidObjects::Record.connection, - configuration: SolidObjects.configuration + configuration: SolidObjects.configuration, + json_compatibility: JsonCompatibility.new ) @connection = connection @configuration = configuration + @json_compatibility = json_compatibility end # @rbs () -> Report @@ -114,6 +117,7 @@ def call configuration_check = check_configuration schema_check = check_schema checks = [ + check_json, configuration_check, schema_check, check_authorization, @@ -129,7 +133,15 @@ def call private - attr_reader :connection, :configuration + attr_reader :connection, :configuration, :json_compatibility + + # @rbs () -> Check + def check_json + problem = json_compatibility.problem + return fail_check(:json, problem) if problem + + pass(:json, "Active Support decodes JSON with the json gem #{JSON::VERSION}") + end # @rbs () -> Check def check_configuration diff --git a/lib/solid_objects/engine.rb b/lib/solid_objects/engine.rb index 312105f..ac819e2 100644 --- a/lib/solid_objects/engine.rb +++ b/lib/solid_objects/engine.rb @@ -15,6 +15,10 @@ class Engine < ::Rails::Engine SolidObjects::LogSubscriber.install end + initializer "solid_objects.json_compatibility" do + JsonCompatibility.new.warn_about_problem + end + initializer "solid_objects.actors" do |application| application.config.to_prepare { ApplicationActorLoader.new.call } end diff --git a/lib/solid_objects/json_compatibility.rb b/lib/solid_objects/json_compatibility.rb new file mode 100644 index 0000000..fc6d213 --- /dev/null +++ b/lib/solid_objects/json_compatibility.rb @@ -0,0 +1,36 @@ +# rbs_inline: enabled + +module SolidObjects + class JsonCompatibility + PROBE = '{"probe":1}' + + # @rbs @decode: ^(String) -> untyped + + # @rbs (?decode: ^(String) -> untyped) -> void + def initialize(decode: ActiveSupport::JSON.method(:decode)) + @decode = decode + end + + # @rbs () -> String? + def problem + decode.call(PROBE) + nil + rescue ArgumentError => error + "Active Support #{ActiveSupport.version} cannot decode JSON with the json gem #{JSON::VERSION} " \ + "(#{error.message}). Add gem \"json\", \"~> 2\" to the Gemfile, " \ + "or use a Rails release that supports json 3" + end + + # @rbs () -> void + def warn_about_problem + message = problem + return unless message + + warn "[solid_objects] #{message}" + end + + private + + attr_reader :decode + end +end diff --git a/sig/generated/lib/solid_objects/doctor.rbs b/sig/generated/lib/solid_objects/doctor.rbs index dc6c636..4448ab3 100644 --- a/sig/generated/lib/solid_objects/doctor.rbs +++ b/sig/generated/lib/solid_objects/doctor.rbs @@ -47,12 +47,14 @@ module SolidObjects def ping: (value: String) -> String end + @json_compatibility: JsonCompatibility + @configuration: Configuration @connection: untyped - # @rbs (?connection: untyped, ?configuration: Configuration) -> void - def initialize: (?connection: untyped, ?configuration: Configuration) -> void + # @rbs (?connection: untyped, ?configuration: Configuration, ?json_compatibility: JsonCompatibility) -> void + def initialize: (?connection: untyped, ?configuration: Configuration, ?json_compatibility: JsonCompatibility) -> void # @rbs () -> Report def call: () -> Report @@ -63,6 +65,11 @@ module SolidObjects attr_reader configuration: untyped + attr_reader json_compatibility: untyped + + # @rbs () -> Check + def check_json: () -> Check + # @rbs () -> Check def check_configuration: () -> Check diff --git a/sig/generated/lib/solid_objects/json_compatibility.rbs b/sig/generated/lib/solid_objects/json_compatibility.rbs new file mode 100644 index 0000000..6c71d52 --- /dev/null +++ b/sig/generated/lib/solid_objects/json_compatibility.rbs @@ -0,0 +1,22 @@ +# Generated from lib/solid_objects/json_compatibility.rb with RBS::Inline + +module SolidObjects + class JsonCompatibility + PROBE: ::String + + @decode: ^(String) -> untyped + + # @rbs (?decode: ^(String) -> untyped) -> void + def initialize: (?decode: ^(String) -> untyped) -> void + + # @rbs () -> String? + def problem: () -> String? + + # @rbs () -> void + def warn_about_problem: () -> void + + private + + attr_reader decode: untyped + end +end diff --git a/test/integration/doctor_test.rb b/test/integration/doctor_test.rb index 687a25e..ccfcc31 100644 --- a/test/integration/doctor_test.rb +++ b/test/integration/doctor_test.rb @@ -27,6 +27,25 @@ class DoctorTest < ActiveSupport::TestCase assert_empty SolidObjects::Process.where(kind: "caller") end + test "passes the json check when Active Support decodes JSON" do + report = SolidObjects::Doctor.new.call + + assert_equal :pass, report.check(:json).status + assert_includes report.check(:json).message, JSON::VERSION + end + + test "fails the json check and names the correction when Active Support cannot decode JSON" do + refused = SolidObjects::JsonCompatibility.new( + decode: ->(_text) { raise ArgumentError, "unknown keyword: :quirks_mode" } + ) + + report = SolidObjects::Doctor.new(json_compatibility: refused).call + + assert_not report.healthy? + assert_equal :fail, report.check(:json).status + assert_includes report.check(:json).message, 'gem "json", "~> 2"' + end + test "runs its probe on a caller process the application cannot adopt" do SolidObjects.caller_process.define_singleton_method(:process_registry) do raise "the doctor probe must not share the application caller process" diff --git a/test/unit/json_compatibility_test.rb b/test/unit/json_compatibility_test.rb new file mode 100644 index 0000000..07e94ec --- /dev/null +++ b/test/unit/json_compatibility_test.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "test_helper" + +class JsonCompatibilityTest < ActiveSupport::TestCase + REFUSED_KEYWORD = ->(_text) { raise ArgumentError, "unknown keyword: :quirks_mode" } + + test "reports no problem when Active Support decodes JSON" do + assert_nil SolidObjects::JsonCompatibility.new.problem + end + + test "names the versions and the correction when Active Support cannot decode JSON" do + problem = SolidObjects::JsonCompatibility.new(decode: REFUSED_KEYWORD).problem + + assert_includes problem, "unknown keyword: :quirks_mode" + assert_includes problem, "json gem #{JSON::VERSION}" + assert_includes problem, "Active Support #{ActiveSupport.version}" + assert_includes problem, 'gem "json", "~> 2"' + end + + test "does not hide a different decode failure" do + broken = ->(_text) { raise NoMethodError, "decode is gone" } + + assert_raises(NoMethodError) { SolidObjects::JsonCompatibility.new(decode: broken).problem } + end + + test "warns on the error stream when there is a problem" do + _output, errors = capture_io { SolidObjects::JsonCompatibility.new(decode: REFUSED_KEYWORD).warn_about_problem } + + assert_includes errors, "[solid_objects]" + assert_includes errors, 'gem "json", "~> 2"' + end + + test "writes nothing when there is no problem" do + output, errors = capture_io { SolidObjects::JsonCompatibility.new.warn_about_problem } + + assert_empty output + assert_empty errors + end + + test "the engine checks JSON compatibility during the boot" do + names = SolidObjects::Engine.instance.initializers.map(&:name) + + assert_includes names, "solid_objects.json_compatibility" + end +end From a27e817ce989852abe51c7a5882bbae07ea1f3b0 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Tue, 13 Oct 2026 13:31:42 -0700 Subject: [PATCH 3/5] docs: state the 1.0 status and its limits Version 1.0 promises a stable public API under semantic versioning. docs/versioning.md gives the promise and what is outside it. The README now gives the status at the top: the release date, one maintainer, one first-party production application, and no production use by other people. It also names the projects that Solid Objects is not, because search results mix them. The gem summary and description start with the problems that the gem solves. Version 1.0 makes no claim of measured scale. The benchmarks are development measurements, and longer soak tests are open work. --- AGENTS.md | 5 ++-- README.md | 20 ++++++++++++-- docs/agents.md | 19 ++++++++----- docs/guides/expiring-reservations.md | 2 +- docs/guides/ordered-jobs.md | 2 +- docs/guides/race-conditions.md | 2 +- docs/guides/transactional-outbox.md | 2 +- docs/roadmap.md | 14 ++++++++-- docs/versioning.md | 41 ++++++++++++++++++++++++++++ docs/virtual-actors.md | 9 +++--- solid_objects.gemspec | 4 +-- test/unit/gem_specification_test.rb | 10 +++++++ 12 files changed, 106 insertions(+), 24 deletions(-) create mode 100644 docs/versioning.md diff --git a/AGENTS.md b/AGENTS.md index f576183..545a02a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,8 +69,9 @@ release commit directly to `main`. snapshot from the tag. The `check:release` step of the site refuses a snapshot that is not the latest published tag. -Before 1.0, a feature or a behavior change gets a minor version, and a fix gets -a patch version. +From 1.0, versions follow `docs/versioning.md`. A fix gets a patch version. An +addition gets a minor version. A change that breaks the public API gets a +major version. The Context7 refresh workflow refreshes the index after each push to `main` that changes the documentation. Confirm that its run succeeded. diff --git a/README.md b/README.md index 712492d..3f36003 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,18 @@ 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. +**Status.** Version 1.0: the public API follows semantic versioning and does +not break before 2.0. [Versioning](docs/versioning.md) gives the limits of the +promise. First public release: August 6, 2026. One maintainer. + +**Production use.** One first-party application with more than 100,000 users +runs it in production. That is a count of users, not a measure of load. No +production use by other people is known. The [benchmarks](docs/benchmarks.md) +are development measurements, and longer soak tests are open work. + +**It is not** Solid Queue, SolidJS, or the `service_actor` gem, and it is not +affiliated with Rails or Cloudflare. + 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. @@ -193,9 +205,11 @@ explanations remain in `docs/`. The README stops here. ## Status and license -Solid Objects Ruby is a pre-1.0 early release. Its correctness core is tested -against SQLite, PostgreSQL, and MySQL, but the project makes no production-ready -claim. That requires more hardening and operational soak evidence. Expect breaking changes. +Version 1.0 promises a stable public API. Its correctness core is tested +against SQLite, PostgreSQL, and MySQL. It makes no claim of measured scale: +the [benchmarks](docs/benchmarks.md) are development measurements, and longer +soak tests are open work. [Versioning](docs/versioning.md) says what a release +can change. Solid Objects is released under the [MIT License](MIT-LICENSE). It is an independent project and is not affiliated with, sponsored by, or endorsed by diff --git a/docs/agents.md b/docs/agents.md index 5fffef7..8ea6af5 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -69,18 +69,22 @@ 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. +The doctor checks the `json` gem, the configuration, the tables, and one real +actor round trip. -Install the current release. `bundle add solid_objects` selects it. Do not pin -a version that you remember from earlier work; the API changed between -releases. The current version is on . +Install the current release. `bundle add solid_objects` selects it. Do not use +an API that you remember from a 0.x release; the API changed between 0.x +releases. From version 1.0, the public API follows +[semantic versioning](versioning.md). The current version is on +. The generated policies deny every call. Do step 5 before you call an actor. 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. +`unknown keyword: quirks_mode`, for every JSON column. The doctor reports this +problem as a failure of its `json` check, and the boot prints a warning. [Installing and upgrading](operations.md#installing-and-upgrading) has the details. @@ -184,7 +188,7 @@ Avoid these mistakes: | Mistake | Correct form | | --- | --- | -| `schedule(at: deadline)` with no operation after it | `schedule(at: deadline, key: buyer).expire(buyer:)`. `schedule` stages a reminder only when you call an operation on its result | +| `schedule(at: deadline)` with no operation after it | `schedule(at: deadline, key: buyer).expire(buyer:)`. A `schedule` call with no operation after it fails the turn with `SolidObjects::ScheduleWithoutOperation` | | `reject "room full"` | `reject(:room_full, "The room is full")` | | `id` inside an actor | `actor_id`. An actor has no `id` method | | `register_effect(:name) { \|context, arguments\| ... }` | `register_effect(:name) { \|arguments, context\| ... }`. The arguments come first | @@ -278,6 +282,7 @@ When you explain Solid Objects to a user, state these limits: - 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. +- Version 1.0 promises a stable public API. It makes no claim of measured scale, + and no production use by other people is known. The [correctness contract](correctness.md) is the source for each guarantee. diff --git a/docs/guides/expiring-reservations.md b/docs/guides/expiring-reservations.md index 8e7c6d6..d1ed2e5 100644 --- a/docs/guides/expiring-reservations.md +++ b/docs/guides/expiring-reservations.md @@ -164,7 +164,7 @@ See [the transactional outbox guide](transactional-outbox.md). - Delivery is at least once. Each operation must be safe to run again. - There are no transactions across actors. A reservation that spans two shows needs its own design. - One busy show runs its calls one at a time. Many shows run at the same time. -- The gem is pre-1.0 and makes no production-ready claim. +- Version 1.0 promises a stable public API. It makes no claim of measured scale. ## More information diff --git a/docs/guides/ordered-jobs.md b/docs/guides/ordered-jobs.md index 324eaf9..eb5c100 100644 --- a/docs/guides/ordered-jobs.md +++ b/docs/guides/ordered-jobs.md @@ -221,7 +221,7 @@ You do not assign sequence numbers. The actor mailbox gives the order. - Delivery is at least once. Each operation must be safe to run again. - The gem provides no transactions across actors. A transfer between two accounts needs its own design, for example one SQL transaction on normal tables. - One busy account runs its entries one at a time. -- The gem is pre-1.0 and makes no production-ready claim. +- Version 1.0 promises a stable public API. It makes no claim of measured scale. ## More information diff --git a/docs/guides/race-conditions.md b/docs/guides/race-conditions.md index cb6acd7..3ce681d 100644 --- a/docs/guides/race-conditions.md +++ b/docs/guides/race-conditions.md @@ -224,7 +224,7 @@ Delivery is at least once, so an effect can run more than once. See [effect idem - Delivery is at least once, not exactly once. Write each operation so that it can run again. - There are no transactions across actors. A change that must touch two events atomically needs one SQL transaction on normal tables. - One busy event runs its calls one at a time. Many events can run at the same time. -- The gem is pre-1.0 and makes no production-ready claim. +- Version 1.0 promises a stable public API. It makes no claim of measured scale. ## More information diff --git a/docs/guides/transactional-outbox.md b/docs/guides/transactional-outbox.md index 72c09a1..4ced9f3 100644 --- a/docs/guides/transactional-outbox.md +++ b/docs/guides/transactional-outbox.md @@ -145,7 +145,7 @@ One successful turn commits the actor state, the staged effects, the same-databa - Solid Objects requires Ruby 3.3 or newer and Rails 7.1 or newer. - Delivery is at least once. Each effect handler must deduplicate with `context.id`. - Solid Objects provides no transactions across actors. -- The gem is pre-1.0 and makes no production-ready claim. +- Version 1.0 promises a stable public API. It makes no claim of measured scale. ## More information diff --git a/docs/roadmap.md b/docs/roadmap.md index aac489e..fc00850 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -9,6 +9,15 @@ [observability](observability.md). - Rails engine, install generator, migration, and CLI +- A stable public API from version 1.0, with the limits in + [versioning](versioning.md) +- A turn that calls `schedule` and then calls no operation on the result fails + with `ScheduleWithoutOperation`, which is not retried, matching JavaScript. + Before 1.0 such a call stored no reminder and gave no error. +- A `json` check in `solid_objects:doctor` and a warning during the boot, for + the `json` gem 3.x on an Active Support release that cannot decode with it. + The check decodes one document and does not compare version numbers. This + surface is Rails-only. - Explicit actor registry, references, JSON state, and state migrations - Queries, observable projections, and personalized payloads reject state mutation and staged durable work with `QueryMutatedState`, matching JavaScript. Query @@ -245,5 +254,6 @@ latency, query counts, and the three reactive delivery paths are measured on SQLite; adapter-specific and end-to-end browser measurements are not. -No production-ready claim should be made until these hardening milestones have -operational soak evidence. +Version 1.0 freezes the public API under [semantic versioning](versioning.md). +It does not complete these hardening milestones. The project makes no claim of +measured scale until they have operational soak evidence. diff --git a/docs/versioning.md b/docs/versioning.md new file mode 100644 index 0000000..3a00174 --- /dev/null +++ b/docs/versioning.md @@ -0,0 +1,41 @@ +# Versioning + +From version 1.0.0, Solid Objects follows +[Semantic Versioning](https://semver.org/). + +## The promise + +The public API is the set of classes, methods, generators, commands, and +configuration options that `README.md` and the files in `docs/` describe. + +| Release | What it can change | +| --- | --- | +| Patch, for example 1.0.1 | Corrections of defects. No new behavior | +| Minor, for example 1.1.0 | New behavior. Code that uses the public API continues to work | +| Major, for example 2.0.0 | A change that breaks code that uses the public API | + +## Outside the promise + +- Classes, methods, and constants that the documentation does not describe. +- The database schema. A minor release can add tables, columns, and indexes, + and it supplies a migration for each change. + [Installing and upgrading](operations.md#installing-and-upgrading) gives the + steps. +- The text of error messages and log lines. The error classes are part of the + public API. +- The timing of background work, where no configuration option sets it. +- A correction of behavior that is different from the + [correctness contract](correctness.md). A patch release can make such a + correction. + +## What version 1.0 does not claim + +Version 1.0 is a promise about the API. It is not a claim of measured scale. +The [benchmarks](benchmarks.md) are development measurements and do not +guarantee capacity. Longer soak tests are open work. The project has one +maintainer, and no production use by other people is known. + +## Releases before 1.0 + +The 0.x releases changed the API between minor versions. `CHANGELOG.md` lists +each change. diff --git a/docs/virtual-actors.md b/docs/virtual-actors.md index 8970c0a..0cf4063 100644 --- a/docs/virtual-actors.md +++ b/docs/virtual-actors.md @@ -12,7 +12,8 @@ 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 +Solid Objects 1.0 promises a stable public API. It makes no claim of measured +scale. Read [Compatibility and maturity](#compatibility-and-maturity) before you choose it. ## What a virtual actor is @@ -198,9 +199,9 @@ matrix. 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. +- Version 1.0: the public API follows [semantic versioning](versioning.md). The + correctness core has tests against all three databases. The project makes no + claim of measured scale, and no production use by other people is known. The [roadmap](roadmap.md) records what is tested, what is partial, and what is next. For TypeScript and Node.js, use the diff --git a/solid_objects.gemspec b/solid_objects.gemspec index 7693231..1461683 100644 --- a/solid_objects.gemspec +++ b/solid_objects.gemspec @@ -8,8 +8,8 @@ Gem::Specification.new do |spec| spec.name = "solid_objects" spec.version = SolidObjects::VERSION spec.authors = [ "Lucas Carlson" ] - 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, and requires Ruby 3.3 or newer and Rails 7.1 or newer." + spec.summary = "Fix race conditions on one record in Rails with SQL-backed virtual actors" + spec.description = "Fix race conditions on one record in Rails: ordered operations, expiring holds, and durable state in your existing SQL database. Solid Objects is a SQL-backed virtual actor library for Ruby on Rails. 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, and requires Ruby 3.3 or newer and Rails 7.1 or newer." spec.homepage = "https://solidobjects.dev/ruby" spec.license = "MIT" spec.metadata = { diff --git a/test/unit/gem_specification_test.rb b/test/unit/gem_specification_test.rb index db589f2..694504b 100644 --- a/test/unit/gem_specification_test.rb +++ b/test/unit/gem_specification_test.rb @@ -21,6 +21,16 @@ class GemSpecificationTest < ActiveSupport::TestCase assert_match(/requires Ruby 3\.3 or newer and Rails 7\.1 or newer/, @specification.description) end + test "leads with the problems that developers search for" do + assert_match(/\AFix race conditions on one record in Rails/, @specification.summary) + assert_match(/\AFix race conditions on one record in Rails: /, @specification.description) + assert_match(/expiring holds/, @specification.description) + end + + test "packages the versioning promise" do + assert_includes @specification.files, "docs/versioning.md" + end + test "packages the agent and category guides" do assert_includes @specification.files, "docs/agents.md" assert_includes @specification.files, "docs/virtual-actors.md" From 17df9ad7d0795f6a936b4d2e8deca8470ffd18cd Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Tue, 13 Oct 2026 13:31:42 -0700 Subject: [PATCH 4/5] chore: prepare version 1.0.0 --- CHANGELOG.md | 20 ++++++++++++++++++++ Gemfile.lock | 4 ++-- lib/solid_objects/version.rb | 2 +- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca75bce..454d144 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 1.0.0 - 2026-10-13 + +- Version 1.0 freezes the public API under semantic versioning. + [docs/versioning.md](docs/versioning.md) gives the promise and its limits. + Version 1.0 is not a claim of measured scale: the benchmarks are + development measurements, and longer soak tests are open work. +- **Breaking:** a turn that calls `schedule` and then calls no operation on the + result now fails with `SolidObjects::ScheduleWithoutOperation`. The runtime + does not retry that turn, and the message becomes a dead letter. Before this + release, such a call stored no reminder and gave no error. Correct code, + such as `schedule(at: time).expire`, does not change. The turn can keep the + result and call the operation later. JavaScript has the same behavior. +- `bin/rails solid_objects:doctor` has a new `json` check, and the boot prints + a warning, when Active Support cannot decode JSON with the installed `json` + gem. This is the case for `json` 3.x on Rails older than 8.1.4. The check + decodes one document and does not compare version numbers. +- The gem summary and description now start with the problems that the gem + solves. The README has a status statement and names the projects that + Solid Objects is not. + ## 0.17.5 - 2026-10-12 - Back off repeated interrupted-message recovery using `retry_delay`. diff --git a/Gemfile.lock b/Gemfile.lock index 28eea9e..e3fb2dc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - solid_objects (0.17.5) + solid_objects (1.0.0) actioncable (>= 7.1) actionpack (>= 7.1) actionview (>= 7.1) @@ -392,7 +392,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.5) + solid_objects (1.0.0) 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 cfc0521..1711725 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.5" + VERSION = "1.0.0" end From a46bb8eb1d97085da47452d58e793dc30da0a640 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Tue, 13 Oct 2026 13:48:29 -0700 Subject: [PATCH 5/5] fix: catch a bare schedule call in an observable The check for a schedule call with no operation ran directly after the operation. An observable that called schedule only after the state changed was read later, so the turn committed with no reminder and no error. The open call stayed on the retained actor and made the next turn fail, although that turn did nothing wrong. The projection guard now counts an open schedule call as staged work. The turn that reads the observable fails with QueryMutatedState, and the failure path clears the open call. --- CHANGELOG.md | 4 ++- docs/reminders.md | 3 ++ docs/roadmap.md | 4 ++- lib/solid_objects/actor.rb | 9 ++++- .../schedule_without_operation_test.rb | 36 +++++++++++++++++++ 5 files changed, 53 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 454d144..51641aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,9 @@ does not retry that turn, and the message becomes a dead letter. Before this release, such a call stored no reminder and gave no error. Correct code, such as `schedule(at: time).expire`, does not change. The turn can keep the - result and call the operation later. JavaScript has the same behavior. + result and call the operation later. A `schedule` call with no operation in + an observable fails the turn with `SolidObjects::QueryMutatedState`. + JavaScript has the same behavior. - `bin/rails solid_objects:doctor` has a new `json` check, and the boot prints a warning, when Active Support cannot decode JSON with the installed `json` gem. This is the case for `json` 3.x on Rails older than 8.1.4. The check diff --git a/docs/reminders.md b/docs/reminders.md index 0d1910c..5c97fd9 100644 --- a/docs/reminders.md +++ b/docs/reminders.md @@ -33,6 +33,9 @@ schedule(at: 10.minutes.from_now, key: buyer).expire(buyer:) You can keep the result and call the operation later in the same turn. +An observable cannot call `schedule`, with or without an operation. The turn +that reads the observable fails with `SolidObjects::QueryMutatedState`. + ## A reminder is one named alarm per actor The uniqueness key is `(actor, reminder name)`. Scheduling a name that is diff --git a/docs/roadmap.md b/docs/roadmap.md index fc00850..e9bbfb5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -13,7 +13,9 @@ [versioning](versioning.md) - A turn that calls `schedule` and then calls no operation on the result fails with `ScheduleWithoutOperation`, which is not retried, matching JavaScript. - Before 1.0 such a call stored no reminder and gave no error. + Before 1.0 such a call stored no reminder and gave no error. The projection + guard counts such a call in an observable as staged work, so that turn fails + with `QueryMutatedState`. - A `json` check in `solid_objects:doctor` and a warning during the boot, for the `json` gem 3.x on an Active Support release that cannot decode with it. The check decodes one document and does not compare version numbers. This diff --git a/lib/solid_objects/actor.rb b/lib/solid_objects/actor.rb index c416275..bf64c62 100644 --- a/lib/solid_objects/actor.rb +++ b/lib/solid_objects/actor.rb @@ -586,7 +586,14 @@ def read_projection(operation) # @rbs () -> Array[Array[Hash[Symbol, Object]]] def intent_snapshot - [ effect_intents, effect_recovery_intents, commit_action_intents, reminder_intents, outbound_message_intents ].map do |intents| + [ + effect_intents, + effect_recovery_intents, + commit_action_intents, + reminder_intents, + outbound_message_intents, + open_schedule_calls + ].map do |intents| intents.map { |intent| intent.to_h.deep_dup } end end diff --git a/test/integration/schedule_without_operation_test.rb b/test/integration/schedule_without_operation_test.rb index 75faf60..6df851c 100644 --- a/test/integration/schedule_without_operation_test.rb +++ b/test/integration/schedule_without_operation_test.rb @@ -37,6 +37,25 @@ def expire end end + class ProjectionScheduler < SolidObjects::Actor + actor_type "schedule-in-projection" + + attribute :status, default: "open" + + observable :deadline do + schedule(at: 10.minutes.from_now, key: "ada") if status == "held" + status + end + + def hold + self.status = "held" + end + + def release + self.status = "open" + end + end + setup do SolidObjects.configuration.max_attempts = 3 SolidObjects.configuration.retry_delay = ->(_) { 0 } @@ -89,4 +108,21 @@ def expire assert_equal [ "expire" ], SolidObjects::Reminder.pluck(:operation) end + + test "a schedule call with no operation in an observable fails the turn that reads it" do + error = assert_raises(SolidObjects::MessageFailed) { ProjectionScheduler.ref("one").sync.hold } + + assert_equal "SolidObjects::QueryMutatedState", error.details.fetch("class") + assert_empty SolidObjects::Instance.find_by!(actor_id: "one").state + assert_equal 1, SolidObjects::Message.sole.attempt_count + end + + test "a schedule call with no operation in an observable does not make the next turn fail" do + reference = ProjectionScheduler.ref("one") + assert_raises(SolidObjects::MessageFailed) { reference.sync.hold } + + reference.sync.release + + assert_equal 1, SolidObjects::DeadLetter.count + end end