Repository navigation
fix: Keep the indexer's memory flat during Meilisearch stalls - #114
Conversation
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
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
|
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 pr-conventions-reviewer: "VERDICT: merge. All three prior-pass findings verify fixed at 8aa159b, the ackWait comments state the heartbeat invariant and match 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. |
0xmc
left a comment
There was a problem hiding this comment.
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.)
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
|
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. |
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
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