Skip to content

fix(repo_store): use SHA-256 for stable advisory-lock key (#210)#215

Open
Gravirei wants to merge 3 commits into
Gitlawb:mainfrom
Gravirei:fix/issue-210-stable-advisory-lock-key
Open

fix(repo_store): use SHA-256 for stable advisory-lock key (#210)#215
Gravirei wants to merge 3 commits into
Gitlawb:mainfrom
Gravirei:fix/issue-210-stable-advisory-lock-key

Conversation

@Gravirei

@Gravirei Gravirei commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Replace DefaultHasher with SHA-256 for the Postgres advisory-lock key so the same (owner_slug, repo_name) produces the same i64 across all Rust toolchain versions, OSes, and machines — fixing silent cross-machine write exclusion breakage.

Motivation & context

Closes #210

Kind of change

  • Bug fix

What changed

  • gitlawb-node: advisory_lock_key() in repo_store.rs now uses sha2::Sha256 instead of std::collections::hash_map::DefaultHasher (not guaranteed stable across Rust releases).
  • gitlawb-node: acquire_write doc comment scoped to clarify cross-machine guarantee holds only on shared Postgres.
  • gitlawb-node: Golden-value unit test pins the key for a known input so CI catches accidental algorithm changes.

How a reviewer can verify

cargo test -p gitlawb-node -- git::repo_store::tests::advisory_lock_key_is_stable

Before you request review

  • Scope is one logical change; no unrelated churn
  • cargo test --workspace passes locally (relevant tests pass)
  • New behavior is covered by tests
  • cargo fmt --all and cargo clippy --workspace --all-targets -- -D warnings are clean
  • Commit titles use Conventional Commits (fix(...))

Protocol & signing impact

N/A — internal implementation detail, no protocol change.

Notes for reviewers

Changing the algorithm re-keys every node. During a mixed-version upgrade window, an in-flight lock held under the old key will not exclude a new-key caller, so this needs a staged rollout (or an accepted brief window), not a hot swap.

Summary by CodeRabbit

  • Bug Fixes
    • Improved repository write locking to retain the same PostgreSQL connection for advisory-lock acquisition and release.
    • Ensured advisory locks are explicitly unlocked when write acquisition fails during repository preparation/fallback paths.
    • Made advisory-lock keys deterministic (SHA-256 of owner + repo), and ensured locks are released when the guard is dropped.
  • Documentation
    • Updated the changelog with “Unreleased” known caveats and rolling-upgrade guidance for the lock-key change.

Copilot AI review requested due to automatic review settings July 17, 2026 16:04

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e98fa845-501e-465a-892d-4611f2393578

📥 Commits

Reviewing files that changed from the base of the PR and between c27b355 and 26c4dfe.

📒 Files selected for processing (1)
  • crates/gitlawb-node/src/git/repo_store.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/gitlawb-node/src/git/repo_store.rs

📝 Walkthrough

Walkthrough

Repository advisory locks now retain their acquiring PostgreSQL connection, explicitly unlock on a fallback error path, and derive keys deterministically with SHA-256. Tests cover stable and input-sensitive keys, while the changelog documents mixed-version rollout caveats.

Changes

Repository advisory-lock handling

Layer / File(s) Summary
Lock connection lifecycle
crates/gitlawb-node/src/git/repo_store.rs
acquire_write() retains the connection that acquires the advisory lock, stores it in RepoWriteGuard, and explicitly unlocks it on the Tigris-download fallback error path.
Guard release and deterministic key contract
crates/gitlawb-node/src/git/repo_store.rs, CHANGELOG.md
RepoWriteGuard releases and drop-closes the retained connection; advisory-lock keys use SHA-256 over repository identity, with stability and input-variation tests and documented rolling-upgrade caveats.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant RepoStore
  participant Postgres
  participant RepoWriteGuard
  RepoStore->>Postgres: Acquire dedicated connection
  RepoStore->>Postgres: Try advisory lock
  Postgres-->>RepoStore: Return lock result
  RepoStore->>RepoWriteGuard: Store connection and lock key
  RepoWriteGuard->>Postgres: Unlock through retained connection
Loading

Possibly related PRs

  • Gitlawb/node#54: Overlaps the repository lock acquisition, fallback, and release paths.
  • Gitlawb/node#196: Its purge workflow uses the per-repository advisory locks updated here.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title concisely matches the main change: a stable SHA-256 advisory-lock key for repo_store.
Description check ✅ Passed The description covers the required sections, verification command, checklist, and rollout notes.
Linked Issues check ✅ Passed The changes satisfy #210 with SHA-256 keying, a golden test, scoped docs, and rollout caveats.
Out of Scope Changes check ✅ Passed No clearly unrelated changes stand out; the lock-lifecycle edits remain tied to advisory-lock correctness.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@beardthelion beardthelion added crate:node gitlawb-node — the serving node and REST API kind:bug Defect fix — wrong or unsafe behavior labels Jul 17, 2026

@beardthelion beardthelion left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The fix is correct and the golden test is load-bearing. I verified the pinned value is the real SHA-256 (recomputed SHA-256("did_key_...:hello")[..8] as LE i64 == -6680856138670956537, and big-endian would differ, so byte order is pinned), then ran a mutation matrix at the head: reverting to DefaultHasher, flipping the separator, flipping to from_be_bytes, and dropping owner_slug from the hash each turn the golden test RED, and it's GREEN restored. The separator is injective because owner_slug is colon-sanitized upstream (owner_did.replace([':','/'], "_")), so there's no wrong-repo aliasing, and the byte-slice path can't panic. This closes #210 the way it asked.

One thing to resolve before it lands, plus a small test note.

Findings

  • [P2] Persist the re-keying rollout caveat where operators will see it after merge
    crates/gitlawb-node/src/git/repo_store.rs:407
    Switching the algorithm re-keys every node. On a shared-Postgres, multi-node deployment doing a rolling upgrade, an old-binary node (DefaultHasher key) and a new-binary node (SHA-256 key) compute different keys for the same repo and stop mutually excluding for the deploy window, a transient loss of the cross-machine write-exclusion this lock exists to provide (concurrent same-repo pushes could race). That topology is real (the shared-DB compose target, plus purge-spam as a separate process on the same lock namespace). Right now the caveat lives only in the PR body, which disappears on merge. I filed #210 with "a staged rollout OR an accepted brief window" as acceptable, so I'm not asking you to change the swap itself, only to make the guidance durable: a short note on acquire_write (drain writes or cut over single-node during the upgrade) plus a CHANGELOG line. If you'd rather remove the window entirely, the mechanism is a transition release where acquire_write takes both the legacy and the new key, dropping the legacy one a release later, but that's optional given the accepted-window path.

  • [P3] Strengthen or drop advisory_lock_key_differs_for_different_inputs
    crates/gitlawb-node/src/git/repo_store.rs:594
    It varies both inputs at once (owner_a/repo_a vs owner_b/repo_b), so it can't isolate a regression that drops one parameter, and I confirmed that by mutation: hashing only repo_name leaves this test green while the golden test goes red. So the golden test already backstops that case and this one adds little. If you want it to pull weight, assert a same-owner/different-repo pair and a different-owner/same-repo pair; otherwise it's fine to drop it and lean on the golden test.

Not a change request: the Cargo.lock version bumps (0.5.0 -> 0.5.1 across five crates, plus a sha2 line under icaptcha-client) aren't from this PR. The manifests are already 0.5.1 on main and gitlawb-node already declares sha2; the lockfile was just stale (that's issue #185), and regenerating it here corrected the drift. Leave them as they are; a one-line mention in the PR description is enough.

Solid, well-scoped fix. Once the rollout note is durable I'm happy to approve.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Retain the lock-owning database session for the write guard
    crates/gitlawb-node/src/git/repo_store.rs:164
    This change makes the key deterministic, but pg_try_advisory_lock is still executed through &PgPool and the guard later unlocks through that pool again. Those are session-scoped PostgreSQL locks: SQLx returns the acquiring connection after fetch_one, so release() can be assigned another session and silently fail to unlock. Worse, a later writer can be given the idle lock-owning session and reentrantly acquire the same lock. The advertised shared-Postgres exclusion therefore remains unreliable even with the SHA-256 key. Hold a PoolConnection<Postgres> in RepoWriteGuard from successful acquisition through unlock (including error cleanup), and add a competing-session test. PR #196 already has this exact dedicated-connection repair, so either make this PR depend on/rebase onto it or bring that repair here before merging.

  • [P2] Preserve exclusion while old and new lock keys coexist
    crates/gitlawb-node/src/git/repo_store.rs:407
    Replacing DefaultHasher re-keys every repository. In a rolling shared-Postgres upgrade, an old node can hold the legacy key while a new node acquires the SHA-256 key for the same repository; PostgreSQL treats them as independent locks, allowing concurrent receive-pack/issue/pull writes and competing archive uploads. The PR body acknowledges this window, but it disappears on merge and the checked-in documentation only describes the topology. Acquire both keys for a transition release, or add durable deployment guidance requiring writes to be drained or cut over through a single node before versions are mixed.

Address review findings on Gitlawb#210:
- RepoWriteGuard now stores PoolConnection<sqlx::Postgres> instead of
  PgPool, so the pg_advisory_unlock on release() runs on the same
  session that acquired the lock (no silent no-op unlock, no
  reentrant acquisition by a later writer handed the idle
  lock-owning connection). Error path also releases the lock on the
  same connection before returning.
- Document the rolling-upgrade re-keying caveat durably on
  acquire_write (drain writes or cut over single-node during upgrade)
  and in CHANGELOG's Unreleased "Known caveats" section, since the PR
  body disappears on merge.
- Strengthen advisory_lock_key_differs_for_different_inputs to vary
  one axis at a time (same-owner/diff-repo, diff-owner/same-repo) so a
  regression that drops one parameter from the hash is caught, not
  just one that drops both.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/gitlawb-node/src/git/repo_store.rs (1)

410-457: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Task cancellation leaks session advisory locks to the connection pool.

If an async task is cancelled (e.g., due to a client timeout or disconnect) while holding the lock, the sqlx::pool::PoolConnection is implicitly dropped and returned to the pool. Because Postgres session-level advisory locks are not cleared automatically when a connection is returned to the pool, any subsequent request drawing this connection will inherit the lock. This can silently lock out legitimate writers or grant unintended access. This hazard applies both to downstream callers holding the guard (e.g., while awaiting receive_pack as seen in snippet 1) and within acquire_write itself during the tigris.download .await.

  • crates/gitlawb-node/src/git/repo_store.rs#L410-L457: Wrap conn in an Option and implement Drop for RepoWriteGuard. If the guard is dropped before release() is explicitly called, invoke conn.detach() to discard the connection entirely, which forces the Postgres session to close and safely clears the lock.
  • crates/gitlawb-node/src/git/repo_store.rs#L231-L266: Construct RepoWriteGuard immediately after lock acquisition so its new Drop implementation protects the tigris.download(...).await point against task cancellations.
🔒️ Proposed fix to implement cancellation safety

For crates/gitlawb-node/src/git/repo_store.rs#L410-L457:

 pub struct RepoWriteGuard {
     owner_slug: String,
     repo_name: String,
     pub local_path: PathBuf,
     lock_key: i64,
-    conn: PoolConnection<sqlx::Postgres>,
+    conn: Option<PoolConnection<sqlx::Postgres>>,
     tigris: Option<TigrisClient>,
 }
 
+impl Drop for RepoWriteGuard {
+    fn drop(&mut self) {
+        if let Some(conn) = self.conn.take() {
+            // Task was cancelled before release() could run.
+            // Detach to discard the connection and close the Postgres session.
+            let _ = conn.detach();
+        }
+    }
+}
+
 impl RepoWriteGuard {
     pub fn path(&self) -> &Path {
         &self.local_path
     }
 
     pub async fn release(mut self, success: bool) {
         if success {
             if let Some(ref tigris) = self.tigris {
                 if let Err(e) = tigris
                     .upload(&self.owner_slug, &self.repo_name, &self.local_path)
                     .await
                 {
                     warn!(repo = %self.repo_name, err = %e, "failed to upload repo to tigris after write");
                 }
             }
         } else {
             warn!(repo = %self.repo_name, "write failed — skipping tigris upload to avoid propagating an inconsistent repo");
         }
 
-        let _ = sqlx::query("SELECT pg_advisory_unlock($1)")
-            .bind(self.lock_key)
-            .execute(&mut *self.conn)
-            .await;
+        if let Some(mut conn) = self.conn.take() {
+            let _ = sqlx::query("SELECT pg_advisory_unlock($1)")
+                .bind(self.lock_key)
+                .execute(&mut *conn)
+                .await;
+        }
     }
 }

For crates/gitlawb-node/src/git/repo_store.rs#L231-L266:

-        // Always download the latest from Tigris before writing.
-        // Local disk may be stale if another machine pushed since our last access.
-        if let Some(ref tigris) = self.tigris {
-            if tigris.exists(&owner_slug, repo_name).await.unwrap_or(false) {
-                debug!(repo = %repo_name, "write acquire: downloading latest from tigris");
-                if let Err(e) = tigris.download(&owner_slug, repo_name, &local_path).await {
-                    // Same self-healing fallback as acquire_fresh: a corrupt/unreadable
-                    // Tigris archive must not block a write when a valid local copy
-                    // exists — release(success) will re-upload a good archive.
-                    if local_path.exists() {
-                        warn!(repo = %repo_name, err = %e,
-                            "write acquire: tigris download failed — falling back to local copy");
-                    } else {
-                        // Drop the lock on the error path before returning; the
-                        // connection is released to the pool by Drop on
-                        // PoolConnection, but the lock must be released explicitly
-                        // so a subsequent writer isn't blocked until idle timeout.
-                        let _ = sqlx::query("SELECT pg_advisory_unlock($1)")
-                            .bind(lock_key)
-                            .execute(&mut *conn)
-                            .await;
-                        return Err(e).context("downloading repo from tigris for write");
-                    }
-                }
-            }
-        }
-
-        Ok(RepoWriteGuard {
+        let mut guard = RepoWriteGuard {
             owner_slug,
             repo_name: repo_name.to_string(),
             local_path,
             lock_key,
-            conn,
+            conn: Some(conn),
             tigris: self.tigris.clone(),
-        })
+        };
+
+        // Always download the latest from Tigris before writing.
+        // Local disk may be stale if another machine pushed since our last access.
+        if let Some(ref tigris) = self.tigris {
+            if tigris.exists(&guard.owner_slug, &guard.repo_name).await.unwrap_or(false) {
+                debug!(repo = %guard.repo_name, "write acquire: downloading latest from tigris");
+                if let Err(e) = tigris.download(&guard.owner_slug, &guard.repo_name, &guard.local_path).await {
+                    if guard.local_path.exists() {
+                        warn!(repo = %guard.repo_name, err = %e,
+                            "write acquire: tigris download failed — falling back to local copy");
+                    } else {
+                        // Explicitly release the guard on error so the connection unlocks and returns to the pool cleanly.
+                        guard.release(false).await;
+                        return Err(e).context("downloading repo from tigris for write");
+                    }
+                }
+            }
+        }
+
+        Ok(guard)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gitlawb-node/src/git/repo_store.rs` around lines 410 - 457, Update
crates/gitlawb-node/src/git/repo_store.rs:410-457 so RepoWriteGuard stores its
PoolConnection in an Option, takes it during successful release, and implements
Drop to call detach() when release() was not completed, preventing cancellation
from returning a lock-bearing session to the pool. In
crates/gitlawb-node/src/git/repo_store.rs:231-266, construct RepoWriteGuard
immediately after advisory-lock acquisition, before awaiting tigris.download, so
its Drop implementation protects that cancellation point; preserve explicit
release behavior and lock cleanup.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@crates/gitlawb-node/src/git/repo_store.rs`:
- Around line 410-457: Update crates/gitlawb-node/src/git/repo_store.rs:410-457
so RepoWriteGuard stores its PoolConnection in an Option, takes it during
successful release, and implements Drop to call detach() when release() was not
completed, preventing cancellation from returning a lock-bearing session to the
pool. In crates/gitlawb-node/src/git/repo_store.rs:231-266, construct
RepoWriteGuard immediately after advisory-lock acquisition, before awaiting
tigris.download, so its Drop implementation protects that cancellation point;
preserve explicit release behavior and lock cleanup.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4b4619c0-2d72-49f8-b859-8dc4678f0ec7

📥 Commits

Reviewing files that changed from the base of the PR and between ad7c2b2 and c27b355.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (2)
  • CHANGELOG.md
  • crates/gitlawb-node/src/git/repo_store.rs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

crate:node gitlawb-node — the serving node and REST API kind:bug Defect fix — wrong or unsafe behavior

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Advisory-lock key uses DefaultHasher — cross-machine write exclusion can silently break across node builds

4 participants