Skip to content

Commit 7cc98ba

Browse files
authored
Merge pull request #90 from cardmagic/test/caller-process-lifecycle
Back off lease-loss recovery and verify caller lifecycle
2 parents f5f6242 + 9fc5fc8 commit 7cc98ba

14 files changed

Lines changed: 455 additions & 10 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,28 @@
11
# Changelog
22

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+
326
## 0.17.4 - 2026-10-12
427

528
- Renew activations between turns in a drain pass when renewal is due,

‎Gemfile.lock‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
solid_objects (0.17.4)
4+
solid_objects (0.17.5)
55
actioncable (>= 7.1)
66
actionpack (>= 7.1)
77
actionview (>= 7.1)
@@ -392,7 +392,7 @@ CHECKSUMS
392392
rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d
393393
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
394394
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
395-
solid_objects (0.17.4)
395+
solid_objects (0.17.5)
396396
sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc
397397
sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d
398398
sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b

‎compatibility/telemetry-events.json‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,21 @@
8484
"sequence"
8585
]
8686
},
87+
{
88+
"name": "activation.lost",
89+
"attributes": [
90+
"actorId",
91+
"actorType",
92+
"attempt",
93+
"deliveryMode",
94+
"durationMilliseconds",
95+
"instanceId",
96+
"messageId",
97+
"operation",
98+
"requestId",
99+
"sequence"
100+
]
101+
},
87102
{
88103
"name": "activation.started",
89104
"attributes": ["actorId", "actorType", "generation", "instanceId", "ownerId"],

‎docs/agents.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,9 @@ Obey these rules in actor code:
173173
write in the same database.
174174
- Do not call an external API in a handler. Use `emit` and an effect handler.
175175
- 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.
176179
- A reminder changes state only when it runs, and it runs only while
177180
`solid_objects start` runs. Do not compute expiry from the clock in a query;
178181
read the state that the reminder committed.

‎docs/observability.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ JavaScript does not await exporters. Telemetry is not a durable audit trail.
4545
| ------------------------------------------- | ---------------------------------------------------------------------------- |
4646
| `activation.started/completed/failed` | Local actor activation hook lifecycle |
4747
| `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 |
4849
| `message.retry` | Failed attempt durably queued for another attempt |
4950
| `dead_letter.created` | Message exhausted retries or failed permanently |
5051
| `commit_action.started/completed/failed` | One registered commit action inside the commit transaction |

‎docs/operations.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,3 +606,24 @@ heartbeat cleanup.
606606
Back up actor tables with the same consistency guarantees as application data.
607607
Restoring only instances without their mailboxes/outboxes, or vice versa, can
608608
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.

‎docs/roadmap.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,12 @@
2525
repeatable read. The tests count statements, and do not measure latency.
2626
- Activation leases, renewal between turns as well as in the renewal thread,
2727
unique activation tokens, generations, and
28-
fenced commits
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.
2934
- Bounded activation passes, idle cache, hot-actor yield, and process records
3035
- At-least-once retries, terminal domain rejection, strict poison ordering,
3136
dead letters, and tail retry

‎docs/virtual-actors.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,10 @@ again without harm.
170170
a crash or a lost lease.
171171
- Ordered turns do not cancel stale Ruby code. Fencing stops a stale activation
172172
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.
173177
- External effects can run more than once. Use the stable effect ID, or
174178
another durable key, as the idempotency key at the provider.
175179
- There are no transactions across actor identities, and there is no replay of

‎lib/solid_objects/activation.rb‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ def drain_until(message_id:, deadline:)
5252
# @rbs () -> bool
5353
def ready?
5454
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) ||
5657
ClaimedMessage.where(
5758
instance_id: lease.instance_id,
5859
process_id: lease.owner_id,
@@ -227,11 +228,12 @@ def recover_stale_claim(instance)
227228
claimed_message.destroy!
228229
return if message.completed? || message.dead?
229230

231+
delay = (message.attempt_count <= 1) ? 0 : SolidObjects.configuration.retry_delay.call(message.attempt_count - 1)
230232
ReadyMessage.create!(
231233
message:,
232234
instance:,
233235
sequence: message.sequence,
234-
available_at: SolidObjects.database_adapter.database_now
236+
available_at: SolidObjects.database_adapter.database_now + delay
235237
)
236238
end
237239

‎lib/solid_objects/executor.rb‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,18 @@ def initialize(activation:, message:)
1616

1717
# @rbs () -> bool
1818
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
1931
state_before = actor.state.to_h
2032
observables_before = actor.observable_values
2133
message_context = MessageContext.new(
@@ -53,10 +65,6 @@ def call
5365
false
5466
end
5567

56-
private
57-
58-
attr_reader :activation, :message
59-
6068
# @rbs () -> Actor
6169
def actor
6270
activation.actor

0 commit comments

Comments
 (0)