Skip to content

fix(distributed): survive a slow control-plane database - #11837

Merged
mudler merged 9 commits into
masterfrom
fix/distributed-db-resilience
Sep 2, 2026
Merged

fix(distributed): survive a slow control-plane database#11837
mudler merged 9 commits into
masterfrom
fix/distributed-db-resilience

Conversation

@localai-org-maint-bot

Copy link
Copy Markdown
Collaborator

What happened

A development cluster stopped loading models and its model replicas thrashed between nodes. The cause was not in the distributed code.

Four inserts had wedged on a corrupt bm25 index in a LocalRecall vector store that shared a PostgreSQL database with the control plane, and they held an open transaction for 42 days. PostgreSQL computes the removable-tuple cutoff per database, so autovacuum could reclaim nothing: backend_nodes reached 1,348,037 dead tuples for 6 live rows and grew to 460 MB, and a six-row scan cost 58,537 buffers and 867 ms.

Nothing in the control plane was broken. It had a slow database. Per hour, across two frontends: 89 context canceled, 32 lock timeouts, 33 connection-refused, 41 in-flight accounting warnings, 8 evicting LRU.

Separating the two databases removes that trigger, but does not make the control plane tolerant of a slow database in general. This PR does.

What changed

1. The router evicted a healthy model on any lookup error. scheduleNewModel asked the registry for a free replica slot and treated every error as "this node is full", so a database timeout evicted a loaded model belonging to another request. The evicted process died, a peer frontend still holding its address dialled the dead port, both retried, and the model thrashed. The comment on the branch already said it meant a full node; the code never tested for it. Now only ErrNoFreeSlot justifies eviction. An audit of the same shape found a second instance in node selection, fixed the same way.

2. Heartbeats no longer write on every beat. Six nodes at a ten second beat is roughly 52,000 UPDATEs a day against a six-row table, and that churn is what turned a blocked autovacuum into an outage. A beat that carries only a fresher timestamp now waits for a checkpoint interval; a first beat, a changed total VRAM, total disk or GPU vendor, and a free VRAM/RAM/disk reading that moves more than 256 MiB still write at once, and a node that is not active is never suppressed. Because the persisted column is now up to one interval stale by design, stale-node-threshold moves from 60s to 5m, and it is now settable (it previously had no binding at all).

3. Worker readiness covers the data path. /readyz tracked only the NATS link, so a worker whose backend processes had died still reported ready and kept receiving loads. One node did exactly that: it answered 200 while its backend port refused connections. Readiness is now the NATS link plus a short dial of each backend the worker believes it is running. An idle worker holding no backends stays ready, and a backend that is still starting is excluded so a cold start does not read as a fault.

4. The database health gauges that would have caught this. localai_control_plane_oldest_xmin_age sits near zero when healthy and was 21,002,291 during the incident. Plus the longest open transaction and the dead-tuple ratio on the registry tables. Sampling is scrape-driven behind a cache, a failed sample returns the last good values rather than failing the scrape, and the gauges are absent until the first successful sample so a down database cannot publish the healthy value.

5. Docs record why the vector store and the control plane must not share a database, so the co-location is not reintroduced.

Behaviour changes for operators

  • Dead-worker detection via the staleness path moves from ~70s to ~4-5m. The per-model gRPC health check and request-time failure are unchanged and still catch a dead node sooner.
  • --stale-node-threshold is now settable, and must exceed --node-heartbeat-checkpoint. This is documented and pinned by a config spec, but is not validated at startup. See the follow-ups.
  • /readyz semantics changed: a worker holding an unreachable backend now reports 503.
  • New gauges on /metrics. Note that pg_stat_activity hides backend_xmin from other roles' backends, so the LocalAI role needs pg_read_all_stats or the gauge sees only its own sessions. Documented.

Reading the commits

The commits tell one story in order, with two deliberate fix-up pairs found by review: 64d54008 is corrected by da1cac09 (a starting backend must not read as unreachable), and 47edb5b6 by 2d6b7ff9 (rate-limit failed samples). Squash-merging is recommended, since a bisect landing between a pair sees a known-transient state.

How to verify

go test ./core/services/nodes/ ./core/services/worker/ ./core/services/monitoring/ ./core/config/ -count=1
make lint

The load-bearing regression is core/services/nodes/router_slot_uncertainty_test.go: revert the errors.Is(slotErr, ErrNoFreeSlot) guard and it fails, which is the point of it. The end-to-end spec tests/e2e/distributed/db_latency_resilience_test.go was verified the same way, and states in its own comments which of its assertions carry the regression and which are secondary under its injection.

Known follow-ups, deliberately not in this PR

  • Nothing validates stale-node-threshold > node-heartbeat-checkpoint at startup. A hard failure would be wrong (the coupling is a spectrum, and booting to a fatal error over a tunable is worse than the flap it prevents); a warning beside the existing registration-token warn is the minimal fix.
  • ResetVRAMBudget does not re-cap available_vram, so that one case waits a full checkpoint interval to self-heal rather than immediately.
  • The heartbeat decision/record split releases its lock between check and write, so two concurrent beats for the same node in one process could both write. The consequence is one duplicate idempotent UPDATE.

Assisted-by: Claude:claude-opus-5 golangci-lint ginkgo

🤖 Generated with Claude Code

https://claude.ai/code/session_01DWTXixqX2PRE76YQDHy7rs

scheduleNewModel asked the registry for a free replica slot and treated
every error as "this node is full", so a control-plane database slow
enough to time out the lookup evicted a healthy loaded model. The
evicted process died, a peer frontend still holding its address dialled
the dead port and retried, and the model thrashed between nodes. The
comment on the branch already said it meant a full node; the code never
tested for it.

Evict only on ErrNoFreeSlot. Any other error now returns and names the
lookup that failed, so a slow database degrades into a diagnosable
load failure instead of into lost work.

An audit of the rest of the router found one branch of the same shape:
node selection discarded the error from its last-resort finder, so a
database timeout there also produced a nil node and evicted for it.
That path now returns unless the finder said gorm.ErrRecordNotFound,
which is the only answer that means the cluster had no node to give.
No other destructive branch in router.go fires on a generic error.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
…y beat

Every heartbeat UPDATEd backend_nodes. Six nodes at a ten second beat is
roughly 52,000 writes a day against a six-row table, and that churn is
what turned a blocked autovacuum into a 460 MB table whose six-row scan
cost 867 ms and timed out the queries that place models.

A beat carrying only a fresher timestamp now waits for the checkpoint
interval. Each reported field is compared against the value last
persisted rather than tested for presence, because a worker sends its
disk figures on every beat and presence alone would suppress nothing.
A node's first beat, a changed total VRAM, total disk or GPU vendor,
and a free VRAM, RAM or disk reading that has moved more than 256 MiB
from the persisted value all still write at once. A node that is not
active is never suppressed, because it recovers only when the health
monitor sees a fresh timestamp.

The persisted column is up to one interval stale by design, so the
stale-node threshold moves from 60s to 5m to cover it.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
…hable

The readiness gate tracked only the NATS link, so a worker whose backend
processes had died still answered /readyz with 200 and kept receiving
loads. One node did exactly that during an incident: it reported healthy
while its backend port refused connections, and every load routed to it
failed.

Readiness is now the NATS link and, for each backend process the worker
believes it is running, a short dial of its recorded address. A worker
holding no backends stays ready, because idle is a healthy state.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
A backend process is inserted into the supervisor map with its gRPC
address already recorded, but the address refuses connections until the
gRPC server binds, which the startup poll allows up to 30 seconds for and
which takes 10 to 15 seconds on a slow node. The new data-path readiness
probe dialled that address straight away, so a worker answered /readyz
with 503 for the whole of every cold backend start. The container
HEALTHCHECK absorbs that, but a Kubernetes readinessProbe at 10s does
not, and the worker would leave rotation each time it loaded a model.

The skip for a stopping process had no counterpart at the other end of
the lifecycle. Backend processes now carry a serving flag, set where the
startup health-check gate succeeds, and the probe dials only processes
that are serving and not yet stopping. backendStartStillValid becomes
markBackendServing: the check and the mark must share one lock hold, so
the flag can only ever land on the entry the key currently owns.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Four transactions wedged on a corrupt index held the vacuum horizon open
for 42 days. Nothing measured it, so the first symptom anyone saw was
models failing to load six weeks later, by which time a six-row table
had grown to 460 MB.

Export the oldest xmin age, the longest open transaction, and the dead
tuple ratio on the registry tables. The first is the number that would
have caught it: it sits near zero in health and was 21,002,291.

Sampling is scrape-driven behind a cache, and a failed sample reports
the last good values rather than failing the scrape, because these
gauges matter most when the database is already struggling.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
The cache advanced its clock only on a successful sample, so once the
database started failing every scrape retried the query immediately.
That turned the cache off in the one regime it exists for: a retry
storm at scrape cadence aimed at a database already in trouble. A
catalog read that consistently exceeds the 5 second timeout also paid
that cost on every scrape, with all scrapes serialised behind the
sampler mutex.

Time every attempt rather than every success, so failures and timeouts
cost the same interval as good samples. Whether a good sample exists
moves to its own field, keeping the gauges absent until the first
success and holding the last good values through later failures.

Also note in the runbook that pg_stat_activity cannot see prepared
transactions or replication slot xmins, so a healthy-looking xmin age
does not by itself rule out a blocked horizon.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Exercises the real distributed stack against a control-plane database that
refuses the router's slot lookup, and asserts the scheduler reports the
lookup it could not answer instead of falling through to eviction.

The failure is injected with privileges rather than a statement timeout. A
timeout set with ALTER DATABASE also breaks AutoMigrate, and it leaks into
every later spec in the suite unless it is reset, so the spec would end up
testing the migration rather than the scheduler. Instead the spec creates a
dedicated login role, points a second gorm handle at it, and revokes that
role's SELECT on node_models.replica_index. This has to be a separate role:
the test container's owner is a PostgreSQL superuser, and superusers bypass
every privilege check, so revoking from CURRENT_USER is recorded and then
ignored.

The revoke is scoped to one column on purpose. Revoking the whole table
would also blind node selection, which runs first and has a guard of its
own, so the scheduler would never reach the slot lookup this spec is about.
Leaving every other column readable lets selection succeed and lands the
refusal exactly on NextFreeReplicaIndex, which plucks replica_index. The
grant is restored from BeforeEach via DeferCleanup, so a failing assertion
or a panic cannot hand the next spec a role that cannot read.

Reverting the eviction guard fails this spec, which is the point of it: the
router then reports "no replica slot on keeper and eviction failed" for an
error that was never evidence the node was full. The surviving-row
assertions are secondary under this injection, because the eviction path
reads whole node_models rows and the same revoke blinds it too; a comment
in the spec says so, so nobody mistakes them for the load-bearing ones.

Also documents why the vector store and the control plane must not share a
database: the removable-tuple cutoff is per database, not per table, so one
transaction left open anywhere stops autovacuum reclaiming the node
registry, and a six-row table bloats into hundreds of megabytes. The note
names LOCALAI_AUTH_DATABASE_URL and LOCALAI_AGENT_POOL_DATABASE_URL as the
two knobs that must differ, and the localai_control_plane_oldest_xmin_age
gauge as the way to see it coming.

grep for StaleNodeThreshold and HealthCheckInterval in
core/config/runtime_settings_registry.go returns no matches: the
distributed duration knobs are not exposed as runtime settings, so the new
heartbeat checkpoint interval follows them and needs no registry entry.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
The stale-node threshold moved from 60 seconds to 5 minutes in this branch
because checkpointing makes last_heartbeat up to one checkpoint interval
behind by design. Two things were left inconsistent with that. NewHealthMonitor
still fell back to a hardcoded 60 seconds when handed a zero threshold, so any
future caller that stopped passing the configured value would mark every
healthy, beating node offline on every cycle. And the threshold itself had a
flag-name constant but no AppOption, no CLI field and no env binding, so an
operator who widened --node-heartbeat-checkpoint had no way to widen the
threshold to match. The fallback now tracks config.DefaultStaleNodeThreshold,
and --stale-node-threshold / LOCALAI_STALE_NODE_THRESHOLD is wired the same
way its sibling is.

Heartbeat suppression compared the RAW reported free VRAM against the
snapshot, but the column persists capAvailable(raw, ceiling). On any node with
a VRAM budget set, whose actual free VRAM oscillates above that ceiling, every
beat looked material while the persisted value never moved: suppression was
defeated on exactly the nodes an operator had configured, and the write
amplification this branch exists to remove came straight back there. The
comparison and the snapshot now both hold the capped figure, so they measure
the same quantity as the column.

Fixing that needs the ceiling, and reading it cost a SELECT on every beat,
including suppressed ones. The skip decision therefore moved ahead of the
updates map and now reuses the ceiling cached on the last durable write, while
the write path still re-reads it before capping anything. A ceiling that
changed inside the checkpoint window can cost one extra or one late write; it
cannot persist a wrong figure. A suppressed beat now costs no query at all.

Also: the operations section now says to grant pg_read_all_stats to the
LocalAI role, because PostgreSQL blanks backend_xmin and xact_start for
sessions owned by other roles, and the transaction that wedged the horizon in
the incident was a co-located vector store connecting as a different role, so
without the grant the new gauge sees only our own sessions. The compose
healthcheck comment now describes readiness covering the backend data path,
and the control-plane gauge registration records the otel.SetMeterProvider
ordering it depends on.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
The dead-tuple gauge queried pg_stat_user_tables against a hardcoded list
of three table names. Those three do not agree on where their name comes
from: BackendNode and NodeModel take gorm's default pluralisation, while
GalleryOperationRecord overrides TableName, and gallery_operations
already had a constant of its own that the list duplicated.

A literal list keeps compiling after any of that moves, and the query
then matches nothing. The failure is silent and it points the wrong way:
a dead-tuple ratio that matched no rows reports the same numbers as a
cluster with no bloat, so the gauge would look healthiest exactly when it
had stopped working.

Ask gorm what each model is stored as instead, which follows a TableName
override and the default pluralisation alike. A spec pins that the
override really is consulted: naive pluralisation of the type would give
gallery_operation_records, so the resolution cannot quietly stop asking
the model.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
@mudler
mudler merged commit 9afe10b into master Sep 2, 2026
70 checks passed
@mudler
mudler deleted the fix/distributed-db-resilience branch September 2, 2026 10:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants