solid_objects 0.14.5 → 0.14.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 45c9deb9d06f62e056668c5d3b547786ba69f9d866c302d850650d4ddeac7f9d
4
- data.tar.gz: be67022b7559e87fa48adc91bc09466c005a77345e1631405cd4be76a038e8b5
3
+ metadata.gz: d36e77947a8de94216c9174cc3a34a878803bdf242efa0b10f6067d48b4e7cda
4
+ data.tar.gz: 178955e77093552df5569ee5c0badfb6035a51a479c7bcfbd1025465ac5947a4
5
5
  SHA512:
6
- metadata.gz: 43ad462709bbb061bc9aef103ce4bed2b9f5432c2efd507a743ccbdd33c255c7c4c8540d8d203baef62ef1c552ffd8e6978403c83a400fa99329be9cafbc44ed
7
- data.tar.gz: 909192b9a0ca8674439e4ade94c09b6d0cd03b6cddc1082d2f55d02e0de41b27b64688bd1cb8c8af77febb5dd3a78241b656fa8d77a12942b3e1678b61450e37
6
+ metadata.gz: 491698862debab8266650dbdc21a86561d3fa7dd51764edd469a7b5173a1b2a24a2542d39a584796d3433a2bc66794d7d5251ebfe73d7c991d539f2a4c92d287
7
+ data.tar.gz: 705ce841595e49cbc892535afd15fb96b8ac4134bb01a1c92de8d12061a6fe5717b745d989decf82fb1c25a19966b9e50a88afb30a1426c23055e1333c00f2cc
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.7 - 2026-09-14
4
+
5
+ - Publish RBS contracts for effect callback envelopes and Ruby error summaries.
6
+ Check the runtime constructors and packaged consumer signatures strictly,
7
+ preserving ordinary hashes, keyword callbacks, serialization, and retries.
8
+ - Add optional actor-specific RBS generation for staged schedule/transmit calls
9
+ and effect callback names. Reuse application-declared operation argument types
10
+ without changing Ruby dispatch, runtime validation, or global effect registries.
11
+
12
+ ## 0.14.6 - 2026-09-12
13
+
14
+ - Preserve committed turns when an Active Record after-commit callback raises.
15
+ Caller assistance and workers expose the original callback exception without
16
+ restoring obsolete state, retrying/rejecting the completed message, or
17
+ masking the error as a missing claim. Track actual SQL commitment before
18
+ SQLite retries and deadline translation; retain pre-commit rollback, retry,
19
+ and domain-rejection behavior.
20
+
3
21
  ## 0.14.5 - 2026-09-03
4
22
 
5
23
  - Split broadcast claiming into separate pending and stale-processing probes,
data/README.md CHANGED
@@ -110,15 +110,13 @@ bundle exec solid_objects start
110
110
  ```
111
111
 
112
112
  Stop that process before the deadline and restart it afterwards. The reminder
113
- is still in the Rails database and runs when the process returns. We have given
114
- `self.available += 1` a supervisor and excellent posture.
113
+ is still in the Rails database and runs when the process returns. The reminder survived the restart.
115
114
 
116
115
  ## Why this exists
117
116
 
118
117
  The handwritten Rails version usually starts with `with_lock`. Then it gains an
119
118
  `expires_at` column, a cron job, an Active Job retry policy, and an Action Cable
120
- broadcast that must agree with the write. A small invariant has become a rich
121
- tapestry of callbacks and scheduled cleanup.
119
+ broadcast that must agree with the write. A small invariant has spread across callbacks and scheduled cleanup.
122
120
 
123
121
  This is complicated, hard to test, fragile and unnecessary.
124
122
 
@@ -133,14 +131,12 @@ PostgreSQL, or MySQL. Redis and a separate actor service are not required.
133
131
  - Account, device, assessment, and approval workflows that survive deploys.
134
132
  - Reactive ERB views that must follow committed actor revisions.
135
133
 
136
- Different identities can run concurrently. Put the whole application behind
137
- one actor ID and Rails will faithfully operate your new bottleneck.
134
+ Different identities can run concurrently. One actor ID for the whole application is a bottleneck.
138
135
 
139
136
  ## When a transaction is better
140
137
 
141
138
  Often. If the entire invariant fits inside one request, use `with_lock`, a
142
- database constraint, or a short transaction. A row lock does not need a
143
- personal brand, and it is usually the clearest answer.
139
+ database constraint, or a short transaction. A row lock is usually the clearest answer.
144
140
 
145
141
  Use Solid Objects when work must happen later, survive a restart, or stay
146
142
  ordered across several requests or jobs. A plain counter remains one line of
@@ -171,15 +167,13 @@ Exactly once is not hiding in a more advanced configuration. Read the
171
167
  - [Detailed documentation](docs/)
172
168
 
173
169
  The dashboard, benchmarks, migration cookbook, schema, and exhaustive API
174
- explanations remain in `docs/`. The README is stopping before it develops a
175
- robust interplay with its own table of contents.
170
+ explanations remain in `docs/`. The README stops here.
176
171
 
177
172
  ## Status and license
178
173
 
179
174
  Solid Objects Ruby is a pre-1.0 early release. Its correctness core is tested
180
175
  against SQLite, PostgreSQL, and MySQL, but the project makes no production-ready
181
- claim. That requires more hardening and operational soak evidence. Pre-1.0 is
182
- not decorative punctuation.
176
+ claim. That requires more hardening and operational soak evidence. Expect breaking changes.
183
177
 
184
178
  Solid Objects is released under the [MIT License](MIT-LICENSE). It is an
185
179
  independent project and is not affiliated with, sponsored by, or endorsed by
data/Rakefile CHANGED
@@ -14,7 +14,7 @@ task :rbs do
14
14
  FileUtils.rm_rf(File.expand_path("sig/generated", __dir__))
15
15
  sh "bundle exec rbs-inline --base lib --base app --output sig/generated lib app"
16
16
  FileUtils.rm_f(File.expand_path("sig/generated/lib/generators/solid_objects/templates/solid_objects.rbs", __dir__))
17
- sh "bundle exec rbs -I sig/generated -I sig/support validate"
17
+ sh "bundle exec rbs -I sig/generated -I sig/support -I sig/public validate"
18
18
  end
19
19
 
20
20
  desc "Run Standard Ruby"
data/docs/architecture.md CHANGED
@@ -314,9 +314,30 @@ Any lease or message predicate failure raises `LostActivation` and rolls back ev
314
314
 
315
315
  The state version can advance because of state migration even when the message itself makes no state change.
316
316
 
317
+ Active Record may raise from an application `after_commit` callback while the
318
+ transaction call unwinds, after SQL has committed. The adapter retains the
319
+ actual transaction object and checks its fully committed state before any
320
+ deadline conversion or SQLite retry. Reaching the end of the block, releasing
321
+ a savepoint, or observing an in-memory message update is not proof of SQL
322
+ commitment. A failing `before_commit` callback follows the rollback path.
323
+
324
+ An internal `CommittedTransactionError` carries the original error through
325
+ executor, SQLite retry, and coordination rescue handlers. The synchronous
326
+ invocation and worker boundaries re-raise the original exception with its
327
+ original backtrace and cause. The wrapper prevents a callback's `Rejected`,
328
+ `LostActivation`, or database error from being mistaken for a pre-commit
329
+ outcome. It is not a durable message error or a new delivery mechanism.
330
+
331
+ After commitment, actor state, application writes, message completion/result,
332
+ and outboxes remain durable. The executor preserves the committed activation
333
+ state and does not retry, reject, or dead-letter that turn. Caller assistance
334
+ still deactivates and releases its lease during cleanup; cached worker state
335
+ can process later messages. A worker running its continuous loop exposes the
336
+ error and runs its existing shutdown cleanup.
337
+
317
338
  ## Failure path
318
339
 
319
- Actor exceptions roll back all in-memory changes by restoring the pre-turn state. A separate short transaction conditionally owned by the current generation:
340
+ Before commitment, actor and commit-action exceptions roll back all in-memory changes by restoring the pre-turn state. A separate short transaction conditionally owned by the current generation:
320
341
 
321
342
  - Stores a sanitized error
322
343
  - Deletes claimed membership
@@ -403,9 +424,15 @@ outer commit, and callers timing out on work they indirectly block.
403
424
  waiting and immediately returns a `MessageReference`. Runtime workers process
404
425
  it normally.
405
426
 
427
+ An executing caller receives an inline after-commit callback error even though
428
+ the turn committed. An independently waiting caller observes the durable
429
+ result and may return before that callback raises in the worker. Completed
430
+ history is not changed retroactively; callback failures must be observed in
431
+ the executing process. Later Rails callbacks may not run after one raises.
432
+
406
433
  ## Domain rejection
407
434
 
408
- Actor code can call `reject` for a validation or business-rule outcome that
435
+ Before commitment, actor code can call `reject` for a validation or business-rule outcome that
409
436
  must not retry. The executor restores pre-turn state, discards staged intents,
410
437
  stores the structured rejection, completes the claimed membership, and
411
438
  continues with the next sequence in one fenced transaction. Synchronous callers
@@ -416,6 +443,10 @@ a dead letter.
416
443
 
417
444
  `emit` creates a staged effect:
418
445
 
446
+ Typed applications can generate [actor-specific RBS signatures](development.md#actor-specific-dispatch-signatures)
447
+ to check callback names and staged `schedule`/`transmit` calls without changing
448
+ their Ruby syntax. Effect and commit-action registry names remain independent.
449
+
419
450
  ```ruby
420
451
  emit(
421
452
  :charge_payment,
@@ -456,6 +487,50 @@ and `result:`. A failure callback receives `effect_id:`, `arguments:`, and
456
487
  `error:`, so an actor can correlate concurrent effects without storing a
457
488
  separate callback ledger.
458
489
 
490
+ ### Typing your on_failure handler
491
+
492
+ The gem ships `SolidObjects::effect_error`,
493
+ `SolidObjects::effect_failure_payload[Arguments]`, and
494
+ `SolidObjects::effect_success_payload[Arguments, Result]` as public RBS aliases.
495
+ They describe the existing string-keyed hashes; they are not Ruby wrapper classes.
496
+ See [signature loading](development.md#public-effect-payload-signatures) for Steep setup.
497
+
498
+ For an actor with `generation` and `status` attributes, declare the callback keywords
499
+ in the application's RBS:
500
+
501
+ ```rbs
502
+ class ChatRun < SolidObjects::Actor
503
+ type run_arguments = { "generation" => Integer }
504
+ def fail_turn: (effect_id: String, arguments: run_arguments, error: SolidObjects::effect_error) -> void
505
+ end
506
+ ```
507
+
508
+ ```ruby
509
+ def fail_turn(effect_id:, arguments:, error:)
510
+ return unless arguments["generation"] == generation
511
+
512
+ self.status = "failed"
513
+ end
514
+ ```
515
+
516
+ Record access with `arguments["generation"]` retains its declared `Integer` type.
517
+ The callback receives top-level keywords, while nested arguments and error keys
518
+ remain strings. The failure envelope requires `"effect_id"`, `"arguments"`, and
519
+ `"error"`; the success envelope replaces `"error"` with `"result"`. Empty original
520
+ arguments remain `{}`, and a success result may be `nil` or any supported JSON value.
521
+ Ruby errors contain `"class"` (`String?`, including anonymous exception classes),
522
+ `"message"` (`String`, limited to 8,192 bytes), and `"backtrace"` (`Array[String]`,
523
+ limited to 50 entries and possibly empty).
524
+
525
+ Applications supply the generic argument/result types to describe their serialized
526
+ JSON values. These aliases do not infer or validate independently registered effect
527
+ handlers. Their type parameters are deliberately unconstrained: Ruby serialization
528
+ accepts and normalizes values such as symbols, and RBS cannot express that conversion
529
+ as a generic bound. The constructors and consumer fixtures are checked with strict
530
+ Steep diagnostics. JavaScript exposes equivalent contracts with its existing
531
+ camelCase ID and `{ name, message }` error shape in
532
+ [solid-objects-js#47](https://github.com/cardmagic/solid-objects-js/issues/47).
533
+
459
534
  A commit action is registered the same way and runs inside the short fenced
460
535
  transaction:
461
536
 
data/docs/correctness.md CHANGED
@@ -125,6 +125,29 @@ it is available only when Solid Objects and `ActiveRecord::Base` share one
125
125
  connection pool. Commit actions must contain only bounded database work.
126
126
  External I/O belongs in the effect outbox.
127
127
 
128
+ ### Errors after SQL commit
129
+
130
+ Active Record `after_commit` callbacks registered by commit actions run after
131
+ SQL commitment. If one raises, the application writes, actor state, message
132
+ result/completion, and claimed-membership deletion remain committed. The
133
+ executor does not restore the pre-turn snapshot, retry the business action,
134
+ reject the message, or create a dead letter. A `before_commit` callback can
135
+ still roll back everything even after the fenced transaction block finishes.
136
+
137
+ The executing synchronous caller or `Worker#run_once` receives the original
138
+ callback exception, including its identity, backtrace, and cause. This also
139
+ applies when the callback raises `Rejected`, `LostActivation`, or a database
140
+ deadline/lock error: its class does not change a committed turn into a failed
141
+ one. Synchronous cleanup releases the activation; a worker retains the
142
+ committed state until normal deactivation or shutdown, so later messages can
143
+ continue from that state.
144
+
145
+ A separate waiting caller can observe the durable result before the callback
146
+ finishes. There is no retroactive failure delivery or durable callback-error
147
+ result. Inspect and report errors in the executing process. Rails may skip
148
+ later callbacks when one raises; these callbacks are not a durable delivery
149
+ mechanism. Use an idempotent effect for work that needs independent retries.
150
+
128
151
  ## Reactive components
129
152
 
130
153
  A successful fenced turn advances `instances.state_revision` to that message's
@@ -205,7 +228,7 @@ wakes the caller, and raises `SolidObjects::ActorDestroyed`.
205
228
 
206
229
  ## Domain rejection
207
230
 
208
- `reject` is a terminal domain outcome, not an infrastructure failure. It rolls
231
+ `reject` before commitment is a terminal domain outcome, not an infrastructure failure. It rolls
209
232
  back in-memory state and staged intents, stores a structured rejection on the
210
233
  message, removes claimed membership, and lets the next sequence run. It is
211
234
  never retried or dead-lettered. The synchronous caller receives
data/docs/development.md CHANGED
@@ -103,6 +103,116 @@ bundle exec rake rbs
103
103
 
104
104
  This follows the inline convention used by `cardmagic/classifier`.
105
105
 
106
+ ### Public effect payload signatures
107
+
108
+ The packaged `sig/public` directory owns the reusable effect payload aliases and
109
+ survives `rake rbs` regeneration. A host application's Steep target can load the
110
+ installed gem's complete signature tree alongside its own signatures:
111
+
112
+ ```ruby
113
+ target :app do
114
+ library "solid_objects"
115
+ signature "sig"
116
+ check "app/actors"
117
+ configure_code_diagnostics(Diagnostic::Ruby.strict)
118
+ end
119
+ ```
120
+
121
+ This requires no internal runtime imports. See the
122
+ [typed callback example](architecture.md#typing-your-on_failure-handler).
123
+ The gem's strict payload target checks the actual constructors; its packaged
124
+ consumer test also verifies that missing keys and incorrect field types fail.
125
+
126
+ ### Actor-specific dispatch signatures
127
+
128
+ Typed applications can opt into `SolidObjects::ActorSignatures` to check ordinary
129
+ `schedule`, `transmit`, and effect callback names inside actor methods. Add `rbs`
130
+ and `steep` to the application's development dependencies. This tool is loaded
131
+ explicitly and is not required by workers or ordinary Ruby applications.
132
+
133
+ Declare application operation types first, including inherited operations and
134
+ block-defined `message` operations. Inline RBS can generate this input, or keep
135
+ handwritten declarations in a separate directory such as `sig/actors`:
136
+
137
+ ```rbs
138
+ class ChatRun < SolidObjects::Actor
139
+ def recover_if_stuck: (generation: Integer) -> nil
140
+ def fail_turn: (effect_id: String, arguments: Hash[String, untyped], error: Hash[String, untyped]) -> nil
141
+ def start: () -> nil
142
+ end
143
+ ```
144
+
145
+ After loading the application's actor classes, generate a separate output file:
146
+
147
+ ```ruby
148
+ require "solid_objects/actor_signatures"
149
+
150
+ Rails.application.reloader.wrap do
151
+ signatures = SolidObjects::ActorSignatures.generate(
152
+ actors: [ChatRun],
153
+ signatures: [Rails.root.join("sig/actors").to_s]
154
+ )
155
+ FileUtils.mkdir_p(Rails.root.join("sig/generated"))
156
+ File.write(Rails.root.join("sig/generated/solid_objects.rbs"), signatures)
157
+ end
158
+ ```
159
+
160
+ Run that script with `bin/rails runner` during development or CI. Outside Rails,
161
+ require the actor definitions and call `generate` directly. The generator returns
162
+ a string and does not write files, execute actor operations, start workers, or run
163
+ migrations. Rails boot follows the application's normal loading configuration;
164
+ the explicit actor list resolves its autoloaded classes within the reloader boundary.
165
+ Keep generated output out of the input signature paths, and regenerate after a
166
+ message is renamed or removed. Output is deterministic; handwritten signatures
167
+ remain separate.
168
+
169
+ Load the gem and both application signature directories in `Steepfile`:
170
+
171
+ ```ruby
172
+ target :actors do
173
+ library "solid_objects"
174
+ signature "sig/actors"
175
+ signature "sig/generated"
176
+ check "app/actors"
177
+ configure_code_diagnostics(Diagnostic::Ruby.strict)
178
+ end
179
+ ```
180
+
181
+ The usual Ruby code now passes Steep without a dispatcher cast:
182
+
183
+ ```ruby
184
+ schedule(at: Time.now, key: "watchdog").recover_if_stuck(generation: 1)
185
+ transmit.recover_if_stuck(generation: 1)
186
+ emit :run_model, generation: 1, on_failure: :fail_turn
187
+ ```
188
+
189
+ Misspelled operations/callbacks, queries, attributes, private methods, infrastructure
190
+ methods, and incorrect keyword arguments fail the strict check. Both string and
191
+ symbol callback literals work. Staging returns `nil` even when an operation's own
192
+ return type differs. Effect and commit-action names remain global registry names;
193
+ registry contract inference is separate work.
194
+
195
+ Reflection supplies message names only. Values come from declared RBS signatures;
196
+ missing signatures and positional/block arguments are rejected. Block-defined
197
+ messages require explicit method declarations in RBS; annotate their block-local
198
+ values separately when checking the block body. Generic actor classes currently
199
+ require application-owned dispatcher signatures. The generator preserves method
200
+ overloads and method type parameters.
201
+
202
+ Deliberately dynamic names can use Ruby's explicit dynamic dispatch:
203
+
204
+ ```ruby
205
+ schedule(at: Time.now).public_send(operation_name, generation: generation)
206
+ public_send(:emit, :run_model, on_failure: callback_name, generation: generation)
207
+ ```
208
+
209
+ Those calls opt out of name/argument checking and retain the existing runtime
210
+ validation. Ordinary calls on the generated dispatcher have no string-name fallback.
211
+ `send_to`, `Reference#async`, direct calls, and queries retain their existing
212
+ signatures and runtime behavior; this generator does not provide complete reference
213
+ typing or Sorbet/Tapioca actor-specific RBI generation. The corresponding TypeScript
214
+ work is tracked in [solid-objects-js#46](https://github.com/cardmagic/solid-objects-js/issues/46).
215
+
106
216
  ## Formatting and security
107
217
 
108
218
  ```bash
data/docs/operations.md CHANGED
@@ -69,6 +69,12 @@ Rails schema migrations and actor state migrations are separate concerns.
69
69
 
70
70
  ### Host application tooling
71
71
 
72
+ RBS/Steep applications can generate
73
+ [actor-specific dispatch signatures](development.md#actor-specific-dispatch-signatures)
74
+ for reminders, transmit calls, and effect callback names. This is optional development
75
+ tooling; generated signatures do not change runtime dispatch. The generator does
76
+ not supply equivalent Sorbet/Tapioca actor-specific types.
77
+
72
78
  Installed engine migrations are copied as
73
79
  `db/migrate/*_create_solid_objects_tables.solid_objects.rb`. If the host enables
74
80
  `Rails/CreateTableWithTimestamps`, exclude engine-owned migrations rather than
data/docs/reminders.md CHANGED
@@ -39,6 +39,14 @@ raises, and nothing is logged except a `solid_objects.reminder.replaced` event.
39
39
 
40
40
  ## An alarm per item, with `key:`
41
41
 
42
+ For watchdogs paired with an effect's give-up callback, see
43
+ [typing your `on_failure` handler](architecture.md#typing-your-on_failure-handler)
44
+ to retain the original argument types without repeating the error hash contract.
45
+
46
+ For static checking of watchdog operation names and keyword arguments, opt into
47
+ [actor-specific RBS signatures](development.md#actor-specific-dispatch-signatures).
48
+ The Ruby `schedule(...).recover_if_stuck(generation: ...)` syntax stays the same.
49
+
42
50
  Pass `key:` when an actor is waiting on several things at once. The key is your
43
51
  own identifier for the item, and it names that item's alarm, so each item gets
44
52
  one:
data/docs/roadmap.md CHANGED
@@ -14,6 +14,8 @@
14
14
  dead letters, and tail retry
15
15
  - Transactional effects with success/failure actor messages carrying the
16
16
  originally staged arguments for callback correlation
17
+ - Public RBS effect success/failure envelopes and error records, checked against
18
+ the runtime constructors and a packaged consumer with strict Steep diagnostics
17
19
  - Actor-to-actor asynchronous outbox delivery. Effects and broadcasts use
18
20
  portable status rows with polling indexes and database check constraints on
19
21
  status, which works on all three adapters; a future version may add narrow
@@ -65,6 +67,10 @@
65
67
  gem's dependencies
66
68
  - Inline RBS generation/validation, Steep, Standard Ruby, Solid Queue's exact
67
69
  RuboCop policy, and a warning-free Brakeman scan
70
+ - Opt-in actor-specific RBS generation for schedule/transmit keyword arguments
71
+ and effect callback names, using application declarations and checked consumer
72
+ fixtures. Dynamic names remain an explicit escape hatch; complete reference
73
+ typing and actor-specific RBI generation are separate work
68
74
  - Compatibility CI across the supported span: Ruby 3.3, 3.4, and 4.0 against
69
75
  Rails 7.1, 7.2, 8.0, and 8.1, pinned through `RAILS_VERSION` so the advertised
70
76
  range is verified rather than assumed. The compatibility job runs SQLite only;
@@ -0,0 +1,80 @@
1
+ # rbs_inline: enabled
2
+
3
+ require "solid_objects"
4
+ require "rbs"
5
+ require "pathname"
6
+
7
+ module SolidObjects
8
+ class ActorSignatures
9
+ # @rbs (actors: Array[Class], signatures: Array[String]) -> String
10
+ def self.generate(actors:, signatures:)
11
+ new(signatures:).generate(actors:)
12
+ end
13
+
14
+ # @rbs @builder: untyped
15
+
16
+ # @rbs (signatures: Array[String]) -> void
17
+ def initialize(signatures:)
18
+ loader = RBS::EnvironmentLoader.new
19
+ loader.add(path: Pathname.new(File.expand_path("../../sig", __dir__)))
20
+ signatures.sort.each { |path| loader.add(path: Pathname.new(path)) }
21
+ environment = RBS::Environment.from_loader(loader).resolve_type_names
22
+ @builder = RBS::DefinitionBuilder.new(env: environment)
23
+ end
24
+
25
+ # @rbs (actors: Array[Class]) -> String
26
+ def generate(actors:)
27
+ actors.uniq.sort_by { |actor| actor.name.to_s }.map { |actor| actor_signature(actor) }.join("\n")
28
+ end
29
+
30
+ private
31
+
32
+ # @rbs (untyped) -> String
33
+ def actor_signature(actor)
34
+ unless actor < Actor && actor.name
35
+ raise ArgumentError, "actor signatures require named SolidObjects::Actor subclasses"
36
+ end
37
+
38
+ name = RBS::TypeName.parse("::#{actor.name}")
39
+ definition = @builder.build_instance(name)
40
+ if definition.type_params.any?
41
+ raise ArgumentError, "generic actor classes require application-owned dispatcher signatures"
42
+ end
43
+ messages = actor.definition.messages.keys.sort
44
+ methods = messages.map do |operation|
45
+ method = definition.methods[operation]
46
+ unless method && method.accessibility == :public
47
+ raise ArgumentError, "declare a public RBS signature for #{actor.name}##{operation}"
48
+ end
49
+ types = method.method_types.map { |type| staged_type(type, actor.name, operation).to_s }
50
+ " def #{operation}: #{types.join("\n | ")}"
51
+ end
52
+ callback_names = messages.flat_map { |operation| [ operation.inspect, operation.to_s.inspect ] }
53
+ callbacks = (callback_names + [ "nil" ]).join(" | ")
54
+ <<~RBS
55
+ class #{name}
56
+ interface _SolidObjectsOperations
57
+ #{methods.join("\n")}
58
+ def public_send: (Symbol | String, **untyped) -> nil
59
+ end
60
+
61
+ def schedule: (at: Time, ?every: Numeric?, ?missed: Symbol | String, ?key: (String | Symbol | Integer)?) -> #{name}::_SolidObjectsOperations
62
+ def transmit: () -> #{name}::_SolidObjectsOperations
63
+ def emit: (Symbol | String, ?on_success: (#{callbacks}), ?on_failure: (#{callbacks}), **untyped) -> nil
64
+ end
65
+ RBS
66
+ end
67
+
68
+ # @rbs (untyped, String, Symbol) -> untyped
69
+ def staged_type(method_type, actor_name, operation)
70
+ function = method_type.type
71
+ if !function.is_a?(RBS::Types::Function) || method_type.block ||
72
+ function.required_positionals.any? || function.optional_positionals.any? ||
73
+ function.rest_positionals || function.trailing_positionals.any?
74
+ raise ArgumentError, "#{actor_name}##{operation} must declare keyword-only arguments without a block"
75
+ end
76
+
77
+ method_type.update(type: function.update(return_type: RBS::Types::Bases::Nil.new(location: nil)))
78
+ end
79
+ end
80
+ end
@@ -123,17 +123,21 @@ module SolidObjects
123
123
 
124
124
  # @rbs () { () -> untyped } -> untyped
125
125
  def transaction(&block)
126
+ active_transaction = nil
126
127
  raise DatabaseDeadlineExceeded, "synchronous invocation deadline expired" if SyncDeadline.expired?
127
128
 
128
129
  with_connection do |connection|
129
130
  with_transaction_deadline(connection) do
130
131
  connection.transaction(requires_new: true) do
132
+ active_transaction = connection.current_transaction
131
133
  configure_transaction_deadline(connection)
132
134
  with_transaction_clock { block.call }
133
135
  end
134
136
  end
135
137
  end
136
138
  rescue => error
139
+ raise CommittedTransactionError.new(error) if active_transaction&.state&.fully_committed?
140
+
137
141
  raise unless deadline_error?(error)
138
142
 
139
143
  raise DatabaseDeadlineExceeded,
@@ -33,6 +33,8 @@ module SolidObjects
33
33
  attempts = 0
34
34
  begin
35
35
  yield
36
+ rescue CommittedTransactionError
37
+ raise
36
38
  rescue => error
37
39
  raise unless busy_error?(error)
38
40
 
@@ -53,6 +55,8 @@ module SolidObjects
53
55
  with_connection do |connection|
54
56
  with_transaction_deadline(connection) { yield }
55
57
  end
58
+ rescue CommittedTransactionError
59
+ raise
56
60
  rescue DatabaseDeadlineExceeded
57
61
  raise if SyncDeadline.expired?
58
62
 
@@ -184,11 +184,11 @@ module SolidObjects
184
184
  effect: locked_effect,
185
185
  operation: locked_effect.success_operation,
186
186
  outcome: "success",
187
- arguments: {
188
- "effect_id" => locked_effect.effect_id,
189
- "arguments" => locked_effect.arguments,
190
- "result" => serialized_result
191
- }
187
+ arguments: EffectPayload.success(
188
+ effect_id: locked_effect.effect_id,
189
+ arguments: locked_effect.arguments,
190
+ result: serialized_result
191
+ )
192
192
  )
193
193
  locked_effect.update!(
194
194
  status: "completed",
@@ -216,21 +216,17 @@ module SolidObjects
216
216
  locked_effect = Effect.lock.find(effect.id)
217
217
  verify_claim!(locked_effect)
218
218
  dead = locked_effect.attempt_count >= locked_effect.max_attempts
219
- error_details = {
220
- "class" => error.class.name,
221
- "message" => error.message.to_s.byteslice(0, 8_192),
222
- "backtrace" => Array(error.backtrace).first(50)
223
- }
219
+ error_details = EffectPayload.error(error)
224
220
  if dead
225
221
  result_message = enqueue_result_message(
226
222
  effect: locked_effect,
227
223
  operation: locked_effect.failure_operation,
228
224
  outcome: "failure",
229
- arguments: {
230
- "effect_id" => locked_effect.effect_id,
231
- "arguments" => locked_effect.arguments,
232
- "error" => error_details
233
- }
225
+ arguments: EffectPayload.failure(
226
+ effect_id: locked_effect.effect_id,
227
+ arguments: locked_effect.arguments,
228
+ error: error_details
229
+ )
234
230
  )
235
231
  end
236
232
  locked_effect.update!(
@@ -0,0 +1,27 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ module EffectPayload
5
+ class << self
6
+ # @rbs [Arguments, Result] (effect_id: String, arguments: Arguments, result: Result) -> effect_success_payload[Arguments, Result]
7
+ def success(effect_id:, arguments:, result:)
8
+ { "effect_id" => effect_id, "arguments" => arguments, "result" => result }
9
+ end
10
+
11
+ # @rbs [Arguments] (effect_id: String, arguments: Arguments, error: effect_error) -> effect_failure_payload[Arguments]
12
+ def failure(effect_id:, arguments:, error:)
13
+ { "effect_id" => effect_id, "arguments" => arguments, "error" => error }
14
+ end
15
+
16
+ # @rbs (Exception) -> effect_error
17
+ def error(exception)
18
+ message = exception.message.to_s.byteslice(0, 8_192) # : String
19
+ {
20
+ "class" => exception.class.name,
21
+ "message" => message,
22
+ "backtrace" => Array(exception.backtrace).first(50)
23
+ }
24
+ end
25
+ end
26
+ end
27
+ end
@@ -67,6 +67,23 @@ module SolidObjects
67
67
  class DatabaseDeadlineExceeded < Error
68
68
  end
69
69
 
70
+ class CommittedTransactionError < Error
71
+ # @rbs @original_error: StandardError
72
+
73
+ attr_reader :original_error
74
+
75
+ # @rbs (StandardError) -> void
76
+ def initialize(original_error)
77
+ @original_error = original_error
78
+ super(original_error.message)
79
+ end
80
+
81
+ # @rbs () -> bot
82
+ def reraise
83
+ raise original_error, cause: original_error.cause
84
+ end
85
+ end
86
+
70
87
  class SyncEnqueueTimeout < Error
71
88
  # @rbs @timeout: Numeric
72
89
  # @rbs @actor_type: String
@@ -4,6 +4,7 @@ module SolidObjects
4
4
  class Executor
5
5
  # @rbs @activation: Activation
6
6
  # @rbs @message: Message
7
+ # @rbs @completion_transaction: untyped
7
8
 
8
9
  # @rbs (activation: Activation, message: Message) -> void
9
10
  def initialize(activation:, message:)
@@ -35,7 +36,7 @@ module SolidObjects
35
36
  state_changed: state_after.value != state_before
36
37
  )
37
38
  true
38
- rescue LostActivation
39
+ rescue CommittedTransactionError, LostActivation
39
40
  raise
40
41
  rescue Rejected => rejection
41
42
  activation.restore_state(state_before) if state_before
@@ -96,6 +97,7 @@ module SolidObjects
96
97
  moved_reminders = []
97
98
 
98
99
  activation.lease.fenced_transaction do |instance|
100
+ @completion_transaction = Record.connection.current_transaction
99
101
  # A busy database makes the adapter retry this whole block, so an
100
102
  # attempt that was rolled back must not leave its work in the lists the
101
103
  # reporting below reads. Each attempt starts from empty.
@@ -154,6 +156,12 @@ module SolidObjects
154
156
  report_large_state(state_after.byte_size)
155
157
  SolidObjects.instrument_after_commit(:"message.completed", **instrumentation_payload)
156
158
  SolidObjects.wake_up.signal
159
+ rescue CommittedTransactionError
160
+ raise
161
+ rescue => error
162
+ raise unless @completion_transaction&.state&.fully_committed?
163
+
164
+ raise CommittedTransactionError.new(error)
157
165
  end
158
166
 
159
167
  # @rbs (Integer) -> void
@@ -20,6 +20,8 @@ module SolidObjects
20
20
  SyncDeadline.with(timeout:) do
21
21
  call_before_deadline(message_reference, timeout:)
22
22
  end
23
+ rescue CommittedTransactionError => error
24
+ error.reraise
23
25
  rescue ActiveRecord::RecordNotFound
24
26
  raise ActorDestroyed, "actor was destroyed while waiting for its result"
25
27
  end
@@ -1,5 +1,5 @@
1
1
  # rbs_inline: enabled
2
2
 
3
3
  module SolidObjects
4
- VERSION = "0.14.5"
4
+ VERSION = "0.14.7"
5
5
  end
@@ -59,6 +59,8 @@ module SolidObjects
59
59
  ).around { activation.drain }
60
60
  release_activation(activation) if activation.pass_exhausted?
61
61
  processed
62
+ rescue CommittedTransactionError => error
63
+ error.reraise
62
64
  rescue ActorDestroyed
63
65
  release_activation(activation) if activation
64
66
  0
data/lib/solid_objects.rb CHANGED
@@ -55,6 +55,7 @@ require "solid_objects/wake_up_adapters/redis"
55
55
  require "solid_objects/wake_up_adapters"
56
56
  require "solid_objects/polling_backoff"
57
57
  require "solid_objects/effect_registry"
58
+ require "solid_objects/effect_payload"
58
59
  require "solid_objects/commit_action_registry"
59
60
  require "solid_objects/lease"
60
61
  require "solid_objects/lease_renewer"
@@ -0,0 +1,24 @@
1
+ # Generated from lib/solid_objects/actor_signatures.rb with RBS::Inline
2
+
3
+ module SolidObjects
4
+ class ActorSignatures
5
+ # @rbs (actors: Array[Class], signatures: Array[String]) -> String
6
+ def self.generate: (actors: Array[Class], signatures: Array[String]) -> String
7
+
8
+ @builder: untyped
9
+
10
+ # @rbs (signatures: Array[String]) -> void
11
+ def initialize: (signatures: Array[String]) -> void
12
+
13
+ # @rbs (actors: Array[Class]) -> String
14
+ def generate: (actors: Array[Class]) -> String
15
+
16
+ private
17
+
18
+ # @rbs (untyped) -> String
19
+ def actor_signature: (untyped) -> String
20
+
21
+ # @rbs (untyped, String, Symbol) -> untyped
22
+ def staged_type: (untyped, String, Symbol) -> untyped
23
+ end
24
+ end
@@ -0,0 +1,14 @@
1
+ # Generated from lib/solid_objects/effect_payload.rb with RBS::Inline
2
+
3
+ module SolidObjects
4
+ module EffectPayload
5
+ # @rbs [Arguments, Result] (effect_id: String, arguments: Arguments, result: Result) -> effect_success_payload[Arguments, Result]
6
+ def self.success: [Arguments, Result] (effect_id: String, arguments: Arguments, result: Result) -> effect_success_payload[Arguments, Result]
7
+
8
+ # @rbs [Arguments] (effect_id: String, arguments: Arguments, error: effect_error) -> effect_failure_payload[Arguments]
9
+ def self.failure: [Arguments] (effect_id: String, arguments: Arguments, error: effect_error) -> effect_failure_payload[Arguments]
10
+
11
+ # @rbs (Exception) -> effect_error
12
+ def self.error: (Exception) -> effect_error
13
+ end
14
+ end
@@ -67,6 +67,18 @@ module SolidObjects
67
67
  class DatabaseDeadlineExceeded < Error
68
68
  end
69
69
 
70
+ class CommittedTransactionError < Error
71
+ @original_error: StandardError
72
+
73
+ attr_reader original_error: untyped
74
+
75
+ # @rbs (StandardError) -> void
76
+ def initialize: (StandardError) -> void
77
+
78
+ # @rbs () -> bot
79
+ def reraise: () -> bot
80
+ end
81
+
70
82
  class SyncEnqueueTimeout < Error
71
83
  @timeout: Numeric
72
84
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  module SolidObjects
4
4
  class Executor
5
+ @completion_transaction: untyped
6
+
5
7
  @message: Message
6
8
 
7
9
  @activation: Activation
@@ -0,0 +1,19 @@
1
+ module SolidObjects
2
+ type effect_error = {
3
+ "class" => String?,
4
+ "message" => String,
5
+ "backtrace" => Array[String]
6
+ }
7
+
8
+ type effect_failure_payload[Arguments] = {
9
+ "effect_id" => String,
10
+ "arguments" => Arguments,
11
+ "error" => effect_error
12
+ }
13
+
14
+ type effect_success_payload[Arguments, Result] = {
15
+ "effect_id" => String,
16
+ "arguments" => Arguments,
17
+ "result" => Result
18
+ }
19
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_objects
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.14.5
4
+ version: 0.14.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lucas Carlson
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-03 00:00:00.000000000 Z
11
+ date: 2026-09-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actioncable
@@ -389,6 +389,7 @@ files:
389
389
  - lib/solid_objects/actor_channel.rb
390
390
  - lib/solid_objects/actor_definition.rb
391
391
  - lib/solid_objects/actor_registry.rb
392
+ - lib/solid_objects/actor_signatures.rb
392
393
  - lib/solid_objects/actor_snapshot.rb
393
394
  - lib/solid_objects/actor_view.rb
394
395
  - lib/solid_objects/administration.rb
@@ -416,6 +417,7 @@ files:
416
417
  - lib/solid_objects/doctor.rb
417
418
  - lib/solid_objects/dom_identity.rb
418
419
  - lib/solid_objects/effect_executor.rb
420
+ - lib/solid_objects/effect_payload.rb
419
421
  - lib/solid_objects/effect_registry.rb
420
422
  - lib/solid_objects/engine.rb
421
423
  - lib/solid_objects/errors.rb
@@ -478,6 +480,7 @@ files:
478
480
  - sig/generated/lib/solid_objects/actor_channel.rbs
479
481
  - sig/generated/lib/solid_objects/actor_definition.rbs
480
482
  - sig/generated/lib/solid_objects/actor_registry.rbs
483
+ - sig/generated/lib/solid_objects/actor_signatures.rbs
481
484
  - sig/generated/lib/solid_objects/actor_snapshot.rbs
482
485
  - sig/generated/lib/solid_objects/actor_view.rbs
483
486
  - sig/generated/lib/solid_objects/administration.rbs
@@ -505,6 +508,7 @@ files:
505
508
  - sig/generated/lib/solid_objects/doctor.rbs
506
509
  - sig/generated/lib/solid_objects/dom_identity.rbs
507
510
  - sig/generated/lib/solid_objects/effect_executor.rbs
511
+ - sig/generated/lib/solid_objects/effect_payload.rbs
508
512
  - sig/generated/lib/solid_objects/effect_registry.rbs
509
513
  - sig/generated/lib/solid_objects/engine.rbs
510
514
  - sig/generated/lib/solid_objects/errors.rbs
@@ -561,6 +565,7 @@ files:
561
565
  - sig/generated/models/solid_objects/ready_message.rbs
562
566
  - sig/generated/models/solid_objects/record.rbs
563
567
  - sig/generated/models/solid_objects/reminder.rbs
568
+ - sig/public/effect_payload.rbs
564
569
  - sig/support/framework.rbs
565
570
  - web/assets/javascripts/application.js
566
571
  - web/assets/javascripts/charts.js