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 52 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
jatmn
left a comment
There was a problem hiding this comment.
I found issues that need to be addressed before this is ready.
Merge readiness
- [P1] Resolve the failing CodeQL security gate
crates/gitlawb-node/src/git/repo_store.rs:135
The currentd1ce2e3head has a failing CodeQL check with four high-severity path-expression alerts (repo_store.rs:135andtigris.rs:440-443). The production call trace appears to route the affected paths throughRepoStore::local_pathand thenvalidated_repo_disk_path, whose allowlist, rooted join, and component walk reject traversal, so I do not currently have evidence that these annotations are exploitable. The check is nevertheless red and cannot be treated as cleared merely because the Rust analysis job itself passed. Please either make the validation barrier recognizable to the analyzer or triage/dismiss each alert with the complete source-to-sink call-path evidence. Do not suppress the query broadly: the same helper protectsexists,remove_dir_all, andrename, so a future unvalidated caller must still be caught.
Findings
-
[P1] Keep an abandoned extraction from publishing after its lock is released
crates/gitlawb-node/src/git/repo_store.rs:477
The under-lock refresh timeout drops the async download and then drops the guard, butdownload_to'sspawn_blockingextraction cannot be cancelled. The blocking task retains an unconditional right to calldecompress_repo, takepublish_lock, removelocal_path, and rename its extracted tree even though the advisory-lock ownership that authorized that refresh no longer exists.The destructive ordering is:
- Writer A takes the advisory lock and starts the under-lock refresh.
- The refresh exceeds its bound. A returns
RepoUnavailable; dropping the guard closes the session and frees the lock. - Writer B acquires the same advisory lock and starts mutating
local_path. - A's detached extraction finishes.
publish_lockonly serializes extraction swaps; B does not hold it. - A removes and replaces B's live directory. B now works in an unlinked tree and can fail unpredictably or report an accepted write whose objects are no longer the live repository.
The root cause is split ownership: cancellation revokes A's database lock but does not revoke the blocking task's authority to publish into the live path. Fix that ownership boundary rather than only changing timeout values. For example, make extraction two-phase and require a still-valid generation/cancellation token immediately before the swap, or register the pending publisher so a successor cannot enter until it has completed or been disarmed. Keep both existing requirements: the request must remain bounded, and a cancelled task must not retain permission to mutate
local_path.Add a deterministic regression with a seam immediately before the swap: park A there, expire/drop A and prove B acquires, let B mutate, then release A and assert A cannot replace B's tree. Include the positive control that a non-cancelled extraction still publishes and cleans up normally. A timeout-only assertion is insufficient because it does not observe the late filesystem effect.
-
[P1] Do not treat a missing release outcome as confirmed durability
crates/gitlawb-node/src/api/repos.rs:2539
A disconnect duringreleaseleavespublish_durabilityasNone, but afterlock_held_transfer_timeout_secs + 5this helper returnstrue. At that point the upload may never have been sent, may have failed, may have timed out with unknowable durability, or may have been refused by the conditional store. The detached tail then proceeds to Pinata mappings, gossip, GraphQL broadcasts, ref-update Arweave anchoring, and peer notifications for a ref whose shared archive was never confirmed.The root cause is that one absence value represents two incompatible states: “the legacy/direct test caller has no durability channel” and “the production handler had a channel but disappeared before publishing an outcome.” The outer
Optionalready covers the first case; an innerNoneis pending/abandoned, not success. The timer currently converts uncertainty into durability to preserve detached-tail liveness, which defeats the new gate's stated safety contract.Preserve INV22's required spawn point above
release; moving the task below release would reopen the disconnect-loss defect this ordering fixed. Instead, give the detached task an explicit completion signal (Released, known non-durable, or producer-dropped/unknown) and fail closed for every state exceptReleased. If local recovery work intentionally has different semantics from public announcements, model those as separate phases rather than using one boolean to admit the whole remaining tail.Add handler-level fault tests for disconnect during the upload and during unlock, plus definite
UploadFailed,UploadUnknowable, andFencedoutcomes. Assert that no Pinata mapping, gossip event, GraphQL broadcast, Arweave ref anchor, or peer notification occurs. Pair them with a successful-release positive control proving each effect still occurs exactly once; otherwise a fix that simply disables the tail would pass. -
[P2] Roll back a fork archive when the DB row cannot be created
crates/gitlawb-node/src/api/repos.rs:3264
The handler publishes the fork withIf-None-Match: *before inserting its DB row. If that PUT succeeds andcreate_repothen fails, times out, or is cancelled, the archive key remains while no repository row exists. A retry passes the DB precheck, loses the create-only PUT to its own orphan, and maps that 409/412 toRepoExists; because archive deletion has no production caller, the user cannot reuse the name without manual object-store repair.The root cause is a non-transactional two-resource commit with no recovery identity. The new create-only fence correctly protects an unrelated archive, but the handler cannot distinguish “someone else owns this key” from “my earlier partial attempt uploaded these exact bytes and failed before DB persistence.” Treating both as
RepoExiststurns an ordinary transient DB failure into a permanent namespace tombstone.Address the partial-commit protocol rather than weakening
If-None-Match. Viable outcomes include reserving the DB name before publishing and finalizing it afterward, uploading under an attempt/staging key and promoting only after DB persistence, compensating a successful PUT when the row insert fails, or attaching enough idempotency/generation identity to recognize and resume the same fork safely. Preserve the protection against genuinely unrelated existing archives.Add a deterministic fault immediately after the successful PUT and before
create_repo: force the DB insert to fail, retry the same fork, and prove the retry can complete without manual cleanup. Also prove that an unrelated pre-existing archive remains protected and that compensation never deletes bytes it cannot attribute to the failed attempt. -
[P3] Recompute the acquire remainder before sleeping
crates/gitlawb-node/src/git/repo_store.rs:430
leftis measured beforepg_try_advisory_lockand reused after that query returns false. A query that consumes nearly the entire remaining budget can therefore be followed by a sleep using the stale value, overshooting the advertised wall-clock acquire cap by up to one second. The root cause is treating one budget snapshot as valid across another await; every await can consume the remainder. Recalculatedeadline.checked_duration_since(now)immediately before backoff and skip the sleep once it has expired. Add a paused-time or controllable-query regression that returnsfalsejust before the deadline and asserts there is no full extra backoff, with an ordinary-contention positive control proving retries still sleep instead of spinning.
Review guidance
This PR has accumulated repeated review rounds because the change is not only a session-affinity repair. It creates a distributed write protocol spanning five authorities that fail independently:
- PostgreSQL decides who currently holds the advisory lock and whether that ownership survives cancellation.
- The local filesystem holds the tree being read or mutated, including blocking extraction tasks that outlive their async caller.
- Tigris holds the cross-node archive generation and can answer success, definite precondition loss, definite failure, or an unknowable timeout.
- PostgreSQL metadata records repository/issue/PR state separately from object storage.
- Detached replication publishes secondary effects to IPFS, Pinata, gossip, GraphQL, Arweave, and peers after the request may no longer exist.
The recurring failure mode is that a repair makes one transition locally correct but does not carry its new outcome through every later consumer. Pinning the lock connection made transfer cancellation load-bearing; bounding extraction let filesystem work outlive lock ownership; conditional writes introduced partial-persistence and topology assumptions; typed non-durable outcomes required every detached effect to distinguish success from absence. Tests around the changed function can all pass while the next lifecycle edge remains inconsistent.
Please do one final state-machine pass rather than patching the four visible lines independently:
- Define the authoritative states explicitly: lock not attempted, probe in flight, locked, refresh pending, local mutation landed, publish pending, publish confirmed, publish refused, publish unknowable, unlock pending, released, and abandoned. Avoid booleans or
Nonewhere those states have different safety rules. - For every cancellation boundary, identify which capability is revoked and which background work survives. A surviving task must not retain permission to mutate or announce state after the guard that authorized it disappears.
- For every cross-system write, define the partial-success recovery path. In particular, cover object accepted / DB failed, DB accepted / object failed, response lost after success, retry after ambiguity, and cleanup that must not delete another writer's state.
- State deployment assumptions as enforced invariants, not comments. If all writers sharing a Tigris key must also share one PostgreSQL advisory-lock namespace, validate or document that topology and test the failure at its boundary.
- Keep one authoritative mapping for each typed outcome and enumerate every producer and consumer.
RepoBusy,RepoUnavailable,RepoWriteFenced, and everyReleaseOutcomeshould have a table covering HTTP status/body, local-cache state, object-store state, DB effects, replication effects, logging, retry behavior, and cleanup. - Test production entry points, not only helpers. Drive push, issue create, issue close, PR merge, and fork through contention, HEAD/GET failure, upload failure, upload timeout, first and second precondition loss, unlock failure/cancellation, request disconnect, and DB failure after object publication.
- Make each negative test load-bearing with a positive control. Assert both the refused response and the absence of irreversible effects; then assert the same effects occur exactly once on confirmed success. This prevents a broad fail-closed patch from looking correct merely because it disables work.
- After each repair, search every sibling caller and every stage after the repaired line. The prior rounds repeatedly fixed the named route while a sibling handler, detached tail, local cache, or persistence step retained the old assumption.
A useful final regression matrix would record, for each injected failure and each write route: client response, advisory-lock/session state, live local tree, Tigris generation, DB rows, webhooks/trust/certificates, IPFS/Pinata state, gossip/GraphQL/peer events, retry result, and cleanup. The goal is not more tests by count; it is one end-to-end source of truth showing that every terminal outcome leaves all five authorities in an intentional, recoverable state. That contract-driven pass is the best way to avoid another round where fixing one surfaced edge reveals the next one.
Needs maintainer decision
-
Confirm whether separate-Postgres writers can share one Tigris bucket in a supported deployment. If they can, the supersede retry at
repo_store.rs:1101is unsafe: after another node publishes E1, a writer based on E0 can loseIf-Match(E0), HEAD E1, and overwrite it underIf-Match(E1). If shared-bucket writers always share the same PostgreSQL advisory lock, that sequence is outside the supported topology and should not be reported as a defect. -
Confirm that the deployed PostgreSQL connection budget can support the new defaults: each node can now allocate 48 application connections plus 32 lock connections, multiplied by node count and added to admin/migration usage. The repository does not contain the fleet's
max_connections, so this cannot be verified from the PR. -
Run the credential-gated
tigris_honors_conditional_writesprobe against the same bucket class used in production, or otherwise confirm the backend's current conditional-write guarantees. The fence is now load-bearing, but the repository contains only the mock and an unrun real-provider test, not production evidence.
Revoke under-lock refresh swaps when the advisory lock ends, fail closed on unrecorded release outcomes before Pinata/gossip, compensate fork archives when create_repo fails, recompute acquire backoff from the live deadline, and route publish swaps through validated_repo_disk_path for CodeQL.
Move close_issue rate limiting after the read gate so denied readers see 404 not 429, roll back local issue refs when publish refuses, re-check swap authority under publish_lock, propagate fork upload failures before DB insert, and update lock-pool config help text.
|
Head Abandoned extraction (P1): Durability gate (P1): CodeQL path alerts (P1): sinks route through Fork partial commit (P2): Acquire backoff (P3): sleep uses Also on this head: Declined: Maintainer-decision items (shared Postgres/Tigris topology, fleet connection budget, conditional-write probe) are unchanged and still called out in the PR body. |
jatmn
left a comment
There was a problem hiding this comment.
I found issues that need to be addressed before this is ready.
Overall guidance
These findings look separate at their individual call sites, but they come from the same underlying design problem: ownership and commit state are represented locally for only part of each asynchronous operation. The code has several boundaries where work continues after the future that initiated it can time out, disconnect, or return an error—blocking extraction, object-store publication, database insertion, and cleanup. The current fixes generally handle one observed error arm at the caller, but the underlying resource or state has already crossed into another owner by then.
Before another point-fix round, I recommend defining one explicit state machine for each write attempt and making every durable or externally observable artifact belong to that attempt until it reaches a terminal state. At minimum:
- A repository write should move through
staged locally -> object-store outcome resolved -> promoted to the readable local path -> acknowledged. The mutable staging tree should not also be the unlocked read cache. A definite publish refusal should discard or refresh the staging state; an unknowable publish should quarantine it until the object-store generation is resolved. - Every background worker must carry an ownership token that remains valid for the exact destructive action it is about to perform. Revocation and commit must be mutually exclusive operations, not an atomic flag checked once before several filesystem mutations.
- Fork creation is a distributed transaction across local disk, object storage, and PostgreSQL. Give each attempt a durable identity and resumable state, or use an outbox/saga protocol. Do not infer "the insert did not commit" from a transport-level database error, and do not make best-effort deletion the only record that cleanup remains due.
- Temporary paths should be owned by an RAII guard from creation through the final commit point. Error-specific cleanup arms are too easy to miss; disarm the guard only after the operation has transferred ownership to the database-backed repository or snapshot object.
- Add deterministic interleaving tests at the ownership boundaries, not only tests for the final enum/status mapping. Each test should pause immediately before the destructive or durable step, trigger timeout/cancellation/failure, admit a retry or successor, and then resume the old work. That is the pattern needed to prove that an abandoned attempt cannot mutate, delete, publish, or leak state afterward.
Addressing those invariants centrally should close the findings below together and reduce the risk of another review round exposing the same class at a different caller.
Findings
-
[P1] Synchronize swap revocation with the destructive swap
crates/gitlawb-node/src/git/repo_store.rs:793
Failure sequence: the extraction worker takespublish_lockand loadsswap_authority == true. Before it reachesremove_dir_allorrename, the outer bounded transfer expires. The timeout arm storesfalse, returns fromacquire_write, and drops the guard, which releases the PostgreSQL advisory lock. A successor can now acquire that repository while the abandoned worker continues deleting and replacing the same live directory. Takingpublish_lockonly around the worker side does not close this ordering because revocation does not participate in that lock.Root cause: authority is modeled as a one-time observation rather than exclusive ownership of the commit operation. The atomic answers whether authority existed at one instant; it does not prevent authority from being revoked between the check and the destructive filesystem steps.
Guidance: make revocation and commit mutually exclusive under the same synchronization primitive or use a terminal state transition such as
Active -> Committing -> CommittedversusActive -> Revoked, where only one transition can win. Do not release the advisory lock until revocation has either prevented the commit or observed that the commit already completed. Add a deterministic test that pauses after the worker has observed authority, times out and admits a successor, then resumes the old worker and proves it cannot replace the successor's tree. -
[P1] Keep refused writes out of the unlocked read cache
crates/gitlawb-node/src/git/repo_store.rs:1260
Failure sequence: receive-pack, issue close, or PR merge mutateslocal_path, after which publication returnsFencedorUploadFailed.releaseconverts that outcome to a retryable error but unlocks without restoring or invalidating the directory.RepoStore::acquirereturns an existing local path without refreshing Tigris, so same-node reads can observe a write that returned 503 and is definitely absent from the durable archive. For a new archive, the lazy-migration path can later upload that retained tree.UploadUnknowableis a related but distinct state: the PUT may land later, so blindly deleting or serving the tree is unsafe until its generation is resolved. The create-issue-specific rollback is not a general solution because it runs after unlock and can race a successor writer.Root cause:
local_pathis serving as both the transaction's mutable workspace and the shared read cache. Once mutation happens in place, an object-store refusal cannot be represented without leaving contradictory state visible somewhere.Guidance: perform writes in an attempt-scoped staging repository and promote it to the readable cache only after publication is confirmed. If an in-place design is retained, preserve a pre-write snapshot and restore or invalidate it under the same write exclusion for definite failures. Quarantine
UploadUnknowablestate and resolve it with HEAD/generation comparison before either serving it or rolling it back. Centralize this inRepoWriteGuardrather than adding rollback code to each handler. Add tests for push, close, merge, and create that force each release outcome, immediately read from the same node, and then admit a successor write; no refused attempt should be observable or able to overwrite the successor. -
[P1] Resolve ambiguous DB outcomes before deleting a fork archive
crates/gitlawb-node/src/api/repos.rs:3264
Failure sequence: the create-only PUT succeeds, PostgreSQL commits the standaloneINSERT, and the connection drops before SQLx receives the completion response.create_reporeturnsErr, so this branch treats the insert as aborted and callscompensate_fork_archive. The archive is deleted even though the repository row exists. Subsequent requests stop at the database name-conflict check, while remote nodes have no durable repository to fetch.Root cause: this is a dual write without a transaction protocol. A transport error is being treated as a definitive database outcome, and compensation has no attempt identifier or generation check proving that the archive being deleted belongs to an aborted operation.
Guidance: give fork creation a durable attempt ID and explicit states such as
pending_upload,uploaded, andready, or write an outbox row before performing the object-store operation. On an ambiguous insert result, query by the attempt/repository ID on a fresh connection before deciding whether to compensate. Preserve the successful PUT generation and condition any deletion on that exact generation so cleanup cannot remove storage owned by a committed or successor state. Test the response-loss case by committing the row and then surfacing a client-side error; recovery must converge to a ready repository rather than deleting its archive. -
[P2] Persist failed fork cleanup instead of tombstoning the name
crates/gitlawb-node/src/git/repo_store.rs:728
Failure sequence: the archive upload succeeds, the database insert definitely fails, and this compensating DELETE encounters a transient object-store failure. The error is logged and forgotten, and the only local clone is removed. A retry sees no database row and clones successfully, but its create-only PUT loses to the orphan archive and returnsRepoExists. The requested name is then unusable until an operator manually deletes the object.Root cause: cleanup is best effort and has no durable owner, retry record, or recovery path. The retry cannot distinguish an unrelated existing repository from the orphan created by its own prior attempt.
Guidance: persist cleanup work before returning, retry it asynchronously until confirmed, and retain the attempt ID plus uploaded generation. Alternatively, allow a retry carrying the same attempt identity to adopt or conditionally replace its own orphan. A precondition loss should become
RepoExistsonly after proving the object belongs to a different successful attempt. Add a test where DELETE fails once and the same-name retry recovers without operator intervention. -
[P2] Keep snapshot cleanup ownership across the async handoff
crates/gitlawb-node/src/git/tigris.rs:323
Failure sequence: the blocking task finishes unpacking, callsmem::forgetonSnapshotCleanup, and returns a plainPathBuf. Before theJoinHandleresult is polled and wrapped inRepoSnapshot, the request future is cancelled. Dropping the join handle detaches the completed or completing blocking task, and dropping itsPathBufdoes not remove the directory. No cleanup owner remains for the expanded.tmp-snapshot.*tree, and no startup scavenger removes it.Root cause: cleanup ownership ends in the producer before ownership has been accepted by the async consumer. The successful value crossing the task boundary does not itself own the filesystem resource it names.
Guidance: have the blocking task return an armed cleanup wrapper rather than a
PathBuf; the task runtime will then drop and clean that wrapper if the join result is abandoned. Convert or disarm it only whenRepoSnapshottakes ownership. If crash recovery is also required, scavenge attempt-namespaced snapshot directories at startup. Add a test that pauses after successful extraction but before the outer future resumes, aborts the outer task, and asserts that the snapshot directory disappears. -
[P2] Make the fork clone attempt-scoped cleanup, not error-arm cleanup
crates/gitlawb-node/src/api/repos.rs:3246
Failure sequence:git clone --mirrorcompletes andrelease_after_writereturnsUploadError::Other, such as a transient Tigris transport or 5xx failure. Only thePreconditionLostarm removesdisk_path; this arm returns while the clone remains and no database row exists. The next request passes the database name check butgit clone --mirrorfails because the destination already exists.Root cause: ownership of the temporary mirror is implicit, and cleanup is attached to one enumerated error variant. Any early return added now or later can strand the same path.
Guidance: create an RAII attempt guard immediately after choosing
disk_path. It should remove the clone on every exit and be disarmed only after the database row has committed and responsibility for the path has transferred to the repository. Keep archive compensation separate because it has a different durable owner and failure protocol. Add a test for an ordinary upload failure followed by an immediate same-name retry, and assert both that the first clone is removed and that the second attempt reaches the upload step.
Make publish swaps claim-exclusive via CAS, invalidate the local read cache on definite publish refusals, harden fork create compensation and clone cleanup, and keep snapshot temp dirs owned through the async handoff.
…e_issue limits Wait for a confirmed release outcome before post_receive_replication_tail runs any walks or coalescing work. Give close_issue its own per-IP rate bucket so it cannot drain receive-pack quota. Add regression tests for both paths.
…rability Introduce a validated-path newtype with the same inline join and component walk main uses, so path-injection sinks only accept sanitised repo paths. Reconcile the publish-durability gate with the disconnect-safe tail spawn via PublishDurabilitySlot and explicit UploadUnknowable handling.
|
Pushed three follow-up commits on Swap authority and late extraction. Definite publish refusal and read cache. Fork compensation. Ambiguous Post-receive tail and publish durability. The tail still spawns above close_issue rate limit. Separate CodeQL path-injection. CI should be running on the new head. Re-requesting review. |
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] Do not skip fork recovery when the confirmation lookup is unavailable
crates/gitlawb-node/src/api/repos.rs:3342
Failure sequence: the create-only Tigris upload succeeds, thencreate_reporeturns an error (for example because the connection is lost before its result is received). The recovery lookup is meant to determine whether the insert actually committed, but an unavailable database makesget_repo(...).await?return immediately. That bypassescompensate_fork_archive;ForkCloneGuardremoves only the local clone, leaving the uploaded archive under the requested key. Once the database is reachable, a retry sees no row, clones successfully, and then itsIfAbsentupload is refused by that orphan, permanently reportingRepoExistsuntil manual cleanup.Root cause: the outcome of a cross-system create is inferred from a second fallible database read, while cleanup is attached only to the branch where that read succeeds. The archive has no durable attempt/recovery owner across the upload → database-result-resolution boundary.
Please make every terminal result of the confirmation step converge: retry or persist the lookup/cleanup work when the DB is unavailable, and only discard the archive after establishing that this attempt did not create a durable row. Keep the successful-response-loss recovery path intact; the goal is recovery from ambiguity, not treating all insert errors as failures.
-
[P3] Make the tail outcome handoff cancellation-safe
crates/gitlawb-node/src/api/repos.rs:2549
Failure sequence: after a successful receive-pack, the detached replication tail starts polling the shared slot.record()first setsrecorded = true, then awaits the mutex before storing the actualReleaseOutcome. If the request is cancelled in that await,Dropseesrecordedand returns without installingUploadUnknowable; likewise, its one-shottry_lockcannot repair the state if the tail owns the mutex at that instant. The slot remainsNone, so the tail waits the complete upload bound plus five seconds — 305 seconds by default — before it can continue with pinning and announcements.Root cause: “a result is being recorded” is represented as a terminal state before the result is durably visible to the consumer. Cancellation can therefore strand the producer/consumer handoff between those two state transitions.
Please make installation of exactly one terminal outcome cancellation-safe, and wake the consumer from that same state transition. An atomic terminal-state primitive, a sender that is completed before cancellation can intervene, or a drop guard that can reliably publish the fallback outcome would all satisfy the contract. Preserve the existing bounded-upload behavior; this change should remove only the accidental full-timeout delay.
-
[P2] Reserve lock-pool capacity for non-push writers
crates/gitlawb-node/src/config.rs:771
Failure sequence: with the shipped defaults, 32 distinctgit-receive-packrequests are admitted and each pins one of the 32 dedicated lock-pool connections for its refresh, Git work, and publish.create_issue,close_issue, andmerge_prdo not consume the push admission permits, but they call the sameacquire_writepath. They consequently time out acquiring a lock-pool connection and return 503 until a push releases one, despite operating on unrelated repositories.Root cause: the new pool’s capacity invariant accounts only for the subset of writers governed by
max_concurrent_git_pushes, while the pool is shared by additional write routes. Moving locks out of the main database pool removed the old eight-connection headroom without replacing its admission or reservation policy.Please make the capacity model cover every lock-pool consumer: reserve capacity for non-push mutations, put those mutations behind a shared writer budget, or validate the configured pool against the push cap plus the documented headroom. Keep the dedicated lock pool — the required outcome is that a saturated push budget cannot turn ordinary issue and merge mutations into avoidable pool-exhaustion failures.
Retry fork create confirmation before compensating or returning, schedule background recovery when the lookup stays unavailable, install publish durability outcomes before the recorded flag, and size the lock pool for non-push mutations (default 40).
Fail closed when publish durability never records, bound pg_advisory_unlock with the same transfer budget as upload, and cap close_issue pre-lock snapshots at git_acquire_timeout_secs instead of the write-lock transfer bound.
|
Head [P2] Fork recovery when confirmation lookup is unavailable ( [P3] Cancellation-safe tail outcome handoff ( [P2] Lock-pool headroom for non-push writers ( Also on this head (self-review pass):
Ready for another look. |
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 roll back a created issue after releasing its write guard
crates/gitlawb-node/src/api/issues.rs:73
release()consumes the guard and releases its advisory-lock session before this failure branch runs. OnUploadUnknowable, the PUT may still land andrelease()deliberately retains the live local tree; a second writer can therefore acquire the same repository before this request executesdelete_issue_ref. The older request then runs an unguardedgit update-ref -dagainst the successor's working tree, defeating the new write-serialization contract and potentially deleting a ref from newer state. It also treats an ambiguous upload as a definite failure: the client receives 503 and this node removes the ref locally, while the late conditional PUT can still publish that issue remotely.The root cause is attempting per-handler compensation only after the write guard has ended, even though the publication outcome is no longer definitive. Keep any repository mutation serialized with the write attempt, and make the guard-level outcome handling reconcile the local tree with a confirmed object-store result before either rolling it back or exposing a retryable failure. The fix should cover the outcome contract centrally rather than adding another route-specific post-release git mutation.
-
[P3] Make the cancellation fallback for the replication tail reliable
crates/gitlawb-node/src/api/repos.rs:2561
The detached tail starts pollinginneras soon as it is spawned. If the request is cancelled while that polling task owns the mutex,PublishDurabilitySlot::drop's singletry_lock()fails and silently returns without installingUploadUnknowable. Nothing retries the installation, so the tail seesNoneuntillock_held_transfer_timeout_secs + 5elapses, then skips the pinning and announcement work for an otherwise successful receive-pack. The existing test only drops the slot while the mutex is uncontended, so it cannot observe this interleaving.The root cause is a producer/consumer terminal-state handoff that is both cancellation-sensitive and best-effort: the producer can disappear between release and the outcome becoming observable. Install exactly one terminal outcome through a cancellation-safe primitive or a drop-safe mechanism that cannot be defeated by transient mutex contention, and notify the consumer from that same transition. Preserve the existing bounded-upload and fail-closed behavior; the required outcome is that a cancelled handler cannot turn an admitted push into a lost detached tail.
… handoff Run create_issue rollback through release_compensating so issue refs are deleted only while the advisory lock is still held and only on definite publish refusals, not UploadUnknowable. Replace the tail durability slot's async try_lock drop with a std mutex so cancellation during polling cannot skip installing UploadUnknowable.
…n release Under-lock GET failures now always shed as RepoUnavailable, even when a local copy exists, so a transient download error cannot publish over a newer stored generation. PublishDurabilitySlot only synthesizes UploadUnknowable after release starts, and receive-pack marks that boundary before spawning the tail. Adds regression tests for both paths; inv22 F4 gate accepts the release wrapper.
|
Addressed the two items on P1 ( P3 ( Adversarial follow-ups on the same head — Under-lock GET failure now refuses even when a local copy exists (no stale-tree publish on a failed refresh). Same test: Checks run locally before push: full |
jatmn
left a comment
There was a problem hiding this comment.
I found four lifecycle issues that need to be addressed before this is ready. They share a common theme: several resources are tracked only by a coarse state such as “release started,” “path exists,” or “row with this name exists,” when correctness depends on the identity and publication stage of one specific write attempt.
Findings
-
[P1] Do not treat cancellation before PUT dispatch as publish durability
crates/gitlawb-node/src/api/repos.rs:2565git_receive_packcallsmark_release_started()immediately before awaitingRepoWriteGuard::release(). Release then entersTigrisClient::upload, which first awaitsspawn_blocking(compress_repo)and does not construct or send the conditional PUT until compression returns. If the handler is cancelled while that blocking compression is still running, dropping the upload future prevents execution from ever reachingreq.send(), so this is a definite “no publication was attempted” state—not an ambiguous in-flight PUT.PublishDurabilitySlot::dropnevertheless records every cancellation aftermark_release_started()asUploadUnknowable.publish_durability_confirmedaccepts that value and lets the detached post-receive tail perform IPFS/Pinata work, P2P announcement, GraphQL publication, Arweave work, and peer notification for refs that exist only in the rejected local write. Because the release future was dropped, the write guard also releases its advisory lock before those effects run.The root cause is that the slot records only whether release entered, while the safety decision depends on a later boundary: whether this attempt's PUT was actually dispatched and whether its durable generation was confirmed. Please represent those stages explicitly. A robust design could keep the release operation independently owned until it reports its real outcome, or distinguish at least
PreparingArchive,PutDispatched,Published,Refused, andAmbiguous. The replication tail should requirePublished; an ambiguous dispatch should first be reconciled to this attempt's expected generation rather than being treated as confirmation.Please add a regression test that blocks compression, cancels the handler after
mark_release_started(), and verifies that no PUT is observed and none of the post-receive effects execute. The existing cancellation test parks after upload, so it cannot exercise this boundary. -
[P1] Quarantine an unknowable generation instead of serving it as normal cache state
crates/gitlawb-node/src/git/repo_store.rs:1407When the bounded release upload expires,
release_maybe_compensatecorrectly avoids deleting the local tree because the PUT may already have landed. However, it leaves that modified tree at the ordinary live path and returnsUploadUnknowable, which becomes a 503. Later read requests callRepoStore::acquire; its existing-path fast path returns the live path without checking which object-store generation it represents. If the timed-out PUT was actually rejected, fenced, or never completed, the node can therefore serve the refused refs indefinitely even though durable storage still contains the previous generation. A later write refreshes the path, but ordinary reads do not.The root cause is that cache validity is inferred solely from filesystem existence. The live path has no provenance tying it to a confirmed object-store generation, and there is no quarantined state for an attempt whose PUT outcome is unresolved. Please make generation state part of the cache contract. For example, mutate an attempt-specific tree and promote it to the readable live path only after publication is confirmed, or mark the live tree quarantined with its expected generation and force reads to reconcile it with HEAD/object metadata before serving it. Immediate deletion is not a safe fix because it could remove the only local copy of a PUT that did land.
A focused test should let a PUT consume the request and then remain unresolved or fail, assert that the writer receives 503, and then issue a same-node read. The read must either serve the last confirmed generation or return a retryable refusal; it must not expose the uncertain refs as an ordinary successful read.
-
[P1] Bind fork confirmation and cleanup to the exact creation attempt
crates/gitlawb-node/src/api/repos.rs:3201Fork creation uploads the archive and then inserts a freshly generated
record.id. Ifcreate_reporeturns an error,confirm_fork_repo_rowlooks up only owner/name and treats any matching row as proof that this insert committed. A concurrent ordinary create, mirror registration, or retry can insert a different row under that logical name, after which this request returns 201 using the other attempt's row even though its own uploaded archive, disk path, and fork provenance do not belong to that row.The background recovery path has the inverse race. It observes
get_repo(owner, name) == Noneonce and then callscompensate_fork_archive, which unconditionally deletes the shared object key and local path. Another creation can commit after theNoneresult but before those deletions, so recovery for the failed fork can erase the succeeding attempt's repository after that attempt has already returned success. Retried object deletion makes the ownership gap persist beyond the initial race window.The root cause is using the public owner/name as both lookup key and cleanup authority. It identifies a namespace, not the attempt that owns a DB row, object generation, or filesystem tree. Please carry a durable attempt identity through the whole workflow: confirm the exact
record.id, associate the uploaded generation/checksum and disk path with that ID, and make every cleanup conditional on those resources still belonging to the failed attempt. Object deletion needs anIf-Match-style generation guard or an equivalent attempt-owned staging/manifest design; a second name lookup immediately before an unconditional delete would only move the race.Please exercise both interleavings with barriers: one where another row commits before confirmation, and one where recovery reads
Nonebefore a successor commits but resumes cleanup afterward. The first request must not claim the successor's row, and the failed attempt must be unable to delete the successor's object or path. -
[P2] Preserve response-loss ambiguity instead of compensating as definite failure
crates/gitlawb-node/src/git/tigris.rs:185TigrisClient::uploadrecognizes 409/412 precondition loss, but maps every other AWS SDK error toUploadError::Other. The callers then treatOtheras proof that publication failed: guarded writes produceUploadFailedand invalidate/compensate local state, while fork creation drops its local clone and skips the DB insert. That classification is not valid for allSdkErrorvariants. Smithy's timeout and dispatch errors allow that the request may have been sent, and a response error means a response was received but could not be parsed. The server can therefore commit the conditional PUT and lose or corrupt the response before the client observes success.Fork creation demonstrates the durable failure mode: its create-only PUT can commit,
req.send()can return a response-loss error, andForkCloneGuardthen removes the local clone without inserting a DB row. Every retry sendsIf-None-Match: *, sees the orphan object, and returnsRepoExists; the fork name remains unusable until operator cleanup. Guarded issue/push writes also take definite-failure cache and compensation paths despite not knowing whether their generation landed.The root cause is that
UploadErrorencodes an HTTP outcome but not the client's knowledge of request dispatch or commit. Please split definite pre-dispatch failures and explicit conditional refusals from possibly-dispatched/response-loss failures. Destructive compensation is safe only for outcomes that prove this attempt did not publish. For ambiguous create-only uploads, reconcile ownership using an attempt identifier plus a stored checksum/generation (or publish through an attempt-owned staging key and atomically claim the logical name) before deciding whether to insert the row or remove the object.Please test with a server that accepts the complete PUT and closes or corrupts the response before the SDK can return success. The implementation must recover the committed attempt or leave it safely reconcilable; it must not delete its only local state and permanently fence the logical fork name.
The four lifecycle findings on this branch share one root cause: a resource
was tracked by a coarse state ("release started", "the path exists", "a row
with this name exists") when the safety question was about the IDENTITY and
the PUBLICATION STAGE of one specific write attempt.
This is the vocabulary that makes those questions answerable:
- `PublishAttemptId` — minted before the request is built, carried with the
bytes as object user metadata, read back off the store to decide whether
what is published is THIS attempt's work. That turns "did my request
succeed", which a lost response makes undecidable, into "are the published
bytes mine", which the store can answer.
- `PublishStage` / `PublishStageCell` — how far one attempt got, observable
from OUTSIDE the future doing the work. A cancelled handler never returns
an outcome, so the stage it had reached is the only thing that can classify
it.
- `UploadError` split by what a failure ENTITLES A CALLER TO DO rather than
by which HTTP status came back. Destructive compensation is licensed only
by `proves_not_published()`.
Deliberately free of any object-storage type. #79 deletes `git/tigris.rs` and
replaces it with a BlobStore layer; this module is what survives that swap,
with each backend supplying only its own error classifier.
…biguity `upload` now reports its progress into a `PublishStageCell` and stamps every PUT with an attempt id in object user metadata, so both of the questions a cancelled or unanswered publish raises become answerable: - `PreparingArchive` is marked before the blocking compression and `PutDispatched` immediately before `send()`. The conditional PUT is not constructed until compression returns, so a caller abandoned in that window definitely never attempted publication — a fact nothing could previously observe, because the only report was the return value of a future that no longer existed. - `attempt_landed()` HEADs the key and compares the stored attempt id, which is what lets a client whose response was lost recover its own committed write instead of guessing. `UploadError::Other` is replaced by `NotPublished` (proven never committed) and `Ambiguous` (may have been dispatched and may have committed). The single backend-aware classifier is `classify_put_failure`: a 4xx is an answer the server gave before storing anything and proves non-publication; a 5xx does not, and neither does a timeout, a dispatch failure, or a response the SDK could not read. `SdkError` is `#[non_exhaustive]`, so the fallback arm is the cautious one. `delete` is replaced by `delete_if_attempt_matches`, which reads the attempt off the object and fences the DELETE with `If-Match` on the generation it came from. A second name lookup before an unconditional delete would only narrow the window in which a successor's object can be erased; the conditional delete closes it.
…empt Closes the four lifecycle findings by consuming the attempt/stage boundary at the sites that were deciding on coarse state. P1 — cancellation before PUT dispatch is not publish durability. `PublishDurabilitySlot` is armed on the guard's publish STAGE rather than on a "release started" flag, and its `Drop` classifies an abandoned handler by how far the attempt actually got: a cancellation during compression records a definite refusal, a cancellation after dispatch records unknowable, and a cancellation after the store acknowledged records `Released` (which the flag could never say). `publish_durability_confirmed` now requires `Released`, so the detached tail no longer does IPFS/Pinata/P2P/GraphQL/Arweave/peer work for refs that exist only in a rejected local write. A guard with no storage backend seeds `NoBackend`, keeping Tigris-less deployments' tails running. P1 — an unknowable generation is quarantined, not served as ordinary cache. `release` writes a sidecar marker naming the unresolved attempt beside the live tree (never inside it: it would be tarred into the next archive), and `acquire`'s existing-path fast path reconciles it before serving. Only the store confirming it holds that attempt lifts the quarantine; anything else is a retryable `RepoUnavailable`. The tree is deliberately NOT deleted — the PUT may have landed and this can be the only local copy. A confirmed publish, an under-lock refresh, and cache invalidation each clear the marker, so the quarantine is a bounded refusal rather than a standing outage. The release-side timeout arm also stops treating every stall alike: a bound that expires at `PreparingArchive` is a definite non-publication, and a bound that expires after dispatch spends one short, separately bounded HEAD trying to reconcile the attempt before falling back to unknowable. P1 — fork confirmation and cleanup bind to the creating attempt. The DB row id is minted up front and used as the attempt id, so the object's metadata, the clone's sidecar stamp and the row all name one attempt. `confirm_fork_repo_row` looks the row up BY ID and reports Ours / Foreign / Absent, so a concurrent create, mirror registration or retry can no longer be returned as this request's own commit. Every destructive step — `compensate_fork_archive`, its background retries, and `ForkCloneGuard::drop` — is conditional on the resource still belonging to this attempt, so recovery that observed `None` before a successor committed can no longer erase that successor's archive or directory afterwards. P2 — response-loss ambiguity is preserved rather than compensated. Guarded writes map an ambiguous publish to `UploadUnknowable` + quarantine instead of `UploadFailed` + cache invalidation + the caller's undo, so `create_issue` no longer deletes an issue ref whose archive is durable. Fork creation reconciles by attempt id: a create-only PUT that committed and lost its response is RECOVERED and the row inserted, and an unresolved one keeps its only local clone and refuses retryably. The fork name is no longer fenced behind the attempt's own orphan. Tests, each RED-checked by reverting its guard: - slot_drop_during_compression_records_a_definite_non_publication - slot_drop_after_dispatch_records_unknowable - slot_drop_after_the_store_acknowledged_records_released - slot_drop_with_no_storage_backend_records_released - publish_durability_confirmed_accepts_only_released - publish_durability_confirmed_refuses_quickly_after_unrecorded_slot_drop - receive_pack_cancelled_during_compression_publishes_nothing_and_runs_no_tail (+ receive_pack_that_completes_its_publish_still_runs_the_tail as control) - a_bound_that_expires_before_dispatch_is_a_definite_failure - an_unresolved_publish_quarantines_the_tree_and_a_later_read_refuses - a_quarantined_tree_is_served_once_the_store_confirms_the_attempt - the_next_write_clears_an_inherited_quarantine - a_confirmed_publish_leaves_the_tree_readable (control) - a_guarded_write_whose_response_is_lost_is_not_compensated - a_put_that_commits_and_loses_its_response_is_ambiguous_and_reconcilable - a_closed_response_with_no_http_status_is_ambiguous - upload_classifies_404_as_a_definite_non_publication - upload_classifies_500_as_ambiguous_not_definite_failure - a_generation_that_moves_between_the_head_and_the_delete_is_not_deleted - an_attempts_own_object_is_deleted_and_a_foreign_one_is_not - fork_confirmation_never_claims_a_concurrent_attempts_row - fork_confirmation_recognizes_this_attempts_own_committed_row - fork_confirmation_reports_absent_when_nothing_owns_the_name - fork_clone_guard_leaves_a_successors_mirror_alone - fork_recovery_resuming_after_a_successor_deletes_neither_object_nor_path - fork_compensation_removes_what_this_attempt_still_owns - fork_publish_that_loses_its_response_stays_recoverable - a_fork_whose_publish_lost_its_response_is_recovered_not_fenced - a_fork_whose_publish_is_unresolved_keeps_its_clone_and_refuses_retryably - an_ordinary_fork_publishes_and_commits (control) - mock_round_trips_the_attempt_metadata_a_put_stamped The S3 mock gains attempt metadata, conditional DELETE, a commit-then-lose- the-response mode, a delivered-but-not-committed mode, and a per-key object store (fork creation touches the source and fork keys in one request, and a single slot would have let an assertion about one silently read the other). The compression seam is a condvar gate so no test holds a guard across an await.
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