August 23, 2023·4 min read·engineeringrqlitetestinggo

Repeated random seeds caused duplicate rqlite test identities

A shared random source addressed duplicate node IDs in rqlite tests. The failure illustrates how resetting a pseudorandom generator can invalidate a distributed test fixture.

Two test nodes assigned the same identity do not represent the cluster a test intended to construct. On August 23, Philip O'Toole described a rqlite testing failure in which a helper sometimes returned duplicate strings. The helper created a new random source from the current time on every call. Its output supplied test node IDs, so duplicate results damaged the fixture before the behavior under test could be interpreted reliably.

O'Toole suspected repeated clock values in the Windows testing environment, while explicitly retaining uncertainty about that diagnosis. His correction reused a singly seeded source. He reported improved test stability after the change. This is a report about removing accidental identity collisions, rather than a claim that the whole distributed test suite now supports deterministic replay.

I value rqlite's small operational model, and I want the tests behind that model to construct the systems they claim to exercise. A fixture that accidentally merges two intended identities can make an ordinary test look like evidence of a difficult consensus failure. The distinction matters before interpreting an intermittent result.

A seed selects a deterministic state

The Go 1.21 random package source documents Rand.Seed() as initializing a generator to a deterministic state. NewSource(seed) creates a pseudorandom source from its supplied seed. Recreating a source with the same seed therefore recreates its starting state, rather than continuing the sequence used by a previous call.

That behavior is useful when a test deliberately requests reproducibility. It is harmful when code assumes that repeated construction gives an independent identity each time. If two calls use the same seed and perform the same operations, both begin at the same point in the sequence. Increasing the length of the generated string does not fix that repeated-state problem.

A minimal illustrative example can isolate the mechanism without depending on clock behavior. The constants below are test inputs, not observations from the rqlite failure.

package main

import (
    "fmt"
    "math/rand"
)

func main() {
    first := rand.New(rand.NewSource(42))
    second := rand.New(rand.NewSource(42))
    fmt.Println(first.Int63() == second.Int63())
}

The comparison prints true because both generators begin with the same seeded state. This example demonstrates why repeated seeds can explain repeated output. It does not establish how often any particular clock returns the same value or prove that a particular CI platform caused the original failure.

That separation is important in diagnosis. The generator's deterministic initialization can be established from the API contract. The resolution and behavior of a clock in a specific environment need their own evidence. A repair can remove dependence on repeated initialization without converting a suspected platform behavior into a confirmed fact.

Sharing state changes the concurrency obligation

The rqlite random helper linked by the maintainer initializes a package-level generator once and guards its operations with a mutex. Its string helper consumes successive values from that generator. The same package also offers numeric helpers and duration jitter through the shared source.

That mutex has a specific purpose. Go's package documentation says that a Source and a Rand require synchronization when shared between goroutines. The convenience functions at package level are safe for concurrent use, but that guarantee does not automatically apply to a separately created Rand instance.

Moving a generator into shared state therefore changes two things. Calls stop resetting the sequence, and concurrent callers now need an ownership rule for the shared object. Guarding access supplies that rule. Omitting synchronization would exchange the original repeated-seed defect for an invalid concurrent-use pattern.

A shared generator still does not make arbitrary concurrent test execution reproducible. Which goroutine receives which value can depend on scheduling. Replaying a failing distributed execution requires control over more than the initial state of a random source. The change discussed here removes a particular source of duplicate fixture identities.

Identity uniqueness is a separate requirement

Randomness and uniqueness are different properties. A pseudorandom generator can produce a repeat without violating its purpose. A test that requires distinct node IDs therefore has a stronger fixture requirement than merely obtaining random-looking strings. The generator change addresses the reported repeated-initialization mechanism, but its output should not be described as a mathematical uniqueness guarantee.

For tests whose behavior does not depend on random identities, explicit distinct identifiers can express the fixture requirement directly. For tests that intentionally vary inputs, recording those inputs can help explain a failure. These are different test-design choices, and neither requires treating a random helper as a security mechanism.

Go's random package explicitly excludes security-sensitive use. The fact that a generator is seeded from time does not change that contract. Test node labels and credentials have different requirements, so a fix suitable for fixture identities should not be generalized into advice about generating authentication secrets.

I regard this failure as useful because it makes the fixture's assumptions inspectable. A test needs distinct participants before its observations can support claims about their interaction. Reusing a generator removes repeated startup state from that particular identity helper, while synchronization makes the shared generator valid for concurrent calls.