Skip to content

fix: Keep the indexer's memory flat during Meilisearch stalls - #114

Merged
ecv merged 8 commits into
mainfrom
fix/indexer-bounded-flush
Sep 9, 2026
Merged

ecv merged 8 commits into
mainfrom
fix/indexer-bounded-flush

Conversation

@ecv

@ecv ecv commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

Summary

When Meilisearch slows down, the indexer keeps starting flushes and each one holds its batch of audit events in memory until Meilisearch answers, so a replica climbs to its memory ceiling and search stops updating.

The task wait had no deadline because the timeout flag was passed to the client as a polling interval, a timed-out enqueue was retried three times inside the flush, and a failed batch was never returned to JetStream, so the same work was redelivered and re-enqueued every two minutes.

The indexer now caps the flushes in flight, blocks intake while the cap is reached, keeps a waiting batch alive with progress acks under a real ceiling, and returns a failed batch to JetStream promptly, so memory stays flat under a fan-out of any size and a stall shows up as a warning log line and in new indexer metrics.

All of one message's index operations now enter a batch together, which closes a path where a message was acknowledged while some of its deletes were still pending, and the controller manager's task wait becomes a real ten minute deadline.

Test plan

  • With Meilisearch paused, a replica's memory stays flat while the backpressure warning and in-flight flush metric show the stall, and it drains once Meilisearch resumes
  • A delete of a resource matched by several policies is removed from every index and its event is acknowledged exactly once
  • Ack pending and redelivered counts on the search-indexer consumer fall after rollout instead of tracking each other
  • Unit tests, race detector, and kustomize render pass

Deletes still fan out to every project regardless of the multi-tenancy flag, tracked as #116.

Note

This ships to production on the next release tag through the infra repo pin, with staging skipped for the same reason as datum-cloud/infra#5002.

Fixes #113

Related to https://github.com/datum-cloud/infra/issues/4999

🤖 Generated with Claude Code

https://claude.ai/code/session_011ML5AmU7i7zuSALJitvS8f

ecv and others added 3 commits September 9, 2026 13:36
Every batch flush ran in its own goroutine and held its NATS
messages, payloads included, until Meilisearch finished the task.
Nothing capped those goroutines, and the JetStream handler never
blocks, so a slow Meilisearch let them pile up until the replica
hit its memory limit. A large delete fan-out drove ten replicas
from a few megabytes to the ceiling in under a minute each.

Three defects compounded it. The task wait had no deadline because
the timeout flag was passed to the Meilisearch client as the poll
interval. A timed-out enqueue was retried three times inside the
flush, holding a slot for about three minutes against a two minute
ackWait. A failed batch was neither acked nor nacked, so the same
work was redelivered and re-enqueued every ackWait.

The batcher now caps in-flight flushes and blocks intake while the
cap is reached, so the consumer's prefetch window bounds what a
replica holds. Every task wait carries a real deadline, timeouts
are not retried, and a failed batch is nacked with a short delay.
All of one message's index operations enter a batch together, which
closes a path where a message was acked while some of its deletes
sat in the next batch. Delete flushes trigger on message count so
each flush sends one full task per index instead of ten small ones.

New indexer metrics and a rate-limited warning make a stall visible
instead of silent. The consumer ackWait rises to 300s to stay above
the worst-case flush budget the indexer now validates at startup.

Resolves: #113

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ML5AmU7i7zuSALJitvS8f
The indexer now serves /metrics, but nothing in the manifests
exposed it. Add the container port, a headless Service for
discovery, and a ServiceMonitor matching the apiserver's, so the
new flush and backpressure metrics reach Prometheus.

Related to #113

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ML5AmU7i7zuSALJitvS8f
The manager's task wait timeout was passed to the Meilisearch
client as a poll interval, so its 3s manifest value never bounded
anything. With the client fixed it becomes a real deadline, and 3s
would fail every index create or delete. Raise the default and the
manifest value to ten minutes, which covers index operations on
large indexes.

Related to #113

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ML5AmU7i7zuSALJitvS8f
ecv and others added 4 commits September 9, 2026 14:00
Validate()'s 2 * (http-timeout + task-wait-timeout) <= ackWait
check had no test covering the boundary, and the 300s
consumerAckWait constant it budgets against duplicates the ackWait
in nats-consumer.yaml with nothing tying the two together.

Add a table test for the budget assertion around the 300s boundary,
and a test that parses the shipped manifest and fails when the
search-indexer consumer's ackWait drifts from the constant.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ML5AmU7i7zuSALJitvS8f
Validate()'s comment documents a residual case as accepted but
untested: one Submit call can make an upsert flush and a delete
flush due together, so a message can wait for two flush slots back
to back with all slots held.

Add a test that primes the batcher to trip both triggers in one
Submit call while the sole flush slot is held, and confirms the
second launch waits its turn instead of spawning past
MaxInFlightFlushes, with both flushes completing once slots free.

Name the production signal for this case in the same Validate()
comment: the backpressure warning log and the
search_indexer_flush_slot_wait_seconds histogram.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ML5AmU7i7zuSALJitvS8f
Measured in production at a healthy 2.8k-deep task queue, a delete
task takes a median of 47s and up to 79s from enqueue to finish,
and a flush waits for the slowest of its ten tasks. A fixed 60s
wait would have nacked most batches and manufactured the duplicate
task storm it was meant to end.

The flush now sends a progress ack for every message in its batch
each minute while the tasks run, so JetStream extends the ack
window for as long as the flush is genuinely waiting. The wait
timeout becomes a ten minute backstop against a wedged Meilisearch
rather than a budget that has to fit under ackWait, and startup
validation checks only that three heartbeats fit inside ackWait.

Sizing follows the same profile. The in-flight cap rises to 16 so
completions clear the stream's fill rate at that latency, the
upload semaphore to 200 so sixteen flushes fanning out to ten
indexes do not throttle inside the cap, and the flush interval to
10s so ticker-driven batches carry more messages per task.

Related to #113

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ML5AmU7i7zuSALJitvS8f
The heartbeat budget was validated only against a constant that
mirrors the manifest. Production's consumer still carries the old
120s ackWait until the NACK controller reconciles the new value,
and whether it updates AckWait on an existing durable consumer is
unverified, so the binary could believe it had a 300s window while
running under 120s.

The indexer now reads each consumer's live AckWait after lookup,
logs it next to the heartbeat interval, warns when fewer than three
heartbeats fit, and refuses to start only when fewer than two fit.
Failing on the softer margin would crashloop a rollout that lands
before the consumer is reconciled, which would stop indexing to
protect it.

The consumer manifest comments now state the heartbeat invariant
instead of the task-wait budget the previous commit replaced.

Related to #113

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ML5AmU7i7zuSALJitvS8f
@ecv
ecv marked this pull request as ready for review September 9, 2026 18:47
@ecv
ecv enabled auto-merge September 9, 2026 18:47
@ecv
ecv disabled auto-merge September 9, 2026 18:47
@ecv
ecv enabled auto-merge September 9, 2026 18:47
@ecv
ecv requested review from JoseSzycho, kevwilliams and scotwells and removed request for scotwells September 9, 2026 18:47
@ecv

ecv commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Both reviewers reached merge on the second pass at 8aa159b.

pr-rereviewer: "VERDICT: merge. All six original findings are confirmed fixed with reproducible evidence, the two follow-on commits (0c9cc0d, 8aa159b) build, vet, and pass go test -race for the touched packages, and every new/changed test (four from 0c9cc0d, one from 8aa159b) was tamper-verified to fail for the reason it was written to catch; the only open item is a non-blocking PR-body staleness nit about the heartbeat mechanism." It ran a build, go vet, go test -race on the touched packages, and tamper-verification of each new or changed test.

pr-conventions-reviewer: "VERDICT: merge. All three prior-pass findings verify fixed at 8aa159b, the ackWait comments state the heartbeat invariant and match checkAckWait's logic exactly, the summary is four one-sentence paragraphs, and the go.mod/#77 sequencing is the maintainer's settled call, and every kustomize render (dev, core-control-plane, ci) reproduces exactly the diff's objects with no undisclosed additions; the one open item is the unaddressed #108 go.mod overlap, which routes to a human rather than blocking." It rendered the dev, core-control-plane, and ci kustomize overlays and compared them against the diff.

Three findings went to the human maintainer rather than being settled in this loop. The single-tenant delete gate in the indexer's consumer stays out of this PR and is tracked in #116. The rollout reaches production on the next release tag through the infra repo pin, with staging skipped for the same reason as datum-cloud/infra#5002, as the PR body's NOTE callout already states. The go.mod overlap with renovate #77 and dependabot #108 gets settled by merge order: #114 merges first, and both bot PRs rebase themselves against it. That third call had not been recorded anywhere on GitHub before now.

Production evidence for the fix, goroutine, heap, and CPU profiles from a production pod, Meilisearch task latency, and consumer state, is posted on the addendum at #113 (comment).

CI is green on 8aa159b. The test-environment-validation check failed once on the chainsaw meilisearch-health suite, a direct curl smoke test against Meilisearch with no path through any file this PR touches, and passed on a rerun of the same commit. Every other check passed on the first run.

Auto-merge is enabled with the merge method, the only method main's branch ruleset allows. That ruleset also requires one approving review, a code owner review, and approval of the latest push, and dismisses stale reviews on every push, so this still waits for a person to approve it before it merges. No human reviewer is configured in this repository's Claude settings, so review requests went to the code owners GitHub already added when the PR came out of draft.

@ecv
ecv requested a review from 0xmc September 9, 2026 19:16

@0xmc 0xmc left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm putting a lot of trust in the DeepSeek V4 Flash agent I used to review this, and it says it's mergeable. But I also gave a quick read, and I think the code can generally be improved with some de-magicking.

heartbeats

Name the multipliers and the invariant as named constants with the derivation:

const (
    heartbeatMargin       = 3 // ackWait must fit ≥ this many heartbeat intervals
    heartbeatHardFloor    = 2 // below this, a single delayed tick redelivers messages
)

Then Validate uses heartbeatMargin * progress and checkAckWait uses both. This kills the literal 2/3 and makes the two-check asymmetry self-evident.

BatchMaxConcurrentUploads: 200

Problem: 200 = 16 in-flight flushes × 10 assumed index policies (from the commit body, not the code). The Validate() only checks > 0, so a config where concurrentUploads < inflight × indexes silently degrades (flushes stall holding slots waiting on sem).

Change 1

Introduce a named constant for the policy-count assumption and document it, right where the default is set:

// indexesPerFlushBudget is the assumed maximum number of index policies a
// single message can fan out to during one flush. Each in-flight flush issues
// one upload per index, so the upload semaphore must be able to accommodate
// every in-flight flush's per-index uploads at once, or flushes stall holding
// their flush slots while waiting on the upload semaphore and memory stays
// pinned. Taken from the production profile (see the in-flight flush comment).
const indexesPerFlushBudget = 10

// batchMaxConcurrentUploadsDefault must stay at least
// defaultMaxInFlightFlushes * indexesPerFlushBudget (16 * 10 = 160);
// 200 leaves headroom above that floor.
const batchMaxConcurrentUploadsDefault = indexesPerFlushBudget * defaultMaxInFlightFlushes * 2 // 200

…and use it in NewResourceIndexerOptions:

BatchMaxConcurrentUploads: batchMaxConcurrentUploadsDefault,

Change 2

Enforce the invariant in Validate(), replacing the bare > 0 check with a check that would catch a misconfig:

// Each in-flight flush issues one upload per index, so the upload semaphore
// must be at least the number of in-flight flushes times the per-flush index
// fan-out. Below that ceiling, flushes that already hold a flush slot block on
// the upload semaphore, leaving their message batches pinned and defeating the
// in-flight cap's memory bound.
if maxUploads := o.BatchMaxInFlightFlushes * indexesPerFlushBudget; o.BatchMaxConcurrentUploads < maxUploads {
    return fmt.Errorf(
        "batch-max-concurrent-uploads %d must be at least batch-max-inflight-flushes (%d) times the per-flush index fan-out (%d) = %d",
        o.BatchMaxConcurrentUploads, o.BatchMaxInFlightFlushes, indexesPerFlushBudget, maxUploads)
}

BatchMaxInFlightFlushes: 16

Problem: 16 is tuned against a measured production profile (delete task median 47s / up to 79s at a 2.8k-deep queue), and that basis lives only in a commit message — it doesn't survive in the code.

Document the basis at both the constant and the CLI flag default:

// defaultMaxInFlightFlushes caps the number of concurrent flush goroutines,
// and with it the number of message batches held in memory at once. It is the
// primary knob that bounds the indexer's memory during a Meilisearch stall.
//
// It is a tuned value, not a derived one. Measured in production at a healthy
// 2.8k-deep task queue, a delete task takes a median of 47s and up to 79s from
// enqueue to finish, and a flush waits for the slowest of its per-index tasks.
// 16 was chosen so that completions clear the stream's fill rate at that
// latency: with each flush holding a slot for ~47-79s, 16 slots give enough
// throughput to keep the backlog (and therefore memory) flat against the
// incoming audit-event rate. If Meilisearch latency or the event rate changes,
// re-measure against the search_indexer_flush_duration_seconds histogram
// rather than assuming 16 still holds.
const defaultMaxInFlightFlushes = 16

And reference it from the options default so NewResourceIndexerOptions doesn't have a second literal 16:

BatchMaxInFlightFlushes: defaultMaxInFlightFlushes,

(Currently the options block hardcodes 16 while the batcher also has defaultMaxInFlightFlushes = 16 — two independent literals with no link.)

@github-project-automation github-project-automation Bot moved this from Backlog to Ready in Searching Resources in Milo Sep 9, 2026
Review asked for the heartbeat multipliers, the upload semaphore
floor, and the in-flight flush default to be named and documented
in code rather than carried in commit messages.

The heartbeat margin and hard floor are constants shared by the
startup validation and the live consumer check, so the asymmetry
between the two checks reads from the definitions. The upload
semaphore default and a new validation rule derive from the
in-flight cap times an assumed ten index policies per flush, so a
configuration where flushes would stall on the semaphore while
holding their flush slot fails at startup instead of degrading.
The in-flight default is exported once from the batcher with the
production measurements that produced it, and the command reads it
instead of repeating the literal.

The per-flush index budget is an assumption about policy count,
not something the process verifies; a deployment with more than
ten index policies needs the cap and the semaphore re-sized.

Related to #113

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ML5AmU7i7zuSALJitvS8f
@ecv

ecv commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Applied in 8d349ce, all three points.

The heartbeat margin and hard floor are named constants next to the ackWait constant, and both checks read from them. The upload semaphore now has a per-flush index budget constant, a default that states its floor, and a validation rule that fails when the semaphore cannot cover every in-flight flush's uploads. The in-flight default is exported once from the batcher with the production measurements as its doc comment, and the command reads it instead of a second literal.

One correction to the suggested derivation: budget times cap times two evaluates to 320, not 200. I kept 200 as the default and wrote the comment as the floor of 160 plus headroom, which is what the number was chosen for.

One caveat worth having on record: the per-flush index budget of ten is an assumption about how many index policies exist, and nothing at startup verifies it against the policy cache. A deployment with more than ten policies passes validation while the real fan-out outgrows the semaphore. Deriving it from the live cache is not possible at flag-validation time, so it stays a documented assumption.

@ecv
ecv requested a review from 0xmc September 9, 2026 20:23

@0xmc 0xmc left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for the updates

@ecv
ecv merged commit eb1a67d into main Sep 9, 2026
6 checks passed
@ecv
ecv deleted the fix/indexer-bounded-flush branch September 9, 2026 20:28
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.

Search stops updating when a delete fan-out outgrows the indexer's memory

2 participants