Le Runtime et les Externalités

Il y a quelques jours je découvrais le concept d’Externalités. Comprendre ce point m’a aidé à mieux cerner le Runtime, son environnement et ses limites.

Je fais ce petit sujet pour vous partager mon apprentissage. J’espère que vous en tirerez profit. Et puis je pourrais moi aussi m’y référer plus tard si besoin. :slight_smile:

:warning: Sujet très technique. :technologist: exprimé avec mes mots et ma compréhension actuelle.

Que s’est-il passé ?

À la base, j’étais simplement en train de creuser un peu ce qui se trouvait dans le Storage dans un bête test unitaire. Et très souvent sur un test unitaire, on tombe sur ce genre de code :

#[test]
fn simple_test() {
    new_test_ext().execute_with(|| {
        // [...] code interagissant avec le Runtime
    });
}

Ici, le code qui m’a posé question est execute_with. J’ai tenté, un peu par hasard, de mettre des appels pour écrire dans le storage avant ou après, par exemple : System::set_block_number(1);, et j’ai reçu comme réponse à l’exécution :

panicked at ‘set_version_1 called outside of an Externalities-provided environment.’

Et c’est cette erreur qui m’a posé question. Je ne comprenais pas pourquoi il était impossible de mettre un pauvre appel de fonction de pallet à cet endroit.

C’est le point de départ de mes recherches sur les Externalities. J’ai donc ouvert mon débogueur sur Duniter V2S, une autre fenêtre d’IDE sur le code source de Substrate, et j’ai creusé. Voici ce qu’il en est sorti.

Les limites du Runtime

Au final, c’est autour de ce point que tout à tourné : la frontière entre Runtime et Client.

Il faut comprendre que le Runtime c’est le cœur du système, au sens où c’est l’endroit où sont définies les règles communes à tous les utilisateurs de la blockchain et qui doit faire consensus.

Or, pour faire consensus il faut exprimer des règles sans ambiguïtés : c’est un exercice délicat où il est très facile de tomber dans le piège d’une spécification interminable voire infinie.

Imaginez simplement : dans le protocole Duniter nous souhaitons stocker des comptes, et un nombre d’unités de monnaie que celui-ci possède. Bien. Où stocke-t-on ces données ? Dans une “base de données”. D’accord mais quelle base précisément ? Et quelle version du logiciel pour cette base ? Et quel protocole pour cette base de donnée ? Et puis, cette base fonctionne elle-même sur un OS, lequel ? Compilé sur quelle architecture ? Et d’ailleurs c’est quoi une architecture ? Etc.

Sans être parfaitement clair sur toutes ces questions, alors il y a toutes les chances que le consensus ne tienne pas car les résultats obtenus chez les uns ne seront pas les mêmes chez les autres.

Une solution : l’agnosticité

C’est-à-dire admettre qu’il existe certains élément échappant à une définition déterministe rigoureuse. Le protocole définit alors plutôt des principes à respecter pour des éléments extérieurs au Runtime, et suppose que ces principes seront respectés.

Un bon exemple, je trouve, est le Storage. Sa structure est parfaitement définie : il s’agit d’un Merkle Patricia tree en Base 16, les règles de stockage sont déterministes.

Mais ! C’est là où se trouve l’hypothèse : ce Trie repose sur une base de données clé/valeur, certes, mais on ne dit pas laquelle. Le protocole est agnostique sur ce point, l’implémentation est laissée au Client (pas au Runtime) qui peut en changer à volonté. D’ailleurs selon la façon dont vous compilez votre nœud, vous pourriez utiliser RocksDB ou bien PartityDB. Et c’est censé ne rien changer.

Ces hypothèses, axiomes, agnosticités, …, ont un nom dans Substrate : ce sont des Externalités.

Les Externalités

Le Storage est la plus emblématique des Externalités, mais il en existe d’autres :

  • les offchain workers : le Runtime sait qu’il peut les solliciter, sans savoir exactement (i.e. sans déterminisme) l’impact que cet appel aura sur la suite des évènements ;
  • le transaction pool : le Runtime sait qu’il peut pousser un extrinsic ;
  • le keystore : le Runtime sait qu’il peut obtenir un trousseau cryptographique pour générer des blocs ;

Ces externalités sont exploitées depuis le Runtime via des runtime interface (RI), déclarées par la macro #[runtime_interface] : ce sont des modules Rust un peu spécifiques possédant la particularité de nécessiter un environnement fournissant les Externalités que ces RI vont utiliser. Exemples :

  • module sp_io::storage : RI tellement central qu’il n’utilise pas d’extension
  • module sp_io::misc : RI qui utilise l’extension ReadRuntimeVersionExt
  • module sp_io::crypto : RI qui utilise l’extension KeystoreExt, VerificationExtDeprecated
  • module sp_io::offchain : RI qui utilise OffchainWorkerExt, TransactionPoolExt, OffchainDbExt
  • module sp_io::statement_store : RI qui utilise StatementStoreExt

:information_source: Vous noterez que tous ces modules sont définis dans sp_io, c’est effectivement cette crate qui expose le runtime interface et fait le pont vers le Client depuis le Runtime.

Pour le dire autrement : au moment où l’on appelle l’une de ces RI (le module sp_io::storage par exemple), les externalités doivent avoir été mises à disposition de cette RI afin que celle-ci puisse les manipuler.

Je me suis demandé “pourquoi ?” avoir conçu ce système, puis en y réfléchissant un peu je pense que c’est dû au Runtime : pour lui, il n’existe qu’un seul storage, qu’un seul fournisseur de crypto ou autre offchain. Et donc la transition avec le monde Rust se fait de cette façon : via les runtime interface, qui
du point de vue de la compilation sont comme des variables d’instance globales (ce qui n’existe normalement pas en Rust).

A noter que c’est très pratique pour les tests unitaires : le Runtime a beau être commun à tous les tests, les données sont à chaque test différentes car l’on injecte pas les mêmes externalités via la fonction execute_with(|| { ... }). CQFD ? :slight_smile:

Non car en fait à part pour les tests, si l’on réinjecte les externalités à chaque appel au Runtime c’est parce que celui-ci peut changer : eh oui ! Les fameuses Runtime Upgrade, changement à chaud du Runtime.

Pour reprendre mon exemple de départ System::set_block_number(1), voici ce qui se passe :

Si l’appel est fait dans new_test_ext().execute_with()

  1. execute_with injecte les externalités (une instance de storage notamment) dans le thread courant
  2. System::set_block_number (une fonction dans le Runtime de test) appelle la méthode “sp_io::storage::set”, où sp_io::storage est donc un module construit par la macro #[runtime_interface] évoquée précédemment, et est une sorte d’objet global au Runtime représentant le Storage ;
  3. sp_io::storage::set appelle alors sp_externalities::with_externalities pour exécuter la fonction ::set dans un contexte où le mot-clé self (c’est du Rust) est l’objet porteur des externalités (c’est l’injection proprement dite), précisément celui défini à l’étape 1, et donc la fonction ::set peut faire l’appel self.set_storage(key.to_vec(), value.to_vec())
  4. qui elle-même fait appel à self.place_storage(key.to_vec(), value.to_vec())
  5. self est typiquement un objet BasicExternalities, donc l’appel mène à BasicExternalities::place_storage qui va réaliser l’écriture dans l’Overlay
  6. La donnée est dans le cache (l’Overlay), elle sera inscrite dans le Storage à la finalisation du bloc

Si l’appel est fait avant new_test_ext().execute_with()

On ne fait rien à l’étape 1. Puis à l’étape 2, #[runtime_interface] plante avec le message :

panicked at ‘set_version_1 called outside of an Externalities-provided environment.’

C’est en fait cette macro qui remonte la panic au Runtime, car à l’exécution, cette macro vérifie que les externalités ont bien été fournies. Ce qui n’est pas le cas avant l’appel à execute_with() !

Si l’appel est fait après new_test_ext().execute_with()

Même comportement que si l’appel était fait avant.

C’est parce que new_test_ext().execute_with() supprime la référence globale aux externalities une fois exécutée.

L’instruction #[runtime_interface]

On le comprend bien à ce stade, c’est la macro exploitée dans la crate sp_io qui fait le boulot de dialogue entre Runtime et Client, que le Runtime soit en Wasm ou en Natif.

Si l’on regarde le détail de cette macro, on verrait que celle-ci génère à la fois du code pour le Runtime natif et le Runtime Wasm. C’est grâce à cela que le nœud Duniter V2S peut exécuter les externalités aussi bien depuis un Runtime natif que Wasm (ce qui est important, car notre nœud peut aussi bien exécuter un Runtime natif que Wasm).

Ecriture finale dans le Storage

Au final, l’instruction System::set_block_number(1) écrit une valeur dans le storage au moment de l’appel à Executive::finalize_block dans le Client. Ce dernier demande au Runtime de finaliser le bloc, qui lui-même appelle l’externalité sp_io::storage pour écrire la valeur au moment du calcul du Merkle root.

Pour les plus curieux, vous pouvez mettre un point d’arrêt ici (crate sp_io) :

#[version(2)]
fn root(&mut self, version: StateVersion) -> Vec<u8> {
	self.storage_root(version)
}

Et lancer :

cargo run -- --dev

Ou encore plus profondément ici (crate sp_state_machine, fichier trie_backend_essance.rs) :

fn insert(&mut self, prefix: Prefix, value: &[u8]) -> H::Out {
	HashDB::insert(self.overlay, prefix, value)
}

Vous pourrez constater que la 1ère fois que l’on passe dans cette méthode, c’est lors du calcul du storage_root, lui-même appelé à la finalisation du bloc.

Conséquence : certains Runtime Upgrade pourraient nécessiter une màj du Client

… si l’une des externalités devait être modifiée pour convenir aux nouvelles spécifications du Runtime.

Par exemple, si le Storage changeait de forme (passait d’un Merkle Patricia tree Base 16 à autre chose). Ce qui est quand même assez peu probable :slight_smile:

Mais c’est logique : les externalités, en tant qu’axiomes, fondent le fonctionnement du Runtime et donc ne doivent pas vraiment bouger.

On comprend bien que si le modèle de donnée changeait la migration devrait passer par un hard fork. :cloud_with_lightning:

Pour résumer

#[test]
fn simple_test() {
    new_test_ext().execute_with(|| {
        // [...] code interagissant avec le Runtime
    });
}

execute_with(|| { ... }) injecte dans une variable globale les externalités présentes dans new_test_ext(), variable globale qui sera réutilisée par les runtime interface lorsque celles-ci sont appelées par le Runtime.

En dehors de ce contexte, les runtime interface paniquent :

panicked at ‘set_version_1 called outside of an Externalities-provided environment.’

3 Likes

runtime-interface est tout un tas de macros qui facilite grandement l’interfaçage entre le runtime WASM et l’exécuteur. Il construit une interface rustique sur le protocole WASM qui est un bête FFI très rudimentaire (le WASM déclare ses fonctions appelables, dont l’argument n’est qu’une slice &[u8], et il peut appeler les fonctions de l’hôte dont la signature est définie comme une FFI C avec #[extern]).

Pour un projet utilisant WASM j’avais commencé avec les crates les plus bas niveau pour exécuter du WASM (wasmtime), puis en constatant la difficulté de faire un truc propre et complet je suis remonté progressivement vers des crates qui gèrent toute la plomberie, donc sp-runtime-interface et tout.

Intéressant, mais je n’ai pas encore vu/creusé les appels Client → Runtime. Pour moi les runtime_interface c’est uniquement dans l’autre sens.

Tres interessant !

Pour les connaisseurs de l’architecture hexagonale, on retrouve les concepts d’injection de dépendances et d’inversion de dépendances à l’aide d’interfaces. Ces concepts utilisés en programmation orientée objet, sont ici adaptés à un langage non orienté objet,

J’ai creusé un peu et je note que l’on peut repérer les bords du Runtime via :

  • la macro #[runtime_interface] pour gérer les appels depuis le Runtime vers son hôte
  • la macro impl_runtime_apis! pour gérer les appels depuis l’hôte vers le Runtime : code côté Duniter, fichier apis.rs. On y voit bien les appels Executive:: qui laisse présager les appels à l’exécutor ensuite, qui gèrera la bifurcation vers natif ou Wasm.

Ces deux macros génèrent à la fois du code côté client et du code côté Runtime (Wasm et Natif) pour faire le pont entre les deux mondes.

Oui c’est ce que j’ai compris.

Je suis allé voir un peu la définition du standard WebAssembly, c’est frappant de voir que les contraintes posées au départ son très similaires à celles que l’on peut rencontrer pour un protocole blockchain (sandboxé et déterministe notamment).

Je ne sais pas si c’est juste une coïncidence. Mais WebAssembly est de ce fait particulièrement adapté à être un Runtime pour blockchain (indépendamment de Substrate je veux dire).