Skip to content

test(early-kickout): integration branch stacking the four open PRs - #16121

Draft
stedfn wants to merge 42 commits into
masterfrom
stedfn/early-kickout-integration
Draft

test(early-kickout): integration branch stacking the four open PRs#16121
stedfn wants to merge 42 commits into
masterfrom
stedfn/early-kickout-integration

Conversation

@stedfn

@stedfn stedfn commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Do not merge. This is a throwaway validation branch, not a landing PR. It exists so CI runs the full suite against the complete early-kickout feature at once. Each source PR still lands separately on its own base.

Stacked on master (f810085):

Most of #16086 had already landed on master. Only three tests were still new: resharding shard-id mapping, empty-exclusion sampler equivalence, and fork isolation. Those were ported. Its old 20/50 threshold assertions were dropped.

Fixes made here, to be back-ported

  • test(early-kickout): end-to-end reassignment + epoch-sync tests #16058: added a test_features-gated threshold override to the epoch manager. Production blacklists a producer at 100 misses past a 1000-block grace. That is about 1100 blocks, which a test-loop chain cannot run. The override lowers both so the gate trips in tens of blocks. A build without test_features reads the plain constants, so production behaviour is unchanged. The override is thread-local: a test-loop chain runs entirely on one thread, while cargo runs test functions on parallel threads, so a process global would leak a lowered grace into unrelated tests.
  • test(early-kickout): end-to-end reassignment + epoch-sync tests #16058: the epoch-sync test now runs to the same depth the sync tests use. The old depth made the epoch-sync proof invalid, so the test quietly degraded into a header-sync-from-genesis run.
  • test(state-sync): early kickout coverage for synced nodes #15983: the anchor probe now asserts it stayed above the GC tail. A green run can no longer be mistaken for garbage-collection coverage.

Results

suite result
near-epoch-manager early_kickout 25/25 pass
near-chain chunk_producers 14/14 pass
sync and early-kickout (#15983) 2/2 pass
e2e (#16058) 1 pass, 1 fail

cargo fmt and clippy --all-features --all-targets are clean.

The failing test found a real bug

test_early_kickout_epoch_sync_bootstrap fails on a genuine divergence, not a test bug.

A node that epoch-syncs and then header-syncs credits the stopped producer with 71 chunks it never made. The source node, which processed every block, records 0 produced out of 19 expected for the same validator in the same epoch. Wrong chunk stats change rewards, so the syncing node computes a different next-epoch block producer hash and rejects every header batch with InvalidNextBPHash. It never joins.

Cause: anchored_chunk_producers_for_aggregator falls back to the plain height sampler when a DBCol::ChunkProducers row is missing. That fallback is only correct while the blacklist is empty, which its own comment says.

Two passing tests bound the problem. test_early_kickout_far_horizon_observer uses the same sync path with no active blacklist. test_early_kickout_reassignment has an active blacklist but no sync.

The lowered test thresholds are not the cause. Production values only delay the blacklist by about 1100 blocks.

stedfn and others added 30 commits June 3, 2026 16:55
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t-pr-6

# Conflicts:
#	CHANGELOG.md
#	chain/chain/src/store/mod.rs
#	chain/epoch-manager/src/epoch_info_aggregator.rs
#	chain/epoch-manager/src/lib.rs
#	test-loop-tests/src/tests/chunk_producers.rs
…sert

Follow-up to the master merge, resolving the debated untrustworthy-sampling
concern for the blacklist-aware writer.

- seed_chunk_producer_rows: debug_assert that the blacklist-aware sampler yields
  a producer for every shard that has one. The safety valve in
  compute_chunk_producer_blacklist guarantees this; the assert enforces the
  coupling (a None only means an empty shard settlement, nothing to seed).
- nonempty_blacklist_anchor_always_has_row: proves the missing-row region and
  the non-empty-blacklist region are disjoint, so the aggregator's lenient
  reader never height-samples (which would re-credit a blacklisted producer)
  while a blacklist is active.
- seeded_rows_match_blacklist_aware_sampler: the seeded ChunkProducers row
  equals the plain height sampler while the blacklist is empty, and equals the
  blacklist-aware sampler (never the down node) once it is non-empty; the strict
  consensus reader returns that same row.

Chose a writer-side assert over a reader-side one: at the aggregator miss site
the per-shard stats are not in scope, and the partial aggregator there reflects
a different point in the backward walk, so asserting against it would check the
wrong time slice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t-pr-6

#15841 (PR5) landed on master independently; resolve the duplicate by taking master for the pure-PR5 files (epoch_info.rs adopts master's bounds-safe sampler, adapter.rs) and keeping the branch's PR6 payload (lib.rs seed rewrite, early_kickout.rs tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
test_finalize_epoch_large_epoch_length asserts the incremental aggregator
visits each block exactly once (epoch_info_aggregator_loop_counter == N). With
EarlyKickout active (nightly/spice builds), seed_chunk_producers reads
get_epoch_info_aggregator_upto_last once per record_block_info, adding bounded
extra walk iterations, so the exact count only holds with the feature compiled
out. Gate the assertion with #[cfg(not(feature = "nightly"))] (the complement of
the seed body's own cfg); the caching invariant stays covered on the stable
build and the kickout seed path is covered by the early_kickout tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The V2 witness prerequisite is provided by #15908 (grandparent-anchored
resolution), not the superseded #15640 consumer. Aligns the doc comment with
the PR description and current code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove stacked-PR references (#15908/PR5/PR6) and narration that just restates
the code from the comments this branch added: revert the compute_chunk_producer_blacklist
comment churn to master's wording, drop the seed_chunk_producers reader/activation
paragraphs (readers and gating are visible in the code), and reword the test module doc.
No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…up blacklist helper

Review response for the mid-epoch chunk-producer kickout writer:
- Observability: emit a nightly counter (near_early_kickout_chunk_producer_reassigned_total,
  by shard) + info log when the seeder reassigns a slot away from a blacklisted producer.
  The kick detector is "the plain scheduled producer is blacklisted", not
  "plain != excluded" (the excluding sampler renormalizes, so that would false-fire).
- DRY: extract blacklist_for_epoch, shared by the seeder and the get_chunk_producer_blacklist
  accessor, removing the duplicated epoch-reset check.
- Tests: 2-shard per-shard blacklist isolation (+ seeding + metric delta), and a
  finality-stall regression guard pinning the per-block aggregator-walk cost.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cspell (CI spellcheck) flagged 'unfinalized' in the stall-test comments; it is
not in the project dictionary. Reword to 'not-yet-finalized'. Comment-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add early_kickout_e2e.rs (nightly + test_features):
- test_early_kickout_reassignment: induce a chunk producer below 80% via the
  adversarial StopProduce message; assert the offending slot is reassigned
  (DB-backed resolver returns a different validator for the target's own slots
  once the grandparent anchor blacklists it) while the shard keeps producing
  chunks (liveness). Condition-based waits, topology avoids the all-blacklisted
  safety valve.
- test_early_kickout_epoch_sync_bootstrap: a fresh node epoch-syncs INTO a
  network that already has an active reassignment; assert the seeded first-block
  rows exist and match the source (no ChunkProducerNotInDB), then that after the
  node lives through a fresh epoch it reproduces the reassignment and agrees with
  the source (no synced-vs-source divergence).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Spice decouples chunk execution from block production, so the fresh-node
epoch-sync path the test drives does not converge within the deadline. The
reassignment test needs no epoch sync and stays enabled under spice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the drop-shard safety valve in compute_chunk_producer_blacklist
with keep-exactly-one-least-bad: when all distinct producers on a shard
would be blacklisted, retain the highest produced/expected producer via a
deterministic total order (u128 cross-multiplication, tiebreak fewer
expected, then lower validator_id) so the worst offenders are still
reassigned away from while the shard keeps producing.

Both compute_chunk_producer_blacklist and blacklist_for_epoch return
ChunkProducerBlacklist { blacklist, shard_stats }. The seeder is the single
owner of the new observability; the accessor is a pure read. New metrics
(safety_valve_fired counter, blacklist_size gauge) are nightly-gated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The record-block seeder computed the chunk-producer blacklist from the
aggregator walked up to the unfinalized anchor. During a finality stall
that rescans the growing not-yet-finalized suffix every block, an
O(stall-depth) per-block walk under the epoch-manager write lock.

Base the blacklist on the anchor's last-final block instead
(BlockInfo::last_final_block_hash / last_finalized_height). This is
copied verbatim from the block header, so it is identical across nodes
for a given canonical anchor - required because the seeded row is read
on the strict consensus path (get_chunk_producer_info_anchored) with no
recompute. It also bounds the walk: the aggregator's sync point already
sits at the last-final block, so the walk covers only blocks finalized
since the previous record (~1 in steady state, 0 during a stall) and
never the unfinalized suffix.

Gate the start-of-epoch grace window on the last-final block height too,
matching the blacklist basis so the seeder is internally consistent.
Update the get_chunk_producer_blacklist accessor to the same basis, and
the epoch-manager tests: model 2-block finality in the block helper,
shift the grace-boundary anchors, and retighten the finality-stall walk
guard from O(depth^2) to constant-per-block across two stall depths.

Stale/old-fork anchors whose last-final is an ancestor of the cached
sync point still walk (rare, non-canonical, bounded by epoch length); a
protocol-fixed budget cap for that case is left as a follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t-e2e-tests

# Conflicts:
#	chain/epoch-manager/src/lib.rs
#	chain/epoch-manager/src/metrics.rs
#	chain/epoch-manager/src/tests/early_kickout.rs
#	chain/epoch-manager/src/tests/mod.rs
…t/least-bad-valve-15843

# Conflicts:
#	chain/epoch-manager/src/adapter.rs
#	chain/epoch-manager/src/lib.rs
#	chain/epoch-manager/src/metrics.rs
#	chain/epoch-manager/src/tests/early_kickout.rs
#	chain/epoch-manager/src/tests/mod.rs
Tighten wordy comments and drop one redundant inline note in the keep-one
blacklist path. Comments only, no code change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop an internal plan-label prefix from one test comment. Comments only,
no code change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop a roadmap clause from the module doc; keep the technical meaning
(a red run is a real gap the tests guard against). Comments only, no code change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ports the 3 net-new blacklist-math tests from #16037 onto the keep-one
safety-valve branch (#16060), plus their two helpers (epoch_info_for_layout,
drive_fork). #16037 sat on the dead least-bad-safety-valve lineage and
re-carried ~1134 lines already on master and #16060; only these tests were
net-new. This supersedes it.

The two math/sampler tests stay plain #[test] (not nightly-gated) so the
default CI job keeps covering them.

The fork test is strengthened over the #16037 original: it now snapshots the
canonical anchor's raw aggregator stats before driving the abandoned fork and
asserts they are unchanged after, proving no cross-fork leak at the raw level
rather than relying on the post-valve blacklist (which the keep-one valve
could otherwise mask).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The changelog line will be added on stabilization, not on the nightly-gated
feature PRs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-separate the gas-keys and promise-yield bullets that the previous
changelog-drop commit accidentally merged onto one line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
stedfn and others added 12 commits July 23, 2026 17:54
…t-seed-walk-cost

# Conflicts:
#	chain/epoch-manager/src/adapter.rs
#	chain/epoch-manager/src/lib.rs
#	chain/epoch-manager/src/metrics.rs
#	chain/epoch-manager/src/tests/early_kickout.rs
#	chain/epoch-manager/src/tests/mod.rs
Reduce the merged seed walk-cost comments to non-obvious rationale only,
reword `unfinalized` -> `not-yet-final` and `rescan` -> `re-walk` to satisfy
cspell, and drop the CHANGELOG entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses review nit: the fn had grown to 9 params, four of which were two
same-typed adjacent pairs. Bundles them into SeedAnchor and SampleEpoch.
No behavior change.

Co-Authored-By: Claude <noreply@anthropic.com>
Drops six comments that restate the code or claim something the compiler
already enforces. Fixes three that were wrong: the seed_chunk_producers doc
called the sample epoch "the epoch after the anchor" (it is the anchor's own
epoch except at a boundary), a comment named a parameter the previous commit
removed, and the adapter mirror warning claimed a fork that cannot happen
while the accessor has no non-test callers.

Co-Authored-By: Claude <noreply@anthropic.com>
…ath-tests-v2' into stedfn/early-kickout-integration

# Conflicts:
#	chain/epoch-manager/src/adapter.rs
#	chain/epoch-manager/src/lib.rs
#	chain/epoch-manager/src/metrics.rs
#	chain/epoch-manager/src/tests/early_kickout.rs
#	chain/epoch-manager/src/tests/mod.rs
…into stedfn/early-kickout-integration

# Conflicts:
#	CHANGELOG.md
#	chain/epoch-manager/src/tests/early_kickout.rs
#	test-loop-tests/src/tests/mod.rs
…GC probe

Integration-branch fixes for the stacked early-kickout PRs.

near-epoch-manager gains a `test_features` feature exposing
`set_early_kickout_thresholds_for_testing`, which lowers the miss floor and
the start-of-epoch grace for the calling thread. Production builds have no
`test_features` and read the plain constants, so the seam is a no-op there.
The override is thread-local because a test-loop chain runs entirely on the
thread that drives its event loop, while cargo runs test functions on parallel
threads; a process global would leak a lowered grace into unrelated tests.

The e2e tests use the seam so the gate trips in tens of blocks instead of
~1100, and now reach the epoch-sync horizon the sync tests use (the previous
depth made the epoch-sync proof invalid and silently degraded the test into a
header-sync-from-genesis run).

The sync test's anchor probe now asserts it stayed above the GC tail, so its
green cannot be read as garbage-collection coverage.
The guard restores a thread-local, so moving it to another thread would make
Drop restore that thread's thresholds and leave the installing thread lowered
for every test the harness later runs on it. A PhantomData<*const ()> field
makes the compiler reject the move.

Found by codex review.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant