Documentation
¶
Overview ¶
Package autoscale holds the primitives shared by spirit's phase-level thread-count controllers.
Everything here is needed by more than one phase and belongs to none of them:
- The utilization zone law: the watermark/cooldown constants, Classify, and MinVCPUs (the instance size below which the signal is too coarse for the law to work at all). The copier's write/read autoscaler and the checksum controller apply the same law to different pools; defining the thresholds once means they cannot silently drift apart.
- Gate (in controller.go), which turns one tick's signals into a Plan. It owns the precedence between the zones, a caller-supplied veto, and the cooldown bookkeeping — the part most likely to drift if each phase kept its own copy, and the hardest to notice when it does.
- The plumbing every controller repeats: Ceiling for a scalable pool's upper bound, RunTicker for the tick loop, Emit for best-effort gauges.
- Limiter, a concurrency gate whose limit can change while work is in flight. errgroup.SetLimit cannot: the errgroup contract forbids modifying the limit while any goroutine in the group is active, so a phase that wants to be resized mid-pass needs its own gate.
What stays with each phase is what is genuinely phase-specific: how big a step is, which pool it lands on, and what may veto one. The copier apportions a step between its read and write pools using the applier queue; the checksum has one pool and a change-feed backlog veto.
The law's rationale — why additive steps with a multiplicative panic backoff, and why the dead band has hysteresis — is documented at length on the copier's autoScaler, which was where it was first derived (issue #831). This package deliberately holds only the mechanism, not that history.
Index ¶
- Constants
- func CeilDiv(n, d int) int
- func Ceiling(start int, enabled bool) int
- func ClientCeiling() int
- func Emit(ctx context.Context, sink metrics.Sink, logger *slog.Logger, ...)
- func FlushBatchSize(concurrency int) int
- func FlushBounds(vCPUs int) (concurrency, batchSize int)
- func ReadBounds(vCPUs int) (start, ceiling int)
- func RunTicker(ctx context.Context, interval time.Duration, fn func(context.Context))
- func WriteStart(vCPUs int) int
- type Action
- type Gate
- type Inputs
- type Limiter
- type Plan
Constants ¶
const ( // LowWatermark is the effective setpoint: below it there is headroom, so a // pool may add a thread (subject to cooldown). LowWatermark = 0.4 // HighWatermark starts the additive back-off. The dead band between the // watermarks must be wider than the utilization step of a single thread, // otherwise one +1 can vault across the band and ping-pong with the -1 // path. HighWatermark = 0.7 // PanicThreshold is where back-off turns multiplicative. At 1.0 the // smoothed signal has reached the throttle point, where the binary // hard-stop is typically already firing on raw samples — so halving is // about resuming gently, not about stopping the bleeding. PanicThreshold = 1.0 // CooldownTicks is how many ticks a direction holds after a change before // it may fire again, giving the change time to register in the signal. // Increases and decreases hold independent cooldowns. CooldownTicks = 2 // MinVCPUs is the smallest instance size (in vCPUs) on which a controller is // allowed to engage at all. It is a property of the law above rather than of // any one phase: the utilization signal's denominator is the vCPU count, so // below this one thread is half or a third of the whole scale and no dead band // is wide enough to rest in — the controller can only oscillate. Observed in // staging on r6g.large (2 vCPUs): the write-thread count ping-ponged 1↔2 // indefinitely (issue #831). At 4+ vCPUs the worst-case per-thread step (0.25) // fits inside the dead band. // // The migration runner enforces it once, at setup, by disabling autoscaling // for the whole migration; the controllers themselves never see a small // instance. MinVCPUs = 4 // VCPUReserve is how many vCPUs a pool sized from the instance leaves free, // so spirit never nominally claims the whole server: the other pool, the // server's own background work, and the application all need room. Both the // write pool (max(1, vCPUs-VCPUReserve)) and the read-side starting point // (see ReadBounds) subtract it. VCPUReserve = 2 // MinReadStartThreads is the floor on a read-side pool's starting size. // ReadBounds' divisor drives small instances down to 1, which would make the // copy single-threaded until the controller has ramped for 15s a step; two // threads is the smallest start that still overlaps read and apply work from // the first chunk. MinReadStartThreads = 2 // FlushRowsInFlight is how many buffered rows one change-feed drain has // outstanding across all of its concurrent REPLACE statements. FlushBounds // holds it constant across instance sizes, trading batch size for // concurrency rather than adding rows. // // Its value is the historical change.DefaultFlushConcurrency × // change.DefaultBatchSize, so an instance small enough to hit the floors // below gets exactly what it got before this derivation existed. This // package cannot import pkg/change (change imports it), so the agreement is // pinned by TestFlushBoundsPreservesChangeDefaults over in pkg/migration, // which can see both. FlushRowsInFlight = 8000 // MinFlushConcurrency is the floor on a derived flush width, equal to the // historical change.DefaultFlushConcurrency. Deriving downwards was never // the goal: the AIMD controller already narrows a flush that is actually // contending, and it does so from evidence rather than from a core count. MinFlushConcurrency = 8 // MinFlushBatchSize is the floor on a derived batch size. Below roughly this // many rows a REPLACE spends more of its life on the round trip than on the // rows it carries, so splitting further stops buying a smaller lock // footprint and starts buying only statements. It is deliberately well above // the AIMD controller's own floor (change.minAdaptiveBatchSize, 50), which // is a distress value reached only after four contention steps and not a // sane starting point. MinFlushBatchSize = 250 // MaxFlushConcurrency caps the derived flush width. It is not an independent // judgement — it is exactly where FlushRowsInFlight meets MinFlushBatchSize, // i.e. the widest flush that can still hold the rows-in-flight invariant. // Past this point more concurrency would mean more rows in flight, which is // the trade FlushBounds exists to avoid making. MaxFlushConcurrency = FlushRowsInFlight / MinFlushBatchSize )
const ClientThreadsPerCore = 16
ClientThreadsPerCore bounds how many workers spirit will run per core of its *own* host, independent of how large the target is.
Every derivation in this file sizes pools from the target instance, on the premise that a worker is mostly waiting on the server. That premise fails quietly when spirit itself is small: a worker also builds its INSERT statement client-side (a datum conversion and a string format per value), and that part is pure local CPU. Measured on a 96-vCPU target, roughly 60% of a write worker's cycle was client-side even on a 16-core host — so the useful worker count is closer to a small multiple of local cores than to the target's vCPU count.
16 is deliberately permissive rather than tuned. The failure it exists to prevent is the order-of-magnitude one: spirit on a 4-core pod deriving 94 write threads from a 96-vCPU target, where ~7 threads' worth of work actually progressed and the other 87 only added queueing and latency. That is ~23 threads per core; capping it at 64 still cuts it by a third, while a 16-core host (256) clears every derivation for targets up to 128 vCPUs, growth ceilings included. The growth ceiling is what binds, not the start: the largest size in the docs' table (192 vCPUs -> 190 write threads growing to 380) needs 24 cores to be fully unconstrained. TestClientCeiling pins both.
The ratio has to sit well above the healthy operating point, not near it. Measured on 16 cores: ~99 write workers, 6.2 per core, with local CPU not saturated — so a cap of 8 per core would begin binding on a host that was still keeping up, and would clip the write pool's room to grow (188 -> 128) while every signal read healthy. 16 leaves ~2.6x headroom over the observed point. A ratio tight enough to be a real sizing input would need the client-side share measured live, which is a feedback loop rather than a constant.
const Tick = 5 * time.Second
Tick is how often a controller should re-evaluate. Aligned with the throttler poll interval — sampling faster than the signal updates just adds noise. Callers copy this into their own var so tests can shorten it without racing other packages.
Variables ¶
This section is empty.
Functions ¶
func CeilDiv ¶
CeilDiv returns ceil(n/d) for positive integers — used for multiplicative halving so that, e.g., 3 backs off to 2 rather than 1.
func Ceiling ¶
Ceiling resolves the upper bound of a scalable pool: the start value when scaling is disabled (so the pool cannot move), twice it when enabled.
It is here rather than in either phase because three copies of "2 ×" invite drift, and because the migration runner resolves the same ceilings itself to hand back to the phases. Callers with an extra rule of their own wrap this rather than reimplementing it (see throttler.ResolveMaxWriteThreads, which additionally refuses to grow the write pool when the redo-aware signal has no commit-latency backstop).
This is a thread ceiling and not a connection budget. Threads scaled past the size of the sql.DB pool they share queue on checkout and buy no parallelism; the pool is --max-connections and does not grow to meet them.
The floor of 1: a zero or negative start would hand a phase that always runs at least one worker a ceiling it already exceeds, which its controller has no way to steer down from.
func ClientCeiling ¶
func ClientCeiling() int
ClientCeiling returns the largest worker count this process can usefully run, from GOMAXPROCS rather than the machine's core count so that a container CPU limit is respected (Go 1.25+ derives GOMAXPROCS from the cgroup quota).
It is a ceiling only: callers take min() with the target-derived size and never scale *up* to it. A fast client does not justify more workers than the target can absorb.
func Emit ¶
func Emit(ctx context.Context, sink metrics.Sink, logger *slog.Logger, values ...metrics.MetricValue)
Emit sends one tick's gauges, or does nothing when sink is nil. The send is bounded by metrics.SinkTimeout and a failure is logged at Debug: a controller must never stall or fail a migration because a metrics sink is unavailable.
Callers compose their own value list, because which gauges are meaningful differs per phase — notably, a phase with no continuous signal omits utilization rather than reporting a hard zero, which on a dashboard would read as "completely idle" instead of "not measured".
func FlushBatchSize ¶ added in v0.17.0
FlushBatchSize returns the batch size that pairs with the given flush concurrency to hold FlushRowsInFlight rows in flight, floored at MinFlushBatchSize. FlushBounds returns both halves together; this exists for the caller that has already clipped the concurrency FlushBounds derived (the migration runner applies ClientCeiling to it) and needs the batch size re-paired to the number it is actually going to use. Re-pairing rather than keeping the original batch size is the point: a narrower flush wants *larger* batches to push the same rows, and the collision cost of that is the cost the pre-derivation code already paid.
func FlushBounds ¶ added in v0.17.0
FlushBounds returns the concurrency and batch size a change-feed flush should use on an instance of the given vCPU count. Unlike the read and write pools these are not autoscaled at runtime — the flush path has its own AIMD controller, driven by lock contention rather than by CPU — so this is a starting point only, and the AIMD penalty shifts both terms down from here.
The two returned values are not independent. Their product is the number of rows a drain has in flight, and it is held at FlushRowsInFlight regardless of instance size: a larger instance buys *more statements*, not more rows at once. That is the whole point, and it is why this can be widened past the historical concurrency of 8 without re-opening the deadlocks that made the AIMD controller necessary in the first place.
The reason the trade is free is that a REPLACE's collision risk scales with its own lock footprint, not with how many siblings it has. A flush batch takes a next-key lock per row per UNIQUE secondary index, so two batches collide when any of their rows land in adjacent slots of any such index; the chance of that is set by how many slots each statement claims. Concurrency only sets how many claims are outstanding at once. So 32x250 and 8x1000 push the same rows per unit time, but the wide-and-narrow form holds a quarter of the locks per statement and is correspondingly less likely to collide — strictly safer than what it replaces, not a risk traded for throughput. (TestReplaceContendsOnlyOnUniqueIndexes in pkg/applier establishes the premise: PK-adjacent rows do not contend, UNIQUE-secondary-adjacent rows do.)
Callers must have already established that the instance is at least MinVCPUs; below that they should not call this at all and the change package's defaults apply. Small instances get today's values anyway, because the concurrency floor is the historical default.
func ReadBounds ¶
ReadBounds returns the starting size and ceiling for a read-side pool — the copier's read workers and the checksum's workers — on an instance of the given vCPU count: start at about a quarter of the instance, grow to at most half of it. Callers must have already established that the instance is at least MinVCPUs; below that no controller engages at all.
This is deliberately not the write side's shape (start at vCPUs-VCPUReserve, grow to 2x that), because the two pools are limited by different things. Write threads spend most of their life parked on a redo-log flush, so a count above the vCPU count is not oversubscription — it is what keeps the log busy, and it is why the redo-aware load signal excludes those waiters. A read thread scanning a table that is already in the buffer pool is pure CPU, so the same count really does compete with the application for cores: oversubscribing here is how a checksum ends up degrading the workload it was supposed to be invisible to.
The ceiling is a fixed share of the instance rather than a multiple of the start because for the checksum it is not a hypothesis, it is a cost paid up front: the snapshot transactions must all take their read view at the same instant, so the whole pool is created serially under the table lock whether or not scaling ever reaches it. Half the box is the most that is worth holding a cutover-class lock to reserve, and it leaves the other half to the workload spirit is supposed to be invisible to.
At exactly MinVCPUs the two bounds meet (start 2, ceiling 2), so the read side can shed but not grow. That is the intended reading of a 4-vCPU instance: two readers is already half of it.
func RunTicker ¶
RunTicker drives fn every interval until ctx is cancelled. Controllers keep their tick step as a separate method so tests can drive it directly without real time; this is only the loop around it.
func WriteStart ¶
WriteStart returns the starting size for the apply (write) pool on an instance of the given vCPU count: the whole instance less VCPUReserve, floored at one.
There is no matching WriteCeiling here because the write side's upper bound is not purely a function of the instance — it also depends on which load signal the throttler picked and whether the commit-latency backstop is armed. That rule lives with the throttler, in ResolveMaxWriteThreads.
Types ¶
type Action ¶
type Action int
Action is the decision the zone law reaches for one sample of the utilization signal.
func Classify ¶
Classify maps one utilization sample onto the zone law:
util < LowWatermark Grow [LowWatermark, HighWatermark) Hold [HighWatermark, PanicThreshold) Shed util >= PanicThreshold Halve
It is deliberately pure and cooldown-free: callers own the cooldown state, because which cooldown gates which action differs between the write-only, dual-pool, and checksum controllers.
type Gate ¶
type Gate struct {
// contains filtered or unexported fields
}
Gate owns the cooldown bookkeeping every controller needs and turns Inputs into a Plan. It is a value type; the zero value is ready to use.
Increases and decreases hold independent cooldowns. That asymmetry is load-bearing: a fresh overload must be able to shed immediately even right after an increase — which likely caused it — while consecutive decreases still space themselves out far enough for the signal to reflect the previous cut.
func (*Gate) Applied ¶
Applied arms the cooldowns for a plan the controller carried out. Any decrease arms the up cooldown too, so a shed is not immediately undone by a growth signal that has not yet had time to reflect the cut.
func (*Gate) Decide ¶
Decide resolves one tick. It is pure: the caller records the outcome with Applied or Idle, because whether a permitted plan was actually carried out is the controller's business (the copier declines to grow a balanced pipeline even when the zone law allows it, and must not burn a cooldown for a move it did not make).
Precedence, highest first:
- Halve. Both this and a veto shed, and this sheds faster, so it has to come first — otherwise a tick spent inside the veto's cooldown would consume the panic response and downgrade the backoff to one thread per cooldown at exactly the moment the server is most overloaded.
- The veto, which therefore outranks Shed, Grow and Hold. Utilization can show plenty of headroom while a caller-visible prerequisite falls behind.
- The zone law: Shed, then Grow, then recovery inside the dead band.
type Inputs ¶
type Inputs struct {
// Zone is the utilization verdict, from Classify. A controller with no
// continuous signal passes Hold, which leaves recovery available but rules
// out growth.
Zone Action
// Veto, when set, sheds a thread and outranks the zone law's Shed, Grow and
// Hold — but not Halve. It is for pressure the utilization signal cannot
// see: the checksum's change-feed backlog is a hard prerequisite for
// finishing a migration while contributing almost nothing to server load.
Veto bool
// GrowthBlocked suppresses PlanGrow and PlanRecover without shedding
// anything. Two cases need it: a veto that has already been paid for this
// window (otherwise the intervening ticks would grow straight back into the
// shed and the two signals would cancel out), and a veto signal that has
// gone stale, where freezing is right but shedding on no evidence is not.
GrowthBlocked bool
// CanRecover enables PlanRecover — typically "the live count is below the
// configured start".
CanRecover bool
}
Inputs is one tick's worth of signals for Gate.Decide.
type Limiter ¶
type Limiter struct {
// contains filtered or unexported fields
}
Limiter is a counting semaphore whose limit may change while permits are held. It is the resizable stand-in for errgroup.SetLimit, which may not be modified while goroutines in the group are active.
Lowering the limit never interrupts work in flight: the reduction is absorbed by subsequent Releases, so callers holding a permit always run to completion. That property is what makes shedding cheap for phases whose unit of work is expensive to redo — a cancelled chunk is wasted I/O, a parked worker is not.
The zero value is not usable; call NewLimiter.
func NewLimiter ¶
NewLimiter returns a Limiter admitting at most limit concurrent holders. A limit below 1 is raised to 1 — a limiter that admits nobody would deadlock its caller rather than throttle it.
func (*Limiter) Acquire ¶
Acquire blocks until a permit is available or ctx is done, returning ctx.Err() in the latter case. On success the caller must call Release exactly once.
func (*Limiter) InFlight ¶
InFlight reports how many permits are currently held. It can exceed Limit transiently, right after a reduction that in-flight holders have not yet absorbed.
type Plan ¶
type Plan int
Plan is what a controller is permitted to do on one tick: the zone law's verdict after cooldowns and any caller-supplied veto have been applied.
It says what kind of move is allowed, not how big it is or which pool it lands on. Those stay with the controller: the copier apportions a step between its read and write pools using the applier queue, while the checksum has a single pool and no arbitration to do.
const ( // PlanNone means nothing may change this tick — the dead band, or a // cooldown still running, or growth suppressed with nothing to shed. PlanNone Plan = iota // PlanHalve is the multiplicative backoff of the panic zone. PlanHalve // PlanShed is the additive decrease of the soft-overload zone. PlanShed // PlanShedVeto is an additive decrease demanded by the caller's veto rather // than by utilization. It is distinguished from PlanShed so the controller // can log the real reason, and so a controller that must record having paid // for the veto can tell the two apart. PlanShedVeto // PlanGrow is the additive increase of the headroom zone. PlanGrow // PlanRecover is an additive increase back toward the configured start // value, offered inside the dead band. It exists for phases that shed for a // reason unrelated to utilization and would otherwise sit on the reduced // count for the rest of the run — a signal saying "no longer overloaded" is // enough to undo such a shed, whereas going *past* the operator's configured // count requires positive headroom (PlanGrow). PlanRecover )