RaftConfig
Parameters
Lower bound of the randomised election timeout window. Validated at construction to be under electionTimeoutMax by at least one whole millisecond — see the constraints above.
Upper bound of the randomised election timeout window.
How often the leader sends a heartbeat. Validated at construction to be strictly less than electionTimeoutMin, and to be at least one whole millisecond — see the constraints above.
When true, throw IllegalStateException at construction time if the owning kotlinx.coroutines.CoroutineScope contains a kotlinx.coroutines.test.TestDispatcher. When false (the default), emit a warning to stdout instead. Set to true in tests that want to assert the guard fires. Leave false in production — the guard is informational there.
Suppresses the TestDispatcher warning (see strictTestGuard) for tests that intentionally run a real RaftNode under a TestDispatcher (both StandardTestDispatcher and UnconfinedTestDispatcher are supported). Under any TestDispatcher, delay() is virtual — the engine's election/heartbeat loops tick via the test scheduler. Has no effect in production. Default false: warn as usual.
The :kuilt-raft suite uses StandardTestDispatcher (see RaftTestFixtures). Set true in any config used by a test that constructs a real RaftNode. NEVER set in production code.
Wall-time threshold for a propose round-trip (from accepted to applied). When the elapsed time exceeds this threshold, the engine logs at warn level. Below this threshold, the log entry is at debug level. Set to Duration.ZERO to treat every propose as slow (useful in tests that want to assert the warn path fires). Default 100ms — appropriate for LAN clusters.
Upper bound on the raw state bytes carried in a single §7 InstallSnapshot chunk. A fabric with a tighter framing limit shrinks chunks automatically: when the transport publishes a RaftTransport.maxPayloadBytes, the engine takes the lesser of this and what that budget leaves once the envelope reserve and CBOR's byte-array expansion are paid. The expansion matters — CBOR renders a ByteArray as an array of integers, so raw bytes cost up to two on the wire and a chunk sized against the budget directly would encode to roughly twice it (#2150).
Upper bound on the bytes a follower will accumulate reassembling one §7 snapshot. snapshotChunkCeiling bounds a single chunk; this bounds their sum. The sender chooses done, so without it a peer that keeps sending well-formed non-final chunks grows the follower's buffer without limit until the process dies (#1881). A chunk that would breach it discards the in-flight reassembly — the overshoot is never allocated — and the follower re-advertises offset 0, so an honest leader restarts the transfer.
The default is 64 MiB, chosen from three directions rather than picked:
Above it, the protocol cannot deliver anyway. The transfer is stop-and-wait — one chunk in flight per peer, await-ack-then-next — so 64 MiB at the default 16 KiB chunk is 4096 round trips: a few seconds on a 1 ms LAN, well over a minute on a 20 ms mobile link. A larger ceiling would bound nothing reachable.
Below it, no legitimate snapshot lives. A snapshot is the application's state machine; the state kuilt's own consumers replicate is kilobytes to low megabytes. 64 MiB leaves three to four orders of magnitude of headroom.
It is sized for the smallest target, not the largest. kuilt runs on wasmJs and iOS as well as JVM servers. Peak follower heap over an install is roughly twice the ceiling (the reassembly buffer plus the copy handed to the installer), so 64 MiB means ~128 MiB — survivable in a browser heap or a mobile process, where a gigabyte is not. A server consumer that genuinely needs more raises it; that is a supportable outcome, and a loud rejection beats an out-of-memory kill.
The type is Int, mirroring snapshotChunkCeiling, which also means the ceiling can never be configured above what a single ByteArray can hold.
How far above this node's own term a frame may claim to be and still be admitted (#1897). A frame whose term exceeds currentTerm + maxTermJump is dropped at the dispatch boundary, before any adoption.
The bound is relative, and that is the whole point. Bounding the term's value against a constant A cannot avoid a cliff: closing it would need T ≤ A ⟹ T + 1 ≤ A, true only for A = ∞, so the one value such a filter admits is the value whose successor every peer — including its own author — refuses. Bounding the jump admits currentTerm + 1 at every term, so no term has an unrepresentable successor. It is also the stronger test: an implausible term is implausible relative to what this node has seen, which is a local witness, where a constant is a guess about the deployment.
The default is 10 000, sized between the two failure modes it sits between:
Too small breaks rejoin. A node that was away while the cluster held many elections must still be able to adopt the current term. Terms advance once per election, so 10 000 covers an absence spanning ten thousand of them — hours of pathological churn, and orders of magnitude beyond an ordinary partition, which costs a handful.
Too large weakens the bound. The value is the attacker's step size: reaching the arithmetic danger zone near Long.MAX_VALUE costs roughly
2^63 / maxTermJumpaccepted frames. At 10 000 that is ~10^15 — infeasible — where the absolute ceiling it replaces cost exactly one frame.
A deployment whose members routinely miss more than 10 000 elections has a liveness problem this knob would only hide; the documented route back for a node that has genuinely fallen outside the bound is a new identity plus an ordinary membership change, never a wiped disk under the same one. See docs/raft-wedge-diagnosis-and-recovery.md.
Validated at construction to 1..2^20 (1 048 576), inclusive (#1972). A knob whose whole job is to bound a safety property must not be settable to a value that defeats it. The two ends are not the same kind of limit, and reading them as symmetric is the error to avoid:
The floor is a cliff, and it is at 0. A jump of exactly one is refused there (
1 > 0), so no candidate'scurrentTerm + 1is ever admitted by anyone and no leader can be elected again. Below 0 the subtraction exceeds the bound for every frame at or above our own term, and the node goes silently deaf — nothing logs abovedebug. This is a property of the arithmetic, not a judgement call: 1 is the smallest value that preserves liveness, so it must be admitted rather than merely non-negative.The ceiling is a chosen line on a continuum. There is no cliff at
2^20. The attack price2^60 / maxTermJumpdegrades continuously as the knob rises — at2^20 + 1the climb still costs about2^40frames, indistinguishable from the value chosen here — and the guard becomes literally vacuous, one frame reinstating #1833's cluster-wide wedge, only asmaxTermJumpapproaches2^60itself. So the ceiling is not where the bound stops bounding; it is the largest value at which the price is still guaranteed to be at least the2^40frames derived below, placed where that guarantee costs no honest deployment anything.
That guarantee is what the ceiling is chosen to hold, from both directions:
Attack cost. This value is the attacker's step size, so climbing from term 0 to the storage-path ceiling
RaftEngine.MAX_PLAUSIBLE_TERM(2^60) costs2^60 / maxTermJumpaccepted frames. At2^20that is2^40≈ 1.1×10^12 — over twelve days of uninterrupted attack even at a sustained million admitted frames per second, a rate far above what any real Raft peer processes. Before #1897 the same climb cost exactly one frame.This
2^60and the2^63in the default's derivation above are different thresholds and both are live — do not reconcile one to the other.2^60is where a running node can still be driven (adoption is relative now, so nothing refuses a term above it) but can no longer restart, becausecheckedRestoredTermrejects a durable term past it; the climb towardLong.MAX_VALUEcontinues from there, and is where the arithmetic itself breaks.Legitimate need. Terms advance once per election, so
2^20covers roughly 1.05 million missed elections — 100× the default above, and still some 29 hours of absence at a pathological ten elections per second. A deployment that genuinely exceeds it has the liveness problem described above, which this knob would hide rather than fix.
Throwing here is right, and is not in tension with RaftEngine.onMessage's refusal to throw on a malformed frame (#1818): this is local, deterministic, consumer-supplied configuration evaluated once at construction, not a value a peer controls on the actor loop.
Source of randomness for the election-timeout draw. Randomness is a dependency, like time: under virtual time a kotlinx.coroutines.test.TestDispatcher makes scheduling deterministic, but an unseeded RNG still injects non-determinism into the durations the engine waits. Production default is Random.Default. Tests that run under virtual time should inject a seeded Random(<fixed seed>) so every run draws identical election timeouts — making the whole engine deterministic. Multi-node tests must use distinct seeds per node so nodes draw different timeouts and symmetry-break into a leader; use MultiNodeRaftSim from :kuilt-raft-test which handles this automatically. NEVER seed in production: a fixed seed defeats the split-vote avoidance that timeout randomisation exists for.