|
|
|
@@ -1067,9 +1067,13 @@ fn parse_site_replication_state(data: &[u8]) -> S3Result<SiteReplicationState> {
|
|
|
|
|
state.peers = normalize_peer_map_by_identity(state.peers);
|
|
|
|
|
// A peer-edit high-water mark only fences a CURRENT peer. A site that
|
|
|
|
|
// leaves drops below two peers, which clears its own state object and
|
|
|
|
|
// restarts its generation counter at zero — a mark left over from the
|
|
|
|
|
// previous membership would then reject every edit it sends after it
|
|
|
|
|
// rejoins. Dropping departed origins on load also keeps the map bounded.
|
|
|
|
|
// restarts its generation counter — a mark left over from the previous
|
|
|
|
|
// membership must not reject the edits it sends after it rejoins. This
|
|
|
|
|
// pruning covers departures THIS site observed; an origin removed
|
|
|
|
|
// unilaterally elsewhere stays in this peer map with its mark, and the
|
|
|
|
|
// wall-clock floor in `next_peer_edit_generation` is what lifts its
|
|
|
|
|
// restarted counter over that mark. Dropping departed origins on load
|
|
|
|
|
// also keeps the map bounded.
|
|
|
|
|
state
|
|
|
|
|
.applied_edit_generations
|
|
|
|
|
.retain(|origin, _| state.peers.contains_key(origin));
|
|
|
|
@@ -5935,11 +5939,51 @@ fn summarize_peer_error_detail(detail: &str) -> String {
|
|
|
|
|
summary
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Allocate the next peer-edit generation. Called inside the state
|
|
|
|
|
/// transaction, so the counter is handed out under the distributed
|
|
|
|
|
/// state-object lock and two nodes of this site can never take the same one.
|
|
|
|
|
/// The wall clock in unix nanoseconds, clamped into u64. A pre-1970 (or
|
|
|
|
|
/// post-2554) clock yields 0, which makes the hybrid allocation below
|
|
|
|
|
/// degrade to the plain `previous + 1` counter — monotone, never panicking.
|
|
|
|
|
fn edit_generation_wall_clock() -> u64 {
|
|
|
|
|
u64::try_from(OffsetDateTime::now_utc().unix_timestamp_nanos()).unwrap_or(0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Allocate the next peer-edit generation as a hybrid logical clock:
|
|
|
|
|
/// `max(wall clock in unix nanoseconds, previous + 1)`. Called inside the
|
|
|
|
|
/// state transaction, so the value is handed out under the distributed
|
|
|
|
|
/// state-object lock and two nodes of this site can never take the same one
|
|
|
|
|
/// (`previous + 1` keeps the sequence strictly increasing even when two
|
|
|
|
|
/// allocations land in one clock tick, and keeps it monotone on a node
|
|
|
|
|
/// whose clock stepped backwards mid-lifetime).
|
|
|
|
|
///
|
|
|
|
|
/// The wall-clock floor is what survives the counter's death. A site
|
|
|
|
|
/// removed while unreachable — the receiver never dropped it from its peer
|
|
|
|
|
/// map, so the load-time mark pruning in `parse_site_replication_state`
|
|
|
|
|
/// never fired — that later rejoins recreates its state object with the
|
|
|
|
|
/// counter back at zero. A plain counter would then hand out generations
|
|
|
|
|
/// below the receiver's stale high-water mark and every delivery would be
|
|
|
|
|
/// silently fenced until the counter caught up. Jumping to wall time clears
|
|
|
|
|
/// that mark: every value the deleted lifetime handed out was capped by the
|
|
|
|
|
/// wall clock at its own allocation (or by a prior lifetime's cap, applied
|
|
|
|
|
/// inductively), so the recreated lifetime's first allocation exceeds them
|
|
|
|
|
/// all — while a pre-removal delivery still in flight stays below the new
|
|
|
|
|
/// floor and remains correctly fenced. Marks recorded by pre-hybrid
|
|
|
|
|
/// receivers (small plain-counter values) sit far below any wall-clock
|
|
|
|
|
/// value, so a restarted origin passes those too — the fix needs only the
|
|
|
|
|
/// sender upgraded, nothing on the wire or in the receiver changed.
|
|
|
|
|
///
|
|
|
|
|
/// A wall clock that regresses across a delete/recreate (the recreating
|
|
|
|
|
/// node's clock behind the clock that fed the previous lifetime) mints
|
|
|
|
|
/// below the stale mark and the origin stays fenced — but only until real
|
|
|
|
|
/// time passes the previous lifetime's last allocation, because every later
|
|
|
|
|
/// allocation takes the wall-clock floor again. Bounded by the skew,
|
|
|
|
|
/// self-healing, and no rollback window beyond the plain counter's: a
|
|
|
|
|
/// delivery applies only at or above the receiver's mark, so the one
|
|
|
|
|
/// cross-lifetime interleaving that can apply stale content — a
|
|
|
|
|
/// pre-removal delivery whose generation lands above everything the
|
|
|
|
|
/// regressed new lifetime has minted — required the same straggler landing
|
|
|
|
|
/// above the mark under the plain counter, where the recreated counter's
|
|
|
|
|
/// low restart made it strictly easier to hit.
|
|
|
|
|
fn next_peer_edit_generation(state: &mut SiteReplicationState) -> u64 {
|
|
|
|
|
state.edit_generation = state.edit_generation.saturating_add(1);
|
|
|
|
|
state.edit_generation = edit_generation_wall_clock().max(state.edit_generation.saturating_add(1));
|
|
|
|
|
state.edit_generation
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -5970,6 +6014,63 @@ fn peer_edit_fence(queries: &HashMap<String, String>) -> Option<(String, u64)> {
|
|
|
|
|
Some((origin.clone(), generation))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The largest generation an incoming fence may carry: this site's clock in
|
|
|
|
|
/// unix nanoseconds plus a day of cross-site skew. Every site authenticates
|
|
|
|
|
/// peer traffic with the shared site-replicator service account, so the
|
|
|
|
|
/// receiver cannot tell WHICH site stamped a fence — a compromised peer can
|
|
|
|
|
/// claim any origin, and a u64::MAX-scale generation would raise that
|
|
|
|
|
/// origin's high-water mark past anything the genuine site ever allocates,
|
|
|
|
|
/// silently fencing out its every future edit. A genuine generation is the
|
|
|
|
|
/// hybrid clock of [`next_peer_edit_generation`], floored by the sender's
|
|
|
|
|
/// wall time, so it exceeds this site's clock only by cross-site skew — and
|
|
|
|
|
/// the mark a ceiling-level forgery can still plant decays as real time
|
|
|
|
|
/// passes it, within the allowance. Only an origin still allocating with the
|
|
|
|
|
/// pre-hybrid plain counter cannot outrun such a mark until it upgrades: its
|
|
|
|
|
/// genuine generations never approach nanosecond scale.
|
|
|
|
|
fn peer_edit_fence_generation_ceiling(now_unix_nanos: u64) -> u64 {
|
|
|
|
|
const CROSS_SITE_SKEW_ALLOWANCE_NANOS: u64 = 24 * 60 * 60 * 1_000_000_000;
|
|
|
|
|
now_unix_nanos.saturating_add(CROSS_SITE_SKEW_ALLOWANCE_NANOS)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Whether an incoming fence may be honoured, as far as this site can vouch
|
|
|
|
|
/// for it. The sender's identity is unverifiable (shared service account),
|
|
|
|
|
/// so the check runs over what the receiving state knows: the claimed origin
|
|
|
|
|
/// must be a site this state currently replicates with — the same membership
|
|
|
|
|
/// rule the load-time mark pruning applies, so every mark recorded behind
|
|
|
|
|
/// this check is one a reload would keep — and not this site itself, which
|
|
|
|
|
/// never delivers edits to itself; and the generation must sit under
|
|
|
|
|
/// [`peer_edit_fence_generation_ceiling`]. The caller IGNORES an
|
|
|
|
|
/// inadmissible fence rather than failing the request: the delivery applies
|
|
|
|
|
/// exactly as an unstamped (pre-fence) delivery would, no high-water mark is
|
|
|
|
|
/// read or written, and the worst a forged fence achieves is forfeiting an
|
|
|
|
|
/// ordering guarantee its sender was never owed.
|
|
|
|
|
fn peer_edit_fence_is_admissible(
|
|
|
|
|
state: &SiteReplicationState,
|
|
|
|
|
local_deployment_id: &str,
|
|
|
|
|
fence: &(String, u64),
|
|
|
|
|
generation_ceiling: u64,
|
|
|
|
|
) -> bool {
|
|
|
|
|
let (origin, generation) = fence;
|
|
|
|
|
let result = if origin == local_deployment_id || !state.peers.contains_key(origin) {
|
|
|
|
|
"fence_origin_not_a_remote_peer"
|
|
|
|
|
} else if *generation > generation_ceiling {
|
|
|
|
|
"fence_generation_beyond_ceiling"
|
|
|
|
|
} else {
|
|
|
|
|
return true;
|
|
|
|
|
};
|
|
|
|
|
warn!(
|
|
|
|
|
event = EVENT_ADMIN_SITE_REPLICATION_STATE,
|
|
|
|
|
component = LOG_COMPONENT_ADMIN,
|
|
|
|
|
subsystem = LOG_SUBSYSTEM_SITE_REPLICATION,
|
|
|
|
|
result,
|
|
|
|
|
origin = %origin,
|
|
|
|
|
generation = *generation,
|
|
|
|
|
generation_ceiling,
|
|
|
|
|
"ignoring inadmissible peer-edit fence"
|
|
|
|
|
);
|
|
|
|
|
false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// True when a strictly newer edit from the same origin site already landed
|
|
|
|
|
/// here. No lock on the sending side can order deliveries issued by two
|
|
|
|
|
/// nodes of that site, so ordering is decided here, on the generation the
|
|
|
|
@@ -9515,6 +9616,7 @@ impl Operation for SRPeerEditHandler {
|
|
|
|
|
let ilm_expiry_override = sr_edit_ilm_expiry_override(&req.uri);
|
|
|
|
|
let endpoint_refresh_requested = queries.get("refresh-targets").is_some_and(|value| value == "true");
|
|
|
|
|
let commit_fence = peer_edit_fence(&queries);
|
|
|
|
|
let fence_generation_ceiling = peer_edit_fence_generation_ceiling(edit_generation_wall_clock());
|
|
|
|
|
let local_endpoint = site_replication_local_endpoint(&req.uri, &req.headers);
|
|
|
|
|
let (refresh_id, incoming) = if endpoint_refresh_requested {
|
|
|
|
|
let refresh: EndpointRefreshRequest = read_site_replication_json(req, "", false).await?;
|
|
|
|
@@ -9532,6 +9634,11 @@ impl Operation for SRPeerEditHandler {
|
|
|
|
|
let outcome = update_site_replication_state_when_changed(move |state| {
|
|
|
|
|
let mut incoming = incoming;
|
|
|
|
|
let local_peer = local_peer_at_endpoint(commit_endpoint, state);
|
|
|
|
|
// The fence is self-reported — the shared service account means
|
|
|
|
|
// the sender cannot be identified — so it is honoured only after
|
|
|
|
|
// the admissibility check, against the same state it will gate.
|
|
|
|
|
let commit_fence = commit_fence
|
|
|
|
|
.filter(|fence| peer_edit_fence_is_admissible(state, &local_peer.deployment_id, fence, fence_generation_ceiling));
|
|
|
|
|
// Ordering fence: the sending site allocates the generation under
|
|
|
|
|
// its state-object lock, so a delivery that lost the race carries
|
|
|
|
|
// a generation this site has already passed. Applying it would
|
|
|
|
@@ -11913,6 +12020,17 @@ mod tests {
|
|
|
|
|
handler_block.contains("record_applied_peer_edit_generation(state, origin, *generation);"),
|
|
|
|
|
"SRPeerEditHandler must record the applied generation so later stale deliveries are recognised"
|
|
|
|
|
);
|
|
|
|
|
// Fence hardening: origin and generation are self-reported by a
|
|
|
|
|
// caller the shared service account cannot identify, so the handler
|
|
|
|
|
// must pass the fence through the admissibility check — against the
|
|
|
|
|
// same state the fence gates, i.e. inside the transaction — before
|
|
|
|
|
// reading or raising any high-water mark.
|
|
|
|
|
assert!(
|
|
|
|
|
handler_block.contains(
|
|
|
|
|
".filter(|fence| peer_edit_fence_is_admissible(state, &local_peer.deployment_id, fence, fence_generation_ceiling))"
|
|
|
|
|
),
|
|
|
|
|
"SRPeerEditHandler must admit a fence only through peer_edit_fence_is_admissible inside the state transaction"
|
|
|
|
|
);
|
|
|
|
|
// P1-15 PR2: both halves of the fence and the edit they fence share
|
|
|
|
|
// ONE transaction. Checking the fence against a state read outside the
|
|
|
|
|
// lock would let the check pass on one snapshot and the write land on
|
|
|
|
@@ -13207,6 +13325,94 @@ mod tests {
|
|
|
|
|
assert!(peer_edit_delivery_is_stale(&state, origin, generation - 1));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A fence is self-reported: every site authenticates peer traffic with
|
|
|
|
|
/// the same site-replicator credential, so a compromised peer can stamp
|
|
|
|
|
/// ANY origin with ANY generation. The receiver must refuse to let such
|
|
|
|
|
/// a stamp touch the high-water marks — an origin it does not replicate
|
|
|
|
|
/// with, its own deployment id, and a generation no genuine counter
|
|
|
|
|
/// could have reached are all ignored, and ignoring one plants no mark.
|
|
|
|
|
#[test]
|
|
|
|
|
fn forged_peer_edit_fences_cannot_poison_the_high_water_marks() {
|
|
|
|
|
let mut state = SiteReplicationState {
|
|
|
|
|
peers: BTreeMap::from([
|
|
|
|
|
(
|
|
|
|
|
"site-local".to_string(),
|
|
|
|
|
PeerInfo {
|
|
|
|
|
deployment_id: "site-local".to_string(),
|
|
|
|
|
..peer("local", "https://local.example:9000")
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
"site-victim".to_string(),
|
|
|
|
|
PeerInfo {
|
|
|
|
|
deployment_id: "site-victim".to_string(),
|
|
|
|
|
..peer("victim", "https://victim.example:9000")
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
]),
|
|
|
|
|
..Default::default()
|
|
|
|
|
};
|
|
|
|
|
let ceiling = peer_edit_fence_generation_ceiling(edit_generation_wall_clock());
|
|
|
|
|
|
|
|
|
|
// An origin outside the current membership is refused outright...
|
|
|
|
|
let unknown = ("site-unknown".to_string(), 4u64);
|
|
|
|
|
assert!(!peer_edit_fence_is_admissible(&state, "site-local", &unknown, ceiling));
|
|
|
|
|
|
|
|
|
|
// No site delivers edits to itself: a fence claiming the receiver as
|
|
|
|
|
// its origin is forged by construction, current peer or not.
|
|
|
|
|
let own = ("site-local".to_string(), 4u64);
|
|
|
|
|
assert!(!peer_edit_fence_is_admissible(&state, "site-local", &own, ceiling));
|
|
|
|
|
|
|
|
|
|
// A generation past the ceiling (the u64::MAX poisoning) is refused
|
|
|
|
|
// even when it names a current peer.
|
|
|
|
|
let poisoned = ("site-victim".to_string(), u64::MAX);
|
|
|
|
|
assert!(!peer_edit_fence_is_admissible(&state, "site-local", &poisoned, ceiling));
|
|
|
|
|
let barely_over = ("site-victim".to_string(), ceiling + 1);
|
|
|
|
|
assert!(!peer_edit_fence_is_admissible(&state, "site-local", &barely_over, ceiling));
|
|
|
|
|
|
|
|
|
|
// With no mark planted, the victim's genuine deliveries keep
|
|
|
|
|
// applying, and its admitted fence works end to end.
|
|
|
|
|
let genuine = ("site-victim".to_string(), 1u64);
|
|
|
|
|
assert!(peer_edit_fence_is_admissible(&state, "site-local", &genuine, ceiling));
|
|
|
|
|
assert!(!peer_edit_delivery_is_stale(&state, &genuine.0, genuine.1));
|
|
|
|
|
record_applied_peer_edit_generation(&mut state, &genuine.0, genuine.1);
|
|
|
|
|
assert_eq!(state.applied_edit_generations.get("site-victim"), Some(&1));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The generation ceiling must admit both live generation shapes: the
|
|
|
|
|
/// small plain counter pre-hybrid sites still allocate, and the
|
|
|
|
|
/// wall-clock-floored hybrid of `next_peer_edit_generation` — including
|
|
|
|
|
/// one running AHEAD of the receiver's clock by cross-site skew, which
|
|
|
|
|
/// is the whole reason the allowance exists, and the ceiling itself,
|
|
|
|
|
/// the last admissible value.
|
|
|
|
|
#[test]
|
|
|
|
|
fn peer_edit_fence_ceiling_admits_counter_and_clock_seeded_generations() {
|
|
|
|
|
let state = SiteReplicationState {
|
|
|
|
|
peers: BTreeMap::from([(
|
|
|
|
|
"site-origin".to_string(),
|
|
|
|
|
PeerInfo {
|
|
|
|
|
deployment_id: "site-origin".to_string(),
|
|
|
|
|
..peer("origin", "https://origin.example:9000")
|
|
|
|
|
},
|
|
|
|
|
)]),
|
|
|
|
|
..Default::default()
|
|
|
|
|
};
|
|
|
|
|
let now = edit_generation_wall_clock();
|
|
|
|
|
let ceiling = peer_edit_fence_generation_ceiling(now);
|
|
|
|
|
|
|
|
|
|
let counter = ("site-origin".to_string(), 42u64);
|
|
|
|
|
assert!(peer_edit_fence_is_admissible(&state, "site-local", &counter, ceiling));
|
|
|
|
|
|
|
|
|
|
// One hour ahead of the receiver's clock: within the skew allowance.
|
|
|
|
|
// Shrinking the allowance to zero must turn this fence away.
|
|
|
|
|
let clock_ahead = ("site-origin".to_string(), now + 60 * 60 * 1_000_000_000);
|
|
|
|
|
assert!(peer_edit_fence_is_admissible(&state, "site-local", &clock_ahead, ceiling));
|
|
|
|
|
|
|
|
|
|
// The boundary is inclusive: rejection starts strictly past it.
|
|
|
|
|
let at_ceiling = ("site-origin".to_string(), ceiling);
|
|
|
|
|
assert!(peer_edit_fence_is_admissible(&state, "site-local", &at_ceiling, ceiling));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// P1-15 review follow-up: a site that leaves the mesh drops below two
|
|
|
|
|
/// peers, which clears its state object and restarts its generation
|
|
|
|
|
/// counter at zero. A mark left over from its previous membership would
|
|
|
|
@@ -13244,6 +13450,104 @@ mod tests {
|
|
|
|
|
assert!(!peer_edit_delivery_is_stale(&reloaded, "origin-site", 1));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The unilateral-removal rejoin gap the hybrid clock closes. The origin
|
|
|
|
|
/// was removed while unreachable, but THIS site never dropped it from
|
|
|
|
|
/// its peer map, so the load-time mark pruning never fired and the mark
|
|
|
|
|
/// from the previous membership survives. The origin's recreated state
|
|
|
|
|
/// object restarts its counter, and with a plain `previous + 1` counter
|
|
|
|
|
/// every delivery it sent — generations 1, 2, … below the stale mark —
|
|
|
|
|
/// would be silently acked-and-dropped until the counter caught up. The
|
|
|
|
|
/// wall-clock floor in `next_peer_edit_generation` lifts the restarted
|
|
|
|
|
/// counter over every value the deleted lifetime handed out. Reverting
|
|
|
|
|
/// the allocation to the plain counter (dropping the wall-clock max)
|
|
|
|
|
/// turns the not-stale assertion red.
|
|
|
|
|
#[test]
|
|
|
|
|
fn hybrid_generation_unfences_a_rejoined_origin_whose_counter_restarted() {
|
|
|
|
|
// First lifetime of the origin's state object: two allocations, both
|
|
|
|
|
// capped by the wall clock at their own allocation.
|
|
|
|
|
let mut first_life = SiteReplicationState::default();
|
|
|
|
|
let straggler = next_peer_edit_generation(&mut first_life);
|
|
|
|
|
let last_applied = next_peer_edit_generation(&mut first_life);
|
|
|
|
|
assert!(last_applied > straggler, "allocations must be strictly increasing");
|
|
|
|
|
|
|
|
|
|
// The receiver applied up to `last_applied` and keeps the origin in
|
|
|
|
|
// its peer map across the unilateral removal — reloading must keep
|
|
|
|
|
// the mark, which is exactly why pruning cannot cover this case.
|
|
|
|
|
let mut receiver = SiteReplicationState::default();
|
|
|
|
|
receiver.peers.insert(
|
|
|
|
|
"origin-site".to_string(),
|
|
|
|
|
PeerInfo {
|
|
|
|
|
deployment_id: "origin-site".to_string(),
|
|
|
|
|
..peer("origin", "https://origin.example:9000")
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
record_applied_peer_edit_generation(&mut receiver, "origin-site", last_applied);
|
|
|
|
|
let mut receiver = parse_site_replication_state(&serde_json::to_vec(&receiver).expect("serialize")).expect("reload");
|
|
|
|
|
assert_eq!(receiver.applied_edit_generations.get("origin-site"), Some(&last_applied));
|
|
|
|
|
|
|
|
|
|
// The origin rejoins with a RECREATED state object: counter back at
|
|
|
|
|
// zero. The wall-clock floor must lift its first allocation over the
|
|
|
|
|
// previous lifetime's mark…
|
|
|
|
|
let mut second_life = SiteReplicationState::default();
|
|
|
|
|
let restarted = next_peer_edit_generation(&mut second_life);
|
|
|
|
|
assert!(
|
|
|
|
|
!peer_edit_delivery_is_stale(&receiver, "origin-site", restarted),
|
|
|
|
|
"the recreated lifetime's first allocation ({restarted}) must not be fenced by the previous lifetime's mark ({last_applied})"
|
|
|
|
|
);
|
|
|
|
|
record_applied_peer_edit_generation(&mut receiver, "origin-site", restarted);
|
|
|
|
|
|
|
|
|
|
// …while a pre-removal delivery still in flight stays below the new
|
|
|
|
|
// floor and remains correctly fenced — the rollback the fence exists
|
|
|
|
|
// to reject.
|
|
|
|
|
assert!(
|
|
|
|
|
peer_edit_delivery_is_stale(&receiver, "origin-site", straggler),
|
|
|
|
|
"a pre-removal in-flight delivery ({straggler}) must stay fenced after the rejoin"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Marks recorded before the hybrid clock existed are small plain-counter
|
|
|
|
|
/// values, far below any wall-clock allocation: a restarted origin passes
|
|
|
|
|
/// them as soon as the SENDER runs the hybrid clock — nothing changes on
|
|
|
|
|
/// the wire or in the receiver, so pre-hybrid receivers get the fix too.
|
|
|
|
|
/// The other direction is unchanged: among plain-counter values the
|
|
|
|
|
/// generation order still fences the delivery that lost the race.
|
|
|
|
|
#[test]
|
|
|
|
|
fn hybrid_generation_passes_marks_recorded_by_plain_counter_receivers() {
|
|
|
|
|
let mut receiver = SiteReplicationState::default();
|
|
|
|
|
record_applied_peer_edit_generation(&mut receiver, "origin-site", 57);
|
|
|
|
|
assert!(peer_edit_delivery_is_stale(&receiver, "origin-site", 56));
|
|
|
|
|
assert!(!peer_edit_delivery_is_stale(&receiver, "origin-site", 57));
|
|
|
|
|
|
|
|
|
|
let mut rejoined = SiteReplicationState::default();
|
|
|
|
|
let restarted = next_peer_edit_generation(&mut rejoined);
|
|
|
|
|
assert!(
|
|
|
|
|
!peer_edit_delivery_is_stale(&receiver, "origin-site", restarted),
|
|
|
|
|
"a wall-clock allocation ({restarted}) must clear a plain-counter mark (57)"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The `previous + 1` half of the hybrid clock: allocations stay strictly
|
|
|
|
|
/// increasing even when the wall clock cannot move them forward — two
|
|
|
|
|
/// allocations inside one clock tick, or a clock that stepped backwards
|
|
|
|
|
/// mid-lifetime (a counter already ahead of the wall clock advances by
|
|
|
|
|
/// exactly one per allocation instead of jumping back). Dropping the
|
|
|
|
|
/// `previous + 1` half (allocating bare wall time) turns this red.
|
|
|
|
|
#[test]
|
|
|
|
|
fn hybrid_generation_is_strictly_increasing_when_the_clock_stalls() {
|
|
|
|
|
let mut state = SiteReplicationState {
|
|
|
|
|
// A counter far ahead of any wall clock this test will see.
|
|
|
|
|
edit_generation: u64::MAX / 2,
|
|
|
|
|
..Default::default()
|
|
|
|
|
};
|
|
|
|
|
assert_eq!(next_peer_edit_generation(&mut state), u64::MAX / 2 + 1);
|
|
|
|
|
assert_eq!(next_peer_edit_generation(&mut state), u64::MAX / 2 + 2);
|
|
|
|
|
// Saturation pins at the ceiling instead of wrapping; the equal-value
|
|
|
|
|
// escape (`applied > generation` is false for equal) keeps deliveries
|
|
|
|
|
// applying rather than fencing the origin out.
|
|
|
|
|
state.edit_generation = u64::MAX;
|
|
|
|
|
assert_eq!(next_peer_edit_generation(&mut state), u64::MAX);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_retry_stats_for_state_counts_pending_and_failed() {
|
|
|
|
|
let state = SiteReplicationState {
|
|
|
|
@@ -16044,10 +16348,77 @@ mod tests {
|
|
|
|
|
generations.len(),
|
|
|
|
|
"two nodes took the same edit generation, so their deliveries cannot be ordered: {generations:?}"
|
|
|
|
|
);
|
|
|
|
|
// The hybrid clock allocates `max(wall nanos, previous + 1)` — the
|
|
|
|
|
// persisted counter is the largest allocation, and the `+ 1` half
|
|
|
|
|
// keeps allocations distinct even inside one clock tick.
|
|
|
|
|
assert_eq!(
|
|
|
|
|
Some(&load_site_replication_state().await.expect("reload").edit_generation),
|
|
|
|
|
unique.last(),
|
|
|
|
|
"the persisted counter must be the largest allocation handed out"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The unilateral-removal rejoin, end to end across the state object's
|
|
|
|
|
/// real lifecycle: dropping below two peers clears the object (the
|
|
|
|
|
/// counter dies with it), and the recreated object's first allocation —
|
|
|
|
|
/// raced by two nodes — must clear the previous lifetime's values via
|
|
|
|
|
/// the wall-clock floor, so a receiver still holding the old mark
|
|
|
|
|
/// accepts the restarted counter instead of fencing it.
|
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
|
|
|
#[serial]
|
|
|
|
|
async fn test_recreated_state_object_allocates_over_the_previous_lifetimes_mark() {
|
|
|
|
|
publish_ready_iam_context().await;
|
|
|
|
|
let seed = || SiteReplicationState {
|
|
|
|
|
peers: ["site-a", "site-b"]
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|name| (name.to_string(), peer(name, &format!("https://{name}.example:9000"))))
|
|
|
|
|
.collect(),
|
|
|
|
|
..Default::default()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
save_site_replication_state(&seed()).await.expect("seed state");
|
|
|
|
|
let straggler = update_site_replication_state(|state| Ok(next_peer_edit_generation(state)))
|
|
|
|
|
.await
|
|
|
|
|
.expect("first-life allocation");
|
|
|
|
|
let last_applied = update_site_replication_state(|state| Ok(next_peer_edit_generation(state)))
|
|
|
|
|
.await
|
|
|
|
|
.expect("first-life allocation");
|
|
|
|
|
// A receiver that never dropped this site from its peer map holds
|
|
|
|
|
// this mark across the removal.
|
|
|
|
|
let mut receiver = SiteReplicationState::default();
|
|
|
|
|
record_applied_peer_edit_generation(&mut receiver, "origin-site", last_applied);
|
|
|
|
|
|
|
|
|
|
// Unilateral removal: the site drops below two peers, which clears
|
|
|
|
|
// its state object and the counter with it.
|
|
|
|
|
let mut departed = seed();
|
|
|
|
|
departed.peers.remove("site-b");
|
|
|
|
|
save_site_replication_state(&departed).await.expect("clear state");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
load_site_replication_state().await.expect("reload").edit_generation,
|
|
|
|
|
generations.len() as u64,
|
|
|
|
|
"the persisted counter must account for every allocation"
|
|
|
|
|
0,
|
|
|
|
|
"clearing the state object must take the counter with it"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Rejoin recreates the state object; two nodes race the first
|
|
|
|
|
// allocation of the new life.
|
|
|
|
|
save_site_replication_state(&seed()).await.expect("recreate state");
|
|
|
|
|
let node_a = tokio::spawn(update_site_replication_state(|state| Ok(next_peer_edit_generation(state))));
|
|
|
|
|
let node_b = tokio::spawn(update_site_replication_state(|state| Ok(next_peer_edit_generation(state))));
|
|
|
|
|
let generation_a = node_a.await.expect("node a task").expect("node a allocation");
|
|
|
|
|
let generation_b = node_b.await.expect("node b task").expect("node b allocation");
|
|
|
|
|
assert_ne!(generation_a, generation_b, "racing allocations must stay distinct");
|
|
|
|
|
|
|
|
|
|
// The receiver's stale mark must not fence the restarted counter…
|
|
|
|
|
let restarted = generation_a.min(generation_b);
|
|
|
|
|
assert!(
|
|
|
|
|
!peer_edit_delivery_is_stale(&receiver, "origin-site", restarted),
|
|
|
|
|
"the recreated life's first allocation ({restarted}) must clear the previous life's mark ({last_applied})"
|
|
|
|
|
);
|
|
|
|
|
record_applied_peer_edit_generation(&mut receiver, "origin-site", restarted);
|
|
|
|
|
// …while the cleared life's in-flight leftovers stay fenced.
|
|
|
|
|
assert!(
|
|
|
|
|
peer_edit_delivery_is_stale(&receiver, "origin-site", straggler),
|
|
|
|
|
"a pre-removal in-flight delivery ({straggler}) must stay fenced after the rejoin"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|