Repository navigation
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 and can overwrite each other's committed events.- 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.