Skip to content

Commit a46bb8e

Browse files
committed
fix: catch a bare schedule call in an observable
The check for a schedule call with no operation ran directly after the operation. An observable that called schedule only after the state changed was read later, so the turn committed with no reminder and no error. The open call stayed on the retained actor and made the next turn fail, although that turn did nothing wrong. The projection guard now counts an open schedule call as staged work. The turn that reads the observable fails with QueryMutatedState, and the failure path clears the open call.
1 parent 17df9ad commit a46bb8e

5 files changed

Lines changed: 53 additions & 3 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
does not retry that turn, and the message becomes a dead letter. Before this
1212
release, such a call stored no reminder and gave no error. Correct code,
1313
such as `schedule(at: time).expire`, does not change. The turn can keep the
14-
result and call the operation later. JavaScript has the same behavior.
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.
1517
- `bin/rails solid_objects:doctor` has a new `json` check, and the boot prints
1618
a warning, when Active Support cannot decode JSON with the installed `json`
1719
gem. This is the case for `json` 3.x on Rails older than 8.1.4. The check

‎docs/reminders.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ schedule(at: 10.minutes.from_now, key: buyer).expire(buyer:)
3333

3434
You can keep the result and call the operation later in the same turn.
3535

36+
An observable cannot call `schedule`, with or without an operation. The turn
37+
that reads the observable fails with `SolidObjects::QueryMutatedState`.
38+
3639
## A reminder is one named alarm per actor
3740

3841
The uniqueness key is `(actor, reminder name)`. Scheduling a name that is

‎docs/roadmap.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
[versioning](versioning.md)
1414
- A turn that calls `schedule` and then calls no operation on the result fails
1515
with `ScheduleWithoutOperation`, which is not retried, matching JavaScript.
16-
Before 1.0 such a call stored no reminder and gave no error.
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`.
1719
- A `json` check in `solid_objects:doctor` and a warning during the boot, for
1820
the `json` gem 3.x on an Active Support release that cannot decode with it.
1921
The check decodes one document and does not compare version numbers. This

‎lib/solid_objects/actor.rb‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -586,7 +586,14 @@ def read_projection(operation)
586586

587587
# @rbs () -> Array[Array[Hash[Symbol, Object]]]
588588
def intent_snapshot
589-
[ 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|
590597
intents.map { |intent| intent.to_h.deep_dup }
591598
end
592599
end

‎test/integration/schedule_without_operation_test.rb‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,25 @@ def expire
3737
end
3838
end
3939

40+
class ProjectionScheduler < SolidObjects::Actor
41+
actor_type "schedule-in-projection"
42+
43+
attribute :status, default: "open"
44+
45+
observable :deadline do
46+
schedule(at: 10.minutes.from_now, key: "ada") if status == "held"
47+
status
48+
end
49+
50+
def hold
51+
self.status = "held"
52+
end
53+
54+
def release
55+
self.status = "open"
56+
end
57+
end
58+
4059
setup do
4160
SolidObjects.configuration.max_attempts = 3
4261
SolidObjects.configuration.retry_delay = ->(_) { 0 }
@@ -89,4 +108,21 @@ def expire
89108

90109
assert_equal [ "expire" ], SolidObjects::Reminder.pluck(:operation)
91110
end
111+
112+
test "a schedule call with no operation in an observable fails the turn that reads it" do
113+
error = assert_raises(SolidObjects::MessageFailed) { ProjectionScheduler.ref("one").sync.hold }
114+
115+
assert_equal "SolidObjects::QueryMutatedState", error.details.fetch("class")
116+
assert_empty SolidObjects::Instance.find_by!(actor_id: "one").state
117+
assert_equal 1, SolidObjects::Message.sole.attempt_count
118+
end
119+
120+
test "a schedule call with no operation in an observable does not make the next turn fail" do
121+
reference = ProjectionScheduler.ref("one")
122+
assert_raises(SolidObjects::MessageFailed) { reference.sync.hold }
123+
124+
reference.sync.release
125+
126+
assert_equal 1, SolidObjects::DeadLetter.count
127+
end
92128
end

0 commit comments

Comments
 (0)