Skip to content

examples: prove the at-least-once clause at a sink - #51

Merged
cardmagic merged 2 commits into
mainfrom
feat/at-least-once-demo
Aug 24, 2026
Merged

examples: prove the at-least-once clause at a sink#51
cardmagic merged 2 commits into
mainfrom
feat/at-least-once-demo

Conversation

@cardmagic

Copy link
Copy Markdown
Owner

Why

docs/correctness.md promises at-least-once execution and tells external
systems to hold stable idempotency keys. Nothing in the repository showed that
duplicate arriving anywhere. A contract clause nobody can watch fire is
decoration, so this adds the artifact that makes it fire on demand, and shows
the documented remedy absorbing it.

This is the Ruby counterpart of
solid-objects-js#26,
which implements
solid-objects-js#23.
Same proof, same shape, same numbers.

No runtime code changes. This is an example, a unit test, a CI step, and two
lines of prose.

What the demo does

bundle exec rake at_least_once runs examples/at_least_once/demo.rb against
a temporary SQLite file:

  1. One actor turn increments count and stages a record effect in the same
    commit.
  2. A child process claims the effect, writes the delivery to an external sink
    file, then calls Process.exit!(1) between the sink write and the
    acknowledgement
    . The sink holds the delivery; the effect row never
    completes.
  3. After the liveness threshold, a second child calls
    SolidObjects::ProcessRegistry.cleanup_dead (production runs this on
    dead_process_cleanup_interval), reclaims the released effect, and delivers
    again.

Phase one runs the sink with deduplication off. Phase two repeats the same
crash with a guard on the stable effect id.

{
  "duplicate": {
    "state_commits": 1,
    "sink_deliveries": 2,
    "same_effect_id": true,
    "attempts": [1, 2]
  },
  "remedy": {
    "state_commits": 1,
    "sink_deliveries": 1
  }
}

The state commit happens exactly once in both phases. That is the sharpest
line of the proof: the actor kept its exactly-once story while the outside
world saw the delivery twice, and only a consumer-side guard on context.id
collapsed it.

The crash point

SolidObjects.register_effect(:record) do |_arguments, context|
  AtLeastOnceSink.record(
    path: sink_path.to_s,
    effect_id: context.id,
    attempt: context.attempt,
    deduplication: deduplication.to_sym
  )
  # A crash between the external write and the acknowledgement: the sink
  # has the delivery, the effect row never completes.
  Process.exit!(1) if mode == "crash"
  nil
end

context.id is stable across attempts and context.attempt reads 1 then 2,
which is exactly what an external consumer needs in order to deduplicate.

Observed failures

The sink test was written first. With examples/at_least_once/sink.rb absent,
bundle exec rake test TEST=test/unit/at_least_once_sink_test.rb:

test/unit/at_least_once_sink_test.rb:5:in 'Kernel#require_relative': cannot load such file -- .../examples/at_least_once/sink (LoadError)

A missing file is a weak failure, so the guard was also removed from a
complete sink. The unit test then failed on the assertion under test rather
than on load:

Failure:
AtLeastOnceSinkTest#test_applies_a_replayed_effect_id_once_when_deduplication_is_on [test/unit/at_least_once_sink_test.rb:35]:
Expected true to not be truthy.

3 runs, 6 assertions, 1 failures, 0 errors, 0 skips

The demo is not self-fulfilling either. With the same guard removed,
bundle exec rake at_least_once fails at the remedy phase:

examples/at_least_once/demo.rb:28:in 'Object#prove': proof failed: the stable effect id absorbed the duplicate (RuntimeError)

Restoring the guard turns all three green.

Files

Path Role
examples/at_least_once/sink.rb The external system: a JSON file, with the guard on or off
examples/at_least_once/actor.rb DeliveryCounter#deliver increments and emits record
examples/at_least_once/boot.rb Standalone runtime boot shared by the parent and the children
examples/at_least_once/effect_worker.rb The child that crashes, and the child that recovers
examples/at_least_once/demo.rb Runs both phases and prints the proof
test/unit/at_least_once_sink_test.rb Unit coverage for both guard modes

Wiring

  • bundle exec rake at_least_once runs it.
  • CI runs it in the SQLite job, after bundle exec rake.
  • docs/correctness.md links it from the handler idempotency section it makes
    observable.
  • CHANGELOG.md records it under Unreleased.

docs/roadmap.md is untouched. The gem's claims about itself did not move;
this makes an existing claim observable.

Effects

  • API: none. No public surface changes.
  • Correctness: none. The demo asserts existing behavior.
  • Security: none. The demo runs against a temporary SQLite file and permissive
    authorization inside its own process.
  • Migration and compatibility: none.

Validation

  • bundle exec rake (test, standard, rubocop, rbs, steep, security): pass.
  • bundle exec rake at_least_once: four consecutive clean runs locally.

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.
@cardmagic

Copy link
Copy Markdown
Owner Author

CI reproduced the proof on Ubuntu in the sqlite job, so the crash timing is not a macOS artifact:

"duplicate": { "state_commits": 1, "sink_deliveries": 2, "same_effect_id": true, "attempts": [1, 2] }
"remedy":    { "state_commits": 1, "sink_deliveries": 1 }

sqlite job log. All checks pass.

Greptile flagged the JS counterpart for reading every failure as an empty
sink, which lets the deduplication phase forget the effect id and still
pass. The Ruby sink already rescues only Errno::ENOENT, and the Ruby
effect worker already fails when no effect became claimable, so neither
defect exists here.

Both properties were incidental until now. These two tests make them
deliberate: a truncated JSON file and a path that is a directory must
raise rather than read as a first run. Widening the rescue to
StandardError fails both.
@cardmagic

Copy link
Copy Markdown
Owner Author

Greptile reviewed the JS counterpart (cardmagic/solid-objects-js#26) and raised two P2 findings. I checked both against this PR:

  1. Sink read swallows every error. Does not apply. AtLeastOnceSink.read rescues only Errno::ENOENT; a damaged or unreadable sink already raises. The JS version had a bare catch and is now fixed to match Ruby.
  2. Recovery worker can exit successfully without processing. Does not apply. effect_worker.rb already raises no effect became claimable when the claim loop comes back empty.

Both properties were incidental rather than pinned, so b8ea47a adds two tests: a truncated JSON file and a path that is a directory must raise instead of reading as a first run. Widening the rescue to StandardError fails both:

AtLeastOnceSinkTest#test_refuses_to_read_a_damaged_sink_as_an_empty_one:
JSON::ParserError expected but nothing was raised.
AtLeastOnceSinkTest#test_refuses_to_read_an_unreadable_sink_as_an_empty_one:
Errno::EISDIR expected but nothing was raised.

Restored, the file reads 5 runs, 9 assertions, 0 failures.

@cardmagic
cardmagic merged commit 6b3adaf into main Aug 24, 2026
40 checks passed
@cardmagic
cardmagic deleted the feat/at-least-once-demo branch August 24, 2026 21:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant