Skip to content

Bug/sc 45009/title rename leaves index key versionstate - #3442

Merged
stevekaplan123 merged 33 commits into
masterfrom
bug/sc-45009/title-rename-leaves-index-key-versionstate
Aug 4, 2026
Merged

Bug/sc 45009/title rename leaves index key versionstate#3442
stevekaplan123 merged 33 commits into
masterfrom
bug/sc-45009/title-rename-leaves-index-key-versionstate

Conversation

@stevekaplan123

@stevekaplan123 stevekaplan123 commented Jun 25, 2026

Copy link
Copy Markdown
Member

Description

The server failed to boot with KeyError: 'Rashi on Genesis 2' in all_index_records(). Root cause: _index_map is keyed by i.title while _index_title_maps is keyed by nodes.key; these diverge when an Index's title ≠ its root node key, and the lookup raised. This is one instance of a pervasive anti-pattern on the startup path: per-record loops over DB records with no per-item error handling, so any single malformed record (Index, Topic, Term, Collection, Lexicon entry, user profile, vstate doc, link) aborts the whole build and prevents server boot.

Code Changes

Per-record try → log → skip guards across the boot path so one bad record is logged and skipped. In skip_tracking.py we store the logs and report them all together in one Slack message.

Example of logs:

{"event": "[pathway:rebuild_toc,startup] TocTree vstate record: skipping ObjectId('54b0265afbfba21cd644530f'): 'title'", "timestamp": "2026-06-30T07:47:56.590355Z", "logger": "sefaria.model.category", "severity": "warning"}
{"event": "[pathway:rebuild_toc,startup] all_index_records key (title/nodes.key mismatch): skipping 'Rashi on Genesis 2': 'Rashi on Genesis 2'", "timestamp": "2026-06-30T07:47:56.670243Z", "logger": "sefaria.model.text", "severity": "error"}

Example of Slack message:
(In the most up-to-date code, instead of showing the confusing "init_library_cache", it shows "startup".

image

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR hardens Sefaria’s startup/library-build path by adding per-record exception guards so that a single malformed MongoDB record (Index/Topic/Term/Category/Lexicon/User profile, etc.) is logged and skipped instead of preventing the server from booting.

Changes:

  • Introduces a shared BAD_RECORD_EXCEPTIONS tuple and uses it to wrap startup-time per-record loops.
  • Makes Index map building, topic TOC/category mapping, and term/topic mappings resilient to malformed records.
  • Adds similar skip-and-log behavior to linker trie initialization, TOC tree building, autocomplete user ingestion, and lexicon trie ingestion.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
sefaria/system/exceptions.py Adds BAD_RECORD_EXCEPTIONS intended for “single bad record” startup guards.
sefaria/model/text.py Adds per-record guards around index-map build, topic TOC recursion/sorting, term mappings, topic mappings, and index enumeration.
sefaria/model/linker/match_template.py Wraps match-template trie building per node to prevent one bad node from aborting trie creation.
sefaria/model/linker/category_resolver.py Makes category matcher initialization resilient to malformed templates/terms.
sefaria/model/category.py Adds per-record guards when building the TOC tree (vstate docs, first-comment links, index placement, collection placement).
sefaria/model/autospell.py Adds per-record guards for user-profile autocomplete ingestion and lexicon entry trie ingestion.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread sefaria/system/exceptions.py
Comment thread sefaria/model/text.py Outdated
Comment thread sefaria/model/linker/match_template.py Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.

Comment thread sefaria/model/category.py Outdated
Comment thread sefaria/model/category.py Outdated
Comment on lines +276 to +281
try:
self._collections_in_library.append(c.slug)
node = TocCollectionNode(collection_object=c)
categories = node.categories
cat = self.lookup(node.categories)
if not cat:
Comment thread sefaria/model/autospell.py Outdated
Comment thread sefaria/model/linker/category_resolver.py
Comment thread sefaria/model/linker/match_template.py
Comment thread sefaria/model/text.py Outdated
Comment thread sefaria/model/text.py Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.

Comment on lines 70 to +73
for node in nodes:
for match_template in node.get_match_templates():
if not node.is_root() and not match_template.matches_scope(self.scope):
continue
curr_dict_queue = [trie]
self.__add_all_term_titles_to_trie(match_template.terms, node, curr_dict_queue)
self.__add_nodes_to_leaves(node, curr_dict_queue)
# One node with a corrupt match_template/term/schema must not abort startup.
with skip_bad_record("init_library_cache", "MatchTemplateTrie node", record=str(node)):
for match_template in node.get_match_templates():
Comment on lines +18 to +23
from contextlib import contextmanager
from collections import defaultdict, namedtuple
from sefaria.system.exceptions import BAD_RECORD_EXCEPTIONS
from sefaria.helper.slack.send_message import notify_engineering_signal


Comment thread sefaria/helper/skip_tracking.py Outdated
Comment on lines +69 to +75
global _skip_saw_error
key = (pathway, what)
_skip_group_counts[key] += 1
if _skip_group_counts[key] <= MAX_STORED_PER_GROUP:
skip_records.append(SkipRecord(pathway, what, record, level, error_type, detail))
if level == "error":
_skip_saw_error = True
Comment thread sefaria/helper/skip_tracking.py Outdated
Comment on lines +106 to +126
if _skip_group_counts:
total = sum(_skip_group_counts.values())
# Group the stored records by (pathway, what), preserving their (bounded) detail.
grouped = defaultdict(list)
for rec in skip_records:
grouped[(rec.pathway, rec.what)].append(rec)

lines = []
for (pw, what), count in _skip_group_counts.items():
lines.append(" [{}] {}: {}".format(pw, what, count))
stored = grouped.get((pw, what), [])
for rec in stored:
lines.append(" - {}".format(_format_skip_record(rec)))
if count > len(stored):
lines.append(" … {} more".format(count - len(stored)))

message = "[pathway:{}] cache build skipped {} bad record(s):\n{}".format(
pathway, total, "\n".join(lines))
notify_engineering_signal(message, level="error" if _skip_saw_error else "warning")
reset_skip_counts()

Comment thread sefaria/helper/skip_tracking.py Outdated
Comment on lines +140 to +145
def reset_skip_counts():
"""Clear the skip log, group counts, and error flag. Called after each build's summary is posted."""
global _skip_saw_error
skip_records.clear()
_skip_group_counts.clear()
_skip_saw_error = False
@stevekaplan123
stevekaplan123 requested a review from yitzhakc July 1, 2026 12:29
@stevekaplan123
stevekaplan123 marked this pull request as ready for review July 1, 2026 12:29

@yitzhakc yitzhakc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks ok

Comment thread sefaria/model/linker/category_resolver.py
@stevekaplan123

Copy link
Copy Markdown
Member Author

Skip log is mutable global state without a lock

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (1)

sefaria/model/linker/match_template.py:72

  • record=str(node) is evaluated before entering the skip_bad_record context. If node.__str__/__repr__ touches corrupt schema fields (the exact scenario this guard is meant to survive), it can raise outside the guard and still abort startup. Use a cheap/safe identifier for record instead of str(node).
            # One node with a corrupt match_template/term/schema must not abort startup.
            with skip_bad_record("startup", "MatchTemplateTrie node", record=str(node)):

Comment on lines +130 to +132
fullname = f"{u.first_name or ''} {u.last_name or ''}"
normal_name = self.normalizer(fullname)
self.title_trie[normal_name] = {
Comment on lines +145 to +150
# Detail (B): a bold group header, bulleted records, with the site's own pathway
# noted only when it differs from the build pathway in the header.
lines = [header]
for (pw, operation), count in groups:
lines.append("\n*{}* — {}".format(operation, count))
stored = grouped.get((pw, operation), [])
@stevekaplan123

Copy link
Copy Markdown
Member Author

@yitzhakc
I updated the lock tests:

Test 1 — test_concurrent_skips_and_summaries_lose_nothing. 8 threads record 200 skips each while the main thread repeatedly posts summaries; asserts all 1,600 show up across those summaries. (Note the sys.setswitchinterval(1e-6) fixture: Python switches threads only every 5ms by default, which is longer than the whole risky window, so without it the threads never interleave and the test passes even with the lock deleted). The fixture restores the original value in a finally — it's process-wide and would slow the rest of the suite.

Test 2 — test_lock_is_not_held_while_posting_to_slack. Guards the fact that the lock is released before the Slack call. Wrapping the whole function in one with _lock: would deadlock startup on any re-entrant path (threading.Lock isn't reentrant) and would reset away skips recorded during delivery. The test's fake Slack call checks _lock.acquire(blocking=False) succeeds and records a skip, then asserts that skip survives. Non-blocking on purpose: a blocking acquire would hang the suite instead of failing.

stevekaplan123 and others added 9 commits July 28, 2026 14:21
The Weblate sync in #3566 added "search.exactMatchToggle.allResults" and
"search.exactMatchToggle.exactMatch" to i18n/interface/{en,he}.json. Those
camelCase keys fail the ID shape enforced by Sefaria._keyedStringIdRegex,
breaking the Jest suite on master.

Widen the regex to allow uppercase letters anywhere except the first
character of each dot-separated segment. That keeps the guard that matters:
_isKeyedStringId() is a classifier, not just a validator -- Sefaria._()
uses it to tell translation keys from plain data values, and Sefaria's data
values are capitalized ("Gen.1", "b.Berakhot"). Requiring a lowercase (or
digit/underscore) first character preserves that distinction.

Verified against all 592 distinct keys across the four JSON maps: none are
rejected, including sheets.1_person_likes_this_sheet, which starts a segment
with a digit. No plain-English string that was safe before is misclassified
now. Added a regression test covering both directions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
strings.test.js duplicated the _keyedStringIdRegex literal, so a change to
the router in sefaria.js would leave the test asserting against a stale
pattern -- passing while real keys had become unroutable.

Import it from Sefaria instead. The test's stated contract is "every key
matches the router regex", which is only literally true when it uses the
router's own regex. Behavior stays pinned by the example-based
_isKeyedStringId test, which hardcodes concrete accept/reject cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…/title-rename-leaves-index-key-versionstate"

This reverts commit 7d85cc8, reversing
changes made to bc5a375.
@stevekaplan123
stevekaplan123 added this pull request to the merge queue Aug 4, 2026
Merged via the queue into master with commit 92127a9 Aug 4, 2026
18 checks passed
@gitvelocity-reviewer

Copy link
Copy Markdown

📊 Code Quality Score: 58/100

72 base × 0.8 ESF (Large tier, 775 effective lines, 13 files) = 57.6 → 58

Category Score Factors
🔭 Scope 16/20 9 files across model, linker, views, startup, exceptions; new public helper module; cross-cutting instrumentation of all 3 build pathways; new Slack integration point; i18n key renames (minor)
🏗️ Architecture 14/20 New observability/resilience pattern with centralized skip-tracking; BAD_RECORD_EXCEPTIONS formal contract with documented rationale; bad_record_guard context manager abstraction; backward-compatible send_message extension; no new service boundaries
⚙️ Implementation 15/20 Non-trivial thread-safety: lock-split in signal_and_reset_skip_counts to avoid deadlock on non-reentrant lock and prevent losing mid-delivery skips; bounded storage (MAX_STORED_PER_GROUP) with separate count tracking; closure pattern for bad_record_guard; worst-offender-first sorting; _format_skip_record handles all combinations
⚠️ Risk 10/20 Additive wrapping of existing loops (happy path unchanged); BAD_RECORD_EXCEPTIONS exclusion of TypeError/AttributeError is well-reasoned and documented; all_index_records and _add_category behavior preserved; Slack timeout=3s; no-op when SLACK_URL unset; no DB migrations or auth changes
✅ Quality 13/15 277-line test suite covering happy path, error escalation, systemic propagation, narrowed exceptions, lifecycle, bounded storage, and 3 thread-safety scenarios; setswitchinterval(1e-6) for GIL forcing; lock-split tested from inside notify callback; honest comment on what was not tested and why; missing: integration test with actual corrupt record in test DB
🔒 Perf / Security 4/5 Slack timeout=3s prevents startup stall; MAX_STORED_PER_GROUP prevents memory exhaustion; notify_engineering_signal swallows all exceptions; lock not held across network call (documented and tested); no-op when SLACK_URL unset

Was this score accurate? 👍 Yes · 👎 No

How this was scored →

Scored by GitVelocity · How are scores calculated?

@stevekaplan123
stevekaplan123 deleted the bug/sc-45009/title-rename-leaves-index-key-versionstate branch August 4, 2026 16:20
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.

4 participants