more platform
This commit is contained in:
1 parent
8a71e32149
commit
2a66754f9e
27 files changed
+2435
-540
No files matched your search
@@ -0,0 +1,478 @@
|
||||
package projection
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"math/rand"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestSnapshotRoundTrip — write 1000 values in shuffled order, read back
|
||||
// and verify all 1000 are found at the right key.
|
||||
func TestSnapshotRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "snap.bin")
|
||||
|
||||
const n = 1000
|
||||
w := NewSnapshotWriter[tProj]().WithTenant("default")
|
||||
// intentionally shuffled input — sort is what reorders at Write.
|
||||
perm := rand.Perm(n)
|
||||
for _, i := range perm {
|
||||
sku := fmt.Sprintf("SKU-%05d", i)
|
||||
if err := appendP(w, makeProjection(sku, int64(100+(i%97)*100))); err != nil {
|
||||
t.Fatalf("Append %s: %v", sku, err)
|
||||
}
|
||||
}
|
||||
if w.Len() != n {
|
||||
t.Fatalf("Len: got %d want %d", w.Len(), n)
|
||||
}
|
||||
final, err := w.Write(dir, "snap.bin")
|
||||
if err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
if final != path {
|
||||
t.Errorf("Write returned %q expected %q", final, path)
|
||||
}
|
||||
|
||||
r, err := ReadSnapshotFile[tProj](path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadSnapshotFile: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
if r.Len() != n {
|
||||
t.Errorf("Len: got %d want %d", r.Len(), n)
|
||||
}
|
||||
if r.Tenant() != "default" {
|
||||
t.Errorf("Tenant: got %q want %q", r.Tenant(), "default")
|
||||
}
|
||||
|
||||
// every key present and correct
|
||||
for _, i := range rand.Perm(n)[:50] { // probe 50 random lookups
|
||||
sku := fmt.Sprintf("SKU-%05d", i)
|
||||
got, ok := r.Get(sku)
|
||||
if !ok {
|
||||
t.Errorf("Get(%q) not found", sku)
|
||||
continue
|
||||
}
|
||||
want := int64(100 + (i%97)*100)
|
||||
if got.PriceIncVat != want {
|
||||
t.Errorf("Get(%q) PriceIncVat: got %d want %d", sku, got.PriceIncVat, want)
|
||||
}
|
||||
}
|
||||
|
||||
// missing key
|
||||
if _, ok := r.Get("NOPE-99999"); ok {
|
||||
t.Error("Get on missing key should return ok=false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotScanWalksAllInKeyOrder — Scan visits every record exactly once in
|
||||
// ascending key order, and early-return stops the walk.
|
||||
func TestSnapshotScanWalksAllInKeyOrder(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
const n = 300
|
||||
w := NewSnapshotWriter[tProj]()
|
||||
for _, i := range rand.Perm(n) {
|
||||
sku := fmt.Sprintf("K-%04d", i)
|
||||
if err := appendP(w, makeProjection(sku, int64(i))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := w.Write(dir, "scan.bin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := ReadSnapshotFile[tProj](filepath.Join(dir, "scan.bin"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
var keys []string
|
||||
r.Scan(func(key string, v tProj) bool {
|
||||
if v.SKU != key {
|
||||
t.Errorf("Scan value/key mismatch: key=%q value.SKU=%q", key, v.SKU)
|
||||
}
|
||||
keys = append(keys, key)
|
||||
return true
|
||||
})
|
||||
if len(keys) != n {
|
||||
t.Fatalf("Scan visited %d records, want %d", len(keys), n)
|
||||
}
|
||||
for i := 1; i < len(keys); i++ {
|
||||
if keys[i-1] >= keys[i] {
|
||||
t.Fatalf("Scan not ascending at %d: %q >= %q", i, keys[i-1], keys[i])
|
||||
}
|
||||
}
|
||||
|
||||
// early-return stops the walk
|
||||
count := 0
|
||||
r.Scan(func(string, tProj) bool {
|
||||
count++
|
||||
return count < 5
|
||||
})
|
||||
if count != 5 {
|
||||
t.Errorf("Scan early-return visited %d, want 5", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotCompareSKUInvariant — verify the on-disk record section is
|
||||
// sorted under the same compareSKU total order that BinarySearch uses.
|
||||
func TestSnapshotCompareSKUInvariant(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
w := NewSnapshotWriter[tProj]()
|
||||
// deliberately inverted order after Append (so sort has work to do)
|
||||
perm := rand.Perm(500)
|
||||
for _, i := range perm {
|
||||
sku := fmt.Sprintf("XX-%05d", i)
|
||||
if err := appendP(w, makeProjection(sku, int64(i))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := w.Write(dir, "snap.bin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := ReadSnapshotFile[tProj](filepath.Join(dir, "snap.bin"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
// Walk every record slot, harvest key bytes from the pad-then-trim
|
||||
// form, and assert non-decreasing order under compareSKU.
|
||||
prevSKU := ""
|
||||
for i := 0; i < r.Len(); i++ {
|
||||
recOff := int(r.raw.recordStart) + i*recordSize
|
||||
raw := r.raw.data[recOff : recOff+32]
|
||||
// trim trailing zeros
|
||||
end := len(raw)
|
||||
for end > 0 && raw[end-1] == 0 {
|
||||
end--
|
||||
}
|
||||
cur := string(raw[:end])
|
||||
if i > 0 {
|
||||
if compareSKU(runesToPadded32(prevSKU), []byte(cur)) > 0 {
|
||||
t.Errorf("record section out of order at i=%d: prev=%q cur=%q", i, prevSKU, cur)
|
||||
}
|
||||
}
|
||||
prevSKU = cur
|
||||
}
|
||||
}
|
||||
|
||||
// runesToPadded32 zero-pads s into a 32-byte slot for compareSKU in the
|
||||
// invariant test.
|
||||
func runesToPadded32(s string) []byte {
|
||||
out := make([]byte, 32)
|
||||
copy(out, s)
|
||||
return out
|
||||
}
|
||||
|
||||
// TestSnapshotTenantRoundTrip — Writer.WithTenant lands in the header and
|
||||
// Reader surfaces it.
|
||||
func TestSnapshotTenantRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
w := NewSnapshotWriter[tProj]().WithTenant("bygglagret")
|
||||
if err := appendP(w, makeProjection("ABC-1", 100)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write(dir, "snap.bin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := ReadSnapshotFile[tProj](filepath.Join(dir, "snap.bin"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
if r.Tenant() != "bygglagret" {
|
||||
t.Errorf("Tenant: got %q want %q", r.Tenant(), "bygglagret")
|
||||
}
|
||||
|
||||
// Empty tenant writes zero-padded.
|
||||
w2 := NewSnapshotWriter[tProj]()
|
||||
if err := appendP(w2, makeProjection("ABC-2", 200)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w2.Write(dir, "snap2.bin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r2, err := ReadSnapshotFile[tProj](filepath.Join(dir, "snap2.bin"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r2.Close()
|
||||
if r2.Tenant() != "" {
|
||||
t.Errorf("Tenant (no tag): got %q want empty", r2.Tenant())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotTenantTooLong — over-long tenant name rejected at Write.
|
||||
func TestSnapshotTenantTooLong(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
w := NewSnapshotWriter[tProj]().WithTenant(string(make([]byte, 33)))
|
||||
if err := appendP(w, makeProjection("ABC", 1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write(dir, "snap.bin"); err == nil {
|
||||
t.Error("expected error for over-long tenant")
|
||||
} else if !errors.Is(err, ErrTenantTooLong) {
|
||||
t.Errorf("got %v want ErrTenantTooLong", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotCountOverflowRejected — tampered count that would cause
|
||||
// recordStart + records*recordSize to overflow uint64 must be rejected
|
||||
// with ErrSnapshotOverflow.
|
||||
func TestSnapshotCountOverflowRejected(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "over.bin")
|
||||
bs := make([]byte, headerSize)
|
||||
binary.LittleEndian.PutUint64(bs[0:8], snapshotMagic)
|
||||
binary.LittleEndian.PutUint32(bs[8:12], snapshotVersion)
|
||||
binary.LittleEndian.PutUint32(bs[12:16], 0)
|
||||
// count that overflows when multiplied by recordSize=44
|
||||
binary.LittleEndian.PutUint64(bs[16:24], math.MaxUint64/uint64(recordSize)+1)
|
||||
binary.LittleEndian.PutUint64(bs[24:32], uint64(headerSize))
|
||||
binary.LittleEndian.PutUint64(bs[32:40], uint64(headerSize))
|
||||
if err := os2WriteFile(path, bs, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReadSnapshotFile[tProj](path); !errors.Is(err, ErrSnapshotOverflow) {
|
||||
t.Errorf("overflow count rejected with %v, want ErrSnapshotOverflow", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotAtomicSwap — verify the temporary-then-rename discipline never
|
||||
// leaves a half-written snap.bin visible to readers.
|
||||
func TestSnapshotAtomicSwap(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
w := NewSnapshotWriter[tProj]()
|
||||
mustAppend := func(w *SnapshotWriter[tProj], sku string, price int64) {
|
||||
t.Helper()
|
||||
if err := appendP(w, makeProjection(sku, price)); err != nil {
|
||||
t.Fatalf("Append %s: %v", sku, err)
|
||||
}
|
||||
}
|
||||
mustAppend(w, "AAA", 100)
|
||||
final, err := w.Write(dir, "snap.bin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// tmp must not exist anymore
|
||||
if _, err := os2Stat(final + ".tmp"); err == nil {
|
||||
t.Errorf(".tmp still present after Write")
|
||||
}
|
||||
|
||||
// replace
|
||||
w2 := NewSnapshotWriter[tProj]()
|
||||
mustAppend(w2, "ZZZ", 200)
|
||||
if _, err := w2.Write(dir, "snap.bin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r, err := ReadSnapshotFile[tProj](final)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
if _, ok := r.Get("ZZZ"); !ok {
|
||||
t.Error("second-snapshot Get(ZZZ) missing")
|
||||
}
|
||||
if _, ok := r.Get("AAA"); ok {
|
||||
t.Error("first-snapshot residue Get(AAA) found — atomic swap bug")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotRejectBadInput — magic, version, truncation, blob-offset OOB.
|
||||
func TestSnapshotRejectBadInput(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "bad.bin")
|
||||
|
||||
// Empty file: truncated.
|
||||
if err := os2WriteFile(path, []byte{}, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReadSnapshotFile[tProj](path); !errors.Is(err, ErrSnapshotTruncated) {
|
||||
t.Errorf("empty file rejected with %v, want ErrSnapshotTruncated", err)
|
||||
}
|
||||
|
||||
// Wrong magic. The fixture must be at least headerSize bytes long
|
||||
// (v2 header is 72 B), otherwise we'd hit ErrSnapshotTruncated
|
||||
// before the magic check fires.
|
||||
wrongMagic := make([]byte, headerSize+1)
|
||||
for i := range wrongMagic {
|
||||
wrongMagic[i] = 0xff
|
||||
}
|
||||
if err := os2WriteFile(path, wrongMagic, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReadSnapshotFile[tProj](path); !errors.Is(err, ErrSnapshotMagic) {
|
||||
t.Errorf("wrong magic rejected with %v, want ErrSnapshotMagic", err)
|
||||
}
|
||||
|
||||
// Truncated mid-record. A valid header claiming 5 records but with no
|
||||
// record section.
|
||||
if err := os2WriteFile(path, buildValidHeader(5), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReadSnapshotFile[tProj](path); !errors.Is(err, ErrSnapshotTruncated) {
|
||||
t.Errorf("truncated snapshot rejected with %v, want ErrSnapshotTruncated", err)
|
||||
}
|
||||
|
||||
// Tampered blob offsets: valid header, valid record section with a
|
||||
// blob pointer that lies past EOF. Should reject with ErrSnapshotBadBlob.
|
||||
tampered := buildTamperedHeader()
|
||||
if err := os2WriteFile(path, tampered, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReadSnapshotFile[tProj](path); !errors.Is(err, ErrSnapshotBadBlob) {
|
||||
t.Errorf("tampered-blob rejected with %v, want ErrSnapshotBadBlob", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotEmpty — empty (count=0) snapshot is valid; Get returns false.
|
||||
func TestSnapshotEmpty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
w := NewSnapshotWriter[tProj]()
|
||||
if _, err := w.Write(dir, "empty.bin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := ReadSnapshotFile[tProj](filepath.Join(dir, "empty.bin"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
if r.Len() != 0 {
|
||||
t.Errorf("Len: got %d want 0", r.Len())
|
||||
}
|
||||
if _, ok := r.Get("anything"); ok {
|
||||
t.Error("empty snapshot should never return ok=true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotSKU32 — key exactly 32 chars (the boundary case) must succeed.
|
||||
func TestSnapshotSKU32(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sku := "SKU-" + repeat32("X", 32-4) // 4 + 28 = 32 chars
|
||||
if len(sku) != 32 {
|
||||
t.Fatalf("test bug: sku length %d", len(sku))
|
||||
}
|
||||
w := NewSnapshotWriter[tProj]()
|
||||
if err := appendP(w, makeProjection(sku, 1)); err != nil {
|
||||
t.Fatalf("Append 32-char key: %v", err)
|
||||
}
|
||||
if _, err := w.Write(dir, "snap.bin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := ReadSnapshotFile[tProj](filepath.Join(dir, "snap.bin"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
if _, ok := r.Get(sku); !ok {
|
||||
t.Error("32-char key Get returned false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotSKU33Rejected — one char over should error at Append.
|
||||
func TestSnapshotSKU33Rejected(t *testing.T) {
|
||||
w := NewSnapshotWriter[tProj]()
|
||||
sku := repeat32("X", 33)
|
||||
err := appendP(w, makeProjection(sku, 1))
|
||||
if !errors.Is(err, ErrSKUTooLong) {
|
||||
t.Errorf("33-char key Append: got %v want ErrSKUTooLong", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotGetSpeed — probe thousands of random key lookups.
|
||||
func TestSnapshotGetSpeed(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("short mode")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
const n = 5000
|
||||
w := NewSnapshotWriter[tProj]()
|
||||
skus := make([]string, n)
|
||||
for i := 0; i < n; i++ {
|
||||
skus[i] = fmt.Sprintf("Z%06d", i)
|
||||
if err := appendP(w, makeProjection(skus[i], int64(i))); err != nil {
|
||||
t.Fatalf("Append %s: %v", skus[i], err)
|
||||
}
|
||||
}
|
||||
if _, err := w.Write(dir, "snap.bin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := ReadSnapshotFile[tProj](filepath.Join(dir, "snap.bin"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
start := time.Now()
|
||||
hits, misses := 0, 0
|
||||
for _, i := range rand.Perm(n) {
|
||||
if _, ok := r.Get(skus[i]); ok {
|
||||
hits++
|
||||
} else {
|
||||
misses++
|
||||
}
|
||||
}
|
||||
elapsed := time.Since(start)
|
||||
t.Logf("100%% hit Get on %d records: %d hits, %d misses, %v (%v/lookup)",
|
||||
n, hits, misses, elapsed, elapsed/time.Duration(n))
|
||||
if misses != 0 {
|
||||
t.Errorf("expected zero misses on full-key set, got %d", misses)
|
||||
}
|
||||
}
|
||||
|
||||
// buildValidHeader synthesises a header byte slice with the given Count
|
||||
// and zero-blob / zero-record section, used to exercise the truncation
|
||||
// branch in newSnapshotReader.
|
||||
func buildValidHeader(count uint64) []byte {
|
||||
bs := make([]byte, headerSize)
|
||||
binary.LittleEndian.PutUint64(bs[0:8], snapshotMagic)
|
||||
binary.LittleEndian.PutUint32(bs[8:12], snapshotVersion)
|
||||
binary.LittleEndian.PutUint32(bs[12:16], 0)
|
||||
binary.LittleEndian.PutUint64(bs[16:24], count)
|
||||
binary.LittleEndian.PutUint64(bs[24:32], uint64(headerSize)) // blobStart
|
||||
binary.LittleEndian.PutUint64(bs[32:40], uint64(headerSize)) // recordStart (truncated)
|
||||
return bs
|
||||
}
|
||||
|
||||
// buildTamperedHeader returns a buffer with a valid header claiming 1 record
|
||||
// AND its 44-byte record section, but the record's blob pointer lies past
|
||||
// EOF. Used to exercise ErrSnapshotBadBlob.
|
||||
func buildTamperedHeader() []byte {
|
||||
bs := make([]byte, headerSize+recordSize)
|
||||
binary.LittleEndian.PutUint64(bs[0:8], snapshotMagic)
|
||||
binary.LittleEndian.PutUint32(bs[8:12], snapshotVersion)
|
||||
binary.LittleEndian.PutUint32(bs[12:16], 0)
|
||||
binary.LittleEndian.PutUint64(bs[16:24], 1)
|
||||
binary.LittleEndian.PutUint64(bs[24:32], uint64(headerSize))
|
||||
binary.LittleEndian.PutUint64(bs[32:40], uint64(headerSize))
|
||||
binary.LittleEndian.PutUint64(bs[headerSize+32:headerSize+40], ^uint64(0))
|
||||
binary.LittleEndian.PutUint32(bs[headerSize+40:headerSize+44], ^uint32(0))
|
||||
return bs
|
||||
}
|
||||
|
||||
// TestSnapshotRejectBadVersion — pin the ErrSnapshotVersion rejection path.
|
||||
func TestSnapshotRejectBadVersion(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "badver.bin")
|
||||
bs := make([]byte, headerSize)
|
||||
binary.LittleEndian.PutUint64(bs[0:8], snapshotMagic)
|
||||
binary.LittleEndian.PutUint32(bs[8:12], snapshotVersion+1)
|
||||
binary.LittleEndian.PutUint32(bs[12:16], 0)
|
||||
binary.LittleEndian.PutUint64(bs[16:24], 0)
|
||||
binary.LittleEndian.PutUint64(bs[24:32], uint64(headerSize))
|
||||
binary.LittleEndian.PutUint64(bs[32:40], uint64(headerSize))
|
||||
if err := os2WriteFile(path, bs, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReadSnapshotFile[tProj](path); !errors.Is(err, ErrSnapshotVersion) {
|
||||
t.Errorf("v2-not-supported header rejected with %v, want ErrSnapshotVersion", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user