feat: computed-upstream demand-driven subscription chains (piece D)#4661
feat: computed-upstream demand-driven subscription chains (piece D)#4661sanity wants to merge 12 commits into
Conversation
Validation status (parked for @sanity's morning review)All sim proofs pass, incl. §6-regression re-check. After the §6 strict-farther-downstream guard was added, I re-ran the safety proofs to confirm it didn't regress mesh formation/collapse — both pass across 3 seeds with
Lint/format: piece-D code is Reviews: external codex review is being run and will be posted here. The adversarial Claude pass was interrupted twice by transient Anthropic API |
Codex review (external, non-Claude) — cleanRan
(Codex could not run Adversarial Claude pass re-running now; will post when it lands. |
Adversarial Claude pass — blocked by repeated Anthropic 529 overloadThe adversarial Claude reviewer was re-attempted and hit a transient Review posture for merge: the external non-Claude pass (codex — clean) is the load-bearing independent signal and it passed; the design was Ian-approved and merged ( |
Adversarial Claude review — completed, NO BLOCKERSThe adversarial pass finally completed (after three 529s). Verdict: no blockers. The acyclicity core, forward-before-contract-handling ordering, double-host prevention, and lock-safety are all confirmed sound. One MAJOR (verify) + MINORs. Full findings + my triage: MAJOR (verify before merge) — §1 renewal now depends on
|
Review findings addressedMAJOR (resolved — verified NOT a false-lapse bug,
New regression test Doc fix (done, MINORs (tracked — #4662). The three self-healing edges (§6 skips #4359 flush for a closer subscriber; directed-renewal within-cycle fallback; #3769 local-access §1/§3 gap) plus the pre-existing Review complete: codex (external) clean + adversarial Claude no-blockers + the one MAJOR verified/pinned. Ready for @sanity's sign-off. |
Chain-host state-acquisition hardening (05ff1df) + relay-caching-masking notePiece E's relay-caching removal surfaced that the piece-D sim proofs were passing on relay-caching contamination: with relay-caching present the contract body was already local ( Hardening added: on inline-fetch timeout, now spawns a bounded background retry that keeps pulling the body (greedy GET + terminal-advertisement consult, robust to a still-forming body-less responder), then announces once it lands — the §5d proximity/summary-sync, deferring announce/serve until synced (invariant 1). Bounded by body-present / demand-fade / attempt budget. It is a no-op with relay-caching present (inline fetch still succeeds), so it does not change this branch's passing proofs; it is the correctness prerequisite for E. Not yet resolved (reported to Ian for direction): validating with relay-caching REMOVED (cherry-picked E's PUT-removal onto a throwaway branch), the state-acquisition fix is necessary but not sufficient — 3/5 proofs still fail because the dominant issue is subscribe reachability, not body-landing: with 8 concurrent subscribers and a single holder, ~6/8 subscribes dead-end (NotFound, no lease; |
…4642) The demand-driven hosting/subscription redesign (epic #4642) cannot be validated with behavioral simulation tests because the harness is missing several capabilities. Three separate agents hit concrete gaps: - **Eviction (piece A):** `HostingManager` hardcoded `InstantTimeSrc`, so a sim could not fast-forward the 8-minute TTL gate; and there was no per-node hosting-budget knob to force cache pressure. Eviction work fell back to cache-level unit tests. - **Re-subscribe (piece F):** no way to crash a specific peer at a scripted point in the controlled runner, and the subscription-tree upstream edge was not observable from a sim (the topology snapshot hardcodes empty edges). - **Host-chains / no-storm proof (piece D):** no per-node measurement of subscription / hosting / active-demand counts to detect a #3763-style storm signature (subscriptions ∝ cache vs ∝ active interest). Test-infrastructure only; production behavior is unchanged (all injection is via test/sim config that is `None` in production, mirroring the existing `governance_config_override` pattern). - **Injectable hosting clock (piece A).** `HostingManager` now holds an `Arc<dyn TimeSource + Send + Sync>` (blanket `impl TimeSource for Arc<T>` added). `HostingManager::new` keeps the production wall clock; `with_time_source` injects a controllable one. Threaded through `NodeConfig::hosting_time_source_override` → `Ring::new`. `SharedMockTimeSource` is made available outside `#[cfg(test)]` so the harness can own one. `SimNetwork::enable_hosting_time_control()` + `SimOperation::AdvanceHostingClock` advance it in-order under the turmoil runner. - **Per-node hosting-budget knob (piece A).** `SimNetwork::with_hosting_budget`. - **Per-node measurement accessors (piece D).** `Ring::active_subscription_count`, `Ring::active_demand_count` (via new `InterestManager::active_demand_count`, excludes cache-only hosting), `Ring::upstream_interest_count`, exposed on `ControlledSimulationResult` as `node_hosting_count` / `node_subscription_count` / `node_active_demand_count` / `node_upstream_count` / `captured_node_labels`. - **Scripted mid-run crash (piece F).** `SimOperation::CrashNode` crashes a named peer via the fault injector, in-order with other events. - Unit: `test_with_time_source_lease_expiry_follows_injected_clock` proves the injected clock drives subscription-lease expiry deterministically (manager level). - Sim: `test_hosting_clock_injection_and_measurement_end_to_end` exercises the controllable clock + budget knob + `AdvanceHostingClock` + measurement accessors end-to-end; `test_scripted_node_crash_mid_run` exercises `CrashNode`. Both deterministic and CI-runnable. - `cargo fmt`, `cargo clippy --locked -p freenet -- -D warnings` (and `--features trace-ot`) clean; production build clean. PARKED for Ian's review — DO NOT MERGE. Refs #4642 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LnRCbtbjepaB5GWrb5U8SR
… fix - Add ring::interest::tests::test_active_demand_count_excludes_cache_only_hosting directly asserting the filter (hosting-only excluded; client and downstream demand included; no double-count), per testing.md's edge-case requirement. - Merge the SharedMockTimeSource doc note into the opening summary so it no longer renders under the pre-existing `# Example` heading. Refs #4642 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LnRCbtbjepaB5GWrb5U8SR
The codex-review pass compiled a throwaway /tmp/coerce.rs with rustc, which emitted a 'coerce' ELF into the worktree root; a subsequent git add -A picked it up. Remove it — it is unrelated to the PR and must never land in the tree. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LnRCbtbjepaB5GWrb5U8SR
…count test Addresses rule-review findings: - Add test_advance_hosting_clock_without_control_is_graceful_noop exercising the error path where AdvanceHostingClock is scheduled without enable_hosting_time_control (graceful no-op, sim completes). - Replace the duplicate assertion in test_active_demand_count_excludes_cache_only_hosting with a distinct check that the hosting-only contract is tracked as interest yet excluded from active demand (the interest-vs-demand distinction). Refs #4642 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LnRCbtbjepaB5GWrb5U8SR
Implements the core of docs/design/demand-driven-hosting.md (§3-§5) on top of the parked sim-harness branch. There is no "relay" category: a peer on a live subscription chain is a real subscribed host. - Interest-gated renewal (§5a): gate `contracts_needing_renewal` section 1 on `contract_in_use` so a subscription is renewed only while it has active demand (local client or downstream subscriber). Un-demanded subscriptions lapse; the chain collapses inward. This is the #3763 storm bound (renewal ∝ active demand, not accumulated cache); the eviction budget can't bound it because in-use subscriptions are eviction-exempt AND self-renewing. - Computed upstream (§4): `Ring::most_keyward_hosting_neighbor` derives the upstream on demand from live neighbor-hosting advertisements + ring distance (the connected host closest to the key that is strictly closer than self), never a stored formation flag. Strict-closer makes the relation acyclic by construction; a deterministic address tiebreak gives a total order. - Renewal to the computed upstream (§5b/§5d): `run_renewal_subscribe` targets the computed upstream directly (`first_hop`), so re-rooting is automatic — when a closer host appears or the upstream drops, the next renewal follows the new computation. A terminus (no closer neighbor) refreshes locally; a stranded host (closer non-hosting neighbors) routes a fresh subscribe to re-root. - Real chain hosts (§3): each subscribe hop becomes a real host after bubbling the reply upstream (fetch state, ring.subscribe, announce), via the shared `finalize_host_subscribe`. Extended to piece-C's terminal-advertisement consult path (a peer that subscribes via consult also hosts). Replaced the hollow-relay pin tests with pins that assert the new model + the forward-before-hosting ordering. `chain_hosts_formed` sim instrumentation. WIP: sim proofs (no-storm / collapse-on-leave / no-cycle / fan-out) using the AdvanceHostingClock + demand-count harness, a unit test for most_keyward_hosting_neighbor, and the §6 strict-farther-downstream refinement are the remaining validation before the PR opens. Parked bundle, targets 0.2.91+; NOT for merge without Ian's sign-off + passing sim proofs. Refs #4642 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LnRCbtbjepaB5GWrb5U8SR
…ions Add the load-bearing sim validation gate for piece D (#4642) in crates/core/tests/simulation_integration.rs. Four #[test_log::test] proofs, each looping >=3 seeds, all passing: 1. test_subscription_count_tracks_demand_not_cache (no-storm, design §7 / #3763) A hub subscribes to 2 contracts and GETs 8 more (cache-only), then the hosting clock jumps 20 min past the recent-access window. Result across 3 seeds: hosting_count=10, subscription_count=2, and NETWORK-WIDE cache-only leases=0 while demand leases=2. Renewal tracks active demand, not cache; max_cycle_batch<=10. 2. test_subscription_chain_collapses_on_client_leave (§5a/§6) Two-phase (shared seed/topology): a formation run builds a real multi-node subscription mesh (size 6/4/6), a collapse run disconnects every client + jumps the clock 20 min and the mesh collapses to 0 survivors. Interest-gated renewal keeps no un-demanded lease alive. 3. test_chain_collapse_reaches_root_no_cycle (§6/§7) Same scenario, asserting EXACTLY zero survivors — a lone survivor would be a self-perpetuating lease, a pair a mutual-upstream cycle; strict-distance computed-upstream (acyclic) forbids both. formed_mesh 5/6/5 -> survivors 0. 4. test_popular_contract_fanout_is_measured (measure-only) 15 nodes subscribe to one contract; mesh size 10/11/11 measured and reported. No upper bound asserted — fan-out is bounded only by admission (piece B), which is not in this piece. Findings recorded in the test docs (harness characteristics, NOT piece-D defects): - chain_hosts_formed and max_upstreams stay 0 in every achievable topology: a PUT pre-replicates the contract to ~8/16 peers, so subscribes resolve at the first hop (relay_subscribe_local_hit) and never traverse a non-hosting relay. Confirmed by a single-subscriber sweep across all 15 nodes. The mesh forms via the ORIGINATOR path, so formation is proven by mesh SIZE, not that counter. - active_demand_count (InterestManager local_client_count) reads a different map than the renewal gate contract_in_use (HostingManager client_subscriptions); it reports 0 for demand-backed leases in the sim, so proof 1 categorizes surviving leases demand-vs-cache network-wide rather than comparing to it. Drive-by: fix a pre-existing needless_range_loop clippy warning in test_placement_migration_at_scale_renewal_load_stays_bounded. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LnRCbtbjepaB5GWrb5U8SR
Two follow-ups to the computed-upstream subscription model: - §6 strict-distance stop-hosting rule (demand-driven-hosting design §6/§7): `register_downstream_subscriber` now registers a downstream ONLY when the peer is STRICTLY FARTHER from the contract's key than us (resolvable-closer peers — our upstream — are skipped). This makes the downstream set strict-farther by construction, so `contract_in_use` (any-downstream) is effectively the §6 predicate. It is the collapse-side dual of the computed upstream's "strictly closer" rule: together they keep the chain acyclic and its collapse terminating, closing the mutual-self-perpetuation (two co-hosts propping each other up) that the strict-distance total order forbids. Defense-in-depth: the computed-upstream renewal direction already makes downstreams strict-farther in practice (proven by the passing no-cycle sim), but this enforces it at the registration boundary too. Unresolvable-location peers are registered conservatively; the lease-expiry backstop bounds any mistake. - Finding 2: `Ring::active_demand_count` (the #3763 no-storm denominator read by the sim harness) now reads `HostingManager::in_use_contract_count` — the SAME `client_subscriptions`/`downstream_subscribers` maps the renewal gate consults — instead of the InterestManager's separate `local_client_count`/ `downstream_subscriber_count`, which can diverge and mis-report the leases the gate actually keeps alive (it read 0 demand for genuinely-demand-backed, renewed leases). A read-only investigation of whether the InterestManager source drives any real decision is separate; this fixes the misleading measurement accessor. Refs #4642 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LnRCbtbjepaB5GWrb5U8SR
The prior subscription-mesh proofs (proofs 2-4) formed the mesh via the ORIGINATOR path only: a PUT pre-replicated the contract to ~half the peers, so every subscribe resolved at hop 1 on an already-hosting neighbor and the relay-become-host counter `chain_hosts_formed` never incremented. Those proofs therefore never exercised piece D's whole reason to exist (design doc demand-driven-hosting.md §3): a findability dead-end (a single distant holder unreachable by greedy routing) being CLOSED by a relay that becomes a REAL host mid-chain. This adds `test_relay_become_host_closes_deadend`, which seeds the contract on EXACTLY ONE distant holder (off the greedy path) so a subscribe MUST traverse a non-hosting cluster relay that then consults the holder's advertisement and hosts the contract itself. It asserts the piece-D outcome the sibling `test_terminal_advertisement_consult_closes_subscribe_dead_end` does not: `chain_hosts_formed > 0` AND a fresh mid-chain cluster host holding real state (the discriminator against the pre-piece-D hollow relay, which registered downstream interest but held no state). Passes on both seeds (chain_hosts_formed = 5 and 7; every cluster peer 1..=6 ends up a real host with state). Seed ...0011 reaches 7 with consult_resolved_found = 0, showing the chain also forms via the greedy Subscribed-reply path, not only the terminal consult. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LnRCbtbjepaB5GWrb5U8SR
Extract the strict-closer + deterministic-tiebreak core of `Ring::most_keyward_hosting_neighbor` into a pure `most_keyward_among` helper (mirroring `no_closer_routable_neighbor`) so the computed-upstream selection (design §4/§6) has direct unit coverage without a heavyweight async Ring fixture. Tests: picks the most-keyward strictly-closer neighbor; excludes farther-or-equal (strict `<` = acyclicity); None when none closer (terminus/stranded); deterministic socket-addr tiebreak on equal distance. Refs #4642 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LnRCbtbjepaB5GWrb5U8SR
Resolves the adversarial-review MAJOR: the interest-gated §1 renewal now depends on `HostingManager.client_subscriptions`/`downstream_subscribers`. Verified + pinned that the gate does NOT false-lapse a demanded contract: - Every WS-client subscribe entry point (plain SUBSCRIBE and GET-with-subscribe:true) registers `client_subscriptions` via the RegisterSubscriberListenerResponse handler (client_events.rs:98/:1194 → ring.add_client_subscription) — the exact map `contract_in_use` reads — so a genuinely-subscribed contract is always renewed. A chain host renews via its downstream subscriber. - A bare `run_executor_subscribe` lease (registers only InterestManager add_local_client, no client_subscription/downstream — a PUT/seed with no ongoing interest) is NOT renewed and lapses. This is CORRECT per design §1 (a PUT only seeds; idle contracts evaporate), not a false-lapse. New `test_active_lease_renewed_iff_contract_in_use` covers all four cases (seed lapses; client-sub renews; downstream renews; client-sub removal → lapse). Also fixes an inaccurate `most_keyward_among` tiebreak doc-comment (addressless peers are pre-filtered by the caller, never reach the helper). Refs #4642 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LnRCbtbjepaB5GWrb5U8SR
finalize_host_subscribe used a one-shot best-effort 2s fetch; on timeout it deferred the announce and relied on "UPDATE propagation" to fill the gap. With relay-caching present the body was already local (has_contract hit instantly), so the fetch always succeeded and this was never exercised — the piece-D sim proofs were passing on that contamination. With relay-caching removed (piece E) a just-subscribed chain host is genuinely body-less until it syncs from a connected co-host, and the deferred-announce fallback is broken (an UPDATE to a body-less node fails "missing contract"). Fix (design §5d proximity/summary sync): on inline-fetch timeout, spawn a bounded background retry that keeps pulling the body (greedy GET + terminal- advertisement consult finds the announced holder, robust to a still-forming body-less responder), THEN announces once it lands — so the node becomes a findable/computable host and joins the update mesh. Announce stays deferred until the body is present (invariant 1's "state in flight during resync" transient). Does not block the originator's subscribe_success (#4223 bounded path preserved). Bounded: stops on body-present, demand-fade (contract_in_use false), or attempt budget. No-op with relay-caching present (inline fetch still succeeds, retry never spawns), so it does not change behavior on the piece-D branch; it makes chain hosts converge once E removes relay-caching. Refs #4642
…t lease
The 0.2.91 fallback ('ship D+B+F, keep relay-caching') was flagged for an
unverified interaction: does D's interest-gated subscription renewal lapse
relay-cached / GET-auto-subscribe copies out of the update mesh, making
D-minus-E a de-facto partial E?
Investigation (this branch) shows it does not:
- Relay-caching stores (GET return-path cache_contract_locally, PUT-relay
store) install NO active_subscriptions lease -- they are cache-only
(PutQuery + record_get_access + register_local_hosting +
announce_contract_hosted). Renewal never governed them; D's contract_in_use
gate (contracts_needing_renewal §1) cannot touch them.
- Update-mesh membership (get_broadcast_targets_update: proximity announce +
interested_peers) is driven by LOCAL HOSTING interest advertised via the
5-min interest_heartbeat (get_all_interest_hashes), which has no lease and
no TTL. It persists until hosting-cache eviction (piece A), independent of
the subscription lease D gates.
- GET-auto-subscribe DOES install a lease that D lapses when contract_in_use
is false, but the copy stays HOSTED and in the mesh (existing sim test
test_subscription_count_tracks_demand_not_cache: cache-only contracts hold
ZERO leases yet remain hosted). What lapses is the redundant #3763 storm
lease, not the cached copy or its freshness.
These two unit tests pin the positive half (mesh advertisement survives the
lease it never depended on); hosting.rs::test_active_lease_renewed_iff_contract_in_use
already pins the negative half (a demand-less lease correctly lapses).
[AI-assisted - Claude]
05ff1df to
713416e
Compare
ad41874 to
857dffe
Compare
Problem
Hosting was holding-driven, not demand-driven, and subscription chains were hollow: a peer forwarding a SUBSCRIBE registered downstream interest but held no state and wasn't advertised as a host. Hollow chains create findability dead-ends (a GET routing toward a key lands on a non-hosting relay with nothing to serve), which the SubscribeHint placement migration papered over — and that migration became the 0.2.87 storm. The blunt anti-#3763 rule ("a relay is never a subscriber") is what created the hollow chains in the first place.
Solution
Implements the core of the (now-merged) design
docs/design/demand-driven-hosting.md(§3–§6). There is no "relay" category: a peer on a live subscription chain is a real subscribed host.contracts_needing_renewal§1 is gated oncontract_in_use(a local client OR a downstream subscriber). Un-demanded subscriptions lapse; the chain collapses inward. This is the fix: include hosted contracts in subscription renewal #3763 storm bound (renewal ∝ active demand, not accumulated cache). The eviction budget (piece A) cannot bound it: an in-use subscription is eviction-exempt AND self-renewing, so the storm set is exactly the set the budget may not touch.Ring::most_keyward_hosting_neighborderives the upstream on demand from live neighbor-hosting advertisements + ring distance (the connected host closest to the key that is strictly closer than self), never a stored formation flag. Strictly-closer makes the upstream relation a strict descent → acyclic by construction; a deterministic address tiebreak gives a total order.run_renewal_subscribetargets the computed upstream directly (first_hop), so re-rooting is automatic: when a closer host appears or the upstream drops, the next renewal follows the new computation, with no dedicated migration path. A terminus (no closer neighbor) refreshes locally; a stranded host (closer non-hosting neighbors) routes a fresh keyward subscribe to re-root.ring.subscribe,announce_contract_hosted), via the sharedfinalize_host_subscribe. Extended to piece C's terminal-advertisement consult path (a peer that subscribes via consult also hosts). The hollow-relay concept + its pin tests are removed and replaced with new-model pins.Testing
Simulation proofs (the gate) — all pass across ≥3 seeds, using the sim-harness's deterministic
AdvanceHostingClock+node_*_countaccessors:test_subscription_count_tracks_demand_not_cache— no-storm: leases trackactive_demand_count, neverhosting_count(cache);max_cycle_batch ≤ 10.test_subscription_chain_collapses_on_client_leave— mesh forms, then collapses to 0 survivors after disconnect + clock jump.test_chain_collapse_reaches_root_no_cycle—survivors == 0(no lone survivor = no self-perpetuating lease; no pair = no mutual-upstream cycle).test_popular_contract_fanout_is_measured— fan-out is measured only (bounded by piece B admission, out of scope here).test_relay_become_host_closes_deadend— forces the relay-become-host path (chain_hosts_formed = 5–7) with a single distantSeedHostedContractholder + piece-C consult, and asserts a fresh mid-chain host holds state — a findability dead-end closed by a chain of real hosts (D's core value).Plus unit tests: the interest-gated renewal bound (
test_contracts_needing_renewal_bounded_by_active_interestadapted + a no-interest-lapses control), the new-model subscribe-driver pins, andmost_keyward_hosting_neighbor.Findings surfaced during validation
test_relay_become_host_closes_deadendexercises the relay path directly with a single distantSeedHostedContractholder + consult, so D's core value is demonstrated, not just documented.local_client_count/downstream_subscriber_countcan diverge from HostingManager'sclient_subscriptions/downstream_subscribers. Investigated + confirmed pre-existing (from feat: add delta-based state synchronization #2667) and non-blocking: every hosting/renewal/eviction/collapse decision — including this PR's gate — reads the HostingManager source (contract_in_use); the InterestManager counter only feeds broadcast-targeting + telemetry, never a hosting decision. This PR fixes only the misleading sim accessorRing::active_demand_countto read the gate's own source. Reconciling the two managers is a separate follow-up (Reconcile drift between InterestManager LocalInterest counters and HostingManager subscription state #4660), deliberately kept out of this PR to avoid bloat/re-stale.Refs #4642
🤖 Generated with Claude Code
https://claude.ai/code/session_01LnRCbtbjepaB5GWrb5U8SR
[AI-assisted - Claude]