Skip to content

Commit 30e0dec

Browse files
committed
fix(repo_store): dedicate lock pool, acquire inside retry loop, close conn on unlock cancellation
1 parent 26c4dfe commit 30e0dec

6 files changed

Lines changed: 221 additions & 48 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,5 @@
11
# Changelog
22

3-
## Unreleased
4-
5-
### Known caveats
6-
7-
* **repo_store:** the advisory-lock key for cross-machine write exclusion is now derived via SHA-256 of `(owner_slug, repo_name)` instead of `std::collections::DefaultHasher` (the previous algorithm was not frozen by the Rust standard). During a shared-Postgres rolling upgrade, old-binary nodes holding the legacy `DefaultHasher` key and new-binary nodes holding the SHA-256 key for the same repo compute *different* i64 keys, so PostgreSQL treats them as independent locks and cross-machine write-exclusion is lost for the deploy window. Operators should drain in-flight writes or cut over through a single node before bringing new-binary nodes online. A future transition release may acquire both keys for one cycle. See `RepoStore::acquire_write` and issue #210.
8-
93
## [0.5.1](https://github.com/Gitlawb/node/compare/v0.5.0...v0.5.1) (2026-07-10)
104

115

‎crates/gitlawb-node/src/auth/mod.rs‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,10 @@ mod tests {
495495
let pool = sqlx::postgres::PgPoolOptions::new()
496496
.connect_lazy("postgres://localhost/gitlawb_test_placeholder")
497497
.expect("lazy pool creation should not fail");
498+
let lock_pool = sqlx::postgres::PgPoolOptions::new()
499+
.max_connections(2)
500+
.connect_lazy("postgres://localhost/gitlawb_test_placeholder")
501+
.expect("lazy lock pool creation should not fail");
498502
let db = Arc::new(crate::db::Db::for_testing(pool.clone()));
499503
let schema = Arc::new(graphql::build_schema(
500504
db.clone(),
@@ -512,7 +516,11 @@ mod tests {
512516
task_event_tx: task_tx,
513517
graphql_schema: schema,
514518
machine_id: None,
515-
repo_store: crate::git::repo_store::RepoStore::for_testing(PathBuf::from("/tmp"), pool),
519+
repo_store: crate::git::repo_store::RepoStore::for_testing(
520+
PathBuf::from("/tmp"),
521+
pool,
522+
lock_pool,
523+
),
516524
rate_limiter: RateLimiter::new(100, Duration::from_secs(60)),
517525
create_ip_rate_limiter: RateLimiter::new(1000, Duration::from_secs(3600)),
518526
push_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),

‎crates/gitlawb-node/src/git/repo_store.rs‎

Lines changed: 189 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -26,29 +26,42 @@ use super::tigris::TigrisClient;
2626
pub struct RepoStore {
2727
repos_dir: PathBuf,
2828
tigris: Option<TigrisClient>,
29-
/// Shared Postgres pool for advisory locks.
29+
/// Shared Postgres pool for application queries.
30+
/// NOTE: advisory-lock connections use `lock_pool` instead.
31+
#[allow(dead_code)]
3032
pool: PgPool,
33+
/// Dedicated pool for advisory-lock sessions. Kept separate from
34+
/// `pool` so lock-holding connections (which may be held for the full
35+
/// duration of a write) never compete with application queries.
36+
lock_pool: PgPool,
3137
/// Tracks repos already confirmed to exist in Tigris — avoids redundant
3238
/// HEAD checks and background uploads for repos we've already migrated.
3339
migrated: Arc<Mutex<HashSet<String>>>,
3440
}
3541

3642
impl RepoStore {
3743
#[cfg(test)]
38-
pub fn for_testing(repos_dir: PathBuf, pool: PgPool) -> Self {
44+
pub fn for_testing(repos_dir: PathBuf, pool: PgPool, lock_pool: PgPool) -> Self {
3945
Self {
4046
repos_dir,
4147
tigris: None,
4248
pool,
49+
lock_pool,
4350
migrated: Arc::new(tokio::sync::Mutex::new(std::collections::HashSet::new())),
4451
}
4552
}
4653

47-
pub fn new(repos_dir: PathBuf, tigris: Option<TigrisClient>, pool: PgPool) -> Self {
54+
pub fn new(
55+
repos_dir: PathBuf,
56+
tigris: Option<TigrisClient>,
57+
pool: PgPool,
58+
lock_pool: PgPool,
59+
) -> Self {
4860
Self {
4961
repos_dir,
5062
tigris,
5163
pool,
64+
lock_pool,
5265
migrated: Arc::new(Mutex::new(HashSet::new())),
5366
}
5467
}
@@ -204,40 +217,47 @@ impl RepoStore {
204217
// writer cannot be handed this connection and reentrantly grab the
205218
// same lock, and the unlock on release() is guaranteed to run on the
206219
// owning session.
207-
let mut conn: PoolConnection<sqlx::Postgres> = self
208-
.pool
209-
.acquire()
210-
.await
211-
.context("acquiring connection for advisory lock")?;
212-
let mut acquired = false;
220+
//
221+
// Each probe checks out and releases a connection from `lock_pool`
222+
// (not the shared `pool`), so a writer that waits for a contended repo
223+
// never occupies a slot that an unrelated query needs.
224+
let mut guard: Option<RepoWriteGuard> = None;
213225
for attempt in 0..60 {
226+
let mut conn: PoolConnection<sqlx::Postgres> = self
227+
.lock_pool
228+
.acquire()
229+
.await
230+
.context("acquiring connection for advisory lock")?;
231+
214232
let row: (bool,) = sqlx::query_as("SELECT pg_try_advisory_lock($1)")
215233
.bind(lock_key)
216234
.fetch_one(&mut *conn)
217235
.await
218236
.context("trying advisory lock")?;
237+
219238
if row.0 {
220-
acquired = true;
239+
// Winner — wrap immediately so Drop protects cancellation.
240+
guard = Some(RepoWriteGuard {
241+
owner_slug: owner_slug.clone(),
242+
repo_name: repo_name.to_string(),
243+
local_path,
244+
lock_key,
245+
conn: Some(conn),
246+
tigris: self.tigris.clone(),
247+
});
221248
break;
222249
}
250+
// Not acquired — conn drops back to lock_pool (no lock held).
223251
if attempt < 59 {
224252
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
225253
}
226254
}
227-
if !acquired {
228-
anyhow::bail!("could not acquire advisory lock after 60s — possible stale lock for {owner_slug}/{repo_name}");
229-
}
230255

231-
// Construct the guard immediately so its Drop implementation protects
232-
// subsequent cancellation points (tigris download below).
233-
let mut guard = RepoWriteGuard {
234-
owner_slug,
235-
repo_name: repo_name.to_string(),
236-
local_path,
237-
lock_key,
238-
conn: Some(conn),
239-
tigris: self.tigris.clone(),
240-
};
256+
let mut guard = guard.ok_or_else(|| {
257+
anyhow::anyhow!(
258+
"could not acquire advisory lock after 60s — possible stale lock for {owner_slug}/{repo_name}"
259+
)
260+
})?;
241261

242262
// Always download the latest from Tigris before writing.
243263
// Local disk may be stale if another machine pushed since our last access.
@@ -260,16 +280,23 @@ impl RepoStore {
260280
"write acquire: tigris download failed — falling back to local copy");
261281
} else {
262282
// Drop the lock on the error path before returning.
263-
// Unlock through the guard's connection, then clear conn
264-
// so Drop returns it to the pool (lock-free) instead of
265-
// closing it.
283+
// Unlock through the guard's connection (borrowed, not
284+
// taken), then clear conn so Drop returns the unlocked
285+
// connection to the pool. If the unlock query itself is
286+
// cancelled, Drop's close_on_drop handles it.
266287
if let Some(ref mut gconn) = guard.conn {
267-
let _ = sqlx::query("SELECT pg_advisory_unlock($1)")
288+
let unlocked = sqlx::query("SELECT pg_advisory_unlock($1)")
268289
.bind(guard.lock_key)
269290
.execute(&mut **gconn)
270291
.await;
292+
if let Err(e) = unlocked {
293+
warn!(repo = %repo_name, err = %e,
294+
"advisory unlock failed on error path — connection will be closed");
295+
// Leave conn in guard so Drop closes it.
296+
} else {
297+
guard.conn = None;
298+
}
271299
}
272-
guard.conn = None;
273300
return Err(e).context("downloading repo from tigris for write");
274301
}
275302
}
@@ -465,22 +492,50 @@ impl RepoWriteGuard {
465492
}
466493

467494
// Release advisory lock on the owning session, then return connection
468-
// to the pool. Take the connection so Drop sees None and does nothing.
469-
if let Some(mut conn) = self.conn.take() {
470-
let _ = sqlx::query("SELECT pg_advisory_unlock($1)")
495+
// to the pool.
496+
//
497+
// Crucially we keep the connection inside `self.conn` across the
498+
// `.await` so that if the future is cancelled here, the guard's `Drop`
499+
// (which calls `close_on_drop`) will close the session and the lock
500+
// is released by Postgres when the backend dies. Only after a
501+
// *successful* unlock is the connection taken out and returned to the
502+
// pool normally.
503+
//
504+
// See also `Drop` below.
505+
if let Some(ref mut conn) = self.conn {
506+
match sqlx::query("SELECT pg_advisory_unlock($1)")
471507
.bind(self.lock_key)
472-
.execute(&mut *conn)
473-
.await;
474-
// conn drops here, returned to the pool
508+
.execute(&mut **conn)
509+
.await
510+
{
511+
Ok(_) => {
512+
// Unlock confirmed — safe to return connection to the pool.
513+
self.conn.take();
514+
}
515+
Err(e) => {
516+
// Unlock failed (e.g. connection broken). Leave conn in
517+
// the guard so Drop closes the session — the lock will be
518+
// released when Postgres sees the backend die.
519+
warn!(
520+
repo = %self.repo_name,
521+
err = %e,
522+
"advisory unlock query failed — closing session to release lock"
523+
);
524+
}
525+
}
475526
}
476527
}
477528
}
478529

479530
impl Drop for RepoWriteGuard {
480531
fn drop(&mut self) {
481-
// If conn is still Some, release() was never called (e.g. cancellation).
482-
// Close the connection instead of returning it to the pool, so the
483-
// Postgres backend session ends and the advisory lock is released.
532+
// If conn is still Some, either:
533+
// * release() was never called (cancellation), or
534+
// * the unlock query itself failed / was cancelled.
535+
//
536+
// In every case, the lock is (or may be) held on this session.
537+
// Close the connection instead of returning it to the pool, so
538+
// Postgres ends the backend and releases the lock.
484539
if let Some(ref mut conn) = self.conn {
485540
conn.close_on_drop();
486541
}
@@ -623,7 +678,13 @@ mod tests {
623678
// the pool or the network. Fabricate a pool reference via PgPool::connect_lazy
624679
// so we don't need a live DB.
625680
let pool = sqlx::PgPool::connect_lazy("postgres://invalid").unwrap();
626-
RepoStore::new(PathBuf::from("/var/lib/gitlawb/repos"), None, pool)
681+
let lock_pool = sqlx::PgPool::connect_lazy("postgres://invalid").unwrap();
682+
RepoStore::new(
683+
PathBuf::from("/var/lib/gitlawb/repos"),
684+
None,
685+
pool,
686+
lock_pool,
687+
)
627688
}
628689

629690
#[tokio::test]
@@ -679,6 +740,96 @@ mod tests {
679740
assert_eq!(key, -6680856138670956537_i64);
680741
}
681742

743+
// ── integration: advisory lock exclusion ────────────────────────────────
744+
//
745+
// These tests exercise the full acquire/release/cancel lifecycle against a
746+
// real Postgres and are marked #[sqlx::test] so the framework provisions a
747+
// fresh database for each.
748+
749+
fn make_store_for_live(pool: PgPool) -> RepoStore {
750+
let lock_pool = sqlx::postgres::PgPoolOptions::new()
751+
.max_connections(2)
752+
.connect_lazy("postgres://invalid")
753+
.expect("lazy lock pool for testing");
754+
RepoStore::new(PathBuf::from("/tmp"), None, pool, lock_pool)
755+
}
756+
757+
#[sqlx::test]
758+
#[ignore = "requires DATABASE_URL — run manually against a real Postgres"]
759+
async fn competing_session_is_excluded_while_guard_held(pool: PgPool) {
760+
let store = make_store_for_live(pool.clone());
761+
762+
// Hold the lock in a guard.
763+
let guard = store
764+
.acquire_write("did:key:z6MkAlice", "excl-test")
765+
.await
766+
.unwrap();
767+
768+
// A second session on the same pool cannot acquire the same lock key.
769+
let key = advisory_lock_key("did_key_z6MkAlice", "excl-test");
770+
let row: (bool,) = sqlx::query_as("SELECT pg_try_advisory_lock($1)")
771+
.bind(key)
772+
.fetch_one(&pool)
773+
.await
774+
.unwrap();
775+
assert!(!row.0, "competing session must be excluded");
776+
777+
// Release.
778+
guard.release(true).await;
779+
780+
// Now the same session can acquire the lock.
781+
let row: (bool,) = sqlx::query_as("SELECT pg_try_advisory_lock($1)")
782+
.bind(key)
783+
.fetch_one(&pool)
784+
.await
785+
.unwrap();
786+
sqlx::query("SELECT pg_advisory_unlock($1)")
787+
.bind(key)
788+
.execute(&pool)
789+
.await
790+
.unwrap();
791+
assert!(row.0, "lock must be released after guard.release()");
792+
}
793+
794+
#[sqlx::test]
795+
#[ignore = "requires DATABASE_URL — run manually against a real Postgres"]
796+
async fn cancelled_guard_releases_lock_when_dropped(pool: PgPool) {
797+
// Spawn a task that acquires the lock and then drops the guard without
798+
// calling release() — simulating cancellation.
799+
let lock_pool = sqlx::postgres::PgPoolOptions::new()
800+
.max_connections(2)
801+
.connect_lazy("postgres://invalid")
802+
.expect("lazy lock pool");
803+
let inner_store = RepoStore::new(PathBuf::from("/tmp"), None, pool.clone(), lock_pool);
804+
let handle = tokio::spawn(async move {
805+
let guard = inner_store
806+
.acquire_write("did:key:z6MkBob", "cancel-test")
807+
.await
808+
.unwrap();
809+
// Drop guard without calling release().
810+
drop(guard);
811+
});
812+
handle.await.unwrap();
813+
814+
// After the task completes, the lock must be released (the guard's Drop
815+
// should have closed the connection, ending the session).
816+
let key = advisory_lock_key("did_key_z6MkBob", "cancel-test");
817+
let row: (bool,) = sqlx::query_as("SELECT pg_try_advisory_lock($1)")
818+
.bind(key)
819+
.fetch_one(&pool)
820+
.await
821+
.unwrap();
822+
sqlx::query("SELECT pg_advisory_unlock($1)")
823+
.bind(key)
824+
.execute(&pool)
825+
.await
826+
.unwrap();
827+
assert!(
828+
row.0,
829+
"lock must be released after guard is dropped without release()"
830+
);
831+
}
832+
682833
#[test]
683834
fn advisory_lock_key_differs_for_different_inputs() {
684835
// Vary one axis at a time so a regression that drops either parameter

‎crates/gitlawb-node/src/main.rs‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,8 +279,20 @@ async fn main() -> Result<()> {
279279
None
280280
};
281281

282-
let repo_store =
283-
git::repo_store::RepoStore::new(config.repos_dir.clone(), tigris, db.pool().clone());
282+
let lock_pool = sqlx::postgres::PgPoolOptions::new()
283+
.max_connections(2)
284+
.acquire_timeout(std::time::Duration::from_secs(
285+
config.db_acquire_timeout_secs,
286+
))
287+
.connect(&config.database_url)
288+
.await
289+
.context("creating advisory-lock pool")?;
290+
let repo_store = git::repo_store::RepoStore::new(
291+
config.repos_dir.clone(),
292+
tigris,
293+
db.pool().clone(),
294+
lock_pool,
295+
);
284296

285297
// Per-DID limiter for the creation endpoints. Keyed on the authenticated
286298
// DID (attacker-varied), so bound its key set to cap memory.

‎crates/gitlawb-node/src/test_support.rs‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,14 @@ fn build_state(db: Arc<crate::db::Db>, pool: PgPool) -> AppState {
7474
task_event_tx: task_tx,
7575
graphql_schema: schema,
7676
machine_id: None,
77-
repo_store: crate::git::repo_store::RepoStore::for_testing(PathBuf::from("/tmp"), pool),
77+
repo_store: crate::git::repo_store::RepoStore::for_testing(
78+
PathBuf::from("/tmp"),
79+
pool,
80+
sqlx::postgres::PgPoolOptions::new()
81+
.max_connections(2)
82+
.connect_lazy("postgres://localhost/gitlawb_test_placeholder")
83+
.expect("lazy lock pool creation should not fail"),
84+
),
7885
rate_limiter: RateLimiter::new(100, Duration::from_secs(60)),
7986
create_ip_rate_limiter: RateLimiter::new(1000, Duration::from_secs(3600)),
8087
push_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),

‎docs/RUN-A-NODE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ GITLAWB_ENFORCE_OWNER_PUSH=true
178178
| Operator key | Dedicated wallet, small ETH balance, not your main treasury |
179179
| Monitoring | Watch `lastHeartbeat` on-chain; alert if > 22h since last beat |
180180
| Public URL | Must resolve and serve `/health` — peers will ping it |
181+
| Rolling upgrade (advisory-lock algorithm) | v0.7.0 re-keys every repo's advisory lock (SHA-256 replaces `DefaultHasher`). During a shared-Postgres rolling upgrade, old nodes compute a different `i64` key than new nodes for the same repo, so PostgreSQL treats them as independent locks and cross-machine write-exclusion is lost. **Drain in-flight writes** or cut over through a **single node** before bringing new-binary nodes online. |
181182

182183
---
183184

0 commit comments

Comments
 (0)