All checks were successful
CI/CD / Test (push) Successful in 2m55s
CI/CD / Lint (push) Successful in 1m12s
CI/CD / Integration Tests (push) Successful in 50s
CI/CD / Native Engine Tests (push) Successful in 51s
CI/CD / Build Binary (push) Successful in 45s
CI/CD / Test Release Build (push) Successful in 1m20s
CI/CD / Release Binaries (push) Successful in 10m27s
Features: - Performance analysis package with 2GB/s+ throughput benchmarks - Comprehensive test coverage improvements (exitcode, errors, metadata 100%) - Grafana dashboard updates - Structured error types with codes and remediation guidance Testing: - Added exitcode tests (100% coverage) - Added errors package tests (100% coverage) - Added metadata tests (92.2% coverage) - Improved fs tests (20.9% coverage) - Improved checks tests (20.3% coverage) Performance: - 2,048 MB/s dump throughput (4x target) - 1,673 MB/s restore throughput (5.6x target) - Buffer pooling for bounded memory usage
520 lines
13 KiB
Go
520 lines
13 KiB
Go
package catalog
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// =============================================================================
|
|
// Concurrent Access Tests
|
|
// =============================================================================
|
|
|
|
func TestConcurrency_MultipleReaders(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping concurrency test in short mode")
|
|
}
|
|
|
|
tmpDir, err := os.MkdirTemp("", "concurrent_test_*")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
cat, err := NewSQLiteCatalog(filepath.Join(tmpDir, "catalog.db"))
|
|
if err != nil {
|
|
t.Fatalf("failed to create catalog: %v", err)
|
|
}
|
|
defer cat.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Seed with data
|
|
for i := 0; i < 100; i++ {
|
|
entry := &Entry{
|
|
Database: "testdb",
|
|
DatabaseType: "postgres",
|
|
BackupPath: filepath.Join("/backups", "test_"+string(rune('A'+i%26))+string(rune('0'+i/26))+".tar.gz"),
|
|
SizeBytes: int64(i * 1024),
|
|
CreatedAt: time.Now().Add(-time.Duration(i) * time.Minute),
|
|
Status: StatusCompleted,
|
|
}
|
|
if err := cat.Add(ctx, entry); err != nil {
|
|
t.Fatalf("failed to seed data: %v", err)
|
|
}
|
|
}
|
|
|
|
// Run 100 concurrent readers
|
|
var wg sync.WaitGroup
|
|
var errors atomic.Int64
|
|
numReaders := 100
|
|
|
|
wg.Add(numReaders)
|
|
for i := 0; i < numReaders; i++ {
|
|
go func() {
|
|
defer wg.Done()
|
|
entries, err := cat.Search(ctx, &SearchQuery{Limit: 10})
|
|
if err != nil {
|
|
errors.Add(1)
|
|
t.Errorf("concurrent read failed: %v", err)
|
|
return
|
|
}
|
|
if len(entries) == 0 {
|
|
errors.Add(1)
|
|
t.Error("concurrent read returned no entries")
|
|
}
|
|
}()
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
if errors.Load() > 0 {
|
|
t.Errorf("%d concurrent read errors occurred", errors.Load())
|
|
}
|
|
}
|
|
|
|
func TestConcurrency_WriterAndReaders(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping concurrency test in short mode")
|
|
}
|
|
|
|
tmpDir, err := os.MkdirTemp("", "concurrent_test_*")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
cat, err := NewSQLiteCatalog(filepath.Join(tmpDir, "catalog.db"))
|
|
if err != nil {
|
|
t.Fatalf("failed to create catalog: %v", err)
|
|
}
|
|
defer cat.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Start writers and readers concurrently
|
|
var wg sync.WaitGroup
|
|
var writeErrors, readErrors atomic.Int64
|
|
|
|
numWriters := 10
|
|
numReaders := 50
|
|
writesPerWriter := 10
|
|
|
|
// Start writers
|
|
for w := 0; w < numWriters; w++ {
|
|
wg.Add(1)
|
|
go func(writerID int) {
|
|
defer wg.Done()
|
|
for i := 0; i < writesPerWriter; i++ {
|
|
entry := &Entry{
|
|
Database: "concurrent_db",
|
|
DatabaseType: "postgres",
|
|
BackupPath: filepath.Join("/backups", "writer_"+string(rune('A'+writerID))+"_"+string(rune('0'+i))+".tar.gz"),
|
|
SizeBytes: int64(i * 1024),
|
|
CreatedAt: time.Now(),
|
|
Status: StatusCompleted,
|
|
}
|
|
if err := cat.Add(ctx, entry); err != nil {
|
|
writeErrors.Add(1)
|
|
t.Errorf("writer %d failed: %v", writerID, err)
|
|
}
|
|
}
|
|
}(w)
|
|
}
|
|
|
|
// Start readers (slightly delayed to ensure some data exists)
|
|
time.Sleep(10 * time.Millisecond)
|
|
for r := 0; r < numReaders; r++ {
|
|
wg.Add(1)
|
|
go func(readerID int) {
|
|
defer wg.Done()
|
|
for i := 0; i < 5; i++ {
|
|
_, err := cat.Search(ctx, &SearchQuery{Limit: 20})
|
|
if err != nil {
|
|
readErrors.Add(1)
|
|
t.Errorf("reader %d failed: %v", readerID, err)
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
}(r)
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
if writeErrors.Load() > 0 {
|
|
t.Errorf("%d write errors occurred", writeErrors.Load())
|
|
}
|
|
if readErrors.Load() > 0 {
|
|
t.Errorf("%d read errors occurred", readErrors.Load())
|
|
}
|
|
|
|
// Verify data integrity
|
|
entries, err := cat.Search(ctx, &SearchQuery{Database: "concurrent_db", Limit: 1000})
|
|
if err != nil {
|
|
t.Fatalf("final search failed: %v", err)
|
|
}
|
|
|
|
expectedEntries := numWriters * writesPerWriter
|
|
if len(entries) < expectedEntries-10 { // Allow some tolerance for timing
|
|
t.Logf("Warning: expected ~%d entries, got %d", expectedEntries, len(entries))
|
|
}
|
|
}
|
|
|
|
func TestConcurrency_SimultaneousWrites(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping concurrency test in short mode")
|
|
}
|
|
|
|
tmpDir, err := os.MkdirTemp("", "concurrent_test_*")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
cat, err := NewSQLiteCatalog(filepath.Join(tmpDir, "catalog.db"))
|
|
if err != nil {
|
|
t.Fatalf("failed to create catalog: %v", err)
|
|
}
|
|
defer cat.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Simulate backup processes writing to catalog simultaneously
|
|
var wg sync.WaitGroup
|
|
var successCount, failCount atomic.Int64
|
|
|
|
numProcesses := 20
|
|
|
|
// All start at the same time
|
|
start := make(chan struct{})
|
|
|
|
for p := 0; p < numProcesses; p++ {
|
|
wg.Add(1)
|
|
go func(processID int) {
|
|
defer wg.Done()
|
|
<-start // Wait for start signal
|
|
|
|
entry := &Entry{
|
|
Database: "prod_db",
|
|
DatabaseType: "postgres",
|
|
BackupPath: filepath.Join("/backups", "process_"+string(rune('A'+processID))+".tar.gz"),
|
|
SizeBytes: 1024 * 1024,
|
|
CreatedAt: time.Now(),
|
|
Status: StatusCompleted,
|
|
}
|
|
|
|
if err := cat.Add(ctx, entry); err != nil {
|
|
failCount.Add(1)
|
|
// Some failures are expected due to SQLite write contention
|
|
t.Logf("process %d write failed (expected under contention): %v", processID, err)
|
|
} else {
|
|
successCount.Add(1)
|
|
}
|
|
}(p)
|
|
}
|
|
|
|
// Start all processes simultaneously
|
|
close(start)
|
|
wg.Wait()
|
|
|
|
t.Logf("Simultaneous writes: %d succeeded, %d failed", successCount.Load(), failCount.Load())
|
|
|
|
// At least some writes should succeed
|
|
if successCount.Load() == 0 {
|
|
t.Error("no writes succeeded - complete write failure")
|
|
}
|
|
}
|
|
|
|
func TestConcurrency_CatalogLocking(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping concurrency test in short mode")
|
|
}
|
|
|
|
tmpDir, err := os.MkdirTemp("", "concurrent_test_*")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
dbPath := filepath.Join(tmpDir, "catalog.db")
|
|
|
|
// Open multiple catalog instances (simulating multiple processes)
|
|
cat1, err := NewSQLiteCatalog(dbPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to create catalog 1: %v", err)
|
|
}
|
|
defer cat1.Close()
|
|
|
|
cat2, err := NewSQLiteCatalog(dbPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to create catalog 2: %v", err)
|
|
}
|
|
defer cat2.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Write from first instance
|
|
entry1 := &Entry{
|
|
Database: "from_cat1",
|
|
DatabaseType: "postgres",
|
|
BackupPath: "/backups/from_cat1.tar.gz",
|
|
SizeBytes: 1024,
|
|
CreatedAt: time.Now(),
|
|
Status: StatusCompleted,
|
|
}
|
|
if err := cat1.Add(ctx, entry1); err != nil {
|
|
t.Fatalf("cat1 add failed: %v", err)
|
|
}
|
|
|
|
// Write from second instance
|
|
entry2 := &Entry{
|
|
Database: "from_cat2",
|
|
DatabaseType: "postgres",
|
|
BackupPath: "/backups/from_cat2.tar.gz",
|
|
SizeBytes: 2048,
|
|
CreatedAt: time.Now(),
|
|
Status: StatusCompleted,
|
|
}
|
|
if err := cat2.Add(ctx, entry2); err != nil {
|
|
t.Fatalf("cat2 add failed: %v", err)
|
|
}
|
|
|
|
// Both instances should see both entries
|
|
entries1, err := cat1.Search(ctx, &SearchQuery{Limit: 10})
|
|
if err != nil {
|
|
t.Fatalf("cat1 search failed: %v", err)
|
|
}
|
|
if len(entries1) != 2 {
|
|
t.Errorf("cat1 expected 2 entries, got %d", len(entries1))
|
|
}
|
|
|
|
entries2, err := cat2.Search(ctx, &SearchQuery{Limit: 10})
|
|
if err != nil {
|
|
t.Fatalf("cat2 search failed: %v", err)
|
|
}
|
|
if len(entries2) != 2 {
|
|
t.Errorf("cat2 expected 2 entries, got %d", len(entries2))
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Stress Tests
|
|
// =============================================================================
|
|
|
|
func TestStress_HighVolumeWrites(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping stress test in short mode")
|
|
}
|
|
|
|
tmpDir, err := os.MkdirTemp("", "stress_test_*")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
cat, err := NewSQLiteCatalog(filepath.Join(tmpDir, "catalog.db"))
|
|
if err != nil {
|
|
t.Fatalf("failed to create catalog: %v", err)
|
|
}
|
|
defer cat.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Write 1000 entries as fast as possible
|
|
numEntries := 1000
|
|
start := time.Now()
|
|
|
|
for i := 0; i < numEntries; i++ {
|
|
entry := &Entry{
|
|
Database: "stress_db",
|
|
DatabaseType: "postgres",
|
|
BackupPath: filepath.Join("/backups", "stress_"+string(rune('A'+i/100))+"_"+string(rune('0'+i%100))+".tar.gz"),
|
|
SizeBytes: int64(i * 1024),
|
|
CreatedAt: time.Now(),
|
|
Status: StatusCompleted,
|
|
}
|
|
if err := cat.Add(ctx, entry); err != nil {
|
|
t.Fatalf("write %d failed: %v", i, err)
|
|
}
|
|
}
|
|
|
|
duration := time.Since(start)
|
|
rate := float64(numEntries) / duration.Seconds()
|
|
t.Logf("Wrote %d entries in %v (%.2f entries/sec)", numEntries, duration, rate)
|
|
|
|
// Verify all entries are present
|
|
entries, err := cat.Search(ctx, &SearchQuery{Database: "stress_db", Limit: numEntries + 100})
|
|
if err != nil {
|
|
t.Fatalf("verification search failed: %v", err)
|
|
}
|
|
if len(entries) != numEntries {
|
|
t.Errorf("expected %d entries, got %d", numEntries, len(entries))
|
|
}
|
|
}
|
|
|
|
func TestStress_ContextCancellation(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping stress test in short mode")
|
|
}
|
|
|
|
tmpDir, err := os.MkdirTemp("", "stress_test_*")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
cat, err := NewSQLiteCatalog(filepath.Join(tmpDir, "catalog.db"))
|
|
if err != nil {
|
|
t.Fatalf("failed to create catalog: %v", err)
|
|
}
|
|
defer cat.Close()
|
|
|
|
// Create a cancellable context
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
// Start a goroutine that will cancel context after some writes
|
|
var wg sync.WaitGroup
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
time.Sleep(50 * time.Millisecond)
|
|
cancel()
|
|
}()
|
|
|
|
// Try to write many entries - some should fail after cancel
|
|
var cancelled bool
|
|
for i := 0; i < 1000; i++ {
|
|
entry := &Entry{
|
|
Database: "cancel_test",
|
|
DatabaseType: "postgres",
|
|
BackupPath: filepath.Join("/backups", "cancel_"+string(rune('A'+i/26))+"_"+string(rune('0'+i%26))+".tar.gz"),
|
|
SizeBytes: int64(i * 1024),
|
|
CreatedAt: time.Now(),
|
|
Status: StatusCompleted,
|
|
}
|
|
err := cat.Add(ctx, entry)
|
|
if err != nil {
|
|
if ctx.Err() == context.Canceled {
|
|
cancelled = true
|
|
break
|
|
}
|
|
t.Logf("write %d failed with non-cancel error: %v", i, err)
|
|
}
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
if !cancelled {
|
|
t.Log("Warning: context cancellation may not be fully implemented in catalog")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Resource Exhaustion Tests
|
|
// =============================================================================
|
|
|
|
func TestResource_FileDescriptorLimit(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping resource test in short mode")
|
|
}
|
|
|
|
tmpDir, err := os.MkdirTemp("", "resource_test_*")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
// Open many catalogs to test file descriptor handling
|
|
catalogs := make([]*SQLiteCatalog, 0, 50)
|
|
defer func() {
|
|
for _, cat := range catalogs {
|
|
cat.Close()
|
|
}
|
|
}()
|
|
|
|
for i := 0; i < 50; i++ {
|
|
cat, err := NewSQLiteCatalog(filepath.Join(tmpDir, "catalog_"+string(rune('A'+i/26))+"_"+string(rune('0'+i%26))+".db"))
|
|
if err != nil {
|
|
t.Logf("Failed to open catalog %d: %v", i, err)
|
|
break
|
|
}
|
|
catalogs = append(catalogs, cat)
|
|
}
|
|
|
|
t.Logf("Successfully opened %d catalogs", len(catalogs))
|
|
|
|
// All should still be usable
|
|
ctx := context.Background()
|
|
for i, cat := range catalogs {
|
|
entry := &Entry{
|
|
Database: "test",
|
|
DatabaseType: "postgres",
|
|
BackupPath: "/backups/test_" + string(rune('0'+i%10)) + ".tar.gz",
|
|
SizeBytes: 1024,
|
|
CreatedAt: time.Now(),
|
|
Status: StatusCompleted,
|
|
}
|
|
if err := cat.Add(ctx, entry); err != nil {
|
|
t.Errorf("catalog %d unusable: %v", i, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResource_LongRunningOperations(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping resource test in short mode")
|
|
}
|
|
|
|
tmpDir, err := os.MkdirTemp("", "resource_test_*")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
cat, err := NewSQLiteCatalog(filepath.Join(tmpDir, "catalog.db"))
|
|
if err != nil {
|
|
t.Fatalf("failed to create catalog: %v", err)
|
|
}
|
|
defer cat.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Simulate a long-running session with many operations
|
|
operations := 0
|
|
start := time.Now()
|
|
duration := 2 * time.Second
|
|
|
|
for time.Since(start) < duration {
|
|
// Alternate between reads and writes
|
|
if operations%3 == 0 {
|
|
entry := &Entry{
|
|
Database: "longrun",
|
|
DatabaseType: "postgres",
|
|
BackupPath: filepath.Join("/backups", "longrun_"+string(rune('A'+operations/26%26))+"_"+string(rune('0'+operations%26))+".tar.gz"),
|
|
SizeBytes: int64(operations * 1024),
|
|
CreatedAt: time.Now(),
|
|
Status: StatusCompleted,
|
|
}
|
|
if err := cat.Add(ctx, entry); err != nil {
|
|
// Allow duplicate path errors
|
|
if err.Error() != "" {
|
|
t.Logf("write failed at operation %d: %v", operations, err)
|
|
}
|
|
}
|
|
} else {
|
|
_, err := cat.Search(ctx, &SearchQuery{Limit: 10})
|
|
if err != nil {
|
|
t.Errorf("read failed at operation %d: %v", operations, err)
|
|
}
|
|
}
|
|
operations++
|
|
}
|
|
|
|
rate := float64(operations) / duration.Seconds()
|
|
t.Logf("Completed %d operations in %v (%.2f ops/sec)", operations, duration, rate)
|
|
}
|