Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 3 additions & 11 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "ant-node"
version = "0.18.1"
version = "0.19.0"
edition = "2021"
authors = ["David Irvine <david.irvine@maidsafe.net>"]
description = "Pure quantum-proof network node for the Autonomi decentralized network"
Expand Down Expand Up @@ -42,10 +42,10 @@ mimalloc = "0.1"
# Pinned to the ant-protocol main commit that merged the settlement wire
# types (WithAutonomi/ant-protocol#25). Swap back to a published version pin
# once the release train publishes them.
ant-protocol = { git = "https://github.com/WithAutonomi/ant-protocol", rev = "88ad3f1" }
ant-protocol = "2.4.0"

# Core (provides EVERYTHING: networking, DHT, security, trust, storage)
saorsa-core = "0.27.3"
saorsa-core = "0.27.4"
saorsa-pqc = "0.5"

# Payment verification - autonomi network lookup + EVM payment
Expand Down Expand Up @@ -244,11 +244,3 @@ cognitive_complexity = "allow"
# Allow non-const functions during initial development (may need runtime features later)
missing_const_for_fn = "allow"

# Temporary stacked-PR alignment, mirroring ant-client main: the git-sourced
# ant-protocol (settlement branch, includes main) declares its saorsa-core as
# the v2-903 route-diagnostics branch, so the node's registry saorsa-core must
# resolve to the same source or the graph carries two MultiAddr types.
# Remove together with the git ant-protocol pin once the release train
# publishes ant-protocol with the released saorsa-core (WithAutonomi/saorsa-core#162).
[patch.crates-io]
saorsa-core = { git = "https://github.com/WithAutonomi/saorsa-core.git", branch = "feat/v2-903-download-route-diagnostics" }
9 changes: 8 additions & 1 deletion deploy/terraform/cloud-init/worker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,14 @@ write_files:
Type=simple
User=ant
Group=ant
ExecStart=$${BINARY_PATH} --root-dir $${NODE_DIR} --port 0 --metrics-port $${METRICS_PORT} $${BOOTSTRAP_ARGS}
# --enable-logging is not optional here, whatever it looks like. Without it the
# binary installs no tracing subscriber and emits nothing at all, so a node on this
# unit is silent: no migration progress, no warnings, and nothing said about whether
# it still has an old chunk store. That last one does not make the fleet's state
# decidable, and nothing does; it is what lets a node on this unit surface itself as
# unfinished at all. JSON because what reads these lines is a query, and the fields
# that matter are structured.
ExecStart=$${BINARY_PATH} --root-dir $${NODE_DIR} --port 0 --metrics-port $${METRICS_PORT} --enable-logging --log-format json $${BOOTSTRAP_ARGS}
Restart=always
RestartSec=10
MemoryMax=350M
Expand Down
52 changes: 48 additions & 4 deletions docs/adr/ADR-0014-file-based-chunk-store-and-lmdb-retirement.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,50 @@ belief carry its own expiry — the directory carries its mark, the proof carrie
saw, the write carries its note — rather than to check again and hope the check is close
enough to the act.

## Amendment: see what the neighbours say, and stop the migration slashing anyone

Shipped as a patch on top of this record's release, which had already merged. It changes no
on-disk format and writes no migration state: a node part-way through re-reads its marker, its
first-start time and its remaining keys and carries on. That is asserted by a test rather than
argued.

**A node holding data no longer clears its own commitments.** `storage_empty` asked whether
there was anything left to *commit to*, not whether there were any bytes. Once the migration
settles the commitment narrows to the file-backed set, and `all_keys` drops a file marked
suspect, so two nodes that hold data reported empty: one whose disk filled before it could copy
anything, and one whose last readable file went transiently bad. Both took the `clear_all`
branch, which drops every retained root with no answerability window. An auditor holding a root
gossiped minutes earlier then got `UnknownCommitment` — a confirmed failure on the
commitment-bound lane, which is deliberately enforced in every release and is *not* the lane
the migration holds off. The node slashed itself for data it still had, and no switch could
stop it. Those nodes now take `retire_current`: stop advertising, stay answerable until the
gossip TTL lapses, bytes on disk. That is the staged narrowing this migration was designed
around; the predicate is what routes a node into it.

**Every node says whether it still has an old chunk store, and reads what its neighbours say.**
The state rides the user agent `saorsa-core` already sends with every signed message and keeps
per peer, so this costs no new message, no new field and no protocol version, and the `node/`
prefix that gates DHT membership is preserved. Three states, never folded into two: a directory
that could not be read is not one that is not there.

What the peer half means, in the fewest words that are all true, because a release decision
rests on it.

It counts what the peers a node is connected to **announced**, each as of that peer's own last
start. `saorsa-core` copies the user agent when it builds the transport, so a node that finishes
migrating goes on announcing `legacy` until it restarts.

Two consequences, running in opposite directions, so the tally bounds nothing. A peer announcing
`legacy` may have finished since, so the count can be too high. A node that is offline, or simply
not connected to, is absent from it, so the count can be too low. An all-zero tally proves
nothing on its own either, because a node connected to nobody produces one; the number of peers
seen is what tells that apart.

So this can surface nodes that have not finished. It cannot establish that none remain, and no
amount of it adds up to that. `outstanding` counts `legacy`, `unknown` and `unreported`
together, because a peer whose disk could not be read and a peer on a build from before this
existed are both as far from finished as `legacy` is.

## Consequences

### Positive
Expand Down Expand Up @@ -519,10 +563,10 @@ restored.
matching prediction.
- The second gates on a soak of the first, plus a verified retirement returning the
predicted space.
- The third gates on migration-complete lines across the fleet, refetch backlogs drained, and the
recorded audit failure rate back to its pre-migration baseline. The first release's
observability is
what makes that decidable.
- The third gates on migration-complete lines from the nodes we run, refetch backlogs drained,
and the recorded audit failure rate back to its pre-migration baseline. The observability
added here informs that call; it does not decide it. Nothing here can establish that a node
we neither run nor are connected to has finished.
- **How often a short-of-disk node can actually clear the possession gate.** A node whose
close group is also short of space will not clear it, will not free its disk, and will
tell its operator to add storage. That is the intended answer, but the fleet needs to
Expand Down
36 changes: 36 additions & 0 deletions src/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,26 @@ impl NodeBuilder {
}
}

// Say on the wire whether this node still has an old chunk store. It costs no new
// message and no new field: saorsa-core already sends a user agent with every signed
// message and keeps each peer's, so this is a different value in a string that was
// already there. It is the only thing that tells us anything at all about the nodes we
// do not run and have no logs from. It cannot establish that the fleet has finished:
// a node sees only the peers it is connected to, and each answers as of its own last
// start, so the most this shows is that some peer reported an old store when it last
// started. It can never show that no node has one.
//
// Read from the filesystem here rather than from the store, because the store is
// built later and a node with storage switched off never builds one at all, while
// the directory on its disk is just as real either way.
//
// Fixed for the life of the process: saorsa-core copies the string when it builds
// the transport. A node that finishes migrating goes on saying `legacy` until it
// restarts, which overstates how much is left rather than understating it, and is
// the direction a release gate should err in.
let signal = crate::storage::migration_signal::MigrationSignal::from_disk(&config.root_dir);
core_config.custom_user_agent = Some(crate::storage::migration_signal::user_agent(signal));

// Persist close group peers + trust scores across restarts.
// Default to root_dir (alongside node_identity.key) when not explicitly set.
core_config.close_group_cache_dir = Some(
Expand Down Expand Up @@ -655,6 +675,22 @@ impl RunningNode {
info!("Replication engine started");
}

// Say where this node is with the move off the old chunk store, and what it can see
// of its neighbours. The release that deletes that store may only go out once the
// fleet has moved, and no calendar establishes that: our own logs cover the nodes we
// run, and this is the only view we get of the ones we do not.
{
// Weak on purpose: see `report_until_shutdown`. A reporter that kept the node
// alive would keep its port bound after the node was dropped.
let p2p = Arc::downgrade(&self.p2p_node);
let root_dir = self.config.root_dir.clone();
let shutdown = self.shutdown.clone();
tokio::spawn(async move {
crate::storage::migration_signal::report_until_shutdown(p2p, root_dir, shutdown)
.await;
});
}

// Start upgrade monitor if enabled
if let Some(monitor) = self.upgrade_monitor.take() {
let events_tx = self.events_tx.clone();
Expand Down
45 changes: 45 additions & 0 deletions src/replication/commitment_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -945,6 +945,51 @@ fn prune_slots(inner: &mut Inner, now: Instant) {
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {

/// Retiring keeps an in-window pin answerable; clearing repudiates it.
///
/// This is why the commitment rotation no longer has a "storage is empty" branch. That
/// branch called `clear_all`, and it was reached from a key count that was wrong in the
/// same direction every time it was fixed: a node whose disk filled before it could copy
/// anything, then one whose file had been dropped from the index by a failed read, then a
/// files-only node that had published bytes but not yet indexed them.
///
/// The cost of being wrong is what settles it, and it is what this measures. Clearing
/// repudiates a root a peer is still pinning, which answers `UnknownCommitment` and is
/// graded a confirmed failure. Retiring stops advertising and stays answerable until the
/// gossip TTL lapses. Both stop advertising; only one throws the answer away.
#[test]
fn retiring_keeps_a_pinned_root_answerable_and_clearing_does_not() {
let (pk, sk) = keypair();
let pk_bytes = pk.to_bytes();
let peer_id = *blake3::hash(&pk.to_bytes()).as_bytes();

let retired = ResponderCommitmentState::new();
let c = BuiltCommitment::build(vec![(key(1), bh(1))], &peer_id, &sk, &pk_bytes).unwrap();
let h = c.hash();
retired.rotate(c);
retired.mark_gossiped(h);
retired.retire_current();
assert!(
retired.current().is_none(),
"retiring must stop the node advertising the root"
);
assert!(
retired.lookup_by_hash(&h).is_some(),
"but a peer still pinning it must get an answer, not a repudiation"
);

let cleared = ResponderCommitmentState::new();
let c2 = BuiltCommitment::build(vec![(key(1), bh(1))], &peer_id, &sk, &pk_bytes).unwrap();
let h2 = c2.hash();
cleared.rotate(c2);
cleared.mark_gossiped(h2);
cleared.clear_all();
assert!(
cleared.lookup_by_hash(&h2).is_none(),
"clearing throws the same pin away, which is the confirmed failure this avoids"
);
}
use super::*;
use crate::replication::commitment::{commitment_hash, leaf_hash, verify_path};
use saorsa_pqc::api::sig::ml_dsa_65;
Expand Down
39 changes: 24 additions & 15 deletions src/replication/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9980,7 +9980,6 @@ async fn rebuild_and_rotate_commitment(
// this filter the pruner's reprieve would keep re-committing stale keys
// forever (the rebuild reads all_keys, so a retained-on-disk key would be
// re-committed and re-gossiped every rotation — a permanent pin).
let storage_empty = stored_keys.is_empty();
let self_id = *p2p.peer_id();
let mut keys = Vec::with_capacity(stored_keys.len());
for k in stored_keys {
Expand All @@ -9990,20 +9989,30 @@ async fn rebuild_and_rotate_commitment(
}

if keys.is_empty() {
if storage_empty {
// Storage is genuinely empty — there is nothing to answer for, so
// drop the previously advertised commitment immediately. Keeping it
// would leave remote auditors pinning a hash we can never satisfy
// again (the bytes are gone).
if state.retained_slot_count() > 0 {
debug!("Commitment rotation: storage empty, clearing retained slots");
state.clear_all();
}
storage.note_commitment_rebuilt();
return Ok(());
}
// Bytes are still on disk but no key is currently in range. We must NOT
// clear retention here: a peer may still be pinning a root we gossiped
// There used to be a second branch here that dropped every retained root outright
// when the node looked empty. It is gone, and the reason is worth keeping.
//
// "Empty" was decided from key counts, and every version of that test was wrong in
// the same direction. It read the committable set, which narrows to the file-backed
// keys once the migration settles, so a node whose disk filled before it could copy
// anything looked empty with a full legacy store beside it. Adding the raw file index
// still missed a file dropped from the index by a failed read while its legacy copy
// was being put back. Adding the legacy environment still missed a files-only node
// that had published bytes to disk but not yet indexed them, because a file is
// published before it is indexed. Each fix closed one window and left another.
//
// The asymmetry is what settles it. Clearing wrongly repudiates a root a peer is
// pinning, and `UnknownCommitment` is a confirmed failure on the commitment-bound
// lane, which is enforced in every release and is not the lane the migration holds
// off — so a node that still holds the bytes is slashed for holding them. Retiring
// wrongly costs a root that stops being advertised now and ages out by its gossip TTL
// instead of vanishing now. Both set `has_current = false`; they differ only in
// whether the node goes on being answerable in the meantime. A genuinely empty node
// cannot answer either way, so retiring costs it nothing it had.
//
// So there is one branch, and no emptiness question to get wrong.
//
// A peer may still be pinning a root we gossiped
// moments ago and could demand its bytes in a round-2 challenge, which
// we can still answer (the bytes are present). But we must STOP
// advertising the stale commitment: retire it so `current()` returns
Expand Down
Loading
Loading