Lors d'une mise à jour de Nuxeo Drive, il s'avère qu'une base de données SQLite n'ait pas aimé :

sqlite3.DatabaseError: database disk image is malformed

J'avais déjà eu ce genre de soucis et avais corrigé le fichier avec sqlite3 directement :

$ sqlite3 "bad.db" .dump | sqlite3 "good.db"

Simple, rapide et efficace.

Mais sqlite3 n'est jamais installé sur les machines des clients. Une solution en Python pur est donc nécessaire.


Imports

Pour commencer, nous aurons besoin de ces imports :

import os
import os.path
import sqlite3
from shutil import copyfile

Check-up

Ensuite, il nous faut un moyen de vérifier que ladite base de données est bien corrompue :

def is_healthy(database: str) -> bool:
    """
    Integrity check of the entire database.
    http://www.sqlite.org/pragma.html#pragma_integrity_check
    """
    with sqlite3.connect(database) as con:
        status = con.cursor().execute('PRAGMA integrity_check(1)').fetchone()
        return status[0] == 'ok'

Le pragma integrity_check peut être gourmant, ici je l'appelle avec un argument 1 pour arrêter la vérification dès la 1ère erreur. Sans argument (ni parenthèses du coup), sera retournée la liste des 100 premières erreurs trouvées.
Si le fichier est sain, une seule ligne contenant ok sera retournée.

L'équivalent en ligne de commande :

$ sqlite3 "bad.db" "PRAGMA integrity_check(2)"
*** in database main ***
Page 11: btreeInitPage() returns error code 11
Main freelist: 166 of 10945 pages missing from overflow list starting at 18207

$ sqlite3 "good.db" "PRAGMA integrity_check"
ok

Export

Vient ensuite le moment d'exporter le contenu de la base de donnée endommagée :

def dump(database: str, dump_file: str) -> None:
    """
    Dump the entire database content into `dump_file`.
    This function provides the same capabilities as the .dump command
    in the sqlite3 shell.
    """
    with sqlite3.connect(database) as con:
        with open(dump_file, 'w', encoding='utf-8') as f:
            for line in con.iterdump():
                f.write(f'{line}\n')

L'équivalent en ligne de commande :

$ sqlite3 "bad.db" .dump > $dump_file

Import

Pour faire l'opération inverse :

def read(dump_file: str, database: str) -> None:
    """
    Load the `dump_file` content into the given database.
    This function provides the same capabilities as the .read command
    in the sqlite3 shell.
    """
    with sqlite3.connect(database) as con:
        with open(dump_file, encoding='utf-8') as f:
            con.executescript(f.read())

L'équivalent en ligne de commande :

$ sqlite3 "good.db" ".read $dump_file"
# ou
$ sqlite3 "good.db" < $dump_file

Empaquetage

Enfin, pour souder le tout :

def fix_db(database: str, dump_file: str = 'dump.sql') -> None:
    """
    Re-generate the whole database content to fix eventual FS corruptions.
    This will prevent `sqlite3.DatabaseError: database disk image is malformed`
    issues.  The whole operation is quick and help saving disk space.

    Will raise sqlite3.DatabaseError in case of unrecoverable file.
    """
    if is_healthy(database):
        return

    dump(database, dump_file)
    read(dump_file, database)
    os.remove(dump_file)

Et voilà !

L'équivalent en ligne de commande :

$ sqlite3 "bad.db" .dump | sqlite3 "good.db"



Petit bonus de fin : le fichier réparé sera plus léger car il est compacté et purgé des données vides.

Finalement, tout cela est possible grâce aux auteurs de SQLite : How SQLite Is Tested, des malades ! ☢



Sources :




Historique


  • 2019-07-07 : Utilisation de l'encodage UTF-8 lors de l'ouverture des fichiers.

  • 2018-07-02 : Suppression des lignes de log et utilisation de Python 3.6+.