Skip to content

fix(node): use readiness for peer liveness - #248

Open
MikeTomlin19 wants to merge 6 commits into
Gitlawb:mainfrom
MikeTomlin19:agent/fix-peer-readiness-176
Open

fix(node): use readiness for peer liveness#248
MikeTomlin19 wants to merge 6 commits into
Gitlawb:mainfrom
MikeTomlin19:agent/fix-peer-readiness-176

Conversation

@MikeTomlin19

@MikeTomlin19 MikeTomlin19 commented Jul 25, 2026

Copy link
Copy Markdown

Summary

Use the database-aware /ready endpoint for periodic and manual peer checks, while falling back to /health only when an older peer returns 404 for /ready. Periodic federation state now requires two consecutive failed samples before marking a peer unreachable; the anonymous manual-ping endpoint is read-only.

Motivation & context

/health intentionally reports process liveness and remains successful during a database outage. Using it for peer reachability lets a node continue treating a peer as healthy even though that peer cannot serve database-backed requests. A single transient readiness failure also should not remove a peer from federated repository results for the next five-minute interval.

Closes #176

Kind of change

  • Bug fix
  • Feature
  • Security fix
  • Docs
  • Tests / CI
  • Refactor (no behavior change)
  • Breaking or protocol change (issue required first)

What changed

  • Changed the shared peer probe from /health to /ready, with a 404-only /health fallback for nodes released before /ready existed.
  • Reused that helper from the manual peer-ping API so both paths have identical readiness and no-redirect behavior.
  • Kept the anonymous manual-ping endpoint read-only; only the periodic gossip loop updates persisted federation reachability.
  • Required two consecutive periodic failures before marking a peer unreachable, while restoring reachability immediately after a successful sample.
  • Added structured logs for readiness failures, legacy fallback, fallback failures, transport failures, and timeout exhaustion.
  • Added regression coverage proving the manual handler uses readiness without mutating the federation gate, one transient failure preserves the gate, sustained failure clears it, success restores it, legacy peers remain compatible, and neither request path follows redirects.
  • Updated the operator guide and degraded-router comment to distinguish liveness from readiness.

How a reviewer can verify

DATABASE_URL=postgres://postgres:postgres@localhost:5432/gitlawb_test cargo test --workspace
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo build --release --workspace

The focused regressions can also be run with:

cargo test -p gitlawb-node gossip_ssrf_tests
DATABASE_URL=postgres://postgres:postgres@localhost:5432/gitlawb_test \
  cargo test -p gitlawb-node manual_ping_uses_readiness_without_mutating_federation_gate

Before you request review

  • Scope is one logical change; no unrelated churn
  • cargo test --workspace passes locally
  • New behavior is covered by tests (required for fixes)
  • cargo fmt --all and cargo clippy --workspace --all-targets -- -D warnings are clean
  • Commit titles use Conventional Commits (feat(...), fix(...), docs(...))
  • Docs / .env.example updated if behavior or config changed (N/A)
  • Checked existing PRs so this isn't a duplicate

Protocol & signing impact

N/A — this does not change identity, signatures, UCANs, ref certificates, or P2P wire formats.

Notes for reviewers

The repository's current MSRV lane fails before compilation because newly resolved AWS crates require Rust 1.91.1 while CI installs 1.91 (currently 1.91.0). That existing manifest/lock/dependency drift is tracked in #242 and #243; stable tests, clippy, formatting, and the release build are clean for this change.

Summary by CodeRabbit

  • New Features
    • Peer monitoring now probes a database-aware readiness endpoint (/ready) to determine peer reachability, with legacy fallback to /health when /ready is unavailable.
  • Bug Fixes
    • Reachability updates are stabilized with a hysteresis gate (requires multiple consecutive unready samples before marking peers unreachable).
    • Hardened peer probing behavior for redirects and HTTP errors, and improved readiness-to-health fallback timing.
    • Incoming repo sync requests now validate repo slugs and reject malformed values with 400, without scheduling sync work.
  • Documentation
    • Updated the public URL checklist to require both GET /health and GET /ready.

@github-actions github-actions Bot added the needs-tests Source changed without accompanying tests (advisory) label Jul 25, 2026
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 06d2ebdc-65f9-4624-8828-07b64453eb03

📥 Commits

Reviewing files that changed from the base of the PR and between 9ace2c6 and e9bf1ea.

📒 Files selected for processing (3)
  • crates/gitlawb-node/src/api/peers.rs
  • crates/gitlawb-node/src/main.rs
  • docs/RUN-A-NODE.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • docs/RUN-A-NODE.md

📝 Walkthrough

Walkthrough

Peer liveness checks now use DB-aware /ready semantics, fall back to /health for legacy peers, share timeout budgets, and preserve redirect protections. Gossip persistence requires two consecutive failures, while API pings use the shared helper without mutating the federation gate.

Changes

Peer readiness probing

Layer / File(s) Summary
Readiness probe and validation
crates/gitlawb-node/src/main.rs
The shared probe requests /ready, treats only 2xx responses as ready, falls back to /health on 404, shares timeout handling, rejects redirects, and maps failures to false.
Peer check integration
crates/gitlawb-node/src/main.rs, crates/gitlawb-node/src/api/peers.rs
Gossip checks and API peer pings use the readiness helper; gossip persistence requires two consecutive failures, and manual pings do not rewrite the federation gate.
Operational readiness contract
crates/gitlawb-node/src/main.rs, docs/RUN-A-NODE.md
Degraded-server documentation covers both endpoints, and the public URL checklist requires /health and DB-aware /ready.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant GossipTask
  participant ping_peer_readiness
  participant PeerReadyEndpoint
  participant PeerHealthEndpoint
  participant PeerDatabase
  GossipTask->>ping_peer_readiness: Probe peer URL
  ping_peer_readiness->>PeerReadyEndpoint: GET /ready
  PeerReadyEndpoint-->>ping_peer_readiness: Readiness response
  ping_peer_readiness->>PeerHealthEndpoint: GET /health after 404
  PeerHealthEndpoint-->>ping_peer_readiness: Legacy response
  ping_peer_readiness-->>GossipTask: Ready boolean
  GossipTask->>PeerDatabase: Persist after success or second failure
Loading

Possibly related issues

Possibly related PRs

  • Gitlawb/node#280 — Related last_ping_ok persistence behavior when peer URLs change.

Suggested reviewers: kevincodex1, beardthelion

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: switching peer checks to readiness.
Description check ✅ Passed The description follows the template well and includes the required summary, context, change list, verification steps, and checklist items.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Thanks for the contribution. A couple of things will help us review this faster:

  • This changes Rust source but no tests changed. Tests are required for fixes and strongly encouraged for features.

See CONTRIBUTING.md. Update the PR and these notes will clear automatically.

@beardthelion beardthelion added crate:node gitlawb-node — the serving node and REST API kind:bug Defect fix — wrong or unsafe behavior subsystem:peers Peer announce, discovery, and registry labels Jul 25, 2026
@MikeTomlin19

Copy link
Copy Markdown
Author

For reviewer context, the needs-tests signal is a triage false positive rather than missing coverage.

This PR adds regression coverage inside the existing #[cfg(test)] module in crates/gitlawb-node/src/main.rs, using Tokio's #[tokio::test] attribute. In particular, ping_peer_readiness_ignores_liveness_only_health proves that 200 /health plus 503 /ready is treated as unready and that /health is never requested.

The triage workflow currently recognizes newly added literal #[test] and #[cfg(test)] lines, but not #[tokio::test], so it misses these inline async tests.

Validation completed locally:

  • cargo test -p gitlawb-node gossip_ssrf_tests — 4 passed
  • cargo test --workspace against PostgreSQL 16 — passed
  • cargo fmt --all -- --check — passed
  • cargo clippy --workspace --all-targets -- -D warnings — passed

@MikeTomlin19
MikeTomlin19 marked this pull request as ready for review July 25, 2026 19:30

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Preserve compatibility with peers that do not implement /ready
    crates/gitlawb-node/src/main.rs:985
    This unconditionally changes the peer wire contract from /health to /ready. Released v0.5.0 nodes expose /health but not /ready, and #170 deliberately left the peer probe on /health for exactly that rolling-upgrade case. Their /ready response is therefore 404, which this helper maps to false; both the periodic job and the manual ping persist that value. Since federated repository listing filters on last_ping_ok, a healthy v0.5.0 peer disappears from federation after the first probe. Please add a version/capability transition (for example, a 404-only legacy /health fallback that still treats a real /ready 503 as unready) and cover the mixed-version case, or provide the required coordinated rollout and compatibility window.

  • [P2] Update the operator peer-endpoint requirement
    docs/RUN-A-NODE.md:180
    The operator guide currently says that serving /health is sufficient because peers ping it. After this change peers instead require /ready, so an operator following the documented contract can be reported unreachable. Update the guidance to require /ready (and retain /health only for its liveness purpose).

  • [P3] Correct the degraded-router peer-ping comment
    crates/gitlawb-node/src/main.rs:735
    This comment still says peer pings use a successful /health response. The changed callers now use /ready, so the comment gives the wrong rationale for the degraded response and is likely to mislead future outage-routing work.

@MikeTomlin19

Copy link
Copy Markdown
Author

Addressed all three findings in f9b4ce3:

  • /ready now falls back to /health only on a 404, preserving compatibility with pre-/ready nodes while keeping real readiness failures such as 503 fail-closed.
  • Added mixed-version coverage (404 /ready + 200 /health) and redirect-safety coverage for the legacy fallback. The existing test still proves 503 /ready never falls back to a successful /health.
  • Updated the operator checklist to require both liveness /health and DB-aware /ready.
  • Corrected the degraded-router comment to describe peer readiness probes rather than 2xx /health pings.

Validation:

  • cargo test -p gitlawb-node gossip_ssrf_tests — 6 passed
  • cargo test --workspace against PostgreSQL 16 — passed
  • cargo fmt --all -- --check — passed
  • cargo clippy --workspace --all-targets -- -D warnings — passed

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Findings

  • [P2] Keep the legacy fallback within one peer-probe deadline
    crates/gitlawb-node/src/main.rs:988
    The shared client gives each request a 10-second timeout, but the new 404 fallback starts a second independently timed /health request. A legacy (or attacker-controlled public) peer can therefore hold the serial gossip loop for nearly 20 seconds by delaying /ready before returning 404 and delaying /health; the prior probe had a single 10-second budget. Since the peer table is unbounded and the loop updates one row at a time, enough such rows cause sweeps to overrun the five-minute interval and leave peer reachability stale. Apply one deadline to the complete /ready-then-/health operation (or pass the remaining budget to the fallback), and cover a delayed-404 case.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@beardthelion LGTM, requires your manual review.

@beardthelion beardthelion left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The readiness switch is the right call and all four findings from the earlier rounds landed properly. I confirmed the compatibility story by execution rather than by reading it: an axum router carrying only /health and no fallback, which is v0.5.0's shape, answers 404 for GET /ready, and the probe falls through to /health and returns ready. /ready first shipped in v0.5.1, so the peers that arm exists for really do return 404. A 403 and a 500 both fail closed without touching /health.

I also mutated each line inside ping_peer_readiness_with_timeout one at a time against the seven tests. Pointing the probe at /health, widening the fallback trigger past 404, giving the fallback its own timeout budget, letting the client follow redirects, flipping the catch-all to true, and deleting the 404 arm each turn at least one test red. That part is well covered. Four things below.

Findings

  • [P2] Bind the manual ping path to readiness with a test
    crates/gitlawb-node/src/api/peers.rs:442
    Reverting this line to the pre-PR inline /health probe leaves all seven tests green. Every line inside the helper reddens under mutation, so the harness works and this is a real hole: the handler's readiness behavior is unpinned, and a later edit can quietly return GET /api/v1/peers/{did}/ping to liveness-only while CI stays green. A test that drives the handler (or at minimum asserts the call site's behavior) closes it.

  • [P2] Do not let a single probe sample drop a peer from federation
    crates/gitlawb-node/src/main.rs:948-949
    The probe result is written to last_ping_ok unconditionally, and api/repos.rs:1451 filters the federated fan-out on that flag, so one failed sample removes a peer's repos from /api/v1/repos/federated until the next 300s tick. The single-sample write predates this PR; what changes is the set of states that flip it, since a peer whose database hiccups for two seconds now drops out where previously only an unreachable process did. Two consecutive failures before writing false, or one re-probe inside the existing budget, would fix it. Whichever shape you pick, the property to preserve is that a peer survives one transient failure and still drops out on a sustained one.

  • [P3] Log the probe outcome
    crates/gitlawb-node/src/main.rs:1000-1017
    Five materially different exits collapse into one bool with no tracing on any arm: ready, database-degraded 503, legacy 404 then healthy, refused redirect, and budget exhaustion. From the logs an operator cannot tell a peer whose database is down from one that is unreachable, which is the distinction this PR exists to draw. The bootstrap announce in the same function logs both its error and its timeout arm, so this is a gap against the file's own habit. The 503 arm and the legacy-404 downgrade are the two worth recording; the downgrade also gives you the list of peers still on the old build, which is what tells you when the fallback can be dropped.

  • [P3] Reconsider the anonymous ping route writing the federation gate
    crates/gitlawb-node/src/api/peers.rs:444, route wired at crates/gitlawb-node/src/server.rs:292
    peer_read_routes carries no auth layer and no per-IP brake, while sync_trigger_routes and peer_write_routes in the same file each carry one with a comment explaining that outbound fan-out needs it. This handler now drives a database-backed probe on the target and writes the flag that gates federation, from a single unauthenticated sample. Same lineage as the finding above, and the cheaper fix is probably to report the probe result without calling mark_peer_ping, leaving the flag owned solely by the gossip loop.

Not asks, just recording them

The probe URL is built by string concatenation, so a peer that announces https://host/?x=1 gets probed at / rather than /ready. I ran it: the /ready mock is never requested, / answers 200, and the peer reads ready even though /ready would have said 503. is_public_http_url validates scheme and host only, and upsert_peer stores the URL verbatim, so that shape survives announce. It is the same concatenation the pre-PR code used, and a hostile peer controls its own answer regardless, so it only really bites an honestly misconfigured operator. Url::join would be the tidy follow-up, separate from this PR.

README.md:276 and docs/OSS-READINESS-AUDIT.md:69 still list /health alone. Both predate this change and neither claims to state the peer contract, so I am not asking you to touch them here.

@github-actions github-actions Bot removed the needs-tests Source changed without accompanying tests (advisory) label Jul 27, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
crates/gitlawb-node/src/main.rs (1)

941-962: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

failed_once never sheds entries for peers that disappear.

A DID is removed from the set only on a readiness success. Peers pruned from the peers table (or permanently down) leave their DID behind for the process lifetime, and DIDs are announce-influenceable. Retaining against the current snapshot each tick keeps the set bounded.

♻️ Prune the failure set against the current peer list
                 let peers = match state.db.list_peers().await {
                     Ok(p) => p,
                     Err(_) => continue,
                 };
+                // Drop bookkeeping for peers that no longer exist so the set
+                // cannot grow unbounded over the process lifetime.
+                let current: HashSet<&str> = peers.iter().map(|p| p.did.as_str()).collect();
+                failed_once.retain(|did: &String| current.contains(did.as_str()));
                 for peer in peers {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gitlawb-node/src/main.rs` around lines 941 - 962, Update the peer
polling loop around failed_once and the list_peers result to prune failure
entries against the current peer snapshot on every tick. Retain only DIDs
present in peers, so removed or permanently unavailable peers cannot accumulate
in the HashSet while preserving the existing readiness update behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@crates/gitlawb-node/src/main.rs`:
- Around line 941-962: Update the peer polling loop around failed_once and the
list_peers result to prune failure entries against the current peer snapshot on
every tick. Retain only DIDs present in peers, so removed or permanently
unavailable peers cannot accumulate in the HashSet while preserving the existing
readiness update behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 80a159f2-3246-4a44-ac4e-42951e15b196

📥 Commits

Reviewing files that changed from the base of the PR and between a1072e8 and 9ace2c6.

📒 Files selected for processing (2)
  • crates/gitlawb-node/src/api/peers.rs
  • crates/gitlawb-node/src/main.rs

@beardthelion
beardthelion dismissed their stale review July 28, 2026 21:17

Superseded: re-reviewed the new head (e8a000c).

@beardthelion beardthelion left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both of my round-three asks landed, and both are now pinned by a test that fails without them. I re-ran the mutation matrix on this head rather than carrying last round's results forward: pointing the probe at /health reddens four tests, widening the fallback trigger past 404 reddens one, giving the fallback a fresh budget reddens one, flipping the catch-all to true reddens two, and letting the client follow redirects reddens two. Re-adding mark_peer_ping to ping_peer, or putting that handler back on an inline /health probe, each redden the new handler test.

The new hysteresis is the exception, and it is the one thing I want before merge.

Findings

  • [P2] Pin the gossip loop's use of the hysteresis, not just the helper
    crates/gitlawb-node/src/main.rs:956
    I replaced the whole match peer_ping_db_update(...) block with the pre-PR let _ = state.db.mark_peer_ping(&peer.did, ok).await;, which deletes the feature outright, and everything stayed green: 8 tests in gossip_ssrf_tests, 20 in api::peers::tests. The unit test covers the pure function and nothing covers its only call site, so this round's work is removable without CI noticing. Same shape as the manual-ping hole from last round, which you closed well. Deleting the failed_once.retain prune is green too, so one test can cover both. I built the fix before asking for it: extract the tick body into async fn gossip_ping_round(db: &crate::db::Db, client: &reqwest::Client, failed_once: &mut HashSet<String>, peers: Vec<crate::db::PeerRecord>), call it from the select! arm, and drive it from a #[sqlx::test] against a mockito peer answering /ready 503: after round one last_ping_ok is still TRUE, after round two it is FALSE. That is green as written and RED once the hysteresis inside the extracted function is deleted. Seed the peer with a raw INSERT the way your manual_ping... test does; upsert_peer rejects a loopback http_url and mockito binds on 127.0.0.1.

  • [P3] Give the gossip interval MissedTickBehavior::Delay
    crates/gitlawb-node/src/main.rs:940
    The hysteresis is counted in ticks, but its value is wall-clock: the second sample is meant to be 300s after the first. tokio::time::interval defaults to MissedTickBehavior::Burst in the pinned tokio 1.50, so a sweep that overruns the period is followed by catch-up ticks and the two samples can land seconds apart, which is the mass-failure case the guard exists for. A sequential sweep at up to 10s per peer needs roughly 30 unreachable peers to get there. Adding interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); after the constructor is the whole change and it compiles clean. Worst case today is the guard degrading to the old behavior rather than doing something wrong, hence P3.

Not asks, recorded

  • The anonymous ping route still has no per-IP brake, and /ready costs the target a database query where /health did not. I checked the delta, not the state: the pre-PR handler made the same unauthenticated outbound request and additionally wrote last_ping_ok, and the probe now sits inside a single 10s budget covering both legs. This PR moves that surface in the right direction; the brake is a separate question and not something I want in this PR.
  • last_seen is no longer refreshed on a first-failure tick, since that tick skips the write. Every reader is display or ordering and none gates on freshness. No action.
  • A peer alternating ready/unready is never persisted unreachable, because one success clears the set. That is the tradeoff behind the invariant I gave last round: survive one transient failure, drop out on a sustained one. Settled as intended, not open.

Scope is closed after this. The two commits since my last round have never been through CI: PR Checks is awaiting approval on both, and the last green run was a1072e8, before the hysteresis and the prune.

@MikeTomlin19

Copy link
Copy Markdown
Author

Addressed the final review findings in 32e4578:

  • Extracted one periodic sampling pass into gossip_ping_round.
  • Added a PostgreSQL-backed test that drives a mock /ready 503 peer through the real round function: the first round preserves last_ping_ok = TRUE, the second persists FALSE, and removed peers are pruned from the bounded failure set.
  • Set the five-minute interval's missed-tick behavior to Delay.

Validation:

  • cargo test --workspace against PostgreSQL 16 — passed
  • cargo fmt --all -- --check — passed
  • cargo clippy --workspace --all-targets -- -D warnings — passed

@beardthelion
beardthelion dismissed their stale review July 29, 2026 01:29

Superseded: both round-four asks landed and are now pinned by tests that fail without them. Re-reviewing head 32e4578.

@beardthelion beardthelion left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. The premise is pinned now: pointing the probe back at /health reddens six tests across both modules, and reverting gossip_ping_round's match to the pre-PR let _ = db.mark_peer_ping(&peer.did, ok).await; fails gossip_ping_round_requires_two_failures_before_persisting_unreachable. That was my round-four finding and it is closed by execution rather than by reading the new test.

I re-ran the whole matrix on this head instead of carrying earlier results forward. Deleting the hysteresis in peer_ping_db_update reddens two tests, deleting the failed_once.retain prune reddens one, widening the fallback trigger past 404 reddens two, unbinding the shared /ready+/health deadline reddens one, letting the client follow redirects reddens two, flipping the transport-error arm to true reddens one, and re-adding mark_peer_ping(&did, ok) to the manual handler or putting it back on an inline /health probe each redden the handler test.

CI is green on this head across all nine jobs. Your note about the MSRV lane failing on the AWS crate drift is stale: the base is current with main and MSRV (Rust 1.91) passes.

Findings

None blocking.

Recorded, not asks

  • I overstated one thing last round. I wrote that re-adding mark_peer_ping to ping_peer reddens the handler test. That holds for the faithful revert, which writes the probe result: mark_peer_ping(&did, ok) with /ready at 503 writes false and the test fails. It does not hold for a write of a hardcoded true, which stays green because the test seeds last_ping_ok = TRUE and asserts the same value. mark_peer_ping also stamps last_seen (db/mod.rs:2106), so asserting last_seen is byte-identical would pin the no-write property in both directions. Not asking for it.
  • gossip_task has no test, so the cross-tick lifetime of failed_once is unpinned: moving let mut failed_once (main.rs:942) inside the interval.tick() arm turns the hysteresis into a no-op and all 29 tests stay green, as does deleting the MissedTickBehavior::Delay line. This is the next layer of the shape I asked about last round, and pinning it needs the loop extracted the way the round was. Not asking for that here: the feature is pinned at the pure function and at the round, and the residue is loop scaffolding.
  • Dropping the Some(true) arm's write leaves all 29 green. A regression there strands a peer as permanently unreachable with no operator path back, since the manual ping is read-only by design now. Recording it, not asking.
  • The two-tick guard is spaced in ticks but argued in wall-clock. Probes are serial at up to 10s each and list_peers orders by last_seen DESC (db/mod.rs:2118), so a peer probed last in one round and first in the next can have its samples land seconds apart. That is traced from the code rather than measured. It takes roughly 30 unreachable peers to get there and the cost is one peer missing from /api/v1/repos/federated for a cycle, so I am leaving it. Keying first-failure on an Instant would make it wall-clock by construction if it ever bites.
  • peer_read_routes (server.rs:290) still carries no auth layer and no per-IP brake, unlike sync_trigger_routes and peer_write_routes in the same file. What changes here is the shape of the probe, not its cost to us: I checked the pre-PR handler and it already made one outbound request under the same 10s client timeout and additionally wrote mark_peer_ping, so our own per-request DB work goes down. What is new is that a peer answering 404 draws a second outbound leg, and the probe lands on /ready, which runs a query on the target where /health (server.rs:497) ran none. Filing that separately rather than growing this PR.
  • Not yours: upsert_peer (db/mod.rs:2093) rewrites http_url on conflict and leaves last_ping_ok alone, and announce accepts unsigned bodies while require_signed_peer_writes defaults false, so a repointed peer URL inherits reachable=true. Pre-existing, I will file it.

Scope is closed.

@MikeTomlin19
MikeTomlin19 force-pushed the agent/fix-peer-readiness-176 branch from 32e4578 to e9bf1ea Compare July 30, 2026 04:28
MikeTomlin19 pushed a commit to MikeTomlin19/node that referenced this pull request Jul 30, 2026
…its http_url (Gitlawb#270)

The ON CONFLICT branch rewrote http_url and left last_ping_ok alone, so an
unsigned announce could repoint an existing peer's URL and inherit the
reachable=true that the previous host earned. api/repos.rs filters the
federated fan-out on that flag and then stamps each entry from the peer's
response body with the peer's DID, so the inheritance is what made the
injection instant rather than probe-delayed.

Clear the flag when the URL actually changes, and leave it alone when it does
not. In the DO UPDATE branch peers.http_url is the existing pre-update row and
the comparison runs under the conflict row lock, so this needs no second round
trip and does not race a read-then-write. Both writers funnel through
upsert_peer, so the announce handler and the bootstrap announce-back are both
covered.

Observed transition on the tests from the previous commit:

  before  url_change_clears_reachability ... FAILED (last_ping_ok assertion)
  after   url_change_clears_reachability ... ok
          the other three ... ok throughout

Both guards were confirmed load-bearing by reverting, not by reading a green
suite. Removing the CASE clause entirely turns url_change_clears_reachability
RED again. Flattening it to last_ping_ok = (peers.http_url IS NOT DISTINCT FROM
$2) still passes three of the four cases and is caught only by
same_url_reannounce_does_not_grant_reachability, which is why that case exists.

This bounds the automatic inheritance; it does not close the rewrite. Four
other http_url consumers never read this flag, and until Gitlawb#248 lands the
unauthenticated ping route can write it back from the attacker's own host.
Gitlawb#273 is the closure.

Closes Gitlawb#270
@beardthelion

Copy link
Copy Markdown
Collaborator

@jatmn this was force-push rebased onto c83cbc5 after you approved, so the approval state you see is still the one from a1072e8 and GitHub carried it forward on its own.

The rebase itself is clean. The branch's own diff is unchanged, and the head-to-head delta is exactly main's, which I checked over the whole tree rather than just the touched files. Nothing came in with it.

What your approval predates is the commits after a1072e8, and that is the part worth your time. ping_peer no longer writes the federation gate at all, so the anonymous endpoint is read-only now. The gossip loop gained hysteresis: peer_ping_db_update plus the gossip_ping_round extraction in main.rs, with a timeout wrapper on the probe. One failed readiness probe no longer marks a peer unreachable, it takes two consecutive failures, so a peer that fails once stays reachable for another five-minute tick. That availability tradeoff is the call I would like a second read on.

CI is green on the rebased head apart from cargo audit, which is the ruint advisory hitting every branch right now (#292 is the bump). Nothing in this diff touches dependencies.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

crate:node gitlawb-node — the serving node and REST API kind:bug Defect fix — wrong or unsafe behavior subsystem:peers Peer announce, discovery, and registry

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Peer liveness ping targets /health (constant 200), so a mid-life DB outage doesn't drain peer traffic

3 participants