mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-30 19:46:15 +00:00
Make the cost-based chaos assertions reliable under parallel CI load
cost-avoidance and mixed-technology decide a parent by measured link cost (etx * (1 + srtt_ms/100)). Under the local CI's 4-way-parallel chaos the fast fiber link's measured srtt spikes from host scheduling and can exceed the Bluetooth link's, flipping the choice and reddening a run that proves nothing about the daemon. The old margin was ~0.4 cost units, which a ~24ms one-sided scheduling blip closes. Widen the Bluetooth delay to 150-250ms so the margin dwarfs any plausible one-sided spike. The link still establishes well within the handshake budget (a ~500ms RTT against a 30s stale-handshake window), so the losing candidate is still a real, established peer rather than an absent one. Add a netem mutation exclude_edges option and use it in mixed-technology to pin the two links n08 chooses between, so a random degradation can't flip the asserted comparison either. An unknown excluded edge is a hard error, since a silent no-op would reintroduce the flakiness it exists to remove. Validated by running both scenarios repeatedly under heavier-than-CI concurrent load: the parent choice holds every time.
This commit is contained in:
@@ -13,9 +13,21 @@
|
|||||||
# n04 (test subject)
|
# n04 (test subject)
|
||||||
#
|
#
|
||||||
# n04 has two candidate parents at depth 1: n02 (via Bluetooth L2CAP)
|
# n04 has two candidate parents at depth 1: n02 (via Bluetooth L2CAP)
|
||||||
# and n03 (via fiber). Cost-based selection should pick n03 because:
|
# and n03 (via fiber). Cost-based selection should pick n03 because
|
||||||
# effective_depth(n02) = 1 + ~1.4 (Bluetooth) = ~2.4
|
# link_cost = etx * (1 + srtt_ms/100), so the far slower Bluetooth link
|
||||||
# effective_depth(n03) = 1 + ~1.01 (fiber) = ~2.01
|
# costs far more:
|
||||||
|
# effective_depth(n02) = 1 + ~4.5 (Bluetooth, ~150-250ms) = ~5.5
|
||||||
|
# effective_depth(n03) = 1 + ~1.05 (fiber, ~1-5ms) = ~2.05
|
||||||
|
#
|
||||||
|
# The Bluetooth delay was widened deliberately, and the size is set by load,
|
||||||
|
# not by realism. At the original 15-40ms the cost margin was ~0.4, and even
|
||||||
|
# 80-120ms was not enough: under the CI's parallel-chaos contention the
|
||||||
|
# *measured* srtt of the fiber link spikes (host scheduling, not netem) and
|
||||||
|
# was observed to exceed a 120ms Bluetooth base, flipping n04 to n02. At
|
||||||
|
# 150-250ms the gap dwarfs any plausible one-sided fiber spike, so the
|
||||||
|
# assertion below is a reliable verdict rather than a coin toss under load.
|
||||||
|
# n04 still establishes the n02 link (it was parented to it when this flaked),
|
||||||
|
# so the choice remains real rather than vacuous.
|
||||||
#
|
#
|
||||||
# Validation: tree snapshot shows n04's parent is n03.
|
# Validation: tree snapshot shows n04's parent is n03.
|
||||||
|
|
||||||
@@ -44,11 +56,14 @@ netem:
|
|||||||
jitter_ms: [0, 1]
|
jitter_ms: [0, 1]
|
||||||
loss_pct: [0, 0.5]
|
loss_pct: [0, 0.5]
|
||||||
link_policies:
|
link_policies:
|
||||||
# Bluetooth (L2CAP) link from n02 to n04
|
# Bluetooth (L2CAP) link from n02 to n04. Delay widened to 150-250ms so the
|
||||||
|
# fiber-vs-Bluetooth cost margin survives CI load spikes on the fiber
|
||||||
|
# probe's measured latency (see header). Well within handshake timeouts, so
|
||||||
|
# the n02 link still establishes and the choice stays real.
|
||||||
- edges: ["n02-n04"]
|
- edges: ["n02-n04"]
|
||||||
policy:
|
policy:
|
||||||
delay_ms: [15, 40]
|
delay_ms: [150, 250]
|
||||||
jitter_ms: [5, 15]
|
jitter_ms: [15, 30]
|
||||||
loss_pct: [2, 8]
|
loss_pct: [2, 8]
|
||||||
|
|
||||||
link_flaps:
|
link_flaps:
|
||||||
|
|||||||
@@ -44,8 +44,8 @@
|
|||||||
#
|
#
|
||||||
# Netem mutation shifts fiber-only links between normal and degraded.
|
# Netem mutation shifts fiber-only links between normal and degraded.
|
||||||
#
|
#
|
||||||
# NEITHER TEST SUBJECT IS ASSERTED YET, and the reason has been found and
|
# n08 IS now asserted (below); n06 is not, and both reasons trace to the root,
|
||||||
# fixed at the root rather than papered over here.
|
# which was found and fixed rather than papered over here.
|
||||||
#
|
#
|
||||||
# The diagram above puts n01 at the top, and both test subjects only make
|
# The diagram above puts n01 at the top, and both test subjects only make
|
||||||
# sense in a tree rooted there. The mesh does not root by topology: it roots
|
# sense in a tree rooted there. The mesh does not root by topology: it roots
|
||||||
@@ -64,13 +64,15 @@
|
|||||||
#
|
#
|
||||||
# `topology.pin_root` (default true) now assigns identities in NodeAddr order,
|
# `topology.pin_root` (default true) now assigns identities in NodeAddr order,
|
||||||
# so n01 holds the smallest and the diagram describes the tree that forms.
|
# so n01 holds the smallest and the diagram describes the tree that forms.
|
||||||
# Both criteria should therefore become reachable and assertable.
|
# Under that root n08 takes fiber n03 as its subject assertion (below) now
|
||||||
|
# checks. n06 stays unasserted: its "fiber n03" was a WiFi link (see the
|
||||||
|
# corrected note above), so it was never a founded criterion. Tracked in
|
||||||
|
# ISSUE-2026-0085.
|
||||||
#
|
#
|
||||||
# STILL TO DO: run this under the pinned root, confirm n06 and n08 both take
|
# The n08 assertion is made reliable, not merely reachable: the Bluetooth
|
||||||
# n03, and encode `tree_parents` for them. Until then the baseline block below
|
# contrast is widened (netem below) and the two links n08 chooses between are
|
||||||
# is a floor and not a substitute — it holds the mesh to forming a tree and
|
# excluded from the netem mutation, so neither host load nor a random
|
||||||
# carrying traffic, and nothing here yet checks that the tree is the one these
|
# degradation can flip the asserted comparison.
|
||||||
# comments describe. Tracked in ISSUE-2026-0085.
|
|
||||||
|
|
||||||
scenario:
|
scenario:
|
||||||
name: "mixed-technology"
|
name: "mixed-technology"
|
||||||
@@ -105,11 +107,15 @@ netem:
|
|||||||
jitter_ms: [0, 1]
|
jitter_ms: [0, 1]
|
||||||
loss_pct: [0, 0.5]
|
loss_pct: [0, 0.5]
|
||||||
link_policies:
|
link_policies:
|
||||||
# Bluetooth (L2CAP) links
|
# Bluetooth (L2CAP) links. Delay widened to 150-250ms so the
|
||||||
|
# fiber-vs-Bluetooth cost margin on n08's choice (fiber n03 vs Bluetooth
|
||||||
|
# n04) dwarfs any load-induced srtt spike on the fiber probe, matching
|
||||||
|
# cost-avoidance. Combined with excluding n03-n08/n04-n08 from the mutation
|
||||||
|
# (below), n08's asserted comparison is robust to both load and churn.
|
||||||
- edges: ["n02-n06", "n04-n08"]
|
- edges: ["n02-n06", "n04-n08"]
|
||||||
policy:
|
policy:
|
||||||
delay_ms: [15, 40]
|
delay_ms: [150, 250]
|
||||||
jitter_ms: [5, 15]
|
jitter_ms: [15, 30]
|
||||||
loss_pct: [2, 8]
|
loss_pct: [2, 8]
|
||||||
# WiFi links (moderate latency, low loss)
|
# WiFi links (moderate latency, low loss)
|
||||||
- edges: ["n03-n06", "n08-n10"]
|
- edges: ["n03-n06", "n08-n10"]
|
||||||
@@ -120,6 +126,12 @@ netem:
|
|||||||
mutation:
|
mutation:
|
||||||
interval_secs: {min: 30, max: 60}
|
interval_secs: {min: 30, max: 60}
|
||||||
fraction: 0.2
|
fraction: 0.2
|
||||||
|
# Never mutate the two links n08's tree_parents assertion depends on: the
|
||||||
|
# degraded policy (50-100ms) could otherwise slow the fiber n03-n08 link
|
||||||
|
# enough to flip n08 to n04 independently of the widened Bluetooth contrast,
|
||||||
|
# making the assertion a coin toss. The mutation still churns the other ten
|
||||||
|
# links, so the dynamic behaviour it exists to exercise is unchanged.
|
||||||
|
exclude_edges: ["n03-n08", "n04-n08"]
|
||||||
policies:
|
policies:
|
||||||
normal:
|
normal:
|
||||||
delay_ms: [1, 10]
|
delay_ms: [1, 10]
|
||||||
|
|||||||
@@ -144,6 +144,27 @@ class NetemManager:
|
|||||||
len(self._edge_overrides),
|
len(self._edge_overrides),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Edges the periodic mutation must never touch (canonical "nXX-nYY").
|
||||||
|
# Unlike a link_policy typo, which merely fails to shape a link, a typo
|
||||||
|
# here would silently leave an asserted link unprotected and reintroduce
|
||||||
|
# the exact flakiness the exclusion exists to remove — so an unknown
|
||||||
|
# edge is a hard error, not a warning.
|
||||||
|
self._mutation_exclude: set[str] = set()
|
||||||
|
for edge_str in config.mutation.exclude_edges:
|
||||||
|
canonical = "-".join(sorted(edge_str.split("-")))
|
||||||
|
if canonical not in topo_edge_strs:
|
||||||
|
raise ValueError(
|
||||||
|
f"netem.mutation.exclude_edges references {edge_str!r}, "
|
||||||
|
"which is not an edge in the topology"
|
||||||
|
)
|
||||||
|
self._mutation_exclude.add(canonical)
|
||||||
|
if self._mutation_exclude:
|
||||||
|
log.info(
|
||||||
|
"Mutation excludes %d edge(s): %s",
|
||||||
|
len(self._mutation_exclude),
|
||||||
|
", ".join(sorted(self._mutation_exclude)),
|
||||||
|
)
|
||||||
|
|
||||||
def _htb_rate(self, node_id: str, peer_id: str) -> str:
|
def _htb_rate(self, node_id: str, peer_id: str) -> str:
|
||||||
"""Return the HTB rate string for a link direction."""
|
"""Return the HTB rate string for a link direction."""
|
||||||
rate = self._edge_rates.get((node_id, peer_id), 0)
|
rate = self._edge_rates.get((node_id, peer_id), 0)
|
||||||
@@ -370,10 +391,12 @@ class NetemManager:
|
|||||||
if not self.config.mutation.policies:
|
if not self.config.mutation.policies:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Only consider edges where both endpoints are up
|
# Only consider edges where both endpoints are up, and never the
|
||||||
|
# explicitly excluded ones (links an assertion depends on).
|
||||||
live_edges = [
|
live_edges = [
|
||||||
(a, b) for a, b in self.topology.edges
|
(a, b) for a, b in self.topology.edges
|
||||||
if a not in self.down_nodes and b not in self.down_nodes
|
if a not in self.down_nodes and b not in self.down_nodes
|
||||||
|
and "-".join(sorted([a, b])) not in self._mutation_exclude
|
||||||
]
|
]
|
||||||
if not live_edges:
|
if not live_edges:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -73,6 +73,11 @@ class NetemMutationConfig:
|
|||||||
interval_secs: Range = field(default_factory=lambda: Range(15, 30))
|
interval_secs: Range = field(default_factory=lambda: Range(15, 30))
|
||||||
fraction: float = 0.3
|
fraction: float = 0.3
|
||||||
policies: dict[str, NetemPolicy] = field(default_factory=dict)
|
policies: dict[str, NetemPolicy] = field(default_factory=dict)
|
||||||
|
# Edges the periodic mutation must never touch, "nXX-nYY" strings. Use it
|
||||||
|
# to pin the links a tree_parents assertion depends on so a random
|
||||||
|
# degradation cannot flip the very comparison being asserted. Validated
|
||||||
|
# against the topology in NetemManager — an unknown edge is a hard error.
|
||||||
|
exclude_edges: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -388,7 +393,7 @@ _SECTION_KEYS = {
|
|||||||
},
|
},
|
||||||
"netem": {"enabled", "default_policy", "link_policies", "mutation"},
|
"netem": {"enabled", "default_policy", "link_policies", "mutation"},
|
||||||
"netem.link_policies[]": {"edges", "policy", "policy_name"},
|
"netem.link_policies[]": {"edges", "policy", "policy_name"},
|
||||||
"netem.mutation": {"interval_secs", "fraction", "policies"},
|
"netem.mutation": {"interval_secs", "fraction", "policies", "exclude_edges"},
|
||||||
"link_flaps": {
|
"link_flaps": {
|
||||||
"enabled", "interval_secs", "max_down_links", "down_duration_secs",
|
"enabled", "interval_secs", "max_down_links", "down_duration_secs",
|
||||||
"protect_connectivity",
|
"protect_connectivity",
|
||||||
@@ -549,6 +554,7 @@ def load_scenario(path: str) -> Scenario:
|
|||||||
mc.get("interval_secs", {"min": 15, "max": 30}), "netem.mutation.interval_secs"
|
mc.get("interval_secs", {"min": 15, "max": 30}), "netem.mutation.interval_secs"
|
||||||
)
|
)
|
||||||
s.netem.mutation.fraction = float(mc.get("fraction", 0.3))
|
s.netem.mutation.fraction = float(mc.get("fraction", 0.3))
|
||||||
|
s.netem.mutation.exclude_edges = list(mc.get("exclude_edges", []))
|
||||||
if "policies" in mc:
|
if "policies" in mc:
|
||||||
s.netem.mutation.policies = {
|
s.netem.mutation.policies = {
|
||||||
name: _parse_netem_policy(pdata, f"netem.mutation.policies.{name}")
|
name: _parse_netem_policy(pdata, f"netem.mutation.policies.{name}")
|
||||||
|
|||||||
Reference in New Issue
Block a user