Make repair_sqlite_db actually repair a damaged database - #15109
Open
rtibblesbot wants to merge 1 commit into
Open
Make repair_sqlite_db actually repair a damaged database#15109rtibblesbot wants to merge 1 commit into
rtibblesbot wants to merge 1 commit into
Conversation
Contributor
Build Artifacts
Smoke test screenshot |
rtibblesbot
force-pushed
the
issue-15108-47a72c
branch
from
July 30, 2026 06:57
64c5633 to
60ea1b3
Compare
rtibblesbot
force-pushed
the
issue-15108-47a72c
branch
from
July 30, 2026 07:17
60ea1b3 to
89052e6
Compare
rtibblesbot
marked this pull request as ready for review
July 30, 2026 07:18
rtibblesbot
force-pushed
the
issue-15108-47a72c
branch
from
July 30, 2026 07:21
89052e6 to
610a4a7
Compare
6 tasks
rtibblesbot
force-pushed
the
issue-15108-47a72c
branch
from
July 30, 2026 07:37
610a4a7 to
0cb70a9
Compare
repair_sqlite_db has never recovered a database. Three coupled defects: - The ENGINE guard compared against Django's stock sqlite3 backend, but every Kolibri SQLite deployment sets its own, so the function returned before doing anything. common_clean carried the same guard. - iterdump() ran on an unopened connection, raising an AttributeError that none of the except clauses caught. - The SQL dump was copyfile'd over the database while still in Python's write buffer, so a 0-byte file landed on top of it. SQLite reads that as a valid empty database, so the tables check found none and the database was regenerated. The file backup was the only survivor. Fixing the first makes a destructive path live for the first time, so this also narrows what counts as unrecoverable. A transient sqlite3.OperationalError - a lock timeout, a read-only file, a disk I/O error - is caught separately from the corruption that DatabaseError signals, and leaves the database alone; five of the six call sites reach here from an except that never checked for corruption. OSError is caught too, so a failed backup, a full disk or a Windows sharing violation cannot escape into connect(). The swap is declined when a journal - a -wal, or a -journal on a filesystem where WAL is unavailable - outlives our close(), which is how we know another connection still holds the database open and no swap is safe. A copy of the database is taken immediately before each of the two things it cannot be recovered from - the swap, and the regeneration - and before nothing else. A call that hands the database back exactly as it found it, which a lock timeout is, no longer costs a full binary copy of it; backups/ is never pruned. Fixes learningequality#15108
rtibblesbot
force-pushed
the
issue-15108-47a72c
branch
from
July 30, 2026 22:11
0cb70a9 to
320fbb8
Compare
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.
Summary
Recovery of a corrupt notifications database has never worked. The repair returned before doing anything, because its guard compared against Django's stock SQLite backend and every Kolibri SQLite deployment sets its own; had it run, it would have dumped the database to a file it never closed and copied that still-empty file over the original. This makes it recover a damaged database instead — the dump is closed and replayed into a fresh file that is swapped over the original, and the connection is opened before it is dumped.
Fixing the guard makes a destructive path live for the first time, so this also narrows what counts as unrecoverable: a lock timeout no longer deletes the database it was asked to repair. The copy the old code took up front is now taken only immediately before the swap or the regeneration — the two points where data can be lost — and a repair that cannot take one is abandoned rather than run blind.
References
Fixes #15108. Defects found while planning #15096, merged as #15107.
Acceptance criteria
All six are met. Two are annotated rather than met verbatim, because they cite
kolibri/core/sqlite/test/test_utils.pyas an existing file:kolibri/core/sqlite/test/does not exist ondevelop— the #15096 characterization tests were dropped before that PR merged — so there was nonot_on_windowsskip to remove and nothing to replace.repair_sqlite_dbandcommon_cleanrecognise Kolibri's SQLite backend — both call_default_db_is_sqlite()(kolibri/core/sqlite/utils.py:18), the substring formkolibri/core/deviceadmin/utils.py:88already used.iterdump()—kolibri/core/sqlite/utils.py:97-104, guarded asdbbackupguards it.with open(...)block ends before the dump is read back, and the replayed file isos.replaced over the original rather thancopyfiled.os.replaceand before thefinallydeletes it, and a sharing violation fromos.replaceis caught rather than escaping. No skip to remove: the new file carries no platform skip at all, so it runs on the Windows CI axis like every other test.regenerate_database— by theDatabaseErrorclause and by the zero-tables check. Deliberately narrowed so that a transientsqlite3.OperationalErrordoes not; see below.Reviewer guidance
Three of these are deliberate additions beyond the acceptance criteria. They exist only because fixing the guard makes the destructive path reachable, and each is a judgement call worth a second opinion:
kolibri/core/sqlite/utils.py:111-113— the one that most needs your answer. The rebuild is declined when a journal (-walor-journal) outlives ourclose(), which is how we know another connection still holds the database open. Neither ordering aroundos.replaceis safe there: dropping the journal loses every transaction still in it, and keeping it lets the next opener replay the damaged database's pages over the rebuilt one with valid checksums. Declining costs us the repair whenever another thread holds anotificationsconnection — the normal state in production. Confirm you want that trade rather than swapping anyway.kolibri/core/sqlite/utils.py:205—sqlite3.OperationalErroris now treated as "not corruption" and leaves the database alone, split out from theDatabaseErrorclause that regenerates. SinceOperationalErrorsubclassesDatabaseError, check nothing that genuinely is corruption reaches us as anOperationalErrorand now gets left unrepaired.kolibri/core/sqlite/utils.py:164-173—_kolibri_repairingis set on the Django connection object to stop the reopen at:224re-entering throughconnection_created. Check that a connection object is never shared between threads in a way that makes a flag on it the wrong place for this.Not covered by the tests, so verified by hand against a real
KOLIBRI_HOME: the tests drive the"default"alias, soregenerate_database'scall_command("migrate", ...)branch — the only branch that runs in production — is exercised only manually. A garbagenotifications.sqlite3regenerated cleanly with no traceback and one repair attempt, and a healthy one with five seededLearnerProgressNotificationrows round-tripped through dump, replay and swap with all five rows,quick_checkok,journal_modewaland no leftover files.Known and not fixed here, each a follow-up candidate:
kolibri/core/apps.py:71checksquick_checkfirst. Aquick_checkguard insiderepair_sqlite_dbwould make it safe from all of them, and would also boundbackups/, which is never pruned.regenerate_databaserunscall_command("migrate")on the default database from inside thetransaction.atomic()atkolibri/core/notifications/tasks.py:62. Never executed before, because the guard blocked it.notifications.sqlite3crashes the CLI before Django app loading —sqlite_check_foreign_keys(kolibri/utils/database.py:88) letssqlite3.DatabaseErrorescape — so this recovery is unreachable from a cold start. Pre-existing, and it defeats the recovery for exactly the case the recovery exists for.AI usage
I used Claude Code to plan and implement this test-first, prompting it to follow the forms
kolibri/core/deviceadmin/utils.pyalready had right — the substring engine check at:88and the connection-open guard at:106-111— rather than inventing new ones. I reviewed each of the three defects against the source before accepting the fix, pushed back on the initial version that swapped the file unconditionally, and verified with the new tests, the notifications/deviceadmin/coach suites on both the SQLite and postgres axes,prek, and the manual runs described above.@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?
Deviations from the issue spec
kolibri/core/sqlite/test/test_utils.pyas existing — it does not exist ondevelop, so there was nonot_on_windowsskip to remove and no characterization assertions to replace. Annotated above rather than ticked verbatim.regenerate_database; this narrows that so a transientsqlite3.OperationalErrordoes not, because fixing the guard would otherwise let a lock timeout delete the database.close(), and when no copy of the database can be taken first — neither is in the spec, both bound the destructive path the guard fix opens up. Rationale in Reviewer guidance.🟡 Waiting for feedback
Last updated: 2026-07-30 22:13 UTC