Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

391 lines
13 KiB
Go

package store_sqlite
import (
"fmt"
"path/filepath"
"sync"
"testing"
"github.com/zzet/gortex/internal/graph"
)
func mkFnNode(id, name, file string) *graph.Node {
return &graph.Node{ID: id, Kind: graph.KindFunction, Name: name, FilePath: file, Language: "go"}
}
// newTestBundleCache builds a cache with the default byte budget without
// consulting the environment, so the fingerprint / invalidation unit tests
// stay hermetic regardless of GORTEX_BUNDLE_CACHE_MAX_MB.
func newTestBundleCache() *bundleCache {
return &bundleCache{
fingerprints: map[string]uint64{},
entries: map[string]*bundleCacheEntry{},
maxBytes: bundleCacheDefaultMaxBytes,
maxEntries: bundleCacheMaxEntries,
}
}
// --- unit tests over the cache logic in isolation ---
func TestBundleCache_ServesOnlyValidatedFingerprints(t *testing.T) {
c := newTestBundleCache()
b := graph.SymbolBundle{Node: mkFnNode("pkg/x.go::A", "A", "pkg/x.go")}
// No fingerprint reported for the package yet -> store is a no-op
// (conservative: never cache an unvalidated bundle).
c.store(b)
if _, ok := c.lookup("pkg/x.go::A"); ok {
t.Fatal("bundle was cached despite no package fingerprint")
}
// Report a fingerprint, then store: now it caches and serves.
c.refresh(map[string]uint64{"pkg": 100})
c.store(b)
if _, ok := c.lookup("pkg/x.go::A"); !ok {
t.Fatal("bundle should be served once its package fingerprint is known")
}
}
func TestBundleCache_InvalidatesOnFingerprintChange(t *testing.T) {
c := newTestBundleCache()
c.refresh(map[string]uint64{"pkg": 1})
c.store(graph.SymbolBundle{Node: mkFnNode("pkg/x.go::A", "A", "pkg/x.go")})
if _, ok := c.lookup("pkg/x.go::A"); !ok {
t.Fatal("expected a cache hit on the unchanged fingerprint")
}
// Fingerprint changes -> the entry is invalidated.
c.refresh(map[string]uint64{"pkg": 2})
if _, ok := c.lookup("pkg/x.go::A"); ok {
t.Fatal("entry must be dropped when its package fingerprint changes")
}
}
func TestBundleCache_CrossRepoIsolation(t *testing.T) {
c := newTestBundleCache()
// Two repos with the same inner directory name resolve to DIFFERENT
// package keys because the stored file paths are repo-prefixed.
c.refresh(map[string]uint64{
"repoA/pkg": 10,
"repoB/pkg": 20,
})
c.store(graph.SymbolBundle{Node: mkFnNode("repoA/pkg/x.go::A", "A", "repoA/pkg/x.go")})
c.store(graph.SymbolBundle{Node: mkFnNode("repoB/pkg/x.go::A", "A", "repoB/pkg/x.go")})
// Bumping only repoA's fingerprint must not touch repoB's entry.
c.refresh(map[string]uint64{
"repoA/pkg": 11,
"repoB/pkg": 20,
})
if _, ok := c.lookup("repoA/pkg/x.go::A"); ok {
t.Fatal("repoA entry should have been invalidated")
}
if _, ok := c.lookup("repoB/pkg/x.go::A"); !ok {
t.Fatal("repoB entry must survive a repoA-only fingerprint bump")
}
}
func TestBundlePackageKey(t *testing.T) {
cases := map[string]string{
"pkg/sub/x.go": "pkg/sub",
"x.go": "",
"": "",
"repo/a/b.go": "repo/a",
}
for in, want := range cases {
if got := bundlePackageKey(in); got != want {
t.Errorf("bundlePackageKey(%q) = %q, want %q", in, got, want)
}
}
}
// --- integration tests through the store's SearchSymbolBundles ---
func newBundleTestStore(t *testing.T) *Store {
t.Helper()
s, err := Open(filepath.Join(t.TempDir(), "b.sqlite"))
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
return s
}
func seedBundleStore(t *testing.T, s *Store) {
t.Helper()
s.AddNode(mkFnNode("pkg/x.go::A", "AlphaWidget", "pkg/x.go"))
s.AddNode(mkFnNode("pkg/x.go::B", "BetaWidget", "pkg/x.go"))
s.AddEdge(&graph.Edge{From: "pkg/x.go::A", To: "pkg/x.go::B", Kind: graph.EdgeCalls, FilePath: "pkg/x.go"})
items := []graph.SymbolFTSItem{
{NodeID: "pkg/x.go::A", Tokens: "alpha widget"},
{NodeID: "pkg/x.go::B", Tokens: "beta widget"},
}
if err := s.BulkUpsertSymbolFTS("", items); err != nil {
t.Fatalf("BulkUpsertSymbolFTS: %v", err)
}
if err := s.BuildSymbolIndex(); err != nil {
t.Fatalf("BuildSymbolIndex: %v", err)
}
}
func bundleByID(bundles []graph.SymbolBundle) map[string]graph.SymbolBundle {
out := make(map[string]graph.SymbolBundle, len(bundles))
for _, b := range bundles {
if b.Node != nil {
out[b.Node.ID] = b
}
}
return out
}
func TestSearchSymbolBundles_CacheHitOnUnchangedFingerprint(t *testing.T) {
s := newBundleTestStore(t)
seedBundleStore(t, s)
// Report a fingerprint so the first query populates the cache.
s.SetBundleFingerprints(map[string]uint64{"pkg": 1})
first, err := s.SearchSymbolBundles("widget", 10)
if err != nil {
t.Fatalf("first SearchSymbolBundles: %v", err)
}
got := bundleByID(first)
if b, ok := got["pkg/x.go::A"]; !ok || len(b.OutEdges) != 1 {
t.Fatalf("expected A with 1 out-edge on first query, got %+v", got["pkg/x.go::A"])
}
// Mutate the graph WITHOUT bumping the fingerprint: add a second
// out-edge from A. A correct content-addressed cache serves the
// STALE (1-edge) bundle because the fingerprint is unchanged — proof
// the bundle came from cache, not a fresh fetch.
s.AddNode(mkFnNode("pkg/x.go::C", "GammaWidget", "pkg/x.go"))
s.AddEdge(&graph.Edge{From: "pkg/x.go::A", To: "pkg/x.go::C", Kind: graph.EdgeCalls, FilePath: "pkg/x.go"})
second, err := s.SearchSymbolBundles("widget", 10)
if err != nil {
t.Fatalf("second SearchSymbolBundles: %v", err)
}
cached := bundleByID(second)["pkg/x.go::A"]
if len(cached.OutEdges) != 1 {
t.Fatalf("expected the cached 1-edge bundle to be served on an unchanged fingerprint, got %d edges",
len(cached.OutEdges))
}
}
func TestSearchSymbolBundles_MissAndRecomputeOnFingerprintChange(t *testing.T) {
s := newBundleTestStore(t)
seedBundleStore(t, s)
s.SetBundleFingerprints(map[string]uint64{"pkg": 1})
if _, err := s.SearchSymbolBundles("widget", 10); err != nil {
t.Fatalf("warm-up query: %v", err)
}
// Add a real out-edge, then bump the package fingerprint to signal
// the content changed. The next query must recompute and surface the
// new edge.
s.AddNode(mkFnNode("pkg/x.go::C", "GammaWidget", "pkg/x.go"))
s.AddEdge(&graph.Edge{From: "pkg/x.go::A", To: "pkg/x.go::C", Kind: graph.EdgeCalls, FilePath: "pkg/x.go"})
s.SetBundleFingerprints(map[string]uint64{"pkg": 2})
after, err := s.SearchSymbolBundles("widget", 10)
if err != nil {
t.Fatalf("post-invalidation query: %v", err)
}
fresh := bundleByID(after)["pkg/x.go::A"]
if len(fresh.OutEdges) != 2 {
t.Fatalf("expected the recomputed 2-edge bundle after a fingerprint bump, got %d edges",
len(fresh.OutEdges))
}
}
func TestSearchSymbolBundles_UncachedWithoutFingerprints(t *testing.T) {
s := newBundleTestStore(t)
seedBundleStore(t, s)
// No SetBundleFingerprints call -> the cache stays inert and every
// query recomputes live. Adding an edge must show up immediately.
first, err := s.SearchSymbolBundles("widget", 10)
if err != nil {
t.Fatalf("first query: %v", err)
}
if got := bundleByID(first)["pkg/x.go::A"]; len(got.OutEdges) != 1 {
t.Fatalf("expected 1 edge live, got %d", len(got.OutEdges))
}
s.AddNode(mkFnNode("pkg/x.go::C", "GammaWidget", "pkg/x.go"))
s.AddEdge(&graph.Edge{From: "pkg/x.go::A", To: "pkg/x.go::C", Kind: graph.EdgeCalls, FilePath: "pkg/x.go"})
second, err := s.SearchSymbolBundles("widget", 10)
if err != nil {
t.Fatalf("second query: %v", err)
}
if got := bundleByID(second)["pkg/x.go::A"]; len(got.OutEdges) != 2 {
t.Fatalf("uncached path must reflect the new edge live, got %d", len(got.OutEdges))
}
}
// --- byte-budget tests ---
func TestBundleCache_ByteBudgetEvictionAtBoundary(t *testing.T) {
c := newTestBundleCache()
c.refresh(map[string]uint64{"pkg": 1})
// Fixed-width ids so every entry estimates to the same size.
mk := func(i int) graph.SymbolBundle {
return graph.SymbolBundle{Node: mkFnNode(fmt.Sprintf("pkg/x.go::N%03d", i), "W", "pkg/x.go")}
}
unit := bundleEntryBytes(mk(0))
const k = 4
c.maxBytes = unit * k // budget holds exactly k entries
for i := 0; i < k; i++ {
c.store(mk(i))
}
if len(c.entries) != k {
t.Fatalf("expected %d entries filling the budget, got %d", k, len(c.entries))
}
if c.curBytes != unit*k {
t.Fatalf("curBytes = %d, want %d", c.curBytes, unit*k)
}
// One more entry crosses the budget -> wholesale clear, only the newest
// survives and the byte total resets to a single unit.
c.store(mk(k))
if len(c.entries) != 1 {
t.Fatalf("crossing the budget must clear wholesale to 1 entry, got %d", len(c.entries))
}
if c.curBytes != unit {
t.Fatalf("curBytes after clear = %d, want %d", c.curBytes, unit)
}
if _, ok := c.lookup(fmt.Sprintf("pkg/x.go::N%03d", k)); !ok {
t.Fatal("the entry that triggered the clear must remain served")
}
if _, ok := c.lookup("pkg/x.go::N000"); ok {
t.Fatal("a pre-clear entry must be gone after the wholesale clear")
}
}
func TestBundleCache_RefusesEntryLargerThanBudget(t *testing.T) {
c := newTestBundleCache()
c.refresh(map[string]uint64{"pkg": 1})
b := graph.SymbolBundle{Node: mkFnNode("pkg/x.go::A", "A", "pkg/x.go")}
c.maxBytes = bundleEntryBytes(b) - 1 // budget just below a single entry
c.store(b)
if _, ok := c.lookup("pkg/x.go::A"); ok {
t.Fatal("an entry larger than the whole budget must not be cached")
}
if len(c.entries) != 0 || c.curBytes != 0 {
t.Fatalf("oversized store must leave the cache empty, got %d entries / %d bytes",
len(c.entries), c.curBytes)
}
}
func TestBundleCacheMaxBytes_EnvOverride(t *testing.T) {
t.Setenv("GORTEX_BUNDLE_CACHE_MAX_MB", "128")
if got := bundleCacheMaxBytes(); got != 128<<20 {
t.Fatalf("env override = %d, want %d", got, 128<<20)
}
if c := newBundleCache(); c.maxBytes != 128<<20 {
t.Fatalf("newBundleCache maxBytes = %d, want %d", c.maxBytes, 128<<20)
}
// Empty and unparseable values keep the default.
t.Setenv("GORTEX_BUNDLE_CACHE_MAX_MB", "")
if got := bundleCacheMaxBytes(); got != bundleCacheDefaultMaxBytes {
t.Fatalf("empty override should keep the default, got %d", got)
}
t.Setenv("GORTEX_BUNDLE_CACHE_MAX_MB", "not-a-number")
if got := bundleCacheMaxBytes(); got != bundleCacheDefaultMaxBytes {
t.Fatalf("unparseable override should keep the default, got %d", got)
}
}
func TestBundleCache_DisabledMode(t *testing.T) {
t.Setenv("GORTEX_BUNDLE_CACHE_MAX_MB", "0")
c := newBundleCache()
if c.maxBytes != 0 {
t.Fatalf("expected a disabled cache (maxBytes 0), got %d", c.maxBytes)
}
c.refresh(map[string]uint64{"pkg": 1})
c.store(graph.SymbolBundle{Node: mkFnNode("pkg/x.go::A", "A", "pkg/x.go")})
if len(c.entries) != 0 {
t.Fatalf("a disabled cache must not store, got %d entries", len(c.entries))
}
if _, ok := c.lookup("pkg/x.go::A"); ok {
t.Fatal("a disabled cache must always miss")
}
// A negative budget disables too.
t.Setenv("GORTEX_BUNDLE_CACHE_MAX_MB", "-4")
if got := bundleCacheMaxBytes(); got != 0 {
t.Fatalf("a negative override should disable the cache (0), got %d", got)
}
}
func TestSearchSymbolBundles_DisabledCacheStillServes(t *testing.T) {
t.Setenv("GORTEX_BUNDLE_CACHE_MAX_MB", "0")
s := newBundleTestStore(t)
seedBundleStore(t, s)
s.SetBundleFingerprints(map[string]uint64{"pkg": 1})
res, err := s.SearchSymbolBundles("widget", 10)
if err != nil {
t.Fatalf("SearchSymbolBundles with the cache disabled: %v", err)
}
if b, ok := bundleByID(res)["pkg/x.go::A"]; !ok || len(b.OutEdges) != 1 {
t.Fatalf("a disabled cache must still return live bundles, got %+v", b)
}
if s.bundles.maxBytes != 0 {
t.Fatalf("expected the store's cache disabled, got maxBytes %d", s.bundles.maxBytes)
}
if len(s.bundles.entries) != 0 {
t.Fatalf("a disabled cache must stay empty, got %d entries", len(s.bundles.entries))
}
}
func TestBundleCache_ConcurrentReadInsert(t *testing.T) {
c := newTestBundleCache()
c.maxBytes = 8 << 10 // small budget so wholesale clears fire under contention
c.refresh(map[string]uint64{"pkg": 1})
const workers = 8
const iters = 3000
var wg sync.WaitGroup
wg.Add(workers)
for w := 0; w < workers; w++ {
go func(w int) {
defer wg.Done()
for i := 0; i < iters; i++ {
id := fmt.Sprintf("pkg/x.go::N%d_%d", w, i%64)
switch i % 3 {
case 0:
c.store(graph.SymbolBundle{Node: mkFnNode(id, "W", "pkg/x.go")})
case 1:
_, _ = c.lookup(id)
default:
c.refresh(map[string]uint64{"pkg": uint64(i)})
}
}
}(w)
}
wg.Wait()
// No goroutines remain: the running total must exactly equal the summed
// bytes of the surviving entries (the accounting invariant), which also
// proves it never drifted negative under contention.
var sum int64
for _, e := range c.entries {
sum += e.bytes
}
if c.curBytes != sum {
t.Fatalf("curBytes %d != sum of live entry bytes %d", c.curBytes, sum)
}
if c.curBytes > c.maxBytes {
t.Fatalf("curBytes %d exceeds the byte budget %d", c.curBytes, c.maxBytes)
}
}