fix(db): survive a schema left without term_schedule (#275, #285) - #287
Merged
Conversation
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.
# Conflicts: # CHANGELOG.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.COALESCEguards 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: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.sqlfailed there witherrno 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:Not a fallback — the same answer. Every term on such a schema is ungraded by definition, so the status-seed expression is what the
COALESCEwould have returned for all of them anyway.MySqlTermScheduleRepositorydoes 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;Migrationsclears 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 inScheduleSql— a second cache could only be one that goes stale.Then stop leaving installs in that state (#285)
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 = 3was 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 callsrestoreRetryBudget(), 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 ~178Migration 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_scheduledropped to reproduce #275:/review,/words,/texts,/text/5/read, terms API — all 200, zeroDatabaseException; term list returns real data, due dates computed from statusfailed -> applied,term_schedule+review_logrecreated, foreign keys back to 28Migration statement skipped (expected): ... Duplicate column, still recordedappliedThe 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
restoreRetryBudget()is public so it can be tested directly, and so admin repair tooling can reach it later.ScheduleSql::setHasScheduleTable()is a test seam; without itScheduleSqlTestwould reach for a connection and fail on CI, which runs PHPUnit without MySQL.develop:MigrationsTest::testForeignKeysSurviveDropAndRestorefails in a full-suite run (passes alone), losingtexttags.fk_texttags_text_tagacross the drop/restore cycle. Verified present without this branch. CI does not catch it because the test skips without a database. Worth a separate issue — a legacy table whose referenced table was renamed may be silently skipped byrestoreForeignKeys().TermCrudApiHandler/WordDiscoveryService— that is Dictionary-imported terms are never linked to text occurrences, so marking a word known fails with a duplicate-key error #283/Handler error payloads are wrapped in Response::success(), so failed writes return HTTP 200 #284.Checks
Psalm 0 errors · PHPCS 0 errors, 0 warnings · PHPUnit 9104 pass, 6 new · no frontend changes.