Skip to content

fix: take the level-check unique lock after the transaction - #1252

Open
rahul05ranjan wants to merge 2 commits into
dev-null-sh:productionfrom
rahul05ranjan:fix-check-user-level-lock-after-commit
Open

rahul05ranjan wants to merge 2 commits into
dev-null-sh:productionfrom
rahul05ranjan:fix-check-user-level-lock-after-commit

Conversation

@rahul05ranjan

@rahul05ranjan rahul05ranjan commented Oct 28, 2026 •

Copy link
Copy Markdown

Summary

Fixes #1066.

CreateCommit runs in a DB::transaction and dispatches CheckUserLevel with ->afterCommit(). CheckUserLevel is ShouldBeUnique, and the unique-job lock is acquired at dispatch time, inside the transaction, on the database cache store. When the lock is already held (previous check for the same user still queued or running), DatabaseLock::acquire fails the INSERT and falls back to an UPDATE — but on Postgres the failed INSERT has aborted the enclosing transaction, so the UPDATE dies with 25P02 and the whole create rolls back. The user's second approved commit inside the lock window returns 500 and is lost, exactly as reported.

The star path already solved this: CreateCommitStar takes the same job's unique lock in DB::afterCommit, outside the transaction (app/Actions/CreateCommitStar.php:45), and CommitStarControllerTest has a regression test for posting a star while the lock is held.

Changes

  • app/Actions/CreateCommit.php: dispatch(new CheckUserLevel($user))->afterCommit() becomes DB::afterCommit(fn () => dispatch(new CheckUserLevel($user))). The lock INSERT (and the duplicate-INSERT fallback UPDATE) now run after the commit, on their own statements, so a held lock can no longer abort the create. DB::afterCommit executes immediately when no transaction is active, so the behavior outside a transaction is unchanged.

The job keeps its ShouldBeUnique semantics: when the lock is held, acquire() returns false after the fallback UPDATE and the second check is dropped as before — the level is looked at once the burst settles, which is the job's documented purpose.

Validation

  • Static trace on the HEAD of production: CreateCommit is the only remaining dispatch site of CheckUserLevel inside a transaction (the star path moved in CreateCommitStar.php:45; UpdateCommitStatuses — the other afterCommit + CheckUserLevel site — was checked too); of the other jobs dispatched in this transaction only ReplyAsTom is ShouldBeUnique, and its uniqueId is the commit id, which is new per create, so it cannot collide and never triggers the duplicate-INSERT path.
  • Python mirror of the exact Postgres semantics (unique violation aborts the transaction; the fallback UPDATE then hits 25P02): HEAD → create rolls back with the 500 from the report; with the DB::afterCommit wrap → commit persists, the lock INSERT fails at statement level, the fallback UPDATE succeeds, acquire() returns false and the job is dropped as a duplicate. Output in the notes below; no PHP runtime was available on this machine.
  • New Pest regression test creates a commit while a unique level-check job already holds a database cache lock in tests/Feature/Controllers/CommitControllerTest.php, mirroring the existing star test line for line (admin poster so the commit is approved and the check is dispatched, cache.default set to database, Cache::lock(...)->get() to hold the unique key, DatabaseTransactionsManager swap so after-commit callbacks stay pending until the test's outer transaction). It fails on HEAD with the 500 and passes with the wrap. Not run locally — PHP is not available on this machine; hosted CI (tests.yml) is the run.

Independent review

Agent-created PR. No independent reviewer was available for this revision; recorded verification is my own static trace and the Postgres-semantics mirror above. Hosted CI plus a maintainer review are requested before merge.

Limitations

  • If the process dies between the commit and the after-commit callback, the level check is not dispatched; the next XP-earning action (post, star, …) dispatches it. This is the same tradeoff CreateCommitStar already accepts.
  • DB::afterCommit defers to the outermost transaction, so a future caller that opens a transaction around CreateCommit gets the same deferred behavior — noted here so the semantics are explicit.

Summary by CodeRabbit

  • Bug Fixes
    • Commit requests now complete successfully when a user-level check is already locked, rather than failing because of the existing check.
    • In this case, the commit is still created and the request redirects as expected.

rahul05ranjan and others added 2 commits October 28, 2026 09:26
Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com>
Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 745c742b-6547-4fdb-bf76-19fd39b5aa42
Commits

Reviewing files that changed from the base of the PR and between fa8d540 and 9579b10.

Files selected for processing: 2
  • app/Actions/CreateCommit.php
  • tests/Feature/Controllers/CommitControllerTest.php

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 8 remain after this review.

Walkthrough

CreateCommit now registers CheckUserLevel dispatch to run after the database transaction commits. A feature test holds the job’s unique lock and verifies that the commit request still redirects and creates a commit.

Changes: Commit dispatch

Layer / File(s) Summary
After-commit dispatch and regression test
app/Actions/CreateCommit.php, tests/Feature/Controllers/CommitControllerTest.php
CreateCommit registers the CheckUserLevel dispatch with DB::afterCommit. The test holds the user-specific unique-job lock and verifies that the commit request redirects and creates a commit.

Priority: Normal

Estimated code review effort: 2 (Simple) | ~10 minutes

Change: Bug fix · Severity of issue fixed: Medium

Possibly related PRs

  • dev-null-sh/dev-null.sh#1136: This PR calls CreateCommit inside a scheduled-submission transaction, which uses the same commit path changed here.

Suggested reviewers: hbenabdallah, xheaven

Merge Risk: Minimal · up to 9579b

Creating a commit no longer fails or rolls back when a level-check job lock is already held, because the job is now dispatched only after the transaction commits. The only noted tradeoff is that if the process dies between commit and callback, the level check waits for a later XP-earning action. The regression test was not run locally, so confirm hosted CI passes before merging.

Security architecture risk: Low · up to 9579b

This is a narrowly scoped transaction-lifecycle correction. The authenticated user, approval rules and per-user job identity remain unchanged. Recovery after a post-commit dispatch failure remains unverified.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The changed scheduling decision remains scoped to the creating user's level-check identity and notification. The changed call does not introduce a caller-selected job owner or expand cross-user authority.

Trust Boundaries and Controls

  • observed — The web commit-creation route remains inside the auth middleware group and retains posting-timeout, commit-rules and commit-throttling middleware. The controller supplies the current authenticated User to the action; the PR does not modify these boundary declarations.

Resilience and Maintainability Implications

  • inferred — Moving dispatch after commit prevents this callback from acquiring a unique lock on a rolled-back create and separates lock contention from commit persistence. Conversely, interruption or dispatch failure after commit cannot undo the saved content and may omit its notification check. Queue execution was already post-commit in the base; the evidence does not establish this as a newly introduced security regression or prove durable recovery.
Pre-merge checks: Passed 5
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly and concisely describes the main change: moving acquisition of the level-check unique lock until after the transaction.
Linked Issues check Passed Issue #1066 requires the second approved commit to avoid the PostgreSQL unique-lock failure and transaction rollback. CreateCommit now registers dispatch(new CheckUserLevel($user)) with `DB::after…
Out of Scope Changes check Passed The changed production code and the new test directly address Issue #1066. The comments document the PostgreSQL failure mechanism. No unrelated production behavior or unrelated test scope appears in t…
Docstring Coverage Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentat…
Full details: Linked Issues check

Explanation

Issue #1066 requires the second approved commit to avoid the PostgreSQL unique-lock failure and transaction rollback. CreateCommit now registers dispatch(new CheckUserLevel($user)) with DB::afterCommit, so lock acquisition runs after the commit. The new regression test holds the database cache lock and verifies a redirect plus persisted commit creation. The change keeps CheckUserLevel unique-job behavior.

Full details: Out of Scope Changes check

Explanation

The changed production code and the new test directly address Issue #1066. The comments document the PostgreSQL failure mechanism. No unrelated production behavior or unrelated test scope appears in the reviewed diff.

Full details: Docstring Coverage

Explanation

Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 1 functions across 2 files.

Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@arifhossen-dev

Copy link
Copy Markdown

Thanks for picking this up! I tested this locally (macOS, PHP 8.5, Postgres 18), merged onto the current production (704f8c8). Two things I noticed:

1. The new test passes without the fix. I put CreateCommit.php back to the production version (dispatch(new CheckUserLevel($user))->afterCommit()) and kept your test. creates a commit while a unique level-check job already holds a database cache lock still passes, so it doesn't catch the regression in this test setup. I didn't dig into why.

2. The moderation path has the same bug. UpdateCommitStatuses still dispatches CheckUserLevel with ->afterCommit() inside its DB::transaction (app/Actions/UpdateCommitStatuses.php:100). Approving a commit whose author still has a check queued can abort the same way.

There's also an earlier PR for the same issue, #1075, which covers both paths. Its tests fail without the fix and pass with it, though it needs a small update for the username → handle rename (details on #1075). It might be worth comparing the two so only one gets merged.

To reproduce point 1:

gh pr checkout 1252
git fetch https://github.com/dev-null-sh/dev-null.sh production
git checkout FETCH_HEAD -- app/Actions/CreateCommit.php
vendor/bin/pest tests/Feature/Controllers/CommitControllerTest.php --filter="unique level-check"

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.

Second approved commit 500s while CheckUserLevel still holds its unique-job lock

2 participants