Skip to content

Commit 12be2cb

Browse files
committed
Add binary protocol, WAL, member tags tests, Go SDK docs, and plans page
New registry internals: binary wire protocol, write-ahead log, delta sync, binary client. New tests for member tags and scaling. Simulation harness for load testing. Website: Go SDK documentation and plans page.
1 parent 8e13d4b commit 12be2cb

11 files changed

Lines changed: 4709 additions & 0 deletions

File tree

pkg/registry/binary_client.go

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
package registry
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"log/slog"
8+
"net"
9+
"sync"
10+
"time"
11+
)
12+
13+
// BinaryClient talks to a registry server using the binary wire protocol.
14+
// It provides native binary encoding for hot-path operations (heartbeat, lookup,
15+
// resolve) and JSON-over-binary passthrough for all other operations.
16+
type BinaryClient struct {
17+
conn net.Conn
18+
mu sync.Mutex
19+
addr string
20+
closed bool
21+
}
22+
23+
// DialBinary connects to a registry server and negotiates the binary wire protocol.
24+
// The server detects the magic bytes and switches to binary mode for this connection.
25+
func DialBinary(addr string) (*BinaryClient, error) {
26+
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
27+
if err != nil {
28+
return nil, fmt.Errorf("dial registry: %w", err)
29+
}
30+
31+
// Send magic + version to negotiate binary protocol
32+
var handshake [5]byte
33+
copy(handshake[:4], wireMagic[:])
34+
handshake[4] = wireVersion
35+
if _, err := conn.Write(handshake[:]); err != nil {
36+
conn.Close()
37+
return nil, fmt.Errorf("binary handshake: %w", err)
38+
}
39+
40+
return &BinaryClient{conn: conn, addr: addr}, nil
41+
}
42+
43+
// Close shuts down the binary client connection.
44+
func (c *BinaryClient) Close() error {
45+
c.mu.Lock()
46+
c.closed = true
47+
conn := c.conn
48+
c.mu.Unlock()
49+
if conn != nil {
50+
return conn.Close()
51+
}
52+
return nil
53+
}
54+
55+
// Addr returns the registry address this client is connected to.
56+
func (c *BinaryClient) Addr() string {
57+
return c.addr
58+
}
59+
60+
// reconnect re-establishes the binary connection. Must be called with c.mu held.
61+
func (c *BinaryClient) reconnect() error {
62+
if c.closed {
63+
return fmt.Errorf("client closed")
64+
}
65+
if c.conn != nil {
66+
c.conn.Close()
67+
}
68+
69+
backoff := 500 * time.Millisecond
70+
maxBackoff := 10 * time.Second
71+
var lastErr error
72+
73+
for attempts := 0; attempts < 5; attempts++ {
74+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
75+
conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", c.addr)
76+
cancel()
77+
if err != nil {
78+
lastErr = err
79+
slog.Warn("binary client reconnect failed", "attempt", attempts+1, "err", err)
80+
time.Sleep(backoff)
81+
backoff *= 2
82+
if backoff > maxBackoff {
83+
backoff = maxBackoff
84+
}
85+
continue
86+
}
87+
88+
// Re-negotiate binary protocol
89+
var handshake [5]byte
90+
copy(handshake[:4], wireMagic[:])
91+
handshake[4] = wireVersion
92+
if _, err := conn.Write(handshake[:]); err != nil {
93+
conn.Close()
94+
lastErr = err
95+
continue
96+
}
97+
98+
c.conn = conn
99+
slog.Info("binary client reconnected", "addr", c.addr)
100+
return nil
101+
}
102+
return fmt.Errorf("reconnect failed after 5 attempts: %w", lastErr)
103+
}
104+
105+
// Heartbeat sends a binary heartbeat and returns the server time and key expiry warning.
106+
func (c *BinaryClient) Heartbeat(nodeID uint32, sig []byte) (unixTime int64, keyExpiryWarning bool, err error) {
107+
c.mu.Lock()
108+
defer c.mu.Unlock()
109+
110+
unixTime, keyExpiryWarning, err = c.heartbeatLocked(nodeID, sig)
111+
if err != nil && !c.closed {
112+
// Connection-level failure — reconnect and retry once
113+
if reconnErr := c.reconnect(); reconnErr != nil {
114+
return 0, false, fmt.Errorf("heartbeat failed and reconnect failed: %w", err)
115+
}
116+
unixTime, keyExpiryWarning, err = c.heartbeatLocked(nodeID, sig)
117+
}
118+
return
119+
}
120+
121+
func (c *BinaryClient) heartbeatLocked(nodeID uint32, sig []byte) (int64, bool, error) {
122+
if err := wireWriteFrame(c.conn, wireMsgHeartbeat, encodeHeartbeatReq(nodeID, sig)); err != nil {
123+
return 0, false, fmt.Errorf("send heartbeat: %w", err)
124+
}
125+
126+
msgType, payload, err := wireReadFrame(c.conn)
127+
if err != nil {
128+
return 0, false, fmt.Errorf("recv heartbeat: %w", err)
129+
}
130+
131+
if msgType == wireMsgError {
132+
return 0, false, fmt.Errorf("registry: %s", decodeWireError(payload))
133+
}
134+
if msgType != wireMsgHeartbeatOK {
135+
return 0, false, fmt.Errorf("unexpected response type 0x%02x", msgType)
136+
}
137+
138+
return decodeHeartbeatResp(payload)
139+
}
140+
141+
// Lookup sends a binary lookup request and returns the decoded result.
142+
func (c *BinaryClient) Lookup(nodeID uint32) (*WireLookupResult, error) {
143+
c.mu.Lock()
144+
defer c.mu.Unlock()
145+
146+
result, err := c.lookupLocked(nodeID)
147+
if err != nil && !c.closed {
148+
if reconnErr := c.reconnect(); reconnErr != nil {
149+
return nil, fmt.Errorf("lookup failed and reconnect failed: %w", err)
150+
}
151+
result, err = c.lookupLocked(nodeID)
152+
}
153+
return result, err
154+
}
155+
156+
func (c *BinaryClient) lookupLocked(nodeID uint32) (*WireLookupResult, error) {
157+
if err := wireWriteFrame(c.conn, wireMsgLookup, encodeLookupReq(nodeID)); err != nil {
158+
return nil, fmt.Errorf("send lookup: %w", err)
159+
}
160+
161+
msgType, payload, err := wireReadFrame(c.conn)
162+
if err != nil {
163+
return nil, fmt.Errorf("recv lookup: %w", err)
164+
}
165+
166+
if msgType == wireMsgError {
167+
return nil, fmt.Errorf("registry: %s", decodeWireError(payload))
168+
}
169+
if msgType != wireMsgLookupOK {
170+
return nil, fmt.Errorf("unexpected response type 0x%02x", msgType)
171+
}
172+
173+
result, err := decodeLookupResp(payload)
174+
if err != nil {
175+
return nil, fmt.Errorf("decode lookup response: %w", err)
176+
}
177+
return &result, nil
178+
}
179+
180+
// Resolve sends a binary resolve request and returns the decoded result.
181+
func (c *BinaryClient) Resolve(nodeID, requesterID uint32, sig []byte) (*WireResolveResult, error) {
182+
c.mu.Lock()
183+
defer c.mu.Unlock()
184+
185+
result, err := c.resolveLocked(nodeID, requesterID, sig)
186+
if err != nil && !c.closed {
187+
if reconnErr := c.reconnect(); reconnErr != nil {
188+
return nil, fmt.Errorf("resolve failed and reconnect failed: %w", err)
189+
}
190+
result, err = c.resolveLocked(nodeID, requesterID, sig)
191+
}
192+
return result, err
193+
}
194+
195+
func (c *BinaryClient) resolveLocked(nodeID, requesterID uint32, sig []byte) (*WireResolveResult, error) {
196+
if err := wireWriteFrame(c.conn, wireMsgResolve, encodeResolveReq(nodeID, requesterID, sig)); err != nil {
197+
return nil, fmt.Errorf("send resolve: %w", err)
198+
}
199+
200+
msgType, payload, err := wireReadFrame(c.conn)
201+
if err != nil {
202+
return nil, fmt.Errorf("recv resolve: %w", err)
203+
}
204+
205+
if msgType == wireMsgError {
206+
return nil, fmt.Errorf("registry: %s", decodeWireError(payload))
207+
}
208+
if msgType != wireMsgResolveOK {
209+
return nil, fmt.Errorf("unexpected response type 0x%02x", msgType)
210+
}
211+
212+
result, err := decodeResolveResp(payload)
213+
if err != nil {
214+
return nil, fmt.Errorf("decode resolve response: %w", err)
215+
}
216+
return &result, nil
217+
}
218+
219+
// SendJSON sends a JSON message over the binary protocol using JSON passthrough.
220+
// This allows any registry operation to be used without a native binary encoding.
221+
func (c *BinaryClient) SendJSON(msg map[string]interface{}) (map[string]interface{}, error) {
222+
c.mu.Lock()
223+
defer c.mu.Unlock()
224+
225+
resp, err := c.sendJSONLocked(msg)
226+
if err != nil && resp == nil && !c.closed {
227+
if reconnErr := c.reconnect(); reconnErr != nil {
228+
return nil, fmt.Errorf("send failed and reconnect failed: %w", err)
229+
}
230+
resp, err = c.sendJSONLocked(msg)
231+
}
232+
return resp, err
233+
}
234+
235+
func (c *BinaryClient) sendJSONLocked(msg map[string]interface{}) (map[string]interface{}, error) {
236+
body, err := json.Marshal(msg)
237+
if err != nil {
238+
return nil, fmt.Errorf("json encode: %w", err)
239+
}
240+
241+
if err := wireWriteFrame(c.conn, wireMsgJSON, body); err != nil {
242+
return nil, fmt.Errorf("send: %w", err)
243+
}
244+
245+
msgType, payload, readErr := wireReadFrame(c.conn)
246+
if readErr != nil {
247+
return nil, fmt.Errorf("recv: %w", readErr)
248+
}
249+
250+
if msgType == wireMsgError {
251+
errMsg := decodeWireError(payload)
252+
return map[string]interface{}{"type": "error", "error": errMsg}, fmt.Errorf("registry: %s", errMsg)
253+
}
254+
if msgType != wireMsgJSON {
255+
return nil, fmt.Errorf("unexpected response type 0x%02x for JSON passthrough", msgType)
256+
}
257+
258+
var resp map[string]interface{}
259+
if err := json.Unmarshal(payload, &resp); err != nil {
260+
return nil, fmt.Errorf("json decode response: %w", err)
261+
}
262+
if errMsg, ok := resp["error"].(string); ok {
263+
return resp, fmt.Errorf("registry: %s", errMsg)
264+
}
265+
return resp, nil
266+
}

pkg/registry/delta.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package registry
2+
3+
import (
4+
"encoding/json"
5+
"sync"
6+
)
7+
8+
// DeltaType identifies what kind of mutation a delta represents.
9+
type DeltaType uint8
10+
11+
const (
12+
DeltaRegister DeltaType = 1
13+
DeltaDeregister DeltaType = 2
14+
DeltaHeartbeat DeltaType = 3
15+
DeltaTrustAdd DeltaType = 4
16+
DeltaTrustRevoke DeltaType = 5
17+
DeltaVisibility DeltaType = 6
18+
DeltaHostname DeltaType = 7
19+
DeltaTags DeltaType = 8
20+
DeltaNetworkCreate DeltaType = 9
21+
DeltaNetworkJoin DeltaType = 10
22+
DeltaNetworkLeave DeltaType = 11
23+
DeltaKeyRotation DeltaType = 12
24+
DeltaTaskExec DeltaType = 13
25+
)
26+
27+
// DeltaEntry records a single state mutation for incremental replication.
28+
type DeltaEntry struct {
29+
SeqNo uint64 `json:"seq_no"`
30+
Type DeltaType `json:"type"`
31+
NodeID uint32 `json:"node_id,omitempty"`
32+
Data json.RawMessage `json:"data,omitempty"`
33+
}
34+
35+
// maxDeltaLogSize bounds the delta log to prevent unbounded memory growth.
36+
// At ~500 bytes per entry, 10K entries ≈ 5MB.
37+
const maxDeltaLogSize = 10000
38+
39+
// deltaLog is a bounded, append-only log of recent mutations.
40+
// When the log exceeds maxDeltaLogSize, oldest entries are discarded.
41+
// Standbys that fall behind the log window receive a full snapshot instead.
42+
type deltaLog struct {
43+
mu sync.Mutex
44+
entries []DeltaEntry
45+
nextSeq uint64
46+
}
47+
48+
func newDeltaLog() *deltaLog {
49+
return &deltaLog{
50+
entries: make([]DeltaEntry, 0, 1024),
51+
nextSeq: 1,
52+
}
53+
}
54+
55+
// Append adds a new delta entry to the log and returns its sequence number.
56+
func (dl *deltaLog) Append(typ DeltaType, nodeID uint32, data json.RawMessage) uint64 {
57+
dl.mu.Lock()
58+
defer dl.mu.Unlock()
59+
60+
seq := dl.nextSeq
61+
dl.nextSeq++
62+
63+
dl.entries = append(dl.entries, DeltaEntry{
64+
SeqNo: seq,
65+
Type: typ,
66+
NodeID: nodeID,
67+
Data: data,
68+
})
69+
70+
// Trim oldest entries if log exceeds max size
71+
if len(dl.entries) > maxDeltaLogSize {
72+
// Keep last maxDeltaLogSize entries
73+
excess := len(dl.entries) - maxDeltaLogSize
74+
copy(dl.entries, dl.entries[excess:])
75+
dl.entries = dl.entries[:maxDeltaLogSize]
76+
}
77+
78+
return seq
79+
}
80+
81+
// Since returns all entries with SeqNo > sinceSeq.
82+
// Returns nil if sinceSeq is too old (before the oldest entry in the log).
83+
// The caller should fall back to a full snapshot in that case.
84+
func (dl *deltaLog) Since(sinceSeq uint64) []DeltaEntry {
85+
dl.mu.Lock()
86+
defer dl.mu.Unlock()
87+
88+
if len(dl.entries) == 0 {
89+
return nil
90+
}
91+
92+
// If requested seq is before our oldest entry, caller needs full snapshot
93+
if sinceSeq < dl.entries[0].SeqNo {
94+
return nil
95+
}
96+
97+
// Binary search for the start position
98+
start := 0
99+
for start < len(dl.entries) && dl.entries[start].SeqNo <= sinceSeq {
100+
start++
101+
}
102+
103+
if start >= len(dl.entries) {
104+
return []DeltaEntry{} // up to date, no new entries
105+
}
106+
107+
// Return a copy to avoid data races
108+
result := make([]DeltaEntry, len(dl.entries)-start)
109+
copy(result, dl.entries[start:])
110+
return result
111+
}
112+
113+
// CurrentSeq returns the most recent sequence number.
114+
func (dl *deltaLog) CurrentSeq() uint64 {
115+
dl.mu.Lock()
116+
defer dl.mu.Unlock()
117+
if dl.nextSeq == 0 {
118+
return 0
119+
}
120+
return dl.nextSeq - 1
121+
}
122+
123+
// Len returns the number of entries currently in the log.
124+
func (dl *deltaLog) Len() int {
125+
dl.mu.Lock()
126+
defer dl.mu.Unlock()
127+
return len(dl.entries)
128+
}

0 commit comments

Comments
 (0)