Skip to content
quarry Artifact [72c9b01301]
Login

Artifact [72c9b01301]

Artifact 72c9b013018c535ae35923e65ff961b871cca0aee8d2f6acddd6792b0dd0543f:


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)
	}
}