Repository navigation
Replies: 1 comment
Revision 1: the harness and the network move to bedrockz5labs/bedrock#628 proposes the half of this proposal that doesn't depend on humus: the harness, as a new bedrock module This revision builds on that proposal. humus keeps the models of its packages, its hooks, verify mode, its telemetry and its CI. Eight things change:
Each section below replaces or adds to the part of the same name in the post. The rest of the post stands. The note at the topA third paragraph is added:
SummaryThe first bullet becomes:
In the bullet on faults, " The example becomes: func TestOrders_simulation(t *testing.T) {
sim.Run(t, func(r *sim.R) {
broker := kafkasim.Broker(r, "kafka:9092")
orders := humussim.Service(r, "orders", app.Init,
sim.Replicas(1, 3),
sim.Env(map[string]string{"KAFKA_BROKERS": "kafka:9092"}),
)
m := newOrdersModel()
sim.Workload(r, m, placeOrder, cancelOrder, produceEvent)
r.Check(kafkasim.AtLeastOnce(broker, "orders"))
r.Check(m)
r.Lifecycle(orders, sim.Crashes(), humussim.Rollouts())
})
}PackagesThe table becomes:
The paragraph under it becomes:
A simulation test
One runSteps 1 to 4 and 6 to 10 are bedrock's
A sweep's seeds come from Determinism at the seamThe table and its four rules are bedrock's. Three rows say what humus adds:
The row for franz-go's RNGs stands. The reproducibility claim: two run modesThe claim and the run modes are bedrock's (z5labs/bedrock#628, question 1), and humus claims nothing beyond them. What this section says of humus's bugs stands: #959 failed only at Nodes and lifecycleThe events are bedrock's. humus adds two things through
The networkThe network is bedrock's hostsim, with everything this section said: the cut at What end users' code must doThe rules are bedrock's, in its "What an App's code must do", and rule 1's Host is Conflicts with prior decisions
The rest stand. TestingUnder "How the toolkit is tested":
PhasesPhase 1 builds on bedrock's phase 1. That brings the seeds, the swarm, the run modes, the self-check, hostsim delivering at once, nodes, liveness and the canaries. humus builds:
Its first users and pins don't change. Phase 2 builds on bedrock's phase 2. That brings the network's seeded schedule, its faults and controls, and crashes. humus builds:
Its first users and pins don't change. Phases 3 and 4 don't change. Migration from today"humustest,
Open questions
Questions 5, 6, 8 and 9 stand. Out of scope"Changes to bedrock or Referencesbedrock's simulation testing, host and retry proposals are added. |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Important
Revision 1 moves the harness and the simulated network to bedrock, in z5labs/bedrock#628. humus ships
humussim,kafkasim,grpcsimandkubesimon bedrock'ssim, with its models, hooks, verify mode, telemetry and CI. Questions 1, 2, 3 and 7 move to bedrock, conflict C6 is dropped and C11 moves there, and phases 1 and 2 build on bedrock's.Note
A new feature for humus. It isn't part of the re-evaluation in Proposal: humus on the bedrock process model, so it has no wave. It builds on accepted proposals: the lifecycle, OpenTelemetry without globals, humustest, queue processing and the Kafka runtime, failure injection and Kubernetes controllers. It works next to Proposal: finding flaky tests.
As a feature request it conflicts with some earlier decisions, among them that humus writes no fakes of other projects' servers. Conflicts with prior decisions names each one, for debate here.
Summary
testing/synctestbubble: the end user's services, their peers and the dependency fakes, each a node on a simulated network that it reaches through itshumus.Host. A harness owns the seed, the plan (the swarm, the workload, and faults and lifecycle events at fake times), the delivery of every network chunk and the checks.net.Connup: TLS, HTTP/2, gRPC, franz-go and client-go. That cut is lower than TigerBeetle's, and keeps in scope the transport and shutdown code where humus's recent bugs were (openapi/v3: over HTTP/2, a write that fails because the client closed its connection is logged as "response failed" #1144, grpcclient: a rollout of a dependency served by grpcserver opens the breaker of a Client with five streams open to the pod #1176, example/wallet: a rollout of the ledger can end a watch with LEDGER_UNAVAILABLE, when the wallet opens the ledger's watch again on the pod that's shutting down #1254).fault's own edge model.fault,bedrock/fitand bedrock don't change.humus.verifybuild tag, telemetry and leak checks. Liveness is checked after a healthy core is healed, within bounds computed from the nodes'Settings.go test: a seed per subtest, printed with a replay line before the run, eight failure classes, a trace inT.ArtifactDir(), and Go's fuzz engine to store and shrink inputs. CI keeps a deterministic gate, and fresh seeds run off it.Background
TigerBeetle's simulation testing
The VOPR runs a whole TigerBeetle cluster in one process, on one thread, as a function of a seed. Its essentials, from TigerBeetle's source at
0450c32band its own posts:TigerStyle supplies the oracle and the bounds: assert invariants and the negative space, put a limit on everything, handle operating errors and assert programmer errors.
Two findings shape this proposal:
What Go gives, and what it doesn't
testing/synctestgives a bubble with a fake clock. The clock advances only when every goroutine in the bubble is durably blocked. The bubble also detects deadlocks and goroutines left behind. Everything made inside the bubble runs on that fake clock, dependencies too: franz-go, kfake, grpc-go keepalives, net/http timeouts, client-go's clock, the OTel batcher, and bedrock's retry and circuit. The cost follows events, not simulated time. 24 simulated hours of heartbeats took 25 ms of wall time, and an HTTP workload took about 1 s per simulated hour.A bubble doesn't order goroutines. They run on the normal scheduler across every P. Timers due at the same instant are randomized on purpose (
runtime/time.go:711-717, golang/go#73876), and the Go team declined a reproducible bubble scheduler (golang/go#67434, golang/go#73850). In experiments on Go 1.27:selectand map order gave a different history every run.-race.Every route to exact interleaving replay in Go leaves the stock toolchain, or imposes a programming model on the code being tested:
globalRandgolang/go#67690;humus's end users write ordinary Go handlers on the stock toolchain. So this proposal builds on synctest, and makes determinism a property the harness checks rather than one the runtime enforces.
What humus has today
Most of the parts are already here:
synctest.Testcalls in 168 test files.humus.Hoston every dial and listen. No humus package hard-codesnet.Dial,net.Listen,http.DefaultClientor a resolver outsideOSHost.faultonbedrock/fitfor request-scoped faults.Target.What's missing is everything that makes a test a simulation:
The bugs that got past it
Each of these escaped humus's current tests, or was found only by a flaky CI run or a reproduction someone built by hand:
Testing says which of these the design finds, and which it replays.
Proposed API
The sketches below show the shape and the roles of each part. Their exact signatures come from the phase 1 prototype (open question 11).
Packages
simSource, the seed tree, the swarm, nodes and lifecycle events, the simulated network and its fault schedule, the workload driver, the checker and reporter interfaces, the liveness phase, the trace and history hash, the failure classes and the replay line. It also holds the models for the root module's packages: the lifecycle, httpserver, httphealth, openapi, httpclient, health and job.grpc/grpcsimqueue/kafka/kafkasimkfake.FaultandControlKeyat fake times, and readsGroupInfofor the commit oracles. It derives the queue model and the Kafka liveness bound fromkafka.Settings.kube/kubesimkube/export_test.go. It also holds the API-server fidelity reactors (open question 5) and the controller models.internal/simhooksimpackages set. Every humus module can import it, asgrpc/grpcauthimportsinternal/authntoday.humus.verifyconst, compiled out of default builds.Labels and attributes take the
humus.sim.prefix:humus.sim.seed,humus.sim.nodeandhumus.sim.class.humus.Host's interface doesn't change, and neither do humustest's documented semantics,fault, bedrock orbedrock/fit.A simulation test
The end user writes four things:
sim.Rungives each seed one bubble and one subtest.sim.Fuzzruns the same body on Go's fuzz engine, so its corpus stores the failing inputs and its minimizer shrinks them:A scenario test is the same harness with a fixed seed and scripted events:
One run
HUMUS_SIM_SEEDSor as a fuzz input. Before the run, the harness writes one unbuffered stderr line with the seed and the replay command. It also sets ahumus.sim.seedpprof label and emitsT.Attr. Each survives a different failure: a panic, a-timeoutkill, or-jsonoutput.cryptotest.SetGlobalRandomon the outert(open question 2). One TLS 1.3 transcript came out of 30 processes.Appvalue with humustest.Start's semantics. It gets its own root context, a node-scoped Host, and ahumus.sim.nodepprof label.synctest.Wait();t.Errorf, with its class.T.ArtifactDir();Determinism at the seam
humus.HostDialFuncandLookupFuncdo in the walkthroughsbedrock.ProcessAppvalues, because the Mains callos.Exitselect, map order-raceexplores itrand.Textcryptotest.SetGlobalRandomsdktrace.WithIDGeneratorsim's telemetrytime.Now()bedrock/retry.Exponentialmath/rand/v2drawsFour more rules make the seam hold:
H(root, label path), so adding a component or a dimension doesn't reshuffle the other streams. Draws stay on the driver goroutine. The harness turns aSource's output into values with its own functions, becausemath/rand/v2promises no stable output forRand's methods across releases.crypto/tlsand HTTP/2 with the seam scheduler, a run gave 1 trace per seed in 300 runs at GOMAXPROCS 32, 4 and 1, bytes included. When the workload's own goroutines started 10 requests at once, it gave 284 to 292 distinct traces in 300.The reproducibility claim: two run modes
-race, several Ps-cpu=1, no-race,GOGC=off, a fresh process,-count=1, the recorded toolchain throughGOTOOLCHAIN-cpu2 and 4selectwith several ready casesA tie-free run replayed exactly across processes, under
-coverand-gcflags=all=-N -l, and on go1.26.5, 1.27.0 and 1.27.2.dlv test … -- -test.run 'FuzzX/<id>'stopped inside a bubble. A breakpoint stops real time, not fake time.The toolkit claims three things, no more:
The run's identity is printed with every seed:
GITHUB_SHA, plus the head and base SHAs on a pull request, orgit rev-parseand whether the tree is dirty, because test binaries carry novcs.*build info;-cpu,GOGC,GODEBUGand-run.Nodes and lifecycle
App.Runon a fresh bedrocktest Process, with its own root context and node-scoped networkterminationGracePeriodSecondsdoesGo can't kill a goroutine, so an in-process crash leaks: in an experiment a Kafka Processor made its side effect 0.5 s after its consumer "crashed". So an edge counts as crashed only if the toolkit revokes it. Each node gets a crash context that the end user's in-memory fakes consult, so a zombie's calls fail. A zombie that can still reach an unfenced edge is reported as a partial partition, not a crash.
A restart isn't a fresh process. Package-level state survives it, and the docs say so.
The network
The cut is at
net.Conn, throughhumus.Host. It is the only cut that keeps #1144, #1176 and #1254 in scope, and it is where humus already cuts. A RoundTripper, a Handler, bufconn or a library's API stay useful for unit tests of logic above the transport, but they aren't transport simulation.The network is new, in
sim. It shares humustest's internals and leaves humustest's documented semantics alone (see conflict C11). It is made of nodes, each a Host, joined by directed links. Each link's latency, jitter bound and buffer are drawn per seed. The network starts no goroutines. An 11-minute partition took 17 to 37 ms of wall time.Its semantics are TCP's, each checked by a conformance test against loopback, as humustest's
conformance_test.goalready does:*net.OpErrorwrapping*os.SyscallError.Faults are only those TCP can produce: stalls (clogs and partitions, which queue bytes and lose none), resets, RST on close, silent peers, silent connections, refused or black-holed dials, paused listeners, slow readers, short reads, and a partial write followed by an error. It never injects loss with the stream going on, duplication, reordering, truncation followed by more bytes, or corruption. Vortex removed its truncation fault after it caused a false failure.
Protocol faults come from real peers through their real APIs. GOAWAY comes from
http.Server.Shutdownor grpcserver's Shutdown. RST_STREAM comes from a client that cancels, and a gRPC status from a handler. Nothing comes from a frame-level fake peer.Zero latency stays in the swarm. With 1 ms of modelled latency, #1144 never happened, because fake time froze while the busy handler ran.
Scripted controls are typed values in
sim. Each has a "hold until" form, and each is recorded in the trace:humus hand-builds three of these per test today: held reads, ClusterIP routing and a handshaking connection.
Dependencies
kfake.Fault,ControlKeyand topology changes at fake times. Simulations build with franz-go's-tags synctests, which removed every mutex hang: 0 in 600 seeds, against 8-10% without itresourceVersion, and gave a false split brain (open question 5)*pgconn.PgErrorwith a SQLSTATE,pgconn.Timeoutand unexpected EOFEvery stand-in inside the simulation keeps a differential test against the real dependency, which shows where they differ. That is the guard against TigerBeetle's false-positive lesson, which the research reproduced on humus's own stack.
Faults and the envelope
One seeded schedule of timed events carries:
faultandbedrock/fitdon't change.The envelope is composed.
Settingsthe node runs with.ARolloutOfTheWallet's exactly-once effect. They also state their assumptions, such as "my Processor is idempotent".The package docs are precise enough to derive the safety envelopes. They give no time bounds for progress after a dependency heals, which the pieces add.
Beyond the envelope:
Workloads and models
One decision
Sourcehas three feeds:go test -fuzzwith its corpus and shrinking;humus's package models:
:info, which a retry with the same idempotency key must resolve to one effect.Oracles don't depend on order wherever humus allows concurrency. They compare multisets with counts, per-key and per-partition order, final states and per-request outcomes. Even at GOMAXPROCS=1, six spans ended in 249 distinct orders in 300 runs.
Visibility has three levels:
GroupInfo.#757's rule needed only the third. RabbitMQ offers none, so its oracles stay on the client side.
Telemetry is the event feed. The oracles read per-request outcomes, processing events, breaker and retry decisions, recovered panics and fault evaluations from it. They read it with seeded IDs, simple processors and AlwaysSample. humus adds no production telemetry just for tests, because dashboards and alerts depend on what humus emits.
Recovered panics fail a run by default: "handler panicked", "RPC panicked", "operation panicked", "reconcile panicked" and "the KeySet panicked". The end user can override this per service.
Checks, hooks and verify mode
humus doesn't own the process, so a failed invariant is never a crash in production:
group.failalready does inqueue/kafka/service.go.humus.-named ERROR record and span event, and humus goes on.Hooks are nil function fields in
internal/simhook, with no measurable cost in production:Verify mode. Expensive checks sit behind
const verify, under//go:build humus.verify. They are compiled out by default, with 0 symbols left in a default build. Code underif verifyhas no side effects. humus's unit tests run with and without the tag, and the heaviest checks are sampled.Liveness
Settings. Every humus background loop is "capped backoff, unlimited attempts", so "not yet" and "never" separate after a bound computed from the drawn settings. Simulation configurations shrink the caps through existing settings: kube's per-object 1000 s, grpc-go's 120 s reconnect, RabbitMQ's 30 s.The swarm
Settingsfields documented as tuning, within theirCheck*ranges.Delivery,Classify, topology,HTTP2Onlyand RabbitMQConcurrencybelong to the end user.Failures, traces and minimization
t.Errorffrom any goroutine in the bubble, thenT.Attr("humus.sim.class", …)-timeoutThree classes kill the process, so a fleet runs seeds in supervised processes: the fuzz engine's workers, or a runner that reads test2json.
A trace has one line per state change: the fake elapsed time, the node, the component's
humus.name, the event, andkey=valuepairs. It is written toT.ArtifactDir()only on a failure, and its last lines are printed.Minimization.
uint64seed is recorded, never shrunk. Coarse minimization covers it: keep the fastest failing seed per signature, truncate the step budget, and knock out swarm dimensions.A fixed seed's regression form is a corpus entry that lands with the fix. A hand-written scenario test replaces the entry once it stops pinning the failure.
What end users' code must do
Setup.Host. Their own clients take its dial and resolver. Peers are addressed by name. Code doesn't rely on*net.TCPConn.tls.Config.TimeorRand.t.Parallel.math/rand/v2draws as free nondeterminism. The self-check reports when they leak into the history.These rules are documented, not enforced by an analyzer. The watchdog and the self-check report violations at run time.
humus's own packages and the walkthroughs
queue/kafka-tags synctestsShutdownCause, not just the exit codegrpchttpserver,openapi/v3,httpclientkubeauth/jwt,fault/jsonscenario, openapi parameters,retryAfterandpushback, health's And and Or, the queue header carriersSource: codec round trips, differential checks and models. humus has nofunc Fuzztarget todayprojector.Foldas a differential oracle for humus's machinerysim's nodes and networkkubesim's client seam landsThe existing synctest tests and shared cases become scenario tests in place. They gain seed control, a trace and a replay line, and aren't rewritten.
CI
go testalready runs corpus entries.-racejobs, because-racerandomizes the scheduler.main, and in a small scheduled fleet. A fleet of eight shards for 6 hours is about 8 core-days a night, against TigerBeetle's roughly 1,000 core-days a day. The binding limit is the Free plan's 20 concurrent jobs, which a CI run already shares among 54 jobs.GITHUB_SHA, the event, the job and an index, and are always printed. The merge group's SHA is the SHA that lands onmain, so a seed from the SHA alone would run twice on the same code.-race, to show its rate.Design decisions
net.Conn. TigerBeetle cuts higher because its protocol tolerates any message fault. humus's protocols don't, and its bugs live above the cut.sim, not a changed humustest. The existing tests keep their meaning.faultand fit don't change. The failure injection proposal's rule against "failing a dependency for every request" is about production traffic, and a simulation's environment faults never pass through fit.Settingschange, and end users don't re-derive AtLeastOnce from doc text.utilruntime.ErrorHandlersbackoff, initialised at real time, blocked a caller for about 27 fake years, sokubesimneutralises it.Conflicts with prior decisions
Each conflict names the decision, where the design bites, and the case for it.
sim,kafkasim,grpcsimandkubesim. Case: Proposal: queue processing and the Kafka runtime #683 declined a Kafka test package because "the tests' helpers are a few lines each". The Kafka envelope is oracles Q1-Q9 over kfake's commit history, which queue/kafka: a fenced-out member's late commit can move the new owner's committed offset #757 shows takes humus's own expertise. Hooks stayinternal/(open question 4).humus.verifytag and franz-go's-tags synctests. Case: a build constraint isn't a declaration language. Schedules and controls are typed values.cryptotest.SetGlobalRandomis process-wide; test binaries replaceutilruntime.ErrorHandlers; OTel's error handler, set by everyApp.Run, sends one App's errors to the last-started App's stderr. Case: these live in test binaries only. The real-time-initialised backoff goes upstream, and the OTel handler is routed per App in phase 1.math/rand/v2globals. Case: open question 3 seeds it without a bedrock change.-race, in CI, triage-bug and the backlog. Case: replay-mode jobs are added and the-racejobs stay.kube/export_test.go'sSetClients). Case: end users can't run a whole controller in a bubble the way humus's tests do.kubesimcloses that gap without a production option.sim(open question 7).humus.FSreturns*os.File, so no fault-injecting filesystem is possible. Case: keep it. Filesystem simulation is out of scope, and file-change events use real files.t.Parallel. Case: a practice, not a decision. A case takes 10-90 ms, and unseeded scenario tests stay parallel.Testing
The evidence
No prototype of the toolkit was built. The research behind this proposal ran throwaway experiments on Go 1.27, with humus at 08b44fa, franz-go v1.22.2 and client-go's fakes. They aren't kept. These are the results the design rests on:
queue/kafkaService on kfake, against a copy with queue/kafka: a fenced-out member's late commit can move the new owner's committed offset #757's fix reverted.kfake.GroupInfo, found a rewind from 23 to 5.-race.crypto/tlsand HTTP/2, it gave 1 trace per seed in 300 without-raceand 2-3 per seed with it.humustest.Hostwith real humus services.-race). Running each seed twice caught it in 33 of 100 processes.-raceon one CPU. With 1 ms of latency it never happened.-cover, under-gcflags=all=-N -l, on three toolchains and under Delve.humus.verifyconstleft 0 symbols in a default build.What it would have caught
"Found" means the design's swarm and oracles reach the trigger. "Replayed" means the seed reproduces the failure. Only #757 was run against the design's pieces. Every other row is inferred from the bug's triage record.
kfake.Faulton ListOffsets forces the triggerSo:
How the toolkit is tested
-tags conformance.humus.verify.Phases
One proposal and one milestone. Its stories make the cross-package changes: hooks, verify checks and invariant reporting in queue/kafka, kube, the servers and the clients.
Phase 1: the harness, on Kafka.
Sourceand its feeds, the seed tree and the swarm;internal/simhook, the invariant policy andhumus.verify;Settings;kafkasimwith oracles Q1-Q9, the lifecycle model, and orderstatus's vocabulary, generator and model.Phase 2: the network and crashes.
grpcsim, the HTTP models, wallet and shoppingcart.Phase 3: dependencies and envelopes.
kubesim, with the client seam, the fidelity reactors and the controller models;kube, databases and billing.Phase 4: CI, fleet and reporting.
GOCOVERDIRandcovdata;Phase 3 needs phase 2 only for crash events, so the two can run in parallel once phase 1's interfaces land.
Migration from today
Nothing breaks.
fault, bedrock and fit keep their behaviour.simsees no change.Open questions
Is the three-part reproducibility claim acceptable? As proposed: the plan is exact, the history is exact when the self-check says so, and interleaving inside a node is statistical, with explore and replay modes. The alternatives:
Recommendation: as proposed. The alternatives take end users off the stock toolchain, or are commercial and outside-in.
Seed crypto with
cryptotest.SetGlobalRandom? It is process-wide, forbidst.Parallel, and is unsupported with the FIPS v1.0.0 module.Recommendation: (a), or (b) if the no-secrets rule is read strictly (C7). Seeded keys and certificates make traces comparable.
humus's retry jitter in simulation?
retry.Exponential.Recommendation: (b). Phase 1 measures how much jitter moves the history.
Where does the toolkit live? As proposed:
simin the root module, pluskafkasim,grpcsimandkubesimin their modules. The root module can't import kfake or client-go. The alternative extendshumustestand the*servertestpackages instead. Recommendation: as proposed, which answers Proposal: queue processing and the Kafka runtime #683's reason (C2). The names are open too:simcould besimulation.Ratify "fidelity corrections to an upstream fake"?
Recommendation: (a). Without it, kube fault simulation reports a split brain that can't happen (C1).
RabbitMQ in the deterministic tier?
queuecontract (redelivery, duplicates, a crash mid-processing) that skipsrabbitmq.Service.Recommendation: (a). TigerBeetle leaves its own AMQP integration outside its simulator too. The mailer and end users' RabbitMQ services aren't simulated whole.
Should the simulated network replace humustest's semantics? As proposed, it is new, in
sim, and humustest stays as it is (C11). The alternative makes humustest TCP-faithful, which leaves one network to keep but changes documented behaviour. Recommendation: as proposed.Where does exploration run, and who posts failing seeds?
mainand in a scheduled fleet. CI posts a deduplicated Bugs discussion from the fleet only, which needsdiscussions: write.Recommendation: (b). A fresh-seed gate would block pull requests on bugs they didn't cause (C10).
Restructure the walkthroughs' shared cases into a vocabulary, a generator and a model?
Recommendation: (a). The
Targetseam already lets one vocabulary run in the bubble and on kind (C13).One proposal in four phases, or four proposals? Four would give smaller reviews, but phase 1's interfaces would then be decided before the network and kube needs that shape them were visible. Recommendation: one proposal, whose phases order its stories.
Prototype phase 1 before the stories are filed? Three things the design rests on are unmeasured or untried:
humustest.Hostwith real humus services;A prototype of the harness on
queue/kafkaand orderstatus would settle them, and fix the sketches' signatures. Recommendation: yes, posted as a revision before stories.Out of scope
queuecontract model (open question 6).bedrock/fit.References
0450c32b: TIGER_STYLE.md, ARCHITECTURE.md, the VOPR's docs,src/vopr.zig,src/testing/andsrc/vortex.zigtesting/synctest,testing/cryptotest, and the issues golang/go#67434, golang/go#73850, golang/go#73876 and golang/go#67690synctestsbuild tag inpkg/kgo/internal/xsyncAll reactions