Skip to content
quarry Artifact [7e57786f14]
Login

Artifact [7e57786f14]

Artifact 7e57786f14143dc0afe12a125b77b222ea47e85ea326240d2f74c78d613e75ec:


package oltp

import (
	"path/filepath"
	"testing"
	"time"

	"craftdesign.group/quarry/internal/fault"
)

func excDB(t *testing.T) *DB {
	t.Helper()
	d, err := Open(filepath.Join(t.TempDir(), "t.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { d.Close() })
	return d
}

// The whole point of persisting: the panel can answer for more than the
// process that is running now.
func TestExceptionSurvivesAndCounts(t *testing.T) {
	d := excDB(t)
	first := time.Now().Add(-time.Hour)
	e := Exception{Sig: "abc12345", Message: "boom", Surface: "web", Path: "/x",
		Count: 1, First: first, Last: first}
	if err := d.RecordException(e); err != nil {
		t.Fatal(err)
	}
	e.Last = time.Now()
	if err := d.RecordException(e); err != nil {
		t.Fatal(err)
	}
	got, err := d.Exceptions(10)
	if err != nil {
		t.Fatal(err)
	}
	if len(got) != 1 {
		t.Fatalf("stored %d rows, want 1 — a repeat is the same signature", len(got))
	}
	if got[0].Count != 2 {
		t.Errorf("count %d, want 2", got[0].Count)
	}
	// The FIRST sighting keeps its date; only the clock on "last" moves.
	if !got[0].First.Truncate(time.Second).Equal(first.UTC().Truncate(time.Second)) {
		t.Errorf("first moved to %v, want %v", got[0].First, first.UTC())
	}
	sigs, occ, err := d.ExceptionTotals()
	if err != nil {
		t.Fatal(err)
	}
	if sigs != 1 || occ != 2 {
		t.Errorf("totals = %d signatures / %d occurrences, want 1/2", sigs, occ)
	}
}

// An error that comes back is not resolved, whatever anybody clicked.
func TestRecurrenceReopensAResolvedException(t *testing.T) {
	d := excDB(t)
	now := time.Now()
	e := Exception{Sig: "s", Message: "flaky", Surface: "ingest", Path: "/y",
		Count: 1, First: now, Last: now}
	if err := d.RecordException(e); err != nil {
		t.Fatal(err)
	}
	if err := d.ResolveException("s"); err != nil {
		t.Fatal(err)
	}
	got, _ := d.Exceptions(10)
	if !got[0].Resolved {
		t.Fatal("resolve did not take")
	}
	e.Last = now.Add(time.Minute)
	if err := d.RecordException(e); err != nil {
		t.Fatal(err)
	}
	got, _ = d.Exceptions(10)
	if got[0].Resolved {
		t.Error("a signature that recurred is still marked resolved")
	}
}

// The context a reader acts on has to survive a restart, and it has to
// describe the LATEST occurrence: a queue depth from an hour ago is not
// the number anyone is about to act on. The trace is the exception —
// it names the code path, which does not move.
func TestExceptionKeepsLatestContextAndFirstTrace(t *testing.T) {
	d := excDB(t)
	first := Exception{
		Sig: "abc12345", Message: "ingest lane is full", Surface: "ingest",
		Path: "ingest sqlite-docsrc", Count: 1, Trace: "the first stack",
		First: time.Now().Add(-time.Hour), Last: time.Now().Add(-time.Hour),
		Why: "check-ins wait to be indexed", Fix: "raise the limit",
		Build: "1.0.0", Req: "9f",
		Fields: []fault.Field{{Key: "queued", Value: "4"}},
	}
	if err := d.RecordException(first); err != nil {
		t.Fatal(err)
	}
	later := first
	later.Count, later.Trace, later.Build, later.Req = 1, "a later stack", "1.0.1", "aa"
	later.Fields = []fault.Field{{Key: "queued", Value: "32"}}
	later.Last = time.Now()
	if err := d.RecordException(later); err != nil {
		t.Fatal(err)
	}

	got, err := d.Exceptions(10)
	if err != nil || len(got) != 1 {
		t.Fatalf("rows %d err %v — one fault must stay one row", len(got), err)
	}
	e := got[0]
	if e.Count != 2 {
		t.Errorf("count %d, want both occurrences", e.Count)
	}
	if e.Trace != "the first stack" {
		t.Errorf("trace %q: the first stack names the code path and should be kept", e.Trace)
	}
	if len(e.Fields) != 1 || e.Fields[0].Value != "32" {
		t.Errorf("fields %v, want the latest occurrence's numbers", e.Fields)
	}
	if e.Build != "1.0.1" || e.Req != "aa" {
		t.Errorf("build %q req %q, want the latest", e.Build, e.Req)
	}
	if e.Why == "" || e.Fix == "" {
		t.Error("what it costs and what to do did not survive the restart")
	}
}

// The count cannot tell a fire from a finished incident: on the live
// journal, 833 occurrences spread over two days and 150 inside one
// 38-minute window were the same number and opposite situations. The
// rate is what separates them.
func TestRatesSeparateABurstFromASteadyFire(t *testing.T) {
	d := excDB(t)
	now := time.Now()
	rec := func(sig string, ago time.Duration) {
		if err := d.RecordException(Exception{Sig: sig, Message: sig, Surface: "test",
			Count: 1, First: now.Add(-ago), Last: now.Add(-ago)}); err != nil {
			t.Fatal(err)
		}
	}
	// Still burning: a few every hour, right up to now.
	for h := 0; h < 12; h++ {
		for i := 0; i < 3; i++ {
			rec("burning", time.Duration(h)*time.Hour)
		}
	}
	// Over: a burst twenty hours ago and silence since.
	for i := 0; i < 40; i++ {
		rec("finished", 20*time.Hour)
	}

	rates, err := d.Rates(24, 24*time.Hour)
	if err != nil {
		t.Fatal(err)
	}
	burning, finished := rates["burning"], rates["finished"]
	if len(burning) != 24 || len(finished) != 24 {
		t.Fatalf("buckets: burning %d finished %d", len(burning), len(finished))
	}
	// The one still happening has occurrences in the newest bucket; the
	// finished one does not, however large its burst was.
	if burning[23] == 0 {
		t.Error("a fault occurring now shows nothing in the newest bucket")
	}
	if finished[23] != 0 {
		t.Error("a fault that stopped hours ago still reports current occurrences")
	}
	// And the burst is a spike, not a spread: one bucket holds it all.
	peak := 0
	for _, b := range finished {
		if b > peak {
			peak = b
		}
	}
	if peak != 40 {
		t.Errorf("the burst spread across buckets: %v", finished)
	}

	// A signature with nothing recent is absent, not a row of zeroes:
	// "not happening" is the answer, and an empty row would draw a
	// flatline that looks like data.
	rec("ancient", 40*24*time.Hour)
	rates, _ = d.Rates(24, 24*time.Hour)
	if _, there := rates["ancient"]; there {
		t.Error("a signature with no recent occurrence was given a rate")
	}
}