Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## Unreleased

- Correct `docs/reminders.md`, which said that Solid Objects has no
`unschedule`. The gem has `unschedule` and `unschedule_all`, and the guide
now has a section on how to cancel a reminder. The agent guide shows the
cancel call. A new test fails when the reminder guide does not name a public
reminder method of an actor.

## 1.0.0 - 2026-10-13

- Version 1.0 freezes the public API under semantic versioning.
Expand Down
2 changes: 2 additions & 0 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ Obey these rules in actor code:
- Keep state in `attribute` values. State must be JSON-compatible.
- Use `schedule(at:, key:)` for delayed work. A reminder is one named alarm
for each actor and key. A new `schedule` with the same key moves the alarm.
`unschedule(:expire, key: buyer)` cancels it.
- Use `reject(code, message)` for a business rule failure that must not retry.
It takes a code and a message, for example
`reject(:room_full, "The room is full")`.
Expand All @@ -191,6 +192,7 @@ Avoid these mistakes:
| `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` |
| `reject "room full"` | `reject(:room_full, "The room is full")` |
| `id` inside an actor | `actor_id`. An actor has no `id` method |
| A guessed cancel method, such as `cancel_reminder` | `unschedule(:expire, key: buyer)`. `unschedule_all(:expire)` cancels every key of one operation |
| `register_effect(:name) { \|context, arguments\| ... }` | `register_effect(:name) { \|arguments, context\| ... }`. The arguments come first |

[Reminders](reminders.md) and the [architecture guide](architecture.md) give
Expand Down
31 changes: 29 additions & 2 deletions docs/reminders.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,35 @@ cannot strand an entry because the handler drains by time rather than by alarm.
Prefer it when the queue is large and the items are interchangeable; prefer
`key:` when an item needs its own alarm that can be moved on its own.

Solid Objects has no `unschedule`. A reminder stops when its handler does not
re-arm it, and destroying an actor removes its reminders.
## Cancel a reminder

`unschedule` cancels one reminder: by operation, by operation and key, or by
the handle that the operation call on the `schedule` result returns.
`unschedule_all` cancels every key of one operation.

```ruby
def confirm(buyer:)
self.holds = holds.except(buyer)
unschedule(:expire, key: buyer)
end

def close_sale
unschedule_all(:expire)
end
```

A cancellation is staged like a schedule. It commits with the state change of
the same turn, and a turn that raises cancels nothing. A cancellation of a
reminder that does not exist is not an error. An unknown operation raises
`UnknownMessage`. A cancellation cannot recall an occurrence that the scheduler
already turned into a message.

A reminder also stops when its handler does not re-arm it, and the destruction
of an actor removes its reminders.

`reminder` returns the status of one reminder, or `nil`. `reminders` lists
every key of one operation. Both include the changes that the current turn
staged.

Self-scheduling actors should also have a low-frequency application reconciler.
It may read `SolidObjects::Instance.states_for`, `.without_pending_work`, and
Expand Down
27 changes: 27 additions & 0 deletions test/unit/reminder_documents_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# frozen_string_literal: true

require "test_helper"

class ReminderDocumentsTest < ActiveSupport::TestCase
ROOT = File.expand_path("../..", __dir__)
REMINDER_METHODS = %i[schedule unschedule unschedule_all reminder reminders].freeze

def guide(path)
File.read(File.join(ROOT, path))
end

test "the reminder guide names each public reminder method of an actor" do
REMINDER_METHODS.each do |method|
assert SolidObjects::Actor.public_method_defined?(method), "SolidObjects::Actor has no public #{method}"
assert_includes guide("docs/reminders.md"), "`#{method}`", "docs/reminders.md does not name #{method}"
end
end

test "the reminder guide does not deny a method that an actor has" do
refute_match(/has no `unschedule`/, guide("docs/reminders.md"))
end

test "the agent guide shows how to cancel a reminder" do
assert_includes guide("docs/agents.md"), "unschedule(:expire, key: buyer)"
end
end
Loading