675 lines
21 KiB
Go
675 lines
21 KiB
Go
package logger_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.misaka.ren/M1saka/token_thief/logger"
|
|
)
|
|
|
|
type batchScript struct {
|
|
prepareErr error
|
|
appendAt int
|
|
appendErr error
|
|
sendErr error
|
|
}
|
|
|
|
type fakeBatch struct {
|
|
script batchScript
|
|
rows [][]any
|
|
appendCall int
|
|
abortCalls int
|
|
}
|
|
|
|
func (f *fakeBatch) Append(v ...any) error {
|
|
f.appendCall++
|
|
if f.script.appendErr != nil && f.appendCall == f.script.appendAt {
|
|
return f.script.appendErr
|
|
}
|
|
f.rows = append(f.rows, append([]any(nil), v...))
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeBatch) Send() error { return f.script.sendErr }
|
|
func (f *fakeBatch) Abort() error { f.abortCalls++; return nil }
|
|
|
|
type fakePool struct {
|
|
calls int
|
|
queries []string
|
|
batches []*fakeBatch
|
|
scripts []batchScript
|
|
unhealthy bool
|
|
}
|
|
|
|
func (p *fakePool) MarkUnhealthy() { p.unhealthy = true }
|
|
|
|
type queueBackend struct {
|
|
prepare func(context.Context, string) (logger.Batch, error)
|
|
healthy atomic.Bool
|
|
calls atomic.Int32
|
|
}
|
|
|
|
func newQueueBackend(prepare func(context.Context, string) (logger.Batch, error)) *queueBackend {
|
|
b := &queueBackend{prepare: prepare}
|
|
b.healthy.Store(true)
|
|
return b
|
|
}
|
|
|
|
func (b *queueBackend) PrepareBatch(ctx context.Context, query string) (logger.Batch, error) {
|
|
b.calls.Add(1)
|
|
return b.prepare(ctx, query)
|
|
}
|
|
|
|
func (b *queueBackend) Healthy() bool { return b.healthy.Load() }
|
|
func (b *queueBackend) MarkUnhealthy() { b.healthy.Store(false) }
|
|
|
|
func (p *fakePool) PrepareBatch(_ context.Context, query string) (logger.Batch, error) {
|
|
idx := p.calls
|
|
p.calls++
|
|
p.queries = append(p.queries, query)
|
|
var script batchScript
|
|
if idx < len(p.scripts) {
|
|
script = p.scripts[idx]
|
|
}
|
|
if script.prepareErr != nil {
|
|
return nil, script.prepareErr
|
|
}
|
|
b := &fakeBatch{script: script}
|
|
p.batches = append(p.batches, b)
|
|
return b, nil
|
|
}
|
|
|
|
func TestFlushSuccessUsesClickHouseTypes(t *testing.T) {
|
|
p := &fakePool{}
|
|
started := time.Date(2026, 7, 9, 1, 2, 3, 4_000_000, time.UTC)
|
|
entry := &logger.LogEntry{
|
|
RequestID: "rid", Method: "POST", Path: "/v1", Query: "a=b", ClientIP: "127.0.0.1",
|
|
RequestHeaders: []byte(`{"x":"y"}`), RequestBody: []byte("request"), RequestTruncated: true,
|
|
StatusCode: 201, ResponseHeaders: []byte(`{"h":"v"}`), ResponseBody: []byte("response"),
|
|
ResponseTruncated: true, IsStream: true, LatencyMS: 150, StartedAt: started,
|
|
FinishedAt: started.Add(150 * time.Millisecond),
|
|
}
|
|
|
|
result := logger.Flush(context.Background(), p, []*logger.LogEntry{entry})
|
|
if result.Err != nil || result.Failed != 0 || len(result.Retry) != 0 {
|
|
t.Fatalf("unexpected result: %+v", result)
|
|
}
|
|
if p.calls != 1 || len(p.batches) != 1 || len(p.batches[0].rows) != 1 {
|
|
t.Fatalf("calls=%d batches=%d rows=%d", p.calls, len(p.batches), len(p.batches[0].rows))
|
|
}
|
|
if contains(p.queries[0], "VALUES") {
|
|
t.Fatalf("query contains VALUES: %s", p.queries[0])
|
|
}
|
|
row := p.batches[0].rows[0]
|
|
if len(row) != 17 {
|
|
t.Fatalf("columns=%d want 17", len(row))
|
|
}
|
|
if row[5] != string(entry.RequestHeaders) || row[6] != string(entry.RequestBody) || row[9] != string(entry.ResponseHeaders) || row[10] != string(entry.ResponseBody) {
|
|
t.Fatalf("byte fields were not converted to strings: %#v", row)
|
|
}
|
|
if _, ok := row[8].(int32); !ok {
|
|
t.Fatalf("status code type=%T want int32", row[8])
|
|
}
|
|
if p.batches[0].abortCalls != 0 {
|
|
t.Fatalf("abort calls=%d want 0", p.batches[0].abortCalls)
|
|
}
|
|
}
|
|
|
|
func TestFlushEmptyBatchDoesNotPrepare(t *testing.T) {
|
|
p := &fakePool{}
|
|
|
|
result := logger.Flush(context.Background(), p, nil)
|
|
if result.Err != nil || result.Failed != 0 || result.Ambiguous != 0 || len(result.Retry) != 0 {
|
|
t.Fatalf("unexpected result: %+v", result)
|
|
}
|
|
if p.calls != 0 {
|
|
t.Fatalf("PrepareBatch calls=%d want 0", p.calls)
|
|
}
|
|
}
|
|
|
|
func TestFlushPrepareFailureIsRetryable(t *testing.T) {
|
|
p := &fakePool{scripts: []batchScript{{prepareErr: errors.New("prepare")}}}
|
|
entries := []*logger.LogEntry{{RequestID: "a"}, {RequestID: "b"}}
|
|
result := logger.Flush(context.Background(), p, entries)
|
|
if result.Err == nil || result.Failed != 0 || len(result.Retry) != 2 {
|
|
t.Fatalf("unexpected result: %+v", result)
|
|
}
|
|
if len(p.batches) != 0 {
|
|
t.Fatalf("prepare failure created %d batches", len(p.batches))
|
|
}
|
|
}
|
|
|
|
func TestFlushAppendFailureIsolatesOnlyBadRow(t *testing.T) {
|
|
appendErr := errors.New("bad row")
|
|
p := &fakePool{scripts: []batchScript{
|
|
{appendAt: 2, appendErr: appendErr},
|
|
{},
|
|
{appendAt: 1, appendErr: appendErr},
|
|
{},
|
|
}}
|
|
entries := []*logger.LogEntry{{RequestID: "a"}, {RequestID: "bad"}, {RequestID: "c"}}
|
|
|
|
result := logger.Flush(context.Background(), p, entries)
|
|
if result.Err == nil || result.Failed != 1 || len(result.Retry) != 0 {
|
|
t.Fatalf("unexpected result: %+v", result)
|
|
}
|
|
if p.calls != 4 {
|
|
t.Fatalf("PrepareBatch calls=%d want 4", p.calls)
|
|
}
|
|
if p.batches[0].abortCalls != 1 || p.batches[2].abortCalls != 1 {
|
|
t.Fatalf("abort calls initial=%d bad-row=%d want 1 each", p.batches[0].abortCalls, p.batches[2].abortCalls)
|
|
}
|
|
if p.batches[1].abortCalls != 0 || p.batches[3].abortCalls != 0 {
|
|
t.Fatalf("successful batches were aborted")
|
|
}
|
|
}
|
|
|
|
func TestFlushAppendFailureCanFullyRecover(t *testing.T) {
|
|
p := &fakePool{scripts: []batchScript{
|
|
{appendAt: 2, appendErr: errors.New("batch append")},
|
|
{},
|
|
{},
|
|
}}
|
|
entries := []*logger.LogEntry{{RequestID: "a"}, {RequestID: "b"}}
|
|
|
|
result := logger.Flush(context.Background(), p, entries)
|
|
if result.Err != nil || result.Failed != 0 || len(result.Retry) != 0 {
|
|
t.Fatalf("unexpected result: %+v", result)
|
|
}
|
|
if p.calls != 3 || p.batches[0].abortCalls != 1 {
|
|
t.Fatalf("calls=%d abort=%d", p.calls, p.batches[0].abortCalls)
|
|
}
|
|
}
|
|
|
|
func TestFlushSendFailureIsAmbiguousAndNotRetried(t *testing.T) {
|
|
p := &fakePool{scripts: []batchScript{{sendErr: errors.New("connection lost")}}}
|
|
entries := []*logger.LogEntry{{RequestID: "a"}, {RequestID: "b"}}
|
|
|
|
result := logger.Flush(context.Background(), p, entries)
|
|
if result.Err == nil || result.Failed != 2 || len(result.Retry) != 0 {
|
|
t.Fatalf("unexpected result: %+v", result)
|
|
}
|
|
if result.Ambiguous != 2 {
|
|
t.Fatalf("Ambiguous=%d want 2", result.Ambiguous)
|
|
}
|
|
if p.calls != 1 {
|
|
t.Fatalf("PrepareBatch calls=%d want 1", p.calls)
|
|
}
|
|
if p.batches[0].abortCalls != 1 {
|
|
t.Fatalf("abort calls=%d want 1", p.batches[0].abortCalls)
|
|
}
|
|
}
|
|
|
|
func TestFlushAppendRecoveryCountsAmbiguousSingleSend(t *testing.T) {
|
|
p := &fakePool{scripts: []batchScript{
|
|
{appendAt: 1, appendErr: errors.New("bad batch")},
|
|
{},
|
|
{sendErr: errors.New("ack lost")},
|
|
}}
|
|
entries := []*logger.LogEntry{{RequestID: "a"}, {RequestID: "b"}}
|
|
|
|
result := logger.Flush(context.Background(), p, entries)
|
|
if result.Failed != 1 || result.Ambiguous != 1 || len(result.Retry) != 0 {
|
|
t.Fatalf("unexpected result: %+v", result)
|
|
}
|
|
}
|
|
|
|
func TestFlushAppendRecoveryStopsAfterFirstAmbiguousSend(t *testing.T) {
|
|
p := &fakePool{scripts: []batchScript{
|
|
{appendAt: 1, appendErr: errors.New("bad batch")},
|
|
{sendErr: errors.New("ack lost")},
|
|
{},
|
|
}}
|
|
entries := []*logger.LogEntry{{RequestID: "a"}, {RequestID: "b"}, {RequestID: "c"}}
|
|
|
|
result := logger.Flush(context.Background(), p, entries)
|
|
if result.Failed != 3 || result.Ambiguous != 1 || len(result.Retry) != 0 {
|
|
t.Fatalf("unexpected result: %+v", result)
|
|
}
|
|
if p.calls != 2 {
|
|
t.Fatalf("PrepareBatch calls=%d want 2; entries after ambiguous Send must not be attempted", p.calls)
|
|
}
|
|
if !p.unhealthy {
|
|
t.Fatal("ambiguous singleton Send did not immediately mark backend unhealthy")
|
|
}
|
|
}
|
|
|
|
func TestEstimatedBytesCoversStringsAndByteSlices(t *testing.T) {
|
|
entry := &logger.LogEntry{
|
|
RequestID: "1", Method: "22", Path: "333", Query: "4444", ClientIP: "55555", Error: "666666",
|
|
RequestHeaders: []byte("7777777"), RequestBody: []byte("88888888"),
|
|
ResponseHeaders: []byte("999999999"), ResponseBody: []byte("0000000000"),
|
|
}
|
|
if got := logger.EstimatedBytes(entry); got <= 55 {
|
|
t.Fatalf("EstimatedBytes=%d must include fixed entry overhead", got)
|
|
}
|
|
}
|
|
|
|
func TestQueueByteBudgetReleasedAfterFinalDiscard(t *testing.T) {
|
|
entry := &logger.LogEntry{
|
|
RequestID: "request", Method: "POST", Path: "/path", Query: "q=1", ClientIP: "ip", Error: "error",
|
|
RequestHeaders: []byte("rh"), RequestBody: []byte("rb"), ResponseHeaders: []byte("sh"), ResponseBody: []byte("sb"),
|
|
}
|
|
budget := logger.EstimatedBytes(entry)
|
|
q := logger.NewQueue(nil, 4, 2, 1, time.Hour, budget)
|
|
|
|
q.Submit(entry)
|
|
q.Submit(entry)
|
|
stats := q.Stats()
|
|
if stats.Enqueued != 1 || stats.Dropped != 1 || stats.Bytes != budget {
|
|
t.Fatalf("before drain: %+v budget=%d", stats, budget)
|
|
}
|
|
q.Start(context.Background())
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
if err := q.Stop(ctx); err != nil {
|
|
t.Fatalf("Stop: %v", err)
|
|
}
|
|
stats = q.Stats()
|
|
if stats.Bytes != 0 || stats.Failed != 1 {
|
|
t.Fatalf("after drain: %+v", stats)
|
|
}
|
|
}
|
|
|
|
func TestQueueEntryBudgetIncludesWorkerBatch(t *testing.T) {
|
|
q := logger.NewQueue(nil, 1, 2, 1, time.Hour)
|
|
q.Submit(&logger.LogEntry{RequestID: "batched"})
|
|
q.Start(context.Background())
|
|
|
|
deadline := time.Now().Add(time.Second)
|
|
for q.Stats().Enqueued == 1 && time.Now().Before(deadline) {
|
|
q.Submit(&logger.LogEntry{RequestID: "queued"})
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
if stats := q.Stats(); stats.Enqueued != 1 {
|
|
t.Fatalf("stats=%+v; entry budget allowed channel plus worker batch", stats)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
if err := q.Stop(ctx); err != nil {
|
|
t.Fatalf("Stop: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestQueueStopBeforeStartReleasesBudget(t *testing.T) {
|
|
entry := &logger.LogEntry{RequestID: "queued"}
|
|
q := logger.NewQueue(nil, 2, 2, 1, time.Hour, logger.EstimatedBytes(entry))
|
|
q.Submit(entry)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
if err := q.Stop(ctx); err != nil {
|
|
t.Fatalf("Stop: %v", err)
|
|
}
|
|
if stats := q.Stats(); stats.Bytes != 0 || stats.Failed != 1 {
|
|
t.Fatalf("stats=%+v", stats)
|
|
}
|
|
}
|
|
|
|
func TestQueueReleasesSubmittedSizeAfterEntryMutation(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
mutate func(*logger.LogEntry)
|
|
}{
|
|
{name: "smaller", mutate: func(entry *logger.LogEntry) { entry.RequestBody = nil }},
|
|
{name: "larger", mutate: func(entry *logger.LogEntry) { entry.RequestBody = []byte("much larger body") }},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
entry := &logger.LogEntry{RequestID: "queued", RequestBody: []byte("body")}
|
|
q := logger.NewQueue(nil, 2, 2, 1, time.Hour, logger.EstimatedBytes(entry))
|
|
q.Submit(entry)
|
|
tc.mutate(entry)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
if err := q.Stop(ctx); err != nil {
|
|
t.Fatalf("Stop: %v", err)
|
|
}
|
|
if stats := q.Stats(); stats.Bytes != 0 || stats.Failed != 1 {
|
|
t.Fatalf("stats=%+v", stats)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestQueueSubmitOwnsEntrySnapshot(t *testing.T) {
|
|
prepareStarted := make(chan struct{})
|
|
releasePrepare := make(chan struct{})
|
|
batch := &fakeBatch{}
|
|
backend := newQueueBackend(func(context.Context, string) (logger.Batch, error) {
|
|
close(prepareStarted)
|
|
<-releasePrepare
|
|
return batch, nil
|
|
})
|
|
entry := &logger.LogEntry{
|
|
RequestID: "original-id",
|
|
RequestHeaders: []byte("original-request-headers"),
|
|
RequestBody: []byte("original-request-body"),
|
|
ResponseHeaders: []byte("original-response-headers"),
|
|
ResponseBody: []byte("original-response-body"),
|
|
}
|
|
q := logger.NewQueueWithBackend(backend, 1, 1, 1, time.Hour)
|
|
q.Start(context.Background())
|
|
q.Submit(entry)
|
|
<-prepareStarted
|
|
|
|
entry.RequestID = "mutated-id"
|
|
entry.RequestHeaders[0] = 'X'
|
|
entry.RequestBody[0] = 'X'
|
|
entry.ResponseHeaders[0] = 'X'
|
|
entry.ResponseBody[0] = 'X'
|
|
close(releasePrepare)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
if err := q.Stop(ctx); err != nil {
|
|
t.Fatalf("Stop: %v", err)
|
|
}
|
|
if len(batch.rows) != 1 {
|
|
t.Fatalf("written rows=%d want 1", len(batch.rows))
|
|
}
|
|
row := batch.rows[0]
|
|
if row[0] != "original-id" ||
|
|
row[5] != "original-request-headers" || row[6] != "original-request-body" ||
|
|
row[9] != "original-response-headers" || row[10] != "original-response-body" {
|
|
t.Fatalf("queued entry changed after Submit: %#v", row)
|
|
}
|
|
}
|
|
|
|
func TestQueueRetainsBatchUntilBackendRecovers(t *testing.T) {
|
|
backend := newQueueBackend(func(context.Context, string) (logger.Batch, error) {
|
|
return &fakeBatch{}, nil
|
|
})
|
|
backend.healthy.Store(false)
|
|
q := logger.NewQueueWithBackend(backend, 1, 1, 1, 10*time.Millisecond)
|
|
q.Start(context.Background())
|
|
q.Submit(&logger.LogEntry{RequestID: "retained"})
|
|
|
|
time.Sleep(50 * time.Millisecond)
|
|
if calls := backend.calls.Load(); calls != 0 {
|
|
t.Fatalf("PrepareBatch calls=%d while unhealthy", calls)
|
|
}
|
|
if stats := q.Stats(); stats.Failed != 0 || stats.Bytes == 0 {
|
|
t.Fatalf("unhealthy stats=%+v want retained bytes and no failure", stats)
|
|
}
|
|
|
|
backend.healthy.Store(true)
|
|
deadline := time.Now().Add(time.Second)
|
|
for backend.calls.Load() == 0 && time.Now().Before(deadline) {
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
if err := q.Stop(ctx); err != nil {
|
|
t.Fatalf("Stop: %v", err)
|
|
}
|
|
if calls := backend.calls.Load(); calls != 1 {
|
|
t.Fatalf("PrepareBatch calls=%d want 1", calls)
|
|
}
|
|
if stats := q.Stats(); stats.Failed != 0 || stats.Bytes != 0 {
|
|
t.Fatalf("recovered stats=%+v", stats)
|
|
}
|
|
}
|
|
|
|
func TestQueueByteBudgetTracksOwnedEntrySnapshot(t *testing.T) {
|
|
batch := &fakeBatch{}
|
|
backend := newQueueBackend(func(context.Context, string) (logger.Batch, error) {
|
|
return batch, nil
|
|
})
|
|
entry := &logger.LogEntry{RequestID: "original", RequestBody: []byte("body")}
|
|
budget := logger.EstimatedBytes(entry)
|
|
q := logger.NewQueueWithBackend(backend, 2, 1, 1, time.Hour, budget)
|
|
|
|
q.Submit(entry)
|
|
entry.RequestID = "mutated"
|
|
entry.RequestBody = []byte("body expanded after submission")
|
|
q.Submit(&logger.LogEntry{RequestID: "x"})
|
|
|
|
if stats := q.Stats(); stats.Enqueued != 1 || stats.Dropped != 1 || stats.Bytes != budget {
|
|
t.Fatalf("before flush: %+v budget=%d", stats, budget)
|
|
}
|
|
|
|
q.Start(context.Background())
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
if err := q.Stop(ctx); err != nil {
|
|
t.Fatalf("Stop: %v", err)
|
|
}
|
|
if len(batch.rows) != 1 {
|
|
t.Fatalf("written rows=%d want 1", len(batch.rows))
|
|
}
|
|
if row := batch.rows[0]; row[0] != "original" || row[6] != "body" {
|
|
t.Fatalf("written row does not match budgeted snapshot: %#v", row)
|
|
}
|
|
if stats := q.Stats(); stats.Bytes != 0 {
|
|
t.Fatalf("after flush: %+v", stats)
|
|
}
|
|
}
|
|
|
|
func TestQueueCanceledStopReleasesAllBudgetBeforeReturning(t *testing.T) {
|
|
entry := &logger.LogEntry{RequestID: "queued"}
|
|
q := logger.NewQueue(nil, 32, 32, 1, time.Hour, 32*logger.EstimatedBytes(entry))
|
|
q.Start(context.Background())
|
|
for i := 0; i < 32; i++ {
|
|
q.Submit(entry)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
_ = q.Stop(ctx)
|
|
|
|
if stats := q.Stats(); stats.Bytes != 0 || stats.Failed != 32 {
|
|
t.Fatalf("stats=%+v", stats)
|
|
}
|
|
}
|
|
|
|
func TestQueueStopAndSubmitAreConcurrentAndRepeatSafe(t *testing.T) {
|
|
q := logger.NewQueue(nil, 32, 8, 2, time.Millisecond, 1<<20)
|
|
q.Start(context.Background())
|
|
|
|
var submitters sync.WaitGroup
|
|
for i := 0; i < 8; i++ {
|
|
submitters.Add(1)
|
|
go func() {
|
|
defer submitters.Done()
|
|
for j := 0; j < 2_000; j++ {
|
|
q.Submit(&logger.LogEntry{RequestID: "x"})
|
|
}
|
|
}()
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
if err := q.Stop(ctx); err != nil {
|
|
t.Fatalf("first Stop: %v", err)
|
|
}
|
|
submitters.Wait()
|
|
if err := q.Stop(ctx); err != nil {
|
|
t.Fatalf("second Stop: %v", err)
|
|
}
|
|
q.Submit(&logger.LogEntry{RequestID: "after-stop"})
|
|
if stats := q.Stats(); stats.Bytes != 0 {
|
|
t.Fatalf("bytes after Stop=%d want 0", stats.Bytes)
|
|
}
|
|
}
|
|
|
|
func TestConcurrentStopCannotCancelFirstStopDrain(t *testing.T) {
|
|
const initialEntries = 32
|
|
firstPrepareStarted := make(chan struct{})
|
|
releaseFirstPrepare := make(chan struct{})
|
|
var prepareCalls atomic.Int32
|
|
backend := newQueueBackend(func(context.Context, string) (logger.Batch, error) {
|
|
if prepareCalls.Add(1) == 1 {
|
|
close(firstPrepareStarted)
|
|
<-releaseFirstPrepare
|
|
}
|
|
return &fakeBatch{}, nil
|
|
})
|
|
q := logger.NewQueueWithBackend(backend, 1024, 1, 1, time.Hour)
|
|
q.Start(context.Background())
|
|
for i := 0; i < initialEntries; i++ {
|
|
q.Submit(&logger.LogEntry{RequestID: "queued"})
|
|
}
|
|
<-firstPrepareStarted
|
|
|
|
firstResult := make(chan error, 1)
|
|
firstCtx, cancelFirst := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancelFirst()
|
|
go func() { firstResult <- q.Stop(firstCtx) }()
|
|
time.Sleep(10 * time.Millisecond)
|
|
for q.Stats().Dropped == 0 {
|
|
q.Submit(&logger.LogEntry{RequestID: "stop-probe"})
|
|
}
|
|
enqueued := q.Stats().Enqueued
|
|
|
|
secondCtx, cancelSecond := context.WithCancel(context.Background())
|
|
cancelSecond()
|
|
if err := q.Stop(secondCtx); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("second Stop error=%v want canceled", err)
|
|
}
|
|
close(releaseFirstPrepare)
|
|
if err := <-firstResult; err != nil {
|
|
t.Fatalf("first Stop: %v", err)
|
|
}
|
|
if calls := uint64(backend.calls.Load()); calls != enqueued {
|
|
t.Fatalf("PrepareBatch calls=%d want %d; second Stop interrupted drain", calls, enqueued)
|
|
}
|
|
}
|
|
|
|
func TestStopIsIndependentFromStartContext(t *testing.T) {
|
|
root, cancelRoot := context.WithCancel(context.Background())
|
|
q := logger.NewQueue(nil, 4, 4, 1, time.Hour, 1024)
|
|
q.Start(root)
|
|
q.Submit(&logger.LogEntry{RequestID: "x"})
|
|
cancelRoot()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
if err := q.Stop(ctx); err != nil {
|
|
t.Fatalf("Stop after root cancellation: %v", err)
|
|
}
|
|
if stats := q.Stats(); stats.Failed != 1 || stats.Bytes != 0 {
|
|
t.Fatalf("stats=%+v", stats)
|
|
}
|
|
}
|
|
|
|
func TestQueueStopDeadlineCancelsBlockedPrepare(t *testing.T) {
|
|
started := make(chan struct{})
|
|
backend := newQueueBackend(func(ctx context.Context, _ string) (logger.Batch, error) {
|
|
close(started)
|
|
<-ctx.Done()
|
|
return nil, ctx.Err()
|
|
})
|
|
q := logger.NewQueueWithBackend(backend, 1, 1, 1, time.Hour)
|
|
q.Start(context.Background())
|
|
q.Submit(&logger.LogEntry{RequestID: "blocked-prepare"})
|
|
<-started
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond)
|
|
defer cancel()
|
|
startedAt := time.Now()
|
|
if err := q.Stop(ctx); !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Fatalf("Stop error=%v want deadline exceeded", err)
|
|
}
|
|
if elapsed := time.Since(startedAt); elapsed > 250*time.Millisecond {
|
|
t.Fatalf("Stop exceeded total deadline: %v", elapsed)
|
|
}
|
|
}
|
|
|
|
func TestQueueStopDeadlineCancelsBlockedSend(t *testing.T) {
|
|
started := make(chan struct{})
|
|
backend := newQueueBackend(func(ctx context.Context, _ string) (logger.Batch, error) {
|
|
return &blockingSendBatch{ctx: ctx, started: started}, nil
|
|
})
|
|
q := logger.NewQueueWithBackend(backend, 1, 1, 1, time.Hour)
|
|
q.Start(context.Background())
|
|
q.Submit(&logger.LogEntry{RequestID: "blocked-send"})
|
|
<-started
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond)
|
|
defer cancel()
|
|
if err := q.Stop(ctx); !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Fatalf("Stop error=%v want deadline exceeded", err)
|
|
}
|
|
if stats := q.Stats(); stats.Bytes != 0 || stats.Ambiguous != 1 || stats.Failed != 1 {
|
|
t.Fatalf("stats after canceled Send=%+v", stats)
|
|
}
|
|
if backend.Healthy() {
|
|
t.Fatal("Send failure did not mark backend unhealthy")
|
|
}
|
|
}
|
|
|
|
func TestQueueStopDeadlineCancelsPrepareRetryBackoff(t *testing.T) {
|
|
backend := newQueueBackend(func(context.Context, string) (logger.Batch, error) {
|
|
return nil, errors.New("prepare failed")
|
|
})
|
|
q := logger.NewQueueWithBackend(backend, 1, 1, 1, time.Hour)
|
|
q.Start(context.Background())
|
|
q.Submit(&logger.LogEntry{RequestID: "backoff"})
|
|
|
|
deadline := time.Now().Add(time.Second)
|
|
for backend.calls.Load() == 0 && time.Now().Before(deadline) {
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond)
|
|
defer cancel()
|
|
if err := q.Stop(ctx); !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Fatalf("Stop error=%v want deadline exceeded", err)
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
if calls := backend.calls.Load(); calls != 1 {
|
|
t.Fatalf("PrepareBatch calls=%d want 1 before deadline cancels backoff", calls)
|
|
}
|
|
}
|
|
|
|
func TestQueueExhaustsPrepareRetriesAndMarksBackendUnhealthy(t *testing.T) {
|
|
backend := newQueueBackend(func(context.Context, string) (logger.Batch, error) {
|
|
return nil, errors.New("prepare failed")
|
|
})
|
|
q := logger.NewQueueWithBackend(backend, 2, 2, 1, time.Hour)
|
|
q.Start(context.Background())
|
|
q.Submit(&logger.LogEntry{RequestID: "a"})
|
|
q.Submit(&logger.LogEntry{RequestID: "b"})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
if err := q.Stop(ctx); err != nil {
|
|
t.Fatalf("Stop: %v", err)
|
|
}
|
|
if calls := backend.calls.Load(); calls != 3 {
|
|
t.Fatalf("PrepareBatch calls=%d want 3", calls)
|
|
}
|
|
if stats := q.Stats(); stats.Failed != 2 || stats.Ambiguous != 0 || stats.Bytes != 0 {
|
|
t.Fatalf("stats=%+v", stats)
|
|
}
|
|
if backend.Healthy() {
|
|
t.Fatal("exhausted Prepare retries did not mark backend unhealthy")
|
|
}
|
|
}
|
|
|
|
type blockingSendBatch struct {
|
|
ctx context.Context
|
|
started chan struct{}
|
|
}
|
|
|
|
func (*blockingSendBatch) Append(...any) error { return nil }
|
|
func (b *blockingSendBatch) Send() error {
|
|
close(b.started)
|
|
<-b.ctx.Done()
|
|
return b.ctx.Err()
|
|
}
|
|
func (*blockingSendBatch) Abort() error { return nil }
|
|
|
|
func contains(s, substr string) bool {
|
|
for i := 0; i+len(substr) <= len(s); i++ {
|
|
if s[i:i+len(substr)] == substr {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|