Je viens seulement d’y penser, mais le fait que la règle de distance soit implémentée via Oracle empêche aussi sa mise à jour de façon fluide sur la blockchain. En gros : à chaque changement de règle de WoT, nous allons devoir organiser une grande messe de mise à jour du Client Duniter.
Or nous avons beaucoup d’idées potentielles, et le passage à Substrate a été motivé en grande partie pour la facilité des mises à jour : se prendre les pieds dans le tapis concernant les règles de distance de la WoT est quand même dommage.
C’est pour cela que je me demande à quel point nous ne pourrions pas rapatrier les règles dans le Runtime.
Je poste ce sujet pour la réflexion. Néanmoins je serai un peu chagriné de devoir lancer la V2 et savoir d’avance qu’il faudra mettre à jour le Client pour faire évoluer cette partie.
Quelques réflexions en vrac :
on pourrait conserver l’Oracle, mais faire exécuter à celui-ci un code stocké dans le Storage (un WASM par exemple) plutôt qu’un code hardcodé dans le Client ;
le calcul de distance pourrait-il être découpé en plusieurs étapes ? Si oui, alors on pourrait garder son exécution directement en blockchain, avec une exécution progressive et stockage des résultats intermédiaires.
En pratique, les « offchain workers » ne sont pas très fiables ni pratiques : ils sont lancés après chaque bloc et obligent à gérer manuellement les forks.
Si l’on veut intégrer la logique de la règle de distance dans le runtime, la solution la plus adaptée est de créer une runtime API. Une runtime API, c’est une fonction définie dans le runtime que le client peut appeler hors chaîne, tout en ayant un accès lecture au storage.
Je suggère de créer une tâche côté client qui appelle deux API runtime :
1. Une première API distance_computable qui vérifie s’il y a une évaluation à faire et qui contrôle si l’une des clés publiques enregistrées pour le calcul de distance figure dans le keystore du nœud.
2. Une deuxième API distance_compute qui calcule effectivement la distance.
L’appel d’une runtime API nécessite d’être un full node, mais présente plusieurs avantages :
• Un unique binaire, sans option de configuration supplémentaire : si l’opérateur souhaite calculer la distance, il lui suffit d’insérer une clé privée dans le keystore du nœud et d’enregistrer la clé publique on-chain comme « calculateur de distance ». Le nœud se met automatiquement à calculer la distance dès qu’il est autorisé à le faire.
• Plus besoin de transférer des données vers un composant externe chargé du calcul : le code de distance accède directement au storage on-chain, sans contrainte de durée.
Nous utilisons un mécanisme similaire dans Moonbeam pour déterminer si un nœud doit produire un bloc : la fonction first_eligible_key récupère toutes les clés du keystore associées à un KeyId donné (4 octets arbitraires) et, pour chacune, appelle la runtime API can_author afin de vérifier si la clé publique (NimbusId) est enregistrée comme autorité et éligible pour le prochain bloc :
Cela nous a permis de modifier l’algorithme d’éligibilité sans mettre à jour le client. Je propose d’appliquer la même approche dans duniter-v2s pour décider dynamiquement si le nœud doit évaluer la distance ou non.
Il suffit de définir des runtimes API dans la pallet distance puis de les mapper dans ce fichier:
Ah super ! En fait il ne nous manque que cela. On peut conserver l’oracle tel qu’on l’a, ça ne me dérange pas. Tout ce qu’il faut c’est que le code de calcul soit dans le Runtime.
Ce sera probablement la solution la plus simple et moins coûteuse, je persiste un peu dans l’autre solution que je voulais explorer, mais probablement qu’on va partir sur ta solution avec la 2ème API
Moi si : le fait d’avoir un binaire séparé me gêne autant côté opérateur que côté développeur :
Côté opérateur, je ne participe pas au calcul de distance pour cette raison (je ne veux pas gérer plusieurs binaires/images pour mon nœud Duniter).
Côté développeur, ça complique le workflow dès qu’on réalise un changement de code qui impacte les métadonnées du runtime, car le binaire duniter-oracle utilise Subxt et ne compile plus si on modifie ces métadonnées. Pour la MR !328, par exemple, j’ai dû commenter toutes les occurrences de « duniter-oracle » pour pouvoir compiler mes changements et générer les nouvelles métadonnées.
Ce serait chouette si tu pouvais profiter des changements sur lesquels tu travailles pour fusionner l’oracle dans le binaire duniter-v2s ; sinon, je pourrais peut-être m’en charger moi-même plus tard
Je rappelle qu’il n’y a plus besoin de binaire séparé, l’oracle est une sous-commande du client. Je ne sais pas ce qui a été fait côté Docker.
L’oracle n’est pas intégré au client pour autant, il reste dans un processus séparé et lancé indépendamment, et je suis d’accord que ça pose des problèmes.
Edit: pour le problème de générer les métadonnées, il y a une feature cargo qu’on peut désactiver.
Ok je comprends. Cela me prendra sûrement du temps néanmoins : la difficulté principale que j’y vois est de trouver les créneaux de calcul pour éviter d’impacter l’import de blocs. C’est pour cette raison qu’il avait été choisi d’avoir un binaire/processus séparé il me semble.
La bonne pratique consiste à créer une tâche cliente asynchrone qui s’abonne aux notifications de finalisation des blocs, et à laisser Tokio gérer les ressources.
De plus, si l’on utilise une runtime API, le calcul de distance sera nécessairement mono-cœur : c’est plus lent, mais cela garantit que les autres cœurs du CPU restent disponibles pour l’importation des blocs (qui elle aussi est mono-cœur).
EDIT: J’ai ajouté un commentaire au ticket #293 où j’essaie de synthétiser ce qui semble se dégager de cette discussion.
Ok merci pour l’info pratique, et à y réfléchir, avec le calcul dans le Runtime il semble assez évident qu’il s’agit d’une tâche principalement I/O. Donc effectivement Tokio saura parfaitement optimiser les ressources et il ne devrait pas y avoir d’impact sensible pour l’instant avec moins de 10k membres.
Par contre il serait bon de monitorer un peu le calcul pour se rendre compte de son impact futur.
Il y a un test (désactivé par défaut, je crois qu’il faut faire --include-ignored ou quelque chose comme ça pour le lancer) qui benchmark le calcul sur la totalité des membres. Il faudrait le compiler en WASM pour évaluer la perte de perf. D’après des benchmarks Rust natif vs Rust WASM trouvés sur Internet, ça pourrait faire ×4 ou ×5, ce qui reste assez rapide.
J’ai parlé de Tokio car c’est ce que Substrate utilise sous le capot ; tu n’as pas besoin d’utiliser Tokio directement.
Cette présentation au Sub0 2022 montre comment créer un Substrate worker async qui s’abonne aux blocs finalisés. Ils expliquent aussi comment soumettre une transaction on-chain depuis le worker pour « publier » le résultat. Dans leur exemple, la logique métier est implémentée en natif côté client, mais rien n’empêche le worker d’appeler une runtime API à la place.
Le mieux serait plutôt de créer un benchmark dans le pallet distance, car les benchmarks de pallet compilent déjà en WASM. On peut très bien benchmarker des fonctions publiques du pallet même si ce ne sont pas des “call”.
Substrate effectue une compilation JIT Wasm-to-native au démarrage du nœud et après chaque mise à jour du runtime. Ce type de compilation n’est pas aussi optimisé que ce que fait rustc, mais il permet d’obtenir des performances proches du natif.