diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b99442..b62651f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +- Publish RBS contracts for effect callback envelopes and Ruby error summaries. + Check the runtime constructors and packaged consumer signatures strictly, + preserving ordinary hashes, keyword callbacks, serialization, and retries. + ## 0.14.6 - 2026-09-12 - Preserve committed turns when an Active Record after-commit callback raises. diff --git a/Rakefile b/Rakefile index 817ab12..5c3671b 100644 --- a/Rakefile +++ b/Rakefile @@ -14,7 +14,7 @@ task :rbs do FileUtils.rm_rf(File.expand_path("sig/generated", __dir__)) sh "bundle exec rbs-inline --base lib --base app --output sig/generated lib app" FileUtils.rm_f(File.expand_path("sig/generated/lib/generators/solid_objects/templates/solid_objects.rbs", __dir__)) - sh "bundle exec rbs -I sig/generated -I sig/support validate" + sh "bundle exec rbs -I sig/generated -I sig/support -I sig/public validate" end desc "Run Standard Ruby" diff --git a/Steepfile b/Steepfile index 4be879e..447cf5a 100644 --- a/Steepfile +++ b/Steepfile @@ -3,9 +3,20 @@ target :lib do signature "sig/generated" signature "sig/support" + signature "sig/public" check "lib" configure_code_diagnostics(Diagnostic::Ruby.lenient) ignore "lib/solid_objects/engine.rb" + ignore "lib/solid_objects/effect_payload.rb" +end + +target :effect_payloads do + signature "sig/generated" + signature "sig/support" + signature "sig/public" + check "lib/solid_objects/effect_payload.rb" + + configure_code_diagnostics(Diagnostic::Ruby.strict) end diff --git a/docs/architecture.md b/docs/architecture.md index 23b36b6..0856790 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -483,6 +483,50 @@ and `result:`. A failure callback receives `effect_id:`, `arguments:`, and `error:`, so an actor can correlate concurrent effects without storing a separate callback ledger. +### Typing your on_failure handler + +The gem ships `SolidObjects::effect_error`, +`SolidObjects::effect_failure_payload[Arguments]`, and +`SolidObjects::effect_success_payload[Arguments, Result]` as public RBS aliases. +They describe the existing string-keyed hashes; they are not Ruby wrapper classes. +See [signature loading](development.md#public-effect-payload-signatures) for Steep setup. + +For an actor with `generation` and `status` attributes, declare the callback keywords +in the application's RBS: + +```rbs +class ChatRun < SolidObjects::Actor + type run_arguments = { "generation" => Integer } + def fail_turn: (effect_id: String, arguments: run_arguments, error: SolidObjects::effect_error) -> void +end +``` + +```ruby +def fail_turn(effect_id:, arguments:, error:) + return unless arguments["generation"] == generation + + self.status = "failed" +end +``` + +Record access with `arguments["generation"]` retains its declared `Integer` type. +The callback receives top-level keywords, while nested arguments and error keys +remain strings. The failure envelope requires `"effect_id"`, `"arguments"`, and +`"error"`; the success envelope replaces `"error"` with `"result"`. Empty original +arguments remain `{}`, and a success result may be `nil` or any supported JSON value. +Ruby errors contain `"class"` (`String?`, including anonymous exception classes), +`"message"` (`String`, limited to 8,192 bytes), and `"backtrace"` (`Array[String]`, +limited to 50 entries and possibly empty). + +Applications supply the generic argument/result types to describe their serialized +JSON values. These aliases do not infer or validate independently registered effect +handlers. Their type parameters are deliberately unconstrained: Ruby serialization +accepts and normalizes values such as symbols, and RBS cannot express that conversion +as a generic bound. The constructors and consumer fixtures are checked with strict +Steep diagnostics. JavaScript exposes equivalent contracts with its existing +camelCase ID and `{ name, message }` error shape in +[solid-objects-js#47](https://github.com/cardmagic/solid-objects-js/issues/47). + A commit action is registered the same way and runs inside the short fenced transaction: diff --git a/docs/development.md b/docs/development.md index df148cf..0c91376 100644 --- a/docs/development.md +++ b/docs/development.md @@ -103,6 +103,26 @@ bundle exec rake rbs This follows the inline convention used by `cardmagic/classifier`. +### Public effect payload signatures + +The packaged `sig/public` directory owns the reusable effect payload aliases and +survives `rake rbs` regeneration. A host application's Steep target can load the +installed gem's complete signature tree alongside its own signatures: + +```ruby +target :app do + library "solid_objects" + signature "sig" + check "app/actors" + configure_code_diagnostics(Diagnostic::Ruby.strict) +end +``` + +This requires no internal runtime imports. See the +[typed callback example](architecture.md#typing-your-on_failure-handler). +The gem's strict payload target checks the actual constructors; its packaged +consumer test also verifies that missing keys and incorrect field types fail. + ## Formatting and security ```bash diff --git a/docs/reminders.md b/docs/reminders.md index 9a1a33b..b794b83 100644 --- a/docs/reminders.md +++ b/docs/reminders.md @@ -39,6 +39,10 @@ raises, and nothing is logged except a `solid_objects.reminder.replaced` event. ## An alarm per item, with `key:` +For watchdogs paired with an effect's give-up callback, see +[typing your `on_failure` handler](architecture.md#typing-your-on_failure-handler) +to retain the original argument types without repeating the error hash contract. + Pass `key:` when an actor is waiting on several things at once. The key is your own identifier for the item, and it names that item's alarm, so each item gets one: diff --git a/docs/roadmap.md b/docs/roadmap.md index cf0d540..b590dcb 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -14,6 +14,8 @@ dead letters, and tail retry - Transactional effects with success/failure actor messages carrying the originally staged arguments for callback correlation +- Public RBS effect success/failure envelopes and error records, checked against + the runtime constructors and a packaged consumer with strict Steep diagnostics - Actor-to-actor asynchronous outbox delivery. Effects and broadcasts use portable status rows with polling indexes and database check constraints on status, which works on all three adapters; a future version may add narrow diff --git a/lib/solid_objects.rb b/lib/solid_objects.rb index f256f6a..dcb48a7 100644 --- a/lib/solid_objects.rb +++ b/lib/solid_objects.rb @@ -55,6 +55,7 @@ require "solid_objects/wake_up_adapters" require "solid_objects/polling_backoff" require "solid_objects/effect_registry" +require "solid_objects/effect_payload" require "solid_objects/commit_action_registry" require "solid_objects/lease" require "solid_objects/lease_renewer" diff --git a/lib/solid_objects/effect_executor.rb b/lib/solid_objects/effect_executor.rb index d6f4aa9..30c1273 100644 --- a/lib/solid_objects/effect_executor.rb +++ b/lib/solid_objects/effect_executor.rb @@ -184,11 +184,11 @@ def complete(effect, result) effect: locked_effect, operation: locked_effect.success_operation, outcome: "success", - arguments: { - "effect_id" => locked_effect.effect_id, - "arguments" => locked_effect.arguments, - "result" => serialized_result - } + arguments: EffectPayload.success( + effect_id: locked_effect.effect_id, + arguments: locked_effect.arguments, + result: serialized_result + ) ) locked_effect.update!( status: "completed", @@ -216,21 +216,17 @@ def fail_effect(effect, error) locked_effect = Effect.lock.find(effect.id) verify_claim!(locked_effect) dead = locked_effect.attempt_count >= locked_effect.max_attempts - error_details = { - "class" => error.class.name, - "message" => error.message.to_s.byteslice(0, 8_192), - "backtrace" => Array(error.backtrace).first(50) - } + error_details = EffectPayload.error(error) if dead result_message = enqueue_result_message( effect: locked_effect, operation: locked_effect.failure_operation, outcome: "failure", - arguments: { - "effect_id" => locked_effect.effect_id, - "arguments" => locked_effect.arguments, - "error" => error_details - } + arguments: EffectPayload.failure( + effect_id: locked_effect.effect_id, + arguments: locked_effect.arguments, + error: error_details + ) ) end locked_effect.update!( diff --git a/lib/solid_objects/effect_payload.rb b/lib/solid_objects/effect_payload.rb new file mode 100644 index 0000000..ef7986c --- /dev/null +++ b/lib/solid_objects/effect_payload.rb @@ -0,0 +1,27 @@ +# rbs_inline: enabled + +module SolidObjects + module EffectPayload + class << self + # @rbs [Arguments, Result] (effect_id: String, arguments: Arguments, result: Result) -> effect_success_payload[Arguments, Result] + def success(effect_id:, arguments:, result:) + { "effect_id" => effect_id, "arguments" => arguments, "result" => result } + end + + # @rbs [Arguments] (effect_id: String, arguments: Arguments, error: effect_error) -> effect_failure_payload[Arguments] + def failure(effect_id:, arguments:, error:) + { "effect_id" => effect_id, "arguments" => arguments, "error" => error } + end + + # @rbs (Exception) -> effect_error + def error(exception) + message = exception.message.to_s.byteslice(0, 8_192) # : String + { + "class" => exception.class.name, + "message" => message, + "backtrace" => Array(exception.backtrace).first(50) + } + end + end + end +end diff --git a/sig/generated/lib/solid_objects/effect_payload.rbs b/sig/generated/lib/solid_objects/effect_payload.rbs new file mode 100644 index 0000000..e1c13c5 --- /dev/null +++ b/sig/generated/lib/solid_objects/effect_payload.rbs @@ -0,0 +1,14 @@ +# Generated from lib/solid_objects/effect_payload.rb with RBS::Inline + +module SolidObjects + module EffectPayload + # @rbs [Arguments, Result] (effect_id: String, arguments: Arguments, result: Result) -> effect_success_payload[Arguments, Result] + def self.success: [Arguments, Result] (effect_id: String, arguments: Arguments, result: Result) -> effect_success_payload[Arguments, Result] + + # @rbs [Arguments] (effect_id: String, arguments: Arguments, error: effect_error) -> effect_failure_payload[Arguments] + def self.failure: [Arguments] (effect_id: String, arguments: Arguments, error: effect_error) -> effect_failure_payload[Arguments] + + # @rbs (Exception) -> effect_error + def self.error: (Exception) -> effect_error + end +end diff --git a/sig/public/effect_payload.rbs b/sig/public/effect_payload.rbs new file mode 100644 index 0000000..caa4958 --- /dev/null +++ b/sig/public/effect_payload.rbs @@ -0,0 +1,19 @@ +module SolidObjects + type effect_error = { + "class" => String?, + "message" => String, + "backtrace" => Array[String] + } + + type effect_failure_payload[Arguments] = { + "effect_id" => String, + "arguments" => Arguments, + "error" => effect_error + } + + type effect_success_payload[Arguments, Result] = { + "effect_id" => String, + "arguments" => Arguments, + "result" => Result + } +end diff --git a/test/integration/effect_payload_types_test.rb b/test/integration/effect_payload_types_test.rb new file mode 100644 index 0000000..fef86da --- /dev/null +++ b/test/integration/effect_payload_types_test.rb @@ -0,0 +1,108 @@ +# rbs_inline: enabled + +require "test_helper" +require "fileutils" +require "open3" +require "tmpdir" +require "rubygems/package" +require "rubygems/installer" + +class EffectPayloadTypesTest < ActiveSupport::TestCase + test "packaged payload contracts check consumers and the actual constructors" do + Dir.mktmpdir("solid-objects-effect-types") do |directory| + package = build_package(directory) + project = File.join(directory, "consumer") + Gem::Package.new(package).extract_files(project) + FileUtils.cp(root_path("test/types/effect_payloads.rbs"), File.join(project, "sig/consumer.rbs")) + consumer_path = File.join(project, "consumer.rb") + consumer = File.read(root_path("test/types/effect_payloads.rb")) + File.write(consumer_path, consumer) + File.write(File.join(project, "Steepfile"), <<~RUBY) + target :consumer do + signature "sig" + check "consumer.rb" + check "lib/solid_objects/effect_payload.rb" + configure_code_diagnostics(Diagnostic::Ruby.strict) + end + RUBY + + output, status = typecheck(project) + assert status.success?, output + + configuration_path = File.join(project, "Steepfile") + configuration = File.read(configuration_path) + File.write(configuration_path, configuration.sub('signature "sig"', "library \"solid_objects\"\n signature \"sig/consumer.rbs\"")) + output, status = typecheck_installed(project, package) + assert status.success?, output + File.write(configuration_path, configuration) + + [ + [ '"effect_id" => "effect-1"', '"effect_identifier" => "effect-1"' ], + [ '"message" => "failed"', '"message" => 42' ], + [ 'arguments["generation"]', 'arguments["generation"].to_s' ] + ].each do |original, invalid| + File.write(consumer_path, consumer.sub(original, invalid)) + output, status = typecheck(project) + refute status.success?, "invalid consumer escaped the compiler: #{invalid}" + assert_includes output, "Ruby::MethodBodyTypeMismatch" + end + File.write(consumer_path, consumer) + + constructor_path = File.join(project, "lib/solid_objects/effect_payload.rb") + constructors = File.read(constructor_path) + [ + [ '"result" => result', '"outcome" => result' ], + [ '"error" => error', '"failure" => error' ], + [ '"backtrace" => Array(exception.backtrace).first(50)', '"backtrace" => [42]' ] + ].each do |original, invalid| + File.write(constructor_path, constructors.sub(original, invalid)) + output, status = typecheck(project) + refute status.success?, "invalid constructor escaped the compiler: #{invalid}" + assert_includes output, "Ruby::MethodBodyTypeMismatch" + end + end + end + + private + + def build_package(directory) + artifact = File.join(directory, "solid_objects.gem") + specification = Gem::Specification.load(root_path("solid_objects.gemspec")) + Gem::DefaultUserInteraction.use_ui(Gem::SilentUI.new) do + Gem::Package.build(specification, false, false, artifact) + end + artifact + end + + def typecheck(project) + output, error_output, status = Open3.capture3( + Gem.ruby, Gem.bin_path("steep", "steep"), "check", "--no-daemon", "-j", "1", + chdir: project + ) + [ output + error_output, status ] + end + + def typecheck_installed(project, package) + gem_directory = File.join(project, "gems") + specification = Gem::Installer.at(package, install_dir: gem_directory, ignore_dependencies: true).install + script = <<~RUBY + gem "solid_objects", #{"= #{SolidObjects::VERSION}".inspect} + resolved = Gem.loaded_specs.fetch("solid_objects").full_gem_path + abort "loaded signatures outside the built gem: \#{resolved}" unless resolved == #{specification.full_gem_path.inspect} + load ARGV.shift + RUBY + output, error_output, status = Open3.capture3( + { + "RUBYOPT" => nil, "BUNDLE_GEMFILE" => nil, + "GEM_HOME" => gem_directory, "GEM_PATH" => ([ gem_directory ] + Gem.path).join(File::PATH_SEPARATOR) + }, + Gem.ruby, "-e", script, Gem.bin_path("steep", "steep"), "check", "--no-daemon", "-j", "1", + chdir: project + ) + [ output + error_output, status ] + end + + def root_path(path) + File.expand_path("../../#{path}", __dir__) + end +end diff --git a/test/integration/effects_test.rb b/test/integration/effects_test.rb index 37ef1ee..be30418 100644 --- a/test/integration/effects_test.rb +++ b/test/integration/effects_test.rb @@ -41,6 +41,86 @@ def payment_failed(effect_id:, arguments:, error:) SolidObjects.configuration.retry_delay = ->(_attempt) { 0 } end + class CallbackActor < SolidObjects::Actor + actor_type "callback-payloads" + attribute :received, default: {} + + def start_delivery(arguments: {}, callback: "receive_success") + emit :delivery, on_success: callback, on_failure: :receive_failure, **arguments.symbolize_keys + end + + def receive_success(effect_id:, arguments:, result:) + self.received = { "effect_id" => effect_id, "arguments" => arguments, "result" => result } + end + + message :receive_block do |effect_id:, arguments:, result:| + self.received = { "effect_id" => effect_id, "arguments" => arguments, "result" => result } + end + + def receive_failure(effect_id:, arguments:, error:) + self.received = { "effect_id" => effect_id, "arguments" => arguments, "error" => error } + end + end + + test "persists and delivers complete success envelopes to method and block callbacks" do + worker = SolidObjects::Worker.new + effect_executor = SolidObjects::EffectExecutor.new + arguments = { "generation" => 2, "nested" => { "keep" => true } } + + [ "receive_success", "receive_block" ].each do |callback| + [ nil, false, 42, "reply", [ "reply" ], { "reply" => "done" } ].each_with_index do |result, index| + SolidObjects.register_effect(:delivery) { result } + reference = CallbackActor.ref("#{callback}-#{index}") + original_arguments = index.zero? ? {} : arguments + reference.async.start_delivery(arguments: original_arguments, callback:) + worker.run_until_idle + assert effect_executor.run_once + + effect = SolidObjects::Effect.order(:id).last + expected = { "effect_id" => effect.effect_id, "arguments" => original_arguments, "result" => result } + message = SolidObjects::Message.find_by!(idempotency_key: "effect:#{effect.effect_id}:success") + assert_equal expected, message.arguments + worker.run_until_idle + assert_equal expected, effect.instance.reload.state.fetch("received") + end + end + ensure + effect_executor&.stop + worker&.stop + end + + test "delivers one complete failure envelope only after exhaustion" do + SolidObjects.configuration.max_attempts = 2 + error = RuntimeError.new("provider unavailable") + error.set_backtrace([ "provider.rb:12" ]) + SolidObjects.register_effect(:delivery) { raise error } + CallbackActor.ref("failure").async.start_delivery + worker = SolidObjects::Worker.new + worker.run_until_idle + effect_executor = SolidObjects::EffectExecutor.new + effect = SolidObjects::Effect.first + + refute effect_executor.run_once + assert_equal "pending", effect.reload.status + assert_nil SolidObjects::Message.find_by(idempotency_key: "effect:#{effect.effect_id}:failure") + refute effect_executor.run_once + assert_equal "dead", effect.reload.status + expected = { + "effect_id" => effect.effect_id, "arguments" => {}, + "error" => { "class" => "RuntimeError", "message" => "provider unavailable", "backtrace" => [ "provider.rb:12" ] } + } + messages = SolidObjects::Message.where(idempotency_key: "effect:#{effect.effect_id}:failure") + assert_equal [ expected ], messages.map(&:arguments) + assert_equal expected.fetch("error"), effect.error + worker.run_until_idle + assert_equal expected, effect.instance.reload.state.fetch("received") + refute effect_executor.run_once + assert_equal 1, messages.count + ensure + effect_executor&.stop + worker&.stop + end + test "commits state, message completion, and effect together" do message_reference = CheckoutActor.ref("order-1").async.checkout(payment_id: "payment-1") worker = SolidObjects::Worker.new diff --git a/test/types/effect_payloads.rb b/test/types/effect_payloads.rb new file mode 100644 index 0000000..b65f7f3 --- /dev/null +++ b/test/types/effect_payloads.rb @@ -0,0 +1,31 @@ +# rbs_inline: enabled + +class EffectPayloadConsumer < SolidObjects::Actor + def fail_turn(effect_id:, arguments:, error:) + arguments["generation"] + end + + def success_value(payload) + payload["result"] + end + + def error_message(payload) + payload["error"]["message"] + end + + def error_class(payload) + payload["error"]["class"] + end + + def error_backtrace(payload) + payload["error"]["backtrace"] + end + + def failure(arguments) + { + "effect_id" => "effect-1", + "arguments" => arguments, + "error" => { "class" => "Error", "message" => "failed", "backtrace" => [] } + } + end +end diff --git a/test/types/effect_payloads.rbs b/test/types/effect_payloads.rbs new file mode 100644 index 0000000..1c26d34 --- /dev/null +++ b/test/types/effect_payloads.rbs @@ -0,0 +1,10 @@ +class EffectPayloadConsumer < SolidObjects::Actor + type run_arguments = { "generation" => Integer } + + def fail_turn: (effect_id: String, arguments: run_arguments, error: SolidObjects::effect_error) -> Integer + def success_value: (SolidObjects::effect_success_payload[run_arguments, String]) -> String + def error_message: (SolidObjects::effect_failure_payload[run_arguments]) -> String + def error_class: (SolidObjects::effect_failure_payload[run_arguments]) -> String? + def error_backtrace: (SolidObjects::effect_failure_payload[run_arguments]) -> Array[String] + def failure: (run_arguments) -> SolidObjects::effect_failure_payload[run_arguments] +end diff --git a/test/unit/effect_payload_test.rb b/test/unit/effect_payload_test.rb new file mode 100644 index 0000000..9f223f1 --- /dev/null +++ b/test/unit/effect_payload_test.rb @@ -0,0 +1,42 @@ +# rbs_inline: enabled + +require "test_helper" + +class EffectPayloadTest < ActiveSupport::TestCase + test "retains original arguments and each JSON success result" do + arguments = { "generation" => 2, "nested" => { "retained" => true } } + + [ nil, false, 42, "reply", [ "reply" ], { "reply" => "done" } ].each do |result| + payload = SolidObjects::EffectPayload.success(effect_id: "effect-1", arguments:, result:) + + assert_equal({ "effect_id" => "effect-1", "arguments" => arguments, "result" => result }, payload) + end + end + + test "retains the Ruby error fields and original empty arguments" do + error = RuntimeError.new("failed") + error.set_backtrace([ "actor.rb:12" ]) + summary = SolidObjects::EffectPayload.error(error) + + assert_equal({ "class" => "RuntimeError", "message" => "failed", "backtrace" => [ "actor.rb:12" ] }, summary) + assert_equal({ "effect_id" => "effect-1", "arguments" => {}, "error" => summary }, + SolidObjects::EffectPayload.failure(effect_id: "effect-1", arguments: {}, error: summary)) + end + + test "retains existing message and backtrace limits" do + error = RuntimeError.new("x" * 9_000) + error.set_backtrace(Array.new(60) { |index| "actor.rb:#{index}" }) + + summary = SolidObjects::EffectPayload.error(error) + + assert_equal "x" * 8_192, summary.fetch("message") + assert_equal Array.new(50) { |index| "actor.rb:#{index}" }, summary.fetch("backtrace") + end + + test "retains anonymous error classes and empty messages and backtraces" do + error = Class.new(StandardError).new("") + + assert_equal({ "class" => nil, "message" => "", "backtrace" => [] }, + SolidObjects::EffectPayload.error(error)) + end +end