Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 82 additions & 27 deletions spectator/writer/lowlatency_buffer.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,35 +31,47 @@ const separator = "\n"
// is less than the impact of the front and back buffer design, but it is still important for
// throughput reasons.
type bufferShard struct {
data [][]byte // Array of chunkSize chunks of spectatord protocol lines, stored as bytes
chunkIndex int // Index of the chunk available for writes
overflows int // Count the buffer overflows, which correspond to data drops, for reporting metrics
overflowBytes int64 // Count the bytes that were dropped, for reporting metrics
mu sync.Mutex
data [][]byte // Array of chunkSize chunks of spectatord protocol lines, stored as bytes
chunkIndex int // Index of the chunk available for writes
overflows int // Count the buffer overflows, which correspond to data drops, for reporting metrics
overflowBytes int64 // Count the bytes that were dropped, for reporting metrics
droppedSampleId string // Meter id of one dropped line this window, for the overflow log line
mu sync.Mutex
}

// getChunkIndexForLine returns the chunkIndex that should be used for storing the line, or -1, if there is
// an overflow and the line cannot be stored in the bufferShard. It reports
// whether space was reserved (true) or the data must be dropped (false); on
// success b.chunkIndex identifies the chunk to append into (advancing it if the
// line does not fit in the current chunk).
func (b *bufferShard) reserveChunkForLine(lineLen int) bool {
totalWriteLength := lineLen
// recordOverflow accounts for a dropped line's byte count and, on the first drop of the
// window (overflows was 0), samples its meter id via meterIdKey so the once-per-window log
// line can name a metric instead of just a count.
func (b *bufferShard) recordOverflow(prefix string, totalWriteLength int) {
if b.overflows == 0 {
if id, ok := meterIdKey(prefix); ok {
b.droppedSampleId = id

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a substring of the caller's string, so it keeps the whole original allocation alive until the shard next reports (possibly indefinitely given the early-return issue in flushBufferShard). For a multi-MB oversized line that could be significant. strings.Clone of a truncated id would avoid it; it only happens once per window so the cost is negligible.

} else {
b.droppedSampleId = prefix

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If meterIdKey fails, the raw prefix is stored and later logged verbatim with no length limit and no escaping. A malformed line containing \n can split or forge log records, and a malformed line over 60KB (the oversized path) would be logged in full every window. Suggest truncating to a bounded length and formatting with %q.

}
}
b.overflows++
b.overflowBytes += int64(totalWriteLength)
}

// reserveChunkForLine reports whether space was reserved for a line of lineLen bytes (true)
// or the buffer is full and the line must be dropped (false). On success b.chunkIndex
// identifies the chunk to append into (advancing it if the line does not fit in the current
// chunk). It does not record overflow stats itself: the caller has the line's prefix (needed
// to sample a dropped meter id for the overflow log line) and calls recordOverflow when this
// returns false.
func (b *bufferShard) reserveChunkForLine(lineLen int) bool {
// All chunks are full for the shard, drop the data
if b.chunkIndex >= len(b.data) {
b.overflows++
b.overflowBytes += int64(totalWriteLength)
return false
}

// This should not happen, drop the data. The maximum length of a well-formed protocol line is 3.8KB.
if lineLen > chunkSize {
b.overflows++
b.overflowBytes += int64(totalWriteLength)
return false
}

totalWriteLength := lineLen
if len(b.data[b.chunkIndex]) > 0 {
// Chunk has data, so account for the separator character
totalWriteLength++
Expand All @@ -72,8 +84,6 @@ func (b *bufferShard) reserveChunkForLine(lineLen int) bool {

// Out of space in the shard, drop the data
if b.chunkIndex == len(b.data) {
b.overflows++
b.overflowBytes += int64(totalWriteLength)
return false
}

Expand Down Expand Up @@ -230,7 +240,7 @@ func (b *bufferShard) appendLine(state *atomic.Uint64, expected uint64, prefix,
return false
}
if !b.reserveChunkForLine(len(prefix) + len(value)) {
// overflows (drops) are counted in reserveChunkForLine, for metric reporting
b.recordOverflow(prefix, len(prefix)+len(value))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Behavior change for the overflowBytes metric: previously the drop path in reserveChunkForLine that advanced chunkIndex counted totalWriteLength (including the +1 separator); now it counts len(prefix)+len(value). Small, but it silently shifts the metric, and the new test encodes the new semantics. Intentional?

return true
}
if len(b.data[b.chunkIndex]) > 0 {
Expand All @@ -250,6 +260,7 @@ func (b *bufferShard) appendLineBytes(state *atomic.Uint64, expected uint64, pre
return false
}
if !b.reserveChunkForLine(len(prefix) + len(value)) {
b.recordOverflow(prefix, len(prefix)+len(value))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This duplicates the recordOverflow call in appendLine, so every caller of reserveChunkForLine now has to remember to record the overflow after a false return, and these two methods already have a keep-in-sync note. Passing the prefix and value length into reserveChunkForLine and recording there would keep the invariant in one place.

return true
}
if len(b.data[b.chunkIndex]) > 0 {
Expand Down Expand Up @@ -302,6 +313,23 @@ func orderSensitiveMeterIdKey(line string) (string, bool) {
return line[first+1 : last], true
}

// meterIdKey returns the id substring of a spectatord protocol line for any
// meter type, order-sensitive or not. The input format is the same
// "<type>[,<extra>]:<id>:<value>" line; the id is the substring between the
// first and last ':'. Returns ok=false for malformed lines (fewer than two ':'
// characters).
func meterIdKey(line string) (string, bool) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this repeats the first/last-colon parsing in orderSensitiveMeterIdKey and meter.idFromLinePrefix, with slightly different guards (first < 0 vs first <= 0). orderSensitiveMeterIdKey could check the type symbol and then delegate to this.

first := strings.IndexByte(line, ':')
if first < 0 {
return "", false
}
last := strings.LastIndexByte(line, ':')
if last <= first {
return "", false
}
return line[first+1 : last], true
}

// isOrderSensitiveMeterType identifies protocol types that carry absolute samples rather than
// commutative observations. Reordering these samples can change what spectatord reports.
func isOrderSensitiveMeterType(symbol string) bool {
Expand Down Expand Up @@ -367,9 +395,22 @@ func (llb *LowLatencyBuffer) swapAndFlush() {
}

// Flush each buffer shard, from the deactivated set
var bytesWritten int
var bytesWritten, totalOverflows int
var totalOverflowBytes int64
var droppeSampleId string

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: typo droppeSampleId, should be droppedSampleId.

for _, buffer := range buffersToFlush {
bytesWritten += llb.flushBufferShard(buffer, bufferSet)
flushed := llb.flushBufferShard(buffer, bufferSet)
bytesWritten += flushed.bytesWritten
totalOverflows += flushed.overflows
totalOverflowBytes += flushed.overflowBytes
if droppeSampleId == "" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sample is always the first line dropped in the lowest-indexed overflowing shard, so it reflects emission order rather than drop volume. Meters emitted in a fixed order each interval (for example a gauge poller) would show the same name every window even if the high-volume culprit is elsewhere. Reservoir sampling (replace with probability 1/overflows per shard, and similarly across shards) would weight the sample by volume.

droppeSampleId = flushed.droppedSampleId
}
}

// Log a single overflow for each window.
if totalOverflows > 0 {
llb.logger.Infof("LowLatencyBuffer overflow in bufferSet=%s: dropped %d lines (%d bytes) this window; sample dropped meter id: %s", bufferSet, totalOverflows, totalOverflowBytes, droppeSampleId)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logs at Info on every flush window with no rate limit. Overflow is usually sustained (buffer too small for the app's volume), so with the default 5s interval that is ~17k lines/day per process. The overflows metric already signals the condition. Consider limiting to at most once per minute or similar.

}

pctUsage := float64(bytesWritten) / float64(llb.bufferSetSize)
Expand All @@ -383,14 +424,21 @@ func (llb *LowLatencyBuffer) swapAndFlush() {
llb.writer.WriteString(fmt.Sprintf("t:spectator-go.lowLatencyBuffer.flushTime,bufferSet=%s:%f", bufferSet, time.Since(start).Seconds()))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Infof call above now runs between start and this measurement, so flushTime includes logger I/O time. With a slow logger it inflates during overflow windows. Capture the elapsed time before logging, or log after emitting flushTime.

}

type flushBufferResult struct {
bytesWritten,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: bytesWritten, on its own line reads as if it has a separate type; bytesWritten, overflows int or separate declarations would be clearer.

overflows int
overflowBytes int64
droppedSampleId string
}

// flushBufferShard flushes a single bufferShard to the socket, iterating through all chunks
func (llb *LowLatencyBuffer) flushBufferShard(buffer *bufferShard, bufferSet string) int {
func (llb *LowLatencyBuffer) flushBufferShard(buffer *bufferShard, bufferSet string) flushBufferResult {
buffer.mu.Lock()
defer buffer.mu.Unlock()

// If there is no data to flush from the shard, then skip socket writes
if buffer.chunkIndex == 0 && len(buffer.data[0]) == 0 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This early return skips reporting and resetting overflows/overflowBytes/droppedSampleId. If the only thing that landed in a shard this window was an oversized line (the lineLen > chunkSize drop path), nothing is logged and no overflow metric is emitted, and the state carries over. In a later window with real drops, the log reports the merged count as "this window" and names the stale meter id from the earlier window. Since recordOverflow only samples when overflows == 0, the stale id is never replaced. Oversized lines are probably the main pathological case this is meant to diagnose. The overflow reporting and reset should happen before the early return.

return 0
return flushBufferResult{}
}

// Write each chunk to the socket, and reset the chunk
Expand All @@ -404,14 +452,21 @@ func (llb *LowLatencyBuffer) flushBufferShard(buffer *bufferShard, bufferSet str
}

// record status metrics and reset shard statistics
if buffer.overflows > 0 {
llb.writer.WriteString(fmt.Sprintf("c:spectator-go.lowLatencyBuffer.overflows,bufferSet=%s:%d", bufferSet, buffer.overflows))
llb.writer.WriteString(fmt.Sprintf("d:spectator-go.lowLatencyBuffer.overflowBytes,bufferSet=%s:%d", bufferSet, buffer.overflowBytes))
overflows, overflowBytes, sampleId := buffer.overflows, buffer.overflowBytes, buffer.droppedSampleId
if overflows > 0 {
llb.writer.WriteString(fmt.Sprintf("c:spectator-go.lowLatencyBuffer.overflows,bufferSet=%s:%d", bufferSet, overflows))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: swapAndFlush now has window-wide totals, but the overflow metrics are still written once per shard, up to 128 separate writes per window on a 64-CPU host during sustained overflow. Emitting the two aggregate lines once from swapAndFlush would reduce that to 2.

llb.writer.WriteString(fmt.Sprintf("d:spectator-go.lowLatencyBuffer.overflowBytes,bufferSet=%s:%d", bufferSet, overflowBytes))
buffer.overflows = 0
buffer.overflowBytes = 0
buffer.droppedSampleId = ""
}
buffer.chunkIndex = 0
return bytesWritten
return flushBufferResult{
bytesWritten: bytesWritten,
overflows: overflows,
overflowBytes: overflowBytes,
droppedSampleId: sampleId,
}
}

func (llb *LowLatencyBuffer) Close() {
Expand Down
174 changes: 173 additions & 1 deletion spectator/writer/lowlatency_buffer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ package writer

import (
"fmt"
"github.com/Netflix/spectator-go/v2/spectator/logger"
"runtime"
"slices"
"strings"
"sync"
"testing"
"time"

"github.com/Netflix/spectator-go/v2/spectator/logger"
)

func splitAndFilterMetricLines(memWriter *MemoryWriter) []string {
Expand Down Expand Up @@ -297,6 +299,42 @@ func TestLowLatencyBuffer_OrderSensitiveMeterIdKey(t *testing.T) {
}
}

func TestLowLatencyBuffer_MeterIdKey(t *testing.T) {
tests := []struct {
line string
key string
ok bool
}{
{line: "g:test.metric:1", key: "test.metric", ok: true},
{line: "g,60:test.metric:1", key: "test.metric", ok: true},
{line: "A:test.metric:1", key: "test.metric", ok: true},
// Unlike orderSensitiveMeterIdKey, commutative types yield an id
{line: "c:test.metric:1", key: "test.metric", ok: true},
{line: "d:test.metric:1", key: "test.metric", ok: true},
{line: "t:test.metric:1", key: "test.metric", ok: true},
{line: "gauge:test.metric:1", key: "test.metric", ok: true},
{line: "c:test.metric,app=foo:1", key: "test.metric,app=foo", ok: true},
// WriteLine passes the prefix alone, which ends with the value separator
{line: "c:test.metric:", key: "test.metric", ok: true},
{line: ":test.metric:1", key: "test.metric", ok: true},
{line: "message=1", ok: false},
{line: "g:test.metric", ok: false},
{line: "", ok: false},
}

for _, test := range tests {
t.Run(test.line, func(t *testing.T) {
key, ok := meterIdKey(test.line)
if ok != test.ok {
t.Fatalf("Expected ok=%t, got %t", test.ok, ok)
}
if key != test.key {
t.Fatalf("Expected key %q, got %q", test.key, key)
}
})
}
}

func TestLowLatencyBuffer_RoundRobinsSameCounterId(t *testing.T) {
shards := runtime.NumCPU()
if shards < 2 {
Expand Down Expand Up @@ -380,3 +418,137 @@ func TestLowLatencyBuffer_ChunkBoundaries_OverMaxSizeIsDropped(t *testing.T) {
t.Errorf("Expected %d lines, got %d", 0, len(lines))
}
}

// captureLogger records Infof messages, so the once-per-flush-window overflow log line
// can be inspected.
type captureLogger struct {
mu sync.Mutex
messages []string
}

var _ logger.Logger = (*captureLogger)(nil)

func (l *captureLogger) Debugf(_ string, _ ...interface{}) {}

func (l *captureLogger) Infof(format string, v ...interface{}) {
l.mu.Lock()
defer l.mu.Unlock()
l.messages = append(l.messages, fmt.Sprintf(format, v...))
}

func (l *captureLogger) Errorf(_ string, _ ...interface{}) {}

// overflowMessages returns the overflow log lines, discarding the initialization message.
func (l *captureLogger) overflowMessages() []string {
l.mu.Lock()
defer l.mu.Unlock()
var messages []string
for _, message := range l.messages {
if strings.Contains(message, "LowLatencyBuffer overflow") {
messages = append(messages, message)
}
}
return messages
}

// newSingleChunkBuffer creates a buffer with one chunk per shard and a long flush interval
// timer, to allow for manual flush trigger. One chunk per shard means a single chunkSize
// line fills a shard, and every subsequent line written to it is dropped.
func newSingleChunkBuffer(t *testing.T, memWriter *MemoryWriter, log logger.Logger) *LowLatencyBuffer {
t.Helper()
shards := runtime.NumCPU()
bufferSize := 2 * chunkSize * shards // two buffer sets, one 60 KB chunk/shard
buffer := NewLowLatencyBuffer(memWriter, log, bufferSize, 3*time.Minute)
t.Cleanup(buffer.Close)
return buffer
}

// appendToShard writes a line to a specific shard, bypassing shard selection, so tests can
// drive overflows deterministically regardless of the number of CPUs.
func appendToShard(t *testing.T, buffer *LowLatencyBuffer, shard *bufferShard, line string) {
t.Helper()
state := buffer.activeBufferState.Load()
if !shard.appendLine(&buffer.activeBufferState, state, line, "") {
t.Fatalf("Expected line %q to be accepted by the active buffer set", line)
}
}

// fillShard writes one chunkSize line to the shard, which leaves the shard with data to
// flush and no room for any further line.
func fillShard(t *testing.T, buffer *LowLatencyBuffer, shard *bufferShard) {
t.Helper()
const prefix = "g:overflow.fill:"
appendToShard(t, buffer, shard, prefix+strings.Repeat("1", chunkSize-len(prefix)))

shard.mu.Lock()
defer shard.mu.Unlock()
if shard.overflows != 0 {
t.Fatalf("Expected the fill line to be stored, got %d overflow(s)", shard.overflows)
}
}

func TestBufferShard_RecordOverflowSamplesFirstDroppedId(t *testing.T) {
shard := &bufferShard{}

shard.recordOverflow("g:first.dropped:1", 17)
shard.recordOverflow("g:second.dropped:1", 18)

if shard.overflows != 2 {
t.Errorf("Expected %d overflows, got %d", 2, shard.overflows)
}
if shard.overflowBytes != 35 {
t.Errorf("Expected %d overflow bytes, got %d", 35, shard.overflowBytes)
}
if shard.droppedSampleId != "first.dropped" {
t.Errorf("Expected the first dropped meter id to be sampled, got %q", shard.droppedSampleId)
}
}

func TestLowLatencyBuffer_OverflowLogsOncePerFlushWindow(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only covers a single window. Worth adding a second swapAndFlush cycle with a different dropped id to verify the sample and counters reset, plus a case where the only line in a shard is an oversized one. Those would catch the early-return issue in flushBufferShard.

memWriter := &MemoryWriter{}
log := &captureLogger{}
buffer := newSingleChunkBuffer(t, memWriter, log)

shard := buffer.frontBuffers[0]
fillShard(t, buffer, shard)

dropped := []string{"g:overflow.dropped:1", "g:overflow.dropped:2", "c:other.dropped:3"}
var droppedBytes int
for _, line := range dropped {
appendToShard(t, buffer, shard, line)
droppedBytes += len(line)
}

buffer.swapAndFlush()

messages := log.overflowMessages()
if len(messages) != 1 {
t.Fatalf("Expected 1 overflow log line for the flush window, got %d: %v", len(messages), messages)
}
message := messages[0]
for _, want := range []string{
"bufferSet=front",
fmt.Sprintf("dropped %d lines (%d bytes)", len(dropped), droppedBytes),
"sample dropped meter id: overflow.dropped",
} {
if !strings.Contains(message, want) {
t.Errorf("Expected overflow log line to contain %q, got %q", want, message)
}
}

// The per-shard overflow metrics are unchanged by the log line
overflowMetrics := map[string]bool{
fmt.Sprintf("c:spectator-go.lowLatencyBuffer.overflows,bufferSet=front:%d", len(dropped)): false,
fmt.Sprintf("d:spectator-go.lowLatencyBuffer.overflowBytes,bufferSet=front:%d", droppedBytes): false,
}
for _, line := range memWriter.Lines() {
if _, ok := overflowMetrics[line]; ok {
overflowMetrics[line] = true
}
}
for line, found := range overflowMetrics {
if !found {
t.Errorf("Expected metric line %q in MemoryWriter, got %v", line, memWriter.Lines())
}
}
}
Loading