Repository navigation
Releases: hmdsefi/lockstep
Release list
lockstep v0.2.0
lockstep v0.2.0 runs lockstep.New() on a pool of worker goroutines, so turns of different actors run in parallel. It also adds event-sourced persistent actors, helpers for duplicate and stale messages, logs and metrics, more lockstep-vet checks, and a benchmark suite. Code that compiles against v0.1.0 compiles against v0.2.0, but lockstep.New() behaves differently in three ways, listed under Breaking changes. The README shows each feature with an example.
Requirements
- Go 1.26 or later.
- faultline v0.1.2, pinned in
go.modas in v0.1.0. Onlylockstep/simimports it; a program that does not importsimnever links faultline.
Modules and tags
This release tags two modules:
github.com/hmdsefi/lockstepatv0.2.0: the runtime,lockstep/sim,lockstep/persist,lockstep/persist/filestore, andlockstep/dedup.github.com/hmdsefi/lockstep/analysisatanalysis/v0.2.0: the analyzers,lockstep-vet, and the golangci-lint plugin.
examples/raft, examples/orders, and bench are modules of their own, built against the lockstep code in the same checkout. They are not tagged.
Breaking changes
The API only adds to v0.1.0. These three changes are in what lockstep.New() does at run time.
Turns run in parallel
In v0.1.0, one goroutine ran every actor turn. In v0.2.0, lockstep.New() runs turns on runtime.GOMAXPROCS(0) workers, so two actors that share memory instead of exchanging messages now race. The turns of one actor still never overlap.
Run your tests with -race, and run lockstep-vet, whose new LS200 and LS210 checks report memory shared through messages and package variables. Until the sharing is gone, WithWorkers(1) runs every turn on one worker:
sys := lockstep.New(lockstep.WithWorkers(1))Long turns fail
In v0.1.0, WithWatchdog was accepted and had no effect. In v0.2.0, a turn that runs for 5 seconds (DefaultWatchdogTimeout) is logged at Error with its worker's stack. When the turn returns, the incarnation fails with an error that matches ErrTimeout, and its supervisor restarts, stops, or escalates it.
Slow work belongs in an effect started with lockstep.Do. To keep long turns, raise the timeout with WithWatchdog(d) or turn the watchdog off:
sys := lockstep.New(lockstep.WithWatchdog(0))Memory admission follows the Go memory limit
In v0.1.0, WithAdmission was accepted and had no effect. In v0.2.0, when a Go memory limit is set (GOMEMLIMIT or debug.SetMemoryLimit), admission stops once the live heap reaches 90% of the limit and resumes when it falls to 80%. While it is stopped, Spawn returns an error that matches ErrOverloaded, and each Send and Ask to a user actor is handled as if the target's mailbox were full. Without a memory limit, nothing changes.
To turn memory admission off:
sys := lockstep.New(lockstep.WithAdmission(0, 0))Added
The production engine
lockstep.New()runs turns on a fixed pool of worker goroutines. Messages from one sender to one receiver arrive in send order, and timers and effect results run on the same workers.WithWorkerssets the pool size (defaultruntime.GOMAXPROCS(0)).WithThroughputsets how many user messages an actor handles in one turn before it yields its worker (default 32), and theSpawnoptionWithActorThroughputsets it for one actor.WithEffectPool(workers, queue)sets the bounds on effects started withlockstep.Do, which v0.1.0 fixed at 64 running and 1024 waiting.- Simulation checks these options and then ignores them: each turn there handles one message, in an order the seed decides.
Overload protection
- Memory admission:
Newsamples the live heap every 100 ms.WithAdmission(high, low)sets byte thresholds in place of the memory limit. Actors that calledctx.SubscribeOverloadget anOverloadedsignal when admission stops and another when it resumes. - Stuck turns: a watchdog reports every turn that has run for the
WithWatchdogtimeout. Until the turn returns, user messages to its actor are handled as if the mailbox were full, and aStopstops the actor once the turn returns, without a restart. - Goroutine count: once a second, the system counts the goroutines of the whole process. When the count reaches
WithGoroutineWarning(n)(defaultDefaultGoroutineWarning, 10,000), it logs one warning with the ten functions that hold the most goroutines.
Logs and metrics
WithLoggersets thelog/sloglogger for the runtime's events: failures and restarts, shutdown, the overload events, and, at Debug, full mailboxes, shed and dropped messages, and dead letters. Without it, the logger isslog.Default()as it is whenNewruns.ctx.Logger()returns that logger with the actor'snode,path, anduidattributes, andctx.Logflogs through it at Info.WithMetricstakes your implementation oflockstep.Metrics, whoseCounterandGaugeFuncmethods register series withMetricLabellabels. lockstep ships no adapter. Architecture lists every series and shows an example adapter.- In simulation,
ctx.Logger()andctx.Logfwrite to the node's faultline kernel log, so the lines go intotrace.jsonland the trace hash. In v0.1.0,ctx.Logfreached only the trace. Metrics are off in simulation.
Persistent actors (lockstep/persist)
persist.Spawnstarts an event-sourced actor: anEventSourced[C, E, S]whoseHandleturns a command into events and whoseApplyapplies an event to the state. lockstep writes the events to apersist.Journaland fsyncs them before it applies them and runs the callbacks registered withctx.Then. A reply sent from a callback therefore means the events are on disk.- A start or restart recovers the state from the newest snapshot and the events after it.
Config.SnapshotEveryandctx.Snapshotsave snapshots to apersist.SnapshotStore, andConfig.PruneEventsdeletes the events that the previous snapshot covers. persist.JSONis a codec for events and state. An actor can implementRecovererto learn when recovery finished, andStopperandSignalReceiverfor theStoppedhook and signals.persist/filestoreis the reference journal and snapshot store.filestore.Open(dir)uses the operating system's files. On recovery, it discards a torn write at the end of the journal and reports its size inBounds.Repaired.- In simulation,
s.Volume(node)returns the node's simulated disk as afilestore.FS, andfilestore.Newruns a store on it. faultline's disk faults, such as lost or torn writes that were not synced and failed syncs, then run through the real recovery code.lockstep/leakfails a seed when an actor stops with a file still open.
Delivery helpers (lockstep/dedup)
Windowholds idempotency keys and tombstones,Repliescaches the first reply to each request, andVersionsaccepts a version for an entity only when it is greater than the one it holds.Config.TTLandConfig.Capacitybound each of them, and each encodes to JSON, so a persistent actor can keep it in its snapshots.- Message delivery explains what your actors must handle themselves: a retried request that arrives twice, and a Cancel that arrives before its Create.
examples/ordersis an order service whose simulation tests find both bugs from a seed.
lockstep-vet
lockstep-vet and the golangci-lint plugin have these new checks, so code that passed v0.1.0's checks can get new findings:
LS200: a variable used after the message that holds it was sent.LS201: a type marked//lockstep:immutablethat is not immutable.LS210: actor code that writes a package variable, or puts the actor's own state in a message.LS220: a reply fromlockstep.Askthat is never awaited or piped, and an ignored error fromlockstep.Sendorlockstep.Spawn.LS230: a message type with no case inReceive.LS240: an actor field that holds a resource and is neither passed toctx.Ownnor released inStopped.
The markers //lockstep:immutable and //lockstep:resource go in a type's doc comment and apply in every package that imports the type.
go install github.com/hmdsefi/lockstep/analysis/cmd/lockstep-vet@v0.2.0
go vet -vettool=$(which lockstep-vet) ./...Benchmarks
- The
benchmodule runs ping-pong, fan-out, idle actors, send latency, and ask latency on lockstep, protoactor-go, Hollywood, and GoAkt, and measures how many seeds per secondsim.Simulateruns. - The README's Benchmarks table and
bench/RESULTS.mdshow the results of one CI run onmain. CI fails when a lockstep result goes over its budget inbench/budgets.txt.
Fixed
System.Shutdowntook time quadratic in the number of sibling actors under one parent (#298). Stopping children, removing death watches, and removing overload subscriptions now take linear time.
Known limitations
- lockstep cannot preempt a turn. The watchdog reports a stuck turn and isolates its actor, but the turn keeps its worker, and
Shutdownwith a deadline returns the context's error when the deadline passes. - No remoting or clustering: actors on different nodes exchange messages only in simulation. lockstep ships no tracing or metrics adapter. The roadmap tracks later releases.
persist/filestorekeeps files on a local disk. Use onefilestore.Storeper directory: two Stores on the same directory, in one process or in two, are not detected...
lockstep v0.1.0
lockstep v0.1.0 is the first release: typed local actors on lockstep.New(), a seeded multi-node simulation in lockstep/sim built on faultline, and lockstep-vet, which checks that actor code stays deterministic. The README walks through a first actor, a simulation test, and a replayed seed.
Requirements
- Go 1.26 or later.
- faultline v0.1.2, pinned in
go.mod. Onlylockstep/simimports it; a program that does not importsimnever links faultline.
Modules and tags
This release tags two modules:
github.com/hmdsefi/lockstepatv0.1.0: the runtime andlockstep/sim.github.com/hmdsefi/lockstep/analysisatanalysis/v0.1.0: the analyzers,lockstep-vet, and the golangci-lint plugin.
examples/raft is a module of its own, built against the lockstep code in the same checkout. It is not tagged.
Added
Actors and messages
- Typed actors:
Actor[M]receives one message typeM, andSpawnreturns aPID[M], so a message of the wrong type does not compile.Spawntakes a factory that runs at every start and restart. SendandSendContextdeliver a message;Stopstops an actor byAddress.- Request and reply:
Askreturns a*Reply[R]and passes aReplyTo[R]into the request.ReplyTo.SendandReplyTo.Failanswer it exactly once,WithTimeoutsets the deadline, andNewReplycreates a reply without a target. Reply.Awaitwaits outside actors. Inside an actor it returnsErrAwaitInActor;Pipedelivers the reply to the actor as one of its own messages.- Signals from the runtime arrive through
SignalReceiver[M], never as anM:Terminated,ChildFailed, andOverloaded. WatchandUnwatchsubscribe to aTerminatedsignal when another actor stops for good.- Names and lookup:
Lookup[M]finds a registered actor by path and type, andPIDAtaddresses whichever actor is registered at a path on a node. Contextgives actor code its runtime:Self,Parent,Now,Rand,Logf,Fail,Stop, andOwn.
Mailboxes and overload
- Bounded mailboxes with a per-actor
WithMailboxor system-wideWithDefaultMailboxcapacity and policy. The default is 1024 messages withFailSend. - Four full-mailbox policies:
FailSend(SendreturnsErrMailboxFull),DropNewest,DropOldest, andBlockSender, which makes an outside sender wait. Timer messages, piped replies, and effect results never count toward capacity and are never refused. - Opt-in memory estimates:
WithMemoryEstimates, messages that implementSizer, actors that implementMemSizer, andSystem.Estimatesfor the largest actors. - Admission: when a system sheds,
SpawnandSendreturnErrOverloaded, and actors that calledContext.SubscribeOverloadgetOverloadedsignals. In v0.1.0, shedding happens in simulation only (see Known limitations).
Lifecycle and supervision
- Lifecycle hooks:
Starter.Startedruns before the first message of an incarnation, andStopper.Stoppedruns once per incarnation, on stop, before every restart, and after a failure. A resource registered withContext.Ownis closed afterStopped. - A panic or
Context.Failfails the actor; a panic arrives as a*PanicErrorwith its stack. - Supervision strategies
OneForOneandAllForOne, with directivesDirectiveRestart,DirectiveStop,DirectiveEscalate, andDirectiveResume.Strategylimits restarts per window (MaxRestarts,Window) and backs off between them (MinBackoff,MaxBackoff).WithStrategysets it per actor andWithGuardianStrategyfor top-level actors. - Stopping a parent stops its children first, and
StopReasontellsStoppedwhy:Normal,Failed,ParentStopping,Shutdown,Restarting, orNoActor. - The actor cap:
WithMaxActorslimits live actors per system (defaultDefaultMaxActors, 1,000,000). Past it,SpawnreturnsErrActorCap; one percent of the cap is held back for restarts and system actors. System.Shutdownstops user actors, then system actors, and waits for everyStoppedhook.
Timers
Context.Afterdelivers a message to the actor once, andContext.Everydelivers it on a period, skipping a firing while the previous message is still in the mailbox.Context.CancelTimercancels either.- Timers run on the engine clock: wall time on
lockstep.New(), the node's virtual clock in simulation.
Effects
Doruns anEffect[T](a network call, file I/O, a query) outside the actor's turn and delivers its result as a message through adonefunction that runs on the actor's turn.WithEffectTimeoutsets the timeout (defaultDefaultEffectTimeout, 30 s);Context.CancelEffectcancels a pending effect.- On
lockstep.New(), effects run on worker goroutines, at most 64 at a time with up to 1024 waiting; past that,donegetsErrOverloaded. - In simulation,
Effect.Runis never called:Sim.HandleEffectanswers each effect by name with a value, an error, and a virtual latency.
Simulation (lockstep/sim)
sim.Simulateandsim.SimulateSeedsrun a test body once per seed on faultline's simulation kernel, with virtual time and a simulated network.sim.Optionsembedsfaultline.Options.- Multiple lockstep nodes per run:
Sim.Nodeadds a server andSim.Clienta client node. Each boot gets a newSystem, and actors on different nodes exchange messages over the simulated network. sim.Sendandsim.Askdrive the run from the test body.Sim.System,Sim.World, andSim.Countsexpose the run's state.- Faults: faultline's planners and injector (partitions, crashes and restarts, pauses, link faults, clock faults) apply to lockstep nodes. lockstep adds
Sim.DropBetweenandSim.DelayBetweenfor messages between two actors,Sim.InjectFailandSim.StopActorfor single actors, and memory pressure withSim.PressureandSim.PressureFor. - Count-based admission budgets:
sim.Budgetsets per-node limits on live actors and estimated bytes, with a resume mark, so shedding replays exactly. - Checks that fail a seed:
lockstep/deadlockreports a cycle of pending asks aslockstep: deadlock: A → B → A;lockstep/leakreports an actor that stops or restarts with an effect still pending, or a resource that fails to close;lockstep/no-effect-handlerreports an effect with no handler (ErrNoEffectHandler);lockstep/misusereports API misuse such asAwaiton a pending reply. - Exact replay: a failing seed prints a
FAULTLINE_SEED=...command that reruns it with the same trace hash.LOCKSTEP_CHECK_DETERMINISM=1runs every seed twice and compares the traces.
lockstep-vet and the golangci-lint plugin
-
LS100reports nondeterminism and blocking in actor code: the wall clock, global and crypto randomness, goroutines,select, map iteration order, environment variables, channel operations, locks, and blocking network, file, database, and process calls. -
LS110reportsReply.Awaitin actor code. -
LS001andLS002report malformed and unknown//lockstep:directives.//lockstep:allow LSnnn <reason>suppresses one finding. -
lockstep-vetruns the analyzers withgo vet -vettool:go install github.com/hmdsefi/lockstep/analysis/cmd/lockstep-vet@v0.1.0 go vet -vettool=$(which lockstep-vet) ./... -
github.com/hmdsefi/lockstep/analysis/golangciregisters the same analyzers as the golangci-lint module pluginlockstep, with adisablesetting that leaves out analyzers by name.
The Raft demo
examples/raftruns a Raft key-value cluster of three or five servers, written as lockstep actors, through partitions, crashes, paused servers, and lossy links. It checks Raft's safety properties after every event, and checks the client history against the committed log when the run ends.- Three planted bugs (
vote-ignores-log,stale-read,vote-not-durable) each fail within the first 300 seeds of their fault preset, andTestDemoPartitionBugfinds and replays one. - The demo passes
lockstep-vetwith no//lockstep:allowdirectives.
Known limitations
lockstep.New()runs on a local engine: one goroutine runs every actor turn, so actors interleave but never run in parallel. There is no pooled production scheduler.WithAdmission,WithWatchdog, andWithGoroutineWarningare accepted and validated but have no effect:lockstep.New()does not read the live heap, watch for stuck turns, or count goroutines, and never sheds. Admission andOverloadedsignals work in simulation throughsim.Budgetand memory pressure.- No remoting, clustering, persistence, or OpenTelemetry. Actors on different nodes exchange messages only in simulation. The roadmap tracks later releases.
System.Shutdowntakes time quadratic in the number of sibling actors under one parent (#298).- An ask deadlock through a PID built with
PIDAtis not reported as a deadlock; the seed ends by the ask's timeout instead. It never causes a false failure (#299). - Replay from a seed (
FAULTLINE_SEED) is exact, down to the trace hash. Replay from aschedule.jsonfile (FAULTLINE_SCHEDULE) reproduces the same failure and the same lockstep records, but its trace hash differs from the original run's, because faultline v0.1.2 records schedule-driven faults differently from planned ones. - Memory pressure (
Sim.Pressure,Sim.PressureFor) and the message rules ofSim.DropBetweenandSim.DelayBetweendraw from lockstep's own random streams, not faultline faults. A seed replays them exactly, butschedule.jsondoes not list them, and faultline cannot minimize them. - The API can change before v1.0.