Skip to content

Commit abe93f0

Browse files
committed
test: prove the at-least-once clause at a sink
The correctness doc promises at-least-once execution and tells external systems to hold stable idempotency keys, but nothing in the repository showed that duplicate arriving anywhere. A contract clause nobody can watch fire is decoration. This adds the artifact that fires it on demand and shows the documented remedy absorbing it. examples/at_least_once stages one actor turn whose effect writes to an external sink file. The first effect worker crashes between the sink write and the acknowledgement. A second worker runs ProcessRegistry.cleanup_dead after the liveness threshold, reclaims the released effect, and delivers again. With deduplication off the sink reads 2, both deliveries carrying the same context.id at attempts 1 and 2. With a guard on that id the sink reads 1. The actor state commits exactly once in both runs, which is the sharpest line of the proof: the state machine kept its exactly-once story while the outside world saw two. bundle exec rake at_least_once runs it, CI runs it in the SQLite job, and docs/correctness.md links it from the handler idempotency section it makes observable. The sink module carries unit coverage for both guard modes. This is the Ruby counterpart of solid-objects-js#26.
1 parent e49c694 commit abe93f0

10 files changed

Lines changed: 294 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ jobs:
1515
ruby-version: "3.3"
1616
bundler-cache: true
1717
- run: bundle exec rake
18+
- run: bundle exec rake at_least_once
1819

1920
javascript:
2021
runs-on: ubuntu-latest

CHANGELOG.md

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

3+
## Unreleased
4+
5+
- Add `examples/at_least_once` and `bundle exec rake at_least_once`, an
6+
executable proof that the at-least-once clause fires and that the
7+
documented remedy absorbs it. One actor turn stages an effect that writes
8+
to an external sink file. The first effect worker crashes between the sink
9+
write and the acknowledgement, and a second worker reclaims the stale
10+
effect after the liveness threshold and delivers again. With deduplication
11+
off the sink reads 2, both deliveries carrying the same `context.id` at
12+
attempts 1 and 2; with a guard on that id the sink reads 1. The actor state
13+
commits exactly once in both runs. CI runs the demo in the SQLite job, and
14+
`docs/correctness.md` links it from the handler idempotency section. This
15+
mirrors `pnpm run test:at-least-once` in solid-objects-js.
16+
317
## 0.14.0 - 2026-08-22
418

519
- Add `SolidObjects::Transmission.receive(envelope)`, the server ingest for

Rakefile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ task :steep do
3232
sh "bundle exec steep check"
3333
end
3434

35+
desc "Prove the at-least-once clause by crashing an effect worker at a sink"
36+
task :at_least_once do
37+
sh "bundle exec ruby examples/at_least_once/demo.rb"
38+
end
39+
3540
desc "Scan the Rails engine for security warnings"
3641
task :security do
3742
sh "bundle exec brakeman --force --no-pager -q ."

docs/correctness.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,15 @@ end
9393
The guard prevents a repeated state transition. The effect consumer still
9494
deduplicates with `context.id`.
9595

96+
This clause is observable, not decorative. `bundle exec rake at_least_once`
97+
crashes an effect worker between the external sink write and the
98+
acknowledgement, restarts one after the liveness threshold, and shows the sink
99+
reading 2 with deduplication off. Both deliveries carry the same `context.id`
100+
at attempts 1 and 2. A guard on that id absorbs the same duplicate and the
101+
sink reads 1. The actor state commits exactly once in both runs. The source is
102+
`examples/at_least_once/`; solid-objects-js runs the same proof with
103+
`pnpm run test:at-least-once`.
104+
96105
## Atomic boundaries
97106

98107
The following are atomic:

examples/at_least_once/actor.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# rbs_inline: enabled
2+
3+
class DeliveryCounter < SolidObjects::Actor
4+
actor_type "delivery-counter"
5+
6+
attribute :count, default: 0
7+
8+
# @rbs () -> Integer
9+
def deliver
10+
self.count += 1
11+
emit :record
12+
count
13+
end
14+
end

examples/at_least_once/boot.rb

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# rbs_inline: enabled
2+
3+
require "bundler/setup"
4+
require "active_record"
5+
require "solid_objects"
6+
7+
# Boots a standalone runtime against a shared SQLite file, the way the
8+
# demo's parent and its crashing children all attach to one database.
9+
module AtLeastOnceBoot
10+
ROOT = File.expand_path("../..", __dir__)
11+
12+
# @rbs (String database_path) -> void
13+
def self.call(database_path)
14+
ActiveRecord::Base.establish_connection(
15+
adapter: "sqlite3",
16+
database: database_path,
17+
pool: 5,
18+
timeout: 5_000
19+
)
20+
ActiveRecord::Migration.verbose = false
21+
migrate unless ActiveRecord::Base.connection.table_exists?("solid_objects_instances")
22+
23+
require "solid_objects/database_adapter"
24+
%w[
25+
record process instance message ready_message claimed_message
26+
reminder effect broadcast dead_letter
27+
].each { |model| require File.join(ROOT, "app/models/solid_objects", model) }
28+
29+
SolidObjects.configuration.authorize_message = ->(**) { true }
30+
SolidObjects.configuration.authorize_query = ->(**) { true }
31+
SolidObjects.configuration.polling_interval = 0.01
32+
SolidObjects.configuration.process_heartbeat_interval = 0.075
33+
SolidObjects.configuration.process_alive_threshold = 0.3
34+
SolidObjects.configuration.lease_duration = 0.25
35+
SolidObjects.configuration.lease_renewal_interval = 0.05
36+
end
37+
38+
# @rbs () -> void
39+
def self.migrate
40+
require File.join(ROOT, "db/migrate/20260805000000_create_solid_objects_tables")
41+
require File.join(ROOT, "db/migrate/20260806000000_add_state_revision_to_solid_objects_instances")
42+
require File.join(ROOT, "db/migrate/20260813000000_rename_message_dispatch_columns")
43+
CreateSolidObjectsTables.new.migrate(:up)
44+
AddStateRevisionToSolidObjectsInstances.new.migrate(:up)
45+
RenameMessageDispatchColumns.new.migrate(:up)
46+
end
47+
end

examples/at_least_once/demo.rb

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# rbs_inline: enabled
2+
3+
# An executable proof for the at-least-once clause: a contract clause
4+
# nobody can observe firing is decoration. Run with:
5+
#
6+
# bundle exec rake at_least_once
7+
#
8+
# Phase one crashes an effect worker between the external sink write and
9+
# the acknowledgement, restarts one, and shows the sink reading 2 with
10+
# deduplication off. Both deliveries carry the same stable effect id.
11+
# Phase two repeats the crash with a guard on that id; the sink reads 1.
12+
# The actor state commits exactly once in both phases.
13+
14+
require_relative "boot"
15+
require_relative "actor"
16+
require_relative "sink"
17+
require "fileutils"
18+
require "json"
19+
require "rbconfig"
20+
require "tmpdir"
21+
22+
directory = Dir.mktmpdir("solid_objects_at_least_once_")
23+
database_path = File.join(directory, "state.sqlite3")
24+
AtLeastOnceBoot.call(database_path)
25+
26+
# @rbs (String message) -> void
27+
def prove(message)
28+
raise "proof failed: #{message}" unless yield
29+
end
30+
31+
# @rbs (String actor_id) -> void
32+
def stage_one_delivery(actor_id)
33+
DeliveryCounter.ref(actor_id).async.deliver
34+
worker = SolidObjects::Worker.new
35+
begin
36+
worker.run_until_idle
37+
ensure
38+
worker.stop
39+
end
40+
end
41+
42+
# @rbs (database_path: String, sink_path: String, mode: String, deduplication: String) -> Integer?
43+
def run_effect_worker(database_path:, sink_path:, mode:, deduplication:)
44+
script = File.expand_path("effect_worker.rb", __dir__)
45+
pid = Process.spawn(
46+
RbConfig.ruby, script, database_path, sink_path, mode, deduplication,
47+
chdir: AtLeastOnceBoot::ROOT
48+
)
49+
_pid, status = Process.wait2(pid)
50+
status.exitstatus
51+
end
52+
53+
# @rbs (database_path: String, sink_path: String, deduplication: String) -> void
54+
def crash_then_recover(database_path:, sink_path:, deduplication:)
55+
crash = run_effect_worker(database_path:, sink_path:, mode: "crash", deduplication:)
56+
prove("the first delivery crashed before acknowledgement") { crash == 1 }
57+
sleep 0.4
58+
recovery = run_effect_worker(database_path:, sink_path:, mode: "complete", deduplication:)
59+
prove("the second delivery completed and acknowledged") { recovery == 0 }
60+
end
61+
62+
begin
63+
sink_off = File.join(directory, "sink-dedup-off.json")
64+
stage_one_delivery("dedup-off")
65+
crash_then_recover(database_path:, sink_path: sink_off, deduplication: "off")
66+
deliveries = AtLeastOnceSink.read(sink_off)
67+
effect_ids = deliveries.map { |delivery| delivery.fetch("effect_id") }
68+
state_off = SolidObjects::Instance.find_by!(actor_id: "dedup-off").state.fetch("count")
69+
prove("the state commit happened exactly once") { state_off == 1 }
70+
prove("the sink observed the duplicate") { deliveries.length == 2 }
71+
prove("both deliveries carried the same stable effect id") { effect_ids.uniq.length == 1 }
72+
73+
sink_on = File.join(directory, "sink-dedup-on.json")
74+
stage_one_delivery("dedup-on")
75+
crash_then_recover(database_path:, sink_path: sink_on, deduplication: "on")
76+
guarded = AtLeastOnceSink.read(sink_on)
77+
state_on = SolidObjects::Instance.find_by!(actor_id: "dedup-on").state.fetch("count")
78+
prove("the state commit happened exactly once") { state_on == 1 }
79+
prove("the stable effect id absorbed the duplicate") { guarded.length == 1 }
80+
81+
puts JSON.pretty_generate(
82+
duplicate: {
83+
state_commits: state_off,
84+
sink_deliveries: deliveries.length,
85+
same_effect_id: effect_ids.uniq.length == 1,
86+
attempts: deliveries.map { |delivery| delivery.fetch("attempt") }
87+
},
88+
remedy: { state_commits: state_on, sink_deliveries: guarded.length }
89+
)
90+
ensure
91+
FileUtils.remove_entry(directory) if directory
92+
end
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# rbs_inline: enabled
2+
3+
require_relative "boot"
4+
require_relative "actor"
5+
require_relative "sink"
6+
7+
database_path, sink_path, mode, deduplication = ARGV
8+
raise ArgumentError, "usage: effect_worker.rb DATABASE SINK crash|complete on|off" unless deduplication
9+
10+
AtLeastOnceBoot.call(database_path.to_s)
11+
12+
SolidObjects.register_effect(:record) do |_arguments, context|
13+
AtLeastOnceSink.record(
14+
path: sink_path.to_s,
15+
effect_id: context.id,
16+
attempt: context.attempt,
17+
deduplication: deduplication.to_sym
18+
)
19+
# A crash between the external write and the acknowledgement: the sink
20+
# has the delivery, the effect row never completes.
21+
Process.exit!(1) if mode == "crash"
22+
nil
23+
end
24+
25+
# Production runs this on the dead-process-cleanup interval; the demo runs
26+
# it once, after the liveness threshold, to release the crashed claim.
27+
SolidObjects::ProcessRegistry.cleanup_dead
28+
29+
effect_executor = SolidObjects::EffectExecutor.new
30+
begin
31+
worked = false
32+
200.times do
33+
worked = effect_executor.run_once
34+
break if worked
35+
sleep 0.01
36+
end
37+
raise "no effect became claimable" unless worked
38+
ensure
39+
effect_executor.stop
40+
end

examples/at_least_once/sink.rb

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# rbs_inline: enabled
2+
3+
require "json"
4+
5+
# The external system in the at-least-once demo: a JSON file that records
6+
# every delivery it accepts. With deduplication :off it accepts everything,
7+
# which makes an at-least-once duplicate visible. With deduplication :on it
8+
# accepts each stable effect id once, which is the documented remedy.
9+
module AtLeastOnceSink
10+
# @rbs (String path) -> Array[Hash[String, untyped]]
11+
def self.read(path)
12+
JSON.parse(File.read(path))
13+
rescue Errno::ENOENT
14+
[]
15+
end
16+
17+
# @rbs (path: String, effect_id: String, attempt: Integer, deduplication: Symbol) -> bool
18+
def self.record(path:, effect_id:, attempt:, deduplication:)
19+
deliveries = read(path)
20+
seen = deliveries.any? { |delivery| delivery.fetch("effect_id") == effect_id }
21+
return false if deduplication == :on && seen
22+
23+
deliveries << { "effect_id" => effect_id, "attempt" => attempt }
24+
File.write(path, JSON.pretty_generate(deliveries))
25+
true
26+
end
27+
end
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
require "tmpdir"
5+
require_relative "../../examples/at_least_once/sink"
6+
7+
class AtLeastOnceSinkTest < ActiveSupport::TestCase
8+
test "records every delivery when deduplication is off" do
9+
Dir.mktmpdir do |directory|
10+
path = File.join(directory, "sink.json")
11+
12+
first = AtLeastOnceSink.record(path:, effect_id: "effect-1", attempt: 1, deduplication: :off)
13+
second = AtLeastOnceSink.record(path:, effect_id: "effect-1", attempt: 2, deduplication: :off)
14+
15+
assert first
16+
assert second
17+
assert_equal(
18+
[
19+
{ "effect_id" => "effect-1", "attempt" => 1 },
20+
{ "effect_id" => "effect-1", "attempt" => 2 }
21+
],
22+
AtLeastOnceSink.read(path)
23+
)
24+
end
25+
end
26+
27+
test "applies a replayed effect id once when deduplication is on" do
28+
Dir.mktmpdir do |directory|
29+
path = File.join(directory, "sink.json")
30+
31+
first = AtLeastOnceSink.record(path:, effect_id: "effect-1", attempt: 1, deduplication: :on)
32+
replay = AtLeastOnceSink.record(path:, effect_id: "effect-1", attempt: 2, deduplication: :on)
33+
34+
assert first
35+
refute replay
36+
assert_equal([ { "effect_id" => "effect-1", "attempt" => 1 } ], AtLeastOnceSink.read(path))
37+
end
38+
end
39+
40+
test "reads an empty sink where no file exists" do
41+
Dir.mktmpdir do |directory|
42+
assert_equal [], AtLeastOnceSink.read(File.join(directory, "missing.json"))
43+
end
44+
end
45+
end

0 commit comments

Comments
 (0)