Skip to content

Commit 5ba3d7c

Browse files
otroanclaude
andcommitted
adapter/statsclient: read ring buffers incrementally
A ring-buffer stat could only be read whole. CopyEntryData copies every thread's entire ring into a fresh allocation, so the cost of a read is the size of the ring rather than the number of entries produced into it - which is the wrong way round. A ring is sized for burst headroom, so making it big enough to survive a stall makes it too expensive to poll: an 8192-entry ring of 128-byte records is 1 MiB copied and allocated per thread per read, and at the 10 ms cadence a fast producer needs, on eight workers, that is 800 MiB/s of copying and garbage to deliver a few hundred entries. A 16M-entry ring is 2 GiB per thread per read and cannot be polled at all. adapter.RingBufferWindowStat copies only what the producers appended since the previous refresh, into buffers it already owns. On an 8192-entry ring delivering 256 entries: RingBufferStat 483 us, 1 MiB, 5 allocations RingBufferWindowStat 4.1 us, none 118x, and the ratio grows with the ring: ring size no longer appears in the cost of a read. The read cursor is consumer-side state, a field on the RingBufferWindowStat the caller holds rather than anything in the segment, which is mapped read-only. So a window refreshes through UpdateDir like any other prepared entry - the switch in UpdateEntryData already dispatches on the stat's dynamic type. CopyEntryData deliberately never produces one: windowing needs a cursor carried between calls, and only the consumer has anywhere to keep it. Entries are located from the sequence and not from head. The two are congruent - both start at zero and advance together on every commit - but a producer stores head plainly and then publishes the sequence with a release store, so a consumer reading the pair astride a commit sees a head one slot ahead of the sequence that explains it. Positioning off head then delivers the slot a worker is writing and skips the oldest live entry, silently and only under load. The sequence is the only field of the two that carries an ordering guarantee, and it is also the one that publishes the entry bytes. A copy is validated against the producer afterwards. The segment's optimistic lock covers directory changes, not ring data, so a worker is free to overwrite the slots being copied while the copy runs - most easily in the configuration this is for, a large window on a fast producer. Re-reading the sequence after the copy identifies the entries that were overwritten underneath it; they are the oldest of the window, so dropping them from the front leaves exactly the ones that are still whole, and they are counted as lost. Delivering a record that is half one entry and half another while reporting no loss would be worse than losing it, because loss is visible and tearing is not. Lost and Pending are separate. Lost counts entries the producer overwrote before the reader reached them, which are gone; Pending counts entries still in the ring that MaxEntries held back, which the next read delivers. They call for opposite responses - read again now, against the reader is not keeping up - so a single "missed" figure would tell a rate-limited reader it was losing data. PrepareRingBuffer exists because PrepareDir cannot express this: it populates every entry by way of CopyEntryData, so preparing a ring copies it once even if every later read is windowed, and that copy repeats on every epoch change. It is reached through adapter.RingBufferAPI rather than StatsAPI, because serving a window means carrying that cursor between refreshes and a mock or a v1 segment has nowhere to keep one; callers type-assert for it. The three region bounds checks are now in one helper that both the windowed and the whole-ring path use. They are what stands between a racing header and an out-of-bounds read of the mapped segment, so a second copy of them is a second chance to get one subtly wrong; go vet's unsafe.Pointer warnings for this file drop from three to none as a side effect. Tested against the synthetic v2 segment: first-refresh positioning with and without SkipBacklog, delivering only what is new, unwrapping across the ring's seam, exact loss on a lap, the cap and the drain loop that empties it, re-sync on a sequence rewind, per-thread independence, a head published ahead of its sequence, entries overwritten while the copy runs, and that a steady refresh allocates nothing. The last two are reachable only by racing a real producer, so an unexported hook between the copy and its validation stands in for the worker; it is nil in production and costs one nil check per thread per refresh. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent dcc9074 commit 5ba3d7c

4 files changed

Lines changed: 993 additions & 72 deletions

File tree

adapter/stats_api.go

Lines changed: 124 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -188,15 +188,111 @@ type RingBufferStat struct {
188188
Data [][]byte // per-thread raw ring data
189189
}
190190

191-
func (ScalarStat) isStat() {}
192-
func (ErrorStat) isStat() {}
193-
func (SimpleCounterStat) isStat() {}
194-
func (CombinedCounterStat) isStat() {}
195-
func (NameStat) isStat() {}
196-
func (EmptyStat) isStat() {}
197-
func (GaugeStat) isStat() {}
198-
func (HistogramLog2Stat) isStat() {}
199-
func (RingBufferStat) isStat() {}
191+
// RingBufferAPI is implemented by adapters that can read a ring-buffer stat
192+
// incrementally, and is separate from StatsAPI because not every adapter can:
193+
// serving a window means carrying a read cursor between refreshes. That cursor
194+
// is consumer-side state - the segment itself is mapped read-only - and a mock
195+
// or a v1 segment has nowhere to keep one. Callers type-assert for it.
196+
type RingBufferAPI interface {
197+
// PrepareRingBuffer resolves one ring-buffer stat by name and returns a
198+
// StatDir holding an incremental reader for it, to be refreshed with
199+
// UpdateDir. maxEntries bounds entries delivered per thread per refresh, zero
200+
// meaning the ring size; skipBacklog starts at the producer's head rather
201+
// than at the oldest entry the ring still holds.
202+
PrepareRingBuffer(name string, maxEntries uint32, skipBacklog bool) (*StatDir, error)
203+
}
204+
205+
// RingBufferWindow is the run of entries one producer thread appended since a
206+
// consumer last read it.
207+
type RingBufferWindow struct {
208+
// Entries holds Count entries of RingBufferConfig.EntrySize bytes each,
209+
// oldest first and already unwrapped, so a consumer indexes it without
210+
// knowing where the ring's seam fell.
211+
//
212+
// It aliases a buffer the stat owns and reuses, so it is only valid until
213+
// the next refresh. Copy anything that must outlive that.
214+
//
215+
// The slice always starts at the buffer's origin and is capped to the entries
216+
// this refresh delivered, so cap(Entries) is the room a refresh has to fill
217+
// and there is no second buffer field to keep in step with it.
218+
Entries []byte
219+
Count uint32
220+
221+
// FirstSeq is the producer sequence of Entries[0], and NextSeq the sequence
222+
// the following read will start from. Both are absolute counts of entries
223+
// this thread has ever written, so they stay meaningful across wraps.
224+
FirstSeq uint64
225+
NextSeq uint64
226+
227+
// Lost counts entries the producer overwrote before this read reached them:
228+
// data that is gone. Pending counts entries still in the ring that this read
229+
// did not return because MaxEntries capped it: data that the next read will
230+
// deliver.
231+
//
232+
// They are separate because they call for opposite responses. Pending means
233+
// read again immediately; Lost means the reader is not keeping up and the
234+
// gap is unrecoverable. A single "missed" figure would conflate a reader
235+
// that is behind with one that is merely rate-limited.
236+
Lost uint64
237+
Pending uint64
238+
}
239+
240+
// RingBufferWindowStat reads a ring buffer incrementally: every refresh copies
241+
// only what the producers appended since the previous one, into buffers the stat
242+
// already owns.
243+
//
244+
// This is the difference between a cost proportional to entries produced and one
245+
// proportional to ring size. RingBufferStat copies the whole ring, for every
246+
// thread, into a fresh allocation on every read - so a ring sized for burst
247+
// headroom rather than for poll latency becomes unreadable long before it
248+
// becomes useful. A 16M-entry ring of 128-byte records is 2 GiB per thread per
249+
// read as a RingBufferStat, and the entries actually produced as this.
250+
//
251+
// Put one in a prepared StatDir entry's Data and refresh it with
252+
// StatsClient.UpdateDir, or let StatsClient.PrepareRingBuffer build both.
253+
// CopyEntryData never produces one: windowing needs a read cursor, and only the
254+
// consumer has it.
255+
//
256+
// The zero value is valid and self-initialising. The first refresh reads the
257+
// geometry, allocates the per-thread buffers, and positions the cursor - at the
258+
// oldest entry the ring still holds, or at the producer's head if SkipBacklog is
259+
// set - and returns no entries. SkipBacklog only decides where that first
260+
// refresh starts and is ignored afterwards; MaxEntries is honoured on every
261+
// refresh.
262+
type RingBufferWindowStat struct {
263+
// MaxEntries bounds how many entries one refresh delivers per thread, and so
264+
// bounds both the buffer this stat allocates and the work one refresh does.
265+
// Zero means the ring size, which is the largest window that can ever be
266+
// available. A consumer draining a fast producer wants this small enough to
267+
// bound a single read and to loop while Pending is non-zero.
268+
//
269+
// It is read on every refresh, so raising or lowering it between refreshes
270+
// takes effect on the next one; the buffers grow to match and are not shrunk.
271+
MaxEntries uint32
272+
273+
// SkipBacklog starts the first read at the producer's head rather than at the
274+
// oldest entry still in the ring, so a consumer that wants only what happens
275+
// from now on does not first have to read and discard a ring of history.
276+
SkipBacklog bool
277+
278+
Config RingBufferConfig
279+
Threads []RingBufferThreadMeta
280+
Schema []byte
281+
282+
// Windows holds one window per producer thread, in thread order.
283+
Windows []RingBufferWindow
284+
}
285+
286+
func (ScalarStat) isStat() {}
287+
func (ErrorStat) isStat() {}
288+
func (SimpleCounterStat) isStat() {}
289+
func (CombinedCounterStat) isStat() {}
290+
func (NameStat) isStat() {}
291+
func (EmptyStat) isStat() {}
292+
func (GaugeStat) isStat() {}
293+
func (HistogramLog2Stat) isStat() {}
294+
func (RingBufferStat) isStat() {}
295+
func (*RingBufferWindowStat) isStat() {}
200296

201297
func (s ScalarStat) IsZero() bool {
202298
return s == 0
@@ -352,6 +448,25 @@ func (s RingBufferStat) Type() StatType {
352448
return RingBuffer
353449
}
354450

451+
func (s *RingBufferWindowStat) IsZero() bool {
452+
return s.Config.NThreads == 0 || s.Config.EntrySize == 0
453+
}
454+
455+
func (s *RingBufferWindowStat) Type() StatType {
456+
return RingBuffer
457+
}
458+
459+
func (s *RingBufferWindowStat) String() string {
460+
var b strings.Builder
461+
fmt.Fprintf(&b, "\n config: entry_size=%d, ring_size=%d, threads=%d, schema_version=%d, schema_size=%d",
462+
s.Config.EntrySize, s.Config.RingSize, s.Config.NThreads, s.Config.SchemaVersion, s.Config.SchemaSize)
463+
for i, w := range s.Windows {
464+
fmt.Fprintf(&b, "\n thread[%d]: entries=%d first_seq=%d next_seq=%d lost=%d pending=%d",
465+
i, w.Count, w.FirstSeq, w.NextSeq, w.Lost, w.Pending)
466+
}
467+
return b.String()
468+
}
469+
355470
func (s RingBufferStat) String() string {
356471
var b strings.Builder
357472
fmt.Fprintf(&b, "\n config: entry_size=%d, ring_size=%d, threads=%d, schema_version=%d, schema_size=%d",

adapter/statsclient/statsclient.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -769,3 +769,77 @@ func symlinkItem(full adapter.Stat, item uint32, dst *adapter.Stat) bool {
769769
}
770770
return false
771771
}
772+
773+
var _ adapter.RingBufferAPI = (*StatsClient)(nil)
774+
775+
// PrepareRingBuffer resolves one ring-buffer stat by name and returns a StatDir
776+
// holding an incremental reader for it, to be refreshed with UpdateDir.
777+
//
778+
// It exists because PrepareDir cannot express this: PrepareDir populates every
779+
// entry it prepares by way of CopyEntryData, which for a ring buffer copies the
780+
// whole ring. On a ring sized for burst headroom that one copy at prepare time is
781+
// the largest allocation the process makes, and it is repeated on every epoch
782+
// change. Here nothing is read but the directory entry itself; the first
783+
// UpdateDir reads the geometry and sizes the reader's buffers from maxEntries.
784+
//
785+
// maxEntries bounds entries per thread per refresh, and so bounds both the
786+
// buffers allocated and the work one refresh does; zero means the ring size. A
787+
// consumer draining a fast producer should set it and loop while any window
788+
// reports Pending. skipBacklog starts at the producer's head rather than at the
789+
// oldest entry still in the ring.
790+
//
791+
// name is matched exactly, not as a pattern: this returns one entry or an error,
792+
// because a caller reading a specific producer's records has nothing sensible to
793+
// do with a second ring that happened to match.
794+
func (sc *StatsClient) PrepareRingBuffer(name string, maxEntries uint32, skipBacklog bool) (*adapter.StatDir, error) {
795+
sc.accessLock.RLock()
796+
defer sc.accessLock.RUnlock()
797+
798+
if !sc.isConnected() {
799+
return nil, adapter.ErrStatsDisconnected
800+
}
801+
802+
accessEpoch := sc.accessStart()
803+
if accessEpoch == 0 {
804+
return nil, adapter.ErrStatsAccessFailed
805+
}
806+
807+
vector := sc.GetDirectoryVector()
808+
if vector == nil {
809+
return nil, fmt.Errorf("failed to prepare ring buffer: directory vector is nil")
810+
}
811+
812+
want := []byte(name)
813+
var entry *adapter.StatEntry
814+
vecLen := *(*uint32)(vectorLen(vector))
815+
for i := uint32(0); i < vecLen; i++ {
816+
// Compared in place: GetStatDirOnIndex clones every name it walks past,
817+
// and on a real directory that is thousands of allocations to find one
818+
// entry.
819+
_, dirType, ok := sc.StatDirOnIndexMatches(vector, i, want)
820+
if !ok {
821+
continue
822+
}
823+
if dirType != adapter.RingBuffer {
824+
return nil, fmt.Errorf("stat %q is %v, not a ring buffer", name, dirType)
825+
}
826+
entry = &adapter.StatEntry{
827+
StatIdentifier: adapter.StatIdentifier{Index: i, Name: want},
828+
Type: adapter.RingBuffer,
829+
Data: &adapter.RingBufferWindowStat{
830+
MaxEntries: maxEntries,
831+
SkipBacklog: skipBacklog,
832+
},
833+
}
834+
break
835+
}
836+
if entry == nil {
837+
return nil, fmt.Errorf("ring buffer stat %q not found", name)
838+
}
839+
840+
if !sc.accessEnd(accessEpoch) {
841+
return nil, adapter.ErrStatsDataBusy
842+
}
843+
844+
return &adapter.StatDir{Epoch: accessEpoch, Entries: []adapter.StatEntry{*entry}}, nil
845+
}

0 commit comments

Comments
 (0)