Skip to content

fix(db): survive a schema left without term_schedule (#275, #285) - #287

Merged
HugoFara merged 2 commits into
developfrom
fix/275-guard-missing-schedule-table
Aug 27, 2026
Merged

fix(db): survive a schema left without term_schedule (#275, #285)#287
HugoFara merged 2 commits into
developfrom
fix/275-guard-missing-schedule-table

Conversation

@HugoFara

Copy link
Copy Markdown
Owner

Fixes #285. Makes #275 survivable rather than fatal.

A missing table is a 500, not a degradation

Phase 2b reads every term's due date from term_schedule. COALESCE guards a missing row; nothing guards a missing table. MySQL resolves names at prepare time, so on a schema where the phase-2a migration failed the whole statement is rejected:

DatabaseException: Table 'learning-with-texts.term_schedule' doesn't exist
  MySqlReviewRepository::getReviewCounts()   -> GET /review?text=5     500
  WordListQueryService::getWordsList()       -> GET /api/v1/terms/list 500

That is the review page and the entire vocabulary list. Those users can use the app on 3.4.2; on the next release two of its main pages stop working. #275 is exactly such an install — 20260805_200000_add_fsrs_scheduling.sql failed there with errno 150, and per #285 the retry could never fire, so it stays failed.

The guard

ScheduleSql::effectiveDue() is the single place that names the table, so it is the single place that needs to know:

if (!self::hasScheduleTable()) {
    return '(' . $seeded . ')';
}

Not a fallback — the same answer. Every term on such a schema is ungraded by definition, so the status-seed expression is what the COALESCE would have returned for all of them anyway.

MySqlTermScheduleRepository does the same across its five table-touching methods, dropping writes rather than failing. A review session keeps working: the term's status still moves, and the schedule starts being kept once the schema is repaired.

Connection::tableExists() memoises the probe for the request; Migrations clears it after a run, so a table created during bootstrap is seen by the code that reads it later in the same request. The lookup is deliberately not cached a second time in ScheduleSql — a second cache could only be one that goes stale.

Then stop leaving installs in that state (#285)

$retryMigrations = [];
if (count($newMigrations) > 0) {          // <- unreachable where it was needed
    $retryMigrations = array_intersect(self::getRetryableMigrations(), $allMigrations);
}

A fresh install's first run records all 53 migrations, so the second run has nothing new, the gate never opens, and a migration that failed stays failed at one attempt until some later release happens to add a file. MAX_ATTEMPTS = 3 was meant to be the bound and never got the chance to count.

Retries now happen on ordinary requests, bounded by MAX_ATTEMPTS. The gate's real intent survives: an upgrade that brings new files calls restoreRetryBudget(), because a migration usually fails on a prerequisite that a later one repairs, and an exhausted counter should not be what stops it running again.

The logging goes with it. runMigrationFile() logged before classifying, so a healthy fresh install wrote ~178 Migration failed: lines with all 53 applied — and the eight real errors of #275 read identically to the ~170 expected ones. Expected failures now log as skipped.

Verified against a real 3.4.2 database

A populated 3.4.2 install (241 terms, 58 texts, missing FKs), with term_schedule dropped to reproduce #275:

Scenario Result
Table missing, retry budget spent /review, /words, /texts, /text/5/read, terms API — all 200, zero DatabaseException; term list returns real data, due dates computed from status
Budget spent table stayed absent across 4 requests — the bound holds
Budget available, no new migration files (the #285 condition) next ordinary request re-ran the migration: failed -> applied, term_schedule + review_log recreated, foreign keys back to 28
Harmless failure logging Migration statement skipped (expected): ... Duplicate column, still recorded applied

The same database also upgraded 3.4.2 -> this branch cleanly: 54/54 applied at one attempt each, the three legacy score columns dropped, no queue flood (all terms are 115-2153 days old, so equally due under the retired Leitner formula), and foreign keys went 0 -> 28 — the repair migration fixed the missing FKs from #275.

Notes for review

Checks

Psalm 0 errors · PHPCS 0 errors, 0 warnings · PHPUnit 9104 pass, 6 new · no frontend changes.

An install where 20260805_200000_add_fsrs_scheduling.sql failed has no
term_schedule, and phase 2b reads every term's due date from it. COALESCE
guards a missing row, not a missing table: MySQL resolves names at prepare
time, so the whole statement is rejected and both the review page and the
entire vocabulary list answer 500 — worse than 3.4.2, where the same broken
schema was still usable. #275 is such an install.

Guard the one place that names the table. ScheduleSql::effectiveDue() emits
the status-seed expression alone when term_schedule is absent, which is not
a degraded answer but the same one: every term on that schema is ungraded
by definition. MySqlTermScheduleRepository does the same, dropping its
writes rather than failing, so a review session keeps going and the term's
status still moves; the schedule starts being kept once the schema is
repaired. Connection::tableExists() memoises the probe for the request and
Migrations clears it after a run, so a table created during bootstrap is
seen by the code that reads it later in the same request.

Then stop leaving installs in that state. A failed migration was only
reconsidered when an upgrade brought new files along, which on a fresh
install can never happen: the first run records all of them, so there is
never anything new afterwards and the migration stays failed at one attempt
until some later release happens to add a file. MAX_ATTEMPTS was meant to
be the bound and never got the chance to count. Retry on ordinary requests
instead, and keep the gate's real intent by restoring the attempt budget
when new migrations do arrive — a migration usually fails on a prerequisite
that a later one repairs, and an exhausted counter should not be what stops
it running again.

Log the classification too. runMigrationFile() logged before deciding
whether a failure mattered, so a healthy fresh install wrote ~178
"Migration failed:" lines with all 53 migrations applied, and the eight
real errors of #275 read exactly like the ~170 expected ones.

Verified on a 3.4.2 database with term_schedule dropped: review page,
vocabulary list, texts and reader all 200 with no schedule table and the
retry budget spent; with one attempt left and no new migration files, the
next ordinary request re-ran the migration, recreated both tables and
restored the foreign keys.
@HugoFara
HugoFara merged commit 5349655 into develop Aug 27, 2026
14 checks passed
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