@@ -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)]
19221933struct 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