Partie 20: Réindexation et compaction de tables

Lecture 10 min. ‱

Table des matiĂšres

Les articles de la série

Bonjour à toutes et tous 😃

Lors de la partie précédente, nous avons supprimé des enregistrements de la base de données. Mais en agissant ainsi, nous avons laissé des trous dans nos pages de stockage.

En effet, chaque insertion incrĂ©mente un ID interne propre Ă  chaque page, ce qui fait que mĂȘme si nous supprimons un enregistrement, son emplacement n’est pas rĂ©affectĂ© pour l’enregistrement suivant. Au lieu de cela, une nouvelle entrĂ©e est créée dans la page ayant le plus grand ID interne. S’il n’y a pas de place, alors nous ajoutons une nouvelle page.

Nous allons considérer que les pages possÚdent au maximum 3 enregistrements.

# page 0
0 => data0
1 => data1
2 => data2
# page 1
3 => data3
4 => data4

Supprimons quelques enregistrements.

# page 0 
0 => NULL
1 => data1
2 => NULL
# page 1
3 => data3
4 => NULL

Si l’on ajoute un nouvel enregistrement, il va se trouver dans la page 1. Et si l’on ajoute un autre enregistrement, il va se trouver dans la page 2 nouvellement créée.

# page 0
0 => NULL
1 => data1
2 => NULL
# page 1
3 => data3
4 => NULL
5 => data5
# page 2
6 => data6

On se retrouve avec des trous dans nos pages aux index 0, 2 et 4.

Ces emplacements vides imposent un surplus de pages. Nous voulons réduire le nombre de pages nécessaire.

# page 0
0 => data6
1 => data1
2 => data5
# page 1
3 => data3

Comme vous pouvez le voir, les ID internes 0 et 2 ont été réaffectés pour deux enregistrements déjà existants, respectivement data6 et data5.

Cependant, les index, et plus particuliĂšrement les index primaires, ne sont pas au courant de ces changements et restent donc dans le mĂȘme Ă©tat qu’avant la compaction de la table.

Il va donc falloir mettre à jour ces index pour que notre table reste cohérente.

Aujourd’hui encore, cela ne sera pas si simple, mais cela promet d’ĂȘtre intĂ©ressant. 😎

Compactation de la table

Nous allons commencer par nous charger de la compaction de la table.

Notre algorithme de compaction sera volontairement naĂŻf.

Nous allons passer en revue les pages une par une et dĂšs que l’on trouve un emplacement qui a Ă©tĂ© libĂ©rĂ© par une suppression, nous allons le remplacer par l’enregistrement ayant le plus grand ID interne.

Et l’on rĂ©pĂšte ce processus jusqu’à ce que tous les trous aient Ă©tĂ© comblĂ©s par des les enregistrements de haut ID.

Lorsque le premier trou rencontrĂ© se trouve au-delĂ  du nombre total d’enregistrements, le processus de compaction est terminĂ©.

Comme tout cela est un peu compliqué, je vous ai fait une animation pour vous expliquer tout cela.

La légende est la suivante:

Les numĂ©ros des blocs reprĂ©sentent l’ID de donnĂ©es de l’enregistrement.

L’ID interne lui est reprĂ©sentĂ© par la position du bloc dans chaque rectangle.

Chacun de ces rectangles représente une page de 20 enregistrements.

Aussi bien pour les pages que pour les blocs, les ID se lisent de haut en bas et de gauche Ă  droite.

On y voit tout le processus de déplacement des données.

D’abord, on recherche le premier emplacement supprimĂ©. Il se trouve ĂȘtre l’ID 1. On recherche alors le dernier enregistrement actif de la base de donnĂ©es. Dans notre cas, c’est l’enregistrement ayant l’ID 76.

On inverse les deux enregistrements 1 et 76.

Note

Sur l’animation, je swap les deux enregistrements, mais dans la rĂ©alitĂ© je replace l’enregistrement ayant l’ID 1 par celui ayant l’ID 76.

Comme l’ID 1 est supprimĂ©, il n’est pas nĂ©cessaire de conserver les donnees de l’enregistrement ayant l’ID 1.

On s’économise alors une copie de donnĂ©es par inversion.

Et on repart pour un tour en recherchant le prochain emplacement supprimĂ© et le dernier enregistrement actif, respectivement l’ID 16 et l’ID 75.

La recherche des emplacements vides et actifs est optimisée par notre systÚme de header de page qui nous permet de trouver rapidement les emplacements vides.

La derniùre inversion est entre l’enregistrement ayant l’ID 59 et celui ayant l’ID 63.

À partir de ce moment, le dernier enregistrement actif est l’enregistrement ayant l’ID 62 et le premier emplacement supprimĂ© est celui ayant l’ID 63.

L’algorithme de compaction est donc terminĂ©.

Nous nous retrouvons avec tous les enregistrements actifs de la base de donnĂ©es contigus et l’ensemble des enregistrements supprimĂ©s Ă  la suite.

La derniĂšre Ă©tape consiste Ă  les marquer comme inutilisĂ©s en modifiant l’ID interne de la table pour qu’elle Ă©crive le prochain enregistrement Ă  l’ID interne 63.

Il existe un cas particulier de l’algorithme qui amùne à vider entiùrement une page.

Dans ce cas, la page est devenue inutile et peut ĂȘtre supprimĂ©e.

Supression des pages

Nous avons donc besoin d’un mĂ©canisme pour rĂ©aliser la supression des pages.

Nous allons implémenter ce systÚme sur le pager associé à la table.

impl Pager {
    /// Delete the last n pages
    pub fn delete_last_n_pages(&mut self, n: usize) {
        self.pages.drain(self.pages.len() - n..);
    }
}

L’API drain consomme les Ă©lĂ©ment d’un itĂ©rateur et les supprime de celui-ci.

Implémentation du garbage collector

Le processus de compaction nécessite deux étapes:

La suppression des pages a été réalisée juste au-dessus.

Il nous reste à réaliser le déplacement des enregistrements supprimés.

Pour faire cela, nous allons créer une structure que nous allons appeler garbage collector.

Et associer cette structure Ă  la table.

struct GarbageCollector<'a> {
    table: &'a mut Table,
}

Comme la compaction est une opération complexe, nous allons créer une structure que nous appellerons VacuumReport qui nous donnera des statistiques sur la compaction.

struct VacuumReport {
    pub table_name: String,
    pub swapped_records: BTreeMap<usize, usize>,
    pub pages_deleted: usize,
    pub vacuumed_records: usize,
}

Nous pouvons désormais implementer notre garbage collector.

impl GarbageCollector<'_> {
    /// Vacuum the table
    /// Workflow:
    /// 1. Start from the first page
    /// 2. Get the header of the page
    /// 3. Get the list of deleted records in the page
    /// 4. Get the last page lower than the last available record
    /// 5. Swap the first deleted record with the last available record
    /// 6. Scan for the next last available record
    /// 7. Repeat until the last deleted record exceeds the last available record
    pub fn vacuum(&mut self) -> Result<VacuumReport, VacuumError> {
        let old_row_number = self.table.row_number;

        // on initialise le curseur de l'enregistrement
        // avec le premier de la premiĂšre page
        let mut current_row = 0;

        // on initialise le curseur de la derniere page
        // avec le dernier de la deriĂšre page
        let mut last_row = self.table.row_number;

        // on initialise le rapport
        let mut report = VacuumReport {
            table_name: self.table.table_name.clone(),
            ..Default::default()
        };

        // on cherche le premier enregistrement supprimé
        while current_row < last_row {
            
            // via les headers de page
            // nous sommes capables de savoir si un enregistrement 
            // est supprimé ou non sans devoir lire les données de l'enregistrement
            if self
                .table
                .pager
                .check_if_record_deleted(current_row)
                .map_err(VacuumError::Page)?
            {
                // on cherche le dernier enregistrement actif
                while last_row > current_row {
                    // là aussi le header de page permet d'accélérer la recherche
                    if !self
                        .table
                        .pager
                        .check_if_record_deleted(last_row - 1)
                        .map_err(VacuumError::Page)?
                    {
                        // si on trouve un enregistrement actif
                        report.vacuumed_records += 1;
                        report.swapped_records.insert(current_row, last_row - 1);

                        // on va le remplacer par l'enregistrement supprimé
                        self.table
                            .pager
                            .replace_record(last_row - 1, current_row)
                            .map_err(VacuumError::Page)?;

                        // et on va le marquer comme actif
                        self.table
                            .pager
                            .set_record_undeleted(current_row)
                            .map_err(VacuumError::Page)?;
                        
                        // on décrémente le dernier enregistrement de 1
                        last_row -= 1;
                        // et on quitte la boucle
                        break;
                    }
                    // sinon on poursuit la recherche de l'enregistrement actif
                    last_row -= 1;
                }
            }
            // sinon on poursuit la recherche de l'enregistrement supprimé
            current_row += 1;
        }

        // on va mettre la table sur le dernier enregistrement actif
        self.table.row_number = last_row;

        // calcul le nombre d'enregistrements supprimés
        let delta_row_number = old_row_number - self.table.row_number + 1;

        // calcul le nombre de pages supprimables
        let pages_to_delete = delta_row_number / self.table.pager.rows_per_page;

        // on supprime les pages inutiles
        self.table.pager.delete_last_n_pages(pages_to_delete);

        // on met Ă  jour le rapport
        report.pages_deleted = pages_to_delete;

        Ok(report)
    }
}

Commande VACUUM TABLE

Contrairement Ă  ce que laisse penser son nom de “garbage collector”, celui-ci n’est ni une tĂąche de fond, ni une opĂ©ration autonome.

Elle sera déclenchée par la commande VACUUM TABLE.

VACUUM TABLE table_name;

Notre commande sera matérialisée par la structure suivante:

pub struct VacuumCommand {
    table_name: String,
}

Et son parser:

impl<'a> Visitable<'a, u8> for VacuumCommand {
    fn accept(scanner: &mut Scanner<'a, u8>) -> parser::Result<Self> {
        // on nettoie de potentiel blanc
        scanner.visit::<OptionalWhitespaces>()?;
        // on reconnait le token VACUUM
        recognize(Token::Vacuum, scanner)?;
        // on reconnait au moins 1 blanc
        scanner.visit::<Whitespaces>()?;
        // on reconnait le token TABLE
        recognize(Token::Table, scanner)?;
        // on reconnait au moins 1 blanc
        scanner.visit::<Whitespaces>()?;

        // on reconnait une literal string représentant le nom de table qui se termine
        // soit un par un blanc, soit par un point-virgule
        let table_name_tokens = Forecaster::new(scanner)
            .add_forecastable(UntilToken(Token::Semicolon))
            .add_forecastable(UntilToken(Token::Whitespace))
            .forecast()?
            .ok_or(ParseError::UnexpectedToken)?;

        let table_name =
            String::from_utf8(table_name_tokens.data.to_vec()).map_err(ParseError::Utf8Error)?;
        scanner.bump_by(table_name_tokens.data.len());

        // on nettoie les potentiels blancs
        scanner.visit::<OptionalWhitespaces>()?;
        // on reconnaĂźt le point-virgule final
        recognize(Token::Semicolon, scanner)?;
        Ok(VacuumCommand { table_name })
    }
}

Réindexation de la table

Comme je vous l’ai dit en introduction, nous avons besoin de rĂ©indexer notre table aprĂšs compaction pour Ă©viter une dĂ©cohĂ©rence des index par rapport au contenu de la table.

Mais fort heureusement, la rĂ©indexation n’est pas un processus si complexe que cela, au vu de l’avancĂ© actuelle de notre projet.

L’algorithme de rĂ©indexation est le suivant:

On rajoute sur le trait Indexable une méthode clear qui a pour responsabilité de vider un index.

pub trait Indexable {
    fn clear(&mut self) -> Result<(), IndexError>;
}

Nous avons deux implémentations de Indexable :

impl Indexable for PrimaryIndex {
    fn clear(&mut self) -> Result<(), IndexError> {
        self.inner.clear();
        Ok(())
    }
}

impl Indexable for ValueIndex {
    fn clear(&mut self) -> Result<(), IndexError> {
        self.inner.clear();
        Ok(())
    }
}

On implĂ©mente alors une mĂ©thode sur l’IndexRegistry qui permet de vider tous les index de la table.

impl IndexRegistry {
        /// Clear all indexes
    pub fn clear_all_indexes(&mut self) -> Result<(), IndexError> {
        for indexes in self.indexes.values_mut() {
            for index in indexes.values_mut() {
                index.clear()?;
            }
        }
        Ok(())
    }
}

On implémente ensuite la réindexation de la table.

Tout comme lorsque l’on a rĂ©alisĂ© la suppression dans la partie 19, nous avons besoin de lire les enregistrements de la table et de les reindexer.

Mais là encore, il n’est pas possible de le faire lors du scan de la table, on doit d’abord lire un certain nombre d’enregistrements, que l’on puisse ensuite reindexer.

On dĂ©fini alors une “continuation” qui correspond au dernier enregistrement lu.

Ce qui permet de relancer le scan en commençant à partir de cette continuation.

Et l’on recommence ainsi jusqu’a ce que l’on ait lu tous les enregistrements de la table.

impl Table {
    /// Reindex a part of the table
    fn reindex_part(&mut self, rows: Vec<Row>) {
        // on parcourt tous les enregistrements du paquet
        for row in rows {
            // on construit un HashMap avec les colonnes et leurs valeurs
            let row_data = self
                .schema
                .columns
                .iter()
                .cloned()
                .zip(row.1.into_iter())
                .collect();

            println!("Reindexing row {}", row.0);

            // on reindex l'enregistrement
            self.indexes.insert_into_index(&row_data, row.0).unwrap();
        }
    }

    /// Reindex the table
    pub fn reindex(&mut self) -> Result<(), SelectError> {
        self.indexes
            .clear_all_indexes()
            .map_err(SelectError::IndexError)?;

        let mut continuation = 0;

        // on lit par paquets de 20 enregistrements
        loop {
            let query_engine = QueryEngine::new(self);
            let scanner = Scanner::new(query_engine);
            let rows = scanner
                .skip(continuation)
                .take(20)
                .collect::<Result<Vec<Row>, _>>()?;

            if rows.is_empty() {
                break;
            }

            continuation += rows.len();

            // que l'on reindex
            self.reindex_part(rows);
        }

        Ok(())
    }
    }

Implémentation de la commande VACUUM

Maintenant que nous avons tous les éléments nécessaires, nous pouvons implémenter la commande VACUUM.

On implémente la commande sur la base de données:

impl Database {
    pub fn vacuum(&mut self, table_name: String) -> Result<VacuumReport, VacuumError> {
        match self.tables.get_mut(&table_name) {
            Some(table) => table.vacuum(),
            None => Err(VacuumError::TableNotExist(table_name))?,
        }
    }
}

Puis sur la table:

On y construit le garbage collector que l’on exĂ©cute, ce qui produit un rapport de compaction.

On réindex alors la table.

Et on renvoie le rapport.

impl Table {
    /// Vacuum the table and reindex it
    pub fn vacuum(&mut self) -> Result<VacuumReport, VacuumError> {
        let mut gc = garbage_collector::GarbageCollector::new(self);
        let report = gc.vacuum()?;
        self.reindex().map_err(VacuumError::SelectionError)?;
        Ok(report)
    }
}

Afin de rendre plus intelligible le rapport de compaction, on définit le trait Display:

impl Display for VacuumReport {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let mut buffer = format!("\nVacuum Report for table: {}\nPages deleted: {}\nVacuumed records: {}\nSwap records:\n", self.table_name, self.pages_deleted, self.vacuumed_records);
        for (i, j) in self.swapped_records.iter() {
            buffer = format!("{buffer}\tSwap #{i} -> #{j}\n");
        }
        write!(f, "{buffer}")
    }
}

Ce qui permet de l’afficher en retour de commande dans la mĂ©thode run du projet:

pub fn run() -> Result<(), Box<dyn Error>> {
    let mut database = database::Database::new();

    // snip ...

    loop {
        
        match database.run(command) {
            // snip ...
            Ok(ExecuteResult::Vaccum(report)) => {
                println!("{}", report);
            }
            Ok(ExecuteResult::Nil) => {}
            Err(err) => {
                println!("{}", err);
            }
        }
    }
}

On test !

On va maintenant tester notre commande VACUUM.

CREATE TABLE Users (id INTEGER PRIMARY KEY, value TEXT(180));
CREATE INDEX idx_value ON Users (value);

Puis on ajoute quelques enregistrements:

-- INSERT for 1 to 77
INSERT INTO Users (id, value) VALUES (0, 'test_0');
INSERT INTO Users (id, value) VALUES (1, 'test_1');
INSERT INTO Users (id, value) VALUES (2, 'test_2');
INSERT INTO Users (id, value) VALUES (3, 'test_3');
-- ...
INSERT INTO Users (id, value) VALUES (74, 'test_74');
INSERT INTO Users (id, value) VALUES (75, 'test_75');
INSERT INTO Users (id, value) VALUES (76, 'test_76');

La liste complĂšte est ici.

Puis, on supprime quelques enregistrements pour créer des trous dans nos pages:

-- DELETE 1, 8, 16, 19, 28, 33, 35, 37, 39, 49, 52, 55, 56, 59, 64, 65, 66, 69, 71, 72,
DELETE FROM Users WHERE id = 1;
DELETE FROM Users WHERE id = 8;
DELETE FROM Users WHERE id = 16;
DELETE FROM Users WHERE id = 19;
DELETE FROM Users WHERE id = 28;
DELETE FROM Users WHERE id = 33;
DELETE FROM Users WHERE id = 35;
DELETE FROM Users WHERE id = 37;
DELETE FROM Users WHERE id = 39;
DELETE FROM Users WHERE id = 49;
DELETE FROM Users WHERE id = 52;
DELETE FROM Users WHERE id = 55;
DELETE FROM Users WHERE id = 56;
DELETE FROM Users WHERE id = 59;
DELETE FROM Users WHERE id = 64;
DELETE FROM Users WHERE id = 65;
DELETE FROM Users WHERE id = 66;
DELETE FROM Users WHERE id = 69;
DELETE FROM Users WHERE id = 71;
DELETE FROM Users WHERE id = 72;

On ajoute un enregistrement:

INSERT INTO Users (id, value) VALUES (666, 'satan');

Si on liste les enregistrements de la table:

SELECT * FROM Users;

On obtient:

ID interneIDValue
00test_0
22test_2
33test_3
44test_4
55test_5
66test_6
77test_7
99test_9
1010test_10
1111test_11
1212test_12
1313test_13
1414test_14
1515test_15
1717test_17
1818test_18
2020test_20
2121test_21
2222test_22
2323test_23
2424test_24
2525test_25
2626test_26
2727test_27
6363test_63
6767test_67
6868test_68
7070test_70
7373test_73
7474test_74
7575test_75
7676test_76
7777satan

On remarque que:

On va maintenant lancer le processus de compaction.

VACUUM TABLE Users;

On obtient :

Vacuuming table Users
Clearing index PRIMARY
Clearing index idx_value
Reindexing row 0
Insert [Text("test_0")] into non unique index idx_value => row_id 0
Reindexing row 1
Insert [Text("satan")] into non unique index idx_value => row_id 1
Reindexing row 2
Insert [Text("test_2")] into non unique index idx_value => row_id 2
Reindexing row 3
// ...
Reindexing row 55
Insert [Text("test_60")] into non unique index idx_value => row_id 55
Reindexing row 56
Insert [Text("test_58")] into non unique index idx_value => row_id 56
Reindexing row 57
Insert [Text("test_57")] into non unique index idx_value => row_id 57

Vacuum Report for table: Users
Pages deleted: 1
Vacuumed records: 13
Swap records:
        Swap #1 -> #77
        Swap #8 -> #76
        Swap #16 -> #75
        Swap #19 -> #74
        Swap #28 -> #73
        Swap #33 -> #70
        Swap #35 -> #68
        Swap #37 -> #67
        Swap #39 -> #63
        Swap #49 -> #62
        Swap #52 -> #61
        Swap #55 -> #60
        Swap #56 -> #58

On y voit la purge des index puis la réindaxation des enregistrements.

Et finallement le rapport de compaction.

On y remarque les opĂ©rations d’inversement de blocs dans les pages.

Le nombre de pages supprimées ainsi que les rows supprimées du stockage.

On recommence Ă  lire la table:

SELECT * FROM Users;
ID interneIDValue
00test_0
1666satan
22test_2
33test_3
44test_4
55test_5
66test_6
77test_7
876test_76
99test_9
1010test_10
1111test_11
1212test_12
1313test_13
1414test_14
1515test_15
1675test_75
1717test_17
1818test_18
1974test_74
2020test_20
2121test_21
2222test_22
2323test_23
2424test_24
2525test_25
2626test_26
2727test_27
2873test_73
2929test_29
3030test_30
3131test_31
3232test_32
3370test_70
3434test_34
3568test_68
3636test_36
3767test_67
3838test_38
3963test_63
4040test_40
4141test_41
4242test_42
4343test_43
4444test_44
4545test_45
4646test_46
4747test_47
4848test_48
4962test_62
5050test_50
5151test_51
5261test_61
5353test_53
5454test_54
5560test_60
5658test_58
5757test_57

Cette fois-ci les ID internes et les ID ne sont plus synchronisés.

Par contre, il n’y a plus de saut sur les ID internes.

De mĂȘme l’ID interne maximale qui Ă©tait de 77 est passĂ© Ă  57.

Dans l’opĂ©ration, nous venons de supprimer du stockage sans perte de donnĂ©es ! 😄

Parlant de perte de données, vérifions que nos index sont toujours cohérents:

EXPLAIN SELECT * FROM Users WHERE value = "test_76";

On obtient le plan suivant:

Scan index idx_value : value = 'test_76' for table Users
Filter: value = 'test_76'

Donc le query planner ira chercher sa donnĂ©e depuis l’index idx_value.

Donc si on a foirĂ© la rĂ©indexation de l’index idx_value, il n’y aura perte de donnĂ©es.

Vérifions cela:

SELECT * FROM Users WHERE value = "test_76";
ID interneIDValue
876test_76

Nickel, pas de perte de donnĂ©es ! 🎉

Conclusion

Clairement, nous sommes dans l’optimisation, mais cela me semblait intĂ©ressant de poser les bases de la rĂ©indexation de donnĂ©es.

Et puis, je me suis bien amusĂ© Ă  rĂ©aliser les petites animations. 😄

Dans la prochaine partie, nous allons implementer la commande UPDATE table SET value = x WHERE id = y.

Merci de votre lecture ❀

Vous pouvez trouver le code la partie ici et le diff lĂ .