Cluster membership: the set of peer nodes with their lifecycle state and
capacity weight, plus a deterministic epoch derived from the membership content.
It feeds internal/hashring.
- Source:
internal/cluster/cluster.go,internal/cluster/provider.go - Tests:
internal/cluster/cluster_test.go,internal/cluster/provider_test.go - Import path:
github.com/data-accelerator/dart/internal/cluster
DART discovers peers from Kubernetes (all peers equal, TCP only, no broadcast).
This package models the resulting membership as an immutable snapshot (View)
and exposes it to the placement/distribution layers. It deliberately contains no
Kubernetes code: a pluggable Provider supplies Views, so the core is testable
and usable without a cluster (StaticProvider). A Kubernetes EndpointSlice
seeder ships as the separate providers/k8s module (see k8s.md),
keeping client-go out of this module.
| Term | Meaning |
|---|---|
State |
Member lifecycle: Joining → Ready → Suspect → Leaving. Reserved transitions: today every producer sets Ready only (DynamicProvider.Learn forces it; the seeders have no member-state wire field), so Live() == Ready() in practice. The non-Ready states, their dedup rank, and their placement exclusions are the contract future producers (drain-on-shutdown, join grace) will code against — they are not emitted yet. |
Member |
One node: stable ID, capacity Weight, State. |
View |
Immutable membership snapshot at a given Epoch; safe to share across goroutines. |
Epoch |
Deterministic 64-bit hash of the canonical membership; an agreement/change token, not a monotonic counter. |
Provider |
Source of Views over time (Current + Subscribe). |
Only Ready members participate in ownership (placement). Read attempts may also include Suspect members (their data may still be present, so a brief NotReady does not force an immediate reshard).
type State uint8
const ( Joining State = iota; Ready; Suspect; Leaving )
func (s State) String() stringString returns "Joining"|"Ready"|"Suspect"|"Leaving", or "Unknown" for
any other value.
type Member struct {
ID string // stable identity, cluster-consistent, restart-stable; never PodIP
Addr string // peer-transport address host:port; auxiliary, NOT part of the epoch
Weight float64 // relative cache capacity; <=0 treated as 1 by hashring; stored verbatim
State State
}Addr is routing metadata (where to pull blocks from this member) and is
deliberately excluded from the epoch: it does not affect placement ordering, and
a stale Addr only causes a fallback to origin (safe under read-only
semantics). It is retrievable via Get.
NewView copies and canonicalizes the input: sorts by (ID, State, Weight)
and de-duplicates by ID. Duplicate IDs are a conflicting-reports case: the
canonical sort is (ID, state rank, Weight) with the rank Ready > Suspect >
Joining > Leaving, so the entry claiming the node can serve always wins — a
Joining or Leaving marker must never suppress a member another source still
reports as Ready (placement would silently lose a serving node). The resulting
View and its epoch are therefore independent of input order.
The input slice is not modified.
Methods (all on an immutable receiver; safe for concurrent use):
| Method | Semantics | Complexity |
|---|---|---|
Epoch() uint64 |
deterministic membership epoch | O(1) |
Len() int |
number of members (all states) | O(1) |
Members() []Member |
copy of all members in canonical order (caller owns it) | O(n) |
Get(id string) (Member, bool) |
member by ID via binary search | O(log n) |
Ready() []hashring.Node |
Ready members as hashring nodes, sorted by ID (shared, read-only) | O(1) |
Live() []hashring.Node |
Ready+Suspect members as hashring nodes, sorted by ID (shared, read-only) | O(1) |
Ready()/Live() return internal slices precomputed at construction. They are
shared and must not be mutated; hashring.Rank copies its input, so they are
safe to pass directly.
v := cluster.NewView(members)
owner := hashring.Rank(chunkKey, v.Ready())[0] // placement over Ready membersEstablished by ADR-0002.
The member-ID alphabet: every byte in [0x21, 0x7E] (visible ASCII, no spaces,
no control bytes). Enforced at both ingress points — -self-id/-peers
parsing in internal/node and roster ingest in DynamicProvider.Learn — so
the 0x1F/0x1E epoch framing can never be collided by an ID (§6).
type Provider interface {
Current() *View // latest View, never nil, lock-free
Subscribe() (<-chan *View, func()) // current + subsequent Views, plus cancel
}StaticProvider is an in-memory implementation for tests and non-Kubernetes
deployments:
func NewStaticProvider(members ...Member) *StaticProvider
func (p *StaticProvider) Current() *View
func (p *StaticProvider) Set(members []Member) *View // rebuild View + notify subscribers
func (p *StaticProvider) Subscribe() (<-chan *View, func())Subscribe returns a buffered(1), coalescing channel: the current View is
delivered immediately, then each Set — atomically: registration and the
snapshot happen under one lock, so a concurrent Set is either in the initial
delivery or arrives right after, never lost in a gap. Set likewise stores and
notifies under one critical section, so concurrent Sets linearize: the one
serialized last owns Current() and subscribers observe every Set in that
order. A slow consumer only ever sees the most
recent View (intermediate ones are dropped) and the producer never blocks. The
returned cancel func unsubscribes and is idempotent.
type Seeder interface { Seeds(ctx) ([]string, error) } // candidate addresses
type DNSSeeder struct { Name string; Port int; Lookup ... }
type StaticSeeder []string
func ParseSeeder(spec string) (Seeder, error) // "dns:<name>:<port>" | "static:a,b"
type RosterFetcher interface { FetchRoster(ctx, addr) (members []Member, responderID string, err error) }
func NewDynamicProvider(DynamicConfig) *DynamicProvider // implements ProviderDiscovery is split in two, and the split is the design:
| Half | Question | Where it runs |
|---|---|---|
| Seeder | "what addresses might be peers?" | environment-specific, pluggable — DNS, a static list, anything |
| Roster exchange | "what are their stable identities, and who else do they know?" | over DART's own peer connections, identical everywhere |
The reason not to push the second half into the environment too: a seed answer
carries addresses, and a Member.ID must be a stable identity (never an
address — see §3.2). Only the peer itself can say what its ID is. Exchanging
rosters also means one reachable neighbour is enough to find the whole cluster,
which is what makes a truncated DNS answer or a partial seed list survivable.
| Method | Contract |
|---|---|
Self() Member |
the configured self member (constant after construction). |
Current() *View / Subscribe() (<-chan *View, func()) |
the Provider interface: latest view / view-change feed (unsubscribe via the returned func). |
Run(ctx) |
the background loop: periodic Refresh on RefreshInterval until ctx is canceled. Start it before serving traffic so the first view is in place. |
Refresh(ctx) *View |
one synchronous gather + publish cycle; also callable on demand. |
Learn(members ...Member) |
hearsay ingest: invalid IDs are rejected (§3.3.1) and reported via OnError; self entries ignored; valid members are learned immediately. Safe for concurrent use. |
LearnPeer(id, addr string) |
records an inbound contact from id at addr — refreshes that member's liveness clock (only direct contact does) and adopts the newest address. |
Roster() []Member |
the full member list we answer roster fetches with (self included). |
- Adding happens immediately, on hearsay. Being wrong costs a request sent to a node that turns out not to hold the block, which is safe.
- Forgetting waits for
ForgetAfter(default 60s) of no direct contact, because removing a member re-runs placement, moves ownership of ~1/N of the keyspace and re-forms the distribution tree. Routing around an unreachable peer already happens in about a second, locally, via the circuit breaker — so removal can afford to be slow, and should be.
tracked.lastContact is updated by a successful roster fetch answered by that
member, or by that member contacting us. It is deliberately not updated by
another peer mentioning it — and that includes hearsay carrying a changed
address: the newest address is adopted (a pod can be recreated with a new IP
while keeping its identity), but the clock is untouched, since an address report
is not liveness evidence. Otherwise two survivors flip-flopping conflicting
reports about a dead node would refresh each other's memory of it forever.
The credit goes to the member that answered, identified by its self-reported
stable ID (responderID), never to the member that happens to advertise the
dialed address: an address can be recycled to a different member (pod-IP reuse),
and crediting by address would keep the previous owner's clock alive
indefinitely, pinning a dead member in placement. A responder that does not
identify itself earns no credit.
This is load-bearing rather than fussy. Every surviving peer keeps listing a dead node for as long as it still remembers it, so if hearsay refreshed the clock, two survivors would refresh each other's memory of the dead node and nobody would ever remove it. A live 3-node run demonstrated exactly that before the distinction existed.
Counting inbound contact as evidence also handles asymmetric reachability: a peer we cannot dial but which can dial us stays in membership.
Member.State is not serialized. A peer's opinion about who is up is its own local
observation; importing it would let one node's transient failure become cluster-wide
membership churn. Nodes are allowed to disagree — under read-only semantics a wrong
guess costs a hop, never correctness.
- Deterministic epoch: identical membership → identical epoch, regardless of
input order; any change to any
ID/Weightchanges the epoch.StateandAddrare excluded (see below). - Canonical order: members are sorted by
(ID, State, Weight)and unique byID;Ready()/Live()are sorted byID. - Immutability: a
Viewnever changes after construction;Members()returns a copy;Ready()/Live()return shared read-only slices. - Placement scope:
Ready()contains exactly the Ready members;Live()adds Suspect; Joining/Leaving are excluded from both.
Viewis immutable and safe for concurrent use; share the pointer freely.StaticProvider.Current()is lock-free (atomic.Pointer[View]), safe on the hot path.Set/Subscribe/cancel take a short mutex; subscriber notification is non-blocking.Members()returns a fresh copy (caller may mutate).Ready()/Live()return shared slices that must not be mutated.NewViewdoes not mutate its input slice.- Verified with
-race(concurrent readers + writers + a draining subscriber).
- The epoch serialization (FNV-1a over
ID,0x1F, big-endian float64 weight bits,0x1E, per canonical member, then fmix64) is part of the wire protocol (X-DART-Epoch). Changing it changes every epoch and must be treated as a protocol change. It changed once, in 2026-08, whenStatewas removed from the hash. - The epoch covers only the authoritative fields:
IDandWeight.Addris routing metadata, andStateis a local judgement about liveness. Hashing either would make two nodes holding the same membership compute different epochs, and the epoch would stop being able to answer the one question it exists for — "are we looking at the same membership?". Liveness is layered on top of the view, not part of it. - Member IDs are restricted to visible ASCII (
ValidMemberID: every byte in [0x21, 0x7E]), enforced at both ingress points (-self-id/-peersparsing and roster ingest). The serialization frames fields with 0x1F/0x1E, so an ID containing those bytes could make two different memberships hash to the same epoch; the alphabet restriction keeps framing unambiguous without a protocol change. A rejected roster ID is reported throughDynamicConfig.OnError(safely quoted; the valid siblings are still learned) — a silently dropped ID would hide a mixed-version cluster behind a missing member. - Wart, documented not "fixed":
Weight <= 0is normalized to 1 for placement but hashed verbatim into the epoch byNewView(the roster ingest path normalizes first, so only directNewViewcallers can observe it). Two such members would agree on placement but disagree on epoch — harmless config churn. Normalizing before hashing would change epoch values, a protocol-visible change, so the wart stays documented instead. - The epoch is an agreement/change token, not a monotonic counter: on a mismatch a node refreshes membership (re-resolving seeds and re-exchanging rosters) and views converge. This suffices under the read-only semantics, where a transiently divergent view only affects routing efficiency, not correctness.
- Results:
go vetclean;go testall pass;go test -raceall pass. - Coverage: 95.6% of statements. Every function is 100% except
NewView95.0% (the uncovered path is a defensive branch). - Reproduce:
# In the sandbox, export the cache dirs first (see docs/README.md)
go test ./internal/cluster/ -v -count=1
go test ./internal/cluster/ -race -count=1
go test ./internal/cluster/ -cover -count=1| Test | Property guarded |
|---|---|
TestEpochGolden |
epoch matches an independent Python reference (serialization pinned) |
TestEpochAgainstPythonReference |
CI diffs the epoch goldens against the tracked Python script |
TestEpochDeterministicUnderShuffle |
epoch independent of input order |
TestEpochChangesOnMutation |
an id or weight change bumps the epoch; a state change does not |
TestEpochExcludesState |
any combination of states yields one epoch, so the epoch can serve as a convergence token |
TestReadyLiveFilters |
Ready=Ready only, Live=Ready+Suspect, sorted, weights carried |
TestValidMemberID / TestRosterIngestRejectsControlByteIDs / TestEpochCollisionInputsRejected / TestInvalidRosterIDReportedViaOnError |
member-ID alphabet; control-byte IDs rejected at roster ingest; the crafted epoch collision stays asserted (fails loudly if framing ever changes); invalid IDs surface one diagnostic each via OnError |
TestDedupDeterministic |
duplicate IDs collapse deterministically regardless of order |
TestDedupKeepsServingStateOverJoining |
duplicate IDs keep the most authoritative serving state (Ready > Suspect > Joining > Leaving) |
TestHearsayAddrChangeDoesNotRefreshLiveness |
hearsay address changes are adopted but never refresh the liveness clock; flip-flops still expire |
TestConcurrentSetLastReturnedWins |
concurrent Sets linearize: the Set serialized last owns Current() |
TestSubscribeNeverMissesConcurrentSet |
a Set racing Subscribe is always delivered (no registration gap) |
TestGet |
binary-search lookup, presence and absence |
TestMembersCopy |
Members() returns a copy; View not mutable through it |
TestInputNotMutated |
NewView does not reorder/modify the caller's slice |
TestFeedsHashring |
Ready() ranks cleanly via hashring (Suspect excluded) |
TestStateString |
State stringification incl. unknown |
TestStaticProviderCurrent |
seeded current view |
TestStaticProviderSetBumpsEpoch |
Set publishes a new epoch/view |
TestSubscribeReceivesCurrentThenUpdates |
subscriber gets current then each update |
TestSubscribeCoalesces |
slow consumer sees only the latest; producer never blocks; buffer ≤1 |
TestUnsubscribe |
cancel stops delivery and is idempotent |
TestConcurrentSetAndCurrent |
lock-free reads under concurrent writers + subscriber (-race) |
TestDynamicConfirmCreditsResponderNotAddress |
liveness credit goes to the answering member's ID, not the dialed address; a dead member whose address was recycled is forgotten after ForgetAfter |
TestDynamicUnidentifiedResponderEarnsNoCredit |
an anonymous responder refreshes nobody's clock |
- Kubernetes seeder: implemented as the separate
providers/k8smodule (EndpointSlice watch → seed addresses; see k8s.md). It is aSeeder, not aProvider: identity learning, the state machine and removal hysteresis stay here inDynamicProvider, unchanged across platforms. - Weight hysteresis:
Viewstores weights verbatim; smoothing/hysteresis is a provider-level concern (all members currently self-report weight 1, and the k8s seeder does not read weights from Kubernetes metadata yet), not part of the immutable snapshot. - Conflicting address reports are resolved by arrival order: one
Refreshasks both the seeds and the already-known peers, concurrently, andLearntakes the newest report for an ID unconditionally. If two live endpoints claim the same node ID with different addresses — an old pod lingering through a rollout, or its IP recycled by another pod — the address that ends up in the view is whichever reply landed last, so it can differ between nodes and between passes. The consequence is bounded: a request to a stale address fails and the circuit breaker routes around it, and the next refresh re-resolves. Making the merge deterministic (a tie-break, or preferring the address a node reports for itself over hearsay) needs a decision on which report should win, and is deliberately left open rather than guessed at.