610 lines
28 KiB
Go
610 lines
28 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"textmachine/platform/internal/backup"
|
|
"textmachine/platform/internal/books"
|
|
"textmachine/platform/internal/config"
|
|
"textmachine/platform/internal/exports"
|
|
"textmachine/platform/internal/httpapi"
|
|
"textmachine/platform/internal/jobs"
|
|
"textmachine/platform/internal/metrics"
|
|
"textmachine/platform/internal/pgstore"
|
|
"textmachine/platform/internal/pricing"
|
|
"textmachine/platform/internal/readmodel"
|
|
"textmachine/platform/internal/runner"
|
|
"textmachine/platform/internal/runs"
|
|
)
|
|
|
|
// runsConfig maps the operator's environment onto the reconciler's settings.
|
|
//
|
|
// A function and not a literal inside startRunner so that the mapping has a witness: startRunner
|
|
// needs a database, a systemd bus and a queue, so nothing could observe a field that stopped being
|
|
// assigned — which is how RunBudget came to be declared, documented and left at zero.
|
|
func runsConfig(cfg config.Config, marker []string, ceiling []string) runs.Config {
|
|
return runs.Config{
|
|
StateDir: cfg.Runner.StateDir,
|
|
EngineBinary: cfg.Runner.EngineBinary,
|
|
MarkerArgv: marker,
|
|
Ceiling: ceiling,
|
|
KeysFile: cfg.Runner.KeysFile,
|
|
MemoryMax: cfg.Runner.MemoryMax,
|
|
TasksMax: cfg.Runner.TasksMax,
|
|
AllowEngineVersionChange: cfg.Runner.AllowEngineVersionChange,
|
|
ResyncEvery: cfg.Runner.ResyncEvery,
|
|
RunBudget: cfg.Runner.RunBudget,
|
|
}
|
|
}
|
|
|
|
// startRunner wires the run lifecycle and the book intake, and returns the function that stops them.
|
|
//
|
|
// The reads are mounted whether or not runs can be spawned: a library and a book card are not run
|
|
// machinery, and an instance without an engine binary is a perfectly useful read replica. What an
|
|
// unconfigured runner refuses is starting a run, and it says so at boot rather than at the first
|
|
// click.
|
|
func startRunner(ctx context.Context, cfg config.Config, db *pgstore.Store, log *slog.Logger,
|
|
m *metrics.Metrics, deps *httpapi.Deps) (func(), error) {
|
|
deps.Library = db
|
|
// The keys survive without a runner: `Idempotency-Key` is a property of a WRITE, and the writes
|
|
// that take one are mounted independently of whether a run can be spawned.
|
|
deps.Keys = db
|
|
deps.Capabilities = capabilities(cfg)
|
|
// Said before the read-replica return below, so an instance that serves only reads still says it.
|
|
// LOUD, and unconditionally: a deployment holding paid work and a credit ledger with no copy of
|
|
// either is one lost disk away from having neither, and that is the one fault of this service
|
|
// with no partial outcome (unified backlog row 269). A WARN is the strongest thing that may be
|
|
// said here — refusing to start would take a running deployment down over a setting it has
|
|
// survived without until now, which is a worse trade than a line an operator can act on.
|
|
if !cfg.BackupEnabled() {
|
|
log.Warn("no TM_PLATFORM_BACKUP_DIR: this deployment keeps NO restore point of the paid translations or of the credit ledger; losing this host's disk loses both irrecoverably")
|
|
}
|
|
if !cfg.RunsEnabled() {
|
|
log.Warn("no TM_PLATFORM_ENGINE_BIN: the library is served read-only, no run can be started and no book can be uploaded")
|
|
// ⚠ AND IF THIS READ REPLICA WAS ALSO GIVEN A BACKUP DIRECTORY, SAY THAT IT WILL NOT BE USED.
|
|
// The restore-point maker lives below this return because a consistent copy of a book is an
|
|
// engine call; an operator who set the variable here believed they had backups, and the only
|
|
// other signal they would get is a gauge that never leaves +Inf.
|
|
if cfg.BackupEnabled() {
|
|
log.Warn("TM_PLATFORM_BACKUP_DIR is set on an instance with no engine binary: NO restore points will be taken here (a consistent copy of a book's database is an engine call); point the variable at the instance that runs translations",
|
|
"dir", cfg.Backup.Dir)
|
|
}
|
|
return func() {}, nil
|
|
}
|
|
model, err := pricing.New(cfg.Runner.HoldFactorPercent)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ceiling, err := runner.ParseCeilingTemplate(cfg.Runner.CeilingArg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(ceiling) == 0 {
|
|
// Loud at boot, refused at the handle. The alternative — starting runs anyway — spends the
|
|
// account's money under the BOOK's ceiling instead of the one the user chose (row 145).
|
|
log.Warn("TM_PLATFORM_ENGINE_CEILING_ARG is empty: starting a run will be refused rather than run under the book's own ceiling (row 145)")
|
|
}
|
|
if cfg.Runner.KeysFile == "" {
|
|
// Runs still start: a stand may keep a `.env` beside each book. What cannot be allowed is the
|
|
// silent version — a SaaS deployment whose engine gets no keys from anywhere fails at the first
|
|
// provider call, hours of queueing later (row 211).
|
|
log.Warn("no TM_PLATFORM_ENGINE_KEYS_PATH: the engine is passed no provider keys; a run finds them only in a .env beside its own book.yaml")
|
|
}
|
|
marker, err := markerArgv(cfg.Runner.MarkerBinary)
|
|
if err != nil {
|
|
// Reads keep working. Losing the exit-marker command means a finished run could not be
|
|
// recorded and its hold would stay reserved, so runs are refused — but the library is not
|
|
// run machinery and taking it down with them turns a binary upgrade into a full outage.
|
|
log.Warn("the exit-marker command is not usable; starting a run will be refused", "err", err)
|
|
}
|
|
rn := runner.New(log)
|
|
if err := rn.Available(ctx); err != nil {
|
|
// Not fatal: reads still work, and an operator who has not enabled lingering yet gets a line
|
|
// that names the cause instead of a run that never starts.
|
|
log.Warn("transient units are not available; runs cannot be spawned on this host", "err", err)
|
|
}
|
|
reader := &readmodel.Service{Store: db, Engine: rn, Binary: cfg.Runner.EngineBinary, Log: log}
|
|
svc := &runs.Service{
|
|
Store: db,
|
|
Runner: rn,
|
|
Engine: rn,
|
|
Bank: rn,
|
|
Pricing: model,
|
|
Cfg: runsConfig(cfg, marker, ceiling),
|
|
Log: log,
|
|
}
|
|
// Before the door serves: a decision document still on disk here outlived its process, and
|
|
// nothing else ever deletes by that mask (PD-409's cure).
|
|
svc.SweepCorrectionScratch()
|
|
intake, err := startIntake(cfg, db, rn, reader, log, deps)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
exporter, err := startExports(cfg, db, rn, log, deps)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// A typed nil is not nil once it is inside an interface, and the queue decides which workers to
|
|
// register by exactly that check. Spelled out so an instance without intake registers no parse
|
|
// worker rather than one that calls a nil service.
|
|
var parser jobs.Parser
|
|
if intake != nil {
|
|
parser = intake
|
|
}
|
|
var builder jobs.Exporter
|
|
if exporter != nil {
|
|
builder = exporter
|
|
}
|
|
queue, err := jobs.New(db.Pool(), svc, parser, builder, log, cfg.Runner.Workers)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
svc.Queue = queue
|
|
deps.Runs = svc
|
|
// The correction door mounts with the run machinery: it needs the same engine binary, the same
|
|
// state directory and the same per-book serialization. The capability flag says the same fact.
|
|
deps.Bank = svc
|
|
if intake != nil {
|
|
intake.Queue = queue
|
|
}
|
|
if exporter != nil {
|
|
exporter.Queue = queue
|
|
}
|
|
if err := queue.Start(ctx); err != nil {
|
|
return nil, fmt.Errorf("start queue: %w", err)
|
|
}
|
|
go backupLoop(ctx, startBackup(cfg, db, rn, log), m, log)
|
|
go sweep(ctx, sweeps{runs: svc, books: intake, reader: reader, exports: exporter,
|
|
db: db, metrics: m}, cfg.Runner.SweepEvery, cfg.Runner.SweepBudget, log)
|
|
return func() {
|
|
// The queue is drained; the RUNS are not touched. They are transient units, not children,
|
|
// and outliving this process is the whole point of the seam (D39.106).
|
|
stop, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
|
defer cancel()
|
|
if err := queue.Stop(stop); err != nil {
|
|
log.Warn("queue did not stop cleanly", "err", err)
|
|
}
|
|
}, nil
|
|
}
|
|
|
|
// startBackup wires the restore-point maker, or answers nil when this deployment keeps none.
|
|
//
|
|
// It is created HERE, inside the run machinery, because the consistent copy of a book's database is
|
|
// an engine call and this is where the engine is: an instance with no `TM_PLATFORM_ENGINE_BIN` never
|
|
// reaches this function, and the warning about having no backups at all is said before that return.
|
|
func startBackup(cfg config.Config, db *pgstore.Store, engine backup.Engine, log *slog.Logger) *backup.Service {
|
|
if !cfg.BackupEnabled() {
|
|
return nil
|
|
}
|
|
// The directory is made at boot rather than at the first pass, for the reason the exports
|
|
// directory is: a path that cannot be created is a deployment fault, and discovering it six hours
|
|
// later — inside a pass whose failure is one log line — is discovering it during the outage it
|
|
// was meant to survive.
|
|
if err := os.MkdirAll(cfg.Backup.Dir, 0o750); err != nil {
|
|
log.Error("the backup directory cannot be created; this deployment will keep NO restore points",
|
|
"dir", cfg.Backup.Dir, "err", err)
|
|
return nil
|
|
}
|
|
// ⚠ THE TOOLS ARE LOOKED FOR AT BOOT. The reason is a measurement of this very runbook: the deploy
|
|
// recipe executed literally in a clean container produced a healthy-looking instance whose first
|
|
// backup pass failed with `exec: "pg_dump": executable file not found in $PATH`, and then — with
|
|
// the client tools installed from the distribution — with `aborting because of server version
|
|
// mismatch`. The host runs the control plane, not the database, so nothing there had ever
|
|
// installed the tools, and what the distribution ships is whatever major it ships.
|
|
//
|
|
// ⚠ WHAT THIS LINE ADDS, stated honestly because the first version of this comment overstated it:
|
|
// it is NOT that the failure would otherwise be discovered six hours later. The sweep's first pass
|
|
// runs at BOOT and takes a point when there is none, so the pass's own ERROR is already timely.
|
|
// What this adds is the REMEDY — it names the tool and the major-version rule, where the pass can
|
|
// only name the symptom.
|
|
//
|
|
// A WARN and not a refusal, for the same reason the "no backup directory" line is one: taking a
|
|
// running deployment down over a missing tool is a worse trade than a boot line plus the age
|
|
// gauge, which is what actually pages somebody.
|
|
for _, tool := range []string{cfg.Backup.PgDumpBin, cfg.Backup.PgRestoreBin} {
|
|
if _, err := exec.LookPath(tool); err != nil {
|
|
log.Warn("a PostgreSQL tool the backup needs is not on this host; restore points will FAIL until it is installed (its major version must match the server's)",
|
|
"tool", tool, "err", err)
|
|
}
|
|
}
|
|
log.Info("restore points are kept", "dir", cfg.Backup.Dir, "every", cfg.Backup.Every, "keep", cfg.Backup.Keep)
|
|
return &backup.Service{
|
|
Cfg: backup.Config{
|
|
Dir: cfg.Backup.Dir, Every: cfg.Backup.Every, Keep: cfg.Backup.Keep,
|
|
PgDumpBin: cfg.Backup.PgDumpBin, PgRestoreBin: cfg.Backup.PgRestoreBin,
|
|
DSN: cfg.DSN, EngineBinary: cfg.Runner.EngineBinary,
|
|
},
|
|
Store: db, Engine: engine, Log: log,
|
|
}
|
|
}
|
|
|
|
// startIntake wires the book upload, or explains at boot why this deployment takes none.
|
|
//
|
|
// A nil service leaves POST /books unmounted, which is the same shape every unbuilt contract route
|
|
// has: an instance that accepted a file it had nowhere to put would take the whole upload before it
|
|
// could say so.
|
|
func startIntake(cfg config.Config, db *pgstore.Store, engine books.Manifester, reader books.Reader,
|
|
log *slog.Logger, deps *httpapi.Deps) (*books.Service, error) {
|
|
if !cfg.IntakeEnabled() {
|
|
log.Warn("no TM_PLATFORM_BOOKS_DIR: this instance accepts no book uploads")
|
|
return nil, nil
|
|
}
|
|
// Created at boot, where a permission problem is an operator's to see, rather than on the first
|
|
// upload, where it would be one user's mysterious failure.
|
|
if err := os.MkdirAll(cfg.Intake.BooksDir, 0o750); err != nil {
|
|
return nil, fmt.Errorf("books directory %s: %w", cfg.Intake.BooksDir, err)
|
|
}
|
|
svc := &books.Service{
|
|
Store: db,
|
|
Engine: engine,
|
|
Reader: reader,
|
|
Cfg: books.Config{
|
|
BooksDir: cfg.Intake.BooksDir,
|
|
EngineBinary: cfg.Runner.EngineBinary,
|
|
BookTemplate: cfg.Intake.BookTemplate,
|
|
Pairs: intakePairs(cfg),
|
|
},
|
|
Log: log,
|
|
}
|
|
deps.Intake = svc
|
|
deps.Upload = httpapi.UploadLimits{
|
|
MaxBytes: cfg.Intake.MaxUploadBytes,
|
|
Deadline: cfg.Intake.UploadDeadline,
|
|
}
|
|
return svc, nil
|
|
}
|
|
|
|
// startExports wires the export door, or explains at boot why this deployment builds none.
|
|
//
|
|
// A nil service leaves all three export routes unmounted, which is the same shape every unbuilt
|
|
// contract route has — and `export_formats` answers `[]` from the same fact, so a client never
|
|
// learns of a format by having a user's download fail.
|
|
func startExports(cfg config.Config, db *pgstore.Store, engine exports.Builder,
|
|
log *slog.Logger, deps *httpapi.Deps) (*exports.Service, error) {
|
|
if !cfg.ExportsEnabled() {
|
|
log.Warn("no TM_PLATFORM_EXPORT_FORMATS: this instance builds no exports and `export_formats` is empty")
|
|
return nil, nil
|
|
}
|
|
// Created at boot, where a permission problem is an operator's to see, rather than on the first
|
|
// download, where it would be one user's mysterious failure. The same reasoning as the intake's
|
|
// books directory, and the same mode: artifacts are a user's book text.
|
|
if err := os.MkdirAll(cfg.Export.Dir, 0o750); err != nil {
|
|
return nil, fmt.Errorf("exports directory %s: %w", cfg.Export.Dir, err)
|
|
}
|
|
svc := &exports.Service{Store: db, Engine: engine, Cfg: exportsConfig(cfg), Log: log}
|
|
deps.Exports = svc
|
|
return svc, nil
|
|
}
|
|
|
|
// exportsConfig maps the operator's environment onto the export door's settings.
|
|
//
|
|
// A function and not a literal inside startExports for the same reason runsConfig is one: the
|
|
// literal needs a database and a queue to be reached at all, so an assignment that stopped happening
|
|
// would be observable only by running the daemon — which is exactly how a documented knob came to do
|
|
// nothing for a whole pack (PD-331).
|
|
func exportsConfig(cfg config.Config) exports.Config {
|
|
return exports.Config{
|
|
EngineBinary: cfg.Runner.EngineBinary,
|
|
Formats: cfg.Export.Formats,
|
|
Dir: cfg.Export.Dir,
|
|
TTL: cfg.Export.TTL,
|
|
StaleAfter: exportStaleAfter,
|
|
QueuedGrace: exportQueuedGrace,
|
|
}
|
|
}
|
|
|
|
// exportStaleAfter is how old a `pending` export has to be before the sweep calls its build lost.
|
|
//
|
|
// It must OUTLIVE one whole job or the sweep would give up on builds that are merely slow, and the
|
|
// margin is the sweep's own tick plus room for a queue that took its time picking the job up. Same
|
|
// shape as the intake's claim grace, and written as the job timeout plus a margin rather than as a
|
|
// number, so the two cannot drift apart.
|
|
const exportStaleAfter = jobs.JobTimeout + 5*time.Minute
|
|
|
|
// exportQueuedGrace is the same question for a build that has NOT been picked up yet, and it is four
|
|
// whole jobs rather than one.
|
|
//
|
|
// The number is a share of `jobs.JobTimeout` and not a constant, for the reason the figure exists at
|
|
// all: ONE queue serves spawns, parses and builds, a parse may take a whole job timeout, and the
|
|
// default worker count is four — so an export can legitimately sit behind four full-length jobs
|
|
// without anything being wrong with it. Judged on the started-build clock it would be buried at
|
|
// twenty minutes and its user told it was interrupted, which is false. The poll still ends, which is
|
|
// the canon's actual requirement; a backlog longer than this is an operator's problem, and the line
|
|
// the sweep writes is where they see it.
|
|
const exportQueuedGrace = 4 * jobs.JobTimeout
|
|
|
|
// capabilities is what `GET /capabilities` answers, assembled from what this deployment IS rather
|
|
// than from a document somebody keeps in step with it: the pairs an operator declared, whether an
|
|
// intake is mounted at all, the cap that intake enforces, and the page size every read defaults to.
|
|
func capabilities(cfg config.Config) httpapi.Capabilities {
|
|
caps := httpapi.Capabilities{
|
|
IntakeEnabled: cfg.IntakeEnabled() && cfg.RunsEnabled(),
|
|
IntakeMaxBytes: cfg.Intake.MaxUploadBytes,
|
|
// What this deployment DECLARES its engine can write, and the same condition that mounts
|
|
// the three export routes (startExports runs exactly when ExportsEnabled). The capability
|
|
// and the mount are one fact, or a client learns the truth by failing a user's download.
|
|
ExportFormats: exportFormats(cfg),
|
|
// The same condition that mounts the door (startRunner sets deps.Bank exactly when runs are
|
|
// enabled): the flag and the 404 must be one fact, or a client learns the truth by failing
|
|
// a user's save.
|
|
BankCorrectionsEnabled: cfg.RunsEnabled(),
|
|
PageSizeDefault: pgstore.DefaultPage,
|
|
}
|
|
for _, p := range cfg.LanguagePairs {
|
|
caps.Pairs = append(caps.Pairs, httpapi.LanguagePair{
|
|
Source: p.Source, Target: p.Target, Available: p.Available,
|
|
})
|
|
}
|
|
return caps
|
|
}
|
|
|
|
// exportFormats is what `export_formats` answers: the declared list on a deployment that can build,
|
|
// and an empty one everywhere else. Never nil — an empty collection is an empty array on the wire.
|
|
func exportFormats(cfg config.Config) []string {
|
|
if !cfg.ExportsEnabled() {
|
|
return []string{}
|
|
}
|
|
return append([]string{}, cfg.Export.Formats...)
|
|
}
|
|
|
|
// intakePairs is the AVAILABLE half of the same list: what the intake judges an upload against.
|
|
func intakePairs(cfg config.Config) []books.Pair {
|
|
var out []books.Pair
|
|
for _, p := range cfg.AvailablePairs() {
|
|
out = append(out, books.Pair{Source: p.Source, Target: p.Target})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// refreshSweepBudget is what materializing the owed reading surfaces may take in one pass. Larger
|
|
// than the sweep's own because the work is a re-chunk of the source per book, and it is spent on
|
|
// nobody else's behalf: the money of every run that owes one has already settled.
|
|
const refreshSweepBudget = 10 * time.Minute
|
|
|
|
// intakeSweepBudget is the same for the intake, and it is larger because the work inside it is:
|
|
// re-driving one book's parse is an engine call the queue itself bounds at jobs.JobTimeout, and a
|
|
// pass shorter than that turns "this book is large" into "this host cannot run the engine".
|
|
//
|
|
// ⚠ It carries the intake's materialization too: the parse is followed by two more engine reads of
|
|
// the same source, and the sweep's own guard — "do not start a book with less than one book's worth
|
|
// of pass left" — is only true if the pass can hold both.
|
|
const intakeSweepBudget = jobs.JobTimeout + readmodel.MaterializeBudget + time.Minute
|
|
|
|
// backupBudget is what ONE restore point may take. Large, and the size is what the work is: a
|
|
// `VACUUM INTO` per book plus a whole `pg_dump`, over a deployment's entire library. A pass that runs
|
|
// out of it publishes NOTHING — Take aborts and discards its staging directory rather than leaving a
|
|
// point that is missing the books it never reached — so a budget too small to hold the work is a
|
|
// deployment with no backups at all, which the age gauge then says out loud.
|
|
const backupBudget = 60 * time.Minute
|
|
|
|
// backupTick is how often the loop asks whether a point is due. Far shorter than any sane interval,
|
|
// because the DECISION is made against the newest point on disk (backup.Service.Sweep) and this only
|
|
// bounds how late a due point can be.
|
|
const backupTick = time.Minute
|
|
|
|
// backupLoop is the restore-point maker's own goroutine, and it is NOT a pass of the reconciler's
|
|
// tick — a mistake this file made first and the measurement that corrected it is worth keeping.
|
|
//
|
|
// ⚠ THE RECONCILER'S TICK IS SEQUENTIAL: `sweep`'s `one()` runs every pass in order and only then
|
|
// publishes the telemetry. A pass that copies whole databases therefore does not "starve only what
|
|
// comes after it" — it delays the NEXT tick's settlement of money, the intake's retries, the export
|
|
// GC and every gauge, for as long as it runs. Up to an hour, four times a day, is not a share of a
|
|
// tick: it is the control plane stopping. The copy has no reason to share a schedule with anything —
|
|
// it touches none of the same rows — so it gets its own.
|
|
func backupLoop(ctx context.Context, svc *backup.Service, m *metrics.Metrics, log *slog.Logger) {
|
|
if svc == nil || !svc.Enabled() {
|
|
return
|
|
}
|
|
observe := func() {
|
|
age, has, err := svc.Age(time.Now())
|
|
if err != nil {
|
|
log.Warn("the age of the newest complete restore point could not be read", "err", err)
|
|
return
|
|
}
|
|
m.ObserveBackupAge(age, has)
|
|
}
|
|
one := func() {
|
|
c, cancel := context.WithTimeout(ctx, backupBudget)
|
|
defer cancel()
|
|
start := time.Now()
|
|
if err := svc.Sweep(c); err != nil && !errors.Is(err, context.Canceled) {
|
|
log.Error("the restore point could not be taken", "err", err, "took", time.Since(start))
|
|
}
|
|
observe()
|
|
}
|
|
one()
|
|
t := time.NewTicker(backupTick)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
one()
|
|
}
|
|
}
|
|
}
|
|
|
|
// idempotencyWindow is how long a key is remembered. The contract promises "at least 24 hours, then
|
|
// the key is forgotten" — both halves: without the sweep the table only grows, and a month-old key
|
|
// still replays a response about a book that may no longer exist.
|
|
const idempotencyWindow = 25 * time.Hour
|
|
|
|
// sweeps is everything one tick of the reconciler covers.
|
|
type sweeps struct {
|
|
runs *runs.Service
|
|
books *books.Service
|
|
reader *readmodel.Service
|
|
exports *exports.Service
|
|
db *pgstore.Store
|
|
metrics *metrics.Metrics
|
|
}
|
|
|
|
// sweepPass is one reconciler's share of a tick: its name in the telemetry, what it may take, and
|
|
// what it does.
|
|
type sweepPass struct {
|
|
name string
|
|
budget time.Duration
|
|
run func(context.Context) error
|
|
}
|
|
|
|
// passes is EVERY pass of one tick, in order, as a list rather than a dozen conditionals inside the
|
|
// loop.
|
|
//
|
|
// A list for the same reason the contract surface is one (httpapi.contractSurface): "every
|
|
// reconciler this daemon runs" becomes something CODE can enumerate, so a pass wired into the daemon
|
|
// and into nothing else is not expressible. It was: the whole of an assignment like this used to be
|
|
// observable only by running the daemon against a database, a systemd bus and a queue — which is how
|
|
// `RunBudget` came to be declared, documented and left at zero (PD-331), and how a new pass could be
|
|
// added and silently never armed.
|
|
//
|
|
// ⚠ The BUDGETS are not equal and the differences are argued where they are chosen: the
|
|
// materialization and the intake get their own because the work inside them is engine calls over a
|
|
// whole book, the export GC and the idempotency sweep take the ordinary one because neither calls
|
|
// anything. What this function does NOT do is decide how the tick's time is shared between them —
|
|
// that is `pass`, and register row PD-386 is open on it.
|
|
func (s sweeps) passes(sweepBudget time.Duration, log *slog.Logger) []sweepPass {
|
|
out := []sweepPass{{"runs", sweepBudget, s.runs.Sweep}}
|
|
// Materializing what a boundary left is TWO full engine reads of the source — minutes on a large
|
|
// book — so it gets a pass of its own after the money has settled. Inside the runs pass it held up
|
|
// every account's settlement behind it.
|
|
if s.reader != nil {
|
|
out = append(out, sweepPass{"readmodel", refreshSweepBudget, s.reader.Drain})
|
|
}
|
|
if s.books != nil {
|
|
// The intake's pass is allowed MORE than the runs' pass, and the number is not a taste: one
|
|
// book's parse is the same engine call the queue gives `jobs.JobTimeout`, and a pass that
|
|
// cannot contain one kills it — which the intake then counts as a host that cannot run the
|
|
// engine and spends an attempt on. The pass has to be able to hold at least one book.
|
|
out = append(out, sweepPass{"intake", intakeSweepBudget, s.books.Sweep})
|
|
}
|
|
if s.exports != nil {
|
|
// The export GC: take away what has lapsed, and end the polls of builds nobody is coming back
|
|
// for. It rides this tick because both questions are one indexed statement plus a few unlinks,
|
|
// and it takes the ORDINARY budget: nothing inside it calls the engine.
|
|
out = append(out, sweepPass{"exports", sweepBudget, s.exports.Sweep})
|
|
}
|
|
if s.db != nil {
|
|
// Forgetting idempotency keys past their window is the second half of the promise the header
|
|
// makes; it rides this tick because it is one indexed DELETE and needs no schedule of its own.
|
|
out = append(out, sweepPass{"idempotency", sweepBudget, func(c context.Context) error {
|
|
forgotten, err := s.db.SweepIdempotency(c, time.Now().Add(-idempotencyWindow))
|
|
if err == nil && forgotten > 0 {
|
|
log.Info("idempotency keys forgotten", "keys", forgotten)
|
|
}
|
|
return err
|
|
}})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// sweep runs the reconcilers at boot and then on a ticker.
|
|
//
|
|
// The boot pass is not a special case and is not skipped: after a reboot every transient unit is
|
|
// gone, and this is what notices and restarts the runs they were carrying (unified backlog row 138).
|
|
// The intake's own pass rides the same ticker — it answers the same question about a different
|
|
// object, "whose walk stopped and nobody is coming back for it".
|
|
func sweep(ctx context.Context, s sweeps, every, sweepBudget time.Duration, log *slog.Logger) {
|
|
// EACH pass gets its OWN budget, and that is not tidiness: sharing one deadline let the runs pass
|
|
// spend all of it — two runs whose engine hangs are two minutes — and hand an already-expired
|
|
// context to the intake sweep and to the telemetry, every tick, for as long as those runs sat
|
|
// there. The starvation the per-run budget bounds inside one pass would simply have moved up a
|
|
// level, and the metric that shows it would have stopped updating with it.
|
|
pass := func(name string, budget time.Duration, fn func(context.Context) error) {
|
|
c, cancel := context.WithTimeout(ctx, budget)
|
|
defer cancel()
|
|
start := time.Now()
|
|
err := fn(c)
|
|
// A pass that ran out of its budget left work untouched, and the list is ordered the same way
|
|
// every time, so the tail starves. Counted rather than only logged: this is the number that
|
|
// says whether it is happening (register row PD-169).
|
|
s.metrics.ObserveSweep(name, time.Since(start), errors.Is(err, context.DeadlineExceeded))
|
|
if err != nil && !errors.Is(err, context.Canceled) {
|
|
log.Error(name+" sweep failed", "err", err)
|
|
}
|
|
}
|
|
one := func() {
|
|
for _, p := range s.passes(sweepBudget, log) {
|
|
pass(p.name, p.budget, p.run)
|
|
}
|
|
c, cancel := context.WithTimeout(ctx, sweepBudget)
|
|
defer cancel()
|
|
observe(c, s, log)
|
|
}
|
|
one()
|
|
t := time.NewTicker(every)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
one()
|
|
}
|
|
}
|
|
}
|
|
|
|
// observe publishes the state of the control plane. A failure to measure never fails the sweep: it
|
|
// is one WARN and the next tick tries again.
|
|
func observe(ctx context.Context, s sweeps, log *slog.Logger) {
|
|
o, err := s.db.Observe(ctx, runs.StalledAfter)
|
|
if err != nil {
|
|
log.Warn("the control plane's own state could not be read", "err", err)
|
|
return
|
|
}
|
|
s.metrics.ObserveRunner(metrics.Runner{
|
|
QueueDepth: o.QueueDepth,
|
|
OldestHoldSeconds: o.OldestHoldSeconds,
|
|
QuarantinedAttempts: o.QuarantinedAttempts,
|
|
ParkedAttempts: o.ParkedAttempts,
|
|
LiveRuns: o.LiveRuns,
|
|
BooksUploading: o.BooksUploading,
|
|
BooksParsing: o.BooksParsing,
|
|
StalledRuns: o.StalledRuns,
|
|
AbandonedSurfaces: o.AbandonedSurfaces,
|
|
})
|
|
lag, err := s.runs.Lag(ctx)
|
|
if err != nil {
|
|
log.Warn("the tailer's lag could not be read", "err", err)
|
|
return
|
|
}
|
|
s.metrics.ObserveTailerLag(lag)
|
|
}
|
|
|
|
// markerArgv is the command systemd runs when a unit ends. It defaults to the admin CLI next to the
|
|
// running daemon, because that is the one binary guaranteed to be the same build as this process.
|
|
//
|
|
// ⚠ ABSOLUTE or refused, and the refusal is here rather than at the first run because of what
|
|
// systemd does with the alternative — measured on this stand (systemd 259):
|
|
//
|
|
// Failed to start transient service unit: "./tmplatformctl" is neither a valid executable
|
|
// name nor an absolute path
|
|
//
|
|
// That is the whole unit refusing to START, so every run would take a claim, fail to spawn, give the
|
|
// claim back and try again on the next sweep — a loop whose only symptom is that nothing ever runs.
|
|
// `os.Stat` is happy with a relative path, so it cannot be what catches this (register row PD-165,
|
|
// the same class as StateDir's own boot refusal, PD-149).
|
|
func markerArgv(configured string) ([]string, error) {
|
|
bin := configured
|
|
if bin == "" {
|
|
self, err := os.Executable()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("locate tmplatformctl: %w", err)
|
|
}
|
|
bin = filepath.Join(filepath.Dir(self), "tmplatformctl")
|
|
}
|
|
if !filepath.IsAbs(bin) {
|
|
return nil, fmt.Errorf("exit-marker command %s must be an absolute path: systemd refuses any other kind in ExecStopPost", bin)
|
|
}
|
|
if _, err := os.Stat(bin); err != nil {
|
|
return nil, fmt.Errorf("exit-marker command %s: %w", bin, err)
|
|
}
|
|
return []string{bin, "exit-marker"}, nil
|
|
}
|