textmachine/platform/internal/ingest/tail_test.go

378 lines
15 KiB
Go

package ingest
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
// tailSink records what the tailer decided to materialize, and nothing else: what these tests are
// about is the READER's rule for at-least-once delivery, not what a sink does with an event.
type tailSink struct {
hellos []Hello
applied []Envelope
cursors []Cursor
// failFrom makes the sink refuse every event from this seq on, which is how a database outage
// mid-stream looks to the reader.
failFrom int64
}
func (s *tailSink) Begin(_ context.Context, h Hello) error {
s.hellos = append(s.hellos, h)
return nil
}
func (s *tailSink) Apply(_ context.Context, ev Envelope, c Cursor) error {
if s.failFrom > 0 && ev.Seq >= s.failFrom {
return errors.New("database is down")
}
s.applied = append(s.applied, ev)
s.cursors = append(s.cursors, c)
return nil
}
func (s *tailSink) seqs() []int64 {
out := make([]int64, 0, len(s.applied))
for _, e := range s.applied {
out = append(out, e.Seq)
}
return out
}
func line(t *testing.T, seq int64, typ Type, data any) string {
t.Helper()
raw, err := json.Marshal(data)
if err != nil {
t.Fatal(err)
}
b, err := json.Marshal(Envelope{Seq: seq, Type: typ, Data: raw})
if err != nil {
t.Fatal(err)
}
return string(b) + "\n"
}
func hello(t *testing.T, seq int64, runID string) string {
return line(t, seq, TypeHello, Hello{StreamVersion: StreamVersion, EngineRunID: runID, BookID: "bk"})
}
func progress(t *testing.T, seq int64, done int) string {
return line(t, seq, TypeProgress, Progress{Draft: Counter{Done: done, Total: 10}})
}
func journal(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), JournalFile)
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
return path
}
// The tailer is started when the unit is; the engine writes its first line whenever it gets there.
// Waiting is the correct behaviour and it must not be an error, or every run would log a failure
// during its first seconds — and today, with no emitter at all, forever.
func TestAnAbsentJournalIsNotAFailure(t *testing.T) {
_, _, err := Tail(t.Context(), filepath.Join(t.TempDir(), JournalFile), "", Position{}, &tailSink{})
if !errors.Is(err, ErrNoJournal) {
t.Fatalf("missing journal gave %v, want ErrNoJournal", err)
}
}
// A line without its newline is a line still being written. Reading it as an event turns an ordinary
// interleaving with the writer into a decode error on a paid run.
func TestAHalfWrittenLineIsLeftForNextTime(t *testing.T) {
partial := hello(t, 1, "eng-1") + progress(t, 2, 1) + `{"seq":3,"type":"progress","dat`
path := journal(t, partial)
sink := &tailSink{}
pos, id, err := Tail(t.Context(), path, "", Position{}, sink)
if err != nil {
t.Fatal(err)
}
if id != "eng-1" {
t.Fatalf("engine run id %q", id)
}
// The handshake moves the cursor like any other line: its effect is nothing, its seq is the
// cursor, and without it a resume after the hello reads the next line as a gap.
if got := sink.seqs(); len(got) != 2 || got[0] != 1 || got[1] != 2 {
t.Fatalf("applied %v, want 1,2", got)
}
// The cursor stands before the partial line, so the completed form of it is read next time.
if want := int64(len(hello(t, 1, "eng-1")) + len(progress(t, 2, 1))); pos.Offset != want {
t.Fatalf("offset %d, want %d (before the partial line)", pos.Offset, want)
}
// Now the writer finishes the line and appends another.
rest := `a":{"draft":{"done":2,"total":10}}}` + "\n" + progress(t, 4, 3)
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
t.Fatal(err)
}
if _, err := f.WriteString(rest); err != nil {
t.Fatal(err)
}
f.Close()
if _, _, err := Tail(t.Context(), path, id, pos, sink); err != nil {
t.Fatal(err)
}
if got := sink.seqs(); len(got) != 4 || got[2] != 3 || got[3] != 4 {
t.Fatalf("applied %v, want 1,2,3,4", got)
}
}
// PD-105, ratified by D39.119: delivery is at-least-once, so re-reading the line the cursor stands
// on is NORMAL. It must be idempotent and it must not be fatal — the previous decoder called it
// ErrStreamGap, and after the PD-12 fix that killed a paid run over one duplicated line.
func TestARedeliveredLineIsNormalAndChangesNothing(t *testing.T) {
body := hello(t, 1, "eng-1") + progress(t, 2, 1) + progress(t, 3, 2)
path := journal(t, body)
sink := &tailSink{}
pos, id, err := Tail(t.Context(), path, "", Position{}, sink)
if err != nil {
t.Fatal(err)
}
// Re-read the WHOLE journal from the beginning with the cursor already at seq 3: every line is a
// duplicate, and not one of them may be applied again or raise an error.
again := &tailSink{}
pos2, _, err := Tail(t.Context(), path, id, Position{LastSeq: pos.LastSeq, LastHash: pos.LastHash}, again)
if err != nil {
t.Fatalf("re-reading an already applied journal: %v", err)
}
if len(again.applied) != 0 || len(again.hellos) != 0 {
t.Fatalf("a re-read applied %d events and %d handshakes; both must be zero", len(again.applied), len(again.hellos))
}
if pos2.Offset != pos.Offset {
t.Errorf("the byte hint did not catch up on a re-read: %d, want %d", pos2.Offset, pos.Offset)
}
}
// The one case a high-water mark cannot absorb: the same seq saying something different. Quietly
// skipping it materializes whichever copy was read first (research/25 §эмиттер).
func TestTheSameSeqWithADifferentPayloadIsRefused(t *testing.T) {
first := journal(t, hello(t, 1, "eng-1")+progress(t, 2, 1))
sink := &tailSink{}
pos, id, err := Tail(t.Context(), first, "", Position{}, sink)
if err != nil {
t.Fatal(err)
}
// The same seq, a different payload — a rewritten journal, or two engine instances on one run.
rewritten := journal(t, hello(t, 1, "eng-1")+progress(t, 2, 7))
if _, _, err := Tail(t.Context(), rewritten, id, Position{LastSeq: pos.LastSeq, LastHash: pos.LastHash}, sink); !errors.Is(err, ErrPayloadConflict) {
t.Fatalf("a changed payload gave %v, want ErrPayloadConflict", err)
}
}
// A gap means lines were lost, and reading further cannot recover them: the ratified answer is the
// resync channel, so the reader has to say so rather than materialize a projection with a hole.
func TestALostLineIsReportedRatherThanSkipped(t *testing.T) {
path := journal(t, hello(t, 1, "eng-1")+progress(t, 2, 1)+progress(t, 5, 4))
sink := &tailSink{}
if _, _, err := Tail(t.Context(), path, "", Position{}, sink); !errors.Is(err, ErrStreamGap) {
t.Fatalf("a skipped seq gave %v, want ErrStreamGap", err)
}
if got := sink.seqs(); len(got) != 2 || got[1] != 2 {
t.Fatalf("applied %v: everything before the gap must still land", got)
}
}
// The journal is per BOOK and append-only, so a resumed run appends a SECOND handshake with its own
// run id and a seq that starts over. A reader that treated it as corruption would stop exactly when
// the run resumed; one that adopted it would mix two attempts into one attempt's counters.
func TestAnotherAttemptsStreamInTheSameJournalIsSkipped(t *testing.T) {
body := hello(t, 1, "eng-1") + progress(t, 2, 1) +
hello(t, 1, "eng-2") + progress(t, 2, 5) + progress(t, 3, 6)
path := journal(t, body)
// Attempt one owns the first stream and never sees the second.
first := &tailSink{}
pos, id, err := Tail(t.Context(), path, "", Position{}, first)
if err != nil {
t.Fatal(err)
}
if id != "eng-1" {
t.Fatalf("adopted %q, want eng-1", id)
}
if got := first.seqs(); len(got) != 2 || got[1] != 2 {
t.Fatalf("attempt one applied %v, want 1,2 of eng-1 and nothing of eng-2", got)
}
if pos.Offset != int64(len(body)) {
t.Errorf("the reader did not walk past the other attempt's lines: offset %d of %d", pos.Offset, len(body))
}
// Attempt two starts where its own lines begin — the journal's size when it was admitted.
start := int64(len(hello(t, 1, "eng-1") + progress(t, 2, 1)))
second := &tailSink{}
if _, id, err = Tail(t.Context(), path, "", Position{Offset: start}, second); err != nil {
t.Fatal(err)
}
if id != "eng-2" {
t.Fatalf("attempt two adopted %q, want eng-2", id)
}
if got := second.seqs(); len(got) != 3 || got[1] != 2 || got[2] != 3 {
t.Fatalf("attempt two applied %v, want 1,2,3 of eng-2", got)
}
}
// The same journal read from the START by an attempt that already knows its own id. This is what a
// lost byte hint produces, and it is where the reader used to quarantine a perfectly healthy run:
// the previous attempt's seq 2 landed on our cursor's seq 2 with a different payload, which is
// exactly the corruption signal. Nothing but the last handshake says whose a line is.
func TestARereadFromTheStartDoesNotMistakeAnotherAttemptForCorruption(t *testing.T) {
body := hello(t, 1, "eng-1") + progress(t, 2, 1) +
hello(t, 1, "eng-2") + progress(t, 2, 5) + progress(t, 3, 6)
path := journal(t, body)
// Attempt two, mid-run: it knows its id and its cursor, and its byte hint is gone.
own := &tailSink{}
first, _, err := Tail(t.Context(), path, "eng-2", Position{}, own) // no byte hint at all
if err != nil {
t.Fatalf("re-reading from the start: %v", err)
}
if got := own.seqs(); len(got) != 3 || got[1] != 2 || got[2] != 3 {
t.Fatalf("applied %v, want 1,2,3 of eng-2 and nothing of eng-1", got)
}
if first.Offset != int64(len(body)) {
t.Errorf("offset %d, want %d", first.Offset, len(body))
}
}
// Nothing is materialized before a handshake we own, even when the reader already knows its own
// engine run id. A journal that opens with events instead of a hello is the documented rot path
// (ad-hoc unversioned JSON), and judging those lines against OUR cursor would either apply another
// process's counters or report our own healthy stream as corrupt.
func TestEventsBeforeAnyHandshakeAreNotJudgedAgainstOurCursor(t *testing.T) {
headless := progress(t, 2, 5) + progress(t, 3, 6) + hello(t, 1, "eng-1") + progress(t, 2, 1)
path := journal(t, headless)
sink := &tailSink{}
pos, id, err := Tail(t.Context(), path, "eng-1", Position{}, sink)
if err != nil {
t.Fatalf("a journal that opens without a handshake: %v", err)
}
if id != "eng-1" {
t.Fatalf("engine run id %q", id)
}
if got := sink.seqs(); len(got) != 2 || got[0] != 1 || got[1] != 2 {
t.Fatalf("applied %v, want only the two lines that follow our own handshake", got)
}
if pos.LastSeq != 2 {
t.Errorf("cursor at seq %d", pos.LastSeq)
}
}
// The offset is a HINT and the append-only guarantee is what makes it usable. A journal that got
// SHORTER broke that guarantee, and reading on at a stale offset would decode the middle of a line.
func TestAJournalThatShrankIsRefusedRatherThanMisread(t *testing.T) {
path := journal(t, hello(t, 1, "eng-1"))
_, _, err := Tail(t.Context(), path, "eng-1", Position{Offset: 100_000, LastSeq: 5}, &tailSink{})
if err == nil || !strings.Contains(err.Error(), "append-only") {
t.Fatalf("a shrunken journal gave %v, want a refusal naming the broken guarantee", err)
}
}
// The cursor the sink is handed has to be the position AFTER the line, or a crash between the effect
// and the next read re-reads a line the sink has already applied — harmless by luck, not by design.
func TestTheCursorHandedToTheSinkIsPastTheLine(t *testing.T) {
h, p2 := hello(t, 1, "eng-1"), progress(t, 2, 1)
path := journal(t, h+p2)
sink := &tailSink{}
if _, _, err := Tail(t.Context(), path, "", Position{}, sink); err != nil {
t.Fatal(err)
}
if len(sink.cursors) != 2 {
t.Fatalf("%d cursors, want one per applied line", len(sink.cursors))
}
if want := int64(len(h)); sink.cursors[0].Offset != want {
t.Errorf("handshake cursor offset %d, want %d", sink.cursors[0].Offset, want)
}
if want := int64(len(h) + len(p2)); sink.cursors[1].Offset != want {
t.Errorf("cursor offset %d, want %d", sink.cursors[1].Offset, want)
}
if len(sink.cursors[1].SHA256) != 32 {
t.Errorf("cursor carries no line hash: %v", sink.cursors[1].SHA256)
}
}
// A stream whose major version changed means something different by the same words; materializing it
// is worse than not materializing it.
func TestAMajorVersionBumpIsRefused(t *testing.T) {
body, err := json.Marshal(Envelope{Seq: 1, Type: TypeHello,
Data: json.RawMessage(`{"stream_version":"9.9","engine_run_id":"eng-1"}`)})
if err != nil {
t.Fatal(err)
}
path := journal(t, string(body)+"\n")
if _, _, err := Tail(t.Context(), path, "", Position{}, &tailSink{}); !errors.Is(err, ErrUnsupportedVersion) {
t.Fatalf("a major bump gave %v, want ErrUnsupportedVersion", err)
}
}
// Half of the ratified idempotency key lives in the handshake; without it every run's events would
// collapse into one namespace instead of failing.
func TestAHandshakeWithoutAnEngineRunIdIsRefused(t *testing.T) {
body := fmt.Sprintf(`{"seq":1,"type":"hello","data":{"stream_version":%q,"engine_run_id":""}}`+"\n", StreamVersion)
path := journal(t, body)
if _, _, err := Tail(t.Context(), path, "", Position{}, &tailSink{}); !errors.Is(err, ErrBadHandshake) {
t.Fatalf("an anonymous handshake gave %v, want ErrBadHandshake", err)
}
}
// A sink that fails stops the read where it is: the cursor must not advance past an effect that was
// never applied.
func TestAFailingSinkDoesNotMoveTheCursor(t *testing.T) {
path := journal(t, hello(t, 1, "eng-1")+progress(t, 2, 1)+progress(t, 3, 2))
sink := &tailSink{failFrom: 2}
pos, _, err := Tail(t.Context(), path, "", Position{}, sink)
if err == nil {
t.Fatal("a failing sink was not reported")
}
if pos.LastSeq != 1 {
t.Errorf("cursor moved to seq %d past an event the sink refused", pos.LastSeq)
}
}
// A line past the buffer is REFUSED, not swallowed. Returning io.EOF for it would make the tailer
// report "caught up" at that byte forever: nothing after it would ever be materialized, with no
// error and no quarantine.
func TestALinePastTheBufferIsRefusedRatherThanRead(t *testing.T) {
huge := `{"seq":2,"type":"progress","data":{"note":"` + strings.Repeat("x", maxLine) + `"}}`
path := journal(t, hello(t, 1, "eng-1")+huge+"\n")
_, _, err := Tail(t.Context(), path, "", Position{}, &tailSink{})
if err == nil || !strings.Contains(err.Error(), "exceeds") {
t.Fatalf("an over-long line gave %v, want a refusal naming the limit", err)
}
}
// A blank line is not an event, but it IS bytes: an offset that did not advance past it is short by
// its length from then on, permanently.
func TestABlankLineStillMovesTheOffset(t *testing.T) {
body := hello(t, 1, "eng-1") + "\n" + progress(t, 2, 1)
path := journal(t, body)
pos, _, err := Tail(t.Context(), path, "", Position{}, &tailSink{})
if err != nil {
t.Fatal(err)
}
if pos.Offset != int64(len(body)) {
t.Errorf("offset %d after a journal of %d bytes containing a blank line", pos.Offset, len(body))
}
}
// Neither half of the cursor may advance past an effect the sink refused — the byte hint as much as
// the seq, because the hint is what decides where the next sweep starts reading.
func TestNeitherHalfOfTheCursorAdvancesPastARefusedEffect(t *testing.T) {
h, p2 := hello(t, 1, "eng-1"), progress(t, 2, 1)
path := journal(t, h+p2+progress(t, 3, 2))
sink := &tailSink{failFrom: 3}
pos, _, err := Tail(t.Context(), path, "", Position{}, sink)
if err == nil {
t.Fatal("a failing sink was not reported")
}
if pos.LastSeq != 2 {
t.Errorf("cursor seq %d past a refused event", pos.LastSeq)
}
if want := int64(len(h) + len(p2)); pos.Offset != want {
t.Errorf("cursor offset %d, want %d — the byte hint moved past a refused event", pos.Offset, want)
}
}