textmachine/platform/internal/books/books_test.go

1099 lines
42 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package books
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"io"
"log/slog"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5"
"textmachine/platform/internal/ingest"
"textmachine/platform/internal/jobs"
"textmachine/platform/internal/pgstore"
"textmachine/platform/internal/runner"
)
// Intake is a walk with three ends — parsed, rejected, or removed — and every one of them is reached
// by a DIFFERENT process from the one that started it. That is what these tests are about: the row,
// the directory and the file agree at each step, and no step is reachable only from the request that
// began it.
func intakeDB(t *testing.T) (*pgstore.Store, context.Context) {
t.Helper()
admin := os.Getenv("TM_PLATFORM_TEST_DSN")
if admin == "" {
t.Skip("TM_PLATFORM_TEST_DSN not set: the intake's statuses need a live Postgres")
}
ctx := t.Context()
var suffix [6]byte
if _, err := rand.Read(suffix[:]); err != nil {
t.Fatal(err)
}
name := "tm_books_test_" + hex.EncodeToString(suffix[:])
conn, err := pgx.Connect(ctx, admin)
if err != nil {
t.Fatalf("connect: %v", err)
}
if _, err := conn.Exec(ctx, "create database "+pgx.Identifier{name}.Sanitize()); err != nil {
conn.Close(ctx)
t.Skipf("cannot create a scratch database (%v): grant CREATEDB or point the DSN at one", err)
}
t.Cleanup(func() {
c, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
_, _ = conn.Exec(c, "drop database if exists "+pgx.Identifier{name}.Sanitize()+" with (force)")
conn.Close(c)
})
u, err := url.Parse(admin)
if err != nil {
t.Fatal(err)
}
u.Path = "/" + name
if err := pgstore.Migrate(ctx, u.String()); err != nil {
t.Fatalf("migrate: %v", err)
}
s, err := pgstore.Open(ctx, u.String())
if err != nil {
t.Fatal(err)
}
t.Cleanup(s.Close)
if _, err := s.Pool().Exec(ctx, `insert into users (id, email) values ('u1','u1@example.org')`); err != nil {
t.Fatal(err)
}
return s, ctx
}
// fakeEngine stands in for `tmctl manifest`. It models the two answers that lead to different ends —
// a manifest, and a process that RAN and refused — because a fake that can only succeed proves
// nothing about the branch that rejects a book.
type fakeEngine struct {
mu sync.Mutex
manifest ingest.Manifest
err error
calls int
// onManifest runs INSIDE the call, which is where the deadline the caller granted is visible.
onManifest func(ctx context.Context)
}
func (f *fakeEngine) Manifest(ctx context.Context, _, _ string) (ingest.Manifest, error) {
f.mu.Lock()
f.calls++
hook := f.onManifest
m, err := f.manifest, f.err
f.mu.Unlock()
if hook != nil {
hook(ctx)
}
return m, err
}
func (f *fakeEngine) set(m ingest.Manifest, err error) {
f.mu.Lock()
defer f.mu.Unlock()
f.manifest, f.err = m, err
}
func (f *fakeEngine) called() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.calls
}
// refusal is what the engine's own "no" looks like from here: a process that ran and exited non-zero
// (backend cmd/tmctl maps every failure that is not one of its two sentinels onto exit 1). Produced
// by running a real command rather than by hand-building an ExitError, so the type the classifier
// keys on is the type a real refusal carries.
func refusal(t *testing.T) error {
t.Helper()
err := exec.CommandContext(t.Context(), "/bin/sh", "-c", "exit 1").Run()
if err == nil {
t.Fatal("the refusal helper did not fail")
}
return err
}
type fixture struct {
svc *Service
store *pgstore.Store
engine *fakeEngine
ctx context.Context
root string
now time.Time
}
func newFixture(t *testing.T) *fixture {
t.Helper()
store, ctx := intakeDB(t)
root := t.TempDir()
eng := &fakeEngine{manifest: ingest.Manifest{
Version: "tm-manifest-v2", ChaptersTotal: 500, UnitsTotal: 950,
SourceSHA256: strings.Repeat("ab", 32), ChunkerVersion: "chunk-2026.07",
}}
now := time.Now().UTC().Truncate(time.Millisecond)
f := &fixture{store: store, engine: eng, ctx: ctx, root: root, now: now}
f.svc = &Service{
Store: store,
Engine: eng,
Cfg: Config{BooksDir: root, EngineBinary: "/opt/engine/2026.08.01/tmctl"},
Now: func() time.Time { return f.now },
}
return f
}
// accept runs one upload of the given bytes and returns the book.
func (f *fixture) accept(t *testing.T, filename, body string) pgstore.Book {
t.Helper()
b, err := f.svc.Accept(f.ctx, Intake{UserID: "u1", SourceLang: "zh", TargetLang: "ru",
Genre: "xianxia", Filename: filename, File: strings.NewReader(body)})
if err != nil {
t.Fatal(err)
}
return b
}
// provision puts an engine configuration in the book's directory. Who does this in production is the
// open question the intake stops at (ErrNotProvisioned); here it stands for the operator.
func (f *fixture) provision(t *testing.T, b pgstore.Book) string {
t.Helper()
dir := filepath.Join(f.root, b.ID)
if err := os.WriteFile(filepath.Join(dir, runner.ConfigFile), []byte("book_id: x\n"), 0o600); err != nil {
t.Fatal(err)
}
return dir
}
func (f *fixture) libraryRevision(t *testing.T) int64 {
t.Helper()
lib, err := f.store.ListBooks(f.ctx, "u1", 50, "")
if err != nil {
t.Fatal(err)
}
return lib.Revision
}
func (f *fixture) card(t *testing.T, id string) pgstore.Book {
t.Helper()
b, _, err := f.store.GetBook(f.ctx, "u1", id)
if err != nil {
t.Fatal(err)
}
return b
}
// The whole walk, and the assertion is that each status is written by somebody: the row exists while
// the file arrives, the engine turns it into a chapter tree, and the library reads the result exactly
// as it reads a book the dev intake put there.
func TestAnUploadedBookWalksFromUploadingToNotStarted(t *testing.T) {
f := newFixture(t)
book := f.accept(t, "蛊真人.txt", "первая глава\fвторая глава")
if book.Status != "parsing" {
t.Fatalf("the response says %q; the file is in, so the book is being parsed", book.Status)
}
if book.Title != "蛊真人" {
t.Fatalf("title %q, want the name the user gave the file", book.Title)
}
// Characters, not bytes: the contract's figure is a size in characters and this source is Cyrillic.
if book.CharacterCount != 25 {
t.Fatalf("character_count %d, want 25 runes", book.CharacterCount)
}
f.provision(t, book)
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
t.Fatal(err)
}
got := f.card(t, book.ID)
if got.Status != "not_started" || got.ChapterCount != 500 {
t.Fatalf("after parsing: %q with %d chapters, want not_started with 500", got.Status, got.ChapterCount)
}
// The manifest's identity is kept: it says WHICH cut of WHICH bytes produced that chapter count.
var sha []byte
var chunker string
if err := f.store.Pool().QueryRow(f.ctx,
`select source_sha256, chunker_version from books where id = $1`, book.ID).Scan(&sha, &chunker); err != nil {
t.Fatal(err)
}
if len(sha) != 32 || chunker != "chunk-2026.07" {
t.Fatalf("the manifest's provenance was not recorded: sha %d bytes, chunker %q", len(sha), chunker)
}
}
// `uploading` is a state something can observe, and that is the whole reason the row is written
// before the body is read: a second reader of the library sees the book while its file is still on
// the wire.
func TestABookIsVisibleAsUploadingWhileItsFileIsStillArriving(t *testing.T) {
f := newFixture(t)
reading := make(chan struct{})
release := make(chan struct{})
body := &blockingReader{data: "the whole book", gate: reading, release: release}
done := make(chan error, 1)
go func() {
_, err := f.svc.Accept(f.ctx, Intake{UserID: "u1", SourceLang: "zh", TargetLang: "ru",
Filename: "book.txt", File: body})
done <- err
}()
<-reading
lib, err := f.store.ListBooks(f.ctx, "u1", 10, "")
if err != nil {
t.Fatal(err)
}
if len(lib.Books) != 1 || lib.Books[0].Status != "uploading" {
t.Fatalf("the library shows %+v while the file is arriving, want one book in uploading", lib.Books)
}
close(release)
if err := <-done; err != nil {
t.Fatal(err)
}
if got := f.card(t, lib.Books[0].ID); got.Status != "parsing" {
t.Fatalf("after the body arrived the book is %q, want parsing", got.Status)
}
}
// blockingReader hands over its first chunk and then waits, so a test can look at the world in the
// middle of an upload.
type blockingReader struct {
data string
gate chan struct{}
release chan struct{}
sent bool
}
func (b *blockingReader) Read(p []byte) (int, error) {
if !b.sent {
b.sent = true
n := copy(p, b.data)
close(b.gate)
<-b.release
return n, nil
}
return 0, io.EOF
}
// An upload that did not finish leaves NOTHING: not a row the user cannot delete — the contract has
// no handle for that — and not a directory nothing points at.
//
// The client going AWAY is the ordinary way to get here, so that is what the test does: the request's
// own context is cancelled before the body fails. Cleaning up on it would then clean up nothing.
func TestAnAbandonedUploadLeavesNeitherARowNorADirectory(t *testing.T) {
f := newFixture(t)
ctx, cancel := context.WithCancel(f.ctx)
defer cancel()
_, err := f.svc.Accept(ctx, Intake{UserID: "u1", SourceLang: "zh", TargetLang: "ru",
Filename: "book.txt", File: &failingReader{cancel: cancel}})
if err == nil {
t.Fatal("an upload whose body failed was accepted")
}
lib, err := f.store.ListBooks(f.ctx, "u1", 10, "")
if err != nil {
t.Fatal(err)
}
if len(lib.Books) != 0 {
t.Fatalf("the library holds %+v after a failed upload", lib.Books)
}
entries, err := os.ReadDir(f.root)
if err != nil {
t.Fatal(err)
}
// Everything except the storage marker, which is the root's own and outlives every book: it is
// written by the first upload and never removed, because its absence is what tells the intake the
// volume is gone (FP5-10).
var left []string
for _, e := range entries {
if e.Name() != StorageMarker {
left = append(left, e.Name())
}
}
if len(left) != 0 {
t.Fatalf("the books root holds %v after a failed upload", left)
}
}
// failingReader is a client that goes away mid-body. gate/release, when set, let a test look at the
// world while the upload is still in flight.
type failingReader struct {
cancel context.CancelFunc
gate chan struct{}
release chan struct{}
}
func (f failingReader) Read([]byte) (int, error) {
if f.gate != nil {
close(f.gate)
<-f.release
}
if f.cancel != nil {
f.cancel()
}
return 0, errors.New("the client went away")
}
// A source the engine READ and refused is terminal, and its bytes go: nothing re-parses them, no
// path downloads them, and an authenticated route that writes to an operator's disk and never
// removes anything is a hole this one must not open.
func TestASourceTheEngineRefusesIsRejectedAndItsFileRemoved(t *testing.T) {
f := newFixture(t)
book := f.accept(t, "not-a-book.pdf", "%PDF-1.7 …")
dir := f.provision(t, book)
f.engine.set(ingest.Manifest{}, refusal(t))
// ⚠ The FIRST refusal is not terminal, and that is the correction the cross-family review forced:
// the engine maps every failure it has onto exit 1 — a full disk, an operator's own tmctl holding
// the project lock, a claim taken over mid-parse — so rejecting on the first one turned any of
// those into the irreversible deletion of a user's upload. It spends an attempt instead.
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
t.Fatal(err)
}
if got := f.card(t, book.ID); got.Status != "parsing" {
t.Fatalf("one refusal made the book %q; a single exit 1 must not be terminal", got.Status)
}
if _, err := os.Stat(filepath.Join(dir, SourceName+".pdf")); err != nil {
t.Fatalf("the source was deleted on the first refusal: %v", err)
}
for range parseAttempts {
f.now = f.now.Add(claimGrace + time.Minute)
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
t.Fatal(err)
}
}
got := f.card(t, book.ID)
if got.Status != "rejected" {
t.Fatalf("a source the engine refused is %q, want rejected", got.Status)
}
if _, err := os.Stat(dir); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("the rejected book's directory is still there: %v", err)
}
// The reason is the platform's own word and is kept for an operator — contract v0 has no field
// for it, so it is deliberately not on the wire.
var reason string
if err := f.store.Pool().QueryRow(f.ctx,
`select reject_reason from books where id = $1`, book.ID).Scan(&reason); err != nil {
t.Fatal(err)
}
if reason != ReasonSourceUnreadable {
t.Fatalf("reject_reason %q, want %q", reason, ReasonSourceUnreadable)
}
}
// An engine that cannot be RUN is this host's problem and not this book's. The parse is retried —
// and bounded, because a book that waits forever is the failure this must not trade for (PD-162).
func TestAnEngineThatCannotBeRunIsRetriedAndThenGivenUpOn(t *testing.T) {
f := newFixture(t)
book := f.accept(t, "book.txt", "text")
f.provision(t, book)
f.engine.set(ingest.Manifest{}, &exec.Error{Name: "tmctl", Err: os.ErrNotExist})
// ⚠ The clock moves between attempts because the CLAIM is what spaces them: a failed parse keeps
// it, so the book is not claimable again until the grace passes. Without that the five attempts
// would burn in five ticks of the sweep — 75 seconds — instead of in the time this budget is
// meant to bound, and an engine outage of a couple of minutes would reject every book waiting.
for i := 1; i < parseAttempts; i++ {
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
t.Fatal(err)
}
if got := f.card(t, book.ID); got.Status != "parsing" {
t.Fatalf("attempt %d left the book %q, want it still parsing", i, got.Status)
}
// The very next call, before the grace, must do NOTHING at all.
before := f.engine.called()
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
t.Fatal(err)
}
if f.engine.called() != before {
t.Fatalf("attempt %d was retried before the grace passed", i)
}
f.now = f.now.Add(claimGrace + time.Minute)
}
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
t.Fatal(err)
}
got := f.card(t, book.ID)
if got.Status != "rejected" {
t.Fatalf("after %d attempts the book is %q, want rejected", parseAttempts, got.Status)
}
// The file STAYS: the fault was the deployment's, and deleting a user's upload because this host
// was misconfigured is not a decision to make on their behalf.
if _, err := os.Stat(filepath.Join(f.root, book.ID, SourceName+".txt")); err != nil {
t.Fatalf("the source of a book rejected for a deployment fault was removed: %v", err)
}
}
// The seam of the open question: a book directory with no engine configuration cannot be parsed, and
// the platform does not write that file (D39.110 §2b).
//
// ⚠ This test asserts the OPPOSITE of what it did before the cross-family review, and the reversal is
// deliberate. It used to pin "five attempts, then rejected" — which meant that on any deployment
// without an answer to the `book.yaml` question (that is, every deployment today) EVERY upload was
// destroyed within the hour, over a gap the user cannot see and an operator can fix in a second.
// A missing configuration is a DEPLOYMENT state, so the book waits in `parsing`, visibly, with its
// source intact, and one file dropped in is all it takes.
func TestABookWithNoEngineConfigurationWaitsRatherThanDies(t *testing.T) {
f := newFixture(t)
book := f.accept(t, "book.txt", "text")
for range parseAttempts * 2 {
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
t.Fatal(err)
}
f.now = f.now.Add(claimGrace + time.Minute) // the claim spaces the retries
}
if f.engine.called() != 0 {
t.Fatalf("the engine was asked %d times about a book it has no configuration for", f.engine.called())
}
var status, reason string
if err := f.store.Pool().QueryRow(f.ctx,
`select status, reject_reason from books where id = $1`, book.ID).Scan(&status, &reason); err != nil {
t.Fatal(err)
}
if status != "parsing" || reason != "" {
t.Fatalf("a book with no configuration is %q/%q; it must WAIT, not be destroyed", status, reason)
}
if _, err := os.Stat(filepath.Join(f.root, book.ID, SourceName+".txt")); err != nil {
t.Fatalf("the source of a book waiting for its configuration was removed: %v", err)
}
// And the moment somebody provisions it, the very next pass parses it.
f.provision(t, pgstore.Book{ID: book.ID})
f.now = f.now.Add(claimGrace + time.Minute)
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
t.Fatal(err)
}
if got := f.card(t, book.ID); got.Status != "not_started" {
t.Fatalf("after provisioning the book is %q, want not_started", got.Status)
}
}
// Two parsers on one book would be two writers of the engine's project file. The claim is a
// compare-and-set, and losing that race is the ordinary case rather than a failure.
func TestOnlyOneOfTwoParsersOfOneBookAsksTheEngine(t *testing.T) {
f := newFixture(t)
book := f.accept(t, "book.txt", "text")
f.provision(t, book)
var wg sync.WaitGroup
errs := make([]error, 8)
for i := range errs {
wg.Add(1)
go func() {
defer wg.Done()
errs[i] = f.svc.Parse(f.ctx, book.ID)
}()
}
wg.Wait()
for i, err := range errs {
if err != nil {
t.Errorf("parser %d: %v", i, err)
}
}
if n := f.engine.called(); n != 1 {
t.Fatalf("the engine was asked %d times for one book, want exactly 1", n)
}
}
// The backstop: both halves of a stuck intake are reachable only from the sweep, because the process
// that could have finished them is the one that died.
func TestTheSweepFinishesAWalkNobodyElseCan(t *testing.T) {
f := newFixture(t)
stale := f.accept(t, "abandoned.txt", "half a file")
// This one never got past `uploading`: its request is gone.
if _, err := f.store.Pool().Exec(f.ctx,
`update books set status = 'uploading' where id = $1`, stale.ID); err != nil {
t.Fatal(err)
}
waiting := f.accept(t, "waiting.txt", "text")
f.provision(t, waiting)
f.now = f.now.Add(2 * time.Hour) // past both graces
if err := f.svc.Sweep(f.ctx); err != nil {
t.Fatal(err)
}
lib, err := f.store.ListBooks(f.ctx, "u1", 10, "")
if err != nil {
t.Fatal(err)
}
if len(lib.Books) != 1 || lib.Books[0].ID != waiting.ID {
t.Fatalf("the library holds %+v, want only the book that finished uploading", lib.Books)
}
if got := f.card(t, waiting.ID); got.Status != "not_started" {
t.Fatalf("the waiting book is %q, want the sweep to have parsed it", got.Status)
}
if _, err := os.Stat(filepath.Join(f.root, stale.ID)); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("the abandoned upload's directory survived the sweep: %v", err)
}
}
// Adding a book must MOVE the library's revision. The contract asks a client to drop a read whose
// revision is not above what it applied, so a book that joins at a number the library already had is
// a book the screen that uploaded it never sees (register row PD-122).
func TestABookThatJoinsTheLibraryMovesItsRevision(t *testing.T) {
f := newFixture(t)
before, err := f.store.ListBooks(f.ctx, "u1", 10, "")
if err != nil {
t.Fatal(err)
}
first := f.accept(t, "one.txt", "text")
after, err := f.store.ListBooks(f.ctx, "u1", 10, "")
if err != nil {
t.Fatal(err)
}
if after.Revision <= before.Revision {
t.Fatalf("the library revision stayed at %d after a book joined it", after.Revision)
}
f.provision(t, first)
if err := f.svc.Parse(f.ctx, first.ID); err != nil {
t.Fatal(err)
}
parsed, err := f.store.ListBooks(f.ctx, "u1", 10, "")
if err != nil {
t.Fatal(err)
}
if parsed.Revision <= after.Revision {
t.Fatalf("the library revision stayed at %d when a book's status changed", parsed.Revision)
}
}
// A book the DEV intake registered carries a workdir the operator chose — their own project
// directory, with their own source in it. No path here may remove one.
func TestADirectoryOutsideTheBooksRootIsNeverRemoved(t *testing.T) {
f := newFixture(t)
outside := t.TempDir()
if err := os.WriteFile(filepath.Join(outside, "irreplaceable.txt"), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
f.svc.removeDir(outside)
if _, err := os.Stat(filepath.Join(outside, "irreplaceable.txt")); err != nil {
t.Fatalf("a directory outside the books root was removed: %v", err)
}
for _, dir := range []string{f.root, filepath.Join(f.root, ".."), "", "/"} {
if f.svc.owns(dir) {
t.Errorf("owns(%q) is true; only paths BELOW the root are this service's", dir)
}
}
if !f.svc.owns(filepath.Join(f.root, "bk_1")) {
t.Error("a book directory under the root is not recognised as this service's")
}
}
// The two things the intake takes from a name it did not write.
func TestWhatTheUploadedFileNameIsAllowedToDecide(t *testing.T) {
titles := []struct{ in, want string }{
{"蛊真人.epub", "蛊真人"},
{"/etc/passwd", "passwd"},
{"../../secret.txt", "secret"},
{"", ""},
{"no-extension", "no-extension"},
{".hidden", ".hidden"},
{strings.Repeat("x", 400) + ".txt", strings.Repeat("x", maxTitle)},
{"line\nbreak.txt", "linebreak"},
}
for _, tc := range titles {
if got := titleFrom(tc.in); got != tc.want {
t.Errorf("titleFrom(%q) = %q, want %q", tc.in, got, tc.want)
}
}
exts := []struct{ in, want string }{
{"book.epub", ".epub"},
{"BOOK.EPUB", ".epub"},
{"book.txt", ".txt"},
{"book", ".txt"},
{"book.", ".txt"},
{"book.tar.gz", ".gz"},
{"evil.sh;rm -rf", ".txt"},
{"evil../../x", ".txt"},
{"book.verylongextension", ".txt"},
}
for _, tc := range exts {
if got := extensionOf(tc.in); got != tc.want {
t.Errorf("extensionOf(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
// The languages are CODES and they enter here from a browser.
func TestALanguageThatIsNotACodeIsRefused(t *testing.T) {
f := newFixture(t)
for _, bad := range []string{"", "Chinese", "zh_CN", "ZH", "../zh", "z"} {
_, err := f.svc.Accept(f.ctx, Intake{UserID: "u1", SourceLang: bad, TargetLang: "ru",
Filename: "b.txt", File: strings.NewReader("x")})
if !errors.Is(err, ErrBadIntake) {
t.Errorf("source_lang %q: %v, want ErrBadIntake", bad, err)
}
}
if entries, err := os.ReadDir(f.root); err != nil || len(entries) != 0 {
t.Fatalf("a refused intake left %v in the books root (%v)", entries, err)
}
}
// The engine dispatches its reader by EXTENSION (backend/internal/chunk/ingest.go: `.epub` → the
// epub reader, anything else → plain text), so the extension has to survive intake or an EPUB is
// read as text and the book comes out as one chapter of markup.
func TestTheUploadedFilesExtensionSurvivesIntake(t *testing.T) {
f := newFixture(t)
book := f.accept(t, "蛊真人.EPUB", "PK\x03\x04 not really an epub")
if _, err := os.Stat(filepath.Join(f.root, book.ID, SourceName+".epub")); err != nil {
t.Fatalf("the source was not written as source.epub: %v", err)
}
}
// The zone's log discipline (ENGINEERING_STANDARDS §Наблюдаемость, PD-3): an INFO line does not
// identify a user's library. The intake writes several, so this asserts the whole walk at once.
func TestNoBookIdentifierReachesAnInfoLine(t *testing.T) {
f := newFixture(t)
var buf bytes.Buffer
f.svc.Log = slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
book := f.accept(t, "book.txt", "text")
f.provision(t, book)
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
t.Fatal(err)
}
f.now = f.now.Add(2 * time.Hour)
if err := f.svc.Sweep(f.ctx); err != nil {
t.Fatal(err)
}
for _, line := range strings.Split(strings.TrimSpace(buf.String()), "\n") {
if line == "" {
continue
}
var rec map[string]any
if err := json.Unmarshal([]byte(line), &rec); err != nil {
t.Fatal(err)
}
if rec["level"] != "INFO" && rec["level"] != "WARN" {
continue // an ERROR may carry the path an operator needs — the open class of PD-139
}
if strings.Contains(line, book.ID) {
t.Errorf("a book id reached a %s line: %s", rec["level"], line)
}
}
}
// The library's revision may never go BACKWARDS, and an abandoned upload is the one deletion this
// zone has: the book that joined at the top of the account's scale is removed when its request goes
// away, and the maximum would fall with it. A client that had applied the higher number must then, by
// the contract, drop every later read of the library — the screen keeps rendering a book that is gone.
func TestCancellingAnUploadDoesNotWindTheLibraryBack(t *testing.T) {
f := newFixture(t)
kept := f.accept(t, "kept.txt", "text")
f.provision(t, kept)
before, err := f.store.ListBooks(f.ctx, "u1", 10, "")
if err != nil {
t.Fatal(err)
}
// The number that matters is the one a CLIENT could have seen — the library WHILE the doomed
// upload was in it, not the library before it arrived. Read from inside the upload, which is
// exactly where a second tab would read it.
reading, release := make(chan struct{}), make(chan struct{})
ctx, cancel := context.WithCancel(f.ctx)
defer cancel()
done := make(chan error, 1)
go func() {
_, err := f.svc.Accept(ctx, Intake{UserID: "u1", SourceLang: "zh", TargetLang: "ru",
Filename: "abandoned.txt",
File: &failingReader{cancel: cancel, gate: reading, release: release}})
done <- err
}()
<-reading
during, err := f.store.ListBooks(f.ctx, "u1", 10, "")
if err != nil {
t.Fatal(err)
}
if during.Revision <= before.Revision {
t.Fatalf("the upload joined the library at revision %d, which is not above %d",
during.Revision, before.Revision)
}
close(release)
if err := <-done; err == nil {
t.Fatal("an upload whose body failed was accepted")
}
after, err := f.store.ListBooks(f.ctx, "u1", 10, "")
if err != nil {
t.Fatal(err)
}
if after.Revision < during.Revision {
t.Fatalf("the library revision went back from %d to %d when the upload was cancelled",
during.Revision, after.Revision)
}
if len(after.Books) != 1 || after.Books[0].ID != kept.ID {
t.Fatalf("the library holds %+v, want only the book that stayed", after.Books)
}
}
// The sweep decides from a snapshot too, and its cure is the same: the directory of a book goes only
// when the ROW went with it. A request that finished while the sweep was looking moves the book to
// `parsing`, DeleteUpload's status guard then refuses — and removing the directory anyway would
// delete the source of a live book, leaving one that can only end rejected and an hour of somebody's
// upload gone without a word.
func TestTheSweepNeverRemovesTheSourceOfABookItCouldNotDelete(t *testing.T) {
f := newFixture(t)
book := f.accept(t, "book.txt", "text") // already `parsing`: its upload finished
f.svc.abandon(f.ctx, book.ID, filepath.Join(f.root, book.ID))
if got := f.card(t, book.ID); got.Status != "parsing" {
t.Fatalf("the row is %q, want the parsing book it became", got.Status)
}
if _, err := os.Stat(filepath.Join(f.root, book.ID, SourceName+".txt")); err != nil {
t.Fatalf("the source of a book that was NOT deleted was removed: %v", err)
}
}
// The two constants are a PAIR and neither package can see the other's reasoning, so the pair is
// asserted here. A claim grace shorter than the queue's job timeout means the backstop sweep takes a
// parse away from the worker that is still running it: both then meet on one project directory, the
// loser dies on the engine's exclusive lock with exit 1 — and exit 1 is how the engine says "I cannot
// cut this source".
func TestTheClaimGraceOutlivesTheQueuesJobTimeout(t *testing.T) {
if claimGrace <= jobs.JobTimeout {
t.Fatalf("claimGrace %s does not outlive the queue's job timeout %s: the sweep would steal a claim from a running parse",
claimGrace, jobs.JobTimeout)
}
}
// Every byte is in and the client hung up while waiting for the 201 — a phone that went to sleep, a
// tab that was closed. The book must still be theirs: the row is what makes it findable at all, and
// throwing away a completed upload because nobody is left to read the answer costs the user the whole
// file and the whole wait.
func TestAnUploadSurvivesAClientThatHangsUpAfterTheLastByte(t *testing.T) {
f := newFixture(t)
ctx, cancel := context.WithCancel(f.ctx)
book, err := f.svc.Accept(ctx, Intake{UserID: "u1", SourceLang: "zh", TargetLang: "ru",
Filename: "book.txt", File: &hangUpReader{data: "первая глава", cancel: cancel}})
if err != nil {
t.Fatalf("an upload whose client left after the last byte was dropped: %v", err)
}
if book.Status != "parsing" {
t.Fatalf("the book is %q, want parsing", book.Status)
}
if _, err := os.Stat(filepath.Join(f.root, book.ID, SourceName+".txt")); err != nil {
t.Fatalf("the received source is gone: %v", err)
}
}
// hangUpReader delivers the whole body and then cancels the request's context, which is what a client
// that walks away after sending everything looks like from here.
type hangUpReader struct {
data string
cancel context.CancelFunc
sent bool
}
func (h *hangUpReader) Read(p []byte) (int, error) {
if !h.sent {
h.sent = true
return copy(p, h.data), nil
}
h.cancel()
return 0, io.EOF
}
// FP5-1 (acceptance, HIGH). A library revision must identify ONE state of the library. The floor a
// cancelled upload raises is only half the rule: if the next book joins from the maximum alone it
// lands at or below that floor, and the same number then answers for three different libraries —
// before the cancelled upload, during it, and after the next one arrived.
func TestABookThatJoinsAfterACancelledUploadStillMovesTheRevision(t *testing.T) {
f := newFixture(t)
first := f.accept(t, "kept.txt", "text")
f.provision(t, first)
before := f.libraryRevision(t)
ctx, cancel := context.WithCancel(f.ctx)
defer cancel()
if _, err := f.svc.Accept(ctx, Intake{UserID: "u1", SourceLang: "zh", TargetLang: "ru",
Filename: "abandoned.txt", File: &failingReader{cancel: cancel}}); err == nil {
t.Fatal("an upload whose body failed was accepted")
}
afterCancel := f.libraryRevision(t)
if afterCancel < before {
t.Fatalf("the revision went back after a cancelled upload: %d → %d", before, afterCancel)
}
next := f.accept(t, "next.txt", "text")
afterNext := f.libraryRevision(t)
if afterNext <= afterCancel {
t.Fatalf("a book that joined after a cancelled upload left the revision at %d (was %d): the same number now answers for two different libraries",
afterNext, afterCancel)
}
// And the book itself carries a revision above the floor, so its own card is not stale on arrival.
var rev int64
if err := f.store.Pool().QueryRow(f.ctx, `select revision from books where id = $1`, next.ID).Scan(&rev); err != nil {
t.Fatal(err)
}
if rev <= afterCancel {
t.Fatalf("the new book joined at revision %d, at or below the floor %d", rev, afterCancel)
}
}
// FP5-4 (acceptance). Waiting for a configuration must not bring DELETION closer: every claim counted
// against the budget, so a book that waited out five graces had its budget spent before the engine
// was asked once — and the first answer, which the engine gives as exit 1 for a typo in `book.yaml`
// as much as for an unreadable file, was terminal on arrival and took the source with it.
func TestWaitingForAConfigurationDoesNotBringDeletionCloser(t *testing.T) {
f := newFixture(t)
book := f.accept(t, "book.txt", "text")
for range parseAttempts * 3 {
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
t.Fatal(err)
}
f.now = f.now.Add(claimGrace + time.Minute)
}
var attempts int
if err := f.store.Pool().QueryRow(f.ctx,
`select parse_attempts from books where id = $1`, book.ID).Scan(&attempts); err != nil {
t.Fatal(err)
}
if attempts != 0 {
t.Fatalf("waiting for a configuration spent %d attempts of the budget", attempts)
}
// Now it is provisioned and the engine refuses the source once: with the budget intact, that first
// answer must NOT be terminal and must NOT delete anything.
f.provision(t, pgstore.Book{ID: book.ID})
f.engine.set(ingest.Manifest{}, refusal(t))
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
t.Fatal(err)
}
if got := f.card(t, book.ID); got.Status != "parsing" {
t.Fatalf("the first engine refusal after a long wait made the book %q", got.Status)
}
if _, err := os.Stat(filepath.Join(f.root, book.ID, SourceName+".txt")); err != nil {
t.Fatalf("the source was deleted on the first refusal after a wait: %v", err)
}
}
// FP5-3 (acceptance). The rejection path removes the directory BEFORE it writes the row, so a crash
// between the two leaves a book in `parsing` with nothing on disk. That must be terminal — otherwise
// the missing directory reads as "not configured", which is the one reason that never ends, and the
// book waits for a configuration nobody can put anywhere.
func TestABookWhoseDirectoryIsGoneIsRejectedRatherThanLeftWaiting(t *testing.T) {
f := newFixture(t)
book := f.accept(t, "book.txt", "text")
f.provision(t, book)
if err := os.RemoveAll(filepath.Join(f.root, book.ID)); err != nil {
t.Fatal(err)
}
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
t.Fatal(err)
}
var status, reason string
if err := f.store.Pool().QueryRow(f.ctx,
`select status, reject_reason from books where id = $1`, book.ID).Scan(&status, &reason); err != nil {
t.Fatal(err)
}
if status != "rejected" || reason != ReasonSourceUnreadable {
t.Fatalf("a book with no directory is %q/%q, want rejected/%s", status, reason, ReasonSourceUnreadable)
}
if f.engine.called() != 0 {
t.Fatalf("the engine was asked %d times about a book with no directory", f.engine.called())
}
}
// FP5-5 (acceptance): the backstop's re-drive of a parse is the SAME engine call the queue gives
// fifteen minutes. Under the run sweep's two-minute pass it was killed by the deadline, the kill was
// read as a host that cannot run the engine, and the attempt was spent — every pass, until the book
// was rejected for being large.
func TestOneBookInTheSweepGetsABudgetAParseCanLiveIn(t *testing.T) {
f := newFixture(t)
book := f.accept(t, "book.txt", "text")
f.provision(t, book)
// The engine takes longer than a second and reports the deadline it was given.
var granted time.Duration
f.engine.onManifest = func(ctx context.Context) {
if dl, ok := ctx.Deadline(); ok {
granted = time.Until(dl)
}
}
f.now = f.now.Add(2 * time.Hour) // past the graces, so the sweep picks the book up
if err := f.svc.Sweep(f.ctx); err != nil {
t.Fatal(err)
}
// Not an equality: the deadline is read a few microseconds after it was granted. What matters is
// that a parse gets the same ORDER of time from the sweep as it does from the queue, rather than
// the two minutes the run pass runs on.
if granted < jobs.JobTimeout-time.Minute {
t.Fatalf("the sweep gave one book's parse %s; the same call gets %s from the queue",
granted, jobs.JobTimeout)
}
}
// Cross-family review of the acceptance dofix (H1): the storage ROOT being gone — an unmounted
// volume, a deployment pointed at a path that is not there yet — looks exactly like one book's
// directory being gone, and the two mean opposite things. Read as the second, ONE sweep rejects
// every book in intake with "the source cannot be read": a reason that blames the user's file, is
// terminal by design, and has no way back.
func TestAVanishedStorageRootIsNotEveryBooksFault(t *testing.T) {
f := newFixture(t)
first := f.accept(t, "one.txt", "text")
second := f.accept(t, "two.txt", "text")
f.provision(t, first)
f.provision(t, second)
if err := os.RemoveAll(f.root); err != nil {
t.Fatal(err)
}
// Well past the budget: waiting for a host to come back must never bring a rejection closer.
for range parseAttempts * 2 {
for _, b := range []pgstore.Book{first, second} {
if err := f.svc.Parse(f.ctx, b.ID); err != nil {
t.Fatal(err)
}
}
f.now = f.now.Add(claimGrace + time.Minute)
}
if f.engine.called() != 0 {
t.Fatalf("the engine was asked %d times while the storage root was gone", f.engine.called())
}
for _, b := range []pgstore.Book{first, second} {
var status, reason string
var attempts int
if err := f.store.Pool().QueryRow(f.ctx,
`select status, reject_reason, parse_attempts from books where id = $1`, b.ID).
Scan(&status, &reason, &attempts); err != nil {
t.Fatal(err)
}
if status != "parsing" || reason != "" {
t.Fatalf("book %s is %q/%q after the storage root vanished; the host is at fault, not the book",
b.ID, status, reason)
}
if attempts != 0 {
t.Fatalf("book %s spent %d attempts on a host that could not see its own storage: the first "+
"real answer after this would be terminal on arrival", b.ID, attempts)
}
}
// And when the volume comes back, the very next pass parses the book.
if err := os.MkdirAll(filepath.Join(f.root, first.ID), 0o700); err != nil {
t.Fatal(err)
}
f.provision(t, first)
if err := f.svc.Parse(f.ctx, first.ID); err != nil {
t.Fatal(err)
}
if got := f.card(t, first.ID); got.Status != "not_started" {
t.Fatalf("after the storage came back the book is %q, want not_started", got.Status)
}
}
// Cross-family review of the acceptance dofix (H2): the floor under the library's revision made the
// STATUS walk invisible. A book uploaded before somebody else's upload was cancelled carries a
// counter below that floor, so `uploading → parsing → not_started` — three increments of its own
// number — left `greatest(max, floor)` exactly where it was, and a client obeying the contract drops
// every read that is not above what it applied. The book would sit on the screen as "arriving" until
// an unrelated event moved the library's number.
func TestAStatusChangeIsVisibleEvenUnderTheLibrarysFloor(t *testing.T) {
f := newFixture(t)
book := f.accept(t, "book.txt", "text")
f.provision(t, book)
// A cancelled upload raises the floor above the book's own counter.
ctx, cancel := context.WithCancel(f.ctx)
defer cancel()
if _, err := f.svc.Accept(ctx, Intake{UserID: "u1", SourceLang: "zh", TargetLang: "ru",
Filename: "abandoned.txt", File: &failingReader{cancel: cancel}}); err == nil {
t.Fatal("an upload whose body failed was accepted")
}
floor := f.libraryRevision(t)
var own int64
if err := f.store.Pool().QueryRow(f.ctx,
`select revision from books where id = $1`, book.ID).Scan(&own); err != nil {
t.Fatal(err)
}
if own > floor {
t.Fatalf("the fixture does not reproduce the case: the book is at %d, above the floor %d", own, floor)
}
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
t.Fatal(err)
}
if got := f.card(t, book.ID); got.Status != "not_started" {
t.Fatalf("the book is %q, want not_started", got.Status)
}
if got := f.libraryRevision(t); got <= floor {
t.Fatalf("the book finished parsing and the library still answers %d (was %d): the client drops "+
"that read and never sees the status", got, floor)
}
}
// Re-check of the dofix (FP5-10): the SHAPE the first guard missed. A volume mounted at BooksDir
// leaves an empty directory behind when it is unmounted — the mountpoint — and the boot recreates
// that directory anyway, so "does the root exist" answers yes about storage that is gone. Every book
// in intake would then be rejected as unreadable and its source deleted. The marker is what tells the
// two apart: it is written by an upload and cannot be forged by an unmount or by MkdirAll.
func TestAnUnmountedVolumeLooksLikeAnEmptyRootAndStillIsNotTheBooksFault(t *testing.T) {
f := newFixture(t)
book := f.accept(t, "one.txt", "text")
f.provision(t, book)
// The upload is what writes the marker, and nothing else does — asserted here rather than assumed,
// because a marker that is never written makes every missing directory look like an unmount and
// the crash window of the rejection path (FP5-3) would then wait for ever instead of closing.
if _, err := os.Stat(filepath.Join(f.root, StorageMarker)); err != nil {
t.Fatalf("an accepted upload left no storage marker: %v", err)
}
// Exactly what an unmount leaves: the root is there, everything under it is not — including the
// marker, which lived on the volume.
if err := os.RemoveAll(f.root); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(f.root, 0o750); err != nil { // the boot's own MkdirAll, or the mountpoint
t.Fatal(err)
}
for range parseAttempts * 2 {
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
t.Fatal(err)
}
f.now = f.now.Add(claimGrace + time.Minute)
}
if f.engine.called() != 0 {
t.Fatalf("the engine was asked %d times about storage that is not mounted", f.engine.called())
}
var status, reason string
var attempts int
if err := f.store.Pool().QueryRow(f.ctx,
`select status, reject_reason, parse_attempts from books where id = $1`, book.ID).
Scan(&status, &reason, &attempts); err != nil {
t.Fatal(err)
}
if status != "parsing" || reason != "" {
t.Fatalf("an unmounted volume left the book %q/%q: the storage is the host's problem, not the book's",
status, reason)
}
if attempts != 0 {
t.Fatalf("the book spent %d attempts while its storage was unmounted", attempts)
}
// And the marker is not something the platform re-creates behind an operator's back: only an
// upload writes it, so the volume coming back is what restores the answer.
if _, err := os.Stat(filepath.Join(f.root, StorageMarker)); err == nil {
t.Fatal("the marker was re-created while the volume was away: it can no longer tell mounted from not")
}
}
// Re-check of the dofix (tail в): the rest of the same defect. A budget the BOOK gets is not a budget
// the PASS still has — the second book of a pass inherited whatever the first one left, so a slow
// first parse handed the second a stub of a deadline, the engine call died on it, and the attempt was
// spent on a timeout that says nothing about the book. A pass that cannot give a book its whole
// budget must not start it: the claim is taken inside Parse, so a book left for the next tick has
// spent nothing.
func TestAPassTooShortForAParseStartsNoneAtAll(t *testing.T) {
f := newFixture(t)
first := f.accept(t, "one.txt", "text")
second := f.accept(t, "two.txt", "text")
f.provision(t, first)
f.provision(t, second)
f.now = f.now.Add(2 * time.Hour) // past the graces, so the sweep picks both up
// What is left of a pass whose first book was slow.
ctx, cancel := context.WithTimeout(f.ctx, time.Minute)
defer cancel()
if err := f.svc.Sweep(ctx); err != nil {
t.Fatal(err)
}
if f.engine.called() != 0 {
t.Fatalf("the engine was called %d times on a pass shorter than a parse", f.engine.called())
}
for _, b := range []pgstore.Book{first, second} {
var attempts int
var status string
if err := f.store.Pool().QueryRow(f.ctx,
`select parse_attempts, status from books where id = $1`, b.ID).Scan(&attempts, &status); err != nil {
t.Fatal(err)
}
if attempts != 0 || status != "parsing" {
t.Fatalf("book %s spent %d attempts and is %q after a pass that had no time to parse it",
b.ID, attempts, status)
}
}
}