Skip to content

Commit f117497

Browse files
Track WAL segments and compact acknowledged files
Make WAL writes and replays segment-aware and compact acknowledged segments. Introduce bufferedSample to RingBuffer to carry walSegment IDs, add PushWithWALSegment/DrainAllWithWALSegments, and update buffer/peek semantics. Extend WAL with WriteSampleWithSegment/WriteRecordWithSegment and ReplaySamplesWithSegments (returns segment indices) and perform compaction in AcknowledgeThrough by removing fully acknowledged segment files. Update Agent to write samples with segment IDs, preserve segment metadata through the buffer, and acknowledge/compact WAL on successful exports. Update README/docs and add tests for replay-with-segments, acknowledgement compaction, and agent WAL acknowledgement behavior.
1 parent e726c9d commit f117497

7 files changed

Lines changed: 287 additions & 37 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ When zero values are provided in `pulse.Config`, defaults are applied:
232232
Optional WAL:
233233

234234
- Set `Config.WAL` with a valid directory to enable disk persistence and replay.
235-
- Current implementation focuses on durable segmented writes and startup replay; segment cleanup remains conservative.
235+
- Successful exports acknowledge WAL segments and trigger compaction of acknowledged files.
236236

237237
## Runtime metrics currently collected
238238

agent.go

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -64,14 +64,14 @@ func (a *Agent) Start() error {
6464
}
6565
a.wal = w
6666

67-
replayed, _, err := a.wal.ReplaySamples()
67+
replayed, _, err := a.wal.ReplaySamplesWithSegments()
6868
if err != nil {
6969
_ = a.wal.Close()
7070
a.wal = nil
7171
return err
7272
}
73-
for _, s := range replayed {
74-
a.buffer.Push(s)
73+
for _, item := range replayed {
74+
a.buffer.PushWithWALSegment(item.Sample, item.Segment)
7575
}
7676
}
7777

@@ -144,10 +144,14 @@ func (a *Agent) collectLoop() {
144144
return
145145
case <-ticker.C:
146146
s := a.collectSample()
147-
a.buffer.Push(s)
147+
walSegment := -1
148148
if a.wal != nil {
149-
_ = a.wal.WriteSample(s)
149+
seg, err := a.wal.WriteSampleWithSegment(s)
150+
if err == nil {
151+
walSegment = seg
152+
}
150153
}
154+
a.buffer.PushWithWALSegment(s, walSegment)
151155
}
152156
}
153157
}
@@ -231,19 +235,32 @@ func collectRuntimeValues() map[string]float64 {
231235
}
232236

233237
func (a *Agent) flushExports() {
234-
samples := a.buffer.DrainAll()
235-
if len(samples) == 0 || len(a.cfg.Exporters) == 0 {
238+
entries := a.buffer.DrainAllWithWALSegments()
239+
if len(entries) == 0 || len(a.cfg.Exporters) == 0 {
236240
return
237241
}
238242

243+
samples := make([]Sample, 0, len(entries))
244+
maxWALSegment := -1
245+
for _, entry := range entries {
246+
samples = append(samples, entry.sample)
247+
if entry.walSegment > maxWALSegment {
248+
maxWALSegment = entry.walSegment
249+
}
250+
}
251+
239252
for _, exp := range a.cfg.Exporters {
240253
if err := a.exportWithRetry(exp, samples); err != nil {
241-
for _, s := range samples {
242-
a.buffer.Push(s)
254+
for _, entry := range entries {
255+
a.buffer.PushWithWALSegment(entry.sample, entry.walSegment)
243256
}
244257
return
245258
}
246259
}
260+
261+
if a.wal != nil && maxWALSegment >= 0 {
262+
a.wal.AcknowledgeThrough(maxWALSegment)
263+
}
247264
}
248265

249266
func (a *Agent) exportWithRetry(exp Exporter, samples []Sample) error {

agent_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,3 +234,62 @@ func TestFlushExportsNonRetryableFailsFast(t *testing.T) {
234234
t.Fatalf("expected sample to be requeued")
235235
}
236236
}
237+
238+
func TestFlushExportsAcknowledgesWALOnSuccess(t *testing.T) {
239+
dir := t.TempDir()
240+
w, err := NewWAL(WALConfig{Dir: dir, SegmentSize: 128, SyncEvery: 1})
241+
if err != nil {
242+
t.Fatalf("new wal: %v", err)
243+
}
244+
defer w.Close()
245+
246+
rec := &recordingExporter{}
247+
a := New(Config{
248+
Exporters: []Exporter{rec},
249+
ExportMaxRetries: 1,
250+
ExportBackoffInitial: 1 * time.Millisecond,
251+
ExportBackoffMax: 2 * time.Millisecond,
252+
ExportBackoffJitter: 0,
253+
})
254+
a.ctx = context.Background()
255+
a.wal = w
256+
257+
maxSegment := -1
258+
for i := 0; i < 20; i++ {
259+
s := Sample{
260+
Timestamp: time.Now().UTC(),
261+
Values: map[string]float64{
262+
"v": float64(i),
263+
"pad": float64(i * 1000),
264+
},
265+
}
266+
seg, err := w.WriteSampleWithSegment(s)
267+
if err != nil {
268+
t.Fatalf("write sample %d: %v", i, err)
269+
}
270+
if seg > maxSegment {
271+
maxSegment = seg
272+
}
273+
a.buffer.PushWithWALSegment(s, seg)
274+
}
275+
276+
if maxSegment < 1 {
277+
t.Fatalf("expected multiple WAL segments, got max %d", maxSegment)
278+
}
279+
280+
a.flushExports()
281+
282+
if w.acknowledgedThrough != maxSegment {
283+
t.Fatalf("expected acknowledgedThrough=%d, got %d", maxSegment, w.acknowledgedThrough)
284+
}
285+
286+
segments, err := listSegments(dir)
287+
if err != nil {
288+
t.Fatalf("list segments: %v", err)
289+
}
290+
for _, seg := range segments {
291+
if idx := segmentIndex(seg); idx < maxSegment {
292+
t.Fatalf("expected old segment compacted, found %s", seg)
293+
}
294+
}
295+
}

buffer.go

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,61 +11,87 @@ var ErrInvalidBufferCapacity = errors.New("pulse: buffer capacity must be greate
1111
// RingBuffer is a fixed-size, thread-safe circular buffer for samples.
1212
type RingBuffer struct {
1313
mu sync.Mutex
14-
data []Sample
14+
data []bufferedSample
1515
head int
1616
size int
1717

1818
overflow atomic.Uint64
1919
}
2020

21+
type bufferedSample struct {
22+
sample Sample
23+
walSegment int
24+
}
25+
2126
// NewRingBuffer creates a new ring buffer with fixed capacity.
2227
func NewRingBuffer(capacity int) (*RingBuffer, error) {
2328
if capacity <= 0 {
2429
return nil, ErrInvalidBufferCapacity
2530
}
26-
return &RingBuffer{data: make([]Sample, capacity)}, nil
31+
return &RingBuffer{data: make([]bufferedSample, capacity)}, nil
2732
}
2833

2934
// Push inserts a sample. If full, the oldest sample is evicted.
3035
func (b *RingBuffer) Push(s Sample) {
36+
b.PushWithWALSegment(s, -1)
37+
}
38+
39+
// PushWithWALSegment inserts a sample and tracks the WAL segment id when known.
40+
func (b *RingBuffer) PushWithWALSegment(s Sample, walSegment int) {
3141
b.mu.Lock()
3242
defer b.mu.Unlock()
3343

3444
if len(b.data) == 0 {
3545
return
3646
}
47+
entry := bufferedSample{sample: cloneSample(s), walSegment: walSegment}
3748

3849
if b.size == len(b.data) {
39-
b.data[b.head] = cloneSample(s)
50+
b.data[b.head] = entry
4051
b.head = (b.head + 1) % len(b.data)
4152
b.overflow.Add(1)
4253
return
4354
}
4455

4556
tail := (b.head + b.size) % len(b.data)
46-
b.data[tail] = cloneSample(s)
57+
b.data[tail] = entry
4758
b.size++
4859
}
4960

50-
// DrainAll returns all samples in insertion order and clears the buffer.
51-
func (b *RingBuffer) DrainAll() []Sample {
61+
// DrainAllWithWALSegments returns all buffered entries and clears the buffer.
62+
func (b *RingBuffer) DrainAllWithWALSegments() []bufferedSample {
5263
b.mu.Lock()
5364
defer b.mu.Unlock()
5465

5566
if b.size == 0 {
5667
return nil
5768
}
5869

59-
out := make([]Sample, 0, b.size)
70+
out := make([]bufferedSample, 0, b.size)
6071
for i := 0; i < b.size; i++ {
6172
idx := (b.head + i) % len(b.data)
62-
out = append(out, cloneSample(b.data[idx]))
73+
entry := b.data[idx]
74+
entry.sample = cloneSample(entry.sample)
75+
out = append(out, entry)
6376
}
6477
b.head = 0
6578
b.size = 0
6679
return out
6780
}
6881

82+
// DrainAll returns all samples in insertion order and clears the buffer.
83+
func (b *RingBuffer) DrainAll() []Sample {
84+
entries := b.DrainAllWithWALSegments()
85+
if len(entries) == 0 {
86+
return nil
87+
}
88+
out := make([]Sample, 0, len(entries))
89+
for _, entry := range entries {
90+
out = append(out, entry.sample)
91+
}
92+
return out
93+
}
94+
6995
// Peek returns up to n samples in insertion order without removing them.
7096
func (b *RingBuffer) Peek(n int) []Sample {
7197
b.mu.Lock()
@@ -81,7 +107,7 @@ func (b *RingBuffer) Peek(n int) []Sample {
81107
out := make([]Sample, 0, n)
82108
for i := 0; i < n; i++ {
83109
idx := (b.head + i) % len(b.data)
84-
out = append(out, cloneSample(b.data[idx]))
110+
out = append(out, cloneSample(b.data[idx].sample))
85111
}
86112
return out
87113
}

docs/current-reference.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ <h2>WAL (persistence)</h2>
6969
<li>Format: length prefix + payload + crc32.</li>
7070
<li>Replay in order; partial tail is truncated to the last valid record.</li>
7171
<li>Rotation: 64 MiB segments, 512 MiB total cap.</li>
72-
<li>The current build prioritizes durable append and startup replay; segment cleanup remains conservative.</li>
72+
<li>Successful exports acknowledge WAL segments and compact acknowledged files.</li>
7373
</ul>
7474
</section>
7575

0 commit comments

Comments
 (0)