package index
import (
"sync"
"testing"
"time"
"craftdesign.group/quarry/internal/artifact"
)
func foldCheckin(t *testing.T, ix *Index, hash, date string) {
t.Helper()
if err := ix.FoldNow([]Parsed{
{Hash: hash, Art: &artifact.Artifact{Type: artifact.Manifest,
Date: date, User: "dan", Comment: hash}},
}); err != nil {
t.Fatal(err)
}
}
// holdKindsScan stops the next kinds scan on its first row until
// release is called (or the test ends), and reports when it has
// started. Later scans run freely.
func holdKindsScan(t *testing.T, ix *Index) (started <-chan struct{}, release func()) {
t.Helper()
gate := make(chan struct{})
in := make(chan struct{})
var first, done sync.Once
ix.kindsScanRow = func() {
held := false
first.Do(func() { held = true; close(in) })
if held {
<-gate
}
}
release = func() { done.Do(func() { close(gate) }) }
// Registered after openIndex's Close, so it runs first: the held
// scan finishes before Close waits for it.
t.Cleanup(release)
return in, release
}
func kindsWithin(t *testing.T, ix *Index, d time.Duration) (map[string]int, bool) {
t.Helper()
done := make(chan map[string]int, 1)
go func() {
got, err := ix.EventKinds()
if err != nil {
t.Error(err)
}
done <- got
}()
select {
case got := <-done:
return got, true
case <-time.After(d):
return nil, false
}
}
// The dashboard asks every repository for its kinds, and a mirror folds
// on every pull. A fold must not hand the next reader the scan: the old
// census is served while one refresh runs in the background.
func TestAFoldDoesNotMakeTheNextKindsReaderScan(t *testing.T) {
ix := openIndex(t)
foldCheckin(t, ix, "c1", "2026-01-01T00:00:00")
if _, err := ix.EventKinds(); err != nil {
t.Fatal(err)
}
started, _ := holdKindsScan(t, ix)
foldCheckin(t, ix, "c2", "2026-01-02T00:00:00")
got, ok := kindsWithin(t, ix, 2*time.Second)
if !ok {
t.Fatal("the first reader after a fold waited on the scan")
}
if got["ci"] != 1 {
t.Errorf("reader got %v, want the census from before the fold", got)
}
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("a fold on a warm census started no background refresh")
}
}
// The refresh a fold starts lands, and what it stores is the census
// after the fold. Readers keep asking, as the dashboard does: FoldNow
// invalidates twice (the fold, then the branch recompute), and when
// the second lands while the first refresh still runs it is skipped,
// leaving a correct census stamped one generation behind for the next
// reader to revalidate.
func TestTheKindsRefreshReflectsTheFold(t *testing.T) {
ix := openIndex(t)
foldCheckin(t, ix, "c1", "2026-01-01T00:00:00")
if _, err := ix.EventKinds(); err != nil {
t.Fatal(err)
}
foldCheckin(t, ix, "c2", "2026-01-02T00:00:00")
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if _, err := ix.EventKinds(); err != nil {
t.Fatal(err)
}
ix.countMu.Lock()
n, fresh := ix.kinds["ci"], ix.kindsGen == ix.foldGen
ix.countMu.Unlock()
if n == 2 && fresh {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Error("the census was not refreshed after the fold")
}
// A repository nobody has asked costs nothing on a fold: a bulk import
// folds in a tight loop before anyone has looked.
func TestAFoldDoesNotWarmAColdKindsCensus(t *testing.T) {
ix := openIndex(t)
foldCheckin(t, ix, "c1", "2026-01-01T00:00:00")
ix.warmWG.Wait()
ix.countMu.Lock()
defer ix.countMu.Unlock()
if ix.kinds != nil {
t.Errorf("a fold warmed a census nobody asked for: %v", ix.kinds)
}
}
// Counts is read on every page of a repository. It must not queue
// behind a kinds scan, which is 0.46s cold on the largest store.
func TestCountsIsNotBlockedByAKindsScan(t *testing.T) {
ix := openIndex(t)
foldCheckin(t, ix, "c1", "2026-01-01T00:00:00")
started, _ := holdKindsScan(t, ix)
go func() { _, _ = ix.EventKinds() }()
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("the kinds scan never started")
}
done := make(chan error, 1)
go func() {
_, err := ix.Counts(false)
done <- err
}()
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(2 * time.Second):
t.Fatal("Counts waited on a kinds scan")
}
}
func waitStarted(t *testing.T, started <-chan struct{}) {
t.Helper()
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("the kinds scan never started")
}
}
// A fold that lands while a scan is running must leave that scan's
// answer stale, not current: the scan read the table as it was before
// the fold, so the census it stores is already behind. A later reader
// has to see the fold.
func TestAFoldDuringAKindsScanIsNotMemoisedAway(t *testing.T) {
ix := openIndex(t)
foldCheckin(t, ix, "c1", "2026-01-01T00:00:00")
started, release := holdKindsScan(t, ix)
scanned := make(chan struct{})
go func() {
defer close(scanned)
_, _ = ix.EventKinds()
}()
waitStarted(t, started)
foldCheckin(t, ix, "c2", "2026-01-02T00:00:00")
release()
<-scanned
deadline := time.Now().Add(2 * time.Second)
got, _ := ix.EventKinds()
for got["ci"] != 2 && time.Now().Before(deadline) {
time.Sleep(5 * time.Millisecond)
got, _ = ix.EventKinds()
}
if got["ci"] != 2 {
t.Errorf("readers still get %v after the fold: the mid-scan fold was memoised away", got)
}
}
// Two scans can overlap, and the older one can finish last. Its census
// predates a fold the newer one saw, so it must not replace it.
func TestAnOlderKindsScanDoesNotOverwriteANewerOne(t *testing.T) {
ix := openIndex(t)
foldCheckin(t, ix, "c1", "2026-01-01T00:00:00")
started, release := holdKindsScan(t, ix)
older := make(chan struct{})
go func() {
defer close(older)
_, _ = ix.EventKinds()
}()
waitStarted(t, started)
foldCheckin(t, ix, "c2", "2026-01-02T00:00:00")
// Still cold, so this reader scans for itself, after the fold.
if got, err := ix.EventKinds(); err != nil || got["ci"] != 2 {
t.Fatalf("newer scan: %v err=%v", got, err)
}
release()
<-older
ix.countMu.Lock()
n, gen, cur := ix.kinds["ci"], ix.kindsGen, ix.foldGen
ix.countMu.Unlock()
if n != 2 || gen != cur {
t.Errorf("memo holds ci=%d at generation %d (current %d): the older scan overwrote the newer", n, gen, cur)
}
}
// FoldNow invalidates twice, once for the fold and once for the branch
// recompute. The second lands while the first refresh is still
// scanning, so it starts nothing, and the refresh stores a census one
// generation behind. The refresh looks once more when it finishes, so
// the census is current with no reader needed to revalidate it.
func TestAKindsRefreshCatchesTheFoldThatLandedDuringIt(t *testing.T) {
ix := openIndex(t)
foldCheckin(t, ix, "c1", "2026-01-01T00:00:00")
if _, err := ix.EventKinds(); err != nil {
t.Fatal(err)
}
started, release := holdKindsScan(t, ix)
done := make(chan struct{})
go func() {
defer close(done)
if err := ix.FoldNow([]Parsed{{Hash: "c2", Art: &artifact.Artifact{Type: artifact.Manifest,
Date: "2026-01-02T00:00:00", User: "dan", Comment: "c2"}}}); err != nil {
t.Error(err)
}
}()
waitStarted(t, started)
<-done // both invalidations have landed while the refresh is held
release()
ix.warmWG.Wait()
ix.countMu.Lock()
n, gen, cur := ix.kinds["ci"], ix.kindsGen, ix.foldGen
ix.countMu.Unlock()
if n != 2 || gen != cur {
t.Errorf("after the refresh: ci=%d at generation %d, current %d; it waits for a reader to catch up", n, gen, cur)
}
}
// A Close that lands mid-refresh waits for the refresh. It must wait
// for one scan, not two: the catch-up pass is skipped once the index
// is closing, since nobody will read what it would store.
func TestAClosingIndexSkipsTheKindsCatchUpScan(t *testing.T) {
ix := openIndex(t)
foldCheckin(t, ix, "c1", "2026-01-01T00:00:00")
if _, err := ix.EventKinds(); err != nil {
t.Fatal(err)
}
var mu sync.Mutex
scans := 0
gate := make(chan struct{})
in := make(chan struct{})
ix.kindsScanRow = func() {
mu.Lock()
scans++
first := scans == 1
mu.Unlock()
if first {
close(in)
<-gate
}
}
done := make(chan struct{})
go func() {
defer close(done)
if err := ix.FoldNow([]Parsed{{Hash: "c2", Art: &artifact.Artifact{Type: artifact.Manifest,
Date: "2026-01-02T00:00:00", User: "dan", Comment: "c2"}}}); err != nil {
t.Error(err)
}
}()
waitStarted(t, in)
<-done // the census is now a generation behind the held scan
ix.warmMu.Lock()
ix.closing = true
ix.warmMu.Unlock()
close(gate)
ix.warmWG.Wait()
mu.Lock()
defer mu.Unlock()
if scans != 1 {
t.Errorf("%d kinds scans ran while the index was closing, want the one already running", scans)
}
}