Skip to content

Commit 1fa9a1f

Browse files
committed
fix(node): address four reviewer findings on #26 split 1/4
- P1-A: add startup reconcile step that promotes `prepared` rows to `applied` when the on-disk ref matches the row's `new_sha`. The recovery drain (which only reads `applied` rows) can now pick up a ref that landed when the live handler's `mark_pending_ref_transitions_applied` call errored or was interrupted. Strict SHA equality is the load-bearing check — a `prepared` row whose target did NOT actually land stays `prepared`. - P1-B: route the live handler's cert issuance through `cert::issue_ref_certificate` (the upsert) instead of `issue_ref_certificate_idempotent` (DO NOTHING). A re-push to the same ref now updates the cert's `old_sha` / `new_sha` / `pusher_did` / `issued_at` / `signature` to the new transition while preserving the deterministic `cert_id`. The recovery drain keeps the idempotent variant; both paths collapse to one row. - P2-A: refactor the drain into a `drain_pending_ref_transitions_with` testable seam that does per-row log-and-continue, and add `drain_pending_ref_transitions_all` that loops `DRAIN_PER_PASS_LIMIT=1000` rows for `DRAIN_MAX_PASSES=10` passes. A failing row no longer stalls the batch; a backlog above 1000 rows is fully processed across passes. - P2-B: add a `first_ref_name` column to `pending_ref_transitions` via migration v28. The live handler hoists a `first_ref_name` local and persists it on every row of the same `request_id`. The drain's `derive_one` keys the push event id on `row.first_ref_name` instead of `row.ref_name`, so live and recovery produce the same id and `ON CONFLICT (id) DO NOTHING` collapses a multi-ref push to one push event row (and one trust- score bump). Cert and anchor ids stay per-ref / per-transition.
1 parent 07109f4 commit 1fa9a1f

5 files changed

Lines changed: 1398 additions & 39 deletions

File tree

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

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2279,6 +2279,20 @@ pub async fn git_receive_pack(
22792279
.and_then(|v| v.to_str().ok())
22802280
.unwrap_or("")
22812281
.to_string();
2282+
// The first ref name is hoisted here so it is the SAME value
2283+
// persisted on every `pending_ref_transitions` row of this request
2284+
// (request-scoped) AND the SAME value used to derive the push event
2285+
// id below. The recovery drain reads `first_ref_name` from each row
2286+
// and keys `push_event_id_for` on it, so the live and recovery
2287+
// paths produce the same id and the ON CONFLICT (id) DO NOTHING in
2288+
// `record_push_with_id` collapses a live push followed by a recovery
2289+
// pass into a single push event row. An empty `ref_updates` is
2290+
// defensive: receive-pack on a push with no refs would have already
2291+
// been rejected upstream, so this branch is unreachable in practice.
2292+
let first_ref_name = ref_updates
2293+
.first()
2294+
.map(|u| u.ref_name.clone())
2295+
.unwrap_or_default();
22822296
if let Err(e) = state
22832297
.db
22842298
.insert_pending_ref_transitions(
@@ -2290,6 +2304,7 @@ pub async fn git_receive_pack(
22902304
&signature_header,
22912305
&signature_input,
22922306
&content_digest,
2307+
&first_ref_name,
22932308
)
22942309
.await
22952310
{
@@ -2464,11 +2479,12 @@ pub async fn git_receive_pack(
24642479
// The push event is keyed on the FIRST ref's name so a
24652480
// multi-ref push collapses to one push event row, not N. The
24662481
// deterministic id is the same one the recovery drain
2467-
// derives.
2468-
let first_ref_name = ref_updates
2469-
.first()
2470-
.map(|u| u.ref_name.clone())
2471-
.unwrap_or_else(|| "refs/heads/main".to_string());
2482+
// derives, because the drain reads `first_ref_name` from the
2483+
// outbox row (persisted above on every row of this request) and
2484+
// uses it in the same `push_event_id_for` call. The outer
2485+
// `first_ref_name` local was hoisted above the
2486+
// `insert_pending_ref_transitions` call so this id matches
2487+
// the persisted value exactly.
24722488
let push_event_id = crate::db::push_event_id_for(&request_id, &first_ref_name);
24732489
let _ = state
24742490
.db
@@ -2484,9 +2500,25 @@ pub async fn git_receive_pack(
24842500
// Issue a signed certificate for every ref this push advanced, each
24852501
// carrying that ref's real old→new transition. A multi-ref push must
24862502
// not collapse to a single cert covering only the first ref.
2503+
//
2504+
// P1-B: this routes through `issue_ref_certificate` (the
2505+
// upsert, `ON CONFLICT (repo_id, ref_name) DO UPDATE`) so a
2506+
// re-push to the same ref updates the row's
2507+
// `old_sha` / `new_sha` / `pusher_did` / `issued_at` /
2508+
// `signature` to the new transition while preserving the
2509+
// original `id` (the `insert_ref_certificate` upsert's
2510+
// `EXCLUDED.issued_at > ref_certificates.issued_at` guard).
2511+
// The previous `issue_ref_certificate_idempotent` call
2512+
// (DO NOTHING) left the first cert's fields frozen on every
2513+
// later push. The deterministic `cert_id` makes a recovery
2514+
// re-pass safe: the recovery's `insert_ref_certificate_idempotent`
2515+
// (DO NOTHING) is a no-op when the live handler has already
2516+
// written a row, and the live handler's upsert preserves the
2517+
// original `id` so the deterministic id survives across the
2518+
// push / recovery / re-push cycle.
24872519
for update in &ref_updates {
24882520
let cert_id = crate::db::ref_cert_id_for(&request_id, &update.ref_name);
2489-
match cert::issue_ref_certificate_idempotent(
2521+
match cert::issue_ref_certificate(
24902522
&state,
24912523
&record.id,
24922524
&update.ref_name,
@@ -2497,12 +2529,9 @@ pub async fn git_receive_pack(
24972529
)
24982530
.await
24992531
{
2500-
Ok(Some(c)) => {
2532+
Ok(c) => {
25012533
tracing::info!(cert_id = %c.id, repo = %record.name, ref_name = %update.ref_name, pusher = %did, "issued ref certificate")
25022534
}
2503-
Ok(None) => {
2504-
tracing::debug!(ref_name = %update.ref_name, repo = %record.name, "ref certificate already exists for this ref, idempotent skip")
2505-
}
25062535
Err(e) => {
25072536
tracing::warn!(err = %e, ref_name = %update.ref_name, "failed to issue ref certificate")
25082537
}

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

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,29 +11,57 @@ use uuid::Uuid;
1111
use crate::db::RefCertificate;
1212
use crate::state::AppState;
1313

14-
/// Issue a signed ref-update certificate for a successful push.
14+
/// Issue a signed ref-update certificate for a successful push. The
15+
/// live receive-pack handler calls this on every successful push.
1516
///
16-
/// Builds a canonical JSON payload, signs it with the node's Ed25519 key,
17-
/// persists the certificate, and returns it.
17+
/// `cert_id` is the deterministic id derived from `(request_id,
18+
/// ref_name)` (see [`crate::db::ref_cert_id_for`]). It is required so
19+
/// the recovery drain and the live handler produce the same primary
20+
/// key: a live push followed by a recovery pass collapses to a
21+
/// single cert row, and a re-push to the same `(repo, ref)` updates
22+
/// the existing row's `old_sha` / `new_sha` / `pusher_did` /
23+
/// `issued_at` / `signature` to the new transition while preserving
24+
/// the original `id` (the `insert_ref_certificate` upsert is
25+
/// keyed on `(repo_id, ref_name)` and only updates fields when the
26+
/// new `issued_at` is strictly greater).
1827
///
19-
/// #26 Split PR 1: the live handler now uses
20-
/// [`issue_ref_certificate_idempotent`] so the cert id is deterministic
21-
/// and recovery re-derives the same primary key. This legacy entry
22-
/// point remains for callers that prefer a fresh UUID per cert (it
23-
/// keeps the older `insert_ref_certificate` upsert semantics); Split
24-
/// PR 3 owns the cert/CLI compatibility decision of whether to keep
25-
/// it or remove it.
26-
#[allow(dead_code)] // kept for the PR 3 cert/CLI compat pass
28+
/// #26 Split PR 1 P1-B: the live handler routes through this
29+
/// function (the upsert), NOT through
30+
/// [`issue_ref_certificate_idempotent`] (DO NOTHING). The
31+
/// idempotent variant is reserved for the recovery drain. Both
32+
/// paths use the same deterministic `cert_id` so a re-pass is
33+
/// always safe:
34+
///
35+
/// - Live handler → live upsert: re-push updates the row, preserves
36+
/// the original `id`. The contract pinned by
37+
/// `insert_ref_certificate_upserts_on_repo_ref` is restored.
38+
/// - Live handler → recovery: live's `ON CONFLICT (id) DO UPDATE`
39+
/// preserves the original `id`; the recovery's
40+
/// `ON CONFLICT (repo_id, ref_name) DO NOTHING` is a no-op.
41+
/// - Recovery → live handler: the recovery wrote a row with the
42+
/// deterministic `id`; the live upsert (which preserves `id` and
43+
/// only updates other fields when `issued_at` is strictly newer)
44+
/// is a no-op for an equal-`issued_at` re-run and a refresh for
45+
/// a strictly-newer one.
2746
pub async fn issue_ref_certificate(
2847
state: &AppState,
2948
repo_id: &str,
3049
ref_name: &str,
3150
old_sha: &str,
3251
new_sha: &str,
3352
pusher_did: &str,
53+
cert_id: &str,
3454
) -> Result<RefCertificate> {
35-
let cert =
36-
build_ref_certificate(state, repo_id, ref_name, old_sha, new_sha, pusher_did, None).await?;
55+
let cert = build_ref_certificate(
56+
state,
57+
repo_id,
58+
ref_name,
59+
old_sha,
60+
new_sha,
61+
pusher_did,
62+
Some(cert_id.to_string()),
63+
)
64+
.await?;
3765
state.db.insert_ref_certificate(&cert).await
3866
}
3967

0 commit comments

Comments
 (0)