271 lines
8.9 KiB
Go
271 lines
8.9 KiB
Go
package runevents
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestMoneyCrossesTheSeamAsWholeMicroUSDRoundedUp(t *testing.T) {
|
|
// The ledger is a LOWER bound on what a provider billed, so a fraction of a micro-dollar must round
|
|
// AWAY from zero: rounding it down would let the seam report less than the engine already knows it
|
|
// owes. The float-noise cases are the ones that matter in practice — 0.114378 is not representable.
|
|
for _, c := range []struct {
|
|
usd float64
|
|
want int64
|
|
}{
|
|
{0, 0},
|
|
{-1, 0},
|
|
{0.114378, 114378},
|
|
{0.0000001, 1}, // a tenth of a micro-dollar still costs a whole one
|
|
{0.000001, 1}, // exactly one, and float noise must not turn it into two
|
|
{2.0, 2_000_000}, //
|
|
} {
|
|
if got := MicroUSD(c.usd); got != c.want {
|
|
t.Errorf("MicroUSD(%v) = %d, want %d", c.usd, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestALineIsTheSameBytesEveryTimeItIsRendered(t *testing.T) {
|
|
// The reader compares a re-read line against the sha256 it recorded, so two renders of one event must
|
|
// be byte-identical or the projection is quarantined (ErrPayloadConflict). This is why the OUTBOX
|
|
// keeps the bytes instead of the event.
|
|
at := time.Date(2026, 8, 14, 10, 0, 0, 0, time.UTC)
|
|
a, err := Line(7, TypeSpend, at, Spend{CommittedMicroUSD: 5})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, err := Line(7, TypeSpend, at, Spend{CommittedMicroUSD: 5})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(a) != string(b) {
|
|
t.Fatalf("two renders of one event differ:\n%s\n%s", a, b)
|
|
}
|
|
var env Envelope
|
|
if err := json.Unmarshal(a, &env); err != nil {
|
|
t.Fatalf("a line must be one JSON object: %v", err)
|
|
}
|
|
if env.Seq != 7 || env.Type != TypeSpend || !env.Time.Equal(at) {
|
|
t.Fatalf("envelope lost a field: %+v", env)
|
|
}
|
|
if strings.Contains(string(a), "\n") {
|
|
t.Fatalf("a line must not carry a newline of its own: %q", a)
|
|
}
|
|
}
|
|
|
|
func TestATornTailIsBlankedAndTheFileNeverShrinks(t *testing.T) {
|
|
// The only way a journal can end mid-line is a process that died while writing one: a line and its
|
|
// newline leave in ONE write(2).
|
|
//
|
|
// The repair PADS the fragment into a blank line instead of truncating it, and the file's LENGTH is
|
|
// the reason. A consumer of this journal seeds an attempt's starting offset from the file's SIZE
|
|
// (platform/internal/runs/spawn.go journalSize), torn fragment included; a repair that shortened the
|
|
// file below that offset would make its very next read report "journal shrank … it is not
|
|
// append-only" and quarantine a healthy resumed run. A run of spaces plus a newline is what that
|
|
// same reader skips as a blank line.
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, JournalFile)
|
|
torn := `{"seq":1}` + "\n" + `{"seq":2}` + "\n" + `{"seq":3,"ty`
|
|
if err := os.WriteFile(path, []byte(torn), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before := int64(len(torn))
|
|
|
|
j, err := OpenJournal(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := j.Append([]byte(`{"seq":1,"again":true}`)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
j.Close()
|
|
|
|
got, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if int64(len(got)) < before {
|
|
t.Fatalf("the journal shrank from %d to %d bytes — a reader holding a size-seeded offset reads that as corruption", before, len(got))
|
|
}
|
|
lines := strings.Split(strings.TrimSuffix(string(got), "\n"), "\n")
|
|
if len(lines) != 4 {
|
|
t.Fatalf("want the two complete lines, the blanked fragment and the new line, got %d: %q", len(lines), got)
|
|
}
|
|
if lines[0] != `{"seq":1}` || lines[1] != `{"seq":2}` {
|
|
t.Fatalf("the complete lines were damaged: %q", got)
|
|
}
|
|
if strings.TrimSpace(lines[2]) != "" {
|
|
t.Fatalf("the torn fragment must become a line the reader skips, got %q", lines[2])
|
|
}
|
|
if lines[3] != `{"seq":1,"again":true}` {
|
|
t.Fatalf("the new line is wrong: %q", lines[3])
|
|
}
|
|
}
|
|
|
|
func TestACompleteJournalIsNotTouchedOnOpen(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, JournalFile)
|
|
body := `{"seq":1}` + "\n" + `{"seq":2}` + "\n"
|
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
j, err := OpenJournal(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
j.Close()
|
|
got, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(got) != body {
|
|
t.Fatalf("a journal that ends in a newline must be left alone: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestAFileThatIsOneTornLineBecomesOneBlankLine(t *testing.T) {
|
|
// The degenerate torn tail: the process died on its very first line. Still padded, not emptied — the
|
|
// length must not go backwards (see the test above).
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, JournalFile)
|
|
if err := os.WriteFile(path, []byte(`{"seq":1,"ty`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
j, err := OpenJournal(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
j.Close()
|
|
got, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) < len(`{"seq":1,"ty`) {
|
|
t.Fatalf("the journal shrank: %q", got)
|
|
}
|
|
if strings.TrimSpace(string(got)) != "" || !strings.HasSuffix(string(got), "\n") {
|
|
t.Fatalf("want one blank, terminated line, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestAPartialLineTooLongToBeOursIsRefusedRatherThanCut(t *testing.T) {
|
|
// Events carry counters and ids, never text. A megabyte with no newline in it is not a line this
|
|
// engine wrote, and truncating a file we do not recognise is how data is destroyed.
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, JournalFile)
|
|
if err := os.WriteFile(path, []byte(strings.Repeat("x", maxPartialTail+16)), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := OpenJournal(dir); err == nil {
|
|
t.Fatal("want a refusal on a partial line longer than the reader's own cap")
|
|
}
|
|
st, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if st.Size() != int64(maxPartialTail+16) {
|
|
t.Fatalf("the refused file must be intact, size is now %d", st.Size())
|
|
}
|
|
}
|
|
|
|
func TestTheJournalIsCreatedInTheBooksDirectoryUnderTheRatifiedName(t *testing.T) {
|
|
// The name is the platform's constant too (ingest.JournalFile): a rename here is a coordinated change
|
|
// of both zones, not an engine decision.
|
|
dir := t.TempDir()
|
|
j, err := OpenJournal(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
j.Close()
|
|
if _, err := os.Stat(filepath.Join(dir, "events.jsonl")); err != nil {
|
|
t.Fatalf("want %s in the book's directory: %v", JournalFile, err)
|
|
}
|
|
}
|
|
|
|
// shortWriter accepts `limit` bytes and then refuses, the way a filesystem behaves when it runs out of
|
|
// room mid-line. It is the only way to reach this path without filling a real disk.
|
|
type shortWriter struct {
|
|
to *os.File
|
|
limit int
|
|
fail error
|
|
}
|
|
|
|
func (w *shortWriter) Write(p []byte) (int, error) {
|
|
if w.fail == nil {
|
|
return w.to.Write(p)
|
|
}
|
|
n := len(p)
|
|
if n > w.limit {
|
|
n = w.limit
|
|
}
|
|
written, err := w.to.Write(p[:n])
|
|
if err != nil {
|
|
return written, err
|
|
}
|
|
return written, w.fail
|
|
}
|
|
|
|
func TestAWriteThatStoppedPartWayIsResumed(t *testing.T) {
|
|
// The defect this pins: a failed append leaves a PREFIX of the line on the file, and the projection
|
|
// retries by re-offering the SAME line. Re-sending it whole concatenates a prefix and a copy — a
|
|
// malformed record that no later repair can recognise, sitting behind every future cursor. The retry
|
|
// must therefore send only the remainder.
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, JournalFile)
|
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
w := &shortWriter{to: f, limit: 7, fail: errors.New("no space left on device")}
|
|
j := &Journal{f: f, w: w}
|
|
|
|
line := []byte(`{"seq":2,"type":"unit_done"}`)
|
|
if err := j.Append(line); err == nil {
|
|
t.Fatal("the failing write must be reported")
|
|
}
|
|
// The disk frees up; the projection retries the same line.
|
|
w.fail = nil
|
|
if err := j.Append(line); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
j.Close()
|
|
|
|
got, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(got) != string(line)+"\n" {
|
|
t.Fatalf("the retry did not resume the interrupted line:\n got %q\n want %q", got, string(line)+"\n")
|
|
}
|
|
var env Envelope
|
|
if err := json.Unmarshal([]byte(strings.TrimSuffix(string(got), "\n")), &env); err != nil {
|
|
t.Fatalf("the repaired line is not a readable event: %v (%q)", err, got)
|
|
}
|
|
}
|
|
|
|
func TestANewLineIsRefusedWhileAnotherIsHalfWritten(t *testing.T) {
|
|
// Appending a different line after a half-written one splices two records into a malformed third.
|
|
// The projection cannot do it (a failed append leaves its cursor where it was), so this is an
|
|
// assertion rather than a recovery path — but an unasserted invariant is the kind that rots.
|
|
dir := t.TempDir()
|
|
f, err := os.OpenFile(filepath.Join(dir, JournalFile), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
w := &shortWriter{to: f, limit: 5, fail: errors.New("no space left on device")}
|
|
j := &Journal{f: f, w: w}
|
|
if err := j.Append([]byte(`{"seq":2}`)); err == nil {
|
|
t.Fatal("the failing write must be reported")
|
|
}
|
|
w.fail = nil
|
|
if err := j.Append([]byte(`{"seq":3}`)); err == nil {
|
|
t.Fatal("a different line must be refused while a remainder is outstanding")
|
|
}
|
|
j.Close()
|
|
}
|