textmachine/platform/cmd/tmplatformd/runner.go

137 lines
4.8 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"time"
"textmachine/platform/internal/config"
"textmachine/platform/internal/httpapi"
"textmachine/platform/internal/money"
"textmachine/platform/internal/pgstore"
"textmachine/platform/internal/pricing"
"textmachine/platform/internal/runner"
"textmachine/platform/internal/runs"
)
// startRunner wires the run lifecycle and returns the function that stops it.
//
// 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, deps *httpapi.Deps) (func(), error) {
deps.Library = db
if !cfg.RunsEnabled() {
log.Warn("no TM_PLATFORM_ENGINE_BIN: the library is served read-only and no run can be started")
return func() {}, nil
}
model, err := pricing.New(money.MicroUSD(cfg.Runner.PerChapterMicroUSD))
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)")
}
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)
}
svc := &runs.Service{
Store: db,
Runner: rn,
Engine: rn,
Pricing: model,
Cfg: runs.Config{
StateDir: cfg.Runner.StateDir,
EngineBinary: cfg.Runner.EngineBinary,
MarkerArgv: marker,
Ceiling: ceiling,
MemoryMax: cfg.Runner.MemoryMax,
TasksMax: cfg.Runner.TasksMax,
AllowEngineVersionChange: cfg.Runner.AllowEngineVersionChange,
ResyncEvery: cfg.Runner.ResyncEvery,
},
Log: log,
}
queue, err := runs.NewQueue(db.Pool(), svc, log, cfg.Runner.Workers)
if err != nil {
return nil, err
}
svc.Queue = queue
deps.Runs = svc
if err := queue.Start(ctx); err != nil {
return nil, fmt.Errorf("start queue: %w", err)
}
go sweepRuns(ctx, svc, cfg.Runner.SweepEvery, 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
}
// sweepRuns runs the reconciler 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).
func sweepRuns(ctx context.Context, svc *runs.Service, every time.Duration, log *slog.Logger) {
sweep := func() {
c, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
if err := svc.Sweep(c); err != nil && !errors.Is(err, context.Canceled) {
log.Error("run sweep failed", "err", err)
}
}
sweep()
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
sweep()
}
}
}
// 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.
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 _, err := os.Stat(bin); err != nil {
return nil, fmt.Errorf("exit-marker command %s: %w", bin, err)
}
return []string{bin, "exit-marker"}, nil
}