Skip to content

Commit 99cfd5a

Browse files
authored
Merge pull request #29 from cardmagic/agent/reminder-scheduler-mailbox
fix: load the mailbox when the gem is required
2 parents efbe93a + 9d26c92 commit 99cfd5a

9 files changed

Lines changed: 213 additions & 4 deletions

File tree

AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ Every owned Ruby file must enable inline RBS with `# rbs_inline: enabled`; annot
2424

2525
Write Minitest files as `test/**/*_test.rb`. Start behavioral changes with a focused failing test. Exercise locking, leases, fencing, and claiming against real database adapters. Synchronize races with queues, barriers, or condition variables instead of arbitrary sleeps. Host-app actor tests should include `SolidObjects::TestHelper` rather than rely on transactional tests.
2626

27+
Watch the test fail before making it pass, and quote the observed failure in the pull request. A test that has never failed has not been shown to test anything, and the ways it can pass while proving nothing are not exotic: a regression test written after the fix, a double that lacks the `ensure` its real collaborator runs, a mock that ignores the signal under test, or a second guard that covers for the one being removed. When a change fixes a defect, revert the fix and confirm the test fails for the expected reason rather than some other one.
28+
29+
Run code in the process and environment it actually runs in. A runtime role gets only what `require "solid_objects"` defines, so verify it through `solid_objects start` rather than in a test process that has already loaded the constant it needs; `test/integration/load_contract_test.rb` enforces that boundary and is the place to record a deliberate exception. Browser modules go through a real browser as well as jsdom. Adapter behavior goes through the real database and every supported client, and because a skipped test looks exactly like a passing one in the summary line, check the skip count when a change touches an adapter.
30+
2731
## Commit & Pull Request Guidelines
2832

2933
Use concise imperative subjects, preferably under 50 characters; use prefixes such as `fix:`, `ci:`, or `chore:`. Pull requests should explain API, correctness, security, migration, and compatibility effects; list exact validation commands; link issues; and include screenshots for UI or reactive ERB changes. Never bypass hooks or add AI attribution.

CHANGELOG.md

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

3+
## 0.10.2 - 2026-08-10
4+
5+
- Load the mailbox when the gem is required. `SolidObjects::Mailbox` was
6+
reachable only through the caller path, which loads it as a side effect of
7+
`SolidObjects.client`. The reminder scheduler and the effect executor enqueue
8+
through it directly and run in `solid_objects start`, a process that never
9+
calls the client, so both raised
10+
`NameError: uninitialized constant SolidObjects::ReminderScheduler::Mailbox`.
11+
Reminders never fired and effect result messages never delivered, while the
12+
supervisor replaced the dying role over and over. Nothing caught it because
13+
every test process has already loaded the constant through some other path.
14+
- Add a load contract test that asks a fresh process what `require
15+
"solid_objects"` actually defines, and fails when a file stops being loaded
16+
unless it is listed as deliberately deferred with a reason. This is the class
17+
of bug that only appears in the standalone worker.
18+
- Run a due reminder through a real `solid_objects start` worker in the test
19+
suite, rather than only in process.
20+
321
## 0.10.1 - 2026-08-10
422

523
- Support Trilogy. Adapter selection matched the client name rather than the

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.10.1)
4+
solid_objects (0.10.2)
55
actioncable (>= 8.0)
66
actionpack (>= 8.0)
77
actionview (>= 8.0)
@@ -383,7 +383,7 @@ CHECKSUMS
383383
rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d
384384
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
385385
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
386-
solid_objects (0.10.1)
386+
solid_objects (0.10.2)
387387
sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc
388388
sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d
389389
sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b

docs/roadmap.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@
1818
status, which works on all three adapters; a future version may add narrow
1919
ready/claimed membership tables for very large outboxes, as messages already
2020
have
21-
- One-shot and recurring reminders with `:latest` or `:all` catch-up
21+
- One-shot and recurring reminders with `:latest` or `:all` catch-up, exercised
22+
through a real `solid_objects start` worker as well as in process. They were
23+
listed here while broken in that worker: the scheduler reached a constant the
24+
caller path happened to load, so reminders never fired in production and
25+
every in-process test still passed
2226
- Durable observable invalidations, scalar Turbo replacement, keyed ERB
2327
components, signed component locals, and authorized replace or morph refresh
2428
- Batched component refreshes: components sharing a signed `batch:` collapse to

lib/solid_objects.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@
5555
require "solid_objects/commit_action_registry"
5656
require "solid_objects/lease"
5757
require "solid_objects/lease_renewer"
58+
# The reminder scheduler and the effect executor enqueue through the mailbox,
59+
# and both run in the standalone worker where nothing else has loaded it. It
60+
# was reachable only through the caller path, so requiring the gem was not
61+
# enough to run a role that uses it.
62+
require "solid_objects/mailbox"
5863
require "solid_objects/worker"
5964
require "solid_objects/effect_executor"
6065
require "solid_objects/reminder_scheduler"

lib/solid_objects/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# rbs_inline: enabled
22

33
module SolidObjects
4-
VERSION = "0.10.1"
4+
VERSION = "0.10.2"
55
end

test/dummy/prepare_cli_reminder.rb

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# frozen_string_literal: true
2+
3+
ENV["RAILS_ENV"] = "test"
4+
5+
require_relative "config/environment"
6+
require_relative "../../db/migrate/20260805000000_create_solid_objects_tables"
7+
require_relative "../../db/migrate/20260806000000_add_state_revision_to_solid_objects_instances"
8+
9+
CreateSolidObjectsTables.new.migrate(:up)
10+
AddStateRevisionToSolidObjectsInstances.new.migrate(:up)
11+
12+
# A reminder that is already due, so the scheduler claims and enqueues it on
13+
# its first pass rather than waiting.
14+
instance = SolidObjects::Instance.create!(
15+
actor_type: "CliWorkerActor",
16+
actor_id: "reminder-in-worker",
17+
state: {},
18+
state_version: 1
19+
)
20+
reminder = SolidObjects::Reminder.create!(
21+
instance:,
22+
actor_type: instance.actor_type,
23+
actor_id: instance.actor_id,
24+
name: "deliver_push",
25+
message_name: "complete",
26+
arguments: {},
27+
next_run_at: 1.minute.ago,
28+
status: "scheduled"
29+
)
30+
31+
puts reminder.id

test/integration/cli_test.rb

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,39 @@ class CLITest < ActiveSupport::TestCase
4444
assert_raises(SolidObjects::Unauthorized) { command.prune_messages }
4545
end
4646

47+
# A role that only the standalone worker runs can depend on a constant that
48+
# the caller path happens to load first. In this process everything is
49+
# already loaded, so the failure is only reachable from a real worker.
50+
test "a due reminder fires in the standalone worker" do
51+
Dir.mktmpdir("solid-objects-reminder") do |directory|
52+
completed = File.join(directory, "completed")
53+
environment = worker_environment(directory, completed)
54+
dummy_root = File.expand_path("../dummy", __dir__)
55+
prepare(environment, dummy_root, "prepare_cli_reminder.rb")
56+
57+
_input, output, error_output, wait_thread = Open3.popen3(
58+
environment,
59+
"bundle", "exec", "solid_objects", "start",
60+
"--workers", "1",
61+
"--reminder-schedulers", "1",
62+
"--effect-workers", "0",
63+
"--broadcast-workers", "0",
64+
chdir: dummy_root
65+
)
66+
fired = wait_for_file(completed, timeout: 20)
67+
# The pipes only reach EOF once the process is gone, so reading them for
68+
# the failure message has to wait until after it is stopped.
69+
Process.kill("TERM", wait_thread.pid) if wait_thread.alive?
70+
wait_thread.join
71+
diagnosis = [ output.read, error_output.read ].join("\n")
72+
73+
assert fired, "the scheduler should have enqueued the due reminder:\n#{diagnosis}"
74+
ensure
75+
Process.kill("TERM", wait_thread.pid) if wait_thread&.alive?
76+
wait_thread&.join
77+
end
78+
end
79+
4780
# Deny-by-default means an unconfigured host hits this on its first CLI
4881
# command, so it is the most likely thing a new adopter ever sees.
4982
test "a denied command reports the policy rather than a backtrace" do
@@ -121,4 +154,35 @@ class CLITest < ActiveSupport::TestCase
121154
wait_thread&.join
122155
end
123156
end
157+
158+
private
159+
160+
def worker_environment(directory, probe)
161+
{
162+
"BUNDLE_GEMFILE" => File.expand_path("../../Gemfile", __dir__),
163+
"RAILS_ENV" => "test",
164+
"SOLID_OBJECTS_CLI_WORKER_PROBE" => probe,
165+
"SOLID_OBJECTS_DUMMY_DATABASE" => File.join(directory, "dummy.sqlite3")
166+
}
167+
end
168+
169+
def prepare(environment, dummy_root, script)
170+
_output, error_output, status = Open3.capture3(
171+
environment,
172+
Gem.ruby,
173+
File.join(dummy_root, script),
174+
chdir: dummy_root
175+
)
176+
assert status.success?, error_output
177+
end
178+
179+
def wait_for_file(path, timeout:)
180+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
181+
until Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
182+
return true if File.exist?(path)
183+
184+
sleep 0.1
185+
end
186+
false
187+
end
124188
end
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
require "open3"
5+
6+
# Runtime roles run in `solid_objects start`, a process that requires the gem
7+
# and nothing else. A constant reached only through the caller path resolves
8+
# fine in this test process, where everything is already loaded, and raises
9+
# NameError in that worker. Asking a fresh process what `require
10+
# "solid_objects"` actually defines is the only way to see the difference.
11+
class LoadContractTest < ActiveSupport::TestCase
12+
# Each of these is deliberately not loaded by requiring the gem. Anything
13+
# else that stops being loaded is a role waiting to fail in production, so
14+
# this list is the place to argue that a role never reaches it.
15+
DEFERRED = {
16+
"application_actor_loader" => "used only by the CLI",
17+
"caller_process" => "the caller path, required by SolidObjects.caller_process",
18+
"cli" => "loaded by exe/solid_objects, and pulls in thor",
19+
"client" => "the caller path, required by SolidObjects.client",
20+
"doctor" => "an operator tool, loaded by the doctor command",
21+
"errors" => "defines error classes individually, so no SolidObjects::Errors exists",
22+
"sync_diagnostics" => "the caller path, required with the client",
23+
"synchronous_invocation" => "the caller path, required with the client",
24+
"test_helper" => "opt-in, required by host application tests"
25+
}.freeze
26+
27+
test "requiring the gem defines everything a runtime role reaches for" do
28+
assert_equal DEFERRED.keys.sort, undefined_after_require,
29+
"a file that stopped being loaded is only safe if no runtime role reaches it"
30+
end
31+
32+
# The reported failure: two roles enqueue through the mailbox, and requiring
33+
# the gem did not define it.
34+
test "the mailbox is defined by requiring the gem" do
35+
refute_includes undefined_after_require, "mailbox",
36+
"the reminder scheduler and effect executor enqueue through it"
37+
end
38+
39+
private
40+
41+
# Computed inside the fresh process, where both the file list and what the
42+
# require actually defined are available.
43+
def undefined_after_require
44+
@undefined_after_require ||= begin
45+
output, error_output, status = Open3.capture3(
46+
{ "BUNDLE_GEMFILE" => gem_root("Gemfile") },
47+
Gem.ruby,
48+
"-e",
49+
probe,
50+
chdir: gem_root(".")
51+
)
52+
assert status.success?, error_output
53+
output.split("\n").sort
54+
end
55+
end
56+
57+
def probe
58+
<<~RUBY
59+
require "solid_objects"
60+
61+
def classify(path)
62+
path.split("/").map { |part| part.split("_").map(&:capitalize).join }.join("::")
63+
end
64+
65+
undefined = Dir.glob("**/*.rb", base: "lib/solid_objects").filter_map do |path|
66+
file = path.delete_suffix(".rb")
67+
next if file == "version"
68+
69+
begin
70+
Object.const_get("SolidObjects::" + classify(file))
71+
nil
72+
rescue NameError
73+
file
74+
end
75+
end
76+
puts undefined
77+
RUBY
78+
end
79+
80+
def gem_root(path)
81+
File.expand_path("../../#{path}", __dir__)
82+
end
83+
end

0 commit comments

Comments
 (0)