ES reindex: fix connection-hang root causes + parallelize into scatter-gather [sc-45337] - #3464
ES reindex: fix connection-hang root causes + parallelize into scatter-gather [sc-45337]#3464yodem wants to merge 34 commits into
Conversation
…L; retire slack-webhook secret [sc-44609] SLACK_URL was injected twice: an explicit `env` from the dedicated slack-webhook secret (sefaria.secrets.slackWebhook) and the SLACK_URL key bundled in local-settings-secrets (via envFrom). In Kubernetes an explicit `env` always wins over an envFrom key of the same name, so SLACK_URL overrides set in an environment's / cauldron's local-settings-secrets were silently ignored, leaving pods serving a stale webhook (invalid_token). Make local-settings-secrets the single source of truth and retire the dedicated slack-webhook secret entirely: - Remove the redundant explicit SLACK_URL env from the pods that already mount local-settings-secrets (web, task, reindex-elasticsearch, mongo-backup, postgres-packup) so the envFrom value takes effect. - Migrate the blue-green deploy notifier (analysistemplate/rollout-complete) off the slack-webhook secret: SLACK_URL now comes from local-settings-secrets and the target channel from a new SLACK_CHANNEL key there. Both are optional (the curl is also `|| /bin/true`) so the post-promotion notification never blocks a rollout. - Delete the slack-webhook secret template, the sefaria.secrets.slackWebhook helper, and the secrets.slackWebhook values stanza. Companion infra change (separate PR): add the SLACK_CHANNEL key to local-settings-secrets (prod + dev) and remove the orphaned slack-webhook-* secrets. SLACK_URL already exists in those secrets. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…localSettings ref [sc-44609] Addresses Copilot review on PR #3369: - postgres-packup: the pgdump-uploader container (which runs upload-dumps.sh → `curl ... ${SLACK_URL}` under `set -e`) has no envFrom, so dropping the explicit SLACK_URL env left it unset and would fail the backup job. Restore SLACK_URL from the configured local-settings secret. - mongo-backup: upload-dumps.sh (mongo) does not use SLACK_URL at all — the previous env was dead. Keep it removed; correct the misleading comment. - rollout-complete analysistemplate: reference {{ .Values.secrets.localSettings.ref }} (prod: local-settings-secrets-production, where SLACK_URL/SLACK_CHANNEL live) instead of the hardcoded local-settings-secrets-{{ deployEnv }} pattern. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nnel [sc-44609] The deploy notifier uses a Slack incoming webhook (SLACK_URL), which is bound to a channel at creation; the payload channel override is redundant (and ignored by app-based webhooks). Remove the CHANNEL env and the channel field from the payload, so no SLACK_CHANNEL key is needed in local-settings-secrets. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The explanatory comments read well in the PR diff but are noise in the long-lived chart after merge. Remove them; keep two short present-tense notes where the behavior is non-obvious (pg-backup uploader's explicit SLACK_URL, and the deliberate absence of a channel override). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Commit 12e9a84 introduced hard-required validation for optional sheet fields (summary, datePublished, dateCreated, dateModified) and user profile fields in index_sheet, silently dropping ~48k (71%) of public sheets from search. Restore graceful fallbacks: only 'owner' is truly required. Missing dates and summary are passed as null (valid for ES). Missing profile fields fall back to empty string. make_sheet_text no longer raises ValueError for missing summary; uses empty string instead, preserving all source-recursion and outsideText logic. Also improves the except block to log the error instead of swallowing silently.
…ize phase functions Add three separately-callable phase functions (_index_doc_count helper, reindex_init, reindex_index_shard, reindex_finalize) so the orchestrator can invoke each phase from a different pod. reindex_finalize includes a doc-count sanity gate that refuses alias swap when the new index has fewer than min_doc_ratio of the current index docs. index_all_of_type now composes the three phases and remains as a monolith fallback.
Add --mode {monolith,init,shard,finalize}, --type, --shard-index, --shard-count
args to reindex_elasticsearch_cronjob.py. Shard identity defaults from env vars
JOB_COMPLETION_INDEX / SHARD_COUNT. monolith mode preserves existing behavior;
init/shard/finalize dispatch to reindex_init / reindex_index_shard / reindex_finalize
from sefaria.search.
…olume inheritance
…cleanup - _index_doc_count now returns None on transient read errors (not 0), so reindex_finalize refuses alias swap when either index is unreadable - New test: test_reindex_finalize_fails_closed_on_unreadable_current - Surface TextIndexer failures/skips in shard mode result object - Fix shard-mode log line to use literal 'text' (not args.type) - Fix shard_count env fallback to use is-not-None check (not truthiness) - Add backward-compat note on index_all_of_type skip param - Remove auto-injected OrchestKit headers; fix shebang position in orchestrator - Remove OrchestKit header from reindex-orchestrator-rbac.yaml
|
I'll analyze this PR systematically before scoring. PR OverviewThis PR implements a comprehensive Elasticsearch reindex resilience and scatter-gather sharding system for the Sefaria project. It addresses four root causes of a failing 26-hour weekly reindex job:
Code Review1.
|
Add shared reindex pipeline for init/finalize/catch-up, make alias swaps atomic, preserve partial shard progress on re-init, fail shard pods on indexing errors, and wire Helm shard resources/env/affinity/concurrency. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Branch was 187 commits behind master and in a CONFLICTING state, which prevented GitHub from computing a merge ref. pull_request-triggered workflows (Continuous, which builds the sefaria-web cauldron image) never ran as a result, so the sc-45319 cauldron pod sat in ImagePullBackOff against an image that was never pushed. Resolves the sole conflict in search_test.py, where this branch's ES client hardening test and master's search filter regression test were added at the same location. Both are kept. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…writes
Cauldrons share one dev Elasticsearch cluster but inherited the default
SEARCH_INDEX_NAME_TEXT/SHEET ("text"/"sheet") -- prod's index names. Every
index name derives from that base ({base}-a, {base}-b, alias {base}), so a
cauldron reindex wrote into the shared dev indexes, and the alias swap's
wildcard remove ({"index": "*"}) would strip the alias from every index on
the cluster. This made a cauldron reindex unsafe to run.
Isolation is opt-in via ISOLATE_SEARCH_INDEXES, which appends -<deployEnv>
to the index base names. It defaults to false and is set only for cauldrons.
It is deliberately NOT keyed off cronJobs.reindexElasticSearch.enabled,
because prod runs the reindex too and would otherwise have its indexes
renamed out from under it. Cauldrons that do not reindex keep reading the
shared alias, so their search keeps working.
SEARCH_INDEX_NAME_* were also not run through tpl (unlike APSCHEDULER_NAME),
so a templated override would have rendered its braces literally.
Guard writes to the shared defaults behind REINDEX_ALLOW_SHARED_INDEX, set
true for every environment that already runs the reindex, so behaviour there
is unchanged. It is checked in reindex_init before any index is created or
cleared -- not only in reindex_finalize -- so a misconfigured environment
aborts before destroying shared data rather than after.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cauldrons are given an explicit SEARCH_INDEX_NAME_* at deploy time by the cauldron system, so unconditionally appending -<deployEnv> would suffix an already-isolated name (sc45319reindex_text-sc-45319-reindex). Derive a name only when the environment is still on the shared "text"/"sheet" default, which leaves explicitly-named environments untouched and makes the rule idempotent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two defects found by running the sharded reindex against a live cluster.
1. The shard balancer never balanced anything.
_index_size_map() called get_available_counts() on VersionState.content_node,
which is a bound method taking a schema node - the method lives on StateNode.
It raised AttributeError for every VersionState and a bare `except: pass`
swallowed it, so every title got weight 1. With uniform weights the
"sort by descending size" step collapses to alphabetical order and the snake
distribution degenerated into an alphabetical round-robin: shards got equal
COUNTS but wildly unequal MEMORY (5.8Gi..>12Gi), and one shard OOMKilled
deterministically at the 12Gi limit.
Counts are now summed over LEAF schema nodes via StateNode. Root-only lookup
still missed 1,135 complex/structured texts (their availableCounts live on the
leaves, not the root) - including Zohar, Beit Yosef, Sulam on Zohar and Prisha,
some of the heaviest texts in the corpus, all previously invisible to the
balancer. Measured on the real dataset: 6,657 titles weighted (was 6,596 at a
uniform weight of 1), 1,868 distinct weights, and max/min shard weight ratio
1.022 at 8 shards.
2. Sharding gave no memory benefit at all.
get_all_versions() paged through every Version in the corpus - 11,832 documents,
text included - and index_all() applied the shard filter only AFTER the whole
corpus was in RAM. Every shard pod therefore held the entire corpus: all 8 pods
sat at an identical ~5,820Mi floor regardless of slice, and one shard OOMKilled
with zero bulk PUTs, i.e. during the load, before indexing a single document.
shardCount was not a memory lever, which defeats the premise of sharding.
index_all() now picks the shard's (title, lang) groups from a metadata-only
projection - no text loaded - and then loads only that shard's versions via
get_all_versions(query={"title": {"$in": titles}}). The unsharded/monolith path
is unchanged. get_all_versions() takes an optional query, threaded through the
AutoReconnect retry so a retry cannot silently fall back to loading everything.
Both failures were silent. Degenerate states (empty size map, uniform weights,
skipped VersionStates) now log WARNINGs - the silence is why this survived
review, CI and a full production-scale run.
The snake algorithm itself was correct and is unchanged; it is now factored into
_snake_assign() and shared by _select_shard_groups() and _select_shard_keys(),
with a test asserting the two agree.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dex [sc-45319] An 8-shard reindex against a live cluster killed a shard with: ApiError(429, 'es_rejected_execution_exception', 'rejected execution of coordinating operation [coordinating_and_primary_bytes=204976363, ..., max_coordinating_and_primary_bytes=214748364]') Two stacked defects, both created by the sharding - the old serial indexer had a single writer and never approached the cluster's write buffer. 1. Write pressure was unbounded relative to the cluster. elasticsearch.helpers.bulk defaults to max_chunk_bytes=100MB per request. With N shards flushing concurrently that is up to N*100MB of in-flight coordinating bytes against a cluster capped near 215MB, so Elasticsearch correctly rejects the write. The per-request size is now bounded (REINDEX_BULK_MAX_CHUNK_BYTES, default 10MB, 1MB floor) so aggregate in-flight bytes across all shards stays within budget. 2. A whole-request 429 was not absorbed. bulk(..., max_retries=3) only retries individual items returned inside an HTTP-200 bulk response. When Elasticsearch rejects the entire request with 429, client.bulk() raises ApiError, which the helper's item-level retry never sees, and _flush_bulk_actions caught only ESConnectionError/ConnectionTimeout - so it propagated and killed the shard. With the orchestrator's maxFailedIndexes: 0, one such shard aborts the whole reindex. _flush_bulk_actions now retries a request-level 429 with exponential backoff (2s doubling, capped at 60s, 6 attempts), logging a WARNING per retry so backpressure is visible in production rather than silent. Any non-429 ApiError still propagates immediately; connection-error handling is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e [sc-45319]
An 8-shard reindex against a live cluster left one shard spinning at ~500m CPU for
over three hours with zero bulk writes, zero log output, no crash and no OOM. The
job sat at 6/8 indefinitely. Nothing in the system could detect it, bound it, or
say what it was stuck on.
Three structural gaps turned one pathological text into a permanent, invisible wedge:
* the shard Job had no activeDeadlineSeconds, so a hung shard runs forever;
* the orchestrator's barrier was `while True: ... sleep(60)` with no timeout, so
it waits forever behind that shard;
* the CronJob is concurrencyPolicy: Forbid, so a wedged run also blocks every
future scheduled reindex.
And because per-title logging sat at DEBUG, a wedged (or OOMKilled) shard never
named the text it was processing - the same blind spot that made the earlier OOM
undiagnosable.
Changes:
* Per-title progress is logged at INFO every PROGRESS_LOG_EVERY_N titles, naming
the current title, and TextIndexer tracks _current_title so a postmortem can
always recover it.
* A daemon heartbeat thread WARNs when a shard makes no forward progress between
polls (REINDEX_HEARTBEAT_SECONDS, default 300), naming the stuck title and how
long it has been stuck. Silence is no longer indistinguishable from work.
* The shard Job gets activeDeadlineSeconds (Helm shardActiveDeadlineSeconds,
default 6h; healthy shards finish in ~2-3h).
* The barrier is bounded (REINDEX_BARRIER_TIMEOUT_SECONDS, default 7h, sitting
behind the Job's own deadline). On timeout or failure it logs an ERROR naming
which shard indexes are incomplete and exits non-zero WITHOUT finalizing -
finalizing a partially built index is exactly what the sanity gate exists to
prevent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…query storm [sc-45319]
A sharded reindex left one shard spinning at ~500m CPU for hours with zero writes
and zero log output, holding the barrier open. A py-spy stack dump from the live
pod showed the cause:
make_text_index_document -> Ref.order_id -> root get_child_order -> root
all_children() -> DictionaryNode.all_children() -> DictionaryEntryNode.__init__
-> Mongo find {parent_lexicon, headword}
order_id runs once per indexed segment. For a dictionary text the root's
all_children() expands the virtual DictionaryNode, and DictionaryNode.all_children()
was a generator that re-queried LexiconEntrySet AND constructed a DictionaryEntryNode
(each issuing its own Mongo lookup) for every entry - every time. For Jastrow (tens
of thousands of headwords) that rebuilt the entire dictionary from Mongo once per
segment: O(N) Mongo work repeated N times. It never hung; it could not finish.
This is pre-existing on master - the sharding only exposed it, because one shard now
owns a whole dictionary end-to-end and stalls the barrier for the entire reindex.
Memoize the expansion on the node instance (_all_children_cache), built once from a
single LexiconEntrySet and returned as a fresh iterator each call. Same entries, same
order, same order_id output - the only change is how many times Mongo is hit: once per
DictionaryNode instance instead of once per segment.
Tests in sefaria/model/tests/lexicon_tests.py assert the lexicon set is queried at
most once across repeated calls, that the yielded sequence matches an uncached direct
query, and that repeated calls return independent (non-exhausting) iterators.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…wiki) [sc-45319] Superpowers planning/spec docs live in the Sefaria engineering wiki, not the code repo. Relocated the reindex implementation plan to the wiki; the sc-45940 RBAC design spec was likewise kept out of the repo. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…[sc-45940]
The orchestrator's entire Kubernetes API surface is three calls: create the shard
Indexed Job, read its status (the barrier), and delete a stale prior job. It reads
no pods and no configmaps/secrets via the API — shard pods get config through
envFrom (names only), resolved by the kubelet.
Tighten the Role to match:
- batch/jobs: create, get, delete (drop unused list, watch)
- batch/jobs/status: get (status is read-only)
- remove the pods / pods/log rule (orchestrator reads neither)
Add an in-template comment recording why there is deliberately no secret/configmap
grant, so the least-privilege posture reads as intentional in review. This is the
reference implementation for Sefaria services calling the Kube API.
Verified with helm template: SA, Role, RoleBinding subject/roleRef, and the CronJob
serviceAccountName all render as {deployEnv}-reindex-orchestrator; the whole block
renders nothing when reindexElasticSearch.enabled is false.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…index-resilience-sharding
Summary
The weekly full Elasticsearch reindex CronJob took ~26h and intermittently died with a "connection error," abandoning the entire run. This PR fixes the four root causes (not masks them) and re-architects the job into an isolated, parallel scatter-gather so no section can stall another.
Story: sc-45337 (Team Platform · Devops). Built via a written plan + 12 task-by-task subagent implementations, each individually reviewed, plus an Opus whole-branch review.
Investigation (production logs + code)
Decoded the last completed run (~26h): ~14.5h real work + ~11.3h of silent multi-hour stalls. Log hours 07/08/10–14 were entirely absent; the 6800→6900 stall was 2h17m ≈ Linux TCP keepalive (7200s); silent gaps had zero ES traffic → the process was blocked in Mongo reads. Bottleneck is Mongo/Python-side, not ES ingest.
Root causes fixed
MongoClientset nosocketTimeoutMS(pymongo default = infinite), so a dead Mongo TCP socket blocked reads ~2h until OS keepalive. NowsocketTimeoutMS=300s/connectTimeoutMS=20s/serverSelectionTimeoutMS=60s(global client; a generous ceiling, safe for the online path).request_timeout/retry_on_timeout/max_retries/http_compress); bulk flush now retries429s.12e9a845fmade optional sheet fields hard-required, silently dropping ~48,053 of 67,389 public sheets (71%) from search. Restored graceful fallbacks (onlyownerrequired) → sheets searchable again.reindex_init(pagesheetrank + create indexes + bulk-load settings) → K8s Indexed Job of N=8--mode shardpods (deterministic size-aware sharding, per-shardbackoffLimitPerIndexretry) →reindex_finalize(restore settings, doc-count sanity gate, atomic alias swap). A stall in one shard no longer holds the others hostage; the alias only swaps onto a verified-complete index. The orchestrator pod holds no ES/Mongo connection during the long phase.Also included
refresh_interval:-1/replicas:0during load, restore +_refreshin finalize).local_settings.pyvolume to shard pods.Safety / rollout
reindexElasticSearch.enabled: false) — merging changes nothing in prod.helm lintclean. GKE cluster is v1.35.5 (Indexed-Job per-index retry fields are GA).-debugindexes end-to-end:--mode init→--mode shardforshard-index 0..N-1→--mode finalize; confirm shards index disjoint groups andtext-debugdoc count ≈ a monolith run._index_size_mapresolves liveVersionStatecounts; if it excepts it degrades to count-balanced (safe, just less head-balancing). Log a sample size-map.local_settings.py) and that the orchestrator exits non-zero and leaves the alias untouched on an injected shard failure.🤖 Generated with Claude Code