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
366 lines
11 KiB
Go
366 lines
11 KiB
Go
package persistence
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
func testSnapshot() *Snapshot {
|
|
return &Snapshot{
|
|
Version: "0.1.0-test",
|
|
RepoPath: "/tmp/test-repo",
|
|
CommitHash: "abc123def456",
|
|
Branch: "main",
|
|
IndexedAt: time.Now().Truncate(time.Second),
|
|
Nodes: []*graph.Node{
|
|
{
|
|
ID: "main.go::Foo", Kind: graph.KindFunction, Name: "Foo",
|
|
FilePath: "main.go", StartLine: 1, EndLine: 5, Language: "go",
|
|
Meta: map[string]any{"signature": "func Foo(x int) error"},
|
|
},
|
|
{
|
|
ID: "main.go::Bar", Kind: graph.KindMethod, Name: "Bar",
|
|
FilePath: "main.go", StartLine: 7, EndLine: 12, Language: "go",
|
|
Meta: map[string]any{"receiver": "Server", "signature": "func (s *Server) Bar()"},
|
|
},
|
|
},
|
|
Edges: []*graph.Edge{
|
|
{
|
|
From: "main.go::Foo", To: "main.go::Bar", Kind: graph.EdgeCalls,
|
|
FilePath: "main.go", Line: 3, Confidence: 0.95,
|
|
Meta: map[string]any{"receiver_type": "Server"},
|
|
},
|
|
},
|
|
FileMtimes: map[string]int64{
|
|
"main.go": 1700000000000000000,
|
|
"util.go": 1700000001000000000,
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestFileStore_RoundTrip(t *testing.T) {
|
|
dir := t.TempDir()
|
|
fs, err := NewFileStore(dir, "0.1.0-test")
|
|
require.NoError(t, err)
|
|
|
|
snap := testSnapshot()
|
|
|
|
require.NoError(t, fs.Save(snap))
|
|
assert.True(t, fs.Check(snap.RepoPath, snap.Branch, snap.CommitHash))
|
|
assert.True(t, fs.Validate(snap.RepoPath, snap.Branch, snap.CommitHash))
|
|
|
|
loaded, err := fs.Load(snap.RepoPath, snap.Branch, snap.CommitHash)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, snap.Version, loaded.Version)
|
|
assert.Equal(t, snap.RepoPath, loaded.RepoPath)
|
|
assert.Equal(t, snap.CommitHash, loaded.CommitHash)
|
|
assert.Equal(t, snap.Branch, loaded.Branch)
|
|
assert.Equal(t, snap.IndexedAt, loaded.IndexedAt)
|
|
|
|
require.Len(t, loaded.Nodes, 2)
|
|
assert.Equal(t, "main.go::Foo", loaded.Nodes[0].ID)
|
|
assert.Equal(t, "Foo", loaded.Nodes[0].Name)
|
|
assert.Equal(t, "func Foo(x int) error", loaded.Nodes[0].Meta["signature"])
|
|
|
|
assert.Equal(t, "Server", loaded.Nodes[1].Meta["receiver"])
|
|
|
|
require.Len(t, loaded.Edges, 1)
|
|
assert.Equal(t, "main.go::Foo", loaded.Edges[0].From)
|
|
assert.Equal(t, "main.go::Bar", loaded.Edges[0].To)
|
|
assert.Equal(t, 0.95, loaded.Edges[0].Confidence)
|
|
assert.Equal(t, "Server", loaded.Edges[0].Meta["receiver_type"])
|
|
|
|
assert.Equal(t, snap.FileMtimes, loaded.FileMtimes)
|
|
}
|
|
|
|
func TestFileStore_Validate_VersionMismatch(t *testing.T) {
|
|
dir := t.TempDir()
|
|
fsV1, err := NewFileStore(dir, "0.1.0")
|
|
require.NoError(t, err)
|
|
|
|
snap := testSnapshot()
|
|
snap.Version = "0.1.0"
|
|
require.NoError(t, fsV1.Save(snap))
|
|
|
|
// Same version validates.
|
|
assert.True(t, fsV1.Validate(snap.RepoPath, snap.Branch, snap.CommitHash))
|
|
|
|
// A different BINARY version now REUSES the slot — the extraction version
|
|
// (not the binary string) gates reuse, so a no-op release skips the rebuild.
|
|
fsV2, err := NewFileStore(dir, "0.2.0")
|
|
require.NoError(t, err)
|
|
assert.True(t, fsV2.Validate(snap.RepoPath, snap.Branch, snap.CommitHash))
|
|
|
|
// A LEGACY slot with no extraction-version marker falls back to the exact
|
|
// binary-version match — so it correctly invalidates across the bump.
|
|
entry := fsV2.entryDir(snap.RepoPath, snap.Branch, snap.CommitHash)
|
|
require.NoError(t, os.Remove(filepath.Join(entry, extractionVersionFile)))
|
|
assert.False(t, fsV2.Validate(snap.RepoPath, snap.Branch, snap.CommitHash))
|
|
}
|
|
|
|
func TestFileStore_Evict(t *testing.T) {
|
|
dir := t.TempDir()
|
|
fs, err := NewFileStore(dir, "0.1.0")
|
|
require.NoError(t, err)
|
|
|
|
snap := testSnapshot()
|
|
require.NoError(t, fs.Save(snap))
|
|
assert.True(t, fs.Check(snap.RepoPath, snap.Branch, snap.CommitHash))
|
|
|
|
require.NoError(t, fs.Evict(snap.RepoPath, snap.Branch, snap.CommitHash))
|
|
assert.False(t, fs.Check(snap.RepoPath, snap.Branch, snap.CommitHash))
|
|
}
|
|
|
|
func TestFileStore_Load_NotFound(t *testing.T) {
|
|
dir := t.TempDir()
|
|
fs, err := NewFileStore(dir, "0.1.0")
|
|
require.NoError(t, err)
|
|
|
|
_, err = fs.Load("/nonexistent", "main", "abc123")
|
|
assert.ErrorIs(t, err, ErrNotFound)
|
|
}
|
|
|
|
func TestFileStore_MetaWithSliceTypes(t *testing.T) {
|
|
dir := t.TempDir()
|
|
fs, err := NewFileStore(dir, "0.1.0")
|
|
require.NoError(t, err)
|
|
|
|
snap := &Snapshot{
|
|
Version: "0.1.0",
|
|
RepoPath: "/tmp/test",
|
|
CommitHash: "def789",
|
|
Branch: "main",
|
|
IndexedAt: time.Now().Truncate(time.Second),
|
|
Nodes: []*graph.Node{
|
|
{
|
|
ID: "iface.go::Reader", Kind: graph.KindInterface, Name: "Reader",
|
|
FilePath: "iface.go", Language: "go",
|
|
Meta: map[string]any{"methods": []string{"Read", "Close"}},
|
|
},
|
|
},
|
|
FileMtimes: map[string]int64{"iface.go": 1700000000},
|
|
}
|
|
|
|
require.NoError(t, fs.Save(snap))
|
|
|
|
loaded, err := fs.Load(snap.RepoPath, snap.Branch, snap.CommitHash)
|
|
require.NoError(t, err)
|
|
|
|
methods, ok := loaded.Nodes[0].Meta["methods"].([]string)
|
|
require.True(t, ok, "methods should deserialize as []string")
|
|
assert.Equal(t, []string{"Read", "Close"}, methods)
|
|
}
|
|
|
|
// TestFileStore_BranchKeyedSlots proves snapshots are keyed by
|
|
// (repo, branch): two branches of the same repo, even at the same
|
|
// commit, occupy distinct slots, so switching branches never clobbers
|
|
// the other branch's cached index.
|
|
func TestFileStore_BranchKeyedSlots(t *testing.T) {
|
|
dir := t.TempDir()
|
|
fs, err := NewFileStore(dir, "0.1.0-test")
|
|
require.NoError(t, err)
|
|
|
|
main := testSnapshot()
|
|
main.Branch = "main"
|
|
feature := testSnapshot()
|
|
feature.Branch = "feature/login"
|
|
feature.Nodes[0].Name = "FeatureFoo"
|
|
|
|
require.NoError(t, fs.Save(main))
|
|
require.NoError(t, fs.Save(feature))
|
|
|
|
gotMain, err := fs.Load(main.RepoPath, "main", main.CommitHash)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "Foo", gotMain.Nodes[0].Name)
|
|
|
|
gotFeature, err := fs.Load(feature.RepoPath, "feature/login", feature.CommitHash)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "FeatureFoo", gotFeature.Nodes[0].Name)
|
|
}
|
|
|
|
// TestFileStore_DetachedHeadKeyedByCommit checks the detached-HEAD
|
|
// fallback: with no branch the slot keys on the commit hash, so two
|
|
// checked-out commits keep separate snapshots.
|
|
func TestFileStore_DetachedHeadKeyedByCommit(t *testing.T) {
|
|
dir := t.TempDir()
|
|
fs, err := NewFileStore(dir, "0.1.0-test")
|
|
require.NoError(t, err)
|
|
|
|
a := testSnapshot()
|
|
a.Branch = ""
|
|
a.CommitHash = "aaaaaaaaaaaa"
|
|
b := testSnapshot()
|
|
b.Branch = ""
|
|
b.CommitHash = "bbbbbbbbbbbb"
|
|
|
|
require.NoError(t, fs.Save(a))
|
|
require.NoError(t, fs.Save(b))
|
|
|
|
assert.True(t, fs.Check(a.RepoPath, "", "aaaaaaaaaaaa"))
|
|
assert.True(t, fs.Check(b.RepoPath, "", "bbbbbbbbbbbb"))
|
|
}
|
|
|
|
func TestNopStore(t *testing.T) {
|
|
var s NopStore
|
|
assert.False(t, s.Check("x", "main", "y"))
|
|
_, err := s.Load("x", "main", "y")
|
|
assert.ErrorIs(t, err, ErrNotFound)
|
|
assert.NoError(t, s.Save(testSnapshot()))
|
|
assert.False(t, s.Validate("x", "main", "y"))
|
|
assert.NoError(t, s.Evict("x", "main", "y"))
|
|
assert.NoError(t, s.Close())
|
|
}
|
|
|
|
// TestFileStore_ConcurrentSave exercises the cross-process advisory lock:
|
|
// every writer targets the same cache key, so without serialization
|
|
// one writer's os.RemoveAll would race another's MkdirAll/write sequence
|
|
// and leave a torn entry. flock(2) contends across file descriptors even
|
|
// within one process, so concurrent goroutines reproduce the cross-process
|
|
// hazard. After all writers complete, the entry must load as exactly one
|
|
// writer's complete payload.
|
|
func TestFileStore_ConcurrentSave(t *testing.T) {
|
|
dir := t.TempDir()
|
|
fs, err := NewFileStore(dir, "0.1.0-test")
|
|
require.NoError(t, err)
|
|
|
|
const writers = 12
|
|
markers := make(map[string]bool, writers)
|
|
var wg sync.WaitGroup
|
|
errs := make(chan error, writers)
|
|
for i := range writers {
|
|
marker := fmt.Sprintf("writer-%d", i)
|
|
markers[marker] = true
|
|
wg.Add(1)
|
|
go func(m string) {
|
|
defer wg.Done()
|
|
snap := testSnapshot()
|
|
snap.Nodes[0].Meta["writer"] = m
|
|
errs <- fs.Save(snap)
|
|
}(marker)
|
|
}
|
|
wg.Wait()
|
|
close(errs)
|
|
for e := range errs {
|
|
require.NoError(t, e)
|
|
}
|
|
|
|
loaded, err := fs.Load("/tmp/test-repo", "main", "abc123def456")
|
|
require.NoError(t, err)
|
|
require.Len(t, loaded.Nodes, 2)
|
|
got, _ := loaded.Nodes[0].Meta["writer"].(string)
|
|
assert.True(t, markers[got], "loaded snapshot must be one writer's complete payload, got %q", got)
|
|
}
|
|
|
|
// TestFileStore_ConcurrentReadWrite runs readers against a writer churning
|
|
// the same entry. The shared read lock must hand every reader either a
|
|
// fully decodable snapshot or a clean ErrNotFound — never a gob/gzip
|
|
// decode error from a half-written file.
|
|
func TestFileStore_ConcurrentReadWrite(t *testing.T) {
|
|
dir := t.TempDir()
|
|
fs, err := NewFileStore(dir, "0.1.0-test")
|
|
require.NoError(t, err)
|
|
|
|
snap := testSnapshot()
|
|
require.NoError(t, fs.Save(snap))
|
|
|
|
var wg sync.WaitGroup
|
|
errs := make(chan error, 64)
|
|
stop := make(chan struct{})
|
|
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for i := 0; ; i++ {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
default:
|
|
}
|
|
var e error
|
|
if i%2 == 0 {
|
|
e = fs.Save(snap)
|
|
} else {
|
|
e = fs.Evict(snap.RepoPath, snap.Branch, snap.CommitHash)
|
|
}
|
|
// Honour stop while sending: errs is buffered, and the
|
|
// writer outruns the buffer in microseconds. Without the
|
|
// stop arm here the writer blocks on a full errs channel,
|
|
// never re-checks stop, and wg.Wait() deadlocks (the buffer
|
|
// only drains after wg.Wait()).
|
|
select {
|
|
case errs <- e:
|
|
case <-stop:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
for r := 0; r < 6; r++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for i := 0; i < 40; i++ {
|
|
_, err := fs.Load(snap.RepoPath, snap.Branch, snap.CommitHash)
|
|
if err != nil && err != ErrNotFound {
|
|
errs <- err
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
time.Sleep(50 * time.Millisecond)
|
|
close(stop)
|
|
wg.Wait()
|
|
close(errs)
|
|
for e := range errs {
|
|
require.NoError(t, e, "no reader may observe a torn snapshot")
|
|
}
|
|
}
|
|
|
|
// TestSnapshotReuseAcrossBinaryBump proves the warm snapshot is reused across a
|
|
// binary version bump that did NOT change extraction output (the extraction
|
|
// version is the gate, not the binary-version string) — avoiding the needless
|
|
// full cold rebuild a binary-string gate would force on a no-op release — while
|
|
// a genuine extraction-version change still invalidates the slot.
|
|
func TestSnapshotReuseAcrossBinaryBump(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
s1, err := NewFileStore(dir, "0.48.0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
snap := testSnapshot()
|
|
snap.Version = "0.48.0"
|
|
if err := s1.Save(snap); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// A newer binary with the SAME extraction version reuses the slot.
|
|
s2, err := NewFileStore(dir, "0.48.1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !s2.Validate(snap.RepoPath, snap.Branch, snap.CommitHash) {
|
|
t.Error("snapshot should be reused across a binary bump with an unchanged extraction version")
|
|
}
|
|
|
|
// Corrupt the extraction-version marker to simulate an extraction-output
|
|
// change: the slot must now be rejected (cold rebuild).
|
|
entry := s2.entryDir(snap.RepoPath, snap.Branch, snap.CommitHash)
|
|
if err := os.WriteFile(filepath.Join(entry, extractionVersionFile), []byte("999"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if s2.Validate(snap.RepoPath, snap.Branch, snap.CommitHash) {
|
|
t.Error("a changed extraction version must invalidate the snapshot")
|
|
}
|
|
}
|