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.
Sujet très technique. 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
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 decrypto
ou autreoffchain
. 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 ?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()
execute_with
injecte les externalités (une instance de storage notamment) dans le thread courantSystem::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 ;sp_io::storage::set
appelle alorssp_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’appelself.set_storage(key.to_vec(), value.to_vec())
- qui elle-même fait appel à
self.place_storage(key.to_vec(), value.to_vec())
self
est typiquement un objet BasicExternalities, donc l’appel mène àBasicExternalities::place_storage
qui va réaliser l’écriture dans l’Overlay- 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
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.
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.’