207 lines
9.2 KiB
Go
207 lines
9.2 KiB
Go
package books
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"golang.org/x/sync/semaphore"
|
|
|
|
"textmachine/platform/internal/jobs"
|
|
)
|
|
|
|
// DefaultMaxCuts is how many books this platform lets the engine cut AT ONCE — counted across every
|
|
// way of starting one: the upload that cuts its own book, the queue's workers and the backstop sweep.
|
|
//
|
|
// The number is small on purpose, and the reason is what a cut IS on this host. `tmctl manifest` is
|
|
// spawned as a plain child process (runner.readEngine) — no transient unit, no cgroup, no MemoryMax,
|
|
// unlike a translation run, which is wrapped in all three.
|
|
//
|
|
// ⚠ What this cap bounds, said exactly, because the loose version of it is false: it bounds the CUTS,
|
|
// on every path that starts one — the upload that cuts its own book, the queue's workers and the
|
|
// backstop sweep. It is NOT a bound on every engine process the host may hold: the materializer reads
|
|
// a manifest and an export of its own through the same uncapped `runner.readEngine`
|
|
// (readmodel.refresh), and the run reconciler reads `status` the same way. Those are bounded by the
|
|
// queue's worker count and by their sweeps being sequential, which is a different bound and a looser
|
|
// one. The path that had NO bound at all was the synchronous cut — N uploads were N engine processes
|
|
// — and that is the one this closes.
|
|
//
|
|
// The figure is the queue's own default worker count and is taken from there rather than repeated
|
|
// here (jobs.DefaultWorkers): the host was already sized for that many engine processes, and this is
|
|
// the same number said once for every way of starting one.
|
|
const DefaultMaxCuts = jobs.DefaultWorkers
|
|
|
|
// ErrHostAtCutCapacity is a cut that never started because the host is already running as many as it
|
|
// will run at once.
|
|
//
|
|
// Not a fact about the book and not a fault of the deployment, and no path treats it as either: at
|
|
// intake it leaves the upload to the queue, and on the queue's own path it gives the claim back
|
|
// without spending an attempt of a budget that exists for a BROKEN host. A host at its cap is a
|
|
// working host.
|
|
var ErrHostAtCutCapacity = errors.New("books: the host is already cutting as many books as it will cut at once")
|
|
|
|
// ErrNoTimeToCut is a cut that was not started because what is left of the caller's budget is less
|
|
// than a cut needs.
|
|
//
|
|
// The SAME class as the cap and treated identically — the engine was never asked, so this pass knows
|
|
// nothing about the book and records nothing — and it exists because waiting for a slot spends the
|
|
// caller's budget. Without it a pass could win a slot with seconds left, hand the engine those
|
|
// seconds, and have the killed process read back as `parser_unavailable`: a verdict about the
|
|
// DEPLOYMENT, which spends an attempt of a budget that five times over deletes the user's file.
|
|
var ErrNoTimeToCut = errors.New("books: what is left of this pass is shorter than a cut")
|
|
|
|
// cutSlots is the cap itself, plus what an operator has to be able to see of it: a cap nobody can
|
|
// watch is indistinguishable from latency somebody has to guess at.
|
|
type cutSlots struct {
|
|
sem *semaphore.Weighted
|
|
limit int64
|
|
// inFlight and waiting are the state RIGHT NOW — how saturated the host is, and how deep the line
|
|
// for it is. Gauges, because the question they answer stops being true the moment it changes.
|
|
inFlight atomic.Int64
|
|
waiting atomic.Int64
|
|
// waited and gaveUp are cumulative, because their question is the opposite one: how often has this
|
|
// cap been reached at all, and how often did reaching it cost a cut. A gauge would answer it only
|
|
// for whoever happened to be looking.
|
|
waited atomic.Uint64
|
|
gaveUp atomic.Uint64
|
|
}
|
|
|
|
// cuts builds the cap on first use.
|
|
//
|
|
// Lazily, because this service is assembled as a struct literal by its deployment (cmd/tmplatformd)
|
|
// and by every test that exercises intake, and a cap that only exists when a constructor was called
|
|
// is a cap absent from exactly the paths nobody remembered to route through one.
|
|
func (s *Service) cuts() *cutSlots {
|
|
s.cutsOnce.Do(func() {
|
|
limit := int64(s.Cfg.MaxCuts)
|
|
if limit <= 0 {
|
|
limit = DefaultMaxCuts
|
|
}
|
|
s.cutSlots = &cutSlots{sem: semaphore.NewWeighted(limit), limit: limit}
|
|
})
|
|
return s.cutSlots
|
|
}
|
|
|
|
// takeCutSlot holds one of the host's cut slots for the caller and returns what gives it back.
|
|
//
|
|
// It WAITS rather than refusing, and the caller's own context is what bounds the wait — the upload's
|
|
// cut budget, the queue job's timeout, the sweep's per-book slice. Waiting is right here because
|
|
// every one of those already has a milder answer than a refusal for running out: the upload is
|
|
// accepted `parsing` and the queue finishes it; a queued pass gives the book straight back. A
|
|
// refusal at the door would turn a host that is merely busy into an upload the user has to do again.
|
|
//
|
|
// It cannot lengthen the walk it is called inside, either — that is not a promise about this code
|
|
// but a property of the context it takes: the cut runs on a step of the upload's walk (books.step),
|
|
// so time spent here is time NOT spent on the engine, never time added to the tail.
|
|
//
|
|
// ⛔ `reserve` is what a WON slot must still be worth. Waiting spends the caller's budget, so a slot
|
|
// won at the very end of it buys a cut the engine has no time to finish — and a killed engine reads
|
|
// back as a fault of the DEPLOYMENT, which spends an attempt of the budget that deletes a user's
|
|
// file after five. Zero means the caller has nothing at stake in losing (the intake spends no
|
|
// attempts), and then waiting to the very end is free.
|
|
func (s *Service) takeCutSlot(ctx context.Context, reserve time.Duration) (func(), error) {
|
|
c := s.cuts()
|
|
release := func() {
|
|
c.inFlight.Add(-1)
|
|
c.sem.Release(1)
|
|
}
|
|
if c.sem.TryAcquire(1) {
|
|
c.inFlight.Add(1)
|
|
return release, nil
|
|
}
|
|
if err := worthStarting(ctx, reserve); err != nil {
|
|
// Every slot is taken and there is not enough left to make winning one worth it. Counted as a
|
|
// give-up, because from the operator's side it is the cap that cost this cut.
|
|
c.gaveUp.Add(1)
|
|
return nil, err
|
|
}
|
|
c.waited.Add(1)
|
|
c.waiting.Add(1)
|
|
started := time.Now()
|
|
wait, stopWaiting := waitCtx(ctx, reserve)
|
|
err := c.sem.Acquire(wait, 1)
|
|
stopWaiting()
|
|
c.waiting.Add(-1)
|
|
if err != nil {
|
|
c.gaveUp.Add(1)
|
|
return nil, fmt.Errorf("%w: waited %s for one of %d slots: %w",
|
|
ErrHostAtCutCapacity, time.Since(started).Round(time.Millisecond), c.limit, err)
|
|
}
|
|
// Won — but the wait spent time, so the question of whether it is still worth cutting is asked
|
|
// AGAIN. A slot handed back unused is a slot the next caller gets.
|
|
if err := worthStarting(ctx, reserve); err != nil {
|
|
c.sem.Release(1)
|
|
c.gaveUp.Add(1)
|
|
return nil, err
|
|
}
|
|
c.inFlight.Add(1)
|
|
// The fact and the wait, at INFO: this is the host doing what it was configured to do, and an
|
|
// operator reading it learns the cap is the thing shaping their latency. What it must NOT do is
|
|
// carry the book — a cut waits because of the HOST, and the book it happens to be for is no more
|
|
// at fault than any other (ENGINEERING_STANDARDS §Наблюдаемость).
|
|
s.log().InfoContext(ctx, "a cut waited for one of the host's cut slots",
|
|
"waited_seconds", time.Since(started).Seconds(), "slots", c.limit)
|
|
return release, nil
|
|
}
|
|
|
|
// CutCapacity is one reading of the cap, for the telemetry pass that publishes it.
|
|
//
|
|
// Read from the service rather than collected on scrape, which is the same rule the rest of this
|
|
// deployment's gauges follow: a scrape must not be able to set the load on anything.
|
|
type CutCapacity struct {
|
|
Limit int
|
|
InFlight int
|
|
Waiting int
|
|
// Waited and GaveUp are cumulative counts of cuts that had to wait at all, and of cuts whose
|
|
// caller ran out of budget while waiting.
|
|
Waited uint64
|
|
GaveUp uint64
|
|
}
|
|
|
|
// CutCapacity reports where the host's cut capacity stands.
|
|
func (s *Service) CutCapacity() CutCapacity {
|
|
c := s.cuts()
|
|
return CutCapacity{
|
|
Limit: int(c.limit),
|
|
InFlight: int(c.inFlight.Load()),
|
|
Waiting: int(c.waiting.Load()),
|
|
Waited: c.waited.Load(),
|
|
GaveUp: c.gaveUp.Load(),
|
|
}
|
|
}
|
|
|
|
// waitCtx bounds a wait so that what is left when it ends is still worth a cut. With no reserve, or
|
|
// with no deadline to take it out of, the caller's own context is the bound.
|
|
func waitCtx(ctx context.Context, reserve time.Duration) (context.Context, context.CancelFunc) {
|
|
deadline, ok := ctx.Deadline()
|
|
if !ok || reserve <= 0 {
|
|
return ctx, func() {}
|
|
}
|
|
// The parent stays the parent, so its cancellation still ends the wait; only the deadline is
|
|
// pulled in by the reserve.
|
|
return context.WithDeadline(ctx, deadline.Add(-reserve))
|
|
}
|
|
|
|
// worthStarting reports whether a cut started now would have the time a cut needs.
|
|
func worthStarting(ctx context.Context, reserve time.Duration) error {
|
|
if reserve <= 0 {
|
|
return nil
|
|
}
|
|
deadline, ok := ctx.Deadline()
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if left := time.Until(deadline); left < reserve {
|
|
return fmt.Errorf("%w: %s left, a cut is given %s",
|
|
ErrNoTimeToCut, left.Round(time.Millisecond), reserve)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// engineNotAsked reports whether an error means the engine was never asked at all — the host was at
|
|
// its cap, or what was left of the pass was shorter than a cut. Neither says anything about the book.
|
|
func engineNotAsked(err error) bool {
|
|
return errors.Is(err, ErrHostAtCutCapacity) || errors.Is(err, ErrNoTimeToCut)
|
|
}
|