Skip to content

fix(node): release the advisory lock on the session that took it (#279) - #285

Open
beardthelion wants to merge 26 commits into
mainfrom
fix/279-advisory-lock-session-affinity
Open

fix(node): release the advisory lock on the session that took it (#279)#285
beardthelion wants to merge 26 commits into
mainfrom
fix/279-advisory-lock-session-affinity

Conversation

@beardthelion

@beardthelion beardthelion commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Fixes #279.

Session-scoped Postgres advisory locks were taken with fetch_one(&pool) and released with execute(&pool). Those are two independent pool checkouts, so the release almost always landed on a backend that held nothing, pg_advisory_unlock returned false, and the return value was discarded by a let _. The lock leaked on essentially every write.

Measured on main at 111cff7 before writing any of this:

  • two writers on one node against the same repo both acquired
  • 50 of 50 sequential acquire/release cycles leaked
  • 100 writes left 100 orphaned locks in pg_locks

pg_advisory_unlock reports "you did not hold this" as a false return plus a warning, never an error, which is why this was silent.

What changes

RepoWriteGuard now owns the connection that took the lock for its whole lifetime and releases on that same session. Everything else here follows from that pin rather than being bundled with it.

Pinning a connection per in-flight write means writes can no longer share the 20-connection application pool, so they get a dedicated pool (GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS, default 32, connect_lazy). Without it, a push burst starves ordinary reads.

Pinning also makes the post-write upload load-bearing. It was unbounded and free before, because nothing was held while it ran; now a stalled transfer holds a lock-pool slot, and enough of them deny every write on the node. Hence GITLAWB_LOCK_HELD_TRANSFER_TIMEOUT_SECS (default 300) over any object-storage transfer that runs with the lock held.

The acquire is pg_try_advisory_lock with backoff rather than a blocking acquire, so a stale lock from a crashed connection cannot wedge a repo indefinitely. It is bounded on wall clock as well as attempt count, and a waiter hands its pool slot back before each backoff so spinners cannot starve the pool.

A cancelled .await does not cancel a SQL statement that has already been sent. That asymmetry is the whole design: cancelling an unlock is harmless because the statement completes server side, but cancelling an acquire strands the lock. So close_on_drop is armed before the try-lock goes out, via a wrapper that owns the connection in an Option and closes it in its own Drop unless the lock was positively not taken. PoolConnection::close_on_drop is a one-way setter, so disarming is Option::take rather than a second call.

close_issue took the write lock and then ran the owner-or-author check, returning 403 with the lock held. Once exclusion actually works, that is a wedge primitive for anyone with read access, so authorization moved above the lock. The author fallback reads the issue's git-JSON blob without the lock as a pre-check, and the authoritative owner-or-author check runs again under the guard, because acquire_write re-downloads the archive and the tree that gets mutated is frequently not the one the pre-check read.

Two settled calls, stated here rather than left open:

Readiness does not probe the lock pool. A node can report ready while every write fails, which two reviewers flagged. Failing readiness on a saturated pool would pull the node out of routing, take its reads down with it, and push its write load onto peers carrying the same load. Saturation surfaces in the request path instead: a retryable 503 to the caller and a warn line carrying the pool's own counters so an incident can distinguish "the pool is full" from "the database is gone."

Entry concurrency is not bounded here. Bounding it belongs with hold time, not with this pin, and the arithmetic is in #282. A rate limit provably cannot close that one, so it is not #196's either.

advisory_lock_key deliberately stays on DefaultHasher. #215 owns the change to SHA-256 for #210 and the two need to stay separable.

Verification

Every guard here was checked by reverting the exact production line it protects and observing red first. That is not incidental: an earlier round of this work shipped with tests that did not observe what they claimed, including one that seeded a repo owner as their own issue author, which made it pass with the owner check disabled entirely.

The must-not tests observe pg_locks from a standalone connection, never from the lock pool, because pool reuse hands the observer the lock-holding session and reentrantly re-grabs the lock, hiding the leak. Lock-freed assertions poll with a deadline rather than asserting immediately, since PoolConnection::drop spawns the close.

530 tests pass, clippy is clean under -D warnings.

Known gaps

The under-lock refresh timeout and the corrupt-archive fallback are correct by reading and not by execution. Driving either needs a seam to stall an object-storage response, which does not exist yet and is out of scope here. For the same reason the author-path test cannot distinguish acquire from acquire_fresh: RepoStore::for_testing has no object-storage client, so the two calls are identical in every test in the suite. The test says so rather than claiming the coverage.

The refresh timeout also leaves a hazard it narrows rather than removes: refusing the acquire frees the lock while an uncancellable extraction is still headed for a directory swap. That is #283.

#284 is the remaining cost lever on close_issue, which this branch improves on main (the fetch no longer happens with the lock held) without removing.

One open operational question: 20 application connections plus 32 lock connections per node needs to fit the fleet's Postgres max_connections. If it does not, the default is what should change.

Summary by CodeRabbit

  • New Features

    • Added configurable limits for repository lock connections and storage transfers.
    • Added clear retryable responses when repository operations are temporarily busy or unavailable.
    • Added safeguards to prevent conflicting repository updates.
  • Bug Fixes

    • Unauthorized issue actions are rejected before repository locks are acquired.
    • Improved protection against revealing whether inaccessible issues exist.
    • Repository locks now release safely during cancellations, contention, and transfer failures.
    • Failed lock releases no longer allow incomplete repository or pull request updates.
  • Documentation

    • Updated the environment configuration example with the new settings.

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR adds a dedicated advisory-lock pool, session-pinned repository locks, bounded Tigris transfers, conditional uploads, typed repository errors, and pre-lock authorization checks for issue closure.

Changes

Repository write controls

Layer / File(s) Summary
Dedicated lock-pool configuration and wiring
.env.example, crates/gitlawb-node/src/config.rs, crates/gitlawb-node/src/db/mod.rs, crates/gitlawb-node/src/git/repo_store.rs, crates/gitlawb-node/src/main.rs
Adds validated lock-pool and transfer-timeout settings. Creates a dedicated lazy advisory-lock pool and passes it to RepoStore.
Conditional storage and snapshot transfers
crates/gitlawb-node/src/git/tigris.rs
Adds ETag reads, conditional upload handling, bounded download modes, isolated snapshot extraction, and integration coverage.
Session-pinned locking and bounded transfers
crates/gitlawb-node/src/git/repo_store.rs
Pins locks to owning sessions, handles cancellation and deadlines, bounds transfers, checks unlock results, closes unsafe sessions, and tests cleanup and pool isolation.
Typed repository error propagation
crates/gitlawb-node/src/error.rs, crates/gitlawb-node/src/api/issues.rs, crates/gitlawb-node/src/api/pulls.rs, crates/gitlawb-node/src/api/repos.rs
Maps transient repository errors to 503 Service Unavailable and preserves acquisition and release errors through API handlers.
Pre-lock issue authorization
crates/gitlawb-node/src/api/issues.rs
Checks authorization before locking, revalidates it under the guard, distinguishes missing-issue responses, and adds regression tests.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related issues

  • Gitlawb/node issue 282 — Addresses the lock-held transfer timeout used by repository write locking.

Possibly related PRs

Suggested labels: subsystem:storage

Suggested reviewers: kevincodex1

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR includes substantial close_issue authorization and Tigris conditional-publishing changes beyond the coding requirements stated in linked issue #279. Move the authorization and conditional-publishing changes to linked issues or separate pull requests, unless their scope is explicitly added to #279.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the primary advisory-lock session-affinity fix and references issue #279.
Description check ✅ Passed The description provides detailed motivation, implementation changes, verification results, and known gaps, despite omitting several template sections.
Linked Issues check ✅ Passed The implementation satisfies #279 by preserving session affinity, preventing concurrent writers, releasing locks, and testing cancellation and pool behavior.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/279-advisory-lock-session-affinity

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 30, 2026

@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.

Actionable comments posted: 1

🧹 Nitpick comments (3)
crates/gitlawb-node/src/git/repo_store.rs (2)

1363-1372: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace the fixed 300ms sleep with a poll loop.

PoolConnection::drop spawns the close, so on a loaded CI runner the close may not have completed when the next acquire() runs — the pool then hands back the same still-open connection and the assert_ne! fails spuriously. Polling until the pid changes (or a generous deadline elapses) makes this deterministic, matching the rationale already used in poll_until_free.

♻️ Poll instead of sleeping a fixed interval
-        // Give the spawned close a moment, then see which backend we land on.
-        tokio::time::sleep(std::time::Duration::from_millis(300)).await;
-        let pid_after = {
-            let mut c = lock_pool.acquire().await.unwrap();
-            let pid: (i32,) = sqlx::query_as("SELECT pg_backend_pid()")
-                .fetch_one(&mut *c)
-                .await
-                .unwrap();
-            pid.0
-        };
+        // The close is spawned, so poll rather than sleeping a fixed interval.
+        let started = std::time::Instant::now();
+        let mut pid_after = pid_before;
+        while started.elapsed() < std::time::Duration::from_secs(10) {
+            let mut c = lock_pool.acquire().await.unwrap();
+            let pid: (i32,) = sqlx::query_as("SELECT pg_backend_pid()")
+                .fetch_one(&mut *c)
+                .await
+                .unwrap();
+            pid_after = pid.0;
+            if pid_after != pid_before {
+                break;
+            }
+            drop(c);
+            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
+        }
🤖 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 1363 - 1372, Replace
the fixed 300ms sleep before querying pid_after with a poll loop that repeatedly
acquires a connection and checks pg_backend_pid() until it differs from the
original pid, or a generous deadline is reached. Reuse the existing
poll_until_free approach and preserve the final pid comparison while preventing
transient failures on slow runners.

250-258: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Consider jitter on the retry sleep.

The backoff is a flat 1s with no randomization, so multiple waiters on the same repo tend to synchronize their probes and pg_try_advisory_lock gives no fairness ordering — a waiter can be starved for the whole 90s deadline while later arrivals win. A small random offset (or a short exponential ramp) spreads the probes.

🤖 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 250 - 258, Randomize
the retry delay in the probe loop around the existing tokio::time::sleep call so
concurrent waiters do not synchronize their pg_try_advisory_lock attempts.
Preserve the existing deadline clamp via left and the 1-second maximum, while
adding a small jitter or short exponential backoff without changing the retry
budget or connection-release behavior.
crates/gitlawb-node/src/api/issues.rs (1)

262-270: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Real errors are silently indistinguishable from "not authorized" here.

Ok(None) | Err(_) => None is a reasonable fail-closed default for the client, but a genuine git_issues::get_issue failure (disk/git corruption, IO error) is dropped with no log line, and will look identical to an ordinary "not authorized" 403 in the logs. Compare with the post-lock re-check a few lines down (Line 325-328), which does surface/log the equivalent error. Worth a tracing::warn!/debug! on the Err(e) arm here too, purely for operator visibility — the client-facing fail-closed behavior would stay exactly the same.

♻️ Proposed refactor
-        let author_did: Option<String> = match git_issues::get_issue(&disk_path, &issue_id) {
-            Ok(Some(raw)) => serde_json::from_str::<IssueRecord>(&raw)
-                .ok()
-                .and_then(|i| i.author),
-            // Cannot establish authorship, so fail closed. Deliberately 403 rather
-            // than 404 for a non-owner: a caller who is not authorized to write
-            // should not learn from this route whether the issue exists.
-            Ok(None) | Err(_) => None,
-        };
+        let author_did: Option<String> = match git_issues::get_issue(&disk_path, &issue_id) {
+            Ok(Some(raw)) => serde_json::from_str::<IssueRecord>(&raw)
+                .ok()
+                .and_then(|i| i.author),
+            // Cannot establish authorship, so fail closed. Deliberately 403 rather
+            // than 404 for a non-owner: a caller who is not authorized to write
+            // should not learn from this route whether the issue exists.
+            Ok(None) => None,
+            Err(e) => {
+                tracing::warn!(repo = %repo, issue = %issue_id, err = %e, "pre-lock issue read failed — treating as unauthorized");
+                None
+            }
+        };
🤖 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/api/issues.rs` around lines 262 - 270, Update the
author lookup match around git_issues::get_issue to handle Err(e) separately
from Ok(None): preserve the existing fail-closed None result, but emit a tracing
warn or debug log containing the retrieval error for operator visibility. Keep
successful issue parsing and the client-facing authorization behavior unchanged.
🤖 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.

Inline comments:
In `@crates/gitlawb-node/src/api/repos.rs`:
- Around line 930-941: Update the acquire_write error logging in the repository
write-lock flow to avoid unconditionally logging expected RepoBusy/transient 503
failures at error severity. Preserve propagation through the existing ?
operator, but classify contention consistently with repo_store.rs by using
warning-level logging or suppressing the duplicate log for RepoBusy while
retaining error logging for unexpected failures.

---

Nitpick comments:
In `@crates/gitlawb-node/src/api/issues.rs`:
- Around line 262-270: Update the author lookup match around
git_issues::get_issue to handle Err(e) separately from Ok(None): preserve the
existing fail-closed None result, but emit a tracing warn or debug log
containing the retrieval error for operator visibility. Keep successful issue
parsing and the client-facing authorization behavior unchanged.

In `@crates/gitlawb-node/src/git/repo_store.rs`:
- Around line 1363-1372: Replace the fixed 300ms sleep before querying pid_after
with a poll loop that repeatedly acquires a connection and checks
pg_backend_pid() until it differs from the original pid, or a generous deadline
is reached. Reuse the existing poll_until_free approach and preserve the final
pid comparison while preventing transient failures on slow runners.
- Around line 250-258: Randomize the retry delay in the probe loop around the
existing tokio::time::sleep call so concurrent waiters do not synchronize their
pg_try_advisory_lock attempts. Preserve the existing deadline clamp via left and
the 1-second maximum, while adding a small jitter or short exponential backoff
without changing the retry budget or connection-release behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c10504fc-f6f3-4c2e-a9a7-789138ba8d9a

📥 Commits

Reviewing files that changed from the base of the PR and between c83cbc5 and 1cc2c7c.

📒 Files selected for processing (9)
  • .env.example
  • crates/gitlawb-node/src/api/issues.rs
  • crates/gitlawb-node/src/api/pulls.rs
  • crates/gitlawb-node/src/api/repos.rs
  • crates/gitlawb-node/src/config.rs
  • crates/gitlawb-node/src/db/mod.rs
  • crates/gitlawb-node/src/error.rs
  • crates/gitlawb-node/src/git/repo_store.rs
  • crates/gitlawb-node/src/main.rs

Comment thread crates/gitlawb-node/src/api/repos.rs

@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.

The core lock-session fix looks ready; a few gaps in the new error and transfer layer should be closed before merge.

Findings

  • [P2] Align acquire_fresh HEAD failure handling with the under-lock refresh path
    crates/gitlawb-node/src/git/repo_store.rs:157-158, crates/gitlawb-node/src/api/issues.rs:258-277
    unwrap_or(false) on Tigris HEAD is pre-existing in acquire_fresh, but this PR now routes close_issue's non-owner author pre-check through it while acquire_write was fixed to refuse on RefreshFailure::Unknown. That leaves two freshness paths with different epistemics for the same operation. The author-denial scenario on a HEAD blip is largely the same as on main (both skipped download and read local), but owners and authors who pass pre-check on stale local can now hit a refused acquire_write (500) when HEAD fails under the lock — stricter, not looser. Please propagate HEAD errors out of acquire_fresh the same way the under-lock refresh does, or stop using acquire_fresh for auth until it does.

  • [P2] Map new transient Tigris refusal paths to a retryable 503, not HTTP 500
    crates/gitlawb-node/src/git/repo_store.rs:341-351, crates/gitlawb-node/src/git/repo_store.rs:365-369, crates/gitlawb-node/src/error.rs:82-94
    The under-lock HEAD failure and refresh-timeout arms are new in this series and return plain anyhow errors. AppError::from(anyhow::Error) only downcasts sqlx::Error and RepoBusy, so these surface as internal_error / HTTP 500 even though the comments call them retryable refusals. This is not a regression from main — acquire failures already mapped to 500 via AppError::Git — but it is a gap in the new error taxonomy you added for contention and pool exhaustion. Please introduce a typed retryable error (or extend the RepoBusy pattern) for HEAD failure and under-lock refresh timeout.

  • [P2] Keep repo-identifying detail out of client-visible error bodies on the new paths
    crates/gitlawb-node/src/git/repo_store.rs:365-368, crates/gitlawb-node/src/error.rs:168-172
    The under-lock refresh timeout embeds {owner_slug}/{repo_name} in the error string, which AppError::Internal returns verbatim in the JSON message. That contradicts the fixed-body policy you added for RepoBusy. main already leaked repo names in lock-contention 500s; this is a new instance on the timeout path. Please log operator detail and return a fixed retryable body to callers, consistent with RepoBusy.

  • [P3] Log expected acquire_write contention at warn, not error
    crates/gitlawb-node/src/api/repos.rs:939-940
    inspect_err logs every acquire_write failure at error severity. Base already logged acquire failures at error, but RepoBusy is new — expected 503 contention now hits tracing::error! while repo_store.rs logs the same condition at warn. Please downgrade or suppress logging for RepoBusy (and other expected transient 503 paths) while keeping error logging for unexpected failures.

Tracked follow-up (not blocking this PR)

  • #283 — orphaned Tigris extraction after transfer timeout
    crates/gitlawb-node/src/git/repo_store.rs:353-369, crates/gitlawb-node/src/git/tigris.rs:118-124, crates/gitlawb-node/src/git/tigris.rs:218-223
    spawn_blocking(decompress_repo) is not cancelled when bounded_transfer times out; a late extract can still remove_dir_all + rename after the lock is released. The mechanism is pre-existing; the timeout bound makes it more reachable. Refusing the write on timeout is the right call and is strictly better than the old path. You already track this as #283 — no action required here beyond keeping that follow-up open.

Reviewed and not raised as defects

  • Unbounded acquire_fresh on close_issue pre-checkacquire_fresh without a transfer bound is a pre-existing pattern (repos.rs git-receive-pack uses it too). This PR improves the stranger case (instant 403 vs lock wedge). Not a new amplification primitive worth blocking on.
  • Lock-pool saturation → db_unavailable — deliberate choice documented in repo_store.rs:220-241; operators get pool counters in the warn log. Client-code conflation is a tradeoff, not an oversight.
  • Proxy idle timeout vs composed write budgets — real operational tension, predates this PR; you already note reconciliation is tracked separately.
  • Fleet Postgres connection budget (+32 lock pool) — new default is intentional; PR body asks operators to budget. Deployment sizing, not a logic bug.

Maintainer decisions

  • Proxy idle timeout vs composed write budgets. Fly idle_timeout = 120 vs defaults of 90s lock wait, two 300s under-lock transfer spans, and up to 600s git service work. Please confirm the intended production limits as a set, or document the accepted failure mode when the edge drops the client first.
  • Fleet Postgres connection budget. Defaults now open up to 52 connections per node (20 + 32) with no startup validation. Please confirm fleet sizing or adjust the default before a broad rollout.

CodeRabbit follow-ups verified

  • Still open: repos.rs:939-940 — expected RepoBusy logged at error (see P3 above).
  • Still open: issues.rs:262-270 — pre-lock git_issues::get_issue I/O errors are silently folded into the unauthorized path with no log line (operability nit; client behavior is intentionally fail-closed).
  • Still open: repo_store.rs:1363-1372 — fixed 300ms sleep in release_that_did_not_hold_the_lock_closes_the_session can flake on slow CI; poll like poll_until_free.
  • Still open: repo_store.rs:250-258 — flat 1s backoff with no jitter on lock retry (fairness nit under contention).

What looks good

The core session-affinity fix is sound: the guard owns the lock-holding connection, LockProbe closes on cancellation, unlock results are observed, pool splitting is wired correctly, and the regression tests against pg_locks are thoughtfully constructed. close_issue authorization reorder and under-guard re-authorization close the wedge this series introduced. CI is green on the head commit.

…id-acquire

A cancelled .await does not cancel an already-sent SQL statement, so a
pg_try_advisory_lock whose future is dropped still takes the lock
server-side while the caller abandons the result, leaving nothing to
release it. The connection then returns to the pool holding the lock and
wedges that repo until sqlx recycles the session.

Introduce LockProbe, which owns the connection across the in-flight
try-lock and closes it in its own Drop if it is still held. close_on_drop
is a one-way setter, so the arming lives in Drop rather than being set up
front and cleared on success; disarming is Option::take, which is what
into_conn does once an acquire is actually observed. This is now the only
place that issues pg_try_advisory_lock.

The committed gate drops a probe without taking its connection, which is
the state a cancellation leaves behind, and polls a standalone observer
until the lock frees. Deterministic on purpose: the timing sweep that
found this window leaks roughly 1 in 600, which is not something a CI
gate can rest on. Observed RED before this change with the lock still
held for the full 10s window.

Refs #279
Pinning a connection for the lock's lifetime is only safe if those
connections come from somewhere other than the pool serving ordinary
request handlers, otherwise a push burst starves every other query. Add
GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS (default 32) and a Db::lock_pool
builder, with the sizing tradeoff documented on the field and in
.env.example: every in-flight write pins one connection here, so the
value is a hard ceiling on simultaneous writes node-wide.

The pool connects lazily on purpose. The main pool must connect eagerly
because it runs migrations, which is why it needs connect_db_with_retry's
backoff and degraded-server handoff; that function is not a generic retry
helper and the lock pool is built well after the db-ready handoff has
already resolved. A lazy pool has no startup work, so it adds no new way
for the process to fail to boot and needs no second copy of that
machinery. If Postgres is unreachable when the first write arrives, that
write fails on the pool's own acquire timeout, like any other
database-backed request.

Pure configuration, so no proof-first cycle: the knob is covered by a
parse/default/reject-zero test. Db::lock_pool has no caller until the
guard wiring lands, hence the temporary dead_code attribute.

Refs #279
Postgres advisory locks are session-scoped: only the backend that took one
can release it. acquire_write took the lock through fetch_one(&pool) and
release unlocked through execute(&pool), two independent checkouts, so the
unlock usually landed on a session that held nothing and returned false.
Measured on main: two writers on one node and the same repo BOTH acquired,
50 of 50 sequential cycles leaked, and 100 writes left 100 orphaned
advisory locks on the server.

The guard now owns the PoolConnection that took the lock, drawn from the
dedicated lock pool, and releases on that same session. The retry loop
probes through LockProbe so a cancellation mid-acquire cannot strand the
lock, and hands the connection back before each backoff so a spinner on a
contended repo does not pin a slot while idle.

Pool exhaustion is deliberately not retried. It is a different condition
from lock contention, and retrying it would spend all 60 attempts on a
capacity problem unrelated to this repo while reporting it as someone else
holding the lock.

Both #279 acceptance tests were observed RED first: the exclusion test
admitted the second writer, and the leak test reported 1 lock held where 0
was required. Both GREEN after. Full crate suite 516 passed.

Db::pool() is removed because this change was its only caller.

Refs #279
…easing

A guard can exit without reaching release(): an early ? on the pre-write
download, a panic, or an axum handler future cancelled when the client
disconnects. Its session still holds the lock, so returning that
connection to the pool would block every future write to the repo until
sqlx recycles it, which on the 0.8.6 defaults is ten minutes idle or
thirty minutes lifetime. Close the session instead and let Postgres free
the lock at session end.

One hazard found by writing the teardown test rather than by reasoning:
PoolConnection::drop spawns onto the runtime for both closing and
returning, and panics outright when no runtime handle exists. That panic
would fire inside a Drop and abort the process during unwind. It is not
introduced by this commit, it comes with owning a PoolConnection at all,
but this is where it becomes reachable. So Drop checks for a runtime
first and, with none, leaks the handle deliberately rather than panicking:
the process is already exiting and socket teardown ends the session, which
is what frees the lock at exit anyway.

Observed RED before the fix, with the lock still held for the full 10s
poll window against a standalone observer on a no-reap pool, and the
teardown case panicking in sqlx-core connection.rs:208. Proven
load-bearing after: neutering the close_on_drop call turns the drop test
RED again. The must-not case (a released guard reuses its backend pid
across four writes on a pool sized 1) passes in both states, so the
signal is specific to the abandoned-guard path.

Full crate suite 519 passed.

Refs #279
pg_advisory_unlock reports "you did not hold this lock" as a false RETURN
VALUE plus a server WARNING, never an error, so let _ = execute(...) could
not distinguish a real release from a no-op. Three blindnesses stacked in
those four lines: execute discards the row, the boolean lives in the row,
and let _ discarded the Result too.

Read it through fetch_one into (bool,). A false means this session's lock
state is not what we believe it is, so the connection stays in the guard
for Drop to close rather than being handed back to the pool as clean. Only
a confirmed unlock returns it. A query error gets the same treatment, since
the lock must not outlive a session we can no longer reason about.

Note this is the only unlock site: the pre-write download's error path
returns through the guard, so Drop covers it and there is no second place
to keep in sync.

RED before: the connection came back on the same backend pid after an
unlock that returned false. GREEN after, and proven load-bearing by
treating false as success, which turns it RED again. The must-not case (a
normal release still reuses its backend) passes in both states, so this
does not over-close the happy path.

Full crate suite 520 passed.

Refs #279
…e lock

Two transfers happen while the per-repo advisory lock is held: the archive
download inside acquire_write, which runs after the lock is taken and
before the guard exists, and the upload inside release. Both were free
before the guard pinned a lock-pool connection, because the lock's
connection went back to the pool immediately. Now an unbounded stall holds
a lock-pool slot for its whole duration, so enough concurrent stalls deny
every write on the node with no reaping path. Add
GITLAWB_LOCK_HELD_TRANSFER_TIMEOUT_SECS (default 300) and route both
through it.

A timed-out upload is UNKNOWABLE rather than failed, so the timeout arm
takes no compensating action: the PUT may well have landed. The lock is
released either way, which trades a narrow last-writer-wins window for not
wedging the repo behind a stalled transfer. Deliberate, and stated here
rather than discovered later.

Note what is NOT bounded by this knob: acquire_fresh's download. It runs
before any lock is taken, so it holds nothing. An earlier revision of this
change put the bound there by mistake, because both functions contain an
identical download call and the first match won.

Two disclosures.

Coverage: the committed tests cover the bound mechanism, not the wiring.
Driving a genuinely stalled transfer through acquire_write needs either the
object-store abstraction (out of scope) or a process-global
AWS_ENDPOINT_URL_S3 mutation, which would make the suite order-dependent
under the concurrent runner. That a stalled transfer is bounded is
therefore verified by reading, not by execution.

Behavior: on timeout with a local copy present, the download falls into the
pre-existing self-healing fallback and the write proceeds against that
local copy. This widens the conditions reaching that path from
corrupt-or-unreachable to include merely-slow, so a stale tree could now be
written and re-uploaded on a slow link. Kept consistent with the existing
failed-download behavior rather than inventing new semantics here; changing
it is its own decision.

Full crate suite 522 passed.

Refs #279
close_issue acquired the per-repo advisory lock, then ran the
owner-or-author check, then returned 403 with the lock still held. That was
harmless only because the lock excluded nothing. Making it work, as the
preceding commits do, turns this ordering into a denial primitive: any
caller with repo read access could take the write lock on demand and be
refused the write, while a legitimate writer burned its 60-attempt retry
budget against a lock held by someone with no write authorization. On a
public repo that is every permissionless identity. This series creates the
exposure, so it closes it in the same series.

The owner check is cheap and moves above the lock outright. The author
fallback needs the issue's git-JSON blob, since there is no author column,
so it now reads through acquire() rather than acquire_write(): an issue's
author is set at creation and never changes, so reading it outside the lock
races nothing, and only the mutation needs exclusion.

Two deliberate behavior choices. A non-owner whose authorship cannot be
established gets 403 rather than 404, so this route does not tell an
unauthorized caller whether an issue exists. And the owner's existing
404-for-a-missing-issue path is preserved by re-reading under the guard,
which the mutation wants anyway.

Swept the other three acquire_write sites: merge_pr owner-gates at :200
before acquiring at :214, the repo write path checks did_matches at :916
before :933, and create_issue's read gate is legitimate because that caller
IS authorized for the action it performs. close_issue was the only one with
the wrong order.

RED before: with an independent session holding the repo's lock, a
stranger's request sat in the retry loop until the 3s deadline fired
(Elapsed), which is the wedge itself. GREEN after, refused immediately.
Proven load-bearing: disabling the pre-lock refusal restores the Elapsed.

Full crate suite 523 passed.

Refs #279
Seven fixes from a seven-reviewer pass. Eight of the nine substantive
findings were defects introduced by this branch, and two contradicted
claims made in its own earlier commit messages.

P0, the worst of them. The under-lock download's timeout was folded into
the same Err as a corrupt archive, so it fell through to the
use-the-local-copy fallback and the write proceeded. Two ways that
destroys data: we do not know whether we hold the latest tree, so
re-uploading can overwrite another node's newer archive; and the abandoned
download's extraction runs in an uncancellable spawn_blocking that ends in
remove_dir_all + rename over the same path, so git would have been running
against a directory a background task was about to delete. Now the
Option is branched rather than collapsed: a genuine fetch error keeps the
local-copy fallback, a timeout refuses the acquire and lets the guard's
Drop free the lock.

LockProbe closed its connection on ordinary contention, so a 60-attempt
spinner tore down 60 backends -- the exact behavior an earlier commit
claimed it avoided. The test written to guarantee that asserted the
HOLDER's lock count, which cannot observe the probe's own connection, so it
passed throughout. The correct predicate turned out to be narrower than the
first attempt at this fix: not "we saw an answer" but "we positively know
nothing was acquired," because a true answer means the lock IS held and
dropping without handoff leaks it exactly as a cancellation would. The
first attempt reopened U1's leak and U1's gate test caught it.

tigris.exists() ran unbounded inside the lock-held span, so the knob's
promise was not kept and the docs were wrong about the shape. The HEAD and
the download now share one budget, so worst-case occupancy is one budget
per span rather than two.

close_issue's pre-lock author read used acquire(), whose fast path returns
on a cached directory and never contacts object storage, so on a multi-node
deploy an issue author would be refused their own issue. Now acquire_fresh,
which refreshes without taking the lock -- the property the comment
already claimed. That comment also asserted authorship is immutable, which
a reviewer disproved by force-pushing a forged blob at refs/gitlawb/**;
reworded to the real justification (a pre-check whose mutation re-reads
under the guard). The underlying pushability is pre-existing and larger
than this branch.

The retry loop had no wall-clock cap, reaching ~360s against a 120s proxy
idle timeout that was itself lowered from 600s after held connection slots
caused a production outage. Capped at 90s, with the bail message naming the
real bound.

All four acquire_write call sites stringified their error, destroying the
anyhow downcast that error.rs documents explicitly ("without this, every
database outage surfaces as a 500 instead of a 503"). PoolTimedOut is in
that 503 set, so a saturated lock pool told clients not to retry something
transient. Now propagated.

Tests: six that the plan required and the first pass never wrote. The R4
pool-isolation test (named in the plan as proving the pool split, entirely
absent), close_issue's owner and non-owner-author twins (INV-21c, a
two-principal gate with only its deny arm covered), a waiter-does-not-block-
an-unrelated-repo case, and a config test for the transfer knob. The
runtime-teardown test no longer returns green when DATABASE_URL is unset.

Drop's no-runtime branch now detaches via leak() instead of mem::forget:
PgConnection has no Drop impl, so the socket closes synchronously and the
lock frees immediately rather than at process exit.

Evidence. The probe predicate is proven load-bearing in BOTH directions:
forcing always-close reddens the churn regression, forcing never-close
reddens the cancellation gate, and only the correct predicate satisfies
both. The author twin reddens when the fallback is removed.

One honest limit: the author twin does NOT redden when acquire_fresh is
reverted to acquire. With tigris disabled in tests the two calls are
identical, so that fix is correct by reading, not by execution -- the same
seam that leaves the transfer bound's wiring untested.

Full crate suite 529 passed, clippy -D warnings clean.

Refs #279
…iness

Completes the shed-in-path half of the availability story. F7 already made a
pool timeout a retryable 503 by letting the sqlx downcast through; this adds
the operator-facing half, with the pool's own size/idle counters so an
incident can distinguish "the pool is full" from "the database is gone"
without reproducing it.

Deliberately NOT a readiness probe. /ready gates Fly routing with no
fail-open, so failing it on a saturated pool would pull this node's READS
out of service too and push its write load onto peers carrying the same
load. That is the downward spiral AWS's health-check guidance names and the
SRE Book documents independently. A lock-pool readiness probe would also add
nothing on the reachability axis, because both pools are built from the same
database_url, so the existing app-pool ping already answers it.

The error is still returned via .context() rather than replaced, because
anyhow preserves downcastability through context layers and the 503 mapping
depends on it.

Cost is bounded by construction: one line per failed acquire, and a failed
acquire has already paid a multi-second pool timeout.

Refs #279
… contention as 503

Four defects a seven-reviewer pass found in the previous two commits, three of
them cases where one arm of a match contradicted its neighbour.

The under-lock refresh read a failed Tigris HEAD as "no archive"
(`exists().await.unwrap_or(false)`), skipped the refresh, and wrote against a
possibly-stale tree, then re-uploaded over it. That is the outcome the timeout
arm twenty lines below explicitly refuses. A failed HEAD and a failed download
leave us knowing different things, so they no longer share a branch: only a
download failure after a successful HEAD establishes that the local copy is a
sound thing to fall back to and re-upload. A HEAD failure now refuses the write.

Lock contention that ran out the acquire deadline surfaced as a 500 carrying the
owner slug and repo name in the client-visible body, while pool exhaustion
fifteen lines up returned a deliberate 503. Contention is transient and ordinary,
so it now maps through a typed `RepoBusy` to a retryable 503 with a fixed body;
the detail stays in the log at the raise site.

`lock_not_taken` was assigned after the try-lock answered, so an error left a
previous `true`-derived value standing and could return a lock-holding session to
the pool. It is now cleared before the statement is sent.

`leak()`'s no-panic property in the guard's no-runtime Drop branch depended on
`min_connections == 0` without saying so; a future tuning change would have
silently re-armed a panic inside Drop. Made explicit with the reason.

Two tests that did not bind:

  - `waiter_on_one_repo_does_not_block_another` proved only that two lock keys do
    not collide. It now samples the pool counters across more than two backoff
    cycles. RED at 0/50 samples with `drop(probe)` moved after the sleep, the
    exact defect it names, which the previous version survived.
  - the exhaustion test's `Ok(Err(_)) | Err(_)` was satisfied by its own outer
    timeout. It now requires `PoolTimedOut` and asserts the slots are accounted
    for. RED with the pool sized N+1.

`contended_acquire_sheds_as_repo_busy_not_internal_error` is new and drives the
deadline path for real; the deadline became a field so it does not wait 90s. RED
at 500-vs-503 with the downcast removed.

The acquire deadline's docstring claimed a total under the 120s proxy idle
timeout, which the 300s under-lock transfer bound in the same function
contradicts. It bounds the wait only, and now says so. The backoff is clamped to
the remaining budget.
…e-checking existence

The guarded re-read matched `Ok(Some(_))` and discarded the blob, so the
authorization decision rested entirely on the pre-lock read. A comment above
claimed the opposite. That is not a narrow window: `acquire_write` re-downloads
the archive after locking, so the tree that gets mutated is routinely not the one
the author was read from. With owner-push enforcement defaulting to false and
branch protection covering only `refs/heads/*`, `refs/gitlawb/issues/*` is
pushable, so a forged author blob landing between the two reads was honored.

The blob is already in hand under the guard, so re-asserting owner-or-author
costs a deserialize. A non-owner whose issue has vanished gets 403 rather than
404, matching the pre-check's existing refusal to reveal existence.

`owner_can_still_close_after_the_reorder` seeded the owner as their own issue's
author, which made it unable to fail: with the owner check disabled the author
fallback granted the close and the test stayed green. It now seeds a third party,
so only the owner arm can grant. RED with `is_owner = false`.

Also drops the claim that the author twin covers the acquire-vs-acquire_fresh
distinction. `RepoStore::for_testing` hardcodes `tigris: None`, so the two calls
are identical in every test here and reverting that line leaves the twin green.
Separating them needs an object-storage seam. Stating the gap beats asserting
coverage that does not exist.
Whitespace only; cargo fmt --check gates the push.
…d body

The two refusal arms added for the under-lock transfer bound returned bare
anyhow errors, so AppError's From impl (which downcasts only sqlx::Error and
RepoBusy) landed them in Internal: a 500 internal_error whose body was the
error string. Two defects in one. A transient object-storage failure told the
client not to retry, and the timeout arm's message interpolated the owner slug
and repo name straight into the response body, contradicting the fixed-body
policy the RepoBusy arm sets six lines above it.

RepoUnavailable follows RepoBusy exactly: a fieldless type raised with the
operator detail in a context string, downcast to its own rung, mapped to a 503
whose body interpolates nothing. The detail stays in the log at the raise site.

The timeout arm logs at error! where its sibling logs at warn!, deliberately: a
300s stall pinned a lock-pool slot for five minutes and is the condition the
transfer bound exists to surface, so it must keep paging when the handler
classifier demotes the ordinary blip case.
…read

acquire_fresh collapsed a failed HEAD into "no archive exists" via
unwrap_or(false) and served the local copy, while the sibling path under the
lock already refused on the same condition. A push that hit a storage blip got
an advertisement built from a possibly-stale tree, uploaded a whole pack, and
was then refused by acquire_write for the reason the advertisement had already
swallowed. The authorship pre-check on close_issue read the same way, which is
an infrastructure failure resolving toward a denial.

A failed HEAD now raises RepoUnavailable, so both callers surface it as the
retryable 503 rather than a stale success. The download-failure fallback is
unchanged: a present-but-unreadable archive is still self-healed from local.

info_refs keeps its map_err for every other failure so the read path's error
vocabulary does not move; only the typed error takes the From chain.

A test-only TigrisClient constructor pointed at a closed port makes both
refusals executable, so this is no longer verified by reading. It also proves
the under-lock arm end to end, which the earlier draft had recorded as an
untestable gap.
Both acquire call sites logged every failure at error, including RepoBusy,
which the raise site already logs at warn. Ordinary write contention paged.
The previous commit widened the problem: info_refs now raises RepoUnavailable
on a storage blip, so that site would have started paging on the condition
this series just classified as transient and retryable.

A classifier over the two typed refusals picks the level at both sites,
mirroring the startup path's permanent-vs-transient split. Anything it cannot
classify still logs at error, so an unknown failure keeps paging.
…a denial

The authorship pre-check treated a get_issue I/O error and a genuinely absent
issue as the same None, with no log line. The client answer is deliberately
identical, since a caller who cannot write must not learn whether the issue
exists, but the two are not the same event and an operator had no way to tell
a real authorization denial from a filesystem or parse failure behind it.

Splitting the arm leaves the 403 exactly where it was and makes the read
failure visible in the log.
…00ms

The release-invariant test slept 300ms for a Drop-spawned close before
comparing backend pids, which is a coin flip on a loaded CI runner. It now
polls pg_stat_activity for the captured pid on a standalone connection, the
same discipline poll_until_free documents: a pooled observer would be handed
the session under measurement and hide the effect.

The conversion was checked against the failure it exists to catch rather than
assumed. Pooling the session on an unlock that returned false makes the test
fail after the poll deadline, not hang, and no_reap_pool disables idle timeout
and max lifetime so nothing but the close under test can retire that backend.
A generous deadline would have been the same defect as the sleep.

@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.

Actionable comments posted: 2

🤖 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.

Inline comments:
In `@crates/gitlawb-node/src/git/repo_store.rs`:
- Around line 391-420: Prevent stale asynchronous extraction from replacing
newer repository data: update decompress_repo and the repository write/acquire
flow to track in-flight extractions per repository and make later writes wait or
fail until extraction completes, or validate a repository generation immediately
before publishing. Ensure the final remove_dir_all and rename cannot overwrite
changes made after the timed-out download.
- Around line 923-943: In the no-runtime branch of the write-guard drop logic,
replace the conn.leak() call with conn.detach() so the pool bookkeeping is
released and capacity remains replenishable. Preserve the existing synchronous
drop behavior for the detached PgConnection and update the nearby comment to
describe detach rather than a permanent leak.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 96e1a177-8f0f-4b1e-b34b-f4afde424e77

📥 Commits

Reviewing files that changed from the base of the PR and between 1cc2c7c and 281f0ee.

📒 Files selected for processing (10)
  • .env.example
  • crates/gitlawb-node/src/api/issues.rs
  • crates/gitlawb-node/src/api/pulls.rs
  • crates/gitlawb-node/src/api/repos.rs
  • crates/gitlawb-node/src/config.rs
  • crates/gitlawb-node/src/db/mod.rs
  • crates/gitlawb-node/src/error.rs
  • crates/gitlawb-node/src/git/repo_store.rs
  • crates/gitlawb-node/src/git/tigris.rs
  • crates/gitlawb-node/src/main.rs
🚧 Files skipped from review as they are similar to previous changes (5)
  • crates/gitlawb-node/src/main.rs
  • crates/gitlawb-node/src/db/mod.rs
  • crates/gitlawb-node/src/api/pulls.rs
  • crates/gitlawb-node/src/config.rs
  • crates/gitlawb-node/src/api/issues.rs

Comment thread crates/gitlawb-node/src/git/repo_store.rs
Comment thread crates/gitlawb-node/src/git/repo_store.rs
@beardthelion

Copy link
Copy Markdown
Collaborator Author

All four findings are addressed, plus two of the three CodeRabbit items. The branch is rebased onto current main and pushed as five follow-up commits, so the reviewed history is unchanged. 16 of 17 checks are green on 281f0ee (CodeRabbit has not reported yet).

P2, acquire_fresh HEAD handling

Took the first of your two remedies: acquire_fresh now propagates the HEAD error instead of collapsing it, so both freshness paths refuse on the same condition (9337300). The second remedy, dropping acquire_fresh from the auth pre-check, would have reintroduced the bug its comment documents, where a stale local copy hides an author's own issue and 403s a legitimate author.

Worth flagging that this helper has two callers, not one. The advertisement path in repos.rs wraps it in a map_err that bypasses the From chain entirely, so the typed error would have been stringified into a 500 git_error there. That call site now lets only the typed error through and leaves every other failure on exactly its previous behavior, because it also serves the read path and rerouting all of it would move the read path's error vocabulary. issues.rs needed no change; its bare ? already routes correctly.

P2, transient refusals mapping to 500

RepoUnavailable follows the RepoBusy pattern exactly: fieldless type, raised with the operator detail in a context string, its own downcast rung, mapped to a 503 (abc3c17). Both new refusal arms route through it.

P2, repo detail in client bodies

Same commit. The 503 body interpolates nothing, and the test asserts the negative directly: the response body contains the error code and does not contain the repo name or the owner DID. The detail stays in the log at the raise site.

P3, contention logged at error

Fixed at both call sites (07e4254). The second one matters: the acquire_fresh change above means the advertisement path now raises the same expected-transient class, so fixing only the acquire_write site would have shipped a new source of error-level noise for a condition this series just classified as ordinary. The classifier follows the startup path's permanent-versus-transient split, and anything it cannot classify still logs at error, so an unknown failure keeps paging.

One deliberate asymmetry: the 300s under-lock timeout logs at error at its raise site, not warn, with a comment saying why. It is not an ordinary blip, it held a lock-pool slot for five minutes, and it needs to keep paging through the handler demotion.

CodeRabbit items

Fixed: the swallowed get_issue error is now logged, with the fail-closed 403 unchanged (d4d2766), and the 300ms sleep is now a poll on a standalone connection (281f0ee).

Declined: jitter on the lock retry backoff. The node crate has no direct rand or fastrand dependency (the only rand in the tree is a libp2p-identity feature flag), so this means adding one for a fairness improvement, on a loop that two open PRs already touch. Happy to revisit if you think the contention case justifies it.

The two decisions you asked for

Connection budget. It fits, and the numbers are measured rather than estimated. Postgres gives 97 usable connections (100 minus the 3 superuser reserve), verified against a running instance, and nothing in the compose file or the Terraform template overrides max_connections. The shipped default topology is one Postgres per node, since use_rds defaults to false and the compose template only points at an external host when an operator opts in, so the node count multiplier is 1 and 52 of 97 leaves 45 spare. It breaks only on a shared external database, which is opt-in.

You are right that the missing piece is boot enforcement rather than the number. That belongs in Config::validate, which does not exist on main; it is in #174. Rather than build a second one here and put two open PRs on config.rs at once, it goes in as a clause on the existing validator once #174 lands. Worth noting the existing validator will need retargeting at the same time: its floor keys on the main pool, which is correct today but becomes the wrong pool once writes move to the dedicated one.

Timeout set. Not raising the Fly idle timeout. The 120 is deliberate and the config comment ties it to the 2026-06-12 outage, where long idle windows let hung clients pin connection slots. Not lowering the transfer bound either, since that is what stops a stalled transfer from pinning a lock-pool slot. The real reconciliation needs a different mechanism, and the code comment that said it was tracked separately was tracking nothing, so it is now #299.

On the test seam, and a correction

The earlier draft of this work recorded the wiring as unprovable without an object-store abstraction. That was wrong. RepoStore::new is public and takes the client, so a test-only constructor pointed at a closed port makes a failed HEAD reachable in process with no new dependency and no trait. Both refusals are now executed rather than read-verified, including the under-lock arm, and both tests run in under a second.

Two things remain read-verified and are recorded rather than implied: the timeout arm needs a hang rather than an error, so a refused connection cannot reach it, and nothing joins the store-layer raise to the handler-layer mapping end to end. That second one is #302, and it is cheaper than it looks because a router harness for the advertisement handler already exists.

Also a correction to something I would otherwise have claimed here. Refusing at the advertisement is not strictly cheaper than uploading a pack first. If a storage blip ends between the advertisement and the POST, the push succeeds today and will not after this change, and that window is the pack-upload duration, so it widens with push size. It is still the right call, because the alternative is advertising refs from a tree the write may not be allowed to use, but it is a real behavior change on a read surface and on the close-issue pre-check, where there is no pack upload to save at all.

Filed rather than fixed

Verification of the surrounding code turned up three things that are not in scope here: #300 (a failed HEAD on a cache miss renders a populated repo as an empty 200 on the read endpoints, which is worse than the 500 I first assumed), #301 (the advertisement leg runs an unbounded git subprocess where the other two legs are bounded), and #302 above.

@beardthelion
beardthelion requested a review from jatmn August 3, 2026 17:47
@beardthelion
beardthelion force-pushed the fix/279-advisory-lock-session-affinity branch from 281f0ee to 358dbe9 Compare August 4, 2026 01:56

@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.

🧹 Nitpick comments (1)
crates/gitlawb-node/src/git/repo_store.rs (1)

1631-1652: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the typed refusal instead of an outer timeout.

This test uses the default 90-second LOCK_ACQUIRE_DEADLINE and asserts only that the 8-second outer tokio::time::timeout fired. That assertion passes for any reason the future did not finish in 8 seconds, including a lock-pool stall unrelated to advisory-lock exclusion. It also adds 8 seconds to every suite run.

with_lock_acquire_deadline already exists and is used by contended_acquire_sheds_as_repo_busy_not_internal_error. Apply it here and assert the RepoBusy downcast, so the test proves exclusion positively and finishes in well under a second.

♻️ Proposed change
-        let store = write_store(&pool, &opts).await;
+        let store = write_store(&pool, &opts)
+            .await
+            .with_lock_acquire_deadline(std::time::Duration::from_millis(300));
 
         let _first = store
             .acquire_write("did:key:z6MkU3Excl", "same-repo")
             .await
             .expect("first writer acquires");
 
-        let second = tokio::time::timeout(
-            std::time::Duration::from_secs(8),
-            store.acquire_write("did:key:z6MkU3Excl", "same-repo"),
-        )
-        .await;
-
-        assert!(
-            second.is_err(),
-            "second writer must NOT be admitted while the first holds the guard \
-             (it should still be retrying when the deadline hits)"
-        );
+        let err = match store.acquire_write("did:key:z6MkU3Excl", "same-repo").await {
+            Err(e) => e,
+            Ok(second) => {
+                second.release(false).await;
+                panic!("a second writer must NOT be admitted while the first holds the guard");
+            }
+        };
+        assert!(
+            err.downcast_ref::<RepoBusy>().is_some(),
+            "the second writer must be shed as RepoBusy, got {err:#}"
+        );
🤖 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 1631 - 1652, Update
two_writers_on_the_same_repo_are_not_both_admitted to configure a short deadline
via with_lock_acquire_deadline, matching
contended_acquire_sheds_as_repo_busy_not_internal_error. Replace the outer
tokio::time::timeout assertion with an assertion that the second acquire_write
call returns the typed RepoBusy refusal, while preserving the first writer’s
active guard.
🤖 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.

Nitpick comments:
In `@crates/gitlawb-node/src/git/repo_store.rs`:
- Around line 1631-1652: Update
two_writers_on_the_same_repo_are_not_both_admitted to configure a short deadline
via with_lock_acquire_deadline, matching
contended_acquire_sheds_as_repo_busy_not_internal_error. Replace the outer
tokio::time::timeout assertion with an assertion that the second acquire_write
call returns the typed RepoBusy refusal, while preserving the first writer’s
active guard.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5ca977d5-6794-4baa-baf0-9349aa9c6653

📥 Commits

Reviewing files that changed from the base of the PR and between 281f0ee and 358dbe9.

📒 Files selected for processing (10)
  • .env.example
  • crates/gitlawb-node/src/api/issues.rs
  • crates/gitlawb-node/src/api/pulls.rs
  • crates/gitlawb-node/src/api/repos.rs
  • crates/gitlawb-node/src/config.rs
  • crates/gitlawb-node/src/db/mod.rs
  • crates/gitlawb-node/src/error.rs
  • crates/gitlawb-node/src/git/repo_store.rs
  • crates/gitlawb-node/src/git/tigris.rs
  • crates/gitlawb-node/src/main.rs
🚧 Files skipped from review as they are similar to previous changes (8)
  • crates/gitlawb-node/src/error.rs
  • crates/gitlawb-node/src/main.rs
  • crates/gitlawb-node/src/git/tigris.rs
  • crates/gitlawb-node/src/api/pulls.rs
  • crates/gitlawb-node/src/db/mod.rs
  • crates/gitlawb-node/src/config.rs
  • crates/gitlawb-node/src/api/repos.rs
  • crates/gitlawb-node/src/api/issues.rs

@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.

Rechecked head 358dbe97 after your follow-up commits. The core session-affinity fix looks ready; the prior P2 items from my earlier review are addressed on this head. One gap remains in the new RepoUnavailable error layer.

Findings

  • [P2] Map transient acquire_fresh download failures to RepoUnavailable, not HTTP 500
    crates/gitlawb-node/src/git/repo_store.rs:180-191, crates/gitlawb-node/src/api/repos.rs:579-594, crates/gitlawb-node/src/api/issues.rs:258-261
    acquire_fresh now refuses a failed Tigris HEAD as RepoUnavailable (retryable 503), but a failed GET when no local copy exists still returns a plain anyhow error. On git-receive-pack info/refs, the map_err closure only routes RepoUnavailable through AppError::from; every other failure is stringified to AppError::Git → 500. A transient object-storage GET blip during push advertisement therefore returns a non-retryable 500 while a HEAD blip on the same path returns retryable 503 — inconsistent client semantics within one endpoint. close_issue's non-owner pre-check has the same split via bare ?. Please raise download failures that leave storage state unknowable (archive present per HEAD, GET failed, no local fallback) as RepoUnavailable, matching the HEAD arm and the under-lock refresh path.

  • [P3] Tighten two_writers_on_the_same_repo_are_not_both_admitted to assert RepoBusy
    crates/gitlawb-node/src/git/repo_store.rs:1631-1651
    This acceptance test still wraps the second acquire_write in an 8-second outer tokio::time::timeout and only checks that the future did not finish. That passes for unrelated stalls (lock-pool saturation, slow CI) and adds ~8s to every suite run. CodeRabbit's suggestion still applies: use with_lock_acquire_deadline (as contended_acquire_sheds_as_repo_busy_not_internal_error already does) and assert the typed RepoBusy downcast while the first guard remains held.

Prior review items — verified fixed on this head

  • acquire_fresh HEAD failures now propagate as RepoUnavailable instead of unwrap_or(false) (aef72fa).
  • Under-lock HEAD/timeout refusals map to retryable 503 via RepoUnavailable with fixed bodies (d4c7af6).
  • acquire_write / info_refs contention and expected transient failures log at warn, not error (2cfee3d, repos.rs:579-584, 969-974).
  • close_issue pre-check logs get_issue I/O failures while keeping fail-closed 403 (07d98af).
  • Release-invariant test polls pg_stat_activity instead of sleeping 300ms (358dbe97).

Maintainer decisions (unchanged)

  • Proxy idle timeout vs composed write budgets. Fly idle_timeout = 120 vs defaults of 90s lock wait, 300s under-lock transfer (twice on a full push), and 600s git service work. Please confirm the intended production limit set or document the accepted failure mode when the edge drops first (#299).
  • Fleet Postgres connection budget. Defaults now open up to 52 connections per node (20 + 32) with no startup validation. Your measured single-node topology fits; please confirm fleet sizing for shared external Postgres or adjust defaults before broad rollout. Boot-time enforcement deferred to #174 is still the right place.

Tracked follow-up (not blocking this PR)

  • #283 — orphaned Tigris extraction after under-lock transfer timeout. Refusing the acquire on timeout is strictly better than proceeding; the uncancellable spawn_blocking swap can still race a later writer. Keep #283 open.
  • #300acquire() still swallows Tigris HEAD errors via unwrap_or(false) on read paths. Pre-existing; out of scope here but now inconsistent with the freshness paths this series fixed.

What looks good

The advisory-lock leak is fixed correctly: the guard owns the lock-holding session, LockProbe closes on cancellation, unlock results are observed, pool splitting is wired, and the pg_locks regression tests are load-bearing. close_issue authorization reorder and under-guard re-authorization close the wedge this series introduced. All 17 CI checks are green on head.

…available

acquire_fresh already refused a failed Tigris HEAD as RepoUnavailable, so the
handler layer mapped it to a retryable 503. A failed GET on the same path still
returned a bare anyhow error, which the info/refs map_err closure stringified to
AppError::Git and answered 500. One endpoint therefore told the client a
transient object-storage blip was permanent or retryable depending on which call
failed, and close_issue's pre-check inherited the same split through its bare ?.

Raise it at the source instead of at each consumer: From<anyhow::Error> for
AppError already downcasts RepoUnavailable out of the context chain, so both
callers pick up the retryable mapping without touching either.

The new test drives HEAD 200 with GET 500, which is the exact state the refusal
is for: archive present per HEAD, GET failed, no local copy to fall back on.
…ing out

two_writers_on_the_same_repo_are_not_both_admitted wrapped the second
acquire_write in an 8-second outer timeout and only checked the future had not
finished. That passes for any stall, including lock-pool saturation or a slow
CI box, so it could not tell a working shed from an unrelated hang, and it cost
8 seconds on every suite run.

Use with_lock_acquire_deadline and assert the typed RepoBusy downcast while the
first guard is still held, matching what contended_acquire_sheds_as_repo_busy_
not_internal_error already does. It now also fails loudly if a second writer is
admitted, which the timeout version could not distinguish.
@beardthelion

Copy link
Copy Markdown
Collaborator Author

Both findings are fixed on bc00199.

[P2] Fresh download failures now refuse as RepoUnavailable. Raised at the source in acquire_fresh rather than patched at each consumer: From<anyhow::Error> for AppError (error.rs:98) already downcasts RepoUnavailable out of the context chain, so the info/refs closure and close_issue's bare ? both pick up the retryable mapping with no edit. The new test drives HEAD 200 with GET 500, which is the archive-present, GET-failed, no-local-fallback state you named.

I checked that test is load-bearing rather than trusting it green. Reverting the raise back to return Err(e).context("downloading repo from tigris (fresh)") turns it red at repo_store.rs:2244:

the refusal must be typed so the handler layer maps it to a retryable 503,
got downloading repo from tigris (fresh): tigris GET repos/v1/.../freshrepo.tar.zst: service error

That also confirms the downcast survives the .context() wrap, which is the part the single-site fix depends on.

[P3] The contention test asserts the typed refusal. two_writers_on_the_same_repo_are_not_both_admitted now uses with_lock_acquire_deadline(300ms) and asserts the RepoBusy downcast while the first guard is held, matching contended_acquire_sheds_as_repo_busy_not_internal_error. It fails loudly if a second writer is admitted, which the outer timeout could not tell apart from a stall. The three targeted tests finish in 1.14s.

fmt, clippy --locked --workspace --all-targets -D warnings, and deny_harness pass on the pushed head.

Still open on my side and not code: the proxy idle timeout against the composed write budgets, and the fleet Postgres connection budget. Both are decisions rather than fixes, so I'll answer them on their own rather than fold them into a resolution round. #283 and #300 stay open as tracked.

@beardthelion
beardthelion requested a review from jatmn August 9, 2026 05:16

@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] Do not refresh the live repository before acquiring the write guard
    crates/gitlawb-node/src/api/issues.rs:238-261
    The new non-owner pre-check calls acquire_fresh before taking the advisory lock. That call downloads and publishes directly into local_path; its publish step removes the existing repository directory and renames the extracted copy into place (tigris.rs:240-250), while the guard only serializes Postgres writers. Any signed non-owner can trigger that refresh before get_issue rejects them, concurrently with git_receive_pack or another guarded write on the same path. The refresh can therefore delete/swap the directory under an in-flight write. Use a non-mutating snapshot for the authorship pre-check, or coordinate this refresh/publish with the same write exclusion.

  • [P1] Do not unlock while a timed-out upload can still publish
    crates/gitlawb-node/src/git/repo_store.rs:845-864
    tokio::time::timeout drops the client future, but does not establish that the S3 PUT stopped; the comment correctly notes that it may finish later. The guard then unlocks, letting writer B refresh, modify, and upload the newer archive, after which A's late PUT can overwrite the one object key with A's older archive. A later node refresh then loses B's acknowledged update. Keep serialization until the publication outcome is known, or fence/version/conditionally publish so an abandoned upload cannot become visible after a successor.

  • [P2] Enforce the lock-acquire deadline around each database await
    crates/gitlawb-node/src/git/repo_store.rs:254-295
    The remaining budget is checked only before lock_pool.acquire().await; the pool checkout and the subsequent pg_try_advisory_lock query are not bounded by left. A checkout that begins just before the 90-second deadline may wait the full independently configurable DB acquire timeout (or a slow query may complete after the deadline), and a late successful query is accepted. This violates the advertised wall-clock cap and lets saturated/slow DB paths keep write tasks beyond the retry budget. Apply the remaining deadline to both awaits and reject any late acquisition.

…snapshot

The non-owner author fallback refreshed through acquire_fresh, which publishes
into the live repo directory: its extract step removes the existing directory
and renames the new one into place. That runs with no write lock held, so any
signed non-owner could trigger a directory swap underneath an in-flight
guarded write on the same path.

read_snapshot downloads to a throwaway temp dir and hands back a RepoSnapshot
that removes it on drop, so the pre-check still sees fresh data and the live
path is never touched. download_to grows a publish flag to serve both shapes
from one path.

The wedge invariant still holds: a stranger is refused without waiting on the
write lock.
The remaining budget was checked before the pool checkout but bounded neither
the checkout nor the pg_try_advisory_lock query that follows it. A checkout
starting just under the deadline could wait out the pool's own acquire timeout,
and a slow query could be accepted after the budget was spent, so the advertised
wall-clock cap held only on the fast path.

Both awaits now run under the remaining budget and shed as RepoBusy when it runs
out. The probe's Drop closes its session, which cannot hold a lock it never
confirmed taking, so the query-timeout arm is a plain shed.
The fence work landing next is only as good as what the tests can observe, and
a mock that answers 200 to every PUT would make the whole suite vacuous. This
one holds the object and its ETag, refuses a mismatched If-Match and an
If-None-Match "*" over an existing object with 412, and mints a fresh ETag per
successful PUT so two byte-identical archives never share a token.

Capture-then-replay is deliberate rather than parking a handler and hoping it
resumes: when tokio drops an SDK future the client can tear the connection down
and cancel the server task with it. Replaying what arrived models the arm that
matters (body fully transmitted, commit decided later) with no timing in it.

Six tests pin the semantics in both directions so a hollowed mock cannot hide.
The timeout arm claimed that keeping the advisory lock protected a successor
from an abandoned PUT. It does not. release takes mut self, so the guard drops
the moment it returns and Drop closes the session; measured on this branch, a
successor took the same repo's lock 5ms after release returned while the PUT
was still in flight.

The comment and warn now say what is actually true: the outcome is unknowable,
the PUT may still land, the lock releases normally, and a conditional upload is
what keeps a late publish from overwriting a successor's archive.

Two tests replace the claim. Session disposition is the observable that
separates the two shapes, so the unlock is pinned to run and be confirmed on the
guard's own session with the connection returned to the pool, checked by backend
pid. Successor admission is pinned too, but noted as not what proves the point,
since the lock frees within milliseconds either way.
…rite

upload takes an UploadPrecondition (IfMatch, IfAbsent, Unconditional) rather
than an Option<String>, so the absent case and the deliberate no-fence case are
distinguishable at the type level and a caller cannot lose the fence by passing
None. head_etag reads the current ETag alongside exists, which keeps exists and
its other callers untouched.

A failed precondition has to be classified off the raw HTTP status: PutObjectError
models no PreconditionFailed variant, so a 412 arrives as Unhandled with nothing
useful on it. 412 is always a lost precondition and 409 is one under IfAbsent.
404 deliberately is not: no archive delete exists on this line, so a 404 on a
conditional PUT means a wrong bucket or endpoint, and reporting that as retryable
would send clients into a loop against a permanent fault.

The three background uploads outside the write guard now publish with IfAbsent.
They fire only where the archive is expected absent, and leaving them
unconditional would defeat the fence from the side: init uploads an empty bare
repo, so a push landing just before it could have its archive replaced by that
empty one. A refusal there means someone else already published the key, which is
logged as the correct outcome rather than a failure.
acquire_write now reads the archive's ETag under the advisory lock and the guard
carries it, so release publishes conditionally on the generation it actually
refreshed from. An upload abandoned by an earlier writer no longer overwrites a
successor's archive: the store rejects it, because the ETag it was written
against is gone.

A refused precondition gets exactly one supersede-retry, never a loop. The
distinction that makes this sound is that a 412 is a definite answer, unlike the
timeout arm where nothing is knowable, and the retrying writer still holds the
lock, so whatever landed underneath was written without one and its tree is not
the authority. Two losses in a row refuse instead of escalating.

That retry is what keeps ordinary pushes working now that init publishes
create-only: a first push racing init's upload of the empty repo loses once and
then wins, rather than surfacing a 503 on the most common operation there is.

release returns a must-use outcome and the four publishing handlers propagate it
before any trust bump, webhook, or success body, so a publish the store refused
reads as a retryable 503 instead of a 201. The three release(false) sites
deliberately do not map it: they publish nothing, and a 503 there would shadow
the 403 or 404 the route means to return.

The download-failure fallback also publishes fenced now. That arm knows the
stored generation (its HEAD succeeded, only the GET failed), so publishing
unconditionally from it would reintroduce the same overwrite.
… real backend

The headline test is the one that had to exist. Writer A's release is parked past
its transfer bound and returns with the outcome unknowable, B acquires and
publishes, and A's captured PUT is then replayed: the store answers 412 and B's
archive survives. The create-only variant covers the arm whose real-world failure
is silent rather than loud.

The control is what makes those attributable. With no interleaved B, an
abandoned-then-replayed PUT whose generation still matches lands. Without it the
headline would only show that replays get rejected, not that staleness is what
rejects them.

Header assertions come last in all three, so a lost fence reds on the outcome it
is about rather than on a wire-format check.

A mock cannot prove Tigris honors any of this, and the vendor requires a
Single-region or Multi-region bucket for conditional operations, so against a
Global or Dual-region bucket the fence is a silent no-op. The credentials-gated
probe checks both arms against the real endpoint and cleans up unconditionally,
including when an assertion fails, which is the case it exists to catch. It
accepts 412 or 409 on the create-only arm because both mean the precondition was
enforced and both are already classified as a loss.
@beardthelion

Copy link
Copy Markdown
Collaborator Author

All three findings are addressed on ea5af98, as seven commits rather than a fixup, because P1b turned out to need a different mechanism than the one I first reached for.

[P1] The author pre-check no longer touches the live directory. RepoStore::read_snapshot downloads to a throwaway temp dir and returns a guard that removes it on drop, so the pre-check still reads fresh data and the publish step never runs against local_path. read_snapshot_is_non_mutating asserts the snapshot path differs from the live path, that the live path is never created, and that the temp dir is gone after drop. The wedge invariant survives: stranger_is_refused_without_waiting_on_the_write_lock still passes, which is what ruled out the simpler fix of moving the check under the lock.

[P2] The deadline now bounds both awaits. The pool checkout and the pg_try_advisory_lock query each run under the remaining budget and shed as RepoBusy, so the advertised wall-clock cap no longer holds only on the fast path.

[P1b] You were right that unlocking is the wrong place to fix this, and my first attempt was wrong too. I initially kept the lock held on the timeout arm. That fences nothing, and I should have proven it before writing it: release takes mut self, so the guard drops the moment it returns and Drop closes the session. Measured with the upload parked for 10s behind a 200ms bound, a successor took the same repo's lock 5ms after release returned while the PUT was still in flight. That mechanism is deleted.

The fence is now on the publish itself, which is the only place that can actually reject a stale write. acquire_write reads the archive's ETag under the lock, the guard carries it, and release publishes conditionally on that generation. An abandoned PUT loses because the generation it was written against is gone. Driven end to end rather than argued: A's release is parked past its bound and returns with the outcome unknowable, B acquires and publishes, then A's captured PUT is replayed and the store answers 412 with B's archive intact. The create-only arm has its own test, and a control case pins that an abandoned PUT whose generation still matches does land, so the headline result is attributable to staleness rather than to replay.

Two consequences worth flagging, since neither was in your findings:

The three background uploads outside the write guard (init, acquire's backfill, release_after_write) were publishing unconditionally, which would have let our own code defeat the fence. init uploads an empty bare repo, so a push landing just before it could have had its archive replaced by that empty one. All three now publish create-only, and a refusal there is logged as the correct outcome rather than a failure.

Because init is now create-only, a first push to a fresh repo can lose the race against it. So a lost precondition gets exactly one supersede-retry: the writer still holds the lock, so it re-reads the ETag and republishes once, and a second loss refuses. At most two PUT attempts, ever. That keeps an ordinary first push working instead of surfacing a 503 on the most common operation there is.

A refused publish is surfaced rather than logged and dropped. release returns a #[must_use] outcome and the four publishing handlers propagate it before any trust bump, webhook, or success body, so a publish the store refused reads as a retryable 503 instead of a 201. The three release(false) sites deliberately do not map it, since they publish nothing and a 503 there would shadow the 403 or 404 the route means to return.

One classification call worth your eye: a 404 on a conditional PUT is treated as permanent, not as a lost precondition. AWS documents 404 for a delete racing a conditional write, but TigrisClient::delete has zero callers on this line, so that race cannot arise from our own code, and a 404 here means a wrong bucket or endpoint. Reporting that as retryable would send clients into a loop against a permanent fault. 409 under create-only is folded in, since that one is a genuine conflict.

Verification: the full suite passes locally, and fmt, clippy --locked, and cargo metadata --locked are clean, so the lockfile will not fail CI. Every guard added here was proven load-bearing by injecting the exact defect it names and confirming the named test goes red, 9 of 9. The uncontended-write test was separately confirmed to stay green under the same mutation that reddens the fence, so it pins the do-not-spuriously-refuse property rather than restating the fix.

Direction, not verified: the fence is checked against vendor documentation and a mock that implements the conditional semantics, not against the real backend. Tigris requires a Single-region or Multi-region bucket for conditional operations; against a Global or Dual-region bucket an ignored If-None-Match returns 200 and a publish that should have been fenced would land silently. There is a credentials-gated probe (tigris_honors_conditional_writes) that checks both arms against the real endpoint and cleans up unconditionally, but it has not been run. Worth settling before this is trusted in production.

#283 stays deferred, and no migration was added.

@beardthelion
beardthelion requested a review from jatmn August 10, 2026 12:08

@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.

Actionable comments posted: 1

🧹 Nitpick comments (2)
crates/gitlawb-node/src/git/tigris.rs (2)

242-250: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider replacing the publish boolean with an explicit mode.

download_to changes both its mutation behavior and the meaning of its return value based on publish. At a call site, true and false carry no meaning without reading the doc comment. An enum such as ExtractMode::Publish and ExtractMode::Snapshot names both variants at the call site.

🤖 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/tigris.rs` around lines 242 - 250, Replace the
boolean publish parameter in download_to with an explicit extraction mode enum,
defining named variants for publish and snapshot behavior. Update download_to’s
branching, return-value handling, and all call sites to use the corresponding
mode variants while preserving existing behavior.

268-307: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider extracting the shared temp-dir unpack step.

Lines 288-299 repeat decompress_repo lines 378-391 exactly: create a unique temp dir, unpack the archive, and remove the temp dir on failure. Only the directory-name infix and the final swap differ. A shared helper such as unpack_to_temp_dir(data, parent, prefix) -> Result<PathBuf> would let decompress_repo call it and then perform the swap.

Line 306 also logs path = %target.display() in snapshot mode, but the bytes landed in extracted. Log extracted instead so the message names the directory that was populated.

🤖 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/tigris.rs` around lines 268 - 307, The
temporary-directory extraction logic duplicated in the non-publish branch and
decompress_repo should be moved into a shared helper such as unpack_to_temp_dir,
parameterized by archive data, parent directory, and naming prefix; have
decompress_repo reuse it before performing its existing swap. In the download
log, update the path field to use extracted rather than target so snapshot mode
reports the populated directory.
🤖 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.

Inline comments:
In `@crates/gitlawb-node/src/git/tigris.rs`:
- Around line 185-216: Update the status extraction in the request error
handling around UploadPrecondition and RepoWriteGuard::publish to use
SdkError::raw_response() for both service and response error variants. Ensure
unparsable 409 and 412 responses are classified as lost preconditions so the
existing supersede retry remains reachable, while preserving the current
status-based behavior for other errors.

---

Nitpick comments:
In `@crates/gitlawb-node/src/git/tigris.rs`:
- Around line 242-250: Replace the boolean publish parameter in download_to with
an explicit extraction mode enum, defining named variants for publish and
snapshot behavior. Update download_to’s branching, return-value handling, and
all call sites to use the corresponding mode variants while preserving existing
behavior.
- Around line 268-307: The temporary-directory extraction logic duplicated in
the non-publish branch and decompress_repo should be moved into a shared helper
such as unpack_to_temp_dir, parameterized by archive data, parent directory, and
naming prefix; have decompress_repo reuse it before performing its existing
swap. In the download log, update the path field to use extracted rather than
target so snapshot mode reports the populated directory.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f932b365-e4b9-442c-bb9a-58a6ed92a923

📥 Commits

Reviewing files that changed from the base of the PR and between bc00199 and ea5af98.

📒 Files selected for processing (6)
  • crates/gitlawb-node/src/api/issues.rs
  • crates/gitlawb-node/src/api/pulls.rs
  • crates/gitlawb-node/src/api/repos.rs
  • crates/gitlawb-node/src/error.rs
  • crates/gitlawb-node/src/git/repo_store.rs
  • crates/gitlawb-node/src/git/tigris.rs
🚧 Files skipped from review as they are similar to previous changes (3)
  • crates/gitlawb-node/src/api/pulls.rs
  • crates/gitlawb-node/src/api/repos.rs
  • crates/gitlawb-node/src/api/issues.rs

Comment on lines +185 to +216
if let Err(e) = req.send().await {
// `PutObjectError` models no PreconditionFailed variant (its arms are
// EncryptionTypeMismatch, InvalidRequest, InvalidWriteOffset,
// TooManyParts, Unhandled), so a refused precondition arrives as
// `Unhandled` and matching the enum would classify it as a generic
// failure. The raw HTTP status off the service-error response is the
// only place the answer actually lives.
let status = match &e {
SdkError::ServiceError(ctx) => Some(ctx.raw().status().as_u16()),
_ => None,
};
// 412 is always a lost precondition. 409 is one only when we asked
// for create-only, which is how S3-compatible stores report "the key
// already exists". Everything else, 404 included, is a real failure:
// archive keys are never deleted (`delete` has no callers), so a 404
// here means something permanent like a missing bucket or a
// misrouted endpoint, and reporting that as a lost precondition
// would tell a caller to expect a successor that does not exist.
let lost = match status {
Some(412) => true,
Some(409) => matches!(precondition, UploadPrecondition::IfAbsent),
_ => false,
};
if lost {
return Err(UploadError::PreconditionLost {
status: status.expect("a lost precondition came from a status"),
});
}
return Err(UploadError::Other(
anyhow::Error::new(e).context(format!("tigris PUT {key}")),
));
}

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.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

aws-sdk-s3 Rust SdkError ResponseError vs ServiceError when error response body cannot be parsed

💡 Result:

In the AWS SDK for Rust, the SdkError enum distinguishes between errors received from the service and errors that occur during the handling of the HTTP response [1][2]. When an error response body cannot be parsed (e.g., malformed XML/JSON, unexpected content, or premature connection closure), the SDK returns SdkError::ResponseError [1][2]. ServiceError vs. ResponseError - SdkError::ServiceError(ServiceError<E, R>): This variant indicates that the service successfully returned a structured error response that the SDK was able to parse [1][2]. The ServiceError contains the modeled error type (E) and the raw HTTP response (R) [3]. - SdkError::ResponseError(ResponseError): This variant indicates that the SDK received a response from the service, but it failed to parse the payload according to the expected protocol (e.g., XML/JSON decoding failure) [1][2]. The ResponseError includes the raw response (R) and the underlying parsing error [3]. If the SDK cannot parse the error response body, it cannot identify the specific service error (like NoSuchKey for S3), so it must return a ResponseError rather than a ServiceError [1][2]. Consequently, you cannot use methods like into_service_error or as_service_error on this variant, as no modeled error (E) exists [2][3]. You can access the raw response via the raw_response method to manually inspect the body or headers for debugging purposes [2][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- dependency declarations ---'
rg -n 'aws-sdk-s3|aws-smithy-runtime-api|aws-smithy-types' Cargo.toml '**/Cargo.toml' 2>/dev/null || true
printf '%s\n' '--- classifier and call site ---'
sed -n '175,222p' crates/gitlawb-node/src/git/tigris.rs
sed -n '1040,1125p' crates/gitlawb-node/src/git/repo_store.rs
printf '%s\n' '--- SdkError references ---'
rg -n 'SdkError::(ServiceError|ResponseError)|raw_response|raw\(\)' crates Cargo.toml '**/Cargo.toml' 2>/dev/null || true

Repository: Gitlawb/node

Length of output: 7283


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- manifest and lockfile locations ---'
git ls-files '*Cargo.toml' '*Cargo.lock'
printf '%s\n' '--- relevant dependency entries ---'
rg -n -C 3 'aws-sdk-s3|aws-smithy-runtime-api' --glob 'Cargo.toml' --glob 'Cargo.lock' .
printf '%s\n' '--- surrounding status handling ---'
sed -n '440,490p' crates/gitlawb-node/src/git/tigris.rs
sed -n '3290,3325p' crates/gitlawb-node/src/git/repo_store.rs

Repository: Gitlawb/node

Length of output: 9238


🌐 Web query:

aws-smithy-runtime-api 1.11.6 SdkError raw_response ResponseError ServiceError Rust docs

💡 Result:

In the aws-smithy-runtime-api crate (version 1.11.6), SdkError is a core enum used to represent failures during the execution of an SDK request [1][2]. It is defined in the aws_smithy_runtime_api::client::result module [1][3]. The SdkError enum contains several variants, including ResponseError and ServiceError [1][4]: 1. SdkError::ResponseError(ResponseError): This variant occurs when a response is received from the server, but it cannot be parsed according to the expected protocol (e.g., the server closed the connection prematurely) [1][4][5]. It contains the error encountered during parsing and the raw response (R) [6]. 2. SdkError::ServiceError(ServiceError<E, R>): This variant represents an explicit error response returned by the service (e.g., a 404 Not Found or an unauthorized exception from an AWS service) [4][5][7]. It encapsulates the modeled service error (E) and the raw response (R) [6]. The SdkError type is generic over the service error type (E) and the raw response type (R) [1][4]. Most AWS SDK clients re-export this type, typically fixing R to be the appropriate HTTP response type for that specific service [5]. The crate itself is intended for use by the Smithy code generator and library authors, rather than direct use in application code [2].

Citations:


Classify raw-response errors by HTTP status

SdkError::ResponseError can contain an unparsable 409 or 412 response. Use SdkError::raw_response() to read the status for both response variants; otherwise, RepoWriteGuard::publish skips the supersede retry.

🤖 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/tigris.rs` around lines 185 - 216, Update the
status extraction in the request error handling around UploadPrecondition and
RepoWriteGuard::publish to use SdkError::raw_response() for both service and
response error variants. Ensure unparsable 409 and 412 responses are classified
as lost preconditions so the existing supersede retry remains reachable, while
preserving the current status-based behavior for other errors.

@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] Rebase this branch onto current main before it can be merged
    The current head ea5af98 is not descended from the PR base 241b366 (its merge-base is c926e1e), and GitHub reports the PR as CONFLICTING. A three-way merge conflicts in .env.example, api/repos.rs, error.rs, repo_store.rs, and tigris.rs; the stale head also lacks current-main hardening such as the #174 admission/cleanup path and opaque internal-error handling. Please rebase and resolve these changes, then request a review of the resolved base-to-head diff rather than merging a conflict resolution that can roll those protections back.

  • [P1] Do not refresh the live repository outside the write exclusion
    crates/gitlawb-node/src/api/repos.rs:566-572, crates/gitlawb-node/src/git/repo_store.rs:200-227
    The receive-pack advertisement still calls acquire_fresh, which downloads and publishes into local_path. That publish removes and renames the live directory, but it does not take the advisory lock. A second advertisement can therefore replace the directory while a guarded receive-pack, merge, or issue write is using it. In the especially bad ordering where the mutation has finished but release has not compressed the tree, the guarded release uploads the replaced old tree with its still-valid ETag and reports success, losing the accepted write. The new snapshot implementation addresses the close_issue pre-check only; use a non-mutating snapshot for advertisement or coordinate this refresh with the same write exclusion.

  • [P1] Bound and authorize the pre-lock issue snapshot
    crates/gitlawb-node/src/api/issues.rs:241-269, crates/gitlawb-node/src/git/tigris.rs:252-304
    Any signed non-owner reaches read_snapshot before the handler establishes authorship or even read access. With Tigris enabled, every such request downloads the entire archive into memory and starts an unbounded blocking extraction into a unique directory; this route has no rate/concurrency limit. Disposable identities can issue parallel close requests for arbitrary issue IDs to exhaust transfer, memory, CPU, and disk. A cancellation while the blocking extraction is running occurs before RepoSnapshot is constructed, so its temp directory is not cleaned up. Require a cheap authorization/author lookup before this work, or explicitly bound and clean up the snapshot work.

  • [P1] Classify raw 409/412 responses as a lost conditional write
    crates/gitlawb-node/src/git/tigris.rs:185-215
    The new durability fence extracts a status only from SdkError::ServiceError, but this SDK exposes a raw response for both ServiceError and ResponseError. A Tigris/S3-compatible conditional PUT rejected with an unparsable 409 or 412 is a ResponseError, so this code returns UploadError::Other; RepoWriteGuard::release then only logs it and returns success instead of taking the retry/fenced-503 path. That acknowledges a write whose archive was definitively not published. Use e.raw_response() for the status and cover malformed-body 409/412 responses.

  • [P2] Preserve the retryable error for a cold-cache under-lock download failure
    crates/gitlawb-node/src/git/repo_store.rs:513-529
    When the under-lock HEAD succeeds but the GET fails on a node without a local copy, this arm returns the bare download error. The handlers route that through AppError::from, which maps it to a 500, unlike the equivalent acquire_fresh condition that is deliberately wrapped as RepoUnavailable and returned as a retryable 503. Wrap this no-local-fallback error in RepoUnavailable as well.

  • [P2] Do not accept a conflicting fork archive as a successful fork
    crates/gitlawb-node/src/git/repo_store.rs:631-650
    The new create-only fork upload treats a lost precondition as success because it assumes a missing DB row proves the object key is absent. Database and object-store writes are not atomic: for example, create_repo initializes and starts its background upload before db.create_repo, so a failed DB insertion can leave a permanent orphan archive. A later fork on a node without that local directory can clone its requested source, lose the If-None-Match upload to the orphan, and still create the DB record; other nodes then fetch the unrelated archive. Surface the conflict/refuse the fork, or make the DB and storage namespace transition coordinated and recoverable.

  • [P2] Recompute the lock-acquire remainder before the retry sleep
    crates/gitlawb-node/src/git/repo_store.rs:422-430
    left is measured before pg_try_advisory_lock; if that query returns false just before the deadline, the following sleep uses the old remainder and can run a full additional second past the advertised wall-clock acquire cap. Recompute the remaining duration immediately before sleeping, and skip the sleep when it has expired.

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.

Repo write exclusion does not work: the advisory lock is unlocked on the wrong session, leaks on every write, and does not exclude a second writer

2 participants