Skip to content

Commit 1d2db70

Browse files
committed
feat: implement geo routing policy
Signed-off-by: Maximilian Hagen <git@saxy.dev>
1 parent ea09c87 commit 1d2db70

13 files changed

Lines changed: 1571 additions & 47 deletions

File tree

config/config.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ type Config struct {
8282
// clusterSplitThreshold is the tracked byte size above which a region is
8383
// considered too large and a split is triggered (Phase 4).
8484
clusterSplitThreshold uint64
85+
// zone is the geographic availability zone this node belongs to
86+
// (Phase 6, ADR-006). It is self-declared at startup and reported to
87+
// the PD for geo-aware region placement.
88+
zone string
8589
}
8690

8791
func getEnv[T any](key string, fallback T) T {
@@ -509,6 +513,12 @@ func LoadConfig(args []string) *Config {
509513
getEnv("TSD_CLUSTER_SPLIT_THRESHOLD", uint64(64*1024*1024)),
510514
"Region byte size that triggers an automatic split in bytes (default: 67108864)",
511515
)
516+
fs.StringVar(
517+
&cfg.zone,
518+
"zone",
519+
getEnv("TSD_ZONE", ""),
520+
"Geographic availability zone for geo-aware placement; empty means unknown (default: none)",
521+
)
512522
// Custom usage output to guide operators.
513523
fs.Usage = func() {
514524
println("Tellstone server – high-performance in-memory database")
@@ -826,3 +836,6 @@ func (cfg *Config) GetTSORefillThreshold() int { return cfg.tsoRefillThreshold }
826836

827837
// GetClusterSplitThreshold returns the region byte size that triggers a split.
828838
func (cfg *Config) GetClusterSplitThreshold() uint64 { return cfg.clusterSplitThreshold }
839+
840+
// GetZone returns the node's declared availability zone (empty when unset).
841+
func (cfg *Config) GetZone() string { return cfg.zone }

internal/cluster/geo.go

Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
/*
2+
Package cluster
3+
Tellstone Cloud-Native In-Memory Database
4+
File: geo.go
5+
Description: Geo-aware placement (Phase 6, ADR-006). Nodes self-declare their
6+
availability zone at startup and register it in etcd under /tellstone/nodes/<id>.
7+
Operators define a GeoPolicy mapping key prefixes to zones; the PD places new
8+
regions according to the policy and leader election prefers same-zone replicas.
9+
Unmatched keys default to the global zone "*" (replicated everywhere).
10+
11+
Wire format for NodeInfo (compact binary, no protobuf):
12+
13+
[8B ID][2B zone_len][zone][2B addr_len][addr]
14+
15+
Wire format for GeoPolicy:
16+
17+
[8B version][2B rule_count][rule...]
18+
rule: [2B prefix_len][prefix][2B zone_len][zone][8B replicas]
19+
20+
Authors:
21+
22+
Maximilian Hagen
23+
*/
24+
package cluster
25+
26+
import (
27+
"encoding/binary"
28+
"sync"
29+
)
30+
31+
// GeoZoneGlobal is the wildcard zone used by rules that replicate a key
32+
// range everywhere. It is also the default for keys that match no rule:
33+
// unknown data is replicated globally rather than silently pinned to one
34+
// zone with cross-ocean latency for readers elsewhere (ADR-006 §Negative).
35+
const GeoZoneGlobal = "*"
36+
37+
// GeoDefaultReplicas is the replica count assumed when a rule does not
38+
// specify one.
39+
const GeoDefaultReplicas = 3
40+
41+
const nodesKeyPrefix = "/tellstone/nodes/"
42+
43+
const geoPolicyKey = "/tellstone/geo/rules"
44+
45+
func nodesKey(id uint64) string {
46+
var b [8]byte
47+
binary.BigEndian.PutUint64(b[:], id)
48+
return nodesKeyPrefix + string(b[:])
49+
}
50+
51+
// NodeInfo is the metadata a node self-registers in etcd at startup so every
52+
// other node (and the PD) can resolve node IDs to availability zones.
53+
type NodeInfo struct {
54+
ID uint64
55+
Zone string
56+
Addr string
57+
}
58+
59+
// encodeNodeInfo serializes a NodeInfo into compact binary form.
60+
func encodeNodeInfo(n NodeInfo) []byte {
61+
buf := make([]byte, 0, 12+len(n.Zone)+len(n.Addr))
62+
buf = appendUint64(buf, n.ID)
63+
buf = appendBytesField(buf, []byte(n.Zone))
64+
buf = appendBytesField(buf, []byte(n.Addr))
65+
return buf
66+
}
67+
68+
// decodeNodeInfo parses a NodeInfo value. Returns ok=false on truncated data.
69+
func decodeNodeInfo(b []byte) (NodeInfo, bool) {
70+
var n NodeInfo
71+
if len(b) < 12 { // 8 + 2 + 2
72+
return n, false
73+
}
74+
n.ID = binary.BigEndian.Uint64(b[0:8])
75+
b = b[8:]
76+
var ok bool
77+
b, z, ok := readBytesField(b)
78+
if !ok {
79+
return n, false
80+
}
81+
n.Zone = string(z)
82+
b, a, ok := readBytesField(b)
83+
if !ok {
84+
return n, false
85+
}
86+
n.Addr = string(a)
87+
return n, true
88+
}
89+
90+
// GeoRule pins keys with a matching prefix to a zone. Zone "*" means the
91+
// range is replicated across all zones; Replicas defaults to
92+
// GeoDefaultReplicas when zero.
93+
type GeoRule struct {
94+
Prefix string
95+
Zone string
96+
Replicas int
97+
}
98+
99+
// GeoPolicy is the operator-defined placement policy. Rules are matched by
100+
// longest prefix; a key with no matching rule defaults to GeoZoneGlobal.
101+
type GeoPolicy struct {
102+
Version uint64
103+
Rules []GeoRule
104+
}
105+
106+
// encodeGeoPolicy serializes a policy into compact binary form.
107+
func encodeGeoPolicy(p GeoPolicy) []byte {
108+
buf := make([]byte, 0, 16)
109+
buf = appendUint64(buf, p.Version)
110+
buf = appendUint16(buf, uint16(len(p.Rules)))
111+
for _, r := range p.Rules {
112+
buf = appendBytesField(buf, []byte(r.Prefix))
113+
buf = appendBytesField(buf, []byte(r.Zone))
114+
buf = appendUint64(buf, uint64(r.Replicas))
115+
}
116+
return buf
117+
}
118+
119+
// decodeGeoPolicy parses a policy value. Returns ok=false on truncated data.
120+
func decodeGeoPolicy(b []byte) (GeoPolicy, bool) {
121+
var p GeoPolicy
122+
if len(b) < 10 { // 8 + 2
123+
return p, false
124+
}
125+
p.Version = binary.BigEndian.Uint64(b[0:8])
126+
nRules := binary.BigEndian.Uint16(b[8:10])
127+
b = b[10:]
128+
for i := 0; i < int(nRules); i++ {
129+
var r GeoRule
130+
var prefix, zone []byte
131+
var ok bool
132+
b, prefix, ok = readBytesField(b)
133+
if !ok {
134+
return p, false
135+
}
136+
r.Prefix = string(prefix)
137+
b, zone, ok = readBytesField(b)
138+
if !ok {
139+
return p, false
140+
}
141+
r.Zone = string(zone)
142+
if len(b) < 8 { // replicas
143+
return p, false
144+
}
145+
r.Replicas = int(binary.BigEndian.Uint64(b[0:8]))
146+
b = b[8:]
147+
p.Rules = append(p.Rules, r)
148+
}
149+
return p, true
150+
}
151+
152+
// DefaultGeoPolicy returns the bootstrap policy: a single catch-all rule
153+
// replicating everything globally. Applied when no policy exists in etcd.
154+
func DefaultGeoPolicy() GeoPolicy {
155+
return GeoPolicy{
156+
Version: 1,
157+
Rules: []GeoRule{
158+
{Prefix: "", Zone: GeoZoneGlobal, Replicas: GeoDefaultReplicas},
159+
},
160+
}
161+
}
162+
163+
// Match returns the zone a key should be pinned to. Longest matching prefix
164+
// wins; keys matching none default to GeoZoneGlobal.
165+
func (p GeoPolicy) Match(key []byte) string {
166+
best := ""
167+
bestLen := -1
168+
for _, r := range p.Rules {
169+
if len(r.Prefix) <= bestLen {
170+
continue
171+
}
172+
if len(r.Prefix) == 0 {
173+
// Catch-all rule: a candidate, but only wins if nothing longer
174+
// matches (bestLen guards this below).
175+
if bestLen < 0 {
176+
bestLen = 0
177+
best = r.Zone
178+
}
179+
continue
180+
}
181+
if len(key) >= len(r.Prefix) && string(key[:len(r.Prefix)]) == r.Prefix {
182+
bestLen = len(r.Prefix)
183+
best = r.Zone
184+
}
185+
}
186+
if bestLen < 0 {
187+
return GeoZoneGlobal
188+
}
189+
return best
190+
}
191+
192+
// ReplicaCount returns the target replica count for a key, or
193+
// GeoDefaultReplicas when no rule (or no requested count) matches.
194+
func (p GeoPolicy) ReplicaCount(key []byte) int {
195+
bestPrefix := ""
196+
bestLen := -1
197+
for _, r := range p.Rules {
198+
if len(r.Prefix) <= bestLen {
199+
continue
200+
}
201+
matched := len(r.Prefix) == 0
202+
if !matched && len(key) >= len(r.Prefix) && string(key[:len(r.Prefix)]) == r.Prefix {
203+
matched = true
204+
}
205+
if matched {
206+
bestLen = len(r.Prefix)
207+
bestPrefix = r.Prefix
208+
}
209+
}
210+
if bestLen < 0 {
211+
return GeoDefaultReplicas
212+
}
213+
for _, r := range p.Rules {
214+
if r.Prefix == bestPrefix {
215+
if r.Replicas > 0 {
216+
return r.Replicas
217+
}
218+
}
219+
}
220+
return GeoDefaultReplicas
221+
}
222+
223+
// GeoPolicyProvider is implemented by *GeoManager (and test doubles). It
224+
// supplies the current operator policy to placement/leader-election code.
225+
type GeoPolicyProvider interface {
226+
Policy() GeoPolicy
227+
}
228+
229+
// PreferredZoneOf computes the zone a region covering startKey should be
230+
// pinned to. The policy is applied to the region's start key; a range that
231+
// spans multiple rule zones inherits the zone of its lowest key, and future
232+
// splits at rule boundaries refine it. Global ("*") means the replicas are
233+
// spread across all zones (no pinning).
234+
func PreferredZoneOf(policy GeoPolicy, startKey []byte) string {
235+
return policy.Match(startKey)
236+
}
237+
238+
// PickZonePeers orders candidate node IDs so that nodes in the preferred
239+
// zone come first, followed by all remaining nodes. The global zone "*"
240+
// means no ordering preference (all nodes are treated equally). Nodes not in
241+
// the registry sort after every known node. Used for placement and read
242+
// routing when a region's replicas must be preferred by zone.
243+
func PickZonePeers(zones *NodeZones, preferred string, candidates []uint64) []uint64 {
244+
out := make([]uint64, 0, len(candidates))
245+
if preferred == "" || preferred == GeoZoneGlobal {
246+
// No pinning: preserve the candidate order (all equal).
247+
return candidates
248+
}
249+
preferredSet := make(map[uint64]struct{})
250+
for _, id := range zones.NodesInZone(preferred) {
251+
preferredSet[id] = struct{}{}
252+
}
253+
for _, id := range candidates {
254+
if _, ok := preferredSet[id]; ok {
255+
out = append(out, id)
256+
}
257+
}
258+
for _, id := range candidates {
259+
if _, ok := preferredSet[id]; !ok {
260+
out = append(out, id)
261+
}
262+
}
263+
return out
264+
}
265+
266+
// ZoneOf best-effort resolves a node's zone from the registry for logging and
267+
// latency metrics. Returns "" when unknown.
268+
func ZoneOf(zones *NodeZones, id uint64) string {
269+
if zones == nil {
270+
return ""
271+
}
272+
return zones.Zone(id)
273+
}
274+
275+
// NodeZones tracks the node → zone registry watched from
276+
// /tellstone/nodes/<id>. It is a concurrency-safe cache used by the PD for
277+
// placement decisions and by nodes for zone-aware routing.
278+
type NodeZones struct {
279+
mu sync.RWMutex
280+
zones map[uint64]string
281+
}
282+
283+
// NewNodeZones returns an empty node→zone registry.
284+
func NewNodeZones() *NodeZones {
285+
return &NodeZones{zones: make(map[uint64]string)}
286+
}
287+
288+
// Update upserts a node's zone. Lower-or-equal registrations are ignored so a
289+
// stale watch event cannot roll back a zone change.
290+
func (z *NodeZones) Update(n NodeInfo) {
291+
z.mu.Lock()
292+
defer z.mu.Unlock()
293+
if cur, ok := z.zones[n.ID]; ok && cur != "" && n.Zone == "" {
294+
return // never downgrade a known zone to unknown
295+
}
296+
z.zones[n.ID] = n.Zone
297+
}
298+
299+
// Remove drops a node entry (etcd deletion).
300+
func (z *NodeZones) Remove(id uint64) {
301+
z.mu.Lock()
302+
defer z.mu.Unlock()
303+
delete(z.zones, id)
304+
}
305+
306+
// Zone returns the zone for a node ID, or "" when unknown.
307+
func (z *NodeZones) Zone(id uint64) string {
308+
z.mu.RLock()
309+
defer z.mu.RUnlock()
310+
return z.zones[id]
311+
}
312+
313+
// NodesInZone returns the node IDs registered in the given zone.
314+
func (z *NodeZones) NodesInZone(zone string) []uint64 {
315+
z.mu.RLock()
316+
defer z.mu.RUnlock()
317+
var out []uint64
318+
for id, zz := range z.zones {
319+
if zz == zone {
320+
out = append(out, id)
321+
}
322+
}
323+
return out
324+
}
325+
326+
// Snapshot returns a copy of the registry keyed by node ID.
327+
func (z *NodeZones) Snapshot() map[uint64]string {
328+
z.mu.RLock()
329+
defer z.mu.RUnlock()
330+
out := make(map[uint64]string, len(z.zones))
331+
for id, zone := range z.zones {
332+
out[id] = zone
333+
}
334+
return out
335+
}

0 commit comments

Comments
 (0)