Skip to content

Commit 522aa16

Browse files
committed
LOGC-31: Add offset buffering
Implement offset buffering to reduce database round-trips by batching offset commits.
1 parent b7f843c commit 522aa16

6 files changed

Lines changed: 621 additions & 88 deletions

File tree

pkg/logcourier/offset.go

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"database/sql"
66
"errors"
77
"fmt"
8+
"strings"
89
"time"
910

1011
"github.com/scality/log-courier/pkg/clickhouse"
@@ -29,6 +30,7 @@ import (
2930
// OffsetManagerInterface defines the interface for offset management
3031
type OffsetManagerInterface interface {
3132
CommitOffset(ctx context.Context, bucket string, raftSessionID uint16, offset Offset) error
33+
CommitOffsetsBatch(ctx context.Context, requests []OffsetCommitRequest) error
3234
GetOffset(ctx context.Context, bucket string, raftSessionID uint16) (Offset, error)
3335
}
3436

@@ -39,6 +41,13 @@ type Offset struct {
3941
ReqID string
4042
}
4143

44+
// OffsetCommitRequest holds a single offset commit request
45+
type OffsetCommitRequest struct {
46+
Offset Offset
47+
Bucket string
48+
RaftSessionID uint16
49+
}
50+
4251
// OffsetManager manages offsets
4352
type OffsetManager struct {
4453
client *clickhouse.Client
@@ -55,27 +64,52 @@ func NewOffsetManager(client *clickhouse.Client, database string) *OffsetManager
5564

5665
// CommitOffset commits the processing offset for a bucket using composite key
5766
func (om *OffsetManager) CommitOffset(ctx context.Context, bucket string, raftSessionID uint16, offset Offset) error {
58-
if bucket == "" {
59-
return fmt.Errorf("bucket name cannot be empty")
60-
}
61-
if offset.InsertedAt.IsZero() {
62-
return fmt.Errorf("insertedAt timestamp cannot be zero")
67+
return om.CommitOffsetsBatch(ctx, []OffsetCommitRequest{{
68+
Offset: offset,
69+
Bucket: bucket,
70+
RaftSessionID: raftSessionID,
71+
}})
72+
}
73+
74+
// CommitOffsetsBatch commits multiple offsets in a single database operation
75+
func (om *OffsetManager) CommitOffsetsBatch(ctx context.Context, requests []OffsetCommitRequest) error {
76+
if len(requests) == 0 {
77+
return nil
6378
}
64-
if offset.Timestamp.IsZero() {
65-
return fmt.Errorf("timestamp cannot be zero")
79+
80+
// Validate all requests first
81+
for i, req := range requests {
82+
if req.Bucket == "" {
83+
return fmt.Errorf("request %d: bucket name cannot be empty", i)
84+
}
85+
if req.Offset.InsertedAt.IsZero() {
86+
return fmt.Errorf("request %d: insertedAt timestamp cannot be zero", i)
87+
}
88+
if req.Offset.Timestamp.IsZero() {
89+
return fmt.Errorf("request %d: timestamp cannot be zero", i)
90+
}
91+
if req.Offset.ReqID == "" {
92+
return fmt.Errorf("request %d: reqID cannot be empty", i)
93+
}
6694
}
67-
if offset.ReqID == "" {
68-
return fmt.Errorf("reqID cannot be empty")
95+
96+
// Build query with multiple VALUES clauses for batch insert
97+
valuesClauses := make([]string, len(requests))
98+
args := make([]interface{}, 0, len(requests)*5)
99+
100+
for i, req := range requests {
101+
valuesClauses[i] = "(?, ?, ?, ?, ?)"
102+
args = append(args, req.Bucket, req.RaftSessionID, req.Offset.InsertedAt, req.Offset.Timestamp, req.Offset.ReqID)
69103
}
70104

71105
query := fmt.Sprintf(`
72106
INSERT INTO %s.%s (bucketName, raftSessionID, lastProcessedInsertedAt, lastProcessedTimestamp, lastProcessedReqId)
73-
VALUES (?, ?, ?, ?, ?)
74-
`, om.database, clickhouse.TableOffsetsFederated)
107+
VALUES %s
108+
`, om.database, clickhouse.TableOffsetsFederated, strings.Join(valuesClauses, ", "))
75109

76-
err := om.client.Exec(ctx, query, bucket, raftSessionID, offset.InsertedAt, offset.Timestamp, offset.ReqID)
110+
err := om.client.Exec(ctx, query, args...)
77111
if err != nil {
78-
return fmt.Errorf("failed to commit offset for bucket %s raftSessionID %d: %w", bucket, raftSessionID, err)
112+
return fmt.Errorf("failed to commit %d offsets: %w", len(requests), err)
79113
}
80114

81115
return nil

pkg/logcourier/offset_buffer.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
package logcourier
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log/slog"
7+
"sync"
8+
"time"
9+
)
10+
11+
// OffsetBuffer buffers offsets in memory and flushes them on demand
12+
type OffsetBuffer struct {
13+
// Primary storage: (bucket, raftSessionID) -> latest offset
14+
offsets map[offsetKey]Offset
15+
16+
offsetManager OffsetManagerInterface
17+
logger *slog.Logger
18+
19+
// Configuration
20+
initialBackoff time.Duration
21+
maxBackoff time.Duration
22+
backoffJitterFactor float64
23+
maxRetries int
24+
25+
mu sync.Mutex
26+
}
27+
28+
type offsetKey struct {
29+
bucket string
30+
raftSessionID uint16
31+
}
32+
33+
// OffsetBufferConfig holds configuration for OffsetBuffer
34+
type OffsetBufferConfig struct {
35+
OffsetManager OffsetManagerInterface
36+
Logger *slog.Logger
37+
InitialBackoff time.Duration
38+
MaxBackoff time.Duration
39+
BackoffJitterFactor float64
40+
MaxRetries int
41+
}
42+
43+
// NewOffsetBuffer creates a new offset buffer
44+
func NewOffsetBuffer(cfg OffsetBufferConfig) *OffsetBuffer {
45+
return &OffsetBuffer{
46+
offsets: make(map[offsetKey]Offset),
47+
maxRetries: cfg.MaxRetries,
48+
initialBackoff: cfg.InitialBackoff,
49+
maxBackoff: cfg.MaxBackoff,
50+
backoffJitterFactor: cfg.BackoffJitterFactor,
51+
offsetManager: cfg.OffsetManager,
52+
logger: cfg.Logger,
53+
}
54+
}
55+
56+
// Put stores an offset in the buffer
57+
func (ob *OffsetBuffer) Put(bucket string, raftSessionID uint16, offset Offset) {
58+
ob.mu.Lock()
59+
defer ob.mu.Unlock()
60+
61+
key := offsetKey{bucket: bucket, raftSessionID: raftSessionID}
62+
ob.offsets[key] = offset
63+
}
64+
65+
// Flush commits all buffered offsets to ClickHouse
66+
func (ob *OffsetBuffer) Flush(ctx context.Context) error {
67+
ob.mu.Lock()
68+
if len(ob.offsets) == 0 {
69+
ob.mu.Unlock()
70+
return nil
71+
}
72+
73+
// Take snapshot of offsets to flush
74+
offsetsToFlush := make(map[offsetKey]Offset, len(ob.offsets))
75+
for k, v := range ob.offsets {
76+
offsetsToFlush[k] = v
77+
}
78+
ob.mu.Unlock()
79+
80+
// Flush without holding lock
81+
if err := ob.flushBatch(ctx, offsetsToFlush); err != nil {
82+
return err
83+
}
84+
85+
// Clear successfully flushed offsets
86+
ob.mu.Lock()
87+
defer ob.mu.Unlock()
88+
89+
for key := range offsetsToFlush {
90+
delete(ob.offsets, key)
91+
}
92+
93+
ob.logger.Info("flushed offsets", "count", len(offsetsToFlush))
94+
95+
return nil
96+
}
97+
98+
// flushBatch commits a batch of offsets to ClickHouse with retry logic
99+
func (ob *OffsetBuffer) flushBatch(ctx context.Context, offsets map[offsetKey]Offset) error {
100+
var lastErr error
101+
backoff := ob.initialBackoff
102+
103+
for attempt := 0; attempt <= ob.maxRetries; attempt++ {
104+
if attempt > 0 {
105+
// Apply jitter to the backoff
106+
actualBackoff := applyJitter(backoff, ob.backoffJitterFactor)
107+
108+
ob.logger.Info("retrying offset flush after backoff",
109+
"attempt", attempt,
110+
"backoffSeconds", actualBackoff.Seconds())
111+
112+
select {
113+
case <-time.After(actualBackoff):
114+
case <-ctx.Done():
115+
return ctx.Err()
116+
}
117+
118+
backoff = time.Duration(float64(backoff) * 2.0)
119+
if backoff > ob.maxBackoff {
120+
backoff = ob.maxBackoff
121+
}
122+
}
123+
124+
err := ob.commitBatch(ctx, offsets)
125+
if err == nil {
126+
if attempt > 0 {
127+
ob.logger.Info("offset flush succeeded after retries", "attempt", attempt)
128+
}
129+
return nil
130+
}
131+
132+
lastErr = err
133+
ob.logger.Warn("transient error, will retry offset flush",
134+
"attempt", attempt,
135+
"error", err)
136+
}
137+
138+
return fmt.Errorf("max retries (%d) exceeded for offset flush: %w", ob.maxRetries, lastErr)
139+
}
140+
141+
// commitBatch performs the actual batch commit
142+
func (ob *OffsetBuffer) commitBatch(ctx context.Context, offsets map[offsetKey]Offset) error {
143+
// Convert map to slice of requests
144+
requests := make([]OffsetCommitRequest, 0, len(offsets))
145+
for key, offset := range offsets {
146+
requests = append(requests, OffsetCommitRequest{
147+
Offset: offset,
148+
Bucket: key.bucket,
149+
RaftSessionID: key.raftSessionID,
150+
})
151+
}
152+
153+
return ob.offsetManager.CommitOffsetsBatch(ctx, requests)
154+
}

0 commit comments

Comments
 (0)