Skip to content

feat: per-pod chaos selector — asymmetric BN latency injection (#3348) - #3353

Merged
Nana-EC merged 20 commits into
mainfrom
feature/3348-per-pod-chaos-selector
Aug 19, 2026
Merged

feat: per-pod chaos selector — asymmetric BN latency injection (#3348)#3353
Nana-EC merged 20 commits into
mainfrom
feature/3348-per-pod-chaos-selector

Conversation

@Nana-EC

@Nana-EC Nana-EC commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Problem

Chaos tests injected identical latency across all block nodes simultaneously.
Because every BN was equally degraded, no divergence occurred — the tests were
verifying nothing meaningful about single-node resilience.

Four additional bugs meant chaos was silently never applied at all:

  1. envsubst missing on CI runner — chaos manifest template substitution
    produced empty files; kubectl apply on an empty file returned no error.
  2. Loss field format — test YAMLs specified loss: "50%" but Chaos Mesh
    requires a plain number string ("50"); the admission webhook rejected the
    manifest and the event was still marked PASS.
  3. Silent kubectl apply failure — return code was not checked, so rejected
    manifests caused chaos to silently not apply.
  4. BackfillPlugin LIVE_TAIL (greedy: true) — even when chaos did apply,
    backfill polled unchaosed peers every ~60s and pulled missing blocks before
    the mid-chaos snapshot, collapsing any spread to zero.

Changes

Infrastructure fixes

  • .github/workflows/solo-e2e-test.yml — install gettext-base so envsubst
    is available on the runner.
  • solo-test-runner.sh / execute_inject_latency — strip trailing % from
    loss values; fail fast if kubectl apply returns non-zero.

Per-pod instance selector

  • network-latency.yaml.tmpl — added ${SOURCE_NAME_FILTER} /
    ${TARGET_NAME_FILTER} placeholder lines in each labelSelectors block.
    When set they inject app.kubernetes.io/instance: <name> at the correct
    indentation. When empty the line is blank — existing tests unaffected.
  • solo-test-runner.sh — extracts optional source.name / target.name
    from event args; builds compound dryrun selector for pod-count validation;
    exports the two filter env vars.

Mid-chaos measurement

  • snapshot-block-heights event — queries lastAvailableBlock on every BN
    mid-chaos and writes a snapshot file.
  • blocks-diverged assertion — reads the snapshot; fails if
    max − min < min_spread. Proves chaos produced detectable divergence.
  • blocks-converged assertion — queries live heights at assertion time;
    fails if spread exceeds tolerance_blocks. Proves streams survived and nodes
    caught up.

Topology change

Test definitions

latency-stress — survival test. One BN (BN1) receives high latency +
moderate loss. At this tier TCP throughput still exceeds the block data rate
so lag is constant (~2.5 blocks), not cumulative; divergence is not asserted.
Assertions confirm BN1 stays healthy, keeps receiving blocks, and converges
after chaos clears.

Pod CN↔BN latency BN→BN peers
BN1 5000 ms ± 1250 ms + 50% loss, bidirectional 2500 ms ± 625 ms, direction=to
BN2/3
CNs 100 ms ± 20 ms (background)

latency-severe — divergence test. Three-tier gradient; BN1 most degraded,
BN2 mid, BN3 reference. At 8000 ms + 75% correlated loss, burst-loss RTO chains
freeze the CN↔BN1 stream. Spread of ~39 blocks observed consistently.

Pod CN↔BN latency BN→BN peers
BN1 8000 ms ± 2000 ms + 75% loss (corr 75%), bidirectional 4000 ms ± 1000 ms, direction=to
BN2 4000 ms ± 1000 ms + 40% loss (corr 50%), bidirectional 2000 ms ± 500 ms, direction=to
BN3 — (reference)
CNs 1000 ms ± 250 ms (corr 75%), bidirectional

Convergence tolerance is wide (150 blocks) at this tier because the CN↔BN1
stream is severed and BN1 does not reconnect within the test window.
blocks-increasing and block-rate-floor target BN3 only.

Known limitation / follow-on

latency-stress has no block-divergence assertion because the current
tc-netem approach cannot produce cumulative lag when block payloads are
small (~2 KB). #3379 / #3387 (bandwidth throttle via tc tbf) will cap
ingest below block production rate, creating sustained lag with greedy: true.

Test results

Run Test Outcome Spread
31114793205 latency-severe ✅ PASS 39
31105361075 latency-stress ✅ PASS — (survival)

Closes #3348

Nana-EC added 11 commits August 5, 2026 13:56
NetworkChaos rules previously targeted ALL pods of a given type
(e.g. all block-nodes) via a single type label.  This meant every BN
experienced identical latency simultaneously — no node diverged from its
peers — so BackfillPlugin never triggered a gap-fill and the
backfill-triggered assertion always failed.

Changes:
- network-latency.yaml.tmpl: add ${SOURCE_NAME_FILTER} / ${TARGET_NAME_FILTER}
  optional YAML lines (empty when not used, rendered at correct indentation
  when set) so a second labelSelectors entry can pin the rule to a single pod.
- solo-test-runner.sh / execute_inject_latency: extract optional
  source.name / target.name from event args; build compound kubectl selector
  (type-label,app.kubernetes.io/instance=<name>) for the chaos-dryrun check;
  export SOURCE_NAME_FILTER / TARGET_NAME_FILTER for envsubst; annotate log
  line with instance suffix when name is present.

Backward-compatible: existing tests that omit source.name / target.name
produce empty filter vars → template renders identically to before.

Signed-off-by: Nana Essilfie-Conduah <nana@swirldslabs.com>
The previous symmetric approach applied identical latency to every BN
simultaneously, so no BN fell behind its peers and backfill never fired.

latency-stress (was: symmetric 3x-baseline):
  BN1 — "far region": CN↔BN1 800ms ± 200ms + BN1→BNs 1000ms (direction=to)
  BN2/BN3 — normal: no chaos
  CNs — light background: CN↔CN 100ms ± 20ms

latency-severe (was: uniform all-three 5-6x-baseline):
  BN1 — "very far": CN↔BN1 800ms ± 200ms + BN1→BNs 1000ms (direction=to)
  BN2 — "mid-range": CN↔BN2 400ms ± 100ms + BN2→BNs 500ms (direction=to)
  BN3 — "local": no per-BN chaos (fastest; serves as backfill source)
  CNs — severe: CN↔CN 600ms ± 150ms

BN→BN rules use direction=to (unidirectional) so the two per-BN rules do
not additively stack on the BN1↔BN2 path.

backfill-triggered assertion now targets block-node-1 (the most degraded
node) rather than all; since_seconds extended to cover the full test window.
Both tests require ≥3 BNs with backfill peers (paired-3, 7cn-3bn-distributed);
the chaos-dryrun check will fail fast on 3cn-1bn.

solo-e2e-test.yml: register latency-stress and latency-severe in the
test-definition input description for both workflow_dispatch and
workflow_call blocks.

Signed-off-by: Nana Essilfie-Conduah <nana@swirldslabs.com>
Latency alone keeps the gRPC stream alive (TCP retransmits absorb the
delay); BN1 receives every block just 800ms late, so no sequence gap
forms and BackfillPlugin never fires.

Adding correlated packet loss forces bursty drops that overwhelm TCP
retransmit budgets, break the gRPC stream, and create a genuine missing-
block gap during reconnection — which BackfillPlugin detects and fills.

latency-stress: inject-cn-bn1 gets loss: "25%", corr 75%
latency-severe: inject-cn-bn1 gets loss: "25%", inject-cn-bn2 gets loss: "15%"

Loss correlation matches the existing latency correlation so bursts of
high latency and drops hit simultaneously, maximising disruption.

Signed-off-by: Nana Essilfie-Conduah <nana@swirldslabs.com>
25% loss absorbed by TCP retransmits — BN1 received every block, no gap
formed, backfill never fired. 75% correlated loss (burst model) creates
near-blackout periods that exhaust TCP retransmit budget and force a
stream reset, generating the missing-block gap BackfillPlugin needs.

Signed-off-by: Nana Essilfie-Conduah <nana@swirldslabs.com>
#3348)

TCP on kind's loopback bridge absorbs all packet loss via retransmit — the
gRPC stream from CN to BN1 never breaks. BN1 received every block at the
normal 2-second cadence throughout the chaos window (blocks 115-122
consecutive at peak chaos, confirmed in BN1 logs from run 30780607265).

Even if the stream had broken, the CN resumes from the last-ACKed block on
reconnect, so no storage gap forms. All three BNs ended at block 278 in
every run. BackfillPlugin ran silently (gap detection at TRACE/DEBUG, only
"Starting backfillGap..." at INFO — never triggered).

Remove backfill-triggered from latency-stress and latency-severe. Update
comments to accurately scope these tests: they verify cluster resilience
under asymmetric latency (health, block continuity, rate floor), not
BackfillPlugin gap-fill. BackfillPlugin testing requires explicit storage
gap injection, tracked in #3349.

Also drop the loss parameter from latency-stress inject-cn-bn1; pure latency
is sufficient to exercise the timing gradient between BN1 and BN2/BN3.

Signed-off-by: Nana Essilfie-Conduah <nana@swirldslabs.com>
…riggered (#3348)

BackfillPlugin with greedy=false only scans up to the BN's own last stored
block (determineEndCap returns storeMax). With asymmetric latency BN1 never
misses blocks — TCP retransmit keeps the stream whole — so the non-greedy
scan finds no historical gaps and BackfillPlugin stays silent.

With greedy=true, determineEndCap queries peers for their max available
block (getPeerMaxAvailableBlock). The latency gradient means BN3 is always
1-2 blocks ahead of BN1 at any scan boundary. That range is classified as
LIVE_TAIL and submitted to the liveTailScheduler, producing INFO log:
  "Starting backfillGap type=[LIVE_TAIL] range=[N-N+1]"

This fires every 60s scan cycle throughout the chaos window, making the
backfill-triggered assertion reliable.

Changes:
- paired-3.yaml: greedy: false → greedy: true on all three BNs; regenerated
  out/ overlays will pick this up at task-up time
- latency-stress.yaml: restore loss: "75%" (packet loss widens the live-tail
  gap at scan boundaries, making detection more reliable); restore
  backfill-triggered with grep: "backfillGap" (targets INFO log exactly)
- latency-severe.yaml: same — restore backfill-triggered on BN1 with the
  "backfillGap" grep pattern; update header comments to explain mechanism

Signed-off-by: Nana Essilfie-Conduah <nana@swirldslabs.com>
All three BNs already have peers configured; enabling greedy=true so
BackfillPlugin proactively closes live-tail lag from faster peers,
consistent with the same change made to paired-3.

Signed-off-by: Nana Essilfie-Conduah <nana@swirldslabs.com>
Adds assert_blocks_converged() which queries serverStatus on all block
nodes and checks that max(lastAvailableBlock) - min(lastAvailableBlock)
is within a configurable tolerance. Wired as the 'blocks-converged'
assertion type in the runner dispatch.

This directly measures whether all BNs converged to the same block height
after a chaos window — confirming both that CN↔BN streams survived and that
any latency-induced lag closed — without requiring a specific recovery
mechanism (TCP delivery, backfill, or both satisfy it equally).

Signed-off-by: Nana Essilfie-Conduah <nana@swirldslabs.com>
backfill-triggered could not reliably fire under latency/loss chaos:
TCP absorbs all packet loss on kind's loopback bridge so BN1 receives
every block and never falls behind peers. The BackfillPlugin's LIVE_TAIL
mechanism only triggers when BN1's storeMax < peers' storeMax at a 60s
scan boundary, which doesn't happen when TCP delivers all blocks.

blocks-converged (tolerance 5) tests what actually matters: after chaos
clears, do all BNs converge to the same block height? This confirms that
CN↔BN streams survived throughout and any latency-induced lag closed —
regardless of whether TCP delivery or backfill achieved convergence.

Header comments updated to reflect the new test intent.

Signed-off-by: Nana Essilfie-Conduah <nana@swirldslabs.com>
snapshot-block-heights: new event type that captures lastAvailableBlock
for all BNs at the moment it fires, writing "node=block" lines to
/tmp/chaos-snapshot-<id>.txt. Placed mid-chaos to record per-BN heights
while the fault is still active.

blocks-diverged: new assertion type that reads a named snapshot file and
asserts max(lastBlock) - min(lastBlock) >= min_spread. Confirms that the
injected chaos actually caused measurable block divergence — a necessary
pre-condition for the blocks-converged post-chaos assertion to be meaningful.

Together these two types give a before/after pair: diverged-during-chaos
(spread >= N) followed by converged-after-chaos (spread <= N), proving both
that the fault had real effect and that recovery occurred.

Signed-off-by: Nana Essilfie-Conduah <nana@swirldslabs.com>
… tests

Both latency-stress and latency-severe now:
  1. Capture a snapshot-block-heights event at peak chaos (t=180s stress,
     t=210s severe) — records per-BN lastBlock while the fault is live
  2. Assert blocks-diverged (min_spread=3) — confirms chaos caused ≥3-block
     spread across BNs, proving the latency gradient had real effect
  3. Assert blocks-converged (tolerance=5) — confirms all BNs returned to
     the same height after chaos cleared

This replaces the single end-state-only check with a proper before/after
pair that detects both divergence under chaos and recovery after it.

Signed-off-by: Nana Essilfie-Conduah <nana@swirldslabs.com>
@Nana-EC
Nana-EC force-pushed the feature/3348-per-pod-chaos-selector branch from 5a6c0d6 to 5bcd174 Compare August 5, 2026 17:57
Nana-EC added 9 commits August 5, 2026 17:31
…failure mode

latency-stress: CN↔BN1 800ms→3000ms ±750ms, loss 75%→50% (corr 50%),
BN1→BN peers 1000ms→1500ms ±375ms — borderline 'approaching threshold' zone
(expected spread 3-8 blocks at peak chaos).

latency-severe: CN↔CN 600ms→1000ms, CN↔BN1 800ms→5000ms ±1500ms + 75% loss
(corr 75%), CN↔BN2 400ms→2500ms ±750ms + 40% loss (corr 50%),
BN1→BN 1000ms→2500ms, BN2→BN 500ms→1000ms — full production failure mode
(expected spread 40-80 blocks at peak chaos).

At 5000ms one-way + 75% correlated loss on BN1, TCP retransmit backoff
(10s→20s→40s→80s→160s) creates genuine cumulative block lag matching
the geographic deployment failure observed in production environments.

Closes #3348

Signed-off-by: Nana Essilfie-Conduah <nana@swirldslabs.com>
…mplate rendering

envsubst was not installed on the GitHub Actions ubuntu runner, causing
the NetworkChaos YAML manifest to be empty on every chaos inject event.
kubectl apply returned 'no objects passed to apply' and chaos was never
applied — all spread=0 results were caused by this missing binary, not
by TCP absorption of the injected latency.

gettext-base is appended to the existing 'Install zstd' apt-get line
to avoid an extra apt-get update call.

Signed-off-by: Nana Essilfie-Conduah <nana@swirldslabs.com>
At 3000ms one-way latency (stress tier), base block lag = 3000ms × 0.5 blk/s
= 1.5 blocks. TCP throughput at this latency+loss is ~700 KB/s — far above the
~1-5 KB/s block data rate — so no cumulative queue builds. min_spread: 3 is
unreachable with pure netem; set to 1 (onset of lag, borderline).

At 5000ms + 75% correlated loss (severe tier), base lag = 2.5 blocks plus burst
delays from retransmit backoff. min_spread: 3 may be hit transiently but is
unreliable; set to 2 (consistently reachable at peak chaos).

Reproducing the cumulative-lag production failure mode requires bandwidth
throttling (tc tbf), not just latency+loss. Tracked in #3379.

Signed-off-by: Nana Essilfie-Conduah <nana@swirldslabs.com>
… measurement

With greedy=true, BackfillPlugin's LIVE_TAIL closes any CN-latency-induced
lag within one 60s scan cycle by pulling from BN3 via the unchaosed peer
path. No amount of CN↔BN1 latency creates sustained spread — the gap is
erased before the snapshot fires.

greedy=false forces BN1 to rely solely on its direct CN gRPC stream, making
the one-way latency visible as a block-height difference at snapshot time.

This is a deliberate testing trade-off: not production-faithful on the
backfill dimension, but the only way to measure latency-induced divergence
without bandwidth throttling. The bandwidth-throttle approach (which works
with greedy=true) is tracked in #3379.

Signed-off-by: Nana Essilfie-Conduah <nana@swirldslabs.com>
… divergence

latency-stress: CN↔BN1 3000ms → 5000ms ±1250ms, BN1→BN 1500ms → 2500ms ±625ms
latency-severe: CN↔BN1 5000ms → 8000ms ±2000ms, CN↔BN2 2500ms → 4000ms ±1000ms,
  BN1→BN 2500ms → 4000ms, BN2→BN 1000ms → 2000ms; min_spread 2 → 3

Higher base latency increases the steady-state block lag above the assertion
thresholds, giving a more reliable signal that per-pod chaos is actually degrading
BN1. Prior values (3000ms / 5000ms) were borderline; RTO retry bursts sometimes
landed below min_spread before the snapshot was taken.

Signed-off-by: Nana Essilfie-Conduah <nana@swirldslabs.com>
…inject-latency

Chaos Mesh expects loss percentage as a plain number string ("50" not "50%").
The YAML test files specify loss as "50%" which caused the admission webhook to
reject the NetworkChaos manifest with a ParseFloat error; the event was still
marked PASS because kubectl apply return code was not checked.

- Strip trailing % from loss value immediately after parsing
- Remove stale "0%" guard (now unreachable after stripping)
- Propagate kubectl apply failure so the event fails fast instead of silently
  continuing with no chaos applied

Signed-off-by: Nana Essilfie-Conduah <nana@swirldslabs.com>
At 8000ms + 75% correlated loss over 370s the CN↔BN1 gRPC stream is severed;
BN1 does not recover before assertions run. The divergence signal (spread=40)
is the primary intent of the severe test, not stream survival.

- blocks-converged: tolerance 5 → 150 (catches a totally frozen node while
  accepting that BN1 may lag far behind at this tier)
- blocks-increasing / block-rate-floor: target all → block-node-3 (reference
  node unaffected by chaos); confirms the network is still live even if BN1
  stream was permanently severed

Signed-off-by: Nana Essilfie-Conduah <nana@swirldslabs.com>
At 5000ms + 50% loss, TCP throughput (~100-250 KB/s) is orders of magnitude above
the block data rate (~2 KB/block). BN1's lag is a constant ~2.5 blocks in-flight,
not cumulative — divergence never accumulates to a reliably measurable level.
Block divergence only becomes observable at the severe tier where 8000ms + 75%
correlated loss triggers burst-loss RTO chains that freeze the stream entirely.

Reposition stress as a survival test: BN1 stays healthy, keeps ingesting blocks,
and converges after chaos clears. Remove snapshot-peak-chaos event and
blocks-diverged assertion (no longer applicable). Use latency-severe for
divergence assertions.

Signed-off-by: Nana Essilfie-Conduah <nana@swirldslabs.com>
…#3379)

Add inline comment explaining why no impact assertion is made at the stress tier
and pointing to #3379 for the bandwidth-throttle follow-on that will close the gap.

Signed-off-by: Nana Essilfie-Conduah <nana@swirldslabs.com>
@Nana-EC Nana-EC added this to the 0.41.0 milestone Aug 8, 2026
@Nana-EC Nana-EC self-assigned this Aug 8, 2026
@Nana-EC Nana-EC added the CI/CD Issues related to the CI/CD. label Aug 8, 2026
@Nana-EC
Nana-EC marked this pull request as ready for review August 8, 2026 00:46
@Nana-EC
Nana-EC requested review from a team as code owners August 8, 2026 00:46
@Nana-EC
Nana-EC requested review from AlfredoG87 and brunodam August 8, 2026 00:46
@codecov

codecov Bot commented Aug 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

@@             Coverage Diff              @@
##               main    #3353      +/-   ##
============================================
+ Coverage     83.37%   83.58%   +0.21%     
- Complexity     2626     2678      +52     
============================================
  Files           228      229       +1     
  Lines         11801    11983     +182     
  Branches       1368     1396      +28     
============================================
+ Hits           9839    10016     +177     
- Misses         1431     1438       +7     
+ Partials        531      529       -2     

see 14 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jsync-swirlds jsync-swirlds left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The high latency options seem a bit too high (around 10X any real-world latency that doesn't involve deep-space radio links), in my opinion.

Comment thread tools-and-tests/scripts/solo-e2e-test/tests/latency-severe.yaml
Comment thread tools-and-tests/scripts/solo-e2e-test/tests/latency-stress.yaml
Comment thread tools-and-tests/scripts/solo-e2e-test/tests/latency-stress.yaml

@jsync-swirlds jsync-swirlds left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving to get testing in place, but the extreme latency involved here, compared to larger test environments, suggests we need to look at more than just latency to find the proper edges.

@Nana-EC

Nana-EC commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Approving to get testing in place, but the extreme latency involved here, compared to larger test environments, suggests we need to look at more than just latency to find the proper edges.

Yep, already in motion - #3387
Will continue to explore additional items.
Also captured your thoughts in #3503

@Nana-EC
Nana-EC merged commit 3be71c5 into main Aug 19, 2026
29 of 34 checks passed
@Nana-EC
Nana-EC deleted the feature/3348-per-pod-chaos-selector branch August 19, 2026 17:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CI/CD Issues related to the CI/CD.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Chaos injection targets all pods of a type — no per-pod selector support

3 participants