[WIP] Systematic testing: Test the full KV + History + Consensus stack, with deterministic and randomly-explored interleavings - #8238
Draft
Eddy Ashton (eddyashton) wants to merge 5 commits into
Conversation
Adds a new test suite (real_stack_concurrency_test) that drives a real ccf::kv::Store, a real aft::Aft<LedgerStubProxy>, and a real ccf::MerkleTxHistory together, under genuine OS-thread concurrency and genuine raft view changes - something no existing suite does (kv_test, raft_test, and history_test each stub out at least one of these three). - src/kv/test/interleaving.h: a reusable Checkpoint pause/release primitive and a random_delay helper, for pinning or fuzzing thread interleavings without bespoke per-test machinery. - src/consensus/aft/test/real_stack/fixture.h: RealStackFixture, a harness wiring up the real Store + Aft + MerkleTxHistory, with helpers to drive genuine leadership changes and signature commits. - smoke.cpp: non-concurrent sanity checks for the harness itself. - deterministic.cpp: pinned scenarios covering leadership loss/regain around an in-flight commit, including NOTE_REJECTED_COMMIT_STALL, which documents a real bug where a rejected commit can permanently stall replication until a further election. - fuzzer.cpp: a randomised multi-actor fuzzer (writers, election churn, and a continuous reader) checking the same invariants continuously, plus a slower soak variant using real crypto. Several tests exercise NOTE_IS_PRIMARY_RACE, a pre-existing data race in aft::Aft::is_primary(), and are expected to fail occasionally (or abort the process under ThreadSanitizer) until that is fixed. Registered via add_unit_test with DETECT_DEADLOCKS, under a new "concurrency" CTest label.
…ize into src/commit_concurrency - Add a deterministic, cooperative scheduler (commit_concurrency_model_test) that exhaustively (or randomly, for larger scenarios) explores thread interleavings of the real Store + Aft + MerkleTxHistory stack, by wholesale-swapping ccf::pal::Mutex for a scheduler-aware type across the whole test binary via -include, rather than opting individual call sites in. - Add ccf::pal::unique_lock<LockType>, a labeled drop-in replacement for std::unique_lock/std::lock_guard against ccf::pal::Mutex, so a failing schedule's trace can show real, semantic reasons a lock was held/released, not just an opaque mutex handoff. Apply it at the real call sites in store.h, raft.h, and history.h, with a handful of explicit labels at high-value points (Store::commit()/rollback(), force_become_primary()). - Fix a real bug in the scheduler harness found while wiring this up: the driver thread's reserved actor id could write one entry past the per-actor action-tracking vector once real locks started reporting labels unconditionally - fixed by reserving that slot. - Move all commit-concurrency test suite files (previously split across src/kv/test and src/consensus/aft/test) into a single new top-level directory, src/commit_concurrency/. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… ones - DeterministicScheduler::before_lock()/after_unlock() now call choose_next() unconditionally, so the scheduler considers every ready actor at every real lock acquisition and release, not only when a lock is actually contended - closing the gap where two critical sections separated by an uncontended lock boundary (rather than a hand-placed yield_point()) were never explored interleaved. - The reserved driver "actor" (DriverRegistration) is a deliberate exception: it never contends with a real actor for any lock, so its own incidental lock use only updates ownership bookkeeping, never branches. This fixes a real, deterministic hang the above change first exposed: without this, the driver's own lock use while running real application code (e.g. fixture construction) could get "scheduled away" in favour of a real actor thread that had not started yet, with nothing left to ever hand control back. - Converted the single-writer scenario in rejected_commit_stall.cpp from exhaustive search to random sampling, matching the two-writer scenario: estimate_schedule_count() now reports this scenario's interleaving space at roughly 16.7 million to 4.4 trillion schedules (up from an exact 3 when only contended locks branched), making exhaustive search infeasible for any real-stack scenario. - Updated deterministic_scheduler_test.cpp's toy expectations for the resulting increase in schedule count (6 -> 736) now that every lock/ unlock branches. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This is extremely sketchy, just opening it here for early discussion.
Robot brief explanation
src/commit_concurrency/: a new test suite for KV/Raft/History concurrencyProblem: no existing suite drives a real
ccf::kv::Store, a realaft::Aft, and a realMerkleTxHistorytogether under concurrency. Every existing suite stubs at least one of the three, so bugs in how they interact under real locking/threading go unfound.What's added, two complementary test binaries:
commit_concurrency_test— real OS threads, real timing. ACheckpointprimitive (interleaving.h) lets scenarios pin specific rendezvous points; a randomized fuzzer runs writers, elections, and readers concurrently and checks cross-component invariants (history vs. store vs. raft all agreeing on committed state).commit_concurrency_model_test— a deterministic scheduler that wholesale-swaps everyccf::pal::Mutexin the binary for a scheduler-aware type (via-include, so no call site opts in individually), and treats every lock acquisition/release (not just contended ones) as a branch point in the search. It can exhaust a scenario's interleavings or sample them randomly with a reproducible seed, and every failing schedule prints a full, human-readable decision trace showing real, semantic lock labels from production code itself (e.g.writer (serialise concurrent Store::commit() calls),elector (roll version and history back to tx_id)), not just an opaque mutex handoff.Status: two known-bad scenarios are currently red on purpose (tagged
NOTE_REJECTED_COMMIT_STALLat the specific broken assertions, with one canonical explanation of the underlying bug), reproducing a real gap in how a commit in flight interacts with a concurrent election: a transaction rejected for a stale view can leave a local write applied to the Store that never reaches consensus, and subsequent ordinary commits can keep failing to replicate until a further election restores agreement. Random sampling shows this is reachable via a wide swath of the interleaving space, not one freak timing (though note: uniform sampling over the abstract schedule space is not representative of real-world timing likelihood — it says something about the shape of the bug, not its production frequency). Both binaries build clean under TSAN and pass the existingkv_test/raft_test/history_testsuites unchanged.Known limitation flagged in-repo (
TODOindeterministic_scheduler.h): branching on every single lock/unlock makes the search space for any real-stack scenario enormous (millions to 10^26+ schedules), forcing reliance on random sampling rather than exhaustive search. The next planned step is a per-scenario filter — using the labels already threaded through — to let a test choose a small, targeted set of "interesting" decision points (e.g. specifically the unlock ofversion_lockinStore::commit()) while everything else fast-passes through unbranched. Intended workflow: random search over the full space to find violations, promote each violation to a pinned deterministic regression test, then fuzz narrowly around those known points for cheap ongoing coverage.Note for reviewers: this PR is for discussion/sharing, not merge-ready —
scripts/ci-checks.shwill fail on the intentionalTODO:comment (repo convention normally bansTODO/FIXMEoutright) and on the two deliberately-red test scenarios described above.Robot verbose explanation
Motivation
We currently have no test suite that drives a real
ccf::kv::Store, a realaft::Aft, and a realccf::MerkleTxHistorytogether under concurrency. Every existing suite stubs at least one of the three:kv_test— realStore, but consensus is always a stub (StubConsensus/PrimaryStubConsensus/etc.), andTxHistoryis absent or hand-rolled.raft_test— realAft, but the store isLoggingStubStore, with no real KV semantics or conflict detection.history_test— realStoreandMerkleTxHistory, but consensus is a hand-rolled, single-threaded fake.This gap matters: a prior investigation this session found and fixed a real bug in
Store::commit()'s interaction withStore::rollback()under a concurrent view change, and confirming it required building bespoke pause/rendezvous machinery by hand rather than reusing anything. This PR is the generalized version of that machinery, built as its own suite rather than living insidekv_test.This is additive only. No existing stub classes or test files are touched, consolidated, or removed — they remain legitimate, narrowly-scoped unit tests for their own layers.
What's in
src/commit_concurrency/Two complementary test binaries, both wired into
CMakeLists.txtviaadd_unit_test(...)withDETECT_DEADLOCKS, both under a newconcurrencyCTest label:commit_concurrency_test— real OS threads, real timing.interleaving.hprovides aCheckpointprimitive: any thread can pause at a named point and a controller thread decides when to release it, generalizing the ad hoc pattern from the original bug investigation into one reusable, named primitive.threaded/deterministic.cppports the two hand-pinned scenarios from that investigation across as a harness sanity check, plus additional scenarios covering election-churn variations your review specifically asked about (stepping down to backup vs. reaching candidate again mid-commit).threaded/fuzzer.cpplayers a randomized multi-actor fuzzer on top: N writer threads, an election-churn actor, and a reader thread continuously pollingstore->current_txid()againsthistory's reported state — mirroring the actual production access pattern infrontend.hthat made the original bug externally observable. Seeded (std::mt19937), logged on failure for reproducibility.commit_concurrency_model_test— a deterministic, single-process scheduler for exhaustive/random exploration of interleavings, rather than relying on real OS thread timing to hit a specific ordering.ccf::pal::Mutexitself is wholesale-swapped for a scheduler-aware type across the whole binary via-include(interleaving_lock_override.h), so every real lock instore.h/raft.h/history.hparticipates without any call site opting in.ccf::tasks' own sources are recompiled into this target (not linked from the precompiledccf_tasks.a), because it keeps a process-wide singleton job board that needs the same scheduler-aware locking to stay consistent across explored schedules.deterministic_scheduler.htreats every lock acquisition and release as a decision point (not just contended ones — see "Known limitations" below), plus explicityield_point()s for branching at points with no lock at all.ccf::pal::unique_lock<LockType>(new, ininclude/ccf/pal/locking.h) is a labeled drop-in replacement forstd::unique_lock/std::lock_guard, applied at all 62 real lock call sites across the three production headers. A failing schedule's trace shows real, human-readable reasons a lock was held (e.g.writer (serialise concurrent Store::commit() calls),elector (roll version and history back to tx_id)), not just an opaque sequence of mutex handoffs — this was deliberately built to make failures diagnosable by a human, not just a pass/fail signal.explore_all_interleavings()exhausts a scenario via depth-first search with replay;explore_random_interleavings()samples a fixed, seeded number of schedules when the space is too large;estimate_schedule_count()gives a cheap random-walk estimate of a scenario's true size before committing to either.model_checked/rejected_commit_stall.cppreproduces the same bug as the threaded suite, but via this exhaustive/random mechanism instead of hand-pinned timing.Four production headers (
kv/store.h,consensus/aft/raft.h,consensus/aft/impl/state.h,node/history.h) gained aCCF_STATIC_LIBRARY_BUILD-guarded#error, enforcing they can never be compiled intoccf_kv/ccf_tasks/ccfcrypto— this is what makes it safe forcommit_concurrency_model_testto recompile them under a different lock type without an ODR violation, and it's enforced at compile time, not just by convention.Current status: two scenarios are deliberately red
Per explicit direction this session: tests should assert the correct expected behavior and fail honestly when that behavior isn't yet implemented, rather than being skipped or asserting the current (buggy) behavior. Both
deterministic.cppandrejected_commit_stall.cpphave aNOTE_REJECTED_COMMIT_STALL-tagged test/assertions documenting a real gap: a transaction rejected byStore::commit()for a stale view can leave a local write applied to the Store with no corresponding entry ever reaching consensus — and, worse, every ordinary transaction committed afterwards can keep succeeding locally without reaching consensus either, until a further election restores agreement.Randomly sampling the model-checked version of this scenario (500 samples each, two variants) shows 733 of 1000 sampled schedules that reach the risky state go on to violate the invariant. That's evidence the bug is reachable through a broad part of the interleaving space, not one narrow, contrived ordering — though I want to flag explicitly that this is not a claim about real-world frequency: the scheduler samples uniformly over abstract decision points, which has no relationship to real timing (a lock held for nanoseconds vs. an election taking milliseconds of network round trips). Treat it as evidence about the shape of the bug, not its production likelihood.
Validation
kv_test,raft_test,history_testall pass unchanged (28k+/1M+/46 assertions respectively).commit_concurrency_testandcommit_concurrency_model_testboth build and run cleanly, producing the two known-bad results deterministically across repeated runs.-DTSAN=ONwith zero data race warnings — meaningful given the whole point of this suite is real concurrent access to shared production state.scripts/cpp-format-checks.sh,copyright-checks.sh,includes-checks.sh, andascii-checks.shall pass.What's deliberately not merge-ready, and why this is a draft
scripts/todo-checks.shwill fail. There's an intentionalTODO:comment indeterministic_scheduler.h(repo convention otherwise bansTODO/FIXMEoutright) describing the single most valuable next step, left in deliberately for this PR's discussion rather than filed elsewhere and forgotten.ctestwill report failures for this suite until the underlyingstore.hbug is fixed.Known limitation and the planned next step
Branching on every lock/unlock (rather than only contended ones) makes the search space for any real-stack scenario enormous —
estimate_schedule_count()reports roughly 16.7 million to 4.4 trillion schedules for the single-writer scenario alone, and up to ~10²⁶ for the two-writer one. That forces reliance on random sampling rather than exhaustive search for anything beyond toy scenarios, which is a real loss of rigor compared to what exhaustive search would give.The planned fix (see the
TODOindeterministic_scheduler.h): treat every lock/unlock/yield_point()as only a candidate decision point, and let a per-scenario predicate — matched against the semantic labels already being reported, so no further production code changes are needed — decide which of them actually branch the search versus fast-passing through unchanged. This lets a test dial the search space down to exactly the handful of points it cares about (e.g. specifically the unlock ofversion_lockinStore::commit()), rather than an all-or-nothing choice between "every lock branches" (intractable) and "only explicityield_point()s branch" (may silently miss semantic-lock-ordering bugs). The intended workflow once this exists: random search over the full, unfiltered space to find violations, promote each violation into a deterministic regression test pinned to its exact decision sequence, then fuzz narrowly around those known points for cheap, ongoing, targeted coverage.Explicitly out of scope for this PR
Consolidating or replacing the existing stub zoo (
StubConsensusfamily,LoggingStubStore,DummyConsensus/CompactingConsensus/RollbackConsensus) is a separate, higher-risk decision, not bundled here.