Documentation
¶
Overview ¶
Package peers reports the fleet sharing one shard's database, from that shard's replica registry.
One Sonar works one shard: it owns this replica's registry row there - creating it, refreshing it, deleting it - reads the whole registry on its own clock, and publishes what the reading implies. An owner with N shards runs N of them, and nothing here spans shards: every timestamp in the registry belongs to the shard that stamped it, so a fleet view assembled from two shards would be comparing two clocks.
A Sonar is a CONSUMER of its database. The handle arrives already open and is closed by whoever opened it, so there is no Open, no Close, and no say over pool sizes.
Driving one ¶
s, err := peers.New(engineID, shard, db) s.SetEvidence(dispatcher.Liveness) // optional; without it this replica never claims to dispatch s.Join(ctx) // announce, wait for peers to notice, then read go s.Run(ctx) // ... and keep reading until ctx ends <-runReturned s.Leave(liveCtx) // with a context that still works
Join and Run must be driven by a SINGLE goroutine and must not overlap. Every getter is safe to call from any goroutine at any time.
Join BLOCKS for a fraction of a second by design - see its own doc. It returns with every getter seeded, so an owner that sizes anything from this Sonar can do so immediately afterward and never against an unknown fleet.
Leave needs a live context, so the owner calls it after Run has returned rather than relying on Run to clean up on the way out. Run having returned is also what makes the delete stick: no beat can follow it, and a beat never creates a row.
What it publishes ¶
Replicas is how many replicas hold connections to this shard - the divisor for the shard's connection pool. Partition is the (replicas, ordinal) pair that splits candidate selection across the replicas that actually serve this shard, in the shape a dispatcher consumes: ok=false means "select everything". BlindFor is how long it has been since the registry was last read successfully, which an owner should surface: a Sonar that cannot read holds every one of its published values frozen.
A failed read publishes NOTHING - every getter keeps reporting the last good reading - because a read that did not happen is not evidence that anybody left.
Index ¶
- Constants
- type EvidenceFunc
- type Sonar
- func (s *Sonar) BlindFor() time.Duration
- func (s *Sonar) Join(ctx context.Context) error
- func (s *Sonar) Leave(ctx context.Context) error
- func (s *Sonar) Partition() (replicas, ordinal int, ok bool)
- func (s *Sonar) Replicas() int
- func (s *Sonar) Run(ctx context.Context)
- func (s *Sonar) SetCadence(d time.Duration)
- func (s *Sonar) SetEvidence(fn EvidenceFunc)
- func (s *Sonar) SetLogger(l *slog.Logger)
- func (s *Sonar) SetSeams(sm *seamster.Seamster)
- func (s *Sonar) SetTurnFunc(fn TurnFunc)
- type TurnFunc
Constants ¶
const ( // FaultReadErr makes the registry read fail without touching the database. It is how a test reaches // every consequence of being BLIND: the counts hold, a fall is withheld across the gap that follows, // the partition switches off, and the prune's healthy run starts over. FaultReadErr = "peerReadErr" // FaultBeatErr makes every write that would PROVE this replica's liveness fail without touching the // database - the beat, and the registration repair that would otherwise re-create a row a peer has // pruned. Its row therefore stops being refreshed while the process goes on running, which is what a // replica that has lost the ability to prove itself looks like to its PEERS, and the only way to reach // that view without killing a process. // // It deliberately does NOT gate Leave: a test still has to be able to tear its fleet down. FaultBeatErr = "peerBeatErr" )
The two faults an owner may arm - see SetSeams. Exported so the owning application's catalogue aliases them rather than re-spelling the strings.
const Cadence = 250 * time.Millisecond
The cadences and windows. Only the read cadence is settable (SetCadence), because only it prices something the owner can see; the rest are this package's policy, derived from the asymmetry below rather than from anything an owner knows better. Tests in this package assign the fields directly.
The two windows are deliberately far apart, and it is not a cost decision. Over-counting the replicas over-sizes pools and can collapse a database, so the fresh window is generous. Over-counting the DISPATCHERS hands a residue class of step ids to a replica that never selects them, and work in that class is then run by nobody, so the dispatch window is tight. The two errors point in opposite directions, so THE TWO WINDOWS MUST NOT BE DERIVED FROM ONE NUMBER. Cadence is how often a Sonar reads the registry, and so how often every value it publishes can change.
An owner that polls those values should tick at this rate: slower discards detection it has already paid for, faster reads the same answer twice. It is also what Join blocks for, twice over.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type EvidenceFunc ¶
EvidenceFunc reports whether the owner's dispatcher for this shard is turning - see SetEvidence.
type Sonar ¶
type Sonar struct {
// contains filtered or unexported fields
}
Sonar owns this replica's row in one shard's peer registry and everything derived from reading it.
Join and Run must be driven by a single goroutine. Every getter is safe to call from another one, at any time.
func New ¶
New returns a Sonar for one shard over an already-open database handle.
engineID leads because it is required rather than optional: it is the registry's PRIMARY KEY, so an unset one is not a harmless default - every unconfigured replica in the fleet would collide on id 0 and fight over a single row. A setter would make that state reachable; a constructor argument does not.
func (*Sonar) BlindFor ¶
BlindFor is how long it has been since the registry was last read successfully. Zero on a healthy Sonar.
func (*Sonar) Join ¶
Join announces this replica and returns with every getter seeded. It BLOCKS for two scan intervals, and that wait is the point rather than an implementation detail: it is what keeps a join from exceeding the shard's connection budget even momentarily.
A joining replica sizes its own pool for the fleet it is joining, but its peers go on holding pools sized for the fleet WITHOUT it until they read the registry again, so consuming immediately puts the shard's server over budget by roughly one replica's share until they catch up. Announcing first inverts that: peers shrink, then the newcomer grows. What it buys is peers having STOPPED ACQUIRING beyond their new cap - lowering a pool's limit closes nothing, so any surplus drains as connections are returned.
TWO intervals, not one: a peer's read may have begun just before this replica's row was committed, so that read proves nothing and only the one after it must see the row. It also gives a simultaneously starting fleet's rows time to land, so the reading that follows is of a settled roster rather than a partial one - and a partial one would UNDER-count, which over-sizes pools.
The whole sequence runs even if part of it fails, so a transient error still leaves the getters as well seeded as they can be; the first error is returned for the owner to log.
func (*Sonar) Leave ¶
Leave deletes this replica's row, so peers recount without waiting out the freshness window.
Call it with a LIVE context after Run has returned. Run cannot do it on the way out - its context is already cancelled by then - and Run having returned is what makes the delete stick, since no beat can follow it and a beat never creates a row.
func (*Sonar) Partition ¶
Partition is the (replicas, ordinal) pair that splits candidate selection across the replicas serving this shard, or ok=false when selection must not be partitioned at all.
It reports the DISPATCHER count, not Replicas: the pool divisor counts every replica holding connections, while this divides work, and handing a residue class to a replica that claims none would leave those steps selected by nobody.
ok=false whenever the answer cannot be justified - a solo dispatcher, this replica absent from the roster, an ordinal outside the divisor, or a Sonar gone blind. Every one of those fails OPEN: not partitioning means replicas select overlapping candidates, which costs a lost claim round trip, while partitioning on a stale pair means a class of steps nobody selects.
Blindness is evaluated HERE rather than published by the loop, so a shard that stops answering stops being partitioned within a round trip rather than within a cadence.
func (*Sonar) Replicas ¶
Replicas is how many replicas hold connections to this shard, self included, never below one. It is the divisor for the shard's connection pool: the budget belongs to the shard's DATABASE, so N replicas each holding the whole budget would overshoot it N times over.
func (*Sonar) Run ¶
Run beats, reads and publishes until ctx ends. It blocks; the owner puts it in a goroutine.
A cancelled context is the only stop signal it needs, and no second one is wanted: the read is a pure read and the beat is one idempotent UPDATE, so abandoning either mid-flight commits nothing and strands nothing.
func (*Sonar) SetCadence ¶
SetCadence overrides how often this Sonar reads the registry, for an owner whose whole fleet is ephemeral - a test suite standing engines up and tearing them down, where the default's two-cadence Join would dominate every startup. Zero or negative restores Cadence.
It is the ONE knob here, and every other number is deliberately not one: the windows and the beat are this package's policy, derived from which errors are safe in which direction rather than from anything an owner knows better. This one is different because it prices the owner's startup latency, which only the owner can see.
Shortening it shortens what derives from it - Join's wait and the blindness grace - and nothing else, so a short-cadence Sonar still applies the same windows to the same rows. Not safe to call once anything is being driven; set it before Join.
func (*Sonar) SetEvidence ¶
func (s *Sonar) SetEvidence(fn EvidenceFunc)
func (*Sonar) SetSeams ¶
SetSeams supplies the OWNER's fault-injection seams, so a test driving a whole engine can break this Sonar's database calls without breaking its database. Nil restores an inert one, which is also the default - a Sonar built and never told otherwise consults nothing, and a Seamster built disabled makes every consult a bool read.
The seams are the owner's, not this package's, for the same reason the logger is: one catalogue of fault names per application, armed in one place, however many modules consult it.
EXACTLY TWO FAULTS, both at an I/O boundary, and that is the whole rule for adding a third. A seam inside pure logic here would be a signal that a dependency should have been injected instead - and every time-dependent decision already has one, the clock - so it would buy nothing. These two reach what injection cannot: a database that answers, but wrongly for this replica. FaultReadErr makes the reading fail (blindness, and everything downstream of it); FaultBeatErr stops this replica proving its own liveness while it goes on running, which is the only way for a test to occupy a PEER's point of view of a replica that has effectively died.
Both are consulted unscoped OR scoped by shard, so a test can blind one shard and leave the rest reading - which is the per-shard property this package exists to provide, and it would be untestable otherwise.
func (*Sonar) SetTurnFunc ¶
SetTurnFunc supplies an admission step taken at the START of each pass and released at its end, so an owner that orders access to its database can order this too, without this package knowing what the ordering is. Nil (the default) admits every pass immediately.
It returns a CONTEXT AND A RELEASE rather than just a context, because a derivation alone would be inert: the owner has to be able to hold something for the duration of the pass and hand it back, and a hook that only decorated the context would look wired while metering nothing.
Whatever the owner puts there, IT MUST NOT BE ABLE TO STALL A PASS. A Sonar that cannot beat reads as blind, and a blind Sonar makes its replica stop partitioning and hold its last fleet count - so anything that throttled this in proportion to load would let load reshape the fleet. Ordering a pass ahead of ordinary work is fine; making it queue behind ordinary work is not.
Once per PASS rather than once per statement, because a pass is one unit of work: the beat and the read that follows it describe the same moment, and re-deriving between them would present them as two arrivals.
type TurnFunc ¶
SetEvidence supplies the one fact a Sonar cannot observe for itself: whether the owner's dispatcher for this shard is turning. It decides whether a beat also stamps this replica as SERVING the shard, which is what earns it a residue class of step ids.
The three returns are read together and interpreted as: turns advanced since the last beat, or a turn has been running long enough to count on its own, and the dispatcher is not idling. The second term is for a scan that legitimately runs far longer than the dispatch window on a deep backlog - but it must mean a LONG turn rather than merely one in flight, because a turn that fails instantly is briefly in flight too, and a reader sampling on a cadence catches that often enough to keep a dispatcher that serves nothing looking alive for good.
It is a PURE READ and may be called any number of times - the "since the last beat" part is this package's business, held against the turn count it last published. A consuming getter would make any second caller (a metric, a test) silently clear the evidence and leave a healthy dispatcher looking stalled.
Without it a Sonar keeps its row alive and never claims to dispatch, which is exactly right for a replica that holds connections but claims no work: it counts toward Replicas and toward nobody's partition. Absence of evidence must never read as evidence. TurnFunc admits a pass to whatever the owner meters its database with, returning the context the pass's calls run on and the release to call when they are done - see SetTurnFunc.