docs: lead with the case a lock cannot cover - #54
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
The first is one line of SQL. The second is an
expires_atcolumn plus a cronjob. 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), andobservable :remaining.Answering the objection where it is asked
## Why not just use with_lock?now sits in the first screen. It concedesfirst, 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:
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 whoshould not use this, specifically. A section at line 1245 of a 1362-line file
does not exist.
with_lockorSELECT ... FOR UPDATE.README, which was ahead of this one on honesty, and given the reason the limit
bites rather than only asserting it.
broadcast_replace_tobefore explaining thetwo 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.currentin actor state.Serializationnormalizes to JSON native types and raises
InvalidPayloadfor anything else,so that
reservenever commits; the caller waits out its timeout instead:The README version stores
Time.now.utc.to_i. solidobjects.dev still has thebroken line and should be corrected separately.
Validation
Every example in the changed sections was executed against this branch, not
read for plausibility:
bundle exec rake(test, standard, rubocop, rbs, steep, security): pass.