Files
mesh-catalog/modules/mesh-issues/cmd/mesh-issues/log_test.go
T
jochen d413da203c
mesh/merge-gate pass: builds mesh-issues → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
mesh-issues: resume an interrupted carry by revision and fault one issue instead of the whole start, so no kept value is lost and the tracker always starts (review of PR 228)
An open's lost answer is judged by the issue's first entry; an entry that does not
fold faults its issue on start; a value written beside the log is never folded over;
one issue is kept in at most 252 KiB so its whole operation fits a log entry.
2026-10-12 10:35:57 +02:00

820 lines
27 KiB
Go

package main
import (
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"testing"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// fakeLog is the module's log kept in this process with the runtime's semantics (novox/hq ADR 0297, mesh-sdk
// go/log.go): an append at expect is refused with stdio.ErrChanged when the key's last sequence is another,
// 0 standing for a key with no entry; a read pages from a sequence, narrowed to one key or none.
type fakeLog struct {
mu sync.Mutex
entries []stdio.LogEntry
// fail, when set, is what every append answers, writing nothing: a full log, or a bus that is down.
fail error
// failNext is how many of the next appends fail, writing nothing, as a bus that does not answer.
failNext int
// lost, when set, is what the next append answers after it was written: an answer lost on the way.
lost error
// between, when set, runs once before the next append is judged: another writer coming in between.
between func()
clock time.Time
}
func newFakeLog() *fakeLog {
return &fakeLog{clock: time.Date(2026, 10, 12, 9, 0, 0, 0, time.UTC)}
}
func (l *fakeLog) lastOf(key string) uint64 {
for i := len(l.entries) - 1; i >= 0; i-- {
if l.entries[i].Key == key {
return l.entries[i].Sequence
}
}
return 0
}
func (l *fakeLog) AppendAt(key string, value any, expect uint64) (uint64, error) {
l.mu.Lock()
if b := l.between; b != nil {
l.between = nil
l.mu.Unlock()
b()
l.mu.Lock()
}
defer l.mu.Unlock()
if l.fail != nil {
return 0, l.fail
}
if l.failNext > 0 {
l.failNext--
return 0, errors.New("the bus did not answer")
}
raw, err := json.Marshal(value)
if err != nil {
return 0, err
}
if len(raw) > 256<<10 {
return 0, fmt.Errorf("the value for %s is %d bytes; a log entry holds at most 256 KiB", key, len(raw))
}
if last := l.lastOf(key); last != expect {
return 0, fmt.Errorf("the last entry of %s is %d, not %d: %w", key, last, expect, stdio.ErrChanged)
}
seq := uint64(len(l.entries) + 1)
l.entries = append(l.entries, stdio.LogEntry{Sequence: seq, Key: key, Value: raw, At: l.clock})
if lost := l.lost; lost != nil {
l.lost = nil
return 0, lost
}
return seq, nil
}
func (l *fakeLog) Read(from uint64, keys string, limit int) (*stdio.LogPage, error) {
l.mu.Lock()
defer l.mu.Unlock()
if limit <= 0 {
limit = 100
}
limit = min(limit, 1000)
p := &stdio.LogPage{Entries: []stdio.LogEntry{}, Last: uint64(len(l.entries))}
for _, e := range l.entries {
if e.Sequence < from || (keys != "" && e.Key != keys) {
continue
}
if len(p.Entries) == limit {
p.Next = e.Sequence
break
}
p.Entries = append(p.Entries, e)
}
return p, nil
}
// ops is every operation the log holds of one issue, oldest first.
func (l *fakeLog) ops(t *testing.T, n uint64) []Operation {
t.Helper()
l.mu.Lock()
defer l.mu.Unlock()
var out []Operation
for _, e := range l.entries {
if e.Key != keyOf(n) {
continue
}
var op Operation
if err := json.Unmarshal(e.Value, &op); err != nil {
t.Fatal(err)
}
out = append(out, op)
}
return out
}
// foldCheck holds every issue the bucket folded to what its whole log adds up to, byte for byte: run after every
// test of a harness on this log (the review of PR 228).
func (l *fakeLog) foldCheck(t *testing.T, m *memory) {
l.mu.Lock()
entries := append([]stdio.LogEntry(nil), l.entries...)
l.mu.Unlock()
for k, v := range m.snapshot(IssuesState) {
var is Issue
if json.Unmarshal([]byte(v), &is) != nil || is.Folded == 0 {
continue
}
var f *Issue
var err error
for _, e := range entries {
if e.Key != k || e.Sequence > is.Folded {
continue
}
if f, err = foldEntry(f, e); err != nil {
t.Errorf("issue %s does not fold from its log: %v", k, err)
break
}
}
if raw, _ := json.Marshal(f); err == nil && string(raw) != v {
t.Errorf("issue %s in the bucket is not the fold of its log:\nbucket %s\nfold %s", k, v, raw)
}
}
}
func (l *fakeLog) size() int {
l.mu.Lock()
defer l.mu.Unlock()
return len(l.entries)
}
// another is a second tracker on the same bucket and log: another instance, or the same after a restart.
func (h *harness) another(t *testing.T) *harness {
t.Helper()
o := newHarness(t)
o.mem, o.log = h.mem, h.log
o.Tracker.Store, o.Tracker.Log = h.mem, h.log
o.settings = h.settings
return o
}
// §1: a write whose append is refused changes nothing: not the bucket, not the log, and says no event.
func TestAWriteWhoseAppendIsRefusedChangesNothing(t *testing.T) {
h := newHarness(t)
n := h.open(t, "the bucket fills", "mesh-catalog modules/mesh-issues")
bucket, entries, events := h.mem.snapshot(IssuesState), h.log.size(), len(h.said())
h.log.fail = fmt.Errorf("the log is full: %w", errors.New("maximum bytes exceeded"))
if _, err := h.Note(n, "a note the log refuses"); err == nil || !strings.Contains(err.Error(), "log did not take") {
t.Fatalf("a note whose append is refused answers %v", err)
}
if _, err := h.Move(MoveAsk{Number: n, Status: StatusDiagnosing, By: "fix@g14"}); err == nil {
t.Fatal("a move whose append is refused was answered as made")
}
if got := h.mem.snapshot(IssuesState); fmt.Sprint(got) != fmt.Sprint(bucket) {
t.Fatalf("the bucket changed:\n%v\n%v", bucket, got)
}
if h.log.size() != entries || len(h.said()) != events {
t.Fatalf("the log holds %d entries (was %d), %d events said (was %d)", h.log.size(), entries, len(h.said()), events)
}
}
// §1: an operation that came in between refuses the stale append; the change is read again and applied to
// what the log holds, so neither is lost.
func TestAnOperationInBetweenIsNotLost(t *testing.T) {
h := newHarness(t)
n := h.open(t, "two notes at once", "hq")
other := h.another(t)
h.log.between = func() {
if _, err := other.Note(n, "the other's note"); err != nil {
t.Errorf("the other note: %v", err)
}
}
if _, err := h.Note(n, "this note"); err != nil {
t.Fatal(err)
}
is, err := h.Get(n)
if err != nil {
t.Fatal(err)
}
if len(is.Notes) != 2 || is.Notes[0].Text != "the other's note" || is.Notes[1].Text != "this note" {
t.Fatalf("notes %+v", is.Notes)
}
ops := h.log.ops(t, n)
if len(ops) != 3 || ops[0].Op != OpOpened || ops[1].Op != EventNoted || ops[2].Op != EventNoted {
t.Fatalf("the log of %d: %+v", n, ops)
}
if is.Folded != uint64(h.log.size()) {
t.Fatalf("folded at %d, the log's last is %d", is.Folded, h.log.size())
}
}
// §2: an open refused at validation takes no number.
func TestAnOpenRefusedAtValidationTakesNoNumber(t *testing.T) {
h := newHarness(t)
if _, err := h.Open(OpenAsk{Title: "", Report: "no title"}); err == nil {
t.Fatal("an open without a title was taken")
}
if _, err := h.Open(OpenAsk{Title: "t", Report: "r", LocatedIn: []string{"nowhere"}}); err == nil {
t.Fatal("an open naming an unregistered owner was taken")
}
if c := h.mem.counter(t); c != 0 || h.log.size() != 0 {
t.Fatalf("counter %d, log %d entries", c, h.log.size())
}
if n := h.open(t, "the first"); n != 365 {
t.Fatalf("the first open got %d", n)
}
}
// §2: an append that fails after the number was taken answers the failure and spends that number.
func TestAnAppendFailingAfterTheNumberSpendsIt(t *testing.T) {
h := newHarness(t)
h.log.fail = errors.New("the log is full")
_, err := h.Open(OpenAsk{Title: "refused by a full log", Report: "r"})
if err == nil || !strings.Contains(err.Error(), "365 is spent") || !strings.Contains(err.Error(), "the log is full") {
t.Fatalf("the open answered %v", err)
}
if c := h.mem.counter(t); c != 365 {
t.Fatalf("the counter is at %d: the number was not taken", c)
}
if len(h.mem.snapshot(IssuesState)) != 0 || h.log.size() != 0 || len(h.said()) != 0 {
t.Fatal("an open the log refused left an issue, an entry or an event")
}
h.log.fail = nil
if n := h.open(t, "the next"); n != 366 {
t.Fatalf("the next open got %d, not 366", n)
}
}
// §2: an append whose answer was lost is known as the open's own by its opening id: one issue, one number.
func TestAnAppendWhoseAnswerWasLostIsTheOpensOwn(t *testing.T) {
h := newHarness(t)
h.log.lost = errors.New("timeout")
o, err := h.Open(OpenAsk{Title: "lost answer", Report: "r"})
if err != nil {
t.Fatal(err)
}
if o.Number != 365 || h.log.size() != 1 {
t.Fatalf("number %d, %d entries", o.Number, h.log.size())
}
if is, err := h.Get(365); err != nil || is.Folded != 1 {
t.Fatalf("issue %+v, %v", is, err)
}
}
// writeEverything makes issues with every kind of write: open, move, note, link, set, delivery, triage.
func writeEverything(t *testing.T, h *harness) []uint64 {
t.Helper()
a := h.open(t, "the issues bucket fills toward its cap", "mesh-catalog modules/mesh-issues")
b := h.open(t, "status changes are kept for seven days", "mesh-controller")
c := h.open(t, "an unrelated one")
if _, err := h.Note(a, "measured 10.9 MB of 64 MiB"); err != nil {
t.Fatal(err)
}
if _, err := h.Link(LinkAsk{Number: b, Kind: LinkRelatesTo, Target: keyOf(a)}); err != nil {
t.Fatal(err)
}
if _, err := h.Set(SetAsk{Number: a, Severity: "major", Needs: "decision"}); err != nil {
t.Fatal(err)
}
if _, err := h.Move(MoveAsk{Number: a, Status: StatusFixing, By: "fix@g14", FixedBy: []string{"novox/mesh-catalog#1"}}); err != nil {
t.Fatal(err)
}
if _, err := h.Move(MoveAsk{Number: c, Status: StatusWontfix, Reason: "not ours"}); err != nil {
t.Fatal(err)
}
if _, err := h.Link(LinkAsk{Number: b, Kind: LinkRelatesTo, Target: keyOf(a), Remove: true}); err != nil {
t.Fatal(err)
}
return []uint64{a, b, c}
}
// §3: the bucket emptied, the fold on start restores every issue exactly.
func TestTheFoldOnStartRestoresAnEmptiedBucketExactly(t *testing.T) {
h := newHarness(t)
writeEverything(t, h)
before := h.mem.snapshot(IssuesState)
if len(before) != 3 {
t.Fatalf("%d issues", len(before))
}
h.mem.empty(IssuesState)
r := h.another(t)
s, err := r.Start()
if err != nil {
t.Fatal(err)
}
if s.Rebuilt != 3 || s.Issues != 3 || len(s.Faults) != 0 {
t.Fatalf("started %+v", s)
}
after := h.mem.snapshot(IssuesState)
for k, v := range before {
if after[k] != v {
t.Errorf("issue %s was\n%s\nand is folded as\n%s", k, v, after[k])
}
}
if len(after) != len(before) {
t.Fatalf("%d issues after, %d before", len(after), len(before))
}
}
// §3: a bucket behind its log (the write after the append failed) catches up on start, and at the next change.
func TestABucketBehindItsLogCatchesUp(t *testing.T) {
h := newHarness(t)
n := h.open(t, "crash between the two", "hq")
h.mem.fail[IssuesState] = errors.New("the bus went away")
if _, err := h.Note(n, "kept in the log only"); err != nil {
t.Fatalf("a note the log took is a note made: %v", err)
}
is, _ := h.Get(n)
if len(is.Notes) != 0 {
t.Fatal("the bucket write was meant to fail")
}
r := h.another(t)
s, err := r.Start()
if err != nil {
t.Fatal(err)
}
if s.Refolded != 1 {
t.Fatalf("started %+v", s)
}
if is, _ = h.Get(n); len(is.Notes) != 1 || is.Folded != 2 {
t.Fatalf("after start: %+v", is)
}
// And at the next change without a start: the stale append is refused, the log folded, the change applied.
h.mem.fail[IssuesState] = errors.New("the bus went away")
if _, err := h.Note(n, "second"); err != nil {
t.Fatal(err)
}
if _, err := h.Note(n, "third"); err != nil {
t.Fatal(err)
}
if is, _ = h.Get(n); len(is.Notes) != 3 || is.Folded != 4 {
t.Fatalf("after the next change: notes %d, folded %d", len(is.Notes), is.Folded)
}
}
// §3: the counter is kept at or above the highest number in the log.
func TestStartRaisesTheCounterToTheLog(t *testing.T) {
h := newHarness(t)
h.open(t, "one")
h.open(t, "two")
h.mem.put(CountersState, IssueCounter, uint64(365))
s, err := h.another(t).Start()
if err != nil {
t.Fatal(err)
}
if s.Counter != 366 || h.mem.counter(t) != 366 {
t.Fatalf("counter %d, kept %d", s.Counter, h.mem.counter(t))
}
if n := h.open(t, "three"); n != 367 {
t.Fatalf("the next open got %d", n)
}
}
// §4: each write still says its event, alongside its one operation in the log.
func TestEachWriteSaysItsEventAlongsideItsOperation(t *testing.T) {
h := newHarness(t)
type step struct {
event string
do func() error
}
var n uint64
steps := []step{
{EventOpened, func() error {
o, err := h.Open(OpenAsk{Title: "t", Report: "r", LocatedIn: []string{"hq"}})
n = o.Number
return err
}},
{EventNoted, func() error { _, err := h.Note(n, "a note"); return err }},
{EventLinked, func() error { _, err := h.Link(LinkAsk{Number: n, Kind: LinkDecision, Target: "0300"}); return err }},
{EventSet, func() error { _, err := h.Set(SetAsk{Number: n, Severity: "minor"}); return err }},
{EventMoved, func() error { _, err := h.Move(MoveAsk{Number: n, Status: StatusLocated}); return err }},
}
for i, s := range steps {
events, entries := len(h.said()), h.log.size()
if err := s.do(); err != nil {
t.Fatalf("%s: %v", s.event, err)
}
said := h.said()
if len(said) != events+1 || said[len(said)-1].Event != s.event {
t.Fatalf("step %d said %v", i, said[events:])
}
ops := h.log.ops(t, n)
if h.log.size() != entries+1 || ops[len(ops)-1].Op != s.event || ops[len(ops)-1].By != Unproven {
t.Fatalf("step %d: %d entries (was %d), last %+v", i, h.log.size(), entries, ops[len(ops)-1])
}
}
// A note is kept as itself, not as every note again.
if op := h.log.ops(t, n)[1]; op.Set != nil || len(op.Add) != 1 || op.Add["notes"] == nil {
t.Fatalf("the note's operation is %+v", op)
}
}
// §5: before pages through more than 500 issues, without a gap or a repeat.
func TestBeforePagesThroughMoreThanFiveHundredIssues(t *testing.T) {
h := newHarness(t)
const issues = 530
for i := 0; i < issues; i++ {
h.open(t, fmt.Sprintf("issue %d", i))
}
if _, err := h.Start(); err != nil {
t.Fatal(err)
}
seen := map[uint64]bool{}
var last uint64 = 0
var before uint64
pages := 0
for {
l, err := h.List(ListAsk{Limit: 100, Before: before})
if err != nil {
t.Fatal(err)
}
pages++
for _, r := range l.Rows {
if seen[r.Number] {
t.Fatalf("%d repeated", r.Number)
}
if last != 0 && r.Number != last-1 {
t.Fatalf("a gap from %d to %d", last, r.Number)
}
seen[r.Number], last = true, r.Number
}
if l.Next == 0 {
break
}
before = l.Next
}
if len(seen) != issues || pages != 6 || last != 365 {
t.Fatalf("%d issues seen in %d pages, ending at %d", len(seen), pages, last)
}
// Over the tool too, as JSON gives the cursor.
out, err := h.call(t, "list", map[string]any{"before": 400, "limit": 2})
if err != nil {
t.Fatal(err)
}
if l := out.(*Listed); len(l.Rows) != 2 || l.Rows[0].Number != 399 || l.Next != 398 {
t.Fatalf("listed %+v", l)
}
}
// §5: a search is ranked by relevance; the index is rebuilt on start and kept current by the bucket's watch.
func TestASearchIsRankedAndTheIndexRebuiltOnStart(t *testing.T) {
h := newHarness(t)
full := h.open(t, "the issues bucket fills toward its cap")
h.open(t, "a bucket of the controller")
week := h.open(t, "status changes are kept for seven days")
h.open(t, "nothing alike")
if _, err := h.Note(week, "the events stream drops them after a week; the bucket is not involved"); err != nil {
t.Fatal(err)
}
r := h.another(t)
if _, err := r.Start(); err != nil {
t.Fatal(err)
}
if !r.view().ready() {
t.Fatal("the index was not built on start")
}
l, err := r.List(ListAsk{Search: "issues bucket cap"})
if err != nil {
t.Fatal(err)
}
if len(l.Rows) != 3 || l.Rows[0].Number != full {
t.Fatalf("ranked %+v", l.Rows)
}
// A note's words are found: notes are indexed.
if l, _ = r.List(ListAsk{Search: "week"}); len(l.Rows) != 1 || l.Rows[0].Number != week {
t.Fatalf("a note's word: %+v", l.Rows)
}
// Another instance's write reaches this one's index through the watch of the bucket.
if _, err := h.Note(full, "seen again by the operator on shanks"); err != nil {
t.Fatal(err)
}
e, _ := h.mem.Get(IssuesState, keyOf(full))
if err := r.Watched(stdio.StateChange{State: IssuesState, Key: keyOf(full), Op: "put", Value: e.Value}); err != nil {
t.Fatal(err)
}
if l, _ = r.List(ListAsk{Search: "operator"}); len(l.Rows) != 1 || l.Rows[0].Number != full {
t.Fatalf("after the watch: %+v", l.Rows)
}
if _, err := r.List(ListAsk{Search: "bucket", Before: 400}); err == nil {
t.Fatal("a search with before was taken")
}
}
// keptBefore puts an issue in the bucket as the tracker kept it before the switch: values, no log.
func keptBefore(t *testing.T, h *harness, n uint64, titles ...string) {
t.Helper()
for _, title := range titles {
h.mem.put(IssuesState, keyOf(n), Issue{Number: n, Title: title, Status: StatusOpen, Report: "r",
Opened: time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC), OpenedBy: "x", Opening: fmt.Sprint("o", n)})
}
}
// §6: the carry appends each value the bucket keeps, oldest first, as carried operations holding the whole
// issue; run again it carries nothing; a write after it is one entry in the log.
func TestTheCarryIsIdempotentAndCarriesEveryValueKept(t *testing.T) {
h := newHarness(t)
keptBefore(t, h, 300, "first title", "second title", "third title")
keptBefore(t, h, 301, "only")
c, err := h.Carry()
if err != nil {
t.Fatal(err)
}
if c.Issues != 2 || c.Values != 4 || len(c.Faults) != 0 {
t.Fatalf("carried %+v", c)
}
ops := h.log.ops(t, 300)
if len(ops) != 3 {
t.Fatalf("%d operations of 300", len(ops))
}
for i, title := range []string{"first title", "second title", "third title"} {
if ops[i].Op != OpCarried || ops[i].Issue == nil || ops[i].Issue.Title != title {
t.Fatalf("operation %d: %+v", i, ops[i])
}
}
is, _ := h.Get(300)
if is.Title != "third title" || is.Folded != 3 {
t.Fatalf("300 is %+v", is)
}
again, err := h.Carry()
if err != nil {
t.Fatal(err)
}
if again.Issues != 0 || again.Values != 0 || again.Already != 2 || h.log.size() != 4 {
t.Fatalf("carried again %+v, %d entries", again, h.log.size())
}
if _, err := h.Move(MoveAsk{Number: 300, Status: StatusDiagnosing, By: "fix@g14"}); err != nil {
t.Fatal(err)
}
if h.log.size() != 5 || h.log.ops(t, 300)[3].Op != EventMoved {
t.Fatalf("a move after the carry: %d entries", h.log.size())
}
// The start carries too, and finds nothing more.
s, err := h.another(t).Start()
if err != nil || s.Carried.Issues != 0 || h.log.size() != 5 {
t.Fatalf("start %+v, %v", s, err)
}
}
// §6: a deletion among the values the bucket kept is passed over, said, and the values after it carried.
func TestTheCarryPassesOverADeletionAmongTheKeptValues(t *testing.T) {
h := newHarness(t)
keptBefore(t, h, 300, "before the deletion")
h.mem.deleteKey(IssuesState, keyOf(300))
keptBefore(t, h, 300, "after the deletion", "now")
c, err := h.Carry()
if err != nil {
t.Fatal(err)
}
if c.Issues != 1 || c.Values != 3 || c.Deletes != 1 || !strings.Contains(c.Said, "1 deletions") {
t.Fatalf("carried %+v", c)
}
ops := h.log.ops(t, 300)
if len(ops) != 3 || ops[0].Issue.Title != "before the deletion" || ops[2].Issue.Title != "now" {
t.Fatalf("the log of 300: %+v", ops)
}
if is, _ := h.Get(300); is.Title != "now" || is.Folded != 3 {
t.Fatalf("300 is %+v", is)
}
}
// §6: a write to an issue not carried yet carries it first, so its first operation in the log is a carried one.
func TestAWriteBeforeTheCarryCarriesItsIssueFirst(t *testing.T) {
h := newHarness(t)
keptBefore(t, h, 300, "before", "now")
if _, err := h.Note(300, "after the switch"); err != nil {
t.Fatal(err)
}
ops := h.log.ops(t, 300)
if len(ops) != 3 || ops[0].Op != OpCarried || ops[1].Op != OpCarried || ops[2].Op != EventNoted {
t.Fatalf("the log of 300: %+v", ops)
}
h.mem.empty(IssuesState)
if _, err := h.another(t).Start(); err != nil {
t.Fatal(err)
}
if is, _ := h.Get(300); is.Title != "now" || len(is.Notes) != 1 {
t.Fatalf("300 folds to %+v", is)
}
}
// The counter behind an issue the bucket kept before the switch: the open refuses, overwrites nothing.
func TestAnOpenOverAnIssueKeptBeforeTheSwitchOverwritesNothing(t *testing.T) {
h := newHarness(t)
keptBefore(t, h, 365, "kept")
h.mem.put(CountersState, IssueCounter, uint64(364))
if _, err := h.Open(OpenAsk{Title: "new", Report: "r"}); err == nil || !strings.Contains(err.Error(), "fault") {
t.Fatalf("the open answered %v", err)
}
if is, _ := h.Get(365); is.Title != "kept" {
t.Fatalf("365 is %+v", is)
}
}
// titlesOf is each operation of an issue's log as its kind and, for one holding the issue whole, its title.
func titlesOf(t *testing.T, h *harness, n uint64) string {
t.Helper()
var out []string
for _, op := range h.log.ops(t, n) {
if op.Issue != nil {
out = append(out, op.Op+":"+op.Issue.Title)
} else {
out = append(out, op.Op)
}
}
return strings.Join(out, " ")
}
// §6, the review of PR 228: a carry that fails partway, then a second carry, leaves every kept value carried
// once, oldest first, and each carry says what it really carried.
func TestAnInterruptedCarryResumesAfterTheLastValueCarried(t *testing.T) {
h := newHarness(t)
keptBefore(t, h, 300, "v1", "v2", "v3", "v4")
// The bus goes away after the first value is appended.
h.log.between = func() { h.log.between = func() { h.log.fail = errors.New("the bus went away") } }
c, err := h.Carry()
if err != nil {
t.Fatal(err)
}
if c.Values != 1 || len(c.Faults) != 1 || !strings.Contains(c.Said, "could not be carried") ||
!strings.Contains(c.Faults[0], "the bus went away") {
t.Fatalf("the first carry said %+v", c)
}
if is, _ := h.Get(300); is.Folded != 0 || is.Title != "v4" {
t.Fatalf("after the failure the bucket holds %+v", is)
}
// A write meanwhile is refused, and changes nothing.
if _, err := h.Note(300, "while the bus is away"); err == nil || !strings.Contains(err.Error(), "not carried") {
t.Fatalf("a note on an issue half carried: %v", err)
}
h.log.fail = nil
c, err = h.Carry()
if err != nil {
t.Fatal(err)
}
if c.Values != 3 || c.Issues != 1 || len(c.Faults) != 0 {
t.Fatalf("the second carry said %+v", c)
}
if got := titlesOf(t, h, 300); got != "carried:v1 carried:v2 carried:v3 carried:v4" {
t.Fatalf("the log of 300: %s", got)
}
if is, _ := h.Get(300); is.Title != "v4" || is.Folded != 4 {
t.Fatalf("300 is %+v", is)
}
}
// §6, the review of PR 228: one issue that cannot be carried is a fault, named, and the start carries the others,
// folds, raises the counter and loads memory. Writes to that issue are refused with the reason.
func TestOneIssueThatCannotBeCarriedDoesNotStopTheStart(t *testing.T) {
h := newHarness(t)
keptBefore(t, h, 300, "small")
// Kept before the switch at the bucket's limit: whole, in a carried operation, past what one log entry holds.
h.mem.put(IssuesState, "301", Issue{Number: 301, Title: "huge", Status: StatusOpen, Report: strings.Repeat("x", 256<<10-200),
OpenedBy: "x", Opening: "o301"})
keptBefore(t, h, 302, "also small")
s, err := h.Start()
if err != nil {
t.Fatal(err)
}
if s.Carried.Issues != 2 || len(s.Faults) != 1 || !strings.Contains(s.Faults[0], "issue 301") ||
!strings.Contains(s.Faults[0], "256 KiB") || !strings.Contains(s.Said, "issue 301") {
t.Fatalf("started %+v", s)
}
if !h.view().ready() {
t.Fatal("memory was not loaded")
}
l, err := h.List(ListAsk{})
if err != nil || l.Matched != 3 {
t.Fatalf("listed %+v, %v", l, err)
}
if _, err := h.Note(301, "a note"); err == nil || !strings.Contains(err.Error(), "256 KiB") {
t.Fatalf("a note on the issue not carried: %v", err)
}
if is, _ := h.Get(301); is.Title != "huge" || is.Folded != 0 || len(is.Notes) != 0 {
t.Fatal("the issue not carried was changed")
}
if is, _ := h.Get(302); is.Folded == 0 {
t.Fatal("302 was not carried")
}
}
// §6, the review of PR 228: two instances carrying the same issue at the same moment carry each value once.
func TestTwoInstancesCarryingOneIssueAtOnceCarryEachValueOnce(t *testing.T) {
h := newHarness(t)
keptBefore(t, h, 300, "v1", "v2", "v3")
other := h.another(t)
var otherSaid *Carried
h.log.between = func() {
c, err := other.Carry()
if err != nil {
t.Errorf("the other carry: %v", err)
}
otherSaid = c
}
c, err := h.Carry()
if err != nil {
t.Fatal(err)
}
if got := titlesOf(t, h, 300); got != "carried:v1 carried:v2 carried:v3" {
t.Fatalf("the log of 300: %s", got)
}
if otherSaid.Values+c.Values != 3 || len(c.Faults)+len(otherSaid.Faults) != 0 {
t.Fatalf("this carry %+v, the other %+v", c, otherSaid)
}
}
// §6, the review of PR 228: a value written beside the log after the carry, by an instance from before the switch,
// is never overwritten by the fold on start; it is carried as one more operation.
func TestAValueWrittenBesideTheLogIsCarriedNotOverwritten(t *testing.T) {
h := newHarness(t)
n := h.open(t, "carried", "hq")
if _, err := h.Note(n, "after the switch"); err != nil {
t.Fatal(err)
}
// An instance from before the switch writes the bucket: no folded.
is, _ := h.Get(n)
is.Folded, is.Title = 0, "written beside the log"
h.mem.put(IssuesState, keyOf(n), is)
// Its carry fails at this start: the fold is not written over it.
h.log.failNext = 1
s, err := h.another(t).Start()
if err != nil {
t.Fatal(err)
}
if got, _ := h.Get(n); got.Title != "written beside the log" || got.Folded != 0 || len(s.Faults) != 2 {
t.Fatalf("issue %d is %+v after a start whose carry failed (faults %v)", n, got, s.Faults)
}
// The next start carries it.
if _, err := h.another(t).Start(); err != nil {
t.Fatal(err)
}
got, _ := h.Get(n)
if got.Title != "written beside the log" || got.Folded != 3 || len(got.Notes) != 1 {
t.Fatalf("issue %d is %+v", n, got)
}
if ops := titlesOf(t, h, n); ops != "opened:carried noted carried:written beside the log" {
t.Fatalf("the log: %s", ops)
}
}
// §3, the review of PR 228: an entry that cannot be folded faults its issue on start, and nothing folded past it
// is written.
func TestAnEntryThatCannotBeFoldedLeavesItsIssueAsItIs(t *testing.T) {
h := newHarness(t)
n := h.open(t, "fine")
m := h.open(t, "broken")
if _, err := h.Note(m, "kept"); err != nil {
t.Fatal(err)
}
h.log.mu.Lock()
for i, e := range h.log.entries {
if e.Key == keyOf(m) && e.Sequence == 2 {
h.log.entries[i].Value = json.RawMessage(`{"op":"noted","add":{"notes":"not a list"}}`)
}
}
h.log.mu.Unlock()
before := h.mem.snapshot(IssuesState)[keyOf(m)]
h.mem.empty(IssuesState)
h.mem.put(IssuesState, keyOf(m), json.RawMessage(before))
s, err := h.another(t).Start()
if err != nil {
t.Fatal(err)
}
if s.Rebuilt != 1 || len(s.Faults) != 1 || !strings.Contains(s.Faults[0], fmt.Sprintf("issue %d cannot be folded", m)) {
t.Fatalf("started %+v", s)
}
if h.mem.snapshot(IssuesState)[keyOf(m)] != before {
t.Fatal("the broken issue's bucket value was written over")
}
if is, _ := h.Get(n); is.Title != "fine" {
t.Fatalf("%d is %+v", n, is)
}
// The broken entry is the test's doing: the fold check would find it, so take the issue out of the bucket.
h.mem.empty(IssuesState)
}
// §2, the review of PR 228: an open whose answer was lost, its issue already changed by another operation, is
// still known as its own by its first entry.
func TestALostOpenIsKnownByItsFirstEntryNotItsLast(t *testing.T) {
h := newHarness(t)
h.log.lost = errors.New("no answer in 30 s")
// After the first operation lands and its answer is lost, another operation follows before the retry.
h.log.between = func() {
h.log.between = func() {
h.log.mu.Lock()
h.log.entries = append(h.log.entries, stdio.LogEntry{Sequence: uint64(len(h.log.entries) + 1), Key: "365",
Value: json.RawMessage(`{"op":"triaged","by":"triage","set":{"severity":"\"minor\""}}`)})
h.log.mu.Unlock()
}
}
o, err := h.Open(OpenAsk{Title: "lost and followed", Report: "r"})
if err != nil || o.Number != 365 {
t.Fatalf("%+v %v", o, err)
}
// The entry added by hand is not one the tracker folded: take the issue out before the fold check.
h.mem.empty(IssuesState)
}