Skip to content

docs: add four tested problem guides - #87

Merged
cardmagic merged 5 commits into
mainfrom
docs/problem-guides
Oct 9, 2026
Merged

cardmagic merged 5 commits into
mainfrom
docs/problem-guides

Conversation

@cardmagic

Copy link
Copy Markdown
Owner

Why

Developers search for a problem ("Rails race conditions", "Sidekiq jobs in order per user", "reserve tickets for 15 minutes", "after_commit job lost"), not for a virtual actor library. These guides answer those problems. Each one shows the plain Rails fix first and recommends Solid Objects only where it adds value. They will be published at /ruby/race-conditions, /ruby/ordered-jobs, /ruby/expiring-reservations, and /ruby/transactional-outbox on solidobjects.dev.

What changed

  • docs/guides/race-conditions.md, "Prevent race conditions in Rails".
    • A decision table, then unique indexes, pessimistic locking, and optimistic locking.
    • A ticket-hold example that grows from a reproduced race, to a one-statement SQL fix, to one actor that owns the event. The actor covers holds, confirmation, expiry, retries, a restart, and a stale expiry.
    • A section that says plainly that serial execution does not stop a stale form, and shows a revision check.
  • docs/guides/ordered-jobs.md, "Run jobs in order for each customer in Rails".
    • The out-of-order failure.
    • A fair comparison of a Sidekiq capsule with concurrency 1, Solid Queue limits_concurrency, and a sequence number with a row lock.
    • One actor for each account.
  • docs/guides/expiring-reservations.md, "Expiring reservations in Rails".
    • The stock goes under the identity that owns it: an actor for each reservation does not stop overselling.
    • The plain SQL design that needs no timer, and when a timer is necessary.
    • Hold, extend, confirm, and expire, with reminders that move and survive a restart.
  • docs/guides/transactional-outbox.md, "Save state and queue work together in Rails".
    • The lost job between commit and enqueue, and the plain outbox pattern with Rails Event Store cited.
    • The atomic boundary of an actor turn: state, a commit action, and an effect.
    • A section that says what Solid Objects does not do. It does not wrap ordinary Active Record writes.
  • The README and docs/agents.md list the guides.
  • Version 0.17.3 and its changelog section.

Code and tests

Every code block in a guide is a file under examples/guides/. The tests in test/guides/ (43 runs) prove each claim the guides make.

test/guides/guide_documents_test.rb fails in three cases: a guide does not embed the current example files verbatim (without the rbs_inline header line), a guide contains U+2013 or U+2014, or a relative link points to a missing file.

Observed failures.

  • Red first: each test file was run before its examples existed and failed with LoadError: cannot load such file -- .../examples/guides/.... The document test failed with Errno::ENOENT before the guides existed.
  • Removing each guard once: each example guard was removed once, and a test failed every time. The guards were:
    • the conditional update, the hold and confirm retry guards, the stale expiry checks, the revision check, and the reminders;
    • the out-of-sequence raise, the repeated-sequence skip, retry_on, and the entry dedupe;
    • reject (replaced by a raise), the seat check, the extension reschedule and limit, and unschedule;
    • the outbox transaction, the relay mark, the job idempotency key, the place guard, and the effect idempotency key.
  • Two tests that passed without their guard were rewritten:
    • The conditional-update test let thread timing hide the race. It now loads every copy first and releases all threads at a barrier.
    • The hold retry test now checks that a retried hold reports success.
  • A PostgreSQL deadlock, found and fixed: on PostgreSQL with the default pool of 5, the first concurrency helper failed with could not obtain a connection from the pool within 5.000 seconds. The helper now limits the threads that act at once to the free connections. Each file passed on a fresh local PostgreSQL 17 database. MySQL was not run locally; CI covers it.

Dependency change

activejob joins the development and test group. Compatibility runs pin it with ~> RAILS_VERSION.0, and bundle lock --update --print resolves 7.1.6 and 8.0.5.1 for RAILS_VERSION=7.1 and 8.0. The default lock adds activejob 8.1.3.1 and globalid 1.4.0. No other gem changes. The runtime gemspec is unchanged.

Copy

Codex CLI with GPT-6 Astra (codex exec -m gpt-6-astra, read-only sandbox) wrote the prose from four briefs. The briefs held the outline, the verified facts with sources (checked October 9, 2026), and the ASD-STE100 and no-dash rules. Codex wrote {{EMBED path}} placeholders, and a script replaced them with the exact tested files. Every claim was checked against docs/correctness.md, docs/architecture.md, docs/operations.md, docs/reminders.md, and the cited pages.

Hand edits to the Codex copy.

  1. expiring-reservations.md: "An actor for each reservation does not stop two reservations from the same stock." became "An actor for each reservation cannot prevent an oversold show. Two reservations can take the same stock." The verb was missing.
  2. All four guides: "## Related reading" became "## More information", because STE does not use an -ing form.
  3. race-conditions.md: "SQL retains pending work" became "SQL keeps the unfinished work".

Citations, all checked October 9, 2026:

  • Rails Guides on uniqueness validation and on enqueue_after_transaction_commit.
  • The Rails API pages for pessimistic and optimistic locking.
  • The Solid Queue README section on concurrency controls.
  • The Sidekiq wiki section on capsules.
  • The Rails Event Store outbox documentation.

Effects

  • API, correctness, migration: none. These are documentation, examples, and tests only.
  • Security: none. The guides keep deny-by-default authorization and tell the reader to pass authorization_context:.
  • Compatibility: the examples target Ruby 3.3+ and Rails 7.1+.

Validation

  • bundle exec rake: 916 runs, 0 failures, 0 errors, 28 skips. Standard Ruby and RuboCop found no offenses in 301 files. RBS validates, Steep found no type errors, and Brakeman found no warnings.
  • bundle exec ruby -Itest test/guides/<file> for each guide test against SOLID_OBJECTS_DATABASE_URL=postgresql://localhost/solid_objects_guides_test (a fresh database for each file): every file passed.

Developers search for a problem, not for a virtual actor library. Four
guides now start from one: race conditions in Rails, running jobs in
order for each customer, expiring reservations, and saving state and
queuing work together. Each one reproduces the failure, shows the plain
Rails fix first (a unique index, a conditional update, row and
optimistic locks, a sequence number, Sidekiq capsules, Solid Queue
concurrency limits, a transactional outbox), and only then shows an
actor where ordering, durable state, reminders, and recovery are needed
together. Codex with GPT-6 Astra wrote the prose from briefs that held
only verified facts and cited sources.

Every code block in a guide is a file under examples/guides/, and the
tests in test/guides/ prove each claim. Each test was watched to fail
first, and each guard in the examples was removed once to confirm that
a test fails without it. A document test fails when a guide does not
embed the current example files, contains an em dash or an en dash, or
links to a missing file.

The concurrency helper limits the threads that act at once to the free
connections in the pool. On PostgreSQL with the default pool of five,
ten waiting synchronous callers otherwise held every connection and the
actor turn could not get one ("could not obtain a connection from the
pool within 5.000 seconds").

Active Job joins the development and test bundle for the job examples,
pinned to the Rails line in compatibility runs.
@context7

context7 Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Docs7 for cardmagic/solid-objects-ruby

Result Status Action
Deployment ➖ Not used —
Content review ❌ Needs attention. 1 problem remains. View findings

Commit 8f077a1

1 finding is inline on the changed files.

@greptile-apps

greptile-apps Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[Low impact] The PR appears safe to merge; the stale expiry finding is fixed, and no new blocking issue was found.

Summary

Adds four Rails problem guides with matching examples and tests.

  • Rails developers can compare simple race fixes with an actor-run ticket lifecycle.
  • Rails developers can compare job-order options and follow one account's entries in order.
  • A show actor keeps seat holds, confirmations, and expiry reminders together.
  • Rails developers can compare an outbox with an actor turn that saves state and shipment work together.
  • Project listings and test setup now include the new guides.

No new actionable issues were found. Previous comment 1 is fully addressed.

Reviews (3) · Last reviewed commit: "ci: pull database images from the ECR Pu..." · Reviewed by Greptile

Comment thread examples/guides/expiring_reservations/seat_inventory.rb Outdated
Comment thread examples/guides/expiring_reservations/seat_inventory.rb
Comment thread examples/guides/ordered_jobs/ledger_account.rb Outdated
Comment thread docs/guides/ordered-jobs.md Outdated
Review found three gaps in the guide examples, and Rails 7.1 and 7.2
found two in the tests.

- EventTickets and SeatInventory now store each deadline and check it in
  the operation, so a hold past its deadline cannot be confirmed or
  extended and frees its seats even when the expiry reminder has not
  run. The reminder only cleans up. New tests travel past the deadline
  without delivering a reminder; both failed before the change.
- SeatInventory rejects a seat count that is not a positive integer. A
  hold for -5 seats had added five seats to the show.
- LedgerAccount kept the last 100 entry IDs in state, so a repeated
  entry after 100 newer entries changed the balance again. Each entry
  now writes a ledger_entries row through a commit action in the same
  transaction, with a unique index, and apply checks that row first. A
  test applies 102 entries and repeats the first; it failed before.
- On Rails 7.1 and 7.2, ActiveJob::TestHelper ignores a queue adapter
  assigned inside the test, so the lost-job test raised nothing. It now
  replaces perform_later for that test.
- On Rails 7.1 SQLite, two workers that registered their processes at
  the same time raised "database is locked". The test now creates both
  workers before the threads start.
- The Sidekiq snippet no longer carries the commented-out weighted
  line.

Codex with GPT-6 Astra revised the three affected guides from change
lists, and the guides embed the new example files.
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai Please review the latest commit, bc462f4. It addresses each finding:

  1. Expired holds still succeed: fixed in both actors. EventTickets and SeatInventory store each deadline. confirm and extend_hold accept only a hold whose deadline is in the future, and an expired hold frees its seats before the reminder runs. The reminder only cleans up. New tests use travel past the deadline and deliver no reminder. Both failed before the change.
  2. Negative holds inflate stock: fixed. hold rejects a seat count that is not a positive integer with invalid_seats. A new test covers 0 and -5. It failed before the change.
  3. Older entries change balances twice: fixed with a durable record. Each entry writes a ledger_entries row through the commit action record_ledger_entry, in the same transaction as the balance. The table has a unique index on account and entry. apply checks LedgerEntry.exists? first, which is safe because calls for one account run one at a time. A new test applies 102 entries and then repeats the first. It failed before the change. The guide now says the 64-key idempotency window is not enough for money.
  4. Unused configuration remains: removed the commented-out weighted line from the Sidekiq snippet.

The commit also fixes two Rails 7.1 and 7.2 test failures from CI:

  • The lost-job test now replaces perform_later, because ActiveJob::TestHelper on Rails 7.1 and 7.2 ignores an adapter assigned inside the test.
  • The two-worker test creates its workers before the threads start, so two process registrations no longer race on SQLite.

Validation:

  • bundle exec rake: 920 runs, 0 failures, 28 skips. No offenses, no type errors, no Brakeman warnings.
  • The guide tests pass on a fresh local PostgreSQL 17 database and on Rails 7.1.6 with SQLite. The ordered-jobs test passed 5 of 5 times on 7.1.

Comment thread examples/guides/race_conditions/event_tickets.rb Outdated
A retry with the same buyer and hold ID after the deadline creates a
fresh hold. When the scheduler had already queued the old expiry, that
expiry removed the fresh hold, because expire checked only the hold ID.
The reminder now passes the stored deadline, and expire releases a seat
only when the hold ID and the deadline both match, as SeatInventory
does. A new test retries after the deadline and then delivers the old
expiry; with the deadline check removed, it fails.
GitHub runners hit the Docker Hub pull rate limit for anonymous pulls
(toomanyrequests) on the Redis, PostgreSQL, and MySQL service containers.
The ECR Public mirror serves the same official images without that
limit.
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai Please review the latest commit, 8f077a1.

  • Old expiry cancels new hold (8919831): fixed as you suggested. The reminder passes the stored deadline, expire(buyer:, hold_id:, expires_at:), and expire releases a seat only when the hold ID and the deadline both match, as in SeatInventory. A new test retries with the same hold ID after the deadline, then delivers the old expiry, and the fresh hold stays. With the deadline check removed, the test fails. The guide text and its test list describe this.
  • 8f077a1 moves the CI service images to the ECR Public mirror (public.ecr.aws/docker/library/redis:7, postgres:18, and mysql:8.4), because GitHub runners hit the Docker Hub pull rate limit.

Validation: bundle exec rake ran 921 runs with 0 failures and 28 skips. Standard Ruby, RuboCop, Steep, and Brakeman are clean.

- **Retries:** Two identical holds and two identical confirmations sell one seat.
- **Confirmation after expiry:** The actor rejects the confirmation with the code `no_hold`.
- **Past the deadline, before the reminder runs:** The test moves the clock 11 minutes forward and delivers no reminder. The actor rejects the confirmation with `no_hold`, and another buyer holds the seat.
- **A retry with the same hold ID after the deadline:** the test moves the clock 11 minutes forward. The retry creates a fresh hold, and then the old expiry arrives. The fresh hold stays.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Grammar and spelling: After the bold colon the sentence starts with a lowercase 'the'; capitalize it like the other bullets.

@cardmagic
cardmagic merged commit 8c9f0f9 into main Oct 9, 2026
44 checks passed
@cardmagic
cardmagic deleted the docs/problem-guides branch October 9, 2026 21:26
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