Skip to content

Commit 5e0af57

Browse files
committed
fix(node): no-report durability, cert landing order, bounded reconcile git, flush checks
1 parent c1f62c4 commit 5e0af57

7 files changed

Lines changed: 1036 additions & 88 deletions

File tree

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

Lines changed: 195 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1918,6 +1918,17 @@ const SYNC_NOTIFY_PATH: &str = "/api/v1/sync/notify";
19181918
/// accounting (`run_effects`: push events, certs, webhooks) instead
19191919
/// waits for the outcome commit, else startup reconcile (Option B
19201920
/// attended-restart contract).
1921+
///
1922+
/// Storage/replication split for capability-free pushes: a zero
1923+
/// `report-status` with git exit ok may still have landed refs, and the
1924+
/// shared Tigris copy must stay fresh — otherwise the next write's
1925+
/// `acquire_write` download replaces the local repo with the stale
1926+
/// archive, erasing the acknowledged landing (and its marker) before
1927+
/// startup reconcile can prove it. So `release_ok` also covers exit-ok
1928+
/// pushes with no report (`report_absent`), while `spawn_tail` stays
1929+
/// proven-refs-only: uploading the post-push disk state is always safe,
1930+
/// announcing unproven refs is not. The deferred tail for late-proved
1931+
/// refs runs from the drain (see `tail_owed_after_proof`).
19211932
#[derive(Debug, PartialEq, Eq)]
19221933
struct PostGitDisposition {
19231934
spawn_tail: bool,
@@ -1929,11 +1940,12 @@ fn post_git_disposition(
19291940
exit_ok: bool,
19301941
any_ref_ok: bool,
19311942
outcome_commit_ok: bool,
1943+
report_absent: bool,
19321944
) -> PostGitDisposition {
19331945
let landed = exit_ok && any_ref_ok;
19341946
PostGitDisposition {
19351947
spawn_tail: landed,
1936-
release_ok: landed,
1948+
release_ok: exit_ok && (any_ref_ok || report_absent),
19371949
run_effects: landed && outcome_commit_ok,
19381950
}
19391951
}
@@ -2246,6 +2258,23 @@ pub async fn git_receive_pack(
22462258
));
22472259
}
22482260
}
2261+
// Empty command stream (`0000` flush, no ref updates): no child can ever
2262+
// consume the proof, and the no-report outcome below terminalizes the
2263+
// parent without an accepted child that could ack it — leaving a
2264+
// terminal aggregate the retention gate holds forever. Ack up front:
2265+
// with zero children there are no effects to complete, so the proof is
2266+
// vacuously durable. (The ACK gate itself is untouched: any request
2267+
// that may have landed refs still waits for the drain's ack.)
2268+
if ref_updates.is_empty() {
2269+
if let Err(e) = state.db.ack_request_proof(&request_id).await {
2270+
tracing::warn!(
2271+
err = %e,
2272+
request_id = %request_id,
2273+
repo = %name,
2274+
"empty push: proof ack failed; retention will hold the aggregate"
2275+
);
2276+
}
2277+
}
22492278

22502279
// Per-repo in-process write lease (#174 U2/F3): SUPPLEMENTS the cluster-wide pg
22512280
// advisory lock. Acquire it BEFORE acquire_write (one consistent order everywhere,
@@ -2488,9 +2517,18 @@ pub async fn git_receive_pack(
24882517
// Marker hiding was verified above by `verify_recovery_prereqs`,
24892518
// which is warn-and-proceed (not a push refusal): on failure the
24902519
// marker is still written and reconcile quarantines on a missing
2491-
// or mismatched marker. Write the per-request marker through the
2492-
// bounded runner using the configured git binary.
2493-
match crate::git::store::marker_value_for(&disk_path, &req_row.request_bytes_hash) {
2520+
// or mismatched marker. Compute the value and write the per-request
2521+
// marker through the bounded runners using the configured git binary:
2522+
// both hold admission permits, the lease, and the write lock, so an
2523+
// unbounded `hash-object` could pin them all on a hung child.
2524+
match crate::git::store::marker_value_for_bounded(
2525+
&state.git_bin,
2526+
&disk_path,
2527+
&req_row.request_bytes_hash,
2528+
std::time::Duration::from_secs(10),
2529+
)
2530+
.await
2531+
{
24942532
Ok(marker_value) => {
24952533
if let Err(e) = crate::git::store::write_marker_bounded(
24962534
&state.git_bin,
@@ -2697,7 +2735,7 @@ pub async fn git_receive_pack(
26972735
Vec::new(),
26982736
pending_ref_names.clone(),
26992737
None,
2700-
Some("no report-status: awaiting request-bound disk evidence".to_string()),
2738+
Some(crate::durable_outbox::NO_REPORT_AWAITING_EVIDENCE.to_string()),
27012739
false,
27022740
)
27032741
}
@@ -2970,7 +3008,8 @@ pub async fn git_receive_pack(
29703008
//
29713009
// MUTATION (RED): gating `spawn_tail`/`release_ok` on
29723010
// `outcome_commit_ok` breaks `post_git_disposition_replication_carve_out`.
2973-
let disposition = post_git_disposition(exit_ok, any_ref_ok, outcome_commit_ok);
3011+
let disposition =
3012+
post_git_disposition(exit_ok, any_ref_ok, outcome_commit_ok, report.is_none());
29743013

29753014
if disposition.spawn_tail {
29763015
// Spawn the replication tail only for refs that landed.
@@ -3171,7 +3210,11 @@ pub async fn git_receive_pack(
31713210
/// the per-repo-coalesced pin/encrypt task, and this push's own Pinata + announce
31723211
/// task. Split out of `git_receive_pack` so the ordering the coalescing gate depends
31733212
/// on is directly testable; the handler spawns it and returns.
3174-
async fn post_receive_replication_tail(
3213+
/// Deferred-replication entry: the drain spawns this for aggregates that
3214+
/// complete without ever having a live tail (capability-free pushes whose
3215+
/// landing reconcile proved). `pub(crate)` so the drain can call it; the
3216+
/// live handler is the other caller.
3217+
pub(crate) async fn post_receive_replication_tail(
31753218
state: AppState,
31763219
record: RepoRecord,
31773220
ref_updates: Vec<RefUpdate>,
@@ -4697,7 +4740,7 @@ mod tests {
46974740
fn post_git_disposition_replication_carve_out() {
46984741
// Happy path: everything runs.
46994742
assert_eq!(
4700-
post_git_disposition(true, true, true),
4743+
post_git_disposition(true, true, true, false),
47014744
PostGitDisposition {
47024745
spawn_tail: true,
47034746
release_ok: true,
@@ -4706,31 +4749,52 @@ mod tests {
47064749
);
47074750
// Commit failure defers accounting but NOT replication/Tigris.
47084751
assert_eq!(
4709-
post_git_disposition(true, true, false),
4752+
post_git_disposition(true, true, false, false),
47104753
PostGitDisposition {
47114754
spawn_tail: true,
47124755
release_ok: true,
47134756
run_effects: false
47144757
},
47154758
);
4716-
// No landed refs: nothing runs anywhere.
4759+
// No landed refs with a report: nothing runs anywhere.
47174760
assert_eq!(
4718-
post_git_disposition(true, false, true),
4761+
post_git_disposition(true, false, true, false),
47194762
PostGitDisposition {
47204763
spawn_tail: false,
47214764
release_ok: false,
47224765
run_effects: false
47234766
},
47244767
);
4725-
// Failed git: nothing runs anywhere.
4768+
// Failed git: nothing runs anywhere, report or not.
47264769
assert_eq!(
4727-
post_git_disposition(false, false, false),
4770+
post_git_disposition(false, false, false, false),
47284771
PostGitDisposition {
47294772
spawn_tail: false,
47304773
release_ok: false,
47314774
run_effects: false
47324775
},
47334776
);
4777+
assert_eq!(
4778+
post_git_disposition(false, false, true, true),
4779+
PostGitDisposition {
4780+
spawn_tail: false,
4781+
release_ok: false,
4782+
run_effects: false
4783+
},
4784+
);
4785+
// No report with exit ok: refs may have landed, so the shared
4786+
// copy is refreshed (no stale-download erasure) but nothing is
4787+
// announced and no effects run until reconcile proves the landing.
4788+
// MUTATION (RED): dropping `report_absent` from `release_ok`
4789+
// reintroduces the stale-Tigris erasure of acknowledged pushes.
4790+
assert_eq!(
4791+
post_git_disposition(true, false, true, true),
4792+
PostGitDisposition {
4793+
spawn_tail: false,
4794+
release_ok: true,
4795+
run_effects: false
4796+
},
4797+
);
47344798
}
47354799

47364800
#[test]
@@ -8508,14 +8572,107 @@ mod tests {
85088572
);
85098573
assert_eq!(
85108574
last_error.as_deref(),
8511-
Some("no report-status: awaiting request-bound disk evidence"),
8575+
Some(crate::durable_outbox::NO_REPORT_AWAITING_EVIDENCE),
85128576
"parent carries the indeterminate reason, not an unpack-failure label"
85138577
);
85148578
assert_eq!(
85158579
git_exit_ok,
85168580
Some(true),
85178581
"parent records the true git exit (0), not a false unpack failure"
85188582
);
8583+
// Storage durability: git exited ok, so the shared copy is refreshed
8584+
// even with no proven refs — otherwise the next Tigris-backed write
8585+
// restores a stale archive over the landed refs (and their marker)
8586+
// before reconcile can prove the landing. The upload-site counter
8587+
// records reaching the upload decision (tests run tigris: None).
8588+
assert_eq!(
8589+
state.repo_store.tigris_upload_site_reached(),
8590+
1,
8591+
"exit-ok push must reach the Tigris upload site with or without a report"
8592+
);
8593+
}
8594+
8595+
/// A valid empty (`0000`, no commands) push terminalizes with its proof
8596+
/// acked, so retention can purge it: no child can ever consume the
8597+
/// proof, and the no-report outcome has no accepted child to ack it.
8598+
/// Without the up-front ack the terminal parent fails the ACK gate
8599+
/// forever. Backdates `completed_at` past retention and proves the
8600+
/// purge deletes the aggregate.
8601+
#[cfg(unix)]
8602+
#[sqlx::test]
8603+
async fn empty_receive_pack_acks_proof_for_retention(pool: sqlx::PgPool) {
8604+
use axum::extract::{Path, State};
8605+
use axum::Extension;
8606+
use std::net::SocketAddr;
8607+
8608+
let tmp = tempfile::TempDir::new().unwrap();
8609+
let git_bin = write_fake_git(tmp.path(), "#!/bin/sh\ncat >/dev/null\nexit 0\n");
8610+
let mut state =
8611+
f4_state_with_repo(pool.clone(), tmp.path(), &git_bin, "z6empty", "e1", false).await;
8612+
let mut cfg = (*state.config).clone();
8613+
cfg.enforce_owner_push = false;
8614+
state.config = std::sync::Arc::new(cfg);
8615+
8616+
let peer: SocketAddr = "203.0.113.97:5000".parse().unwrap();
8617+
let resp = git_receive_pack(
8618+
State(state.clone()),
8619+
Path(("z6empty".to_string(), "e1".to_string())),
8620+
Extension(crate::auth::AuthenticatedDid("did:key:z6empty".to_string())),
8621+
crate::rate_limit::PeerAddr(Some(peer)),
8622+
axum::http::HeaderMap::new(),
8623+
axum::body::Bytes::from_static(b"0000"),
8624+
)
8625+
.await
8626+
.expect("empty push receives the Git response");
8627+
assert_eq!(resp.status(), 200);
8628+
8629+
let repo_id = state
8630+
.db
8631+
.get_repo("z6empty", "e1")
8632+
.await
8633+
.unwrap()
8634+
.expect("repo exists")
8635+
.id;
8636+
let (req_id, req_state): (String, String) =
8637+
sqlx::query_as("SELECT id, state FROM receive_pack_requests WHERE repo_id = $1")
8638+
.bind(&repo_id)
8639+
.fetch_one(state.db.pool())
8640+
.await
8641+
.unwrap();
8642+
assert_eq!(
8643+
req_state,
8644+
crate::db::request_state::REJECTED_AT_GIT,
8645+
"empty push terminalizes without effects"
8646+
);
8647+
let proof = state
8648+
.db
8649+
.get_request_proof(&req_id)
8650+
.await
8651+
.unwrap()
8652+
.expect("proof row exists");
8653+
assert!(
8654+
proof.acked_at.is_some(),
8655+
"empty push acks its proof up front (no child ever could)"
8656+
);
8657+
8658+
// Age past retention: the aggregate must purge.
8659+
let old = (chrono::Utc::now() - chrono::Duration::days(8)).to_rfc3339();
8660+
sqlx::query("UPDATE receive_pack_requests SET completed_at = $1 WHERE id = $2")
8661+
.bind(&old)
8662+
.bind(&req_id)
8663+
.execute(state.db.pool())
8664+
.await
8665+
.unwrap();
8666+
let cutoff = (chrono::Utc::now() - chrono::Duration::days(7)).to_rfc3339();
8667+
let purged = state
8668+
.db
8669+
.purge_completed_receive_pack_requests_returning(&cutoff, 100)
8670+
.await
8671+
.unwrap();
8672+
assert!(
8673+
purged.iter().any(|(id, _)| id == &req_id),
8674+
"empty terminal aggregate is purge-eligible, got {purged:?}"
8675+
);
85198676
}
85208677

85218678
/// `unpack_ok: false` with a per-ref `ok: true` bit and exit zero must
@@ -12244,30 +12401,45 @@ mod tests {
1224412401
.await
1224512402
.unwrap();
1224612403

12247-
// Hold a row lock on the parent for the whole call: every cleanup
12248-
// UPDATE blocks on it.
12249-
let mut locker = pool.acquire().await.expect("locker connection");
12404+
// Hold the parent row locked inside an EXPLICIT transaction for the
12405+
// whole call: a pooled `SELECT ... FOR UPDATE` outside a transaction
12406+
// releases at statement end (autocommit) and holds nothing. Every
12407+
// cleanup UPDATE below blocks on this lock until the per-attempt
12408+
// timeout fires.
12409+
let mut locker = pool.begin().await.expect("locker transaction");
1225012410
sqlx::query("SELECT 1 FROM receive_pack_requests WHERE id = $1 FOR UPDATE")
1225112411
.bind("req-row-locked")
1225212412
.fetch_one(&mut *locker)
1225312413
.await
1225412414
.unwrap();
1225512415

12256-
// Outer guard: GREEN returns at the test budget (~0.5s); a missing
12257-
// per-attempt bound parks the UPDATE past it and trips this.
12416+
// GREEN returns at the test budget with elapsed below the 10s
12417+
// per-attempt cap, proving the clamp (not just the loop budget):
12418+
// without the per-attempt bound the UPDATE parks until the lock
12419+
// releases — never, in this test — and trips the outer guard.
12420+
// The outer guard sits above the 10s cap but far below infinity so
12421+
// a regression slow-fails instead of wedging the suite.
12422+
let started = std::time::Instant::now();
1225812423
tokio::time::timeout(
12259-
std::time::Duration::from_secs(20),
12424+
std::time::Duration::from_secs(15),
1226012425
retry_guard_cleanup_with_budget(
1226112426
&state.db,
1226212427
"req-row-locked",
1226312428
false,
1226412429
None,
12265-
std::time::Duration::from_millis(500),
12430+
std::time::Duration::from_secs(2),
1226612431
),
1226712432
)
1226812433
.await
12269-
.expect("bounded attempts must return at the budget, not park past it");
12270-
drop(locker);
12434+
.expect("bounded attempts must return, not park past the outer guard");
12435+
let elapsed = started.elapsed();
12436+
assert!(
12437+
elapsed < std::time::Duration::from_secs(10),
12438+
"cleanup under a held row lock must return inside the per-attempt \
12439+
bound, not park the UPDATE; took {elapsed:?}"
12440+
);
12441+
// Releasing the lock lets the stranded aggregate terminalize.
12442+
locker.rollback().await.unwrap();
1227112443
}
1227212444

1227312445
/// The internal-namespace predicate covers the bare `refs/gitlawb` name

0 commit comments

Comments
 (0)