679 lines
31 KiB
Go
679 lines
31 KiB
Go
package ingest
|
|
|
|
import (
|
|
"bytes"
|
|
"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)
|
|
}
|
|
}
|
|
|
|
// A journal that already holds SOMEBODY ELSE's stream at this attempt's starting offset — an
|
|
// operator running tmctl by hand in the book's directory is the realistic source.
|
|
//
|
|
// ⚠ Reproduced before the fix, and it was three defects in one line: the reader adopted the foreign
|
|
// handshake, materialized its events onto this attempt (a `ceiling` among them, which pauses a run
|
|
// nothing had paused), and then refused the attempt's OWN handshake for carrying a seq that was not
|
|
// 1 — quarantining the projection of a paying run for good. The cure is that the platform NAMES the
|
|
// stream before the unit starts (runs.engineStreamID) and hands the name over, so `want` is never
|
|
// empty and there is nothing left to adopt.
|
|
//
|
|
// Mutation caught: going back to `mine := want != "" && pos.Offset > 0`, and dropping the id from
|
|
// the spawn record so the reader is handed an empty one.
|
|
func TestAForeignStreamAtOurOffsetIsNeverAdopted(t *testing.T) {
|
|
foreign := hello(t, 1, "SOMEONE-ELSE") + line(t, 2, TypeCeiling, Ceiling{Halted: true, Scope: ScopeBook})
|
|
body := foreign + hello(t, 1, "ours") + progress(t, 2, 7)
|
|
path := journal(t, body)
|
|
|
|
// The attempt was admitted when the journal was empty, so its byte hint is 0 and it reads the
|
|
// foreign lines on the way to its own.
|
|
sink := &tailSink{}
|
|
pos, id, err := Tail(t.Context(), path, "ours", Position{}, sink)
|
|
if err != nil {
|
|
t.Fatalf("a foreign stream in the journal broke the read: %v", err)
|
|
}
|
|
if id != "ours" {
|
|
t.Fatalf("the reader ended up on stream %q", id)
|
|
}
|
|
if len(sink.hellos) != 0 {
|
|
t.Errorf("Begin was called for a stream this attempt did not own: %+v", sink.hellos)
|
|
}
|
|
if got := sink.seqs(); len(got) != 2 || got[0] != 1 || got[1] != 2 {
|
|
t.Fatalf("applied %v, want only our own 1,2 — a foreign ceiling event pauses a live run", got)
|
|
}
|
|
if pos.Offset != int64(len(body)) || pos.LastSeq != 2 {
|
|
t.Errorf("cursor %+v after the whole journal", pos)
|
|
}
|
|
|
|
// And the case the byte HINT makes dangerous. An attempt's hint is the journal's SIZE when it was
|
|
// admitted, so it is non-zero before the attempt has read a line — and if a foreign process is
|
|
// writing at that moment, the hint lands INSIDE that process's stream, past its handshake. There
|
|
// is then no hello above those lines to say whose they are.
|
|
//
|
|
// ⚠ This is why the reader asks about the CURSOR (`LastSeq > 0`, and the handshake is seq 1) and
|
|
// not about the hint. Under "my offset is non-zero ⇒ these lines are mine" the foreign
|
|
// continuation below is JUDGED against our cursor, its seq reads as a gap, and the projection of a
|
|
// perfectly healthy attempt is quarantined for good.
|
|
//
|
|
// Mutation caught: `mine := want != "" && pos.Offset > 0`.
|
|
before := hello(t, 1, "SOMEONE-ELSE") + progress(t, 2, 1)
|
|
body = before + progress(t, 3, 2) + hello(t, 1, "ours") + progress(t, 2, 9)
|
|
path = journal(t, body)
|
|
restarted := &tailSink{}
|
|
pos, _, err = Tail(t.Context(), path, "ours", Position{Offset: int64(len(before))}, restarted)
|
|
if err != nil {
|
|
t.Fatalf("a hint landing inside somebody else's stream broke the read: %v", err)
|
|
}
|
|
if got := restarted.seqs(); len(got) != 2 || got[0] != 1 || got[1] != 2 {
|
|
t.Errorf("applied %v, want only our own 1,2", got)
|
|
}
|
|
if pos.Offset != int64(len(body)) {
|
|
t.Errorf("the reader did not walk past the foreign lines: offset %d of %d", pos.Offset, len(body))
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// A handshake that is NOT OURS is skipped whatever it says. The journal is per BOOK, so a foreign
|
|
// process in it is ordinary — an operator's own tmctl, an older build with another major, a broken
|
|
// build writing an empty id or a seq that is not 1. A reader that ran this build's handshake rules
|
|
// over every hello BEFORE asking whose it was would turn a foreign line's defects into errors of
|
|
// OUR read, `quarantines()` calls those terminal, and the projection of the paying attempt that
|
|
// merely shares the file would stop for good (PD-214, PD-426).
|
|
//
|
|
// Mutation caught: moving the ownership test back below any one of the three checks.
|
|
func TestAForeignHandshakeThisBuildCannotReadIsSkippedRatherThanQuarantiningOurs(t *testing.T) {
|
|
for name, foreign := range map[string]string{
|
|
"another major": line(t, 1, TypeHello, Hello{StreamVersion: "9.9", EngineRunID: "SOMEONE-ELSE"}),
|
|
"no engine run id": line(t, 1, TypeHello, Hello{StreamVersion: StreamVersion, EngineRunID: ""}),
|
|
"a seq that is not 1": line(t, 7, TypeHello, Hello{StreamVersion: StreamVersion, EngineRunID: "SOMEONE-ELSE"}),
|
|
"all three at once": line(t, 16, TypeHello, Hello{StreamVersion: "0.1", EngineRunID: ""}),
|
|
"a legal one, for scale": hello(t, 1, "SOMEONE-ELSE"),
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
// The foreign stream, its own events, then ours. Our lines are the only ones that may land.
|
|
body := foreign + line(t, 2, TypeCeiling, Ceiling{Halted: true, Scope: ScopeBook}) +
|
|
hello(t, 1, "ours") + progress(t, 2, 4)
|
|
path := journal(t, body)
|
|
sink := &tailSink{}
|
|
pos, id, err := Tail(t.Context(), path, "ours", Position{}, sink)
|
|
if err != nil {
|
|
t.Fatalf("a foreign handshake broke the read of our own stream: %v", err)
|
|
}
|
|
if id != "ours" {
|
|
t.Fatalf("the reader ended up on stream %q", id)
|
|
}
|
|
if len(sink.hellos) != 0 {
|
|
t.Errorf("Begin was called for a stream this attempt did not own: %+v", sink.hellos)
|
|
}
|
|
if got := sink.seqs(); len(got) != 2 || got[0] != 1 || got[1] != 2 {
|
|
t.Fatalf("applied %v, want only our own 1,2 — the foreign ceiling event pauses a live run", got)
|
|
}
|
|
if pos.Offset != int64(len(body)) || pos.LastSeq != 2 {
|
|
t.Errorf("cursor %+v after the whole journal of %d bytes", pos, len(body))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The same three checks stay in force for OUR OWN handshake: a stream this build cannot read is still
|
|
// a stream it cannot read, and materializing it on a guess is worse than stopping. Ownership decides
|
|
// first only for lines that are NOT ours.
|
|
func TestOurOwnHandshakeThisBuildCannotReadStillStopsTheProjection(t *testing.T) {
|
|
for name, tc := range map[string]struct {
|
|
ours string
|
|
want error
|
|
}{
|
|
"another major": {line(t, 1, TypeHello, Hello{StreamVersion: "9.9", EngineRunID: "ours"}), ErrUnsupportedVersion},
|
|
"a seq that is not 1": {line(t, 16, TypeHello, Hello{StreamVersion: StreamVersion, EngineRunID: "ours"}), ErrBadHandshake},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
path := journal(t, tc.ours+progress(t, 2, 1))
|
|
sink := &tailSink{}
|
|
if _, _, err := Tail(t.Context(), path, "ours", Position{}, sink); !errors.Is(err, tc.want) {
|
|
t.Fatalf("our own unreadable handshake gave %v, want %v", err, tc.want)
|
|
}
|
|
if got := sink.seqs(); len(got) != 0 {
|
|
t.Errorf("applied %v after a handshake this build refused", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// With NO name yet the reader ADOPTS the first handshake it meets, and adoption validates in full —
|
|
// lowering the checks there would take a stream of a foreign major as our own, the same defect from
|
|
// the other side. Legacy shape (attempts predate the platform naming their stream), pinned because
|
|
// the ordering change above must not reach it.
|
|
//
|
|
// Mutation caught: applying the ownership shortcut when `want` is empty.
|
|
func TestAnAdoptedHandshakeIsStillValidatedInFull(t *testing.T) {
|
|
for name, tc := range map[string]struct {
|
|
first string
|
|
want error
|
|
}{
|
|
"another major": {line(t, 1, TypeHello, Hello{StreamVersion: "9.9", EngineRunID: "eng-1"}), ErrUnsupportedVersion},
|
|
"no engine run id": {line(t, 1, TypeHello, Hello{StreamVersion: StreamVersion, EngineRunID: ""}), ErrBadHandshake},
|
|
"a seq that is not 1": {line(t, 16, TypeHello, Hello{StreamVersion: StreamVersion, EngineRunID: "eng-1"}), ErrBadHandshake},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
path := journal(t, tc.first+progress(t, 2, 1))
|
|
sink := &tailSink{}
|
|
if _, _, err := Tail(t.Context(), path, "", Position{}, sink); !errors.Is(err, tc.want) {
|
|
t.Fatalf("adopting %s gave %v, want %v", name, err, tc.want)
|
|
}
|
|
if len(sink.hellos) != 0 {
|
|
t.Errorf("Begin was called for a handshake this build refused: %+v", sink.hellos)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A handshake that does not DECODE has no owner: the engine run id lives inside the payload, so the
|
|
// reader cannot skip it as somebody else's — and it must not, because a payload it cannot read on
|
|
// our own stream would then pass as foreign and our stream would go quiet with nothing saying why.
|
|
// The decode stays above the ownership question whether or not the reader knows its name.
|
|
//
|
|
// Mutation caught: moving the decode below the ownership test (which cannot compile as written,
|
|
// so the landing is a `default` that skips on decode failure when `want` is known).
|
|
func TestAHandshakeThatDoesNotDecodeIsRefusedEvenWhenTheReaderKnowsItsName(t *testing.T) {
|
|
broken := `{"seq":1,"type":"hello","data":"not an object"}` + "\n"
|
|
path := journal(t, broken+hello(t, 1, "ours")+progress(t, 2, 1))
|
|
for _, want := range []string{"", "ours"} {
|
|
sink := &tailSink{}
|
|
_, _, err := Tail(t.Context(), path, want, Position{}, sink)
|
|
if err == nil || !strings.Contains(err.Error(), "hello payload") {
|
|
t.Fatalf("want=%q: an undecodable handshake gave %v, want a refusal naming the payload", want, err)
|
|
}
|
|
if got := sink.seqs(); len(got) != 0 {
|
|
t.Errorf("want=%q: applied %v past a handshake that does not decode", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// THE PASS BOUNDARY, which is the shape drainJournal actually runs in: the sweep reads what the
|
|
// journal has gained, persists the cursor, and comes back a minute later with nothing but that
|
|
// cursor. Ownership is not part of it — `mine` is re-derived from `LastSeq > 0` — so a reader that
|
|
// walked PAST a foreign handshake would hand the next pass a cursor claiming a region that belongs
|
|
// to somebody else: the stranger's next event lands on this attempt (a `ceiling` pauses a paying
|
|
// run) and its seq meeting ours quarantines a healthy projection. The reader stops at the foreign
|
|
// handshake instead and leaves the byte hint on it.
|
|
//
|
|
// Mutation caught: returning `held` (the advanced offset) from the foreign-handshake branch;
|
|
// dropping the `pos.LastSeq > 0` or the `named` half of the stop.
|
|
func TestAForeignStreamDoesNotOwnOurAttemptOnTheNextPass(t *testing.T) {
|
|
for name, foreign := range map[string]string{
|
|
"a legal foreign handshake": hello(t, 1, "SOMEONE-ELSE"),
|
|
"a foreign major": line(t, 1, TypeHello, Hello{StreamVersion: "9.9", EngineRunID: "SOMEONE-ELSE"}),
|
|
"one with no id at all": line(t, 1, TypeHello, Hello{StreamVersion: StreamVersion, EngineRunID: ""}),
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
runOne(t, hello(t, 1, "ours")+progress(t, 2, 1), foreign)
|
|
// The BOUNDARY of the same property, and it is the shape П1 describes: our engine wrote
|
|
// its handshake and died, so exactly ONE line of ours is applied when the stranger's
|
|
// handshake arrives. A guard that starts one line later reads that stranger's region as
|
|
// ours and lets its halting `ceiling` land on a paying attempt.
|
|
runOne(t, hello(t, 1, "ours"), foreign)
|
|
})
|
|
}
|
|
}
|
|
|
|
// runOne walks the two passes of the property for one journal prefix of ours.
|
|
func runOne(t *testing.T, ours, foreign string) {
|
|
t.Helper()
|
|
// Our own prefix is one line per seq, numbered from 1, so its line count IS the cursor the park
|
|
// must leave behind — which is the point of running this with a one-line prefix as well as a
|
|
// two-line one: the guard's boundary is at the first applied line, not at the second.
|
|
mine := int64(strings.Count(ours, "\n"))
|
|
{
|
|
sink := &tailSink{}
|
|
pos, _, err := Tail(t.Context(), journal(t, ours+foreign), "ours", Position{}, sink)
|
|
// The park is an ANSWER, not a failure: our own lines are applied and the stranger's are
|
|
// not, and the caller is told which of the two silences this is.
|
|
if !errors.Is(err, ErrForeignStreamAhead) {
|
|
t.Fatalf("a foreign handshake answered %v, want ErrForeignStreamAhead", err)
|
|
}
|
|
if got := sink.seqs(); int64(len(got)) != mine {
|
|
t.Fatalf("pass one applied %v, want our own %d line(s)", got, mine)
|
|
}
|
|
if pos.Offset != int64(len(ours)) || pos.LastSeq != mine {
|
|
t.Fatalf("pass one left the cursor at %+v, want it on the foreign handshake at offset %d with our seq %d",
|
|
pos, len(ours), mine)
|
|
}
|
|
// Pass two: the stranger writes on. Its ceiling would pause OUR run; its seq 2 is a
|
|
// different payload at the number our cursor stands on.
|
|
// The stranger's own continuation: its seq 2 both as an event that would HALT our run and as a
|
|
// payload at a number our cursor may stand on, plus one past it.
|
|
for _, next := range []string{
|
|
line(t, 2, TypeCeiling, Ceiling{Halted: true, Scope: ScopeBook}),
|
|
progress(t, 2, 9),
|
|
progress(t, 3, 9),
|
|
} {
|
|
sink := &tailSink{}
|
|
pos2, _, err := Tail(t.Context(), journal(t, ours+foreign+next), "ours", pos, sink)
|
|
if !errors.Is(err, ErrForeignStreamAhead) {
|
|
t.Fatalf("the stranger's line answered %v, want the same park (never a quarantine)", err)
|
|
}
|
|
if got := sink.seqs(); len(got) != 0 {
|
|
t.Fatalf("the stranger's line was applied to our attempt: %v", got)
|
|
}
|
|
if pos2.Offset != pos.Offset || pos2.LastSeq != pos.LastSeq || !bytes.Equal(pos2.LastHash, pos.LastHash) {
|
|
t.Fatalf("the cursor moved over a region that is not ours: %+v, want %+v", pos2, pos)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The stop is for the attempt whose stream the platform NAMED, and only once our own lines are in:
|
|
// before that, our handshake may still be further down the file (a foreign process wrote at the byte
|
|
// this attempt was admitted on), and stopping would strand a run whose stream is right there. The
|
|
// nameless legacy path walks past instead, and TestAnotherAttemptsStreamInTheSameJournalIsSkipped is
|
|
// where that half lives.
|
|
func TestAForeignStreamBeforeOursIsWalkedPastRatherThanStoppedOn(t *testing.T) {
|
|
foreign := hello(t, 1, "SOMEONE-ELSE") + line(t, 2, TypeCeiling, Ceiling{Halted: true, Scope: ScopeBook})
|
|
body := foreign + hello(t, 1, "ours") + progress(t, 2, 7)
|
|
sink := &tailSink{}
|
|
pos, id, err := Tail(t.Context(), journal(t, body), "ours", Position{}, sink)
|
|
if err != nil {
|
|
t.Fatalf("a foreign stream before ours: %v", err)
|
|
}
|
|
if id != "ours" || pos.Offset != int64(len(body)) || pos.LastSeq != 2 {
|
|
t.Fatalf("the reader ended on %q at %+v, want it past the stranger and on our seq 2", id, pos)
|
|
}
|
|
if got := sink.seqs(); len(got) != 2 || got[0] != 1 || got[1] != 2 {
|
|
t.Fatalf("applied %v, want only our own 1,2", got)
|
|
}
|
|
}
|
|
|
|
// The park is REPORTED, not swallowed: the caller has to tell "our region ended here" from "caught
|
|
// up", because the two leave the identical cursor and only one of them means the projection has
|
|
// stopped speaking. A reader that returned nil here left a paying run's screen frozen with no error,
|
|
// no quarantine and nothing in any listing.
|
|
//
|
|
// Mutation caught: returning nil instead of ErrForeignStreamAhead from the park.
|
|
func TestTheParkIsReportedSoTheCallerCanTellItFromBeingCaughtUp(t *testing.T) {
|
|
ours := hello(t, 1, "ours") + progress(t, 2, 1)
|
|
path := journal(t, ours+hello(t, 1, "SOMEONE-ELSE")+progress(t, 2, 9))
|
|
sink := &tailSink{}
|
|
pos, _, err := Tail(t.Context(), path, "ours", Position{}, sink)
|
|
if !errors.Is(err, ErrForeignStreamAhead) {
|
|
t.Fatalf("the park answered %v, want ErrForeignStreamAhead", err)
|
|
}
|
|
if pos.Offset != int64(len(ours)) || pos.LastSeq != 2 {
|
|
t.Fatalf("the park moved the cursor: %+v, want it on the foreign handshake at offset %d", pos, len(ours))
|
|
}
|
|
if got := sink.seqs(); len(got) != 2 {
|
|
t.Fatalf("applied %v, want our own two lines and nothing of the stranger's", got)
|
|
}
|
|
// Being caught up is the other answer, and it is not this one.
|
|
if _, _, err := Tail(t.Context(), journal(t, ours), "ours", Position{}, &tailSink{}); err != nil {
|
|
t.Fatalf("a journal with nothing but our own lines answered %v, want nil", err)
|
|
}
|
|
}
|