fix(node): release the advisory lock on the session that took it (#279) - #285
fix(node): release the advisory lock on the session that took it (#279)#285beardthelion wants to merge 34 commits into
Conversation
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThe 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. ChangesRepository write controls
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related issues
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (3)
crates/gitlawb-node/src/git/repo_store.rs (2)
1363-1372: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winReplace the fixed 300ms sleep with a poll loop.
PoolConnection::dropspawns the close, so on a loaded CI runner the close may not have completed when the nextacquire()runs — the pool then hands back the same still-open connection and theassert_ne!fails spuriously. Polling until the pid changes (or a generous deadline elapses) makes this deterministic, matching the rationale already used inpoll_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 valueConsider 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_lockgives 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 winReal errors are silently indistinguishable from "not authorized" here.
Ok(None) | Err(_) => Noneis a reasonable fail-closed default for the client, but a genuinegit_issues::get_issuefailure (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 atracing::warn!/debug!on theErr(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
📒 Files selected for processing (9)
.env.examplecrates/gitlawb-node/src/api/issues.rscrates/gitlawb-node/src/api/pulls.rscrates/gitlawb-node/src/api/repos.rscrates/gitlawb-node/src/config.rscrates/gitlawb-node/src/db/mod.rscrates/gitlawb-node/src/error.rscrates/gitlawb-node/src/git/repo_store.rscrates/gitlawb-node/src/main.rs
jatmn
left a comment
There was a problem hiding this comment.
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_freshHEAD 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 inacquire_fresh, but this PR now routesclose_issue's non-owner author pre-check through it whileacquire_writewas fixed to refuse onRefreshFailure::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 onmain(both skipped download and read local), but owners and authors who pass pre-check on stale local can now hit a refusedacquire_write(500) when HEAD fails under the lock — stricter, not looser. Please propagate HEAD errors out ofacquire_freshthe same way the under-lock refresh does, or stop usingacquire_freshfor 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 plainanyhowerrors.AppError::from(anyhow::Error)only downcastssqlx::ErrorandRepoBusy, so these surface asinternal_error/ HTTP 500 even though the comments call them retryable refusals. This is not a regression frommain— acquire failures already mapped to 500 viaAppError::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 theRepoBusypattern) 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, whichAppError::Internalreturns verbatim in the JSONmessage. That contradicts the fixed-body policy you added forRepoBusy.mainalready 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 withRepoBusy. -
[P3] Log expected
acquire_writecontention at warn, not error
crates/gitlawb-node/src/api/repos.rs:939-940
inspect_errlogs everyacquire_writefailure aterrorseverity. Base already logged acquire failures at error, butRepoBusyis new — expected 503 contention now hitstracing::error!whilerepo_store.rslogs the same condition atwarn. Please downgrade or suppress logging forRepoBusy(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 whenbounded_transfertimes out; a late extract can stillremove_dir_all+renameafter 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_freshonclose_issuepre-check —acquire_freshwithout a transfer bound is a pre-existing pattern (repos.rsgit-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 inrepo_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 = 120vs 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— expectedRepoBusylogged at error (see P3 above). - Still open:
issues.rs:262-270— pre-lockgit_issues::get_issueI/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 inrelease_that_did_not_hold_the_lock_closes_the_sessioncan flake on slow CI; poll likepoll_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.
1cc2c7c to
281f0ee
Compare
There was a problem hiding this comment.
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
📒 Files selected for processing (10)
.env.examplecrates/gitlawb-node/src/api/issues.rscrates/gitlawb-node/src/api/pulls.rscrates/gitlawb-node/src/api/repos.rscrates/gitlawb-node/src/config.rscrates/gitlawb-node/src/db/mod.rscrates/gitlawb-node/src/error.rscrates/gitlawb-node/src/git/repo_store.rscrates/gitlawb-node/src/git/tigris.rscrates/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
|
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 P2, acquire_fresh HEAD handlingTook the first of your two remedies: Worth flagging that this helper has two callers, not one. The advertisement path in P2, transient refusals mapping to 500
P2, repo detail in client bodiesSame 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 errorFixed at both call sites ( 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 itemsFixed: the swallowed Declined: jitter on the lock retry backoff. The node crate has no direct The two decisions you asked forConnection 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 You are right that the missing piece is boot enforcement rather than the number. That belongs in 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 correctionThe earlier draft of this work recorded the wiring as unprovable without an object-store abstraction. That was wrong. 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 fixedVerification 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. |
281f0ee to
358dbe9
Compare
There was a problem hiding this comment.
🧹 Nitpick comments (1)
crates/gitlawb-node/src/git/repo_store.rs (1)
1631-1652: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAssert the typed refusal instead of an outer timeout.
This test uses the default 90-second
LOCK_ACQUIRE_DEADLINEand asserts only that the 8-second outertokio::time::timeoutfired. 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_deadlinealready exists and is used bycontended_acquire_sheds_as_repo_busy_not_internal_error. Apply it here and assert theRepoBusydowncast, 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
📒 Files selected for processing (10)
.env.examplecrates/gitlawb-node/src/api/issues.rscrates/gitlawb-node/src/api/pulls.rscrates/gitlawb-node/src/api/repos.rscrates/gitlawb-node/src/config.rscrates/gitlawb-node/src/db/mod.rscrates/gitlawb-node/src/error.rscrates/gitlawb-node/src/git/repo_store.rscrates/gitlawb-node/src/git/tigris.rscrates/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
left a comment
There was a problem hiding this comment.
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_freshdownload failures toRepoUnavailable, 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_freshnow refuses a failed Tigris HEAD asRepoUnavailable(retryable 503), but a failed GET when no local copy exists still returns a plainanyhowerror. Ongit-receive-packinfo/refs, themap_errclosure only routesRepoUnavailablethroughAppError::from; every other failure is stringified toAppError::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) asRepoUnavailable, matching the HEAD arm and the under-lock refresh path. -
[P3] Tighten
two_writers_on_the_same_repo_are_not_both_admittedto assertRepoBusy
crates/gitlawb-node/src/git/repo_store.rs:1631-1651
This acceptance test still wraps the secondacquire_writein an 8-second outertokio::time::timeoutand 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: usewith_lock_acquire_deadline(ascontended_acquire_sheds_as_repo_busy_not_internal_erroralready does) and assert the typedRepoBusydowncast while the first guard remains held.
Prior review items — verified fixed on this head
acquire_freshHEAD failures now propagate asRepoUnavailableinstead ofunwrap_or(false)(aef72fa).- Under-lock HEAD/timeout refusals map to retryable 503 via
RepoUnavailablewith fixed bodies (d4c7af6). acquire_write/info_refscontention and expected transient failures log atwarn, noterror(2cfee3d,repos.rs:579-584,969-974).close_issuepre-check logsget_issueI/O failures while keeping fail-closed 403 (07d98af).- Release-invariant test polls
pg_stat_activityinstead of sleeping 300ms (358dbe97).
Maintainer decisions (unchanged)
- Proxy idle timeout vs composed write budgets. Fly
idle_timeout = 120vs 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_blockingswap can still race a later writer. Keep #283 open. - #300 —
acquire()still swallows Tigris HEAD errors viaunwrap_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.
|
Both findings are fixed on [P2] Fresh download failures now refuse as I checked that test is load-bearing rather than trusting it green. Reverting the raise back to That also confirms the downcast survives the [P3] The contention test asserts the typed refusal. fmt, 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. |
jatmn
left a comment
There was a problem hiding this comment.
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 callsacquire_freshbefore taking the advisory lock. That call downloads and publishes directly intolocal_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 beforeget_issuerejects them, concurrently withgit_receive_packor 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::timeoutdrops 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 beforelock_pool.acquire().await; the pool checkout and the subsequentpg_try_advisory_lockquery are not bounded byleft. 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.
|
All three findings are addressed on [P1] The author pre-check no longer touches the live directory. [P2] The deadline now bounds both awaits. The pool checkout and the [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: The fence is now on the publish itself, which is the only place that can actually reject a stale write. Two consequences worth flagging, since neither was in your findings: The three background uploads outside the write guard ( Because A refused publish is surfaced rather than logged and dropped. 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 Verification: the full suite passes locally, and fmt, 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 #283 stays deferred, and no migration was added. |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
crates/gitlawb-node/src/git/tigris.rs (2)
242-250: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider replacing the
publishboolean with an explicit mode.
download_tochanges both its mutation behavior and the meaning of its return value based onpublish. At a call site,trueandfalsecarry no meaning without reading the doc comment. An enum such asExtractMode::PublishandExtractMode::Snapshotnames 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 valueConsider extracting the shared temp-dir unpack step.
Lines 288-299 repeat
decompress_repolines 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 asunpack_to_temp_dir(data, parent, prefix) -> Result<PathBuf>would letdecompress_repocall it and then perform the swap.Line 306 also logs
path = %target.display()in snapshot mode, but the bytes landed inextracted. Logextractedinstead 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
📒 Files selected for processing (6)
crates/gitlawb-node/src/api/issues.rscrates/gitlawb-node/src/api/pulls.rscrates/gitlawb-node/src/api/repos.rscrates/gitlawb-node/src/error.rscrates/gitlawb-node/src/git/repo_store.rscrates/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
jatmn
left a comment
There was a problem hiding this comment.
I found issues that need to be addressed before this is ready.
Findings
-
[P1] Rebase this branch onto current
mainbefore it can be merged
The current headea5af98is not descended from the PR base241b366(its merge-base isc926e1e), and GitHub reports the PR asCONFLICTING. A three-way merge conflicts in.env.example,api/repos.rs,error.rs,repo_store.rs, andtigris.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 callsacquire_fresh, which downloads and publishes intolocal_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 butreleasehas 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 theclose_issuepre-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 reachesread_snapshotbefore 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 beforeRepoSnapshotis 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 fromSdkError::ServiceError, but this SDK exposes a raw response for bothServiceErrorandResponseError. A Tigris/S3-compatible conditional PUT rejected with an unparsable 409 or 412 is aResponseError, so this code returnsUploadError::Other;RepoWriteGuard::releasethen only logs it and returns success instead of taking the retry/fenced-503 path. That acknowledges a write whose archive was definitively not published. Usee.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 throughAppError::from, which maps it to a 500, unlike the equivalentacquire_freshcondition that is deliberately wrapped asRepoUnavailableand returned as a retryable 503. Wrap this no-local-fallback error inRepoUnavailableas 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_repoinitializes and starts its background upload beforedb.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 theIf-None-Matchupload 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
leftis measured beforepg_try_advisory_lock; if that query returnsfalsejust 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.
…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
…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.
…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.
…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.
…uard shape The rebase onto main carried main's #174 F-series guard tests forward. The branch's guard replaces main's locked/released/test_pre_unlock_gate Drop mechanics with close-on-drop (runtime) and leak (off-runtime), so two tests that asserted the replaced mechanics are reconciled: the off-runtime disposal test now asserts the leak observable (the slot never returns to idle) and the detached-unlock-returns-connection test is dropped, its invariant covered by the U-series drop-frees-the-lock gates. The pre-unlock gate seam is restored (test-only) so the mid-unlock cancellation tests keep their deterministic park. ipfs.rs tests are updated to the branch's sync test-client constructor and 4-arg RepoStore::new. Refs #279
…eries - P1: serve the receive-pack advertisement from a non-mutating snapshot instead of acquire_fresh, so the unlocked advertisement cannot delete or swap the live repo directory under a concurrent guarded write (the stale-ETag success ordering). acquire_fresh loses its only production caller and is removed. - P1: read-gate, rate-limit, bound, and cancellation-clean the close_issue pre-lock snapshot, so a signed non-owner cannot drive unbounded Tigris downloads and blocking extractions, and an abandoned extraction no longer leaks its temp dir. - P1: classify raw 409/412 responses as a lost conditional write via SdkError::raw_response() in both upload and the publish supersede-retry, so an unparsable error body cannot acknowledge a write that was never published. - P2: wrap the cold-cache under-lock download failure in RepoUnavailable so it sheds as a retryable 503 like the HEAD arm, not a permanent 500. - P2: surface a refused create-only fork upload as PreconditionLost and refuse the fork, so an orphan archive cannot shadow a fork's DB record. Refs #279
… row The gate added for the pre-lock snapshot reads a repo row's own visibility rules and public flag. A row synced from a peer is stored public and carries none of the owner's rules, so for that class the gate can only return allow. Refusing instead would deny the repo's real owner and the issue's real author on any node whose only copy is a synced one, and it would not buy the protection it appears to, because a synced row's recorded owner comes from the peer that sent it. Every other read gate in the API reaches the same verdict for such a row, so this is left as is and stated at the call site rather than special-cased here. The test that covered the gate seeds a locally created repo and cannot observe this. Adds one that pins the property directly: the gate allows an arbitrary caller on a synced row, and the handler refuses that caller anyway, because the owner-or-author check is what decides this route.
ea5af98 to
9dd71e9
Compare
|
All six findings are addressed on Rebase. Merge-base is now P1, refresh outside the write exclusion. P1, the unbounded pre-lock snapshot. Bounded on three axes: a read gate before any snapshot work, the per-IP brake so disposable identities cannot drive parallel downloads, and a transfer timeout that sheds a retryable 503, with the temp dir cleaned up on cancellation. The gate is load-bearing: neutering it flips the refusal from 404 to 403. One thing worth your eye there. That gate reads the repo row's own visibility rules and public flag, and a row synced from a peer is stored public with none of the owner's rules, so for that class it can only return allow. I left it that way rather than refusing, because refusing would deny the repo's real owner and the issue's real author on any node whose only copy is a synced one, and it would not buy the protection it appears to, since a synced row's recorded owner comes from the peer that sent it. Every other read gate in the API reaches the same verdict for such a row, so special-casing it here would be inconsistent and would not close the class. It is stated at the call site, and P1, raw 409/412. Both P2, cold-cache download. The no-local-fallback arm in P2, fork orphan archive. P2, remainder recompute. Not addressing: already correct on this head. Full suite green on the pushed head: 879 passed, 0 failed, 1 ignored. One judgment call to flag rather than bury: the rate limit added for the second P1 reuses the shared per-IP push bucket, so close-issue and push advertisements now share a budget. If you would rather they were independent, that is a second limiter on |
jatmn
left a comment
There was a problem hiding this comment.
I found issues that need to be addressed before this is ready.
Findings
-
[P1] Restore the required INV22 gate
crates/gitlawb-node/tests/inv22_gates.rs:462
Both requiredtest (stable)andtest (beta)jobs fail because this source-level invariant still looks forguard.release(push_succeeded), while the handler now passesreceive_result.is_ok()directly. The focusedcargo test -p gitlawb-node inv22_replication_tail_spawns_at_the_durability_boundary --no-default-featuresreproduces the failure. Update the load-bearing gate along with the intended ordering (or restore the shared flag), so this branch has passing required tests. -
[P1] Make the advertised default pool settings pass startup validation
.env.example:28
The example now setsGITLAWB_DB_MAX_CONNECTIONS=20, butConfig::validate()still enforces the old main-pool floor ofGITLAWB_MAX_CONCURRENT_GIT_PUSHES + 8(32 + 8by default) inconfig.rs:608-625. Since startup calls that validation, deploying the supplied example exits immediately as invalid configuration. The new dedicated lock pool makes the old premise stale; revise the validation/docs for the split pools or keep the example at a validating value. -
[P1] Do not start replication before a fenced publish is known to be durable
crates/gitlawb-node/src/api/repos.rs:2062
post_receive_replication_tailis detached as soon asreceive_packsucceeds, beforeguard.release(...).into_result()?can return the newRepoWriteFencedoutcome. When both conditional publishes are refused, the request correctly returns 503 and skips the synchronous downstream effects, but the already-running tail can still pin and announce the local ref that no other node can read from object storage. Delay/condition the tail on a non-fenced release outcome (while preserving the intended disconnect handling) so the new durability guarantee applies to all downstream effects. -
[P1] Preserve typed acquire failures on the receive-pack endpoint
crates/gitlawb-node/src/api/repos.rs:1987
This handler logsRepoBusy/RepoUnavailableas transient but then stringifies everyacquire_writeerror intoAppError::Git. Lock contention, lock-pool shedding, and under-lock storage refresh failures therefore reach Git push clients as detailed 500 responses instead of the fixed retryable 503 variants this PR adds; the issue and pull write paths preserve the types with?. Route the new typed errors throughAppError::from(e)(with the legacy fallback only where needed). -
[P1] Keep snapshot cleanup alive until its blocking extraction has stopped
crates/gitlawb-node/src/git/tigris.rs:297
On a timeout or disconnect,SnapshotCleanupremoves the chosen temporary path immediately, but the detachedspawn_blockingtask can subsequently executecreate_dir_alland unpack the archive into that same path. Nothing owns a second cleanup after that task finishes, so cancelledread_snapshotcalls from receive-pack advertisements or issue-close prechecks can leave full extracted repositories behind. Make the blocking task own cleanup unless ownership is explicitly transferred, or arrange a detached reaper that removes the directory after the task completes. -
[P2] Remove the local fork clone when the create-only upload is refused
crates/gitlawb-node/src/api/repos.rs:2843
A newPreconditionLostresult now returns fromrelease_after_writeaftergit clone --mirrorhas already created the permanentdisk_path. The comment says handler-state drop cleans it up, butdisk_pathis only aPathBuf; no cleanup guard or removal runs. Each archive-key conflict leaves a complete bare clone behind, so retrying such forks leaks disk space. Clone into a temporary directory or explicitly remove the clone on every post-clone error path.
Align .env.example db pool with validate floor, move snapshot cleanup into the blocking extract task, remove fork clones on PreconditionLost, restore upload_site_reached test plumbing, and use owner DIDs in receive-pack tests after #330 owner-push default.
Take a git_read_semaphore permit before the non-owner author snapshot so parallel close attempts cannot each drive a full archive extraction while still under the hourly rate bucket. Format owner-DID test helpers.
Annotate list_visibility_rules with mirror-rows-handled so the pre-push surface detector accepts the synced-row read gate.
|
Merged origin/main and added four follow-up commits on top of 9dd71e9. Local head is 7ba0fef on fix/279-advisory-lock-session-affinity. Fixed on this head:
Declined (unchanged intent):
Checks run: |
Read CappedBody.text in gl whoami (#381 shape), restore legacy CID sweep wiring dropped in the merge, route rate-limiter cleanup through AppState, and allow build_lock_pool for test-only callers.
jatmn
left a comment
There was a problem hiding this comment.
I found issues that need to be addressed before this is ready.
Findings
-
[P2] Carry the new retryable acquire failures through every write handler
crates/gitlawb-node/src/api/repos.rs:1551
This PR addsRepoBusyandRepoUnavailableas typed outcomes fromRepoStore::acquire_write, andAppError::fromdeliberately renders both as fixed-body 503 responses so clients retry ordinary advisory-lock contention and temporary Tigris unavailability. That contract currently reaches receive-pack, butcreate_issue(api/issues.rs:64-69),close_issue(api/issues.rs:387-392), andmerge_pr(api/pulls.rs:212-217) still callacquire_write_app_error. Its only typed branch isLockPoolBusy; its fallback logs at error and constructsAppError::Git(err.to_string()), so these new marker errors instead return agit_error500 (and expose the contextual error text).The root cause is split error classification: receive-pack was updated with the new marker-aware logic while the shared classifier used by the other three
acquire_writeconsumers was not extended. Please centralize or extend that shared classification soRepoBusyandRepoUnavailableretain their existingAppErrormapping for all current callers, while preserving the dedicated lock-pool-overload response and the existing 500 behavior for genuine untyped Git failures. Add handler-level regression coverage for at least one contention and one storage-unavailability refusal through a non-push mutation endpoint, rather than only testing the marker/classifier in isolation.
Review guidance
This series has accumulated review churn because it changes one distributed write contract across several independently evolving layers: session-scoped PostgreSQL locking, cancellation-safe connection lifetime, object-store refresh and conditional publication, temporary snapshots, HTTP error classification, and several API entry points. Each individual repair can be locally correct while leaving the end-to-end behavior inconsistent at a sibling caller. The remaining finding is an example: the storage layer, central AppError conversion, and receive-pack path agree that a transient refusal should be retryable, but the issue and pull mutation paths still use the older shared conversion boundary.
To avoid another feedback round, please do a final contract-driven sweep before requesting re-review rather than addressing the visible call site alone:
- Start from each new outcome introduced by this PR (
RepoBusy,RepoUnavailable, andRepoWriteFenced) and enumerate every producer, wrapper,map_err,?conversion, handler, response renderer, test, and client-visible side effect it can reach. Verify the status code, error code, response body, retry semantics, logging level, and post-write effects at each route. - Treat every
acquire_writeandrelease(...).into_result()caller as part of one lifecycle contract. For each, trace acquire → refresh/download → local mutation → publish/fence → release → database/webhook/replication/trust/HTTP success effects, including cancellation, timeout, contention, exhausted pool, failed HEAD/GET/PUT, and definite precondition loss. - Prefer one authoritative mapping helper for write-acquisition failures. If a route must differ, make that exception explicit and test it; otherwise duplicated marker checks inevitably diverge as new refusal types are added.
- Add route-level matrix tests rather than relying only on unit tests of marker recognition. In particular, exercise push, issue create, issue close, and PR merge under the shared transient failure classes, and assert both the external response and that irreversible success effects do not occur after a fenced publish.
- Reconcile configuration and operational assumptions as part of the same pass: connection-pool budgets across nodes, proxy/request timeouts versus the composed lock/transfer budgets, real Tigris conditional-write semantics, snapshot cleanup under cancellation, and authorization/read-gate behavior for local and mirrored records.
This is guidance for verifying the existing design, not a request to expand its product scope or rewrite the locking approach.
Fixes #279.
Session-scoped Postgres advisory locks were taken with
fetch_one(&pool)and released withexecute(&pool). Those are two independent pool checkouts, so the release almost always landed on a backend that held nothing,pg_advisory_unlockreturned false, and the return value was discarded by alet _. The lock leaked on essentially every write.Measured on
mainat 111cff7 before writing any of this:pg_lockspg_advisory_unlockreports "you did not hold this" as a false return plus a warning, never an error, which is why this was silent.What changes
RepoWriteGuardnow 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_lockwith 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
.awaitdoes 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. Soclose_on_dropis armed before the try-lock goes out, via a wrapper that owns the connection in anOptionand closes it in its ownDropunless the lock was positively not taken.PoolConnection::close_on_dropis a one-way setter, so disarming isOption::takerather than a second call.close_issuetook 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, becauseacquire_writere-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_keydeliberately stays onDefaultHasher. #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_locksfrom 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, sincePoolConnection::dropspawns 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
acquirefromacquire_fresh:RepoStore::for_testinghas 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 onmain(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
Bug Fixes
Documentation