Skip to content

Commit 98c75ad

Browse files
committed
chore: WIP staging — replication hasSubs, persist buf pool, cgo WaitForTrust, regen-plain brace escape
- pkg/registry/replication.go: hasSubs() helper lets replicaPushLoop skip the 50-100 MB snapshot build when no replicas are attached. - pkg/registry/server/server_persist.go + wal.go: reuse the snapshot bytes.Buffer across save ticks via a sync.Pool pre-grown to 128 MB, eliminating the ~1 GB live growSlice heap that drove GC STW pauses. - sdk/cgo/bindings.go: expose PilotWaitForTrust for foreign-language SDKs that need a blocking handshake completion primitive. - web/scripts/regen-plain.mjs: escape { } when rendering Astro plain mirrors, so JSON examples with curly braces don't trip JSX parsing.
1 parent 772d60d commit 98c75ad

5 files changed

Lines changed: 88 additions & 13 deletions

File tree

pkg/registry/replication.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,17 @@ func newReplicationManager() *replicationManager {
3636
}
3737
}
3838

39+
// hasSubs reports whether any replication subscriber is currently
40+
// connected. Used by replicaPushLoop to skip building the full
41+
// snapshot (which can be 50-100 MB and allocate 1 GB/sec at 170k
42+
// nodes) when no replicas are attached — most rendezvous deployments
43+
// run without replicas in steady state.
44+
func (rm *replicationManager) hasSubs() bool {
45+
rm.mu.Lock()
46+
defer rm.mu.Unlock()
47+
return len(rm.subs) > 0
48+
}
49+
3950
// addSub registers a connection as a replication subscriber.
4051
func (rm *replicationManager) addSub(conn net.Conn) {
4152
rm.mu.Lock()

pkg/registry/server/server_persist.go

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
package server
44

55
import (
6+
"bytes"
67
"crypto/sha256"
78
"encoding/base64"
89
"encoding/hex"
@@ -11,6 +12,7 @@ import (
1112
"log/slog"
1213
"os"
1314
"path/filepath"
15+
"sync"
1416
"time"
1517

1618
"github.com/TeoSlayer/pilotprotocol/internal/fsutil"
@@ -20,6 +22,19 @@ import (
2022
"github.com/TeoSlayer/pilotprotocol/pkg/urlvalidate"
2123
)
2224

25+
// flushSaveBufPool reuses the bytes buffer that backs the snapshot JSON
26+
// across save ticks. Pre-grown to 128 MB so the first save at fleet
27+
// scale (~50-100 MB JSON) doesn't grow it further. After the first
28+
// save the pool always returns a buffer at peak capacity, so subsequent
29+
// saves do zero allocation in the encode path. Eliminates the ~1 GB
30+
// live `bytes.growSlice` heap that was driving GC STW pauses.
31+
var flushSaveBufPool = sync.Pool{
32+
New: func() interface{} {
33+
b := make([]byte, 0, 128*1024*1024)
34+
return &b
35+
},
36+
}
37+
2338
// rawNodeCopy holds raw node fields copied under RLock (no encoding).
2439
// base64/time.Format happens outside the lock to minimize lock hold time.
2540
type rawNodeCopy struct {
@@ -383,22 +398,47 @@ func (s *Server) flushSave() error {
383398
}
384399
s.auditMu.Unlock()
385400

386-
// Compute checksum: marshal once without checksum (omitempty omits it), hash,
387-
// then inject the checksum into the JSON without a second marshal.
401+
// Compute checksum: encode once without checksum (omitempty omits it), hash,
402+
// then inject the checksum into the JSON without a second encode.
403+
//
404+
// 2026-05-14: switched from json.Marshal (one-shot allocate) to json.Encoder
405+
// writing into a pooled bytes.Buffer. At 170k+ active nodes the snapshot is
406+
// ~50-100 MB and was the dominant heap allocator (~1 GB live, GC pressure
407+
// causing kernel UDP drops during STW pauses). The pooled buffer is reused
408+
// across save ticks — the underlying slice grows once and stays at peak,
409+
// no per-tick allocation thereafter.
388410
snap.Checksum = ""
389-
data, err := json.Marshal(snap)
390-
if err != nil {
391-
slog.Error("registry save marshal error", "err", err)
392-
return fmt.Errorf("marshal snapshot: %w", err)
411+
bp := flushSaveBufPool.Get().(*[]byte)
412+
buf := bytes.NewBuffer((*bp)[:0])
413+
enc := json.NewEncoder(buf)
414+
enc.SetEscapeHTML(false)
415+
if err := enc.Encode(snap); err != nil {
416+
*bp = buf.Bytes()[:0]
417+
flushSaveBufPool.Put(bp)
418+
slog.Error("registry save encode error", "err", err)
419+
return fmt.Errorf("encode snapshot: %w", err)
420+
}
421+
data := buf.Bytes()
422+
// json.Encoder.Encode appends a newline; drop it to match prior Marshal output.
423+
if len(data) > 0 && data[len(data)-1] == '\n' {
424+
data = data[:len(data)-1]
393425
}
394426
hash := sha256.Sum256(data)
395427
checksum := hex.EncodeToString(hash[:])
396-
// Insert "checksum":"<hex>" before the closing brace. json.Marshal of a struct
397-
// always produces a JSON object ending with '}' (no trailing whitespace).
428+
// Insert "checksum":"<hex>" before the closing brace. json.Encoder of a struct
429+
// always produces a JSON object ending with '}' (after newline trim).
398430
if len(data) == 0 || data[len(data)-1] != '}' {
399-
return fmt.Errorf("marshal snapshot: unexpected JSON format (expected trailing '}')")
431+
*bp = buf.Bytes()[:0]
432+
flushSaveBufPool.Put(bp)
433+
return fmt.Errorf("encode snapshot: unexpected JSON format (expected trailing '}')")
400434
}
401435
data = append(data[:len(data)-1], []byte(`,"checksum":"`+checksum+`"}`)...)
436+
defer func() {
437+
// Return the (possibly grown) underlying buffer to the pool. AtomicWrite
438+
// has copied data to disk by this point, so it is safe to release.
439+
*bp = data[:0]
440+
flushSaveBufPool.Put(bp)
441+
}()
402442

403443
// Persist to disk atomically
404444
if s.storePath != "" {

pkg/registry/server/wal/wal.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,13 @@ type StoreCallbacks struct {
289289
Push func([]byte)
290290
// IncSaveFailures increments the save-failure metric counter.
291291
IncSaveFailures func()
292+
// HasReplicaSubs reports whether any replication subscriber is
293+
// currently attached. replicaPushLoop uses this to skip the full
294+
// snapshot build when no replicas are listening — at fleet scale
295+
// the snapshot allocates ~1 GB/sec, so skipping is a large win
296+
// for deployments that run without replicas. May be nil; nil is
297+
// treated as "always push" for backward compat.
298+
HasReplicaSubs func() bool
292299
}
293300

294301
// Store manages the WAL file, save channels, standby flag, and replication
@@ -464,8 +471,10 @@ func (st *Store) replicaPushLoop() {
464471
dirty = true
465472
case <-ticker.C:
466473
if dirty {
467-
if data := st.cbs.SnapshotJSON(); data != nil {
468-
st.cbs.Push(data)
474+
if st.cbs.HasReplicaSubs == nil || st.cbs.HasReplicaSubs() {
475+
if data := st.cbs.SnapshotJSON(); data != nil {
476+
st.cbs.Push(data)
477+
}
469478
}
470479
dirty = false
471480
}
@@ -476,7 +485,7 @@ func (st *Store) replicaPushLoop() {
476485
dirty = true
477486
default:
478487
}
479-
if dirty {
488+
if dirty && (st.cbs.HasReplicaSubs == nil || st.cbs.HasReplicaSubs()) {
480489
if data := st.cbs.SnapshotJSON(); data != nil {
481490
st.cbs.Push(data)
482491
}

sdk/cgo/bindings.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,19 @@ func PilotHandshake(h C.uint64_t, nodeID C.uint32_t, justification *C.char) *C.c
143143
return okJSON(r)
144144
}
145145

146+
//export PilotWaitForTrust
147+
func PilotWaitForTrust(h C.uint64_t, nodeID C.uint32_t, timeoutMs C.uint32_t) *C.char {
148+
d, err := driverFromHandle(h)
149+
if err != nil {
150+
return errJSON(err)
151+
}
152+
r, err := d.WaitForTrust(uint32(nodeID), uint32(timeoutMs))
153+
if err != nil {
154+
return errJSON(err)
155+
}
156+
return okJSON(r)
157+
}
158+
146159
//export PilotApproveHandshake
147160
func PilotApproveHandshake(h C.uint64_t, nodeID C.uint32_t) *C.char {
148161
d, err := driverFromHandle(h)

web/scripts/regen-plain.mjs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,9 @@ function escapeHtml(s) {
244244
return String(s)
245245
.replace(/&/g, '&amp;')
246246
.replace(/</g, '&lt;')
247-
.replace(/>/g, '&gt;');
247+
.replace(/>/g, '&gt;')
248+
.replace(/\{/g, '&#123;')
249+
.replace(/\}/g, '&#125;');
248250
}
249251

250252
function renderBlock(block) {

0 commit comments

Comments
 (0)