188 lines
6.2 KiB
Go
188 lines
6.2 KiB
Go
package projection
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"testing"
|
|
)
|
|
|
|
// busToStore wires an Emitter's frames straight into a Store via HandleFrame —
|
|
// the in-process equivalent of publishing to the bus and a receiver consuming.
|
|
// Returns a PublishFunc the emitter calls per frame.
|
|
func busToStore[W, T any](t *testing.T, s *Store[W, T]) PublishFunc {
|
|
t.Helper()
|
|
return func(meta map[string]string, payload []byte) error {
|
|
return s.HandleFrame(meta, payload)
|
|
}
|
|
}
|
|
|
|
// newIdentityStore builds a Store where wire == stored (W == T == tProj), using
|
|
// the generic JSON codec. opts forwards e.g. WithKeyHashing.
|
|
func newIdentityStore(t *testing.T, opts ...StoreOption) *Store[tProj, tProj] {
|
|
t.Helper()
|
|
s, err := NewStore[tProj, tProj](t.TempDir(), "emit.bin",
|
|
JSONDecode[tProj], func(p tProj) tProj { return p }, nil, opts...)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = s.Close() })
|
|
return s
|
|
}
|
|
|
|
func ups(items ...tProj) []Update[tProj] {
|
|
out := make([]Update[tProj], len(items))
|
|
for i, it := range items {
|
|
out[i] = Update[tProj]{Key: it.SKU, Value: it}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestEmitter_SnapshotRoundTripsIntoStore — a full snapshot emitted frame-by-frame
|
|
// reassembles in the receiver's Store, served by Get.
|
|
func TestEmitter_SnapshotRoundTripsIntoStore(t *testing.T) {
|
|
s := newIdentityStore(t)
|
|
e := NewEmitter[tProj](JSONEncode[tProj], busToStore(t, s), WithTenant("se"), WithChunkSize(2))
|
|
|
|
items := ups(
|
|
makeProjection("A", 100), makeProjection("B", 200),
|
|
makeProjection("C", 300), makeProjection("D", 400), makeProjection("E", 500),
|
|
)
|
|
chunks, err := e.Snapshot(1000, items)
|
|
if err != nil {
|
|
t.Fatalf("Snapshot: %v", err)
|
|
}
|
|
if chunks != 3 { // 5 items / chunkSize 2 = ceil = 3
|
|
t.Errorf("chunks = %d, want 3", chunks)
|
|
}
|
|
if ep, base, _ := s.Stats(); ep != 1000 || base != 5 {
|
|
t.Fatalf("stats = epoch %d base %d, want 1000/5", ep, base)
|
|
}
|
|
for _, k := range []string{"A", "C", "E"} {
|
|
if _, ok := s.Get(k); !ok {
|
|
t.Errorf("Get(%q) miss after snapshot", k)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestEmitter_DeltaAfterSnapshot — a same-epoch delta lands in the overlay and
|
|
// wins over the base; a tombstone hides the key.
|
|
func TestEmitter_DeltaAfterSnapshot(t *testing.T) {
|
|
s := newIdentityStore(t)
|
|
e := NewEmitter[tProj](JSONEncode[tProj], busToStore(t, s))
|
|
|
|
if _, err := e.Snapshot(10, ups(makeProjection("A", 100), makeProjection("B", 200))); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// upsert A to a new price + delete B, same epoch.
|
|
if err := e.Delta(10, []Update[tProj]{
|
|
{Key: "A", Value: makeProjection("A", 999)},
|
|
{Key: "B", Deleted: true},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, _ := s.Get("A"); got.PriceIncVat != 999 {
|
|
t.Errorf("A = %d, want overlay 999", got.PriceIncVat)
|
|
}
|
|
if _, ok := s.Get("B"); ok {
|
|
t.Error("B should be tombstoned")
|
|
}
|
|
if !s.IsDeleted("B") {
|
|
t.Error("IsDeleted(B) should be true")
|
|
}
|
|
}
|
|
|
|
// TestEmitter_EmptySnapshotRefused — emitting nothing must not wipe receivers.
|
|
func TestEmitter_EmptySnapshotRefused(t *testing.T) {
|
|
var published int
|
|
e := NewEmitter[tProj](JSONEncode[tProj], func(map[string]string, []byte) error {
|
|
published++
|
|
return nil
|
|
})
|
|
if _, err := e.Snapshot(1, nil); !errors.Is(err, ErrEmptySnapshot) {
|
|
t.Fatalf("empty Snapshot err = %v, want ErrEmptySnapshot", err)
|
|
}
|
|
if published != 0 {
|
|
t.Errorf("published %d frames for an empty snapshot, want 0", published)
|
|
}
|
|
}
|
|
|
|
// TestEmitter_PublishErrorAbortsStream — a publish failure mid-stream stops the
|
|
// emitter and the receiver keeps its previous base.
|
|
func TestEmitter_PublishErrorAbortsStream(t *testing.T) {
|
|
s := newIdentityStore(t)
|
|
// Seed a good base at epoch 5.
|
|
good := NewEmitter[tProj](JSONEncode[tProj], busToStore(t, s))
|
|
if _, err := good.Snapshot(5, ups(makeProjection("OLD", 1))); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// New emitter whose publish fails on the first chunk of the next snapshot.
|
|
calls := 0
|
|
flaky := NewEmitter[tProj](JSONEncode[tProj], func(meta map[string]string, payload []byte) error {
|
|
calls++
|
|
if meta[MetaSnapshotPhase] == SnapshotChunk {
|
|
return fmt.Errorf("boom")
|
|
}
|
|
return s.HandleFrame(meta, payload)
|
|
})
|
|
if _, err := flaky.Snapshot(9, ups(makeProjection("NEW", 2))); err == nil {
|
|
t.Fatal("expected Snapshot to surface the publish error")
|
|
}
|
|
// Base stays at the old epoch; OLD still served, NEW never applied.
|
|
if ep := s.Epoch(); ep != 5 {
|
|
t.Errorf("epoch = %d, want 5 (aborted snapshot didn't swap)", ep)
|
|
}
|
|
if _, ok := s.Get("OLD"); !ok {
|
|
t.Error("OLD should survive the aborted rebuild")
|
|
}
|
|
if _, ok := s.Get("NEW"); ok {
|
|
t.Error("NEW must not be visible from an aborted snapshot")
|
|
}
|
|
}
|
|
|
|
// TestEmitter_HashedKeysEndToEnd — long keys flow producer→consumer in hashed
|
|
// mode: Get by original key works, and Scan yields digests with the original
|
|
// recoverable from the value. Ranked top-N (the popularity use case) works.
|
|
func TestEmitter_HashedKeysEndToEnd(t *testing.T) {
|
|
s := newIdentityStore(t, WithKeyHashing())
|
|
e := NewEmitter[tProj](JSONEncode[tProj], busToStore(t, s))
|
|
|
|
// Keys that blow past 32 bytes (query strings), values carry the original +
|
|
// a score in PriceIncVat to rank by.
|
|
mk := func(q string, score int64) tProj { return tProj{SKU: q, PriceIncVat: score} }
|
|
q1 := "the quick brown fox jumps over the lazy dog (query one)"
|
|
q2 := "another unbounded length search query that a user typed"
|
|
q3 := "short-ish but still over thirty-two bytes for sure yes"
|
|
if _, err := e.Snapshot(1, ups(mk(q1, 30), mk(q2, 10), mk(q3, 20))); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Point lookup by the ORIGINAL key.
|
|
if got, ok := s.Get(q1); !ok || got.PriceIncVat != 30 {
|
|
t.Fatalf("Get(q1) = %+v ok=%v, want score 30", got, ok)
|
|
}
|
|
|
|
// Ranked enumeration: collect via Scan (keys are digests), sort by score,
|
|
// recover the original query from the value. Top-N popularity in a nutshell.
|
|
type row struct {
|
|
query string
|
|
score int64
|
|
}
|
|
var rows []row
|
|
s.Scan(func(key string, v tProj) bool {
|
|
if len(key) != 32 {
|
|
t.Errorf("hashed Scan key len = %d, want 32", len(key))
|
|
}
|
|
rows = append(rows, row{v.SKU, v.PriceIncVat})
|
|
return true
|
|
})
|
|
if len(rows) != 3 {
|
|
t.Fatalf("scanned %d rows, want 3", len(rows))
|
|
}
|
|
sort.Slice(rows, func(i, j int) bool { return rows[i].score > rows[j].score })
|
|
if rows[0].query != q1 || rows[1].query != q3 || rows[2].query != q2 {
|
|
t.Fatalf("ranked order wrong: %+v", rows)
|
|
}
|
|
}
|