381 lines
10 KiB
Go
381 lines
10 KiB
Go
package eventbus
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
|
)
|
|
|
|
func TestDurableOutboxPersistsBeforeAsyncSubmitAndDeletesAfterAck(t *testing.T) {
|
|
dir := t.TempDir()
|
|
delegate := &outboxAsyncSink{}
|
|
var sink *DurableOutboxSink
|
|
delegate.onSubmit = func(record durableRecord) {
|
|
if got := outboxBacklog(sink); got != 1 {
|
|
t.Fatalf("durable backlog visible at submit = %d, want 1", got)
|
|
}
|
|
if record.Envelope.EventID == "" {
|
|
t.Fatal("submitted record must have a stable event id")
|
|
}
|
|
}
|
|
var err error
|
|
sink, err = NewDurableOutboxSink(delegate, DurableOutboxConfig{Directory: dir})
|
|
if err != nil {
|
|
t.Fatalf("NewDurableOutboxSink() error = %v", err)
|
|
}
|
|
|
|
if err := sink.PublishRaw(context.Background(), durableTestEnvelope()); err != nil {
|
|
t.Fatalf("PublishRaw() error = %v", err)
|
|
}
|
|
if got := outboxBacklog(sink); got != 1 {
|
|
t.Fatalf("backlog before ack = %d, want 1", got)
|
|
}
|
|
delegate.completeNext(t, nil)
|
|
if got := outboxBacklog(sink); got != 0 {
|
|
t.Fatalf("backlog after ack = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestDurableOutboxImmediateSubmitErrorKeepsAcceptedRecord(t *testing.T) {
|
|
dir := t.TempDir()
|
|
submitErr := errors.New("nats pending limit")
|
|
delegate := &outboxAsyncSink{submitErrors: []error{submitErr}}
|
|
var reported error
|
|
sink, err := NewDurableOutboxSink(delegate, DurableOutboxConfig{
|
|
Directory: dir,
|
|
OnError: func(err error) {
|
|
reported = err
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewDurableOutboxSink() error = %v", err)
|
|
}
|
|
|
|
if err := sink.PublishRaw(context.Background(), durableTestEnvelope()); err != nil {
|
|
t.Fatalf("accepted durable record should hide submit error, got %v", err)
|
|
}
|
|
if got := outboxBacklog(sink); got != 1 {
|
|
t.Fatalf("backlog after submit error = %d, want 1", got)
|
|
}
|
|
if reported == nil || !strings.Contains(reported.Error(), submitErr.Error()) {
|
|
t.Fatalf("reported error = %v", reported)
|
|
}
|
|
}
|
|
|
|
func TestDurableOutboxAckErrorIsRetriedAndStableRecordIsRemoved(t *testing.T) {
|
|
dir := t.TempDir()
|
|
delegate := &outboxAsyncSink{}
|
|
sink, err := NewDurableOutboxSink(delegate, DurableOutboxConfig{Directory: dir})
|
|
if err != nil {
|
|
t.Fatalf("NewDurableOutboxSink() error = %v", err)
|
|
}
|
|
|
|
if err := sink.PublishRaw(context.Background(), durableTestEnvelope()); err != nil {
|
|
t.Fatalf("PublishRaw() error = %v", err)
|
|
}
|
|
delegate.completeNext(t, errors.New("ack timeout"))
|
|
if got := outboxBacklog(sink); got != 1 {
|
|
t.Fatalf("backlog after ack error = %d, want 1", got)
|
|
}
|
|
if err := sink.ReplayOnce(context.Background()); err != nil {
|
|
t.Fatalf("ReplayOnce() error = %v", err)
|
|
}
|
|
if got := delegate.recordCount(); got != 2 {
|
|
t.Fatalf("async submissions = %d, want 2", got)
|
|
}
|
|
first, second := delegate.recordAt(0), delegate.recordAt(1)
|
|
if first.Envelope.EventID == "" || first.Envelope.EventID != second.Envelope.EventID {
|
|
t.Fatalf("replay event ids = %q and %q", first.Envelope.EventID, second.Envelope.EventID)
|
|
}
|
|
delegate.completeNext(t, nil)
|
|
if got := outboxBacklog(sink); got != 0 {
|
|
t.Fatalf("backlog after replay ack = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestDurableOutboxReplaysPreexistingRecordAfterRestart(t *testing.T) {
|
|
dir := t.TempDir()
|
|
first := &outboxAsyncSink{submitErrors: []error{errors.New("nats unavailable")}}
|
|
writer, err := NewDurableOutboxSink(first, DurableOutboxConfig{Directory: dir})
|
|
if err != nil {
|
|
t.Fatalf("create first outbox: %v", err)
|
|
}
|
|
if err := writer.PublishRaw(context.Background(), durableTestEnvelope()); err != nil {
|
|
t.Fatalf("persist restart record: %v", err)
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
t.Fatalf("close first outbox: %v", err)
|
|
}
|
|
delegate := &outboxAsyncSink{}
|
|
sink, err := NewDurableOutboxSink(delegate, DurableOutboxConfig{Directory: dir, ReplayBatchSize: 10})
|
|
if err != nil {
|
|
t.Fatalf("NewDurableOutboxSink() error = %v", err)
|
|
}
|
|
|
|
if err := sink.ReplayOnce(context.Background()); err != nil {
|
|
t.Fatalf("ReplayOnce() error = %v", err)
|
|
}
|
|
if got := delegate.recordCount(); got != 1 {
|
|
t.Fatalf("replayed records = %d, want 1", got)
|
|
}
|
|
delegate.completeNext(t, nil)
|
|
if got := outboxBacklog(sink); got != 0 {
|
|
t.Fatalf("backlog after replay ack = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestDurableOutboxDoesNotResubmitInflightRecord(t *testing.T) {
|
|
dir := t.TempDir()
|
|
delegate := &outboxAsyncSink{}
|
|
sink, err := NewDurableOutboxSink(delegate, DurableOutboxConfig{Directory: dir})
|
|
if err != nil {
|
|
t.Fatalf("NewDurableOutboxSink() error = %v", err)
|
|
}
|
|
|
|
if err := sink.PublishRaw(context.Background(), durableTestEnvelope()); err != nil {
|
|
t.Fatalf("PublishRaw() error = %v", err)
|
|
}
|
|
if err := sink.ReplayOnce(context.Background()); err != nil {
|
|
t.Fatalf("ReplayOnce() error = %v", err)
|
|
}
|
|
if got := delegate.recordCount(); got != 1 {
|
|
t.Fatalf("submissions while inflight = %d, want 1", got)
|
|
}
|
|
delegate.completeNext(t, nil)
|
|
}
|
|
|
|
func TestDurableOutboxRejectsInvalidRecordBeforePersistence(t *testing.T) {
|
|
dir := t.TempDir()
|
|
delegate := &outboxAsyncSink{validateErr: errors.New("subject not configured")}
|
|
sink, err := NewDurableOutboxSink(delegate, DurableOutboxConfig{Directory: dir})
|
|
if err != nil {
|
|
t.Fatalf("NewDurableOutboxSink() error = %v", err)
|
|
}
|
|
|
|
err = sink.PublishRaw(context.Background(), durableTestEnvelope())
|
|
if err == nil || !strings.Contains(err.Error(), "subject not configured") {
|
|
t.Fatalf("PublishRaw() error = %v", err)
|
|
}
|
|
if got := outboxBacklog(sink); got != 0 {
|
|
t.Fatalf("invalid record backlog = %d, want 0", got)
|
|
}
|
|
if got := delegate.recordCount(); got != 0 {
|
|
t.Fatalf("invalid record submissions = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestDurableOutboxCloseWaitsForPendingAck(t *testing.T) {
|
|
dir := t.TempDir()
|
|
delegate := &outboxAsyncSink{}
|
|
sink, err := NewDurableOutboxSink(delegate, DurableOutboxConfig{
|
|
Directory: dir,
|
|
CloseTimeout: time.Second,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewDurableOutboxSink() error = %v", err)
|
|
}
|
|
if err := sink.PublishRaw(context.Background(), durableTestEnvelope()); err != nil {
|
|
t.Fatalf("PublishRaw() error = %v", err)
|
|
}
|
|
|
|
done := make(chan error, 1)
|
|
go func() { done <- sink.Close() }()
|
|
select {
|
|
case err := <-done:
|
|
t.Fatalf("Close() returned before ack: %v", err)
|
|
case <-time.After(20 * time.Millisecond):
|
|
}
|
|
delegate.completeNext(t, nil)
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatalf("Close() error = %v", err)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("Close() did not return after ack")
|
|
}
|
|
}
|
|
|
|
func TestDurableOutboxCloseTimeoutRetainsRecord(t *testing.T) {
|
|
dir := t.TempDir()
|
|
delegate := &outboxAsyncSink{}
|
|
sink, err := NewDurableOutboxSink(delegate, DurableOutboxConfig{
|
|
Directory: dir,
|
|
CloseTimeout: 10 * time.Millisecond,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewDurableOutboxSink() error = %v", err)
|
|
}
|
|
if err := sink.PublishRaw(context.Background(), durableTestEnvelope()); err != nil {
|
|
t.Fatalf("PublishRaw() error = %v", err)
|
|
}
|
|
|
|
err = sink.Close()
|
|
if err == nil || !strings.Contains(err.Error(), "close timed out") {
|
|
t.Fatalf("Close() error = %v", err)
|
|
}
|
|
if got := outboxBacklog(sink); got != 1 {
|
|
t.Fatalf("backlog after close timeout = %d, want 1", got)
|
|
}
|
|
delegate.completeNext(t, errors.New("connection closed"))
|
|
}
|
|
|
|
func BenchmarkDurableOutboxPublishRaw(b *testing.B) {
|
|
for _, syncWrites := range []bool{false, true} {
|
|
name := "fsync_off"
|
|
if syncWrites {
|
|
name = "fsync_on"
|
|
}
|
|
b.Run(name, func(b *testing.B) {
|
|
sink, err := NewDurableOutboxSink(autoAckOutboxSink{}, DurableOutboxConfig{
|
|
Directory: b.TempDir(),
|
|
SyncWrites: syncWrites,
|
|
})
|
|
if err != nil {
|
|
b.Fatalf("NewDurableOutboxSink() error = %v", err)
|
|
}
|
|
var sequence atomic.Uint32
|
|
b.ResetTimer()
|
|
b.RunParallel(func(pb *testing.PB) {
|
|
for pb.Next() {
|
|
env := durableTestEnvelope()
|
|
env.EventID = ""
|
|
env.Sequence = uint16(sequence.Add(1))
|
|
if err := sink.PublishRaw(context.Background(), env); err != nil {
|
|
b.Fatalf("PublishRaw() error = %v", err)
|
|
}
|
|
}
|
|
})
|
|
b.StopTimer()
|
|
if err := sink.Close(); err != nil {
|
|
b.Fatalf("Close() error = %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func outboxBacklog(sink *DurableOutboxSink) int {
|
|
count, _ := sink.store.Stats()
|
|
return count
|
|
}
|
|
|
|
type outboxAsyncSink struct {
|
|
mu sync.Mutex
|
|
validateErr error
|
|
submitErrors []error
|
|
records []durableRecord
|
|
callbacks []func(error)
|
|
onSubmit func(durableRecord)
|
|
closeCalls int
|
|
}
|
|
|
|
func (s *outboxAsyncSink) ValidateRecord(record durableRecord) error {
|
|
if s.validateErr != nil {
|
|
return s.validateErr
|
|
}
|
|
switch record.Kind {
|
|
case "raw", "unified", "fields":
|
|
return nil
|
|
default:
|
|
return errUnknownRecordKind(record.Kind)
|
|
}
|
|
}
|
|
|
|
func (s *outboxAsyncSink) PublishRecordAsync(record durableRecord, complete func(error)) error {
|
|
if s.onSubmit != nil {
|
|
s.onSubmit(record)
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if len(s.submitErrors) > 0 {
|
|
err := s.submitErrors[0]
|
|
s.submitErrors = s.submitErrors[1:]
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
s.records = append(s.records, record)
|
|
s.callbacks = append(s.callbacks, complete)
|
|
return nil
|
|
}
|
|
|
|
func (s *outboxAsyncSink) PublishRaw(context.Context, envelope.FrameEnvelope) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *outboxAsyncSink) PublishUnified(context.Context, envelope.FrameEnvelope) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *outboxAsyncSink) PublishFields(context.Context, envelope.FrameEnvelope) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *outboxAsyncSink) Close() error {
|
|
s.mu.Lock()
|
|
s.closeCalls++
|
|
s.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (s *outboxAsyncSink) completeNext(t *testing.T, err error) {
|
|
t.Helper()
|
|
s.mu.Lock()
|
|
if len(s.callbacks) == 0 {
|
|
s.mu.Unlock()
|
|
t.Fatal("no pending async callback")
|
|
}
|
|
callback := s.callbacks[0]
|
|
s.callbacks = s.callbacks[1:]
|
|
s.mu.Unlock()
|
|
callback(err)
|
|
}
|
|
|
|
func (s *outboxAsyncSink) recordCount() int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return len(s.records)
|
|
}
|
|
|
|
func (s *outboxAsyncSink) recordAt(index int) durableRecord {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.records[index]
|
|
}
|
|
|
|
type autoAckOutboxSink struct{}
|
|
|
|
func (autoAckOutboxSink) ValidateRecord(record durableRecord) error {
|
|
switch record.Kind {
|
|
case "raw", "unified", "fields":
|
|
return nil
|
|
default:
|
|
return errUnknownRecordKind(record.Kind)
|
|
}
|
|
}
|
|
|
|
func (autoAckOutboxSink) PublishRecordAsync(_ durableRecord, complete func(error)) error {
|
|
complete(nil)
|
|
return nil
|
|
}
|
|
|
|
func (autoAckOutboxSink) PublishRaw(context.Context, envelope.FrameEnvelope) error {
|
|
return nil
|
|
}
|
|
|
|
func (autoAckOutboxSink) PublishUnified(context.Context, envelope.FrameEnvelope) error {
|
|
return nil
|
|
}
|
|
|
|
func (autoAckOutboxSink) PublishFields(context.Context, envelope.FrameEnvelope) error {
|
|
return nil
|
|
}
|
|
|
|
func (autoAckOutboxSink) Close() error { return nil }
|