fix(sidekiq): repair the worker boot, make the deploy check real, date usage from the request#386
Merged
Merged
Conversation
…e usage from the request
Root cause of the frozen usage ledger, found in the EB log bundle. Sidekiq had
not run since 2026-07-22 23:25 UTC (~27h), with 141 jobs queued and 8 dead, while
the site served normally.
1. THE BOOT FAILURE. start-sidekiq.sh hardcoded
vendor/bundle/ruby/3.4.0/bin
The platform moved to Ruby 4.0, that directory stopped existing, and Sidekiq
resolved through system gems where the pinned bundler is absent:
"Could not find 'bundler' (~> 2.5) - did find: [bundler-4.0.10]"
sidekiq.log contains nothing else, on repeat, because Restart=always retried
forever. Puma was unaffected — EB launches it via the platform's own Ruby
setup, not this wrapper — which is exactly why the outage was invisible from
the outside.
Now resolves the vendored bundle for whatever ABI is present, installs the
Gemfile.lock-pinned bundler on demand (--user-install, no privileges needed),
and uses `bundle exec` instead of the binstub that depends on that pin.
2. WHY IT HID FOR A DAY. 99_verify_sidekiq.sh only PRINTED diagnostics: every
check ended in `|| echo`, and the file ended on a succeeding `ps`, so it always
returned 0. It reported SUCCESS on every deploy while the worker was dead —
including 01:18 on 2026-07-24. Verified against the old file: with Sidekiq
absent it exits 0; the new one exits 1.
It now polls for up to 45s, requires the unit active AND a live worker process,
re-checks after 5s so a Restart=always crash-loop cannot pass mid-bounce, and
FAILS the deploy otherwise. A release that leaves the worker dead should not be
reported healthy.
3. QUEUE LAG REWROTE HISTORY. usage_events has only created_at, the job never set
it, and the enqueue passed no timestamp — so a late job is dated from when it
was processed. Draining the 141-job backlog would have stamped all of them with
the recovery moment: a false spike on one day, a permanent hole on the days the
work happened. Both enqueue sites now pass an occurred-at epoch and the job
dates the row from it.
The new argument is LAST and optional so the 141 jobs already serialized with
the old arity keep working when this deploys — pinned by a spec.
Verified: 973 examples, 0 failures. The dating fix is mutation-checked (reverting
to insert-time dating fails). The ai_spec assertion checks the timestamp is a
plausible "now" rather than kind_of(Integer), since a wrong integer would satisfy
a type check while misdating every row.
NOT verified on the instance — I have no access. The launcher change is reasoned
from the log bundle (Ruby 4.0.0 in GEM_PATH vs the hardcoded 3.4.0). Fix 2 is
what makes that safe: if the launcher is still wrong, the next deploy now fails
loudly instead of pretending.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR addresses a production incident where Sidekiq silently stopped running after an Elastic Beanstalk Ruby ABI upgrade, deploys incorrectly reported success, and queued usage jobs would have been mis-dated when eventually processed.
Changes:
- Make Sidekiq startup resilient to Ruby ABI upgrades and bundler-version pinning.
- Make the EB postdeploy Sidekiq verifier actually fail the deploy when the worker isn’t truly running.
- Thread an “occurred-at” timestamp from request time into
RecordUsageJobso ledger rows are dated from the request, not processing time (with specs pinning backward compatibility).
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
spec/requests/api/levelcode/v1/ai_spec.rb |
Tightens the enqueue assertion to ensure an occurred-at epoch close to request time is passed. |
spec/jobs/record_usage_job_spec.rb |
Adds coverage for backdated created_at behavior and legacy-arity fallback. |
app/jobs/record_usage_job.rb |
Accepts optional occurred-at epoch and uses it to set UsageEvent.created_at on insert. |
app/controllers/api/levelcode/v1/ai_controller.rb |
Passes request-time epoch into RecordUsageJob.perform_async at both enqueue sites. |
.platform/hooks/postdeploy/99_verify_sidekiq.sh |
Replaces “print-only” diagnostics with a deploy-failing Sidekiq readiness check. |
.platform/files/start-sidekiq.sh |
Removes hardcoded ABI path and attempts to install the lockfile-pinned bundler version before launching Sidekiq. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…IVE ruby (PR #386 review) All three review points were real, and the first one defeated the whole purpose of this PR. 1. SELF-MATCHING CHECK. The verifier used `pgrep -f "sidekiq"` — and the script is itself named 99_verify_sidekiq.sh, so its own command line matches. Demonstrated: pgrep -f sidekiq -> bash .../99_verify_sidekiq.sh So the "worker process" test could be satisfied by the test, and a crash-loop that briefly reported active would sail through. I had shipped a check that could pass on nothing — the exact failure mode this PR exists to remove. My local run only exited 1 because macOS has no systemctl, which short-circuited before pgrep ever ran; the bug was invisible off-instance. Now asks systemd for MainPID and confirms that pid is alive. There is nothing to mis-match: it is systemd's own view of the process it started. No live pgrep remains. 2. STALE ABI CAN STILL BE PICKED. Globbing vendor/bundle/ruby/*/bin takes the first directory on disk, and a leftover 3.4.0 sorts before a new 4.0.0 — which would hand the new interpreter the old gemset and walk straight back into the boot failure. Now resolves the ABI of the ruby actually being run (RbConfig ruby_version) and uses that directory, falling back to "any present" with a warning only if it is missing. 3. STALE HEADER. The "Enqueued as:" comment omitted occurred_at_epoch. Updated, including the note that the trailing args are optional so jobs serialized by an older deploy still run. Verified: 973 examples, 0 failures; both scripts parse; the verifier still exits 1 when sidekiq is absent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.
Root cause of the frozen usage ledger, found in the EB log bundle. Sidekiq had not run since 2026-07-22 23:25 UTC (~27h), with 141 jobs queued and 8 dead, while the site served normally.
1. The boot failure
start-sidekiq.shhardcodedvendor/bundle/ruby/3.4.0/bin. The platform moved to Ruby 4.0, that directory stopped existing, and Sidekiq resolved through system gems where the pinned bundler is absent:sidekiq.logcontains nothing else, on repeat —Restart=alwaysretried forever. Puma was unaffected because EB launches it through the platform's own Ruby setup rather than this wrapper, which is precisely why the outage was invisible from the outside.Now resolves the vendored bundle for whatever ABI is present, installs the
Gemfile.lock-pinned bundler on demand (--user-install, no privileges needed), and usesbundle execinstead of the binstub that depends on that pin.2. Why it hid for a day
99_verify_sidekiq.shonly printed diagnostics. Every check ended in|| echo, and the file ended on a succeedingps, so it returned0regardless. It reported SUCCESS on every deploy while the worker was dead — including 01:18 on 24 Jul.Verified against the old file, with Sidekiq absent:
It now polls up to 45s, requires the unit active and a live worker process, re-checks after 5s so a
Restart=alwayscrash-loop can't pass mid-bounce, and fails the deploy otherwise.A check that cannot fail is not a check.
3. Queue lag rewrote history
usage_eventshas onlycreated_at, the job never set it, and the enqueue passed no timestamp — so a late job is dated from when it was processed. Draining the 141-job backlog would have stamped all of them with the recovery moment: a false spike on one day, a permanent hole on the days the work happened.Both enqueue sites now pass an occurred-at epoch and the job dates the row from it. The new argument is last and optional so the 141 jobs already serialized with the old arity keep working when this deploys — pinned by a spec.
Verification
973 examples, 0 failures. The dating fix is mutation-checked (reverting to insert-time dating fails). The
ai_specassertion checks the timestamp is a plausible now rather thankind_of(Integer)— a wrong integer would satisfy a type check while misdating every row.Not verified on the instance — I have no access. The launcher change is reasoned from the log bundle (
GEM_PATH=…/ruby/4.0.0vs the hardcoded3.4.0). Fix 2 is what makes that safe: if the launcher is still wrong, the next deploy fails loudly instead of pretending.After deploying
🤖 Generated with Claude Code