fix(testing): hold the chaos final snapshot until every node answers

The settle before the final tree snapshot compared only the nodes that
answered. A node restored at teardown that had not opened its control
socket yet was simply left out of each read, so three reads missing the
same node agreed with each other and the snapshot was taken without it.
A scenario whose node-count floor equals its node count, such as
churn-mixed with ten nodes, then failed its baseline with nine nodes
answering although the tenth had come back.

A read that any node in the topology did not answer now never counts
toward agreement, so the snapshot waits for the restored node and then
for three agreeing reads with every node in them. The ninety-second
bound is unchanged and running out is still not a failure in itself:
the snapshot is taken and the assertions judge it, so a node that never
comes back is still reported absent, now after the bound rather than
after ten seconds. Running out names the nodes still not answering.

The docstring and comments now describe what the settle does, including
that the bound is checked after each read and so can be overrun by one
interval and one read.
This commit is contained in:
Johnathan Corgan
2026-10-02 15:14:25 +00:00
parent 27c41ff4d8
commit 5ab7cd3e89
+39 -18
View File
@@ -48,8 +48,9 @@ from .veth import VethManager
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# The final snapshot waits for this many identical consecutive tree reads, # The final snapshot waits until every node answers and this many identical
# taken this far apart, so the tree must hold still for two intervals. # consecutive tree reads, taken this far apart, agree, so the tree must hold
# still, with every node in it, for two intervals.
SETTLE_READS = 3 SETTLE_READS = 3
SETTLE_INTERVAL_SECS = 5 SETTLE_INTERVAL_SECS = 5
SETTLE_TIMEOUT_SECS = 90 SETTLE_TIMEOUT_SECS = 90
@@ -744,7 +745,8 @@ class SimRunner:
# Take final tree snapshot while nodes are still running, once the # Take final tree snapshot while nodes are still running, once the
# tree has stopped moving. A node restored a moment ago is its own # tree has stopped moving. A node restored a moment ago is its own
# root until it re-parents, so a snapshot taken straight after the # root until it re-parents, so a snapshot taken straight after the
# restore reads a mesh still converging. # restore reads a mesh still converging. It may not answer at all
# yet either, and the settle waits for it.
self._settle_tree() self._settle_tree()
self._take_snapshot("final") self._take_snapshot("final")
@@ -893,29 +895,39 @@ class SimRunner:
return result return result
def _settle_tree(self): def _settle_tree(self):
"""Wait until consecutive tree reads agree, or the settle time runs out. """Wait until every node answers and consecutive tree reads agree.
Compares each answering node's root and parent. A fixed delay would Returns once SETTLE_READS reads in a row, SETTLE_INTERVAL_SECS apart,
either waste time on a mesh that settled at once or cut off one that have each been answered by every node in the topology and show the
had not. Running out is logged and is not a failure in itself: the same root and parent for each, or once SETTLE_TIMEOUT_SECS has passed.
final snapshot is taken anyway, and the assertions judge what it The bound is checked after each read, so the settle can return up to
shows. one interval and one read past it. A fixed delay would either waste
time on a mesh that settled at once or cut off one that had not.
A read that no node answered never counts toward agreement. It does A read that any node did not answer never counts toward agreement. A
not catch a node that stays its own root for longer than the reads node restored at teardown may not have opened its control socket yet,
span, which is a tree that is stable and wrong, and is left to the and a tree that holds still without it is not the tree the final
assertions. snapshot is meant to record.
Running out is logged, naming any node that still does not answer,
and is not a failure in itself: the final snapshot is taken anyway
and the assertions judge what it shows. A node that never comes back
is therefore reported as absent by the assertions that count nodes.
This does not catch a node that answers but stays its own root for
longer than the reads span, which is a tree that is stable and
wrong, and is left to the assertions.
""" """
started = time.time() started = time.time()
previous = None previous = None
agreeing = 0 agreeing = 0
while not self._interrupted: while not self._interrupted:
trees = snapshot_all_trees(self.topology) trees = snapshot_all_trees(self.topology)
missing = sorted(set(self.topology.nodes) - trees.keys())
shape = { shape = {
nid: (data.get("root"), data.get("parent")) nid: (data.get("root"), data.get("parent"))
for nid, data in trees.items() for nid, data in trees.items()
} }
if not shape: if missing:
agreeing = 0 agreeing = 0
else: else:
agreeing = agreeing + 1 if shape == previous else 1 agreeing = agreeing + 1 if shape == previous else 1
@@ -925,10 +937,19 @@ class SimRunner:
log.info("Tree settled after %.0fs", waited) log.info("Tree settled after %.0fs", waited)
return return
if waited >= SETTLE_TIMEOUT_SECS: if waited >= SETTLE_TIMEOUT_SECS:
log.warning( if missing:
"Tree still changing after %.0fs; taking the final snapshot anyway", log.warning(
waited, "%s still not answering after %.0fs; "
) "taking the final snapshot anyway",
", ".join(missing),
waited,
)
else:
log.warning(
"Tree still changing after %.0fs; "
"taking the final snapshot anyway",
waited,
)
return return
self._sleep(SETTLE_INTERVAL_SECS) self._sleep(SETTLE_INTERVAL_SECS)