183 lines
6.6 KiB
Text
183 lines
6.6 KiB
Text
package main
|
|
|
|
// P8-REVIEW axis 3 probe. Reproduces the SCHEDULE half of the sweep composition: `sweep`'s tick runs
|
|
// its five passes one after another on ONE goroutine, so the RUNS pass — the only thing that settles
|
|
// money, restarts a run a reboot lost and re-issues a stop the systemd call dropped — runs once per
|
|
// (sum of every pass's duration), not once per TM_PLATFORM_SWEEP_EVERY.
|
|
//
|
|
// Scaled, and only in the direction that makes it observable: the readmodel pass here is slowed by a
|
|
// fake engine that takes 400 ms per book instead of the real one's minutes, and the tick is 100 ms
|
|
// instead of 15 s. No constant of the zone is changed. The real numbers substitute directly:
|
|
// refreshSweepBudget 10m + intakeSweepBudget 21m + two 2m passes on top of the runs pass's own 2m.
|
|
//
|
|
// The runs pass is counted by the one side effect it has on a stopped run: `reconcile` re-issues
|
|
// `Runner.Stop` on every pass while the unit is alive and an intent stands (reconcile.go:387-397).
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"log/slog"
|
|
"net/url"
|
|
"os"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"textmachine/platform/internal/ingest"
|
|
"textmachine/platform/internal/metrics"
|
|
"textmachine/platform/internal/money"
|
|
"textmachine/platform/internal/pgstore"
|
|
"textmachine/platform/internal/readmodel"
|
|
"textmachine/platform/internal/runner"
|
|
"textmachine/platform/internal/runs"
|
|
)
|
|
|
|
type countingRunner struct{ stops atomic.Int64 }
|
|
|
|
func (c *countingRunner) Start(context.Context, runner.Spec) error { return nil }
|
|
func (c *countingRunner) Stop(context.Context, string) error { c.stops.Add(1); return nil }
|
|
func (c *countingRunner) Alive(context.Context, string) (bool, error) {
|
|
return true, nil
|
|
}
|
|
|
|
// slowEngine is a readmodel engine that answers, slowly, and then says no. Slow enough to dominate a
|
|
// pass and fast enough for a test — the real one is bounded by readmodel.MaterializeBudget (5 min).
|
|
type slowEngine struct{ d time.Duration }
|
|
|
|
func (s slowEngine) Manifest(ctx context.Context, binary, workdir string) (ingest.Manifest, error) {
|
|
select {
|
|
case <-time.After(s.d):
|
|
case <-ctx.Done():
|
|
}
|
|
return ingest.Manifest{}, os.ErrDeadlineExceeded
|
|
}
|
|
func (s slowEngine) Export(ctx context.Context, binary, workdir string) (ingest.Export, error) {
|
|
return ingest.Export{}, os.ErrDeadlineExceeded
|
|
}
|
|
|
|
func probeStore(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")
|
|
}
|
|
ctx := t.Context()
|
|
var suffix [6]byte
|
|
if _, err := rand.Read(suffix[:]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
name := "tm_p8rev_sweep_" + hex.EncodeToString(suffix[:])
|
|
conn, err := pgx.Connect(ctx, admin)
|
|
if err != nil {
|
|
t.Fatal(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", 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, _ := url.Parse(admin)
|
|
u.Path = "/" + name
|
|
if err := pgstore.Migrate(ctx, u.String()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s, err := pgstore.Open(ctx, u.String())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(s.Close)
|
|
return s, ctx
|
|
}
|
|
|
|
// oneStoppedRun leaves the database with exactly one live run whose stop is on file and whose unit
|
|
// still exists: the shape whose only cure is the sweep re-issuing the signal.
|
|
func oneStoppedRun(t *testing.T, db *pgstore.Store, ctx context.Context, now time.Time) {
|
|
t.Helper()
|
|
if _, err := db.Pool().Exec(ctx, `insert into users (id, email) values ('u1','u1@example.org')`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
amount, _ := money.ParseUSD("50")
|
|
if _, err := db.Grant(ctx, "u1", amount, "test", "seed", "", now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bookID, err := db.AddBook(ctx, pgstore.NewBook{OwnerID: "u1", Title: "b", SourceLang: "zh",
|
|
TargetLang: "ru", ChapterCount: 100, Workdir: t.TempDir(), Now: now})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
started, err := db.StartRun(ctx, pgstore.StartRunInput{UserID: "u1", BookID: bookID,
|
|
CeilingChapters: 10, Ceiling: money.MicroUSD(1_000_000), Now: now}, 0,
|
|
func(context.Context, pgstore.Tx, string) error { return nil })
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ok, err := db.RecordSpawn(ctx, pgstore.SpawnRecord{AttemptID: started.AttemptID,
|
|
Unit: "tm-run-x-1", Binary: "/opt/tm/tmctl", EngineRunID: pgstore.EngineStreamID(started.ID, 1),
|
|
Ceiling: money.MicroUSD(1_000_000), CeilingArg: money.MicroUSD(1_000_000), Baseline: 0})
|
|
if err != nil || !ok {
|
|
t.Fatalf("record spawn: %v %v", ok, err)
|
|
}
|
|
if _, _, err := db.RequestStop(ctx, "u1", started.ID, now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// booksOwingAReadingSurface leaves n quiet books whose surface is due, which is what the readmodel
|
|
// pass works through.
|
|
func booksOwingAReadingSurface(t *testing.T, db *pgstore.Store, ctx context.Context, now time.Time, n int) {
|
|
t.Helper()
|
|
for range n {
|
|
id, err := db.AddBook(ctx, pgstore.NewBook{OwnerID: "u1", Title: "owed", SourceLang: "zh",
|
|
TargetLang: "ru", ChapterCount: 10, Workdir: t.TempDir(), Now: now})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := db.Pool().Exec(ctx,
|
|
`update books set read_model_owed_at = now() where id = $1`, id); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestProbeTheRunsPassRunsOncePerSumOfEveryPass(t *testing.T) {
|
|
const window = 4 * time.Second
|
|
const tick = 100 * time.Millisecond
|
|
run := func(t *testing.T, withReadModel bool) int64 {
|
|
db, ctx := probeStore(t)
|
|
now := time.Now().UTC().Truncate(time.Millisecond)
|
|
oneStoppedRun(t, db, ctx, now)
|
|
rn := &countingRunner{}
|
|
svc := &runs.Service{Store: db, Runner: rn, Now: func() time.Time { return now }}
|
|
s := sweeps{runs: svc, db: db, metrics: metrics.New()}
|
|
if withReadModel {
|
|
booksOwingAReadingSurface(t, db, ctx, now, 8)
|
|
s.reader = &readmodel.Service{Store: db, Engine: slowEngine{400 * time.Millisecond},
|
|
Binary: "/opt/tm/tmctl"}
|
|
}
|
|
c, cancel := context.WithCancel(ctx)
|
|
var wg sync.WaitGroup
|
|
wg.Add(1)
|
|
go func() { defer wg.Done(); sweep(c, s, tick, 2*time.Minute, slog.New(slog.DiscardHandler)) }()
|
|
time.Sleep(window)
|
|
cancel()
|
|
wg.Wait()
|
|
return rn.stops.Load()
|
|
}
|
|
var alone, behind int64
|
|
t.Run("runs pass alone", func(t *testing.T) { alone = run(t, false) })
|
|
t.Run("runs pass behind a busy readmodel pass", func(t *testing.T) { behind = run(t, true) })
|
|
t.Logf("in %v at a %v tick: runs passes alone = %d, behind the readmodel pass = %d",
|
|
window, tick, alone, behind)
|
|
if behind >= alone {
|
|
t.Logf("NOT REPRODUCED: the runs pass kept its cadence")
|
|
}
|
|
}
|