Skip to content

Declare the unique indexes production already has - #741

Open
mircealungu wants to merge 1 commit into
masterfrom
wt/uniqidx
Open

mircealungu wants to merge 1 commit into
masterfrom
wt/uniqidx

Conversation

@mircealungu

@mircealungu mircealungu commented Sep 11, 2026 •

Copy link
Copy Markdown
Member

Why

The test database is built by db.create_all() from the models. A UNIQUE index that exists only in production is therefore absent from the SQLite schema tests run against — tests create rows MySQL would refuse, and a test written to exercise a duplicate-key race passes against entirely unfixed code.

That has already produced two bugs in one week: #733 (user_language) and #738 (basic_sr_schedule).

How the list was built

Migrations are not the source of truth — user_language's unique index appears in no migration file at all. So this compares the live production index list against what SQLAlchemy actually puts on db.metadata (dumped from the real metadata, not from reading model files).

That distinction mattered twice:

  • user_mwe_override/unique_user_article_sentence_mwe and exercise_report/unique_user_bookmark_source are in checked-in migrations but not in production — declaring them would have made tests stricter than prod.
  • Conversely teacher_cohort_map, text, source_text, new_text and user_avatar have indexes in production that a migration sweep alone would have ranked differently.

Production had 43 unique indexes; the models declared 39, overlapping imperfectly. Ten were missing.

What changed

Each is declared under production's exact index name, so nobody later generates a conflicting migration:

table index name columns
context_type unique_context_type type
level_adapted_article_text uq_article_level article_id, cefr_level
new_text HASH_INDEX content_hash
source_text HASH_INDEX content_hash
starred_article url_id url_id, user_id
teacher_cohort_map user_id_2 user_id, cohort_id
text content_hash content_hash
user_avatar user_id user_id
user_onboarding_message ux_user_onboarding_message_user_message user_id, onboarding_message_id
user_word unique_user_word user_id, meaning_id

Some of those names are ugly (user_id_2, HASH_INDEX, url_id). They are production's, and matching them is the point.

starred_article needed a second fix

Its constraint was already declared as a bare UniqueConstraint(url_id, user_id) in the class body — a form that does attach to the table, contrary to the comment in tools/migrations/26-05-26-a--dedupe-and-unique-user-video.sql:2 which calls it "a no-op outside __table_args__". The actual problem was that the module was missing from zeeguu/core/model/__init__.py, so the table never reached db.create_all() and the test database had no starred_article table at all. Moved into __table_args__ under production's name, and imported.

Not included, deliberately

  • basic_sr_schedule/unique_user_word_schedule — belongs to Keep the learner's answer when a scheduling race is lost #738, still open. Left alone to avoid conflicting.
  • ai_models/model_name — a stale table. 25-07-31 renamed ai_models → ai_generator and 25-08-20-b prepped dropping it; it was never dropped. No model maps to it. (Worth dropping in prod separately.)
  • exercise_outcome/id and exercise_source/id — unique indexes duplicating the primary key. Declaring unique=True on a PK column is noise.
  • topic_user_feedback/article_id — confirmed stale. Production holds both tables: article_topic_user_feedback (138 rows, the live one the model maps to) and topic_user_feedback (0 rows, the leftover carrying the index). Nothing to declare.

A gap in the other direction, worth a separate look

The same comparison run backwards finds 11 constraints the models declare that production does not have — so production can already hold rows the test database refuses. Not fixable from the model side and out of scope here, but notable:

  • article.source_id and video.source_id — CLAUDE.md states the Article↔Source and Video↔Source one-to-one is "enforced by unique constraint on source_id". In production it is not enforced at all.
  • session.uuid — no unique index in production.
  • level_adapted_article_summary_context/uq_alsc_bookmark_summary — added by 26-08-12 as (bookmark_id, article_level_summary_id) and absent from production, while its sibling uq_latc_bookmark_title is present. Likely lost when the column was renamed to level_adapted_article_text_id.
  • article.img_url_id, phrase(content, language_id), search_filter, search_subscription, topic_filter, topic_subscription, article_topic_user_feedback.

Each needs its production data checked for duplicates before any could be added as DDL.

Testing

Full suite after each individual addition, not just at the end:

DEV_SKIP_TRANSLATION=1 python -m pytest zeeguu/core/test zeeguu/api/test -q

Baseline 494 passed, 12 subtests; after every one of the eleven changes, still 494 passed, 12 subtests. No test turned out to be relying on the laxer schema.

Scope

Code-only. No DDL, no migration — production already has every one of these indexes, so there is nothing to apply. Nothing to deploy beyond the normal API release.

🤖 Generated with Claude Code

The test database is built by db.create_all() from the models, so a UNIQUE
index that exists only in production is absent from the SQLite schema tests
run against. Tests then happily create rows MySQL would refuse, and a test
written to exercise a duplicate-key race passes against entirely unfixed
code. That has already produced two bugs in a week (#733 for user_language,
#738 for basic_sr_schedule).

Migrations are not the source of truth here -- user_language's index appears
in no migration at all -- so this compares the live production index list
against what SQLAlchemy actually puts on db.metadata, rather than against
tools/migrations/*.sql or a reading of the model files.

Ten indexes were missing. Each is declared under production's exact name, so
nobody later generates a second one under a tidier name:

  context_type              unique_context_type
  level_adapted_article_text uq_article_level
  new_text                  HASH_INDEX
  source_text               HASH_INDEX
  starred_article           url_id
  teacher_cohort_map        user_id_2
  text                      content_hash
  user_avatar               user_id
  user_onboarding_message   ux_user_onboarding_message_user_message
  user_word                 unique_user_word

starred_article needed a second fix. Its constraint was already declared as a
bare UniqueConstraint in the class body -- a form that does attach to the
table, contrary to the comment in 26-05-26-a--dedupe-and-unique-user-video.sql
-- but the module was missing from zeeguu.core.model's imports, so the table
never reached db.create_all() and the test database had no starred_article at
all. Moved into __table_args__ under production's name and imported.

No DDL and no migration: production already has every one of these.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

ArchLens - No architecturally relevant changes to the existing views

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