package go_kv import ( "bytes" "fmt" "os" "path/filepath" "sort" "strings" "sync" "sync/atomic" "testing" "github.com/dailz/go-kv/config" "github.com/dailz/go-kv/manifest" "github.com/dailz/go-kv/wal" testifyassert "github.com/stretchr/testify/assert" testifyrequire "github.com/stretchr/testify/require" ) func TestE2EWALRoundtrip(t *testing.T) { dir := t.TempDir() db := openTestDB(t, dir) const entryCount = 1000 for i := range entryCount { key := []byte(e2eKey("roundtrip", i)) value := []byte(e2eValue("roundtrip", i)) testifyrequire.NoError(t, db.Put(key, value), "put %q", key) } closeTestDB(t, db) reopened := openTestDB(t, dir) for i := range entryCount { key := []byte(e2eKey("roundtrip", i)) got := reopened.Get(key) testifyrequire.True(t, got.Found, "get %q after reopen", key) testifyassert.Equal(t, []byte(e2eValue("roundtrip", i)), got.Value, "value for %q", key) } } func TestE2ERecoveryPartialWrite(t *testing.T) { dir := t.TempDir() db := openTestDB(t, dir) testifyrequire.NoError(t, db.Put([]byte("preserved-1"), []byte("value-1"))) testifyrequire.NoError(t, db.Put([]byte("preserved-2"), []byte("value-2"))) testifyrequire.NoError(t, db.Put([]byte("truncated-tail"), bytes.Repeat([]byte("x"), 256))) closeTestDB(t, db) appendWalTail(t, dir, []byte{0xde, 0xad, 0xbe, 0xef}) reopened := openTestDB(t, dir) for _, tc := range []struct { name string key string value []byte }{ {name: "first complete batch survives", key: "preserved-1", value: []byte("value-1")}, {name: "second complete batch survives", key: "preserved-2", value: []byte("value-2")}, } { t.Run(tc.name, func(t *testing.T) { got := reopened.Get([]byte(tc.key)) testifyrequire.True(t, got.Found, "get %q after tail truncation", tc.key) testifyassert.Equal(t, tc.value, got.Value) }) } } func TestE2ERecoveryMultiSegment(t *testing.T) { dir := t.TempDir() cfg := e2eSmallSegmentConfig() db := openTestDBWithConfig(t, dir, cfg) const entryCount = 80 value := bytes.Repeat([]byte("m"), 512) for i := range entryCount { key := []byte(e2eKey("multi-segment", i)) testifyrequire.NoError(t, db.Put(key, e2eValueWithSuffix(value, i)), "put %q", key) } closeTestDB(t, db) testifyassert.Greater(t, len(walSegmentFiles(t, dir)), 1, "test setup should rotate WAL segments") prepareAllSegmentsForRecovery(t, dir, cfg) reopened := openTestDBWithConfig(t, dir, cfg) for i := range entryCount { key := []byte(e2eKey("multi-segment", i)) got := reopened.Get(key) testifyrequire.True(t, got.Found, "get %q after multi-segment recovery", key) testifyassert.Equal(t, e2eValueWithSuffix(value, i), got.Value, "value for %q", key) } } func TestE2EWriteStoppedAfterFailure(t *testing.T) { t.Skip("write-stopped-after-I/O-failure requires an injectable WAL writer or filesystem fault; current DB architecture has neither") } func TestE2EGetSemantics(t *testing.T) { db := openTestDB(t, t.TempDir()) testifyrequire.NoError(t, db.Put([]byte("empty"), []byte{})) gotEmpty := db.Get([]byte("empty")) testifyrequire.True(t, gotEmpty.Found, "empty value should be found") testifyassert.Empty(t, gotEmpty.Value, "empty value should round-trip as empty bytes") gotMissing := db.Get([]byte("missing")) testifyassert.False(t, gotMissing.Found, "nonexistent key should not be found") testifyassert.Empty(t, gotMissing.Value, "nonexistent key should not return a value") testifyrequire.NoError(t, db.Delete([]byte("empty"))) gotDeleted := db.Get([]byte("empty")) testifyassert.False(t, gotDeleted.Found, "deleted key should not be found") } func TestE2EConcurrentReadWrite(t *testing.T) { db := openTestDB(t, t.TempDir()) const ( writerCount = 10 readerCount = 10 entriesPerWriter = 100 readsPerReader = 1000 totalPossibleKeys = writerCount * entriesPerWriter ) var writersDone atomic.Bool errCh := make(chan error, writerCount+readerCount) var writerWG sync.WaitGroup writerWG.Add(writerCount) for writerID := range writerCount { go func() { defer writerWG.Done() for entryID := range entriesPerWriter { key := []byte(concurrentKey(writerID, entryID)) value := []byte(concurrentValue(writerID, entryID)) if err := db.Put(key, value); err != nil { errCh <- fmt.Errorf("writer %d put %q: %w", writerID, key, err) return } } }() } var readerWG sync.WaitGroup readerWG.Add(readerCount) for readerID := range readerCount { go func() { defer readerWG.Done() for i := range readsPerReader { keyIndex := (readerID*readsPerReader + i) % totalPossibleKeys writerID := keyIndex / entriesPerWriter entryID := keyIndex % entriesPerWriter got := db.Get([]byte(concurrentKey(writerID, entryID))) if !got.Found { if writersDone.Load() { errCh <- fmt.Errorf("reader %d missing key after writers done: writer=%d entry=%d", readerID, writerID, entryID) return } continue } want := []byte(concurrentValue(writerID, entryID)) if !bytes.Equal(want, got.Value) { errCh <- fmt.Errorf("reader %d got value %q for writer=%d entry=%d, want %q", readerID, got.Value, writerID, entryID, want) return } } }() } writerWG.Wait() writersDone.Store(true) readerWG.Wait() close(errCh) for err := range errCh { testifyrequire.NoError(t, err) } } func TestE2ELargeBatchSegmentRotation(t *testing.T) { dir := t.TempDir() cfg := e2eSmallSegmentConfig() db := openTestDBWithConfig(t, dir, cfg) const entryCount = 120 value := bytes.Repeat([]byte("b"), 512) for i := range entryCount { key := []byte(e2eKey("large-rotation", i)) testifyrequire.NoError(t, db.Put(key, e2eValueWithSuffix(value, i)), "put %q", key) } closeTestDB(t, db) testifyassert.Greater(t, len(walSegmentFiles(t, dir)), 1, "test setup should rotate WAL segments") prepareAllSegmentsForRecovery(t, dir, cfg) reopened := openTestDBWithConfig(t, dir, cfg) for i := range entryCount { key := []byte(e2eKey("large-rotation", i)) got := reopened.Get(key) testifyrequire.True(t, got.Found, "get %q after large rotation recovery", key) testifyassert.Equal(t, e2eValueWithSuffix(value, i), got.Value, "value for %q", key) } } func openTestDBWithConfig(t *testing.T, dir string, cfg config.WalConfig) *DB { t.Helper() db, err := Open(dir, &cfg) testifyrequire.NoError(t, err, "Open") t.Cleanup(func() { if !db.closed.Load() { testifyrequire.NoError(t, db.Close(), "Close cleanup") } }) return db } func e2eSmallSegmentConfig() config.WalConfig { cfg := config.Defaults() cfg.MaxSegmentSize = 3 * 1024 cfg.MaxBatchSize = 2 * 1024 cfg.MaxInlineValue = 1024 cfg.MemTableSize = 1024 * 1024 return cfg } func e2eKey(prefix string, i int) string { return fmt.Sprintf("%s-key-%04d", prefix, i) } func e2eValue(prefix string, i int) string { return fmt.Sprintf("%s-value-%04d", prefix, i) } func e2eValueWithSuffix(prefix []byte, i int) []byte { return fmt.Appendf(bytes.Clone(prefix), "-%03d", i) } func concurrentKey(writerID int, entryID int) string { return fmt.Sprintf("writer-%02d-key-%03d", writerID, entryID) } func concurrentValue(writerID int, entryID int) string { return fmt.Sprintf("writer-%02d-value-%03d", writerID, entryID) } func walSegmentFiles(t *testing.T, dir string) []string { t.Helper() entries, err := os.ReadDir(dir) testifyrequire.NoError(t, err, "ReadDir") segments := make([]string, 0) for _, entry := range entries { if entry.IsDir() || !strings.HasPrefix(entry.Name(), "segment-") || !strings.HasSuffix(entry.Name(), ".wal") { continue } segments = append(segments, filepath.Join(dir, entry.Name())) } sort.Strings(segments) return segments } func prepareAllSegmentsForRecovery(t *testing.T, dir string, cfg config.WalConfig) { t.Helper() testifyrequire.NoError(t, manifest.WriteCurrent(dir, 0), "reset CURRENT to recover from first WAL segment") for _, segment := range walSegmentFiles(t, dir) { segmentID, ok := wal.ParseSegmentFilename(filepath.Base(segment)) testifyrequire.True(t, ok, "parse WAL segment filename %q", segment) startSequence := firstBatchSequence(t, segment) header := wal.EncodeWalHeader(&wal.WalFileHeader{ BlockSize: cfg.BlockSize, SegmentID: segmentID, StartSequence: startSequence, }) file, err := os.OpenFile(segment, os.O_WRONLY, 0) testifyrequire.NoError(t, err, "open WAL segment header for rewrite") _, err = file.WriteAt(header[:], 0) testifyassert.NoError(t, err, "rewrite WAL segment header") testifyassert.NoError(t, file.Sync(), "sync WAL segment header rewrite") testifyassert.NoError(t, file.Close(), "close WAL segment header rewrite") } } func firstBatchSequence(t *testing.T, segment string) uint64 { t.Helper() records, err := wal.ParseRecordsFromFile(segment) testifyrequire.NoError(t, err, "parse WAL records from %s", segment) collector := wal.NewFragmentCollector() for _, record := range records { testifyrequire.NoError(t, collector.Append(record.Type, record.Payload), "collect WAL record fragments") if !collector.IsComplete() { continue } batch, err := wal.DecodeWalBatch(collector.BatchData()) testifyrequire.NoError(t, err, "decode first WAL batch") return batch.BaseSequence } t.Fatalf("segment %s contains no complete WAL batch", segment) return 0 }