@@ -26,29 +26,42 @@ use super::tigris::TigrisClient;
2626pub 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
3642impl 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
479530impl 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
0 commit comments