Skip to content

docs: lead with the case a lock cannot cover - #54

Merged
cardmagic merged 8 commits into
mainfrom
docs/lead-with-ticket-sale
Aug 25, 2026
Merged

docs: lead with the case a lock cannot cover#54
cardmagic merged 8 commits into
mainfrom
docs/lead-with-ticket-sale

Conversation

@cardmagic

@cardmagic cardmagic commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Framing fix to the first screen and the fit sections. No API documentation was
rewritten, and no runtime code changed.

The lead example

A counter needs one thing from one number, and SQL already does it. The ticket
sale needs three:

  • it must never go below zero;
  • it must give the seat back if the buyer does not pay within ten minutes; and
  • it must show the current count to everyone watching the page.

The first is one line of SQL. The second is an expires_at column plus a cron
job. The third is a broadcast on every path that changes the number. The
combination is the argument, and the example now shows all three in one class:
the guard, schedule(at: 10.minutes.from_now, key: buyer), and
observable :remaining.

Answering the objection where it is asked

## Why not just use with_lock? now sits in the first screen. It concedes
first, in the first sentence, because a concession is what buys the right to be
believed in the paragraph after it. Then it argues scope rather than discipline:

A lock is scoped to one transaction, on one connection, in one process. The
ticket sale above leaves that scope on one line: the hold expires in ten
minutes, and no transaction stays open for ten minutes.

Any column named expires_at, scheduled_at, or next_run_at is evidence
that the critical section already outlived the lock that was supposed to cover
it. What follows such a column is a sweeper that looks for due rows, and then
a race between that sweeper and the next writer of the same row.

Arguing "you might forget the lock" would lose that exchange, because the reader
answers "so remember" and they are right.

Also

  • ## Is it worth installing here? moves into the first screen, and says who
    should not use this, specifically. A section at line 1245 of a 1362-line file
    does not exist.
  • The Comparisons table gains the row people actually reach for: with_lock or
    SELECT ... FOR UPDATE.
  • A "Not a replacement for SQL transactions" callout is ported from the sibling
    README, which was ahead of this one on honesty, and given the reason the limit
    bites rather than only asserting it.
  • The Reactive ERB section concedes broadcast_replace_to before explaining the
    two gaps: two pushes can arrive out of order, and a push is not part of the
    save.

One bug found while verifying

The homepage example stores Time.current in actor state. Serialization
normalizes to JSON native types and raises InvalidPayload for anything else,
so that reserve never commits; the caller waits out its timeout instead:

actor invocation timed out after 5 seconds for TicketSale("event-42").reserve
message_id=1 sequence=1 status=ready waiting_on=not_yet_available

The README version stores Time.now.utc.to_i. solidobjects.dev still has the
broken line and should be corrected separately.

Validation

Every example in the changed sections was executed against this branch, not
read for plausibility:

reserve alice: true
reserve alice again: false
reserve bob: true
remaining: 98
reminders armed: 2
after expire, remaining: 99
holds: ["bob"]

bundle exec rake (test, standard, rubocop, rbs, steep, security): pass.

Two comments on r/rails, both negative, and the first one was right: a
counter is the worst possible lead example, because one line of SQL does
it. The README opened with exactly that counter, so the first thing a
skeptical reader saw was the argument against installing anything.

The lead is now the ticket sale from the homepage: 100 seats, a hold, a
ten-minute expiry that frees the seat, and a live count. It is the
smallest example that needs three things from one number, and the three
things are the actual argument. A counter needs one.

The objection now gets answered where it is asked instead of at line
1245 of a 1362-line file. A new section concedes with_lock first, then
makes the case on scope rather than discipline: a lock is scoped to one
transaction, on one connection, in one process, and any expires_at or
scheduled_at column is evidence the critical section already outlived
it. What follows that column is a sweeper, and then a race between the
sweeper and the next writer. Arguing that someone might forget the lock
would lose, because the reader answers "so remember".

A short worth-it and not-worth-it section moves into the first screen,
the Comparisons table gains the row people actually reach for, and the
reactive section concedes broadcast_replace_to before explaining where
push-after-write drops an update.

The homepage copy stored Time.current in actor state. State normalizes
to JSON native types only, so that reserve never commits. The README
version stores an integer.
The reactive paragraph claimed the fragment is re-rendered once per
change from the saving turn. The saving turn inserts the broadcast row in
the same transaction as the state change, which is the part that answers
push-after-write. Delivery is a separate worker that retries, so "once
per change" promised something the runtime does not.

The claim now matches docs/correctness.md: the row retries until it
succeeds, Cable ignores an older (instance_id, state_revision) pair after
a newer one, and the guarantee is that a viewer cannot end up on an older
number.

Greptile raised this on the JavaScript counterpart. The same sentence was
wrong here.
The README had become a manual pasted into the front door: 1466 lines,
with sections that docs/ already covers in more depth than the README
did. Length is not thoroughness. A reader who bounces at screen three
never reaches the reference material that justified the length.

Removed what docs/ owns outright. The dashboard section restated
docs/dashboard.md, and Reactive ERB restated docs/realtime.md for 167
lines, so both are now the argument, the smallest working example, and a
link. The Cloudflare capability subsections collapse to one paragraph
each, keeping the mapping table and the Solid Queue ordering comparison,
which live nowhere else.

Removed what the README said twice. "When to use it" and the new "Is it
worth installing here?" were the same section 1100 lines apart; the
early one now carries the domain list. Invoking an object explained
per-adapter lock deadlines that docs/correctness.md documents properly.

Removed what was no longer true. The Status list claimed "Implemented
and tested in 0.4" at version 0.14.1 and said the supervisor does not
replace a crashed role, which docs/roadmap.md lists as implemented.

Kept every fact that has no other home: the keyed-alarm section, the
configuration defaults table, the CLI worker-count flags, connects_to
for a separate actor database, the keyword-disambiguation rule,
InvalidRejectionCode, and the turbo-rails dependency.
The README was still twice the size of its JavaScript sibling at 1175
lines, and the difference was not extra capability. It was reference
material sitting on the front door: every reminder rule, every
configuration default, every CLI flag, and two registration examples,
all of which a reader passes on the way to deciding whether to install
anything.

Moved rather than deleted, because most of it had no other home.
docs/reminders.md is new and holds the keyed-alarm rules, the composed
name limit, the one-alarm-for-a-whole-queue drain, and reconciliation
for self-scheduling actors; docs/architecture.md now links there instead
of into a README heading. docs/operations.md gains the configuration
defaults table, the worker-count flags, the upgrade sequence, the
RuboCop exclusion, the Sorbet note, and the extension-component
contract. docs/architecture.md gains register_effect and
register_commit_action with their signatures.

Deleted only what was said twice. The table of contents duplicated the
headings GitHub already renders. A second example actor demonstrated
what the ticket sale demonstrates. Actor identity was three paragraphs
that fit in one, inside the section that defines an actor.

The README is now 528 lines and keeps what only it can say: the ticket
sale, the lock argument, who should not install this, the Durable
Objects mapping, and one worked example per capability with a link to
the guide behind it.
The lead actor declared observable :remaining and the ERB beside it
rendered that value in a span while a component observed :holds. Neither
worked. A scalar span raises ArgumentError unless the observable is
declared broadcast: :value, and a component dependency must itself be a
declared observable or ComponentRegistration raises
UnknownComponentDependency.

remaining now carries broadcast: :value and holds is declared, which is
also the clearer teaching example: one observable opts into sending its
value to every authorized subscriber, the other only invalidates, which
is what the paragraph under it describes.

Verified against this branch rather than read: broadcasts_observable_value?
is true for remaining, holds resolves as a dependency, and reserve,
duplicate reserve, and expire behave as the text claims.
Scored both READMEs against the Prana et al. content categories, the
standard-readme spec, and the popularity correlation study. Three gaps
were real here.

There was no CONTRIBUTING.md and nothing linked to one. Contribution
guidance is the feature both the spec and the popularity study call out,
the sibling repository already had the file, and Development explained
how to run tests without saying what a change must show. The new file
covers setup, the gates, the failing-test-first rule this project
already enforces in review, and where to report a vulnerability.

The table of contents came back. The spec requires one above 100 lines,
and this is 580 with 21 sections. Removing it earlier traded a rule for
fifteen lines on the theory that GitHub renders an outline, which is
true and is also not what a reader arriving from a package page sees.

Two enumerations went back to being lists. The cases a lock cannot reach
and the guarantees this does not promise are both enumerations, and the
study found list-organized READMEs correlate with adoption. Flattening
them into paragraphs during the trim cost scannability for no real
length saving.
The section answering the lock objection was titled after with_lock, so
it read as being about one Rails method. The objection is broader than
that method, and the heading now says transactions. The body still names
with_lock, because that is what a Rails reader reaches for.

"What an actor replaces" was ambiguous in a document that also discusses
Orleans grains and Durable Objects. It now says a Solid Objects actor.

The not-worth-it list ended at high-QPS reads and hot identities without
saying where such a reader should go. Solid Objects Pro is the
commercial performance layer for this gem, so the list now names it and
what it adds: grouped commits, optional ephemeral operations, and
materialized projections, described in the terms the product uses.
The README is what rubygems.org renders, and docs/ ships inside the gem,
so this work does not reach a reader until a release goes out. Published
0.14.1 still leads with the counter example, answers the lock objection
nowhere, and carries a reactive example that cannot run.

Patch, not minor: the runtime is untouched and every change here is
prose, a new guide, or a new CONTRIBUTING file.
@cardmagic
cardmagic merged commit 3cf4588 into main Aug 25, 2026
40 checks passed
@cardmagic
cardmagic deleted the docs/lead-with-ticket-sale branch August 25, 2026 19:30
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