Skip to content

Commit d1ce2e3

Browse files
committed
fix(node): harden release-upload outcomes and replication tail gating
Treat timed-out or failed archive uploads as non-durable in into_result, gate pinata/gossip on confirmed publish durability while keeping inv22 spawn order, split lock-pool vs main DB pool validation, map LockPoolBusy through From<anyhow::Error>, and attach Retry-After on repo write contention errors.
1 parent 4eb0c44 commit d1ce2e3

4 files changed

Lines changed: 187 additions & 64 deletions

File tree

‎crates/gitlawb-node/src/api/repos.rs‎

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2374,13 +2374,15 @@ pub async fn git_receive_pack(
23742374
// would return 200 to the pusher before the durable copy lands, which is a larger
23752375
// change to the client contract than the window it closes.
23762376
let push_succeeded = receive_result.is_ok();
2377+
let publish_durability = Arc::new(tokio::sync::Mutex::new(None));
23772378
if push_succeeded {
23782379
tokio::spawn(post_receive_replication_tail(
23792380
state.clone(),
23802381
record.clone(),
23812382
ref_updates.clone(),
23822383
disk_path.clone(),
23832384
auth.0.to_string(),
2385+
Some(publish_durability.clone()),
23842386
));
23852387
}
23862388

@@ -2401,7 +2403,11 @@ pub async fn git_receive_pack(
24012403
// touching the repo, recording the push, bumping trust, issuing ref
24022404
// certificates or answering 200 would all be reporting a write no other
24032405
// node can read.
2404-
reclaimed.release(push_succeeded).await.into_result()?;
2406+
let outcome = reclaimed.release(push_succeeded).await;
2407+
if push_succeeded {
2408+
*publish_durability.lock().await = Some(outcome);
2409+
}
2410+
outcome.into_result()?;
24052411
// Clean path: clone (a) already dropped inside run_git_service when the receive-pack
24062412
// group was reaped; clone (b) held here spanned the success-only Tigris upload that
24072413
// ran inside release() above. Drop it now so a second same-repo push proceeds the
@@ -2518,12 +2524,36 @@ pub async fn git_receive_pack(
25182524
/// the per-repo-coalesced pin/encrypt task, and this push's own Pinata + announce
25192525
/// task. Split out of `git_receive_pack` so the ordering the coalescing gate depends
25202526
/// on is directly testable; the handler spawns it and returns.
2527+
async fn publish_durability_confirmed(
2528+
slot: &Option<Arc<tokio::sync::Mutex<Option<crate::git::repo_store::ReleaseOutcome>>>>,
2529+
wait: std::time::Duration,
2530+
) -> bool {
2531+
let Some(slot) = slot else {
2532+
return true;
2533+
};
2534+
let start = std::time::Instant::now();
2535+
loop {
2536+
if let Some(outcome) = *slot.lock().await {
2537+
return matches!(outcome, crate::git::repo_store::ReleaseOutcome::Released);
2538+
}
2539+
if start.elapsed() >= wait {
2540+
// Handler disconnected during `release` without recording an outcome.
2541+
// Proceed so inv22's disconnect-during-release tail still runs.
2542+
return true;
2543+
}
2544+
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
2545+
}
2546+
}
2547+
25212548
async fn post_receive_replication_tail(
25222549
state: AppState,
25232550
record: RepoRecord,
25242551
ref_updates: Vec<RefUpdate>,
25252552
disk_path: std::path::PathBuf,
25262553
did: String,
2554+
publish_durability: Option<
2555+
Arc<tokio::sync::Mutex<Option<crate::git::repo_store::ReleaseOutcome>>>,
2556+
>,
25272557
) {
25282558
// Replication enforcement (Phase 2): decide once per push whether the public
25292559
// may read this repo at all and, if so, which blob OIDs must not leave the
@@ -2697,6 +2727,21 @@ async fn post_receive_replication_tail(
26972727
));
26982728
}
26992729

2730+
let durability_wait = std::time::Duration::from_secs(
2731+
state
2732+
.config
2733+
.lock_held_transfer_timeout_secs
2734+
.saturating_add(5),
2735+
);
2736+
if announce_at_root && !publish_durability_confirmed(&publish_durability, durability_wait).await
2737+
{
2738+
tracing::warn!(
2739+
repo = %record.id,
2740+
"skipping pinata/gossip tail: publish durability was not confirmed"
2741+
);
2742+
return;
2743+
}
2744+
27002745
// Pin new git objects to Pinata, then record branch→CID and gossip.
27012746
//
27022747
// #174 P2-2 scope note: this SECOND detached spawn is deliberately NOT brought
@@ -9819,6 +9864,7 @@ mod tests {
98199864
f2a_update("refs/heads/main", &c2),
98209865
repo.path().to_path_buf(),
98219866
F2A_PUSHER.to_string(),
9867+
None,
98229868
)
98239869
.await;
98249870
let after_first = f2a_walks(&log);
@@ -9839,6 +9885,7 @@ mod tests {
98399885
f2a_update("refs/heads/second", &c1),
98409886
repo.path().to_path_buf(),
98419887
F2A_PUSHER.to_string(),
9888+
None,
98429889
)
98439890
.await;
98449891

@@ -9912,6 +9959,7 @@ mod tests {
99129959
f2a_update("refs/heads/main", &c2),
99139960
repo.path().to_path_buf(),
99149961
F2A_PUSHER.to_string(),
9962+
None,
99159963
)
99169964
.await;
99179965

@@ -9993,6 +10041,7 @@ mod tests {
999310041
f2a_update("refs/heads/main", &c2),
999410042
repo.path().to_path_buf(),
999510043
F2A_PUSHER.to_string(),
10044+
None,
999610045
));
999710046
f2a_wait_for(|| started.exists(), "the admitted push's walk to start").await;
999810047

@@ -10106,6 +10155,7 @@ mod tests {
1010610155
}],
1010710156
repo.path().to_path_buf(),
1010810157
F2A_PUSHER.to_string(),
10158+
None,
1010910159
)
1011010160
.await;
1011110161

@@ -10384,6 +10434,7 @@ mod tests {
1038410434
}],
1038510435
repo.path().to_path_buf(),
1038610436
F2A_PUSHER.to_string(),
10437+
None,
1038710438
)
1038810439
.await;
1038910440

@@ -10439,6 +10490,7 @@ mod tests {
1043910490
f2a_update("refs/heads/main", &c1),
1044010491
repo.path().to_path_buf(),
1044110492
F2A_PUSHER.to_string(),
10493+
None,
1044210494
)
1044310495
.await;
1044410496
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
@@ -10480,6 +10532,7 @@ mod tests {
1048010532
}],
1048110533
repo.path().to_path_buf(),
1048210534
F2A_PUSHER.to_string(),
10535+
None,
1048310536
)
1048410537
.await;
1048510538

@@ -10577,6 +10630,7 @@ mod tests {
1057710630
}],
1057810631
repo.path().to_path_buf(),
1057910632
F2A_PUSHER.to_string(),
10633+
None,
1058010634
)
1058110635
.await;
1058210636

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

Lines changed: 42 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -252,11 +252,9 @@ pub struct Config {
252252

253253
/// Maximum connections in the PostgreSQL pool. This is a cap, not a floor
254254
/// (connections open lazily). Size against the database server's
255-
/// max_connections, remembering admin tooling opens its own pool. Each
256-
/// concurrent write pins one pooled connection for its whole duration (the
257-
/// advisory lock in `repo_store::acquire_write` is connection-affine), so this
258-
/// must exceed `max_concurrent_git_pushes` by `DB_POOL_APP_HEADROOM` or slow
259-
/// pushes starve every other DB path — enforced by `Config::validate`.
255+
/// max_connections, remembering admin tooling opens its own pool. Writes pin
256+
/// the dedicated lock pool, not this one; this pool still needs enough headroom
257+
/// for ordinary request handlers and metadata paths (`DB_POOL_APP_HEADROOM`).
260258
#[arg(
261259
long,
262260
env = "GITLAWB_DB_MAX_CONNECTIONS",
@@ -769,21 +767,23 @@ impl Config {
769767
/// catches combinations that ship a denial-of-service under otherwise-valid
770768
/// values. Call once at startup and fail fast on `Err`.
771769
pub fn validate(&self) -> Result<(), String> {
772-
// A write pins one pooled connection for its whole duration (the
773-
// connection-affine advisory lock in repo_store::acquire_write), and
774-
// concurrent writes are capped at max_concurrent_git_pushes. If the pool
775-
// does not exceed that cap by DB_POOL_APP_HEADROOM, a burst of slow pushes
776-
// drains every connection and every other DB path 503s. (#174 F1)
777-
let floor = (self.max_concurrent_git_pushes as u64) + (Self::DB_POOL_APP_HEADROOM as u64);
778-
if (self.db_max_connections as u64) < floor {
770+
// Concurrent git writes pin the dedicated lock pool for their whole
771+
// duration (connection-affine advisory lock in acquire_write). The main
772+
// pool no longer carries that occupancy.
773+
if (self.db_lock_pool_max_connections as usize) < self.max_concurrent_git_pushes {
779774
return Err(format!(
780-
"GITLAWB_DB_MAX_CONNECTIONS ({}) must be at least max_concurrent_git_pushes ({}) \
781-
+ {} headroom = {}: each concurrent write pins one pooled connection for its whole \
782-
duration, so a smaller pool lets a burst of slow pushes starve every other DB path.",
783-
self.db_max_connections,
784-
self.max_concurrent_git_pushes,
785-
Self::DB_POOL_APP_HEADROOM,
786-
floor
775+
"GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS ({}) must be at least \
776+
max_concurrent_git_pushes ({}) so every admitted push can pin a \
777+
lock-pool connection for its whole duration",
778+
self.db_lock_pool_max_connections, self.max_concurrent_git_pushes
779+
));
780+
}
781+
let main_floor = Self::DB_POOL_APP_HEADROOM as u64;
782+
if (self.db_max_connections as u64) < main_floor {
783+
return Err(format!(
784+
"GITLAWB_DB_MAX_CONNECTIONS ({}) must be at least {} for non-git \
785+
database paths",
786+
self.db_max_connections, main_floor
787787
));
788788
}
789789
Ok(())
@@ -1422,41 +1422,48 @@ mod tests {
14221422
);
14231423
}
14241424

1425-
/// #174 F1: a connection-affine write lock pins a pooled connection per
1426-
/// concurrent write, so the pool must clear `max_concurrent_git_pushes` by
1427-
/// `DB_POOL_APP_HEADROOM` or a push burst starves every other DB path.
1428-
/// `validate()` must reject an under-sized pool at boot.
1425+
/// #174 F1: concurrent git writes pin the dedicated lock pool, not the main
1426+
/// pool. `validate()` must reject an under-sized lock pool at boot.
14291427
#[test]
14301428
fn db_pool_must_clear_the_git_push_cap() {
1431-
// Shipped defaults validate (48 >= 32 + 8).
1429+
// Shipped defaults validate (lock pool 32 >= pushes 32, main pool >= headroom).
14321430
Config::parse_from(["gitlawb-node"])
14331431
.validate()
14341432
.expect("default config must validate");
14351433

1436-
// An under-sized pool relative to the push cap is rejected (20 < 32 + 8).
1437-
let under = Config::parse_from([
1434+
// An under-sized lock pool relative to the push cap is rejected.
1435+
let under_lock = Config::parse_from([
14381436
"gitlawb-node",
1439-
"--db-max-connections",
1440-
"20",
1437+
"--db-lock-pool-max-connections",
1438+
"16",
14411439
"--max-concurrent-git-pushes",
14421440
"32",
14431441
]);
14441442
assert!(
1445-
under.validate().is_err(),
1446-
"db_max_connections 20 below max_concurrent_git_pushes 32 + headroom must be rejected"
1443+
under_lock.validate().is_err(),
1444+
"db_lock_pool_max_connections below max_concurrent_git_pushes must be rejected"
14471445
);
14481446

1449-
// Exactly at the floor validates (40 == 32 + 8).
1450-
let at_floor = Config::parse_from([
1447+
// Main pool can be smaller than pushes + headroom when the lock pool carries writes.
1448+
let split = Config::parse_from([
14511449
"gitlawb-node",
14521450
"--db-max-connections",
1453-
"40",
1451+
"16",
1452+
"--db-lock-pool-max-connections",
1453+
"32",
14541454
"--max-concurrent-git-pushes",
14551455
"32",
14561456
]);
14571457
assert!(
1458-
at_floor.validate().is_ok(),
1459-
"db_max_connections at the floor (pushes + headroom) must validate"
1458+
split.validate().is_ok(),
1459+
"a small main pool with a large lock pool must validate"
1460+
);
1461+
1462+
// Main pool still needs the app headroom floor.
1463+
let under_main = Config::parse_from(["gitlawb-node", "--db-max-connections", "4"]);
1464+
assert!(
1465+
under_main.validate().is_err(),
1466+
"db_max_connections below DB_POOL_APP_HEADROOM must be rejected"
14601467
);
14611468
}
14621469

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

Lines changed: 50 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -123,20 +123,24 @@ impl From<anyhow::Error> for AppError {
123123
// 500. The internal message names the owner slug and repo, so the
124124
// variant carries nothing: the detail stays in the log at the raise
125125
// site and the client gets a fixed retryable body.
126-
Err(err) => match err.downcast::<crate::git::repo_store::RepoBusy>() {
127-
Ok(_) => AppError::RepoBusy,
128-
// Same reasoning one rung down: a refused under-lock refresh is a
129-
// transient storage condition, and its internal message names the
130-
// owner slug and repo, so the variant carries nothing.
131-
Err(err) => match err.downcast::<crate::git::repo_store::RepoUnavailable>() {
132-
Ok(_) => AppError::RepoUnavailable,
133-
// And one more rung: a publish the store refused twice is
134-
// transient in the same way, and the retry is the client's
135-
// to make. The variant carries nothing for the same reason
136-
// as the two above.
137-
Err(err) => match err.downcast::<crate::git::repo_store::RepoWriteFenced>() {
138-
Ok(_) => AppError::RepoWriteFenced,
139-
Err(err) => AppError::Internal(err),
126+
Err(err) => match err.downcast::<crate::git::repo_store::LockPoolBusy>() {
127+
Ok(_) => AppError::Overloaded("git write locks at capacity, retry shortly".into()),
128+
Err(err) => match err.downcast::<crate::git::repo_store::RepoBusy>() {
129+
Ok(_) => AppError::RepoBusy,
130+
// Same reasoning one rung down: a refused under-lock refresh is a
131+
// transient storage condition, and its internal message names the
132+
// owner slug and repo, so the variant carries nothing.
133+
Err(err) => match err.downcast::<crate::git::repo_store::RepoUnavailable>() {
134+
Ok(_) => AppError::RepoUnavailable,
135+
// And one more rung: a publish the store refused twice is
136+
// transient in the same way, and the retry is the client's
137+
// to make. The variant carries nothing for the same reason
138+
// as the two above.
139+
Err(err) => match err.downcast::<crate::git::repo_store::RepoWriteFenced>()
140+
{
141+
Ok(_) => AppError::RepoWriteFenced,
142+
Err(err) => AppError::Internal(err),
143+
},
140144
},
141145
},
142146
},
@@ -296,7 +300,11 @@ impl IntoResponse for AppError {
296300
// here rather than in bespoke early returns, keeping each variant handled once.
297301
if matches!(
298302
self,
299-
AppError::Overloaded(_) | AppError::SearchIncomplete { .. }
303+
AppError::Overloaded(_)
304+
| AppError::SearchIncomplete { .. }
305+
| AppError::RepoBusy
306+
| AppError::RepoUnavailable
307+
| AppError::RepoWriteFenced
300308
) {
301309
resp.headers_mut().insert(
302310
axum::http::header::RETRY_AFTER,
@@ -414,4 +422,31 @@ mod tests {
414422
let resp = err.into_response();
415423
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
416424
}
425+
426+
#[test]
427+
fn lock_pool_busy_via_anyhow_from_is_503_overloaded() {
428+
let err: AppError = anyhow::Error::new(crate::git::repo_store::LockPoolBusy).into();
429+
let resp = err.into_response();
430+
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
431+
assert_eq!(
432+
resp.headers().get("retry-after").unwrap().to_str().unwrap(),
433+
"1"
434+
);
435+
}
436+
437+
#[test]
438+
fn repo_busy_unavailable_and_fenced_advertise_retry_after() {
439+
for err in [
440+
AppError::RepoBusy,
441+
AppError::RepoUnavailable,
442+
AppError::RepoWriteFenced,
443+
] {
444+
let resp = err.into_response();
445+
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
446+
assert_eq!(
447+
resp.headers().get("retry-after").unwrap().to_str().unwrap(),
448+
"1"
449+
);
450+
}
451+
}
417452
}

0 commit comments

Comments
 (0)