solid_objects 0.17.4 → 1.0.0

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: 3d26650acca584b91a9933ee43ce1aeb310200d240994040a3ae8c8ea81dea1b
4
- data.tar.gz: e97ad7100bd3822b005c571c2479ab833b65082ad0a249fd88c619a83da62980
3
+ metadata.gz: 11624a306bdeca71c5da082c39329f579c8d5d9b88945db2e49b534d2c317bc2
4
+ data.tar.gz: 7ac2b6c343b243169f92273179591db817a0e51c24e0312eaa59f8ee46c1e17f
5
5
  SHA512:
6
- metadata.gz: c89b65cc0a103afb36bdc8570e55ab53adcd21fa59118d34291c2e174e9c0fc603cdf4c8c534a13acf21bcae54c6e3f1f54510635e89ff53d88b63f3ff09e7a2
7
- data.tar.gz: '08a065d488f819ed054e8b57fd7bef8666be7c8c4dea41bd340cf07f8661c4570dd69d0a37a96c9f80c9a3fabcec6a6f691ab8d061c4e9293b1891da9da07cc4'
6
+ metadata.gz: dbf6ea115998afb97d759724469a29d8dfffa832ca52a6713027cbc5822d56ce2e40b55213deadefc598636beddeaebb49777e3e59d61a20b344a6b8bba1c670
7
+ data.tar.gz: 29e113dbdb487e5eaf3d9061dc494c6e6aaa54e2bf5fc2eba1bcd0e4a9db7684a1a7bffb65efc69cd0436d07c2b5e9523ab9075d8652f0c005affe545fd3d52e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,50 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.0 - 2026-10-13
4
+
5
+ - Version 1.0 freezes the public API under semantic versioning.
6
+ [docs/versioning.md](docs/versioning.md) gives the promise and its limits.
7
+ Version 1.0 is not a claim of measured scale: the benchmarks are
8
+ development measurements, and longer soak tests are open work.
9
+ - **Breaking:** a turn that calls `schedule` and then calls no operation on the
10
+ result now fails with `SolidObjects::ScheduleWithoutOperation`. The runtime
11
+ does not retry that turn, and the message becomes a dead letter. Before this
12
+ release, such a call stored no reminder and gave no error. Correct code,
13
+ such as `schedule(at: time).expire`, does not change. The turn can keep the
14
+ result and call the operation later. A `schedule` call with no operation in
15
+ an observable fails the turn with `SolidObjects::QueryMutatedState`.
16
+ JavaScript has the same behavior.
17
+ - `bin/rails solid_objects:doctor` has a new `json` check, and the boot prints
18
+ a warning, when Active Support cannot decode JSON with the installed `json`
19
+ gem. This is the case for `json` 3.x on Rails older than 8.1.4. The check
20
+ decodes one document and does not compare version numbers.
21
+ - The gem summary and description now start with the problems that the gem
22
+ solves. The README has a status statement and names the projects that
23
+ Solid Objects is not.
24
+
25
+ ## 0.17.5 - 2026-10-12
26
+
27
+ - Back off repeated interrupted-message recovery using `retry_delay`.
28
+ The first claimed attempt recovers immediately; after attempts 2, 3, 4,
29
+ and onward, the delay callback receives 1, 2, 3, and onward (defaults:
30
+ 1, 2, 4 seconds, capped at 60 seconds). Total persisted claim attempts,
31
+ including earlier handler failures, determine the delay. The ready timestamp
32
+ survives worker replacement and preserves mailbox order while other actors
33
+ can proceed. Lease loss alone still does not dead-letter a message; handler
34
+ failure attempt limits, setting names, defaults, and schema stay unchanged.
35
+ - Emit `activation.lost` with the message ID and total attempt count when a
36
+ turn loses its fence, including while saving a handler failure or rejection,
37
+ matching JavaScript instrumentation. Shutdown requested
38
+ by that callback stops the worker before another pass. Skip a cached actor
39
+ whose first message is delayed so later ready messages cannot starve peers.
40
+
41
+ - Preserve caller lifecycle guarantees with regression tests for concurrent
42
+ synchronous calls retaining their staged effects and for caller process
43
+ re-registration after stale-process cleanup and retention. Both cases
44
+ already work in Ruby; no runtime behavior or configuration changes.
45
+ The concurrency test waits for the second caller's busy-actor wait before
46
+ allowing the first caller to commit its staged effect.
47
+
3
48
  ## 0.17.4 - 2026-10-12
4
49
 
5
50
  - Renew activations between turns in a drain pass when renewal is due,
data/README.md CHANGED
@@ -11,6 +11,18 @@ stable identity, and its state lives in the SQL database that your app already
11
11
  uses. [Virtual actors in Ruby on Rails](docs/virtual-actors.md) explains the
12
12
  model and when to use it.
13
13
 
14
+ **Status.** Version 1.0: the public API follows semantic versioning and does
15
+ not break before 2.0. [Versioning](docs/versioning.md) gives the limits of the
16
+ promise. First public release: August 6, 2026. One maintainer.
17
+
18
+ **Production use.** One first-party application with more than 100,000 users
19
+ runs it in production. That is a count of users, not a measure of load. No
20
+ production use by other people is known. The [benchmarks](docs/benchmarks.md)
21
+ are development measurements, and longer soak tests are open work.
22
+
23
+ **It is not** Solid Queue, SolidJS, or the `service_actor` gem, and it is not
24
+ affiliated with Rails or Cloudflare.
25
+
14
26
  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.
15
27
 
16
28
  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.
193
205
 
194
206
  ## Status and license
195
207
 
196
- Solid Objects Ruby is a pre-1.0 early release. Its correctness core is tested
197
- against SQLite, PostgreSQL, and MySQL, but the project makes no production-ready
198
- claim. That requires more hardening and operational soak evidence. Expect breaking changes.
208
+ Version 1.0 promises a stable public API. Its correctness core is tested
209
+ against SQLite, PostgreSQL, and MySQL. It makes no claim of measured scale:
210
+ the [benchmarks](docs/benchmarks.md) are development measurements, and longer
211
+ soak tests are open work. [Versioning](docs/versioning.md) says what a release
212
+ can change.
199
213
 
200
214
  Solid Objects is released under the [MIT License](MIT-LICENSE). It is an
201
215
  independent project and is not affiliated with, sponsored by, or endorsed by
data/docs/agents.md CHANGED
@@ -69,18 +69,22 @@ bin/rails solid_objects:doctor
69
69
  ```
70
70
 
71
71
  The generator adds an initializer and copies migrations into the application.
72
- The doctor checks the configuration, the tables, and one real actor round trip.
72
+ The doctor checks the `json` gem, the configuration, the tables, and one real
73
+ actor round trip.
73
74
 
74
- Install the current release. `bundle add solid_objects` selects it. Do not pin
75
- a version that you remember from earlier work; the API changed between
76
- releases. The current version is on <https://rubygems.org/gems/solid_objects>.
75
+ Install the current release. `bundle add solid_objects` selects it. Do not use
76
+ an API that you remember from a 0.x release; the API changed between 0.x
77
+ releases. From version 1.0, the public API follows
78
+ [semantic versioning](versioning.md). The current version is on
79
+ <https://rubygems.org/gems/solid_objects>.
77
80
 
78
81
  The generated policies deny every call. Do step 5 before you call an actor.
79
82
 
80
83
  The `json` gem 3.x works only with Active Support 8.1.4 or newer. On Rails
81
84
  7.1, 7.2, 8.0, or 8.1 before 8.1.4, pin `gem "json", "~> 2"` in the
82
85
  `Gemfile`. Without the pin, Active Support raises an `ArgumentError`, such as
83
- `unknown keyword: quirks_mode`, for every JSON column.
86
+ `unknown keyword: quirks_mode`, for every JSON column. The doctor reports this
87
+ problem as a failure of its `json` check, and the boot prints a warning.
84
88
 
85
89
  [Installing and upgrading](operations.md#installing-and-upgrading) has the
86
90
  details.
@@ -173,6 +177,9 @@ Obey these rules in actor code:
173
177
  write in the same database.
174
178
  - Do not call an external API in a handler. Use `emit` and an effect handler.
175
179
  - Write each handler so that it can run again. Delivery is at least once.
180
+ - Repeated lease losses back off but do not become dead letters. Observe
181
+ `activation.lost` and set an application stopping policy if a hard bound is
182
+ required; increasing the handler-failure attempt limit does not bound lease loss.
176
183
  - A reminder changes state only when it runs, and it runs only while
177
184
  `solid_objects start` runs. Do not compute expiry from the clock in a query;
178
185
  read the state that the reminder committed.
@@ -181,7 +188,7 @@ Avoid these mistakes:
181
188
 
182
189
  | Mistake | Correct form |
183
190
  | --- | --- |
184
- | `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 |
191
+ | `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` |
185
192
  | `reject "room full"` | `reject(:room_full, "The room is full")` |
186
193
  | `id` inside an actor | `actor_id`. An actor has no `id` method |
187
194
  | `register_effect(:name) { \|context, arguments\| ... }` | `register_effect(:name) { \|arguments, context\| ... }`. The arguments come first |
@@ -275,6 +282,7 @@ When you explain Solid Objects to a user, state these limits:
275
282
  - There are no transactions across actor identities.
276
283
  - Fencing stops a stale activation from a commit, but its code can continue to
277
284
  run.
278
- - The gem is pre-1.0 and makes no production-ready claim.
285
+ - Version 1.0 promises a stable public API. It makes no claim of measured scale,
286
+ and no production use by other people is known.
279
287
 
280
288
  The [correctness contract](correctness.md) is the source for each guarantee.
@@ -164,7 +164,7 @@ See [the transactional outbox guide](transactional-outbox.md).
164
164
  - Delivery is at least once. Each operation must be safe to run again.
165
165
  - There are no transactions across actors. A reservation that spans two shows needs its own design.
166
166
  - One busy show runs its calls one at a time. Many shows run at the same time.
167
- - The gem is pre-1.0 and makes no production-ready claim.
167
+ - Version 1.0 promises a stable public API. It makes no claim of measured scale.
168
168
 
169
169
  ## More information
170
170
 
@@ -221,7 +221,7 @@ You do not assign sequence numbers. The actor mailbox gives the order.
221
221
  - Delivery is at least once. Each operation must be safe to run again.
222
222
  - The gem provides no transactions across actors. A transfer between two accounts needs its own design, for example one SQL transaction on normal tables.
223
223
  - One busy account runs its entries one at a time.
224
- - The gem is pre-1.0 and makes no production-ready claim.
224
+ - Version 1.0 promises a stable public API. It makes no claim of measured scale.
225
225
 
226
226
  ## More information
227
227
 
@@ -224,7 +224,7 @@ Delivery is at least once, so an effect can run more than once. See [effect idem
224
224
  - Delivery is at least once, not exactly once. Write each operation so that it can run again.
225
225
  - There are no transactions across actors. A change that must touch two events atomically needs one SQL transaction on normal tables.
226
226
  - One busy event runs its calls one at a time. Many events can run at the same time.
227
- - The gem is pre-1.0 and makes no production-ready claim.
227
+ - Version 1.0 promises a stable public API. It makes no claim of measured scale.
228
228
 
229
229
  ## More information
230
230
 
@@ -145,7 +145,7 @@ One successful turn commits the actor state, the staged effects, the same-databa
145
145
  - Solid Objects requires Ruby 3.3 or newer and Rails 7.1 or newer.
146
146
  - Delivery is at least once. Each effect handler must deduplicate with `context.id`.
147
147
  - Solid Objects provides no transactions across actors.
148
- - The gem is pre-1.0 and makes no production-ready claim.
148
+ - Version 1.0 promises a stable public API. It makes no claim of measured scale.
149
149
 
150
150
  ## More information
151
151
 
@@ -45,6 +45,7 @@ JavaScript does not await exporters. Telemetry is not a durable audit trail.
45
45
  | ------------------------------------------- | ---------------------------------------------------------------------------- |
46
46
  | `activation.started/completed/failed` | Local actor activation hook lifecycle |
47
47
  | `message.started/completed/failed/rejected` | One attempt's execution outcome |
48
+ | `activation.lost` | A message turn lost its activation fence; includes message ID and total attempt count |
48
49
  | `message.retry` | Failed attempt durably queued for another attempt |
49
50
  | `dead_letter.created` | Message exhausted retries or failed permanently |
50
51
  | `commit_action.started/completed/failed` | One registered commit action inside the commit transaction |
data/docs/operations.md CHANGED
@@ -43,6 +43,11 @@ an older Rails release, pin the 2.x series of `json`:
43
43
  gem "json", "~> 2"
44
44
  ```
45
45
 
46
+ `bin/rails solid_objects:doctor` reports this problem as a failure of its
47
+ `json` check, and the boot prints a warning that starts with
48
+ `[solid_objects]`. Both decode one JSON document with Active Support. They do
49
+ not compare version numbers.
50
+
46
51
  Review [CHANGELOG.md](../CHANGELOG.md) for compatibility and deployment-order
47
52
  notes, then update the gem:
48
53
 
@@ -606,3 +611,24 @@ heartbeat cleanup.
606
611
  Back up actor tables with the same consistency guarantees as application data.
607
612
  Restoring only instances without their mailboxes/outboxes, or vice versa, can
608
613
  violate application expectations.
614
+
615
+ ## Interrupted-message recovery
616
+
617
+ An interrupted first claimed attempt recovers immediately. Recovery after
618
+ persisted attempts 2, 3, 4, ... calls `retry_delay` with 1, 2, 3, ... . The
619
+ callback returns seconds; defaults grow from 1 to 2 to 4 seconds up to 60 seconds.
620
+ This uses total claim attempts, including earlier handler failures, not a
621
+ separate lease-loss counter. The delay is stored in ready-message availability
622
+ when a stale claim is recovered, survives worker replacement, and keeps later
623
+ messages behind that message. Other actors can still run. Custom delays,
624
+ including zero, remain authoritative.
625
+
626
+ Lease loss alone does not create a dead letter, even with `max_attempts: 1`.
627
+ Handler failures still use the full attempt count for `max_attempts` and
628
+ `retry_delay`. An always-losing message remains pending indefinitely. Observe
629
+ `activation.lost` (message ID and total attempt count) and impose an application
630
+ stopping policy if a hard bound is required. Calling `request_shutdown` from
631
+ instrumentation stops the worker before its next pass. Ruby uses a separate
632
+ renewal thread and waits after lost turns; it does not have the JavaScript
633
+ microtask starvation pattern. A handler must still return before its worker
634
+ can finish shutting down.
data/docs/reminders.md CHANGED
@@ -16,6 +16,26 @@ end
16
16
  Use `missed: :latest` to coalesce missed occurrences or `missed: :all` to
17
17
  enqueue each one.
18
18
 
19
+ ## A schedule call needs an operation
20
+
21
+ `schedule` returns an object, and the reminder exists only after you call an
22
+ operation on that object. A turn that calls `schedule` and then calls no
23
+ operation on the result fails with `SolidObjects::ScheduleWithoutOperation`.
24
+ The runtime does not retry that turn, and the message becomes a dead letter.
25
+
26
+ ```ruby
27
+ # Wrong. The turn fails.
28
+ schedule(at: 10.minutes.from_now, key: buyer)
29
+
30
+ # Correct.
31
+ schedule(at: 10.minutes.from_now, key: buyer).expire(buyer:)
32
+ ```
33
+
34
+ You can keep the result and call the operation later in the same turn.
35
+
36
+ An observable cannot call `schedule`, with or without an operation. The turn
37
+ that reads the observable fails with `SolidObjects::QueryMutatedState`.
38
+
19
39
  ## A reminder is one named alarm per actor
20
40
 
21
41
  The uniqueness key is `(actor, reminder name)`. Scheduling a name that is
data/docs/roadmap.md CHANGED
@@ -9,6 +9,17 @@
9
9
  [observability](observability.md).
10
10
 
11
11
  - Rails engine, install generator, migration, and CLI
12
+ - A stable public API from version 1.0, with the limits in
13
+ [versioning](versioning.md)
14
+ - A turn that calls `schedule` and then calls no operation on the result fails
15
+ with `ScheduleWithoutOperation`, which is not retried, matching JavaScript.
16
+ Before 1.0 such a call stored no reminder and gave no error. The projection
17
+ guard counts such a call in an observable as staged work, so that turn fails
18
+ with `QueryMutatedState`.
19
+ - A `json` check in `solid_objects:doctor` and a warning during the boot, for
20
+ the `json` gem 3.x on an Active Support release that cannot decode with it.
21
+ The check decodes one document and does not compare version numbers. This
22
+ surface is Rails-only.
12
23
  - Explicit actor registry, references, JSON state, and state migrations
13
24
  - Queries, observable projections, and personalized payloads reject state mutation
14
25
  and staged durable work with `QueryMutatedState`, matching JavaScript. Query
@@ -25,7 +36,12 @@
25
36
  repeatable read. The tests count statements, and do not measure latency.
26
37
  - Activation leases, renewal between turns as well as in the renewal thread,
27
38
  unique activation tokens, generations, and
28
- fenced commits
39
+ fenced commits. Interrupted first attempts recover immediately; repeated
40
+ interruptions use durable retry delays from total claim attempts minus one,
41
+ preserving mailbox order and progress for other actors. Lease loss emits
42
+ `activation.lost` with the message ID and attempt, including losses while
43
+ saving a handler failure or rejection. There is no hard retry cap;
44
+ applications needing one must impose a stopping policy.
29
45
  - Bounded activation passes, idle cache, hot-actor yield, and process records
30
46
  - At-least-once retries, terminal domain rejection, strict poison ordering,
31
47
  dead letters, and tail retry
@@ -240,5 +256,6 @@
240
256
  latency, query counts, and the three reactive delivery paths are measured on
241
257
  SQLite; adapter-specific and end-to-end browser measurements are not.
242
258
 
243
- No production-ready claim should be made until these hardening milestones have
244
- operational soak evidence.
259
+ Version 1.0 freezes the public API under [semantic versioning](versioning.md).
260
+ It does not complete these hardening milestones. The project makes no claim of
261
+ measured scale until they have operational soak evidence.
@@ -0,0 +1,50 @@
1
+ # Versioning
2
+
3
+ From version 1.0.0, Solid Objects follows
4
+ [Semantic Versioning](https://semver.org/).
5
+
6
+ ## The promise
7
+
8
+ The public API is the set of classes, methods, generators, commands, and
9
+ configuration options that `README.md` and the files in `docs/` describe.
10
+
11
+ | Release | What it can change |
12
+ | --- | --- |
13
+ | Patch, for example 1.0.1 | Corrections of defects. No new behavior |
14
+ | Minor, for example 1.1.0 | New behavior. Code that uses the public API continues to work |
15
+ | Major, for example 2.0.0 | A change that breaks code that uses the public API |
16
+
17
+ ## Supported versions
18
+
19
+ A minor release can raise the minimum version of Ruby, Rails, SQLite,
20
+ PostgreSQL, or MySQL. It does this only for a version that its own project no
21
+ longer supports, and `CHANGELOG.md` names the change. A patch release does not
22
+ raise a minimum version.
23
+ [Compatibility and maturity](virtual-actors.md#compatibility-and-maturity)
24
+ lists the current minimum versions.
25
+
26
+ ## Outside the promise
27
+
28
+ - Classes, methods, and constants that the documentation does not describe.
29
+ - The database schema. A minor release can add tables, columns, and indexes,
30
+ and it supplies a migration for each change.
31
+ [Installing and upgrading](operations.md#installing-and-upgrading) gives the
32
+ steps.
33
+ - The text of error messages and log lines. The error classes are part of the
34
+ public API.
35
+ - The timing of background work, where no configuration option sets it.
36
+ - A correction of behavior that is different from the
37
+ [correctness contract](correctness.md). A patch release can make such a
38
+ correction.
39
+
40
+ ## What version 1.0 does not claim
41
+
42
+ Version 1.0 is a promise about the API. It is not a claim of measured scale.
43
+ The [benchmarks](benchmarks.md) are development measurements and do not
44
+ guarantee capacity. Longer soak tests are open work. The project has one
45
+ maintainer, and no production use by other people is known.
46
+
47
+ ## Releases before 1.0
48
+
49
+ The 0.x releases changed the API between minor versions. `CHANGELOG.md` lists
50
+ each change.
@@ -12,7 +12,8 @@ State and mailboxes live in the SQLite, PostgreSQL, or MySQL database that the
12
12
  Rails application already uses. Redis and a separate actor service are not
13
13
  necessary.
14
14
 
15
- Solid Objects is a pre-1.0 release. It makes no production-ready claim. Read
15
+ Solid Objects 1.0 promises a stable public API. It makes no claim of measured
16
+ scale. Read
16
17
  [Compatibility and maturity](#compatibility-and-maturity) before you choose it.
17
18
 
18
19
  ## What a virtual actor is
@@ -170,6 +171,10 @@ again without harm.
170
171
  a crash or a lost lease.
171
172
  - Ordered turns do not cancel stale Ruby code. Fencing stops a stale activation
172
173
  from a commit, but that code can continue to run.
174
+ - An interrupted first attempt recovers immediately. Repeated interruptions
175
+ use growing, durable retry delays while keeping mailbox order. Lease loss
176
+ alone has no attempt limit; use instrumentation and an application stopping
177
+ policy when a hard bound is required.
173
178
  - External effects can run more than once. Use the stable effect ID, or
174
179
  another durable key, as the idempotency key at the provider.
175
180
  - There are no transactions across actor identities, and there is no replay of
@@ -194,9 +199,9 @@ matrix.
194
199
  InnoDB. MySQL works through `mysql2` or `trilogy`.
195
200
  - SQLite is correct for the same contract, but it is best for development and
196
201
  modest single-host workloads.
197
- - Pre-1.0. Expect changes that break compatibility. The correctness core has tests against all
198
- three databases. The project has no production-ready claim and no measured
199
- scale claim.
202
+ - Version 1.0: the public API follows [semantic versioning](versioning.md). The
203
+ correctness core has tests against all three databases. The project makes no
204
+ claim of measured scale, and no production use by other people is known.
200
205
 
201
206
  The [roadmap](roadmap.md) records what is tested, what is partial, and what is
202
207
  next. For TypeScript and Node.js, use the
@@ -52,7 +52,8 @@ module SolidObjects
52
52
  # @rbs () -> bool
53
53
  def ready?
54
54
  now = SolidObjects.database_adapter.database_now
55
- ReadyMessage.where(instance_id: lease.instance_id, available_at: ..now).exists? ||
55
+ ready_message = ReadyMessage.where(instance_id: lease.instance_id).order(:sequence).first
56
+ (ready_message && ready_message.available_at <= now) ||
56
57
  ClaimedMessage.where(
57
58
  instance_id: lease.instance_id,
58
59
  process_id: lease.owner_id,
@@ -227,11 +228,12 @@ module SolidObjects
227
228
  claimed_message.destroy!
228
229
  return if message.completed? || message.dead?
229
230
 
231
+ delay = (message.attempt_count <= 1) ? 0 : SolidObjects.configuration.retry_delay.call(message.attempt_count - 1)
230
232
  ReadyMessage.create!(
231
233
  message:,
232
234
  instance:,
233
235
  sequence: message.sequence,
234
- available_at: SolidObjects.database_adapter.database_now
236
+ available_at: SolidObjects.database_adapter.database_now + delay
235
237
  )
236
238
  end
237
239
 
@@ -13,6 +13,7 @@ module SolidObjects
13
13
  REMINDER_HANDLE_KEY = "reminder_name"
14
14
 
15
15
  ReminderIntent = Data.define(:name, :operation, :at, :arguments, :interval_seconds, :missed_policy)
16
+ ScheduleCall = Data.define(:at, :key)
16
17
  UnscheduleIntent = Data.define(:name)
17
18
  UnscheduleAllIntent = Data.define(:operation)
18
19
  ReminderStatus = Data.define(:name, :operation, :key, :next_run_at, :interval_seconds,
@@ -154,6 +155,7 @@ module SolidObjects
154
155
  # @rbs @effect_recovery_intents: Array[EffectRecoveryIntent]
155
156
  # @rbs @commit_action_intents: Array[CommitActionIntent]
156
157
  # @rbs @reminder_intents: Array[ReminderIntent]
158
+ # @rbs @open_schedule_calls: Array[ScheduleCall]
157
159
  # @rbs @outbound_message_intents: Array[OutboundMessageIntent]
158
160
 
159
161
  attr_reader :actor_id, :state
@@ -167,6 +169,7 @@ module SolidObjects
167
169
  @effect_recovery_intents = []
168
170
  @commit_action_intents = []
169
171
  @reminder_intents = []
172
+ @open_schedule_calls = []
170
173
  @outbound_message_intents = []
171
174
  end
172
175
 
@@ -257,11 +260,14 @@ module SolidObjects
257
260
  raise ArgumentError, "missed reminder policy must be all or latest"
258
261
  end
259
262
  reminder_key = validated_reminder_key(key)
263
+ call = ScheduleCall.new(at:, key: reminder_key)
264
+ open_schedule_calls << call
260
265
 
261
266
  OperationDispatcher.new(
262
267
  actor_type: self.class.actor_type,
263
268
  handlers: self.class.definition.messages
264
269
  ) do |operation, arguments|
270
+ open_schedule_calls.delete_if { |open_call| open_call.equal?(call) }
265
271
  name = reminder_name(operation:, key: reminder_key)
266
272
  reminder_intents << ReminderIntent.new(
267
273
  name:,
@@ -537,6 +543,18 @@ module SolidObjects
537
543
  commit_action_intents.clear
538
544
  reminder_intents.clear
539
545
  outbound_message_intents.clear
546
+ open_schedule_calls.clear
547
+ end
548
+
549
+ # @rbs () -> void
550
+ def ensure_schedule_calls_have_operations!
551
+ call = open_schedule_calls.first
552
+ return unless call
553
+
554
+ arguments = [ "at: #{call.at.inspect}", ("key: #{call.key.inspect}" if call.key) ].compact.join(", ")
555
+ raise ScheduleWithoutOperation,
556
+ "schedule(#{arguments}) has no operation after it, so it stores no reminder. " \
557
+ "Call an operation on the result, for example schedule(at: time).expire"
540
558
  end
541
559
 
542
560
  # @rbs () -> Integer
@@ -563,11 +581,19 @@ module SolidObjects
563
581
  :effect_recovery_intents,
564
582
  :commit_action_intents,
565
583
  :reminder_intents,
566
- :outbound_message_intents
584
+ :outbound_message_intents,
585
+ :open_schedule_calls
567
586
 
568
587
  # @rbs () -> Array[Array[Hash[Symbol, Object]]]
569
588
  def intent_snapshot
570
- [ effect_intents, effect_recovery_intents, commit_action_intents, reminder_intents, outbound_message_intents ].map do |intents|
589
+ [
590
+ effect_intents,
591
+ effect_recovery_intents,
592
+ commit_action_intents,
593
+ reminder_intents,
594
+ outbound_message_intents,
595
+ open_schedule_calls
596
+ ].map do |intents|
571
597
  intents.map { |intent| intent.to_h.deep_dup }
572
598
  end
573
599
  end
@@ -99,14 +99,17 @@ module SolidObjects
99
99
 
100
100
  # @rbs @connection: untyped
101
101
  # @rbs @configuration: Configuration
102
+ # @rbs @json_compatibility: JsonCompatibility
102
103
 
103
- # @rbs (?connection: untyped, ?configuration: Configuration) -> void
104
+ # @rbs (?connection: untyped, ?configuration: Configuration, ?json_compatibility: JsonCompatibility) -> void
104
105
  def initialize(
105
106
  connection: SolidObjects::Record.connection,
106
- configuration: SolidObjects.configuration
107
+ configuration: SolidObjects.configuration,
108
+ json_compatibility: JsonCompatibility.new
107
109
  )
108
110
  @connection = connection
109
111
  @configuration = configuration
112
+ @json_compatibility = json_compatibility
110
113
  end
111
114
 
112
115
  # @rbs () -> Report
@@ -114,6 +117,7 @@ module SolidObjects
114
117
  configuration_check = check_configuration
115
118
  schema_check = check_schema
116
119
  checks = [
120
+ check_json,
117
121
  configuration_check,
118
122
  schema_check,
119
123
  check_authorization,
@@ -129,7 +133,15 @@ module SolidObjects
129
133
 
130
134
  private
131
135
 
132
- attr_reader :connection, :configuration
136
+ attr_reader :connection, :configuration, :json_compatibility
137
+
138
+ # @rbs () -> Check
139
+ def check_json
140
+ problem = json_compatibility.problem
141
+ return fail_check(:json, problem) if problem
142
+
143
+ pass(:json, "Active Support decodes JSON with the json gem #{JSON::VERSION}")
144
+ end
133
145
 
134
146
  # @rbs () -> Check
135
147
  def check_configuration
@@ -15,6 +15,10 @@ module SolidObjects
15
15
  SolidObjects::LogSubscriber.install
16
16
  end
17
17
 
18
+ initializer "solid_objects.json_compatibility" do
19
+ JsonCompatibility.new.warn_about_problem
20
+ end
21
+
18
22
  initializer "solid_objects.actors" do |application|
19
23
  application.config.to_prepare { ApplicationActorLoader.new.call }
20
24
  end
@@ -10,6 +10,9 @@ module SolidObjects
10
10
  class QueryMutatedState < NonRetryableError
11
11
  end
12
12
 
13
+ class ScheduleWithoutOperation < NonRetryableError
14
+ end
15
+
13
16
  class UnsupportedDatabase < Error
14
17
  end
15
18
 
@@ -16,6 +16,18 @@ module SolidObjects
16
16
 
17
17
  # @rbs () -> bool
18
18
  def call
19
+ execute
20
+ rescue LostActivation
21
+ SolidObjects.instrument(:"activation.lost", **instrumentation_payload, duration_milliseconds: elapsed_milliseconds)
22
+ raise
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :activation, :message
28
+
29
+ # @rbs () -> bool
30
+ def execute
19
31
  state_before = actor.state.to_h
20
32
  observables_before = actor.observable_values
21
33
  message_context = MessageContext.new(
@@ -29,6 +41,7 @@ module SolidObjects
29
41
  SolidObjects.instrument(:"recovery.reclaimed", **instrumentation_payload) if message.attempt_count > 1 && message.error.nil?
30
42
  SolidObjects.instrument(:"message.started", **instrumentation_payload)
31
43
  result = invoke_actor(message_context)
44
+ actor.ensure_schedule_calls_have_operations!
32
45
  observable_changes = changed_observables(observables_before, actor.observable_values)
33
46
  state_after = actor.state.to_h_with_byte_size
34
47
  ensure_query_is_read_only!(state_before, state_after.value)
@@ -53,10 +66,6 @@ module SolidObjects
53
66
  false
54
67
  end
55
68
 
56
- private
57
-
58
- attr_reader :activation, :message
59
-
60
69
  # @rbs () -> Actor
61
70
  def actor
62
71
  activation.actor
@@ -0,0 +1,36 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class JsonCompatibility
5
+ PROBE = '{"probe":1}'
6
+
7
+ # @rbs @decode: ^(String) -> untyped
8
+
9
+ # @rbs (?decode: ^(String) -> untyped) -> void
10
+ def initialize(decode: ActiveSupport::JSON.method(:decode))
11
+ @decode = decode
12
+ end
13
+
14
+ # @rbs () -> String?
15
+ def problem
16
+ decode.call(PROBE)
17
+ nil
18
+ rescue ArgumentError => error
19
+ "Active Support #{ActiveSupport.version} cannot decode JSON with the json gem #{JSON::VERSION} " \
20
+ "(#{error.message}). Add gem \"json\", \"~> 2\" to the Gemfile, " \
21
+ "or use a Rails release that supports json 3"
22
+ end
23
+
24
+ # @rbs () -> void
25
+ def warn_about_problem
26
+ message = problem
27
+ return unless message
28
+
29
+ warn "[solid_objects] #{message}"
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :decode
35
+ end
36
+ end
@@ -1,5 +1,5 @@
1
1
  # rbs_inline: enabled
2
2
 
3
3
  module SolidObjects
4
- VERSION = "0.17.4"
4
+ VERSION = "1.0.0"
5
5
  end
data/lib/solid_objects.rb CHANGED
@@ -20,6 +20,7 @@ require "solid_objects/telemetry"
20
20
  require "solid_objects/diagnostics"
21
21
  require "solid_objects/log_subscriber"
22
22
  require "solid_objects/serialization"
23
+ require "solid_objects/json_compatibility"
23
24
  require "solid_objects/context"
24
25
  require "solid_objects/actor_registry"
25
26
  require "solid_objects/state"
@@ -81,6 +81,19 @@ module SolidObjects
81
81
  def members: () -> [ :name, :operation, :at, :arguments, :interval_seconds, :missed_policy ]
82
82
  end
83
83
 
84
+ class ScheduleCall < Data
85
+ attr_reader at(): untyped
86
+
87
+ attr_reader key(): untyped
88
+
89
+ def self.new: (untyped at, untyped key) -> instance
90
+ | (at: untyped, key: untyped) -> instance
91
+
92
+ def self.members: () -> [ :at, :key ]
93
+
94
+ def members: () -> [ :at, :key ]
95
+ end
96
+
84
97
  class UnscheduleIntent < Data
85
98
  attr_reader name(): untyped
86
99
 
@@ -207,6 +220,8 @@ module SolidObjects
207
220
 
208
221
  @outbound_message_intents: Array[OutboundMessageIntent]
209
222
 
223
+ @open_schedule_calls: Array[ScheduleCall]
224
+
210
225
  @reminder_intents: Array[ReminderIntent]
211
226
 
212
227
  @commit_action_intents: Array[CommitActionIntent]
@@ -348,6 +363,9 @@ module SolidObjects
348
363
  # @rbs () -> void
349
364
  def discard_intents: () -> void
350
365
 
366
+ # @rbs () -> void
367
+ def ensure_schedule_calls_have_operations!: () -> void
368
+
351
369
  # @rbs () -> Integer
352
370
  def intent_count: () -> Integer
353
371
 
@@ -366,6 +384,8 @@ module SolidObjects
366
384
 
367
385
  attr_reader outbound_message_intents: untyped
368
386
 
387
+ attr_reader open_schedule_calls: untyped
388
+
369
389
  # @rbs () -> Array[Array[Hash[Symbol, Object]]]
370
390
  def intent_snapshot: () -> Array[Array[Hash[Symbol, Object]]]
371
391
 
@@ -47,12 +47,14 @@ module SolidObjects
47
47
  def ping: (value: String) -> String
48
48
  end
49
49
 
50
+ @json_compatibility: JsonCompatibility
51
+
50
52
  @configuration: Configuration
51
53
 
52
54
  @connection: untyped
53
55
 
54
- # @rbs (?connection: untyped, ?configuration: Configuration) -> void
55
- def initialize: (?connection: untyped, ?configuration: Configuration) -> void
56
+ # @rbs (?connection: untyped, ?configuration: Configuration, ?json_compatibility: JsonCompatibility) -> void
57
+ def initialize: (?connection: untyped, ?configuration: Configuration, ?json_compatibility: JsonCompatibility) -> void
56
58
 
57
59
  # @rbs () -> Report
58
60
  def call: () -> Report
@@ -63,6 +65,11 @@ module SolidObjects
63
65
 
64
66
  attr_reader configuration: untyped
65
67
 
68
+ attr_reader json_compatibility: untyped
69
+
70
+ # @rbs () -> Check
71
+ def check_json: () -> Check
72
+
66
73
  # @rbs () -> Check
67
74
  def check_configuration: () -> Check
68
75
 
@@ -10,6 +10,9 @@ module SolidObjects
10
10
  class QueryMutatedState < NonRetryableError
11
11
  end
12
12
 
13
+ class ScheduleWithoutOperation < NonRetryableError
14
+ end
15
+
13
16
  class UnsupportedDatabase < Error
14
17
  end
15
18
 
@@ -22,6 +22,9 @@ module SolidObjects
22
22
 
23
23
  attr_reader message: untyped
24
24
 
25
+ # @rbs () -> bool
26
+ def execute: () -> bool
27
+
25
28
  # @rbs () -> Actor
26
29
  def actor: () -> Actor
27
30
 
@@ -0,0 +1,22 @@
1
+ # Generated from lib/solid_objects/json_compatibility.rb with RBS::Inline
2
+
3
+ module SolidObjects
4
+ class JsonCompatibility
5
+ PROBE: ::String
6
+
7
+ @decode: ^(String) -> untyped
8
+
9
+ # @rbs (?decode: ^(String) -> untyped) -> void
10
+ def initialize: (?decode: ^(String) -> untyped) -> void
11
+
12
+ # @rbs () -> String?
13
+ def problem: () -> String?
14
+
15
+ # @rbs () -> void
16
+ def warn_about_problem: () -> void
17
+
18
+ private
19
+
20
+ attr_reader decode: untyped
21
+ end
22
+ 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.17.4
4
+ version: 1.0.0
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-10-12 00:00:00.000000000 Z
11
+ date: 2026-10-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actioncable
@@ -276,12 +276,13 @@ dependencies:
276
276
  - - ">="
277
277
  - !ruby/object:Gem::Version
278
278
  version: '2.7'
279
- description: 'Solid Objects is a SQL-backed virtual actor library for Ruby on Rails,
280
- with durable state, ordered operations, and automatic activation. It brings the
281
- Cloudflare Durable Objects programming model to Rails: addressable objects with
282
- ordered mailboxes, fenced activation, per-object reminders, transactional effects,
283
- and reactive ERB. It runs on MySQL, PostgreSQL, and SQLite without Redis, and requires
284
- Ruby 3.3 or newer and Rails 7.1 or newer.'
279
+ description: 'Fix race conditions on one record in Rails: ordered operations, expiring
280
+ holds, and durable state in your existing SQL database. Solid Objects is a SQL-backed
281
+ virtual actor library for Ruby on Rails. It brings the Cloudflare Durable Objects
282
+ programming model to Rails: addressable objects with ordered mailboxes, fenced activation,
283
+ per-object reminders, transactional effects, and reactive ERB. It runs on MySQL,
284
+ PostgreSQL, and SQLite without Redis, and requires Ruby 3.3 or newer and Rails 7.1
285
+ or newer.'
285
286
  email:
286
287
  executables:
287
288
  - solid_objects
@@ -378,6 +379,7 @@ files:
378
379
  - docs/security.md
379
380
  - docs/state-migrations.md
380
381
  - docs/transmission.md
382
+ - docs/versioning.md
381
383
  - docs/virtual-actors.md
382
384
  - examples/application/README.md
383
385
  - examples/application/app/actors/chat_room_actor.rb
@@ -464,6 +466,7 @@ files:
464
466
  - lib/solid_objects/executor.rb
465
467
  - lib/solid_objects/instance_pruner.rb
466
468
  - lib/solid_objects/instrumentation.rb
469
+ - lib/solid_objects/json_compatibility.rb
467
470
  - lib/solid_objects/lease.rb
468
471
  - lib/solid_objects/lease_renewer.rb
469
472
  - lib/solid_objects/log_subscriber.rb
@@ -569,6 +572,7 @@ files:
569
572
  - sig/generated/lib/solid_objects/executor.rbs
570
573
  - sig/generated/lib/solid_objects/instance_pruner.rbs
571
574
  - sig/generated/lib/solid_objects/instrumentation.rbs
575
+ - sig/generated/lib/solid_objects/json_compatibility.rbs
572
576
  - sig/generated/lib/solid_objects/lease.rbs
573
577
  - sig/generated/lib/solid_objects/lease_renewer.rbs
574
578
  - sig/generated/lib/solid_objects/log_subscriber.rbs
@@ -685,5 +689,5 @@ requirements: []
685
689
  rubygems_version: 3.5.22
686
690
  signing_key:
687
691
  specification_version: 4
688
- summary: SQL-backed virtual actors for Ruby on Rails
692
+ summary: Fix race conditions on one record in Rails with SQL-backed virtual actors
689
693
  test_files: []