solid_objects 0.17.3 → 0.17.5

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: 218b1090ca466db8385017c23f98120d78b605b1db03a7ad69e82156c7669fe5
4
- data.tar.gz: f0deae94f1fa479882d3d47bc5205c30d30b30c3aadfee663959ad7e787ff265
3
+ metadata.gz: 0ba9bcffaef380dc24ebcb6b4da8893e467e5501fe71ed77f6f1e6d95520f516
4
+ data.tar.gz: ed791f24f434f52efb3a5080f37d47c5778e0f5b60be605ef9cf6aed2ac14471
5
5
  SHA512:
6
- metadata.gz: 97c597751ea4330ec9857faec8bc4ef8d3449891f29abd6d99fe13709e785971c42e6d3faf24ab02835685857104ad82e20df42088a0b84a32573a8519177368
7
- data.tar.gz: db9450e6f7657b630cf6d6c2eb191a57614690ef7f34d3d47fea079a5bd388d1b7bc31b84ac22667b6cdd296f0a426e29f00acb20b3473fe53af0e656d146b28
6
+ metadata.gz: e116a5c98d6e5dfcb6ff6b4a3cf37993b7e6ea320b2b87d1fe91f080ffc11b169c97ef5381746a193f2d5d89782eefe147542a9f0f53c597e49c622956f15ff1
7
+ data.tar.gz: 51bf5f1a8f3c87e72ce86cfcf8cd9cf08e3fd24854dce7446e3ef3e723e02a7b74efa9c2f2ea2a2a3bf73cf14ae1716b29687c5fdff734103dc05b391b6df4ad
data/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.17.5 - 2026-10-12
4
+
5
+ - Back off repeated interrupted-message recovery using `retry_delay`.
6
+ The first claimed attempt recovers immediately; after attempts 2, 3, 4,
7
+ and onward, the delay callback receives 1, 2, 3, and onward (defaults:
8
+ 1, 2, 4 seconds, capped at 60 seconds). Total persisted claim attempts,
9
+ including earlier handler failures, determine the delay. The ready timestamp
10
+ survives worker replacement and preserves mailbox order while other actors
11
+ can proceed. Lease loss alone still does not dead-letter a message; handler
12
+ failure attempt limits, setting names, defaults, and schema stay unchanged.
13
+ - Emit `activation.lost` with the message ID and total attempt count when a
14
+ turn loses its fence, including while saving a handler failure or rejection,
15
+ matching JavaScript instrumentation. Shutdown requested
16
+ by that callback stops the worker before another pass. Skip a cached actor
17
+ whose first message is delayed so later ready messages cannot starve peers.
18
+
19
+ - Preserve caller lifecycle guarantees with regression tests for concurrent
20
+ synchronous calls retaining their staged effects and for caller process
21
+ re-registration after stale-process cleanup and retention. Both cases
22
+ already work in Ruby; no runtime behavior or configuration changes.
23
+ The concurrency test waits for the second caller's busy-actor wait before
24
+ allowing the first caller to commit its staged effect.
25
+
26
+ ## 0.17.4 - 2026-10-12
27
+
28
+ - Renew activations between turns in a drain pass when renewal is due,
29
+ including when the renewal thread has not run.
30
+ - Keep the activation fence and expiry while an outstanding message claim
31
+ remains. The next worker pass recovers an expired turn in mailbox order,
32
+ including with `max_attempts: 1`. Reclaiming increments `attempt_count`;
33
+ lease loss alone does not create a dead letter.
34
+
3
35
  ## 0.17.3 - 2026-10-09
4
36
 
5
37
  - Add four problem guides in `docs/guides/`: preventing race conditions in
data/docs/agents.md CHANGED
@@ -173,6 +173,9 @@ Obey these rules in actor code:
173
173
  write in the same database.
174
174
  - Do not call an external API in a handler. Use `emit` and an effect handler.
175
175
  - Write each handler so that it can run again. Delivery is at least once.
176
+ - Repeated lease losses back off but do not become dead letters. Observe
177
+ `activation.lost` and set an application stopping policy if a hard bound is
178
+ required; increasing the handler-failure attempt limit does not bound lease loss.
176
179
  - A reminder changes state only when it runs, and it runs only while
177
180
  `solid_objects start` runs. Do not compute expiry from the clock in a query;
178
181
  read the state that the reminder committed.
@@ -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
@@ -606,3 +606,24 @@ heartbeat cleanup.
606
606
  Back up actor tables with the same consistency guarantees as application data.
607
607
  Restoring only instances without their mailboxes/outboxes, or vice versa, can
608
608
  violate application expectations.
609
+
610
+ ## Interrupted-message recovery
611
+
612
+ An interrupted first claimed attempt recovers immediately. Recovery after
613
+ persisted attempts 2, 3, 4, ... calls `retry_delay` with 1, 2, 3, ... . The
614
+ callback returns seconds; defaults grow from 1 to 2 to 4 seconds up to 60 seconds.
615
+ This uses total claim attempts, including earlier handler failures, not a
616
+ separate lease-loss counter. The delay is stored in ready-message availability
617
+ when a stale claim is recovered, survives worker replacement, and keeps later
618
+ messages behind that message. Other actors can still run. Custom delays,
619
+ including zero, remain authoritative.
620
+
621
+ Lease loss alone does not create a dead letter, even with `max_attempts: 1`.
622
+ Handler failures still use the full attempt count for `max_attempts` and
623
+ `retry_delay`. An always-losing message remains pending indefinitely. Observe
624
+ `activation.lost` (message ID and total attempt count) and impose an application
625
+ stopping policy if a hard bound is required. Calling `request_shutdown` from
626
+ instrumentation stops the worker before its next pass. Ruby uses a separate
627
+ renewal thread and waits after lost turns; it does not have the JavaScript
628
+ microtask starvation pattern. A handler must still return before its worker
629
+ can finish shutting down.
data/docs/roadmap.md CHANGED
@@ -23,8 +23,14 @@
23
23
  instead of 12. Concurrent creation causes no deadlock on SQLite, PostgreSQL,
24
24
  or MySQL. MySQL needs a shared read after a duplicate key, because it uses
25
25
  repeatable read. The tests count statements, and do not measure latency.
26
- - Activation leases, renewal, unique activation tokens, generations, and
27
- fenced commits
26
+ - Activation leases, renewal between turns as well as in the renewal thread,
27
+ unique activation tokens, generations, and
28
+ fenced commits. Interrupted first attempts recover immediately; repeated
29
+ interruptions use durable retry delays from total claim attempts minus one,
30
+ preserving mailbox order and progress for other actors. Lease loss emits
31
+ `activation.lost` with the message ID and attempt, including losses while
32
+ saving a handler failure or rejection. There is no hard retry cap;
33
+ applications needing one must impose a stopping policy.
28
34
  - Bounded activation passes, idle cache, hot-actor yield, and process records
29
35
  - At-least-once retries, terminal domain rejection, strict poison ordering,
30
36
  dead letters, and tail retry
@@ -170,6 +170,10 @@ again without harm.
170
170
  a crash or a lost lease.
171
171
  - Ordered turns do not cancel stale Ruby code. Fencing stops a stale activation
172
172
  from a commit, but that code can continue to run.
173
+ - An interrupted first attempt recovers immediately. Repeated interruptions
174
+ use growing, durable retry delays while keeping mailbox order. Lease loss
175
+ alone has no attempt limit; use instrumentation and an application stopping
176
+ policy when a hard bound is required.
173
177
  - External effects can run more than once. Use the stable effect ID, or
174
178
  another durable key, as the idempotency key at the provider.
175
179
  - There are no transactions across actor identities, and there is no replay of
@@ -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,
@@ -139,6 +140,7 @@ module SolidObjects
139
140
  break
140
141
  end
141
142
 
143
+ renew_lease if processed_count.positive? && lease_renewal_due?
142
144
  message = claim_next_message
143
145
  break unless message
144
146
 
@@ -226,11 +228,12 @@ module SolidObjects
226
228
  claimed_message.destroy!
227
229
  return if message.completed? || message.dead?
228
230
 
231
+ delay = (message.attempt_count <= 1) ? 0 : SolidObjects.configuration.retry_delay.call(message.attempt_count - 1)
229
232
  ReadyMessage.create!(
230
233
  message:,
231
234
  instance:,
232
235
  sequence: message.sequence,
233
- available_at: SolidObjects.database_adapter.database_now
236
+ available_at: SolidObjects.database_adapter.database_now + delay
234
237
  )
235
238
  end
236
239
 
@@ -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(
@@ -53,10 +65,6 @@ module SolidObjects
53
65
  false
54
66
  end
55
67
 
56
- private
57
-
58
- attr_reader :activation, :message
59
-
60
68
  # @rbs () -> Actor
61
69
  def actor
62
70
  activation.actor
@@ -123,6 +123,7 @@ module SolidObjects
123
123
  instance = Instance.lock.find_by(id: instance_id)
124
124
  next false unless instance
125
125
  next false unless owned_generation?(instance)
126
+ next false if ClaimedMessage.where(instance_id:).exists?
126
127
 
127
128
  instance.update!(
128
129
  activation_owner_id: nil,
@@ -1,5 +1,5 @@
1
1
  # rbs_inline: enabled
2
2
 
3
3
  module SolidObjects
4
- VERSION = "0.17.3"
4
+ VERSION = "0.17.5"
5
5
  end
@@ -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
 
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.3
4
+ version: 0.17.5
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-09 00:00:00.000000000 Z
11
+ date: 2026-10-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actioncable