Tous les textes

~/textes/python-to-rust-indexer

Débogage de systèmes
5 min de lecture

La version Python marchait. Je l'ai réécrite en Rust pareil.

Porter un indexeur de chaîne fonctionnel de Python vers Rust, là où les parties faciles deviennent plus dures et les parties floues se font attraper. La plupart de ce que la réécriture m'a rapporté, ce n'était pas de la vitesse, c'était des bogues qu'il devenait impossible d'écrire.

Même projet qu'avant : un ami a fait l'art pendant les années NFT, et j'ai écrit le backend qui répondait à la question de qui possède quoi. J'ai écrit la première version en Python. Ça marchait. Ça a fait rouler le site au complet.

Ensuite je l'ai réécrite en Rust. Pas pour un banc d'essai que je peux brandir, j'en ai jamais roulé un propre et je vais pas en inventer un. Je l'ai réécrite parce que le portage m'a forcé à dire, en types, les choses que Python m'avait laissé garder floues, et une couple de ces zones floues étaient des bogues qui traînaient là depuis le début.

C'est gros comment, le nombre

En Python, un entier a une précision arbitraire et c'est gratuit. J'additionnais des valeurs de jetons sans jamais penser à la largeur. Rust te force à y penser dès la première ligne, et sur la chaîne cette question a une réponse précise et achalante : les ids et les montants de jetons sont des uint256. Rien de ce que Postgres offre tient 256 bits, alors l'indexeur les stocke comme des tableaux JSON de chaînes décimales et l'application fait le calcul dans un type qui comprend la largeur.

La marche des soldes a un piège plus subtil, et c'est le genre que Python cache. Un solde est non signé, on peut pas posséder de jetons négatifs. Mais tu le reconstruis en rejouant les transferts, en additionnant à l'entrée et en soustrayant à la sortie, et les événements arrivent pas triés par ton portefeuille. Un transfert sortant peut atterrir avant le transfert entrant correspondant, alors le total courant descend en bas de zéro au milieu de la marche avant de remonter. Accumule ça dans un entier non signé et ça déborde par le bas. Donc le type de travail est volontairement signé, et le signe se règle seulement à la fin :

src/backend/queries.rs
let mut balances: HashMap<u64, i64> = HashMap::new();
for event in events {
    for (&id, &value) in ids.iter().zip(values.iter()) {
        // signed scratch: an out-before-in can go temporarily negative
        if to == wallet   { *balances.entry(id).or_insert(0) += value; }
        if from == wallet { *balances.entry(id).or_insert(0) -= value; }
    }
}
balances.retain(|_, &mut v| v > 0); // unsigned answer, resolved once at the end

La requête à l'échelle de la collection porte l'autre morceau de sagesse reçue : les frappes viennent de l'adresse zéro et les brûlages vont à l'adresse morte, et si tu comptes l'un ou l'autre comme détenteur ton offre est fausse. Ils sont exclus dans la clause WHERE, pas rapiécés après coup.

src/backend/queries.rs
const DEAD_ADDRESS: &str = "0x000000000000000000000000000000000000dEaD";
const ZERO_ADDRESS: &str = "0x0000000000000000000000000000000000000000";

Deux programmes, un projet

L'indexeur et l'API sont des jobs différentes. L'un crawle la chaîne et écrit dans la base de données, l'autre lit la base de données et répond au HTTP. Mais ils partagent le même type Event et le même module de base de données, et je voulais pas que deux dépôts dérivent l'un de l'autre. Rust fait ça avec un seul workspace et deux binaires sous src/bin, qui partagent une crate de bibliothèque :

afterlife-backend layout
src/
  lib.rs            // pub mod common; pub mod indexer; pub mod backend;
  bin/indexer.rs    // crawls chain -> Postgres
  bin/backend.rs    // warp API over the same tables
  common/           // Event, database, file_loader: shared by both

C'est aussi là que j'ai perdu un après-midi en tant que débutant en Rust. Le système de modules se fiche de ce que tu penses être évident. pub mod common dans lib.rs veut le code dans common.rs ou common/mod.rs, les binaires dans bin/ rejoignent le code partagé par le nom de la crate et pas par un chemin relatif, et le compilateur te laissera rien faire à la légère là-dedans. Une fois que ça compile, ça reste compilé, c'est le marché.

J'ai posé la mauvaise question pendant deux jours

L'API sert beaucoup de petits fichiers JSON : des métadonnées par jeton, lues encore et encore. Alors je suis allé chercher la façon la plus rapide de lire un fichier en Rust et je suis resté coincé sur BufReader versus read_to_string pendant un temps gênant.

C'était la mauvaise question. BufReader aide quand tu fais beaucoup de petites lectures depuis un même handle ; pour avaler un petit fichier au complet d'un coup, c'est du bruit. Le coût, c'était pas l'appel de lecture, c'était de lire les mêmes fichiers des milliers de fois. Le correctif, c'était une mise en cache, et la seule vraie décision était de la borner pour que servir les métadonnées puisse pas manger toute la mémoire de la machine :

src/common/file_loader.rs
pub async fn read_file(path: &Path) -> io::Result<String> {
    let mut buf = String::new();
    BufReader::new(File::open(path).await?).read_to_string(&mut buf).await?;
    Ok(buf)
}
// behind an LruCache with a fixed capacity, shared across handlers

Partager cette mise en cache à travers les handlers warp a produit la plus Rust de toutes les erreurs Rust : future cannot be sent between threads safely. La cache devait être Send + Sync pour traverser les points d'await dans un handler asynchrone, ce qui voulait dire un Arc<Mutex<…>> et un once_cell pour le global, pas le simple HashMap que j'avais attrapé en premier. Python m'aurait laissé clouer un dict sur un module et passer à autre chose, et il m'aurait aussi laissé faire des fuites de mémoire jusqu'à ce que la machine tombe.

Ce que la réécriture m'a vraiment rapporté

Pas un nombre. Le retour honnête est plus étroit et plus plate qu'un gain de vitesse : un lot mal formé peut pas devenir une ligne de base de données parce que la vérification de longueur vit dans le constructeur, la moitié d'une plage de blocs peut pas se valider parce que l'écriture est une seule transaction, et le plafond de la cache est une valeur que j'ai choisie au lieu d'une surprise que je rencontrerais en production. Le tout compile vers un seul binaire statique que j'ai compilé en croisé vers ARM64 depuis un portable amd64 et laissé rouler sur une petite machine. C'est ça, le pitch.

Le code est sur GitHub.