Skip to content

ES reindex: fix connection-hang root causes + parallelize into scatter-gather [sc-45337] - #3464

Open
yodem wants to merge 34 commits into
masterfrom
feat/sc-45319/es-reindex-resilience-sharding
Open

ES reindex: fix connection-hang root causes + parallelize into scatter-gather [sc-45337]#3464
yodem wants to merge 34 commits into
masterfrom
feat/sc-45319/es-reindex-resilience-sharding

Conversation

@yodem

@yodem yodem commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

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

  1. 🔴 Mongo dead-socket hangs (primary killer)MongoClient set no socketTimeoutMS (pymongo default = infinite), so a dead Mongo TCP socket blocked reads ~2h until OS keepalive. Now socketTimeoutMS=300s / connectTimeoutMS=20s / serverSelectionTimeoutMS=60s (global client; a generous ceiling, safe for the online path).
  2. 🔴 Un-hardened ES client — the alias swap and all 67k sheet writes used a client with no retries. Hardened (request_timeout/retry_on_timeout/max_retries/http_compress); bulk flush now retries 429s.
  3. 🟠 Sheet regression — commit 12e9a845f made optional sheet fields hard-required, silently dropping ~48,053 of 67,389 public sheets (71%) from search. Restored graceful fallbacks (only owner required) → sheets searchable again.
  4. 🟠 Single 26h failure domain — split into a scatter-gather: reindex_init (pagesheetrank + create indexes + bulk-load settings) → K8s Indexed Job of N=8 --mode shard pods (deterministic size-aware sharding, per-shard backoffLimitPerIndex retry) → 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

  • Bulk-load tuning (refresh_interval:-1 / replicas:0 during load, restore + _refresh in finalize).
  • Sanity gate fails closed on an unreadable current-index count (final-review hardening).
  • Helm: orchestrator RBAC + cronjob wiring that propagates ES/Mongo env, secrets, and the local_settings.py volume to shard pods.

Safety / rollout

  • CronJob ships disabled (reindexElasticSearch.enabled: false) — merging changes nothing in prod.
  • 20 unit tests pass; helm lint clean. GKE cluster is v1.35.5 (Indexed-Job per-index retry fields are GA).

⚠️ Before enabling in prod (must do)

  • Staging dry-run on -debug indexes end-to-end: --mode init--mode shard for shard-index 0..N-1--mode finalize; confirm shards index disjoint groups and text-debug doc count ≈ a monolith run.
  • Validate _index_size_map resolves live VersionState counts; if it excepts it degrades to count-balanced (safe, just less head-balancing). Log a sample size-map.
  • Confirm shard pods connect (ES secret + mounted local_settings.py) and that the orchestrator exits non-zero and leaves the alias untouched on an injected shard failure.
  • Known limitation (gate-mitigated): cross-pod shard determinism assumes stable Mongo reads during the run; sub-10% group loss could slip past the 90% gate — ticket as a follow-up (consider a coverage assert in finalize).

🤖 Generated with Claude Code

yodem and others added 19 commits June 2, 2026 13:41
…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.
…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
@gitvelocity-reviewer

Copy link
Copy Markdown

I'll analyze this PR systematically before scoring.

PR Overview

This 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:

  1. MongoDB dead-socket hangs (infinite timeout)
  2. Un-hardened ES client (no retries)
  3. ~48k public sheets dropped due to overly strict validation
  4. Single failure domain (no parallelism/isolation)

Code Review

1. sefaria/system/database.py

Good:

  • MONGO_CLIENT_TIMEOUT_KWARGS is defined at module scope before the if hasattr(sys, '_doc_build') guard — correct placement.
  • Values are well-reasoned (5min socket timeout, 20s connect, 60s server selection).

Issue:

  • The MONGO_CLIENT_TIMEOUT_KWARGS dict is defined outside the if hasattr(sys, '_doc_build') guard, but the client variable is only created inside the else branch. This is intentional (the dict needs to exist even in doc-build mode), but the comment in the plan says "Place the dict assignment before the if hasattr(sys, '_doc_build'): guard" — which is what was done. ✓

Minor concern:

  • socketTimeoutMS=300_000 (5 minutes) on the online request path could cause issues for very slow admin queries that legitimately take >5 minutes. The plan acknowledges this tradeoff, but it's worth monitoring.

2. sefaria/helper/search.py

Good:

  • get_elasticsearch_client() now has retry/timeout config.
  • http_compress=True is a nice addition for bandwidth reduction.

Issue:

  • The test test_default_es_client_is_hardened accesses private attributes (es._max_retries, es._retry_on_timeout, es._request_timeout). These are implementation details of elasticsearch-py 8.x and could break on minor version updates. The plan itself acknowledges this risk. A more stable approach would be to test behavior (e.g., mock the transport and verify retry behavior) rather than internal state.

3. sefaria/search.py

Sheet indexing fix (Task 4):

  • The fix is correct — removing the overly strict validation that dropped 71% of public sheets.
  • make_sheet_text now gracefully handles missing title/summary.

Potential issue in index_sheet:

monkeypatch.setattr(search.es_client, "create",
                    lambda index, id, body: created.update({"id": id, "body": body}))

The es_client.create signature in the test uses keyword args, but the actual call in search.py line 193 is:

es_client.create(index=index_name, id=id, body=doc)

This is fine since both use keyword args.

_index_doc_count function:

def _index_doc_count(index_name):
    try:
        if not index_client.exists(index=index_name):
            return 0
    except Exception as e:
        logger.warning(...)
        return None
    try:
        stats = index_client.stats(index=index_name)
        return stats.get('_all', {}).get('primaries', {}).get('docs', {}).get('count', 0)
    except Exception as e:
        logger.warning(...)
        return None

Good defensive coding — returns None on transient errors so callers can fail closed.

reindex_finalize sanity gate:

if new_count is None:
    raise ValueError(...)
if current_count is None:
    raise ValueError(...)

Good — fails closed on unreadable counts.

_select_shard_groups snake distribution:
The algorithm is correct and elegant. The snake distribution ensures large books are spread across shards rather than all landing in shard 0.

Potential issue in reindex_index_shard:

def reindex_index_shard(type, shard_index=None, shard_count=None, debug=False):
    names = get_new_and_current_index_names(type=type, debug=debug)
    if type == 'text':
        TextIndexer.clear_cache()
        TextIndexer.index_all(names['new'], debug=debug, shard_index=shard_index, shard_count=shard_count)

If reindex_init hasn't been called first (e.g., in a retry scenario), this will try to index into a non-existent index. The plan says reindex_init is idempotent, but there's no guard here.

index_all_of_type backward compatibility:

def index_all_of_type(type, skip=0, debug=False):
    """...
    Note: the ``skip`` parameter is accepted for backward compatibility but is no longer
    forwarded; resume-from-skip is a no-op now that indexing is sharded/phase-split.
    """
    reindex_init(type, debug=debug)
    reindex_index_shard(type, debug=debug)
    reindex_finalize(type, debug=debug)

The skip parameter silently becomes a no-op. This could be surprising for callers who relied on it. The docstring documents this, which is good.

index_all_of_type removes the 10-second countdown:
The original code had a 10-second countdown before indexing. This has been removed in the new implementation. While the countdown was probably just a safety measure, removing it without explicit mention could be a concern.

4. scripts/scheduled/reindex_orchestrator.py

Good:

  • Pure functions (job_terminal_state, build_shard_job_manifest) are separated from I/O.
  • Heavy imports are lazy (inside main()), enabling unit testing without kubernetes/django.
  • Env propagation to shard pods is thorough.

Issue — maxFailedIndexes: 0:

"maxFailedIndexes": 0,

With maxFailedIndexes: 0, if any shard fails (even after backoffLimitPerIndex: 2 retries), the entire job fails immediately. This is intentional (the plan says "NOT finalizing (alias unchanged)"), but it means a single flaky shard aborts the whole reindex. Consider whether maxFailedIndexes: 1 or higher would be more resilient.

Issue — pip install numpy kubernetes in the command:

command = [
    "bash", "-c",
    "mkdir -p /log && touch /log/sefaria_book_errors.log && pip install numpy && /app/run /app/scripts/scheduled/reindex_elasticsearch_cronjob.py --mode shard --type text",
]

Installing packages at runtime in production pods is an anti-pattern. These should be baked into the Docker image. The kubernetes package is added to requirements.txt, but numpy is installed at runtime. This is a pre-existing issue but worth noting.

Issue — time.sleep(10) after job deletion:

batch.delete_namespaced_job(job_name, namespace, propagation_policy="Background")
time.sleep(10)

Background propagation is async — 10 seconds may not be enough for the old job to fully terminate before creating the new one. A more robust approach would be to poll until the job is gone.

Issue — No timeout on the polling loop:

while True:
    job = batch.read_namespaced_job_status(job_name, namespace)
    ...
    time.sleep(60)

If the job gets stuck (neither completing nor failing), this loop runs forever. A maximum wait time should be added.

5. helm-chart/sefaria/templates/rbac/reindex-orchestrator-rbac.yaml

Good:

  • Minimal RBAC — only jobs, jobs/status, pods, pods/log in the namespace.
  • Namespace-scoped Role (not ClusterRole) — good security practice.

Minor issue:

  • The jobs/status subresource is listed in the Role, but the orchestrator uses read_namespaced_job_status which reads the main jobs resource. The jobs/status entry is harmless but slightly redundant.

6. helm-chart/sefaria/values.yaml

Good:

  • shardResources is defined in values but not actually used in the CronJob template (it would be used by the dynamically-created shard Job). The orchestrator passes resources directly in build_shard_job_manifest. This is a minor inconsistency — the Helm values define shard resources but they're hardcoded in the Python script.

7. requirements.txt

kubernetes==31.*

Using a wildcard version pin (31.*) is unusual for requirements.txt. Most projects use >=31,<32 or a specific version. This could cause issues with pip's dependency resolver.

8. Test Coverage

sefaria/system/tests/database_timeout_test.py:

  • Tests both the dict values and the live client options. Good.
  • test_live_client_has_timeouts_applied requires a live MongoDB connection — this may fail in CI without a Mongo instance.

sefaria/system/tests/reindex_orchestrator_test.py:

  • Good use of importlib to avoid heavy imports.
  • Tests job_terminal_state and build_shard_job_manifest thoroughly.
  • Missing test: what happens when job_terminal_state is called with succeeded=0, failed=0 (job not started yet)?

sefaria/tests/search_test.py:

  • Good coverage of the new functions.
  • test_index_all_calls_select_shard_groups_when_sharding is well-structured.
  • test_reindex_finalize_sanity_gate and test_reindex_finalize_fails_closed_on_unreadable_current cover important edge cases.

Missing tests:

  • reindex_init is not directly tested (only indirectly through index_all_of_type).
  • reindex_index_shard with type='sheet' is not tested.
  • The _index_size_map method is not tested.
  • index_all_of_type composition (init → shard → finalize) is not tested.

9. The Plan Document (docs/superpowers/plans/...)

This is a 1141-line AI agent execution plan. It's detailed and well-structured, but it's a documentation/planning artifact, not production code. It's included in the diff but shouldn't affect the code quality score significantly.


Summary of Issues

Critical:

  • None

High:

  • maxFailedIndexes: 0 means any single shard failure aborts the entire reindex — consider whether this is the right tradeoff
  • No timeout on the orchestrator polling loop (could run forever)

Medium:

  • Test for test_live_client_has_timeouts_applied requires live MongoDB — may fail in CI
  • Tests access private elasticsearch-py attributes (fragile)
  • pip install numpy at runtime in shard pods (anti-pattern)
  • time.sleep(10) after job deletion may be insufficient
  • shardResources in values.yaml is unused by the Helm templates (hardcoded in Python)
  • kubernetes==31.* version pin syntax is unusual

Low:

  • skip parameter silently becomes no-op in index_all_of_type
  • jobs/status RBAC entry is slightly redundant
  • Missing tests for reindex_init, _index_size_map, sheet shard path

Code Quality Score

Scope (S): This PR touches 12 files across multiple subsystems: database layer, search/indexing engine, Kubernetes orchestration, Helm charts, and test infrastructure. It introduces new public functions (reindex_init, reindex_index_shard, reindex_finalize), a new script, new RBAC resources, and a new dependency. S = 17

Architecture (A): Major architectural shift — from a monolithic serial reindex to a scatter-gather parallel architecture with init/shard/finalize phases, Kubernetes Indexed Jobs, and an orchestrator pattern. New service boundary (orchestrator pod → shard pods). New dependency (kubernetes client). A = 17

Implementation (I): Snake distribution algorithm for balanced sharding, deterministic shard assignment, sanity gate with fail-closed semantics, env propagation to dynamically-created pods, lazy imports for testability, pure functions separated from I/O. Complex state management across phases. I = 16

Risk (R): Changes the global MongoDB client (affects online path), modifies the alias swap logic (must remain atomic), introduces Kubernetes Job creation (new operational surface), changes the weekly reindex entrypoint. The sanity gate mitigates alias swap risk. backoffLimit: 0 on the CronJob is a risk (no retry at the CronJob level). R = 14

Quality (Q): Good test coverage for the new logic (sharding determinism, sanity gate, manifest building, bulk retry kwargs, sheet fallback). Tests use monkeypatching appropriately. Some fragile private-attribute assertions. Missing tests for reindex_init, _index_size_map. The plan document is thorough. Q = 11

Performance/Security (P): Bulk-load settings (disable refresh/replicas during ingest), snake distribution for load balancing, minimal RBAC (namespace-scoped), retry with backoff for 429s. P = 4

Base Score: 17 + 17 + 16 + 14 + 11 + 4 = 79

Effort Scale:

  • Effective Lines: 856 → Extra Large tier (ESF: 1.0x)
  • File Count: 12 → Small tier
  • Gap: Small - XL = negative → No bump
  • Final ESF: 1.0x

Final Score: 79 × 1.0 = 79

Code Quality Data (JSON)
{
  "_schema": "code_quality_v5",
  "total_score": 79,
  "total_factors": "79 × 1.0 (Extra Large ESF) = 79",
  "scope_score": 17,
  "scope_factors": "12 files across database, search/indexing, Kubernetes orchestration, Helm charts, test infrastructure; new public API (reindex_init/shard/finalize); new script; new RBAC resources; new kubernetes dependency",
  "architecture_score": 17,
  "architecture_factors": "Major shift from monolithic serial reindex to scatter-gather parallel architecture; new orchestrator pattern; Kubernetes Indexed Job creation; init/shard/finalize phase decomposition; new service boundary between orchestrator and shard pods",
  "implementation_score": 16,
  "implementation_factors": "Snake distribution algorithm for balanced sharding; deterministic shard assignment without coordination; sanity gate with fail-closed semantics; env propagation to dynamically-created pods; lazy imports for testability; pure functions separated from I/O; complex multi-phase state management",
  "risk_score": 14,
  "risk_factors": "Global MongoDB client change affects online path (+4); alias swap logic modified (must remain atomic) (+4); new Kubernetes Job creation surface (+3); backoffLimit:0 on CronJob (+2); sanity gate mitigates alias swap risk (-2); feature is gated by enabled=false default (-1)",
  "quality_score": 11,
  "quality_factors": "Good coverage of sharding determinism, sanity gate, manifest building, bulk retry kwargs, sheet fallback; appropriate monkeypatching; fragile private-attribute assertions in ES client test; missing tests for reindex_init, _index_size_map, sheet shard path; live-Mongo test may fail in CI",
  "perf_security_score": 4,
  "perf_security_factors": "Bulk-load settings (

yodem and others added 10 commits July 2, 2026 13:27
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>
yodem and others added 5 commits July 14, 2026 16:53
…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>
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.

1 participant