From 8f2470987482585cb8761667d2ace157292f84a5 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Wed, 21 Oct 2026 14:01:46 +0200 Subject: [PATCH] =?UTF-8?q?protocole=20optimiseur=201.2=20:=20les=20r?= =?UTF-8?q?=C3=A8gles=20Q2=20=C3=A0=20Q9,=20et=20le=20moteur=20annonce=201?= =?UTF-8?q?.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/OPTIMIZER_PROTOCOL.md passe en v1.2 (mineure) et écrit les décisions du 2026-10-21 à leur place : - §2 : maxLineBytes (1 Mio, '\n' non compté) dans les deux sens ; lots interdits dans les deux sens ; /run/powersync appartient au paquet de l'optimiseur, le moteur ne le crée ni ne le suppose. Écart connu noté : le moteur ne borne pas encore ce qu'il émet. - §3 : la majeure se compare des deux côtés (-32602 puis fermeture) ; -32001 normé ; un token non exigé est accepté ; un handshake refusé est fermé des deux côtés ; GetPlan avant handshake → -32600, connexion gardée. - §7 : contexte GetPlan invalide → -32602 avec chemins et natures, jamais les valeurs, compté comme un échec ; codes JSON-RPC 2.0 standard sinon ; table des erreurs GÉNÉRÉE depuis le contrat. - §8 : UpdateLearned à sens unique, -32601 avec id ; forme non normée en 1.2. - §9, §0 (changelog, compatibilité 1.1), annexes A, E, G. Contrat OpenRPC : methods[].errors complétés (x-etm-connection), x-etm-errors-engine, x-etm-message-errors (nouveau), x-etm-transport (maxLineBytesScope, batches, runtimeDirectory), règle handshake-refused. La version (info.version, ProtocolVersion) régénérée depuis le code par --enums-from. Moteur : OptimizerProtocol::ProtocolVersion et OptimizerClient::kProtocolVersion passent à "1.2" ; testHandshakeAndPlan vérifie aussi que les deux constantes sont égales. Aucun autre comportement ne change. Tests suivis : version attendue dans testoptimizerclient et testsocketscheduler, handshake de la session de schema-check-test, et un test du bloc « errors » dans gen-protocol-doc-test. Co-Authored-By: Claude Opus 5.5 --- docs/OPTIMIZER_PROTOCOL.md | 167 +++++++++++++++--- ...-powersync-optimizer-protocol.openrpc.json | 84 +++++++-- energyplugin/etm/scheduler/optimizerclient.h | 2 +- .../etm/scheduler/optimizerprotocol.h | 2 +- .../optimizerclient/testoptimizerclient.cpp | 8 +- .../socketscheduler/testsocketscheduler.cpp | 2 +- .../etm-powersync-gen-protocol-doc-test.py | 16 ++ .../tools/etm-powersync-schema-check-test.py | 2 +- 8 files changed, 241 insertions(+), 42 deletions(-) diff --git a/docs/OPTIMIZER_PROTOCOL.md b/docs/OPTIMIZER_PROTOCOL.md index 25c20c8..8e7f376 100644 --- a/docs/OPTIMIZER_PROTOCOL.md +++ b/docs/OPTIMIZER_PROTOCOL.md @@ -1,4 +1,4 @@ -# OPTIMIZER_PROTOCOL — v1.1 +# OPTIMIZER_PROTOCOL — v1.2 Contrat entre le moteur d'énergie **etm-powersync-energy-plugin-etm** (couche A, GPL-3.0) et un **optimiseur** externe (couche B : l'optimiseur ETM, ou toute implémentation tierce). @@ -8,7 +8,7 @@ Ce document fait autorité. Il sert trois usages : 2. Spécification du protocole socket pour un optimiseur en process séparé. 3. Documentation publique : n'importe qui peut implémenter son propre optimiseur. -Statut : **v1.1** — figé à la fin de la beta. Versionné en semver (`protocolVersion`). +Statut : **v1.2** — figé à la fin de la beta. Versionné en semver (`protocolVersion`). > ### La forme est un contrat MACHINE > @@ -42,6 +42,40 @@ Statut : **v1.1** — figé à la fin de la beta. Versionné en semver (`protoco ## 0. Changelog +### 1.2 (2026-10) +Mineure. Norme les points que le premier optimiseur réel a trouvés ouverts : erreurs renvoyées par +l'optimiseur, fermeture après un refus, token non exigé, sens d'`UpdateLearned`, longueur de ligne, +lots, propriétaire de `/run/powersync`. **Côté moteur, seule la version annoncée change** : il +n'émet aucun message nouveau et juge les réponses exactement comme en 1.1. + +**Compatibilité.** Même majeure : un moteur 1.2 accepte un optimiseur 1.0 ou 1.1, et un +optimiseur 1.2 accepte un moteur 1.0 ou 1.1 (seule la majeure est comparée, §3). Les règles +nouvelles portent sur ce que fait l'optimiseur dans des situations que le moteur ne produit pas — +contexte invalide, `GetPlan` avant handshake, autre majeure, lot — et sur une qu'il peut produire, +la ligne de plus de 1 Mio (§2, écart connu). Un optimiseur 1.1 qui y répond autrement continue de +fonctionner avec ce moteur ; il n'est pas conforme 1.2 sur ces points, et s'aligne avant de +s'annoncer 1.2. + +- §7 **Contexte `GetPlan` invalide** : l'optimiseur répond une **erreur `-32602`**, pas une + abstention ; `error.data` liste les chemins fautifs et la nature de chaque problème, jamais les + valeurs. Le moteur la compte comme un échec (règle des échecs consécutifs inchangée). +- §3, §7 **Codes d'erreur de l'optimiseur normés** : majeure incompatible au handshake → `-32602` + puis fermeture ; `GetPlan` avant un handshake réussi → `-32600`, connexion gardée ; token faux + ou absent → `-32001` (inchangé). Toute autre erreur : un code JSON-RPC 2.0 standard. +- §3 **Après un handshake refusé, les deux côtés ferment** : l'optimiseur après avoir envoyé + l'erreur, le moteur à sa réception. +- §3 **Token envoyé alors que l'optimiseur n'en exige pas : accepté**, sans erreur. +- §8 **`UpdateLearned`** : sens optimiseur → moteur uniquement ; reçu par un optimiseur, + notification ignorée, requête avec `id` → `-32601`. Sa **forme reste non normée en 1.2**. +- §2 **`maxLineBytes` (1 Mio) dans les deux sens** ; ligne trop longue reçue par l'optimiseur → + `-32600`, connexion gardée. +- §2 **Lots JSON-RPC interdits** dans les deux sens : un message par ligne ; un lot reçu → `-32600`. +- §2 **`/run/powersync` appartient au paquet de l'optimiseur** ; le moteur ne le crée ni ne le + suppose. +- Contrat machine : `methods[].errors` complétés, `x-etm-message-errors` (nouveau), + `x-etm-transport` (`maxLineBytesScope`, `batches`, `runtimeDirectory`) ; tableau des erreurs + du §7 généré depuis lui. + ### 1.1 (2026-10) Additif sur le fil : toutes les additions sont des **champs optionnels nouveaux** ; un optimiseur 1.0 reste conforme (champs inconnus ignorés, §9). @@ -110,9 +144,28 @@ valeur avait été écrite sans définition ; **aucun émetteur ne l'a jamais pr - Endpoint configurable : - `unix:///run/powersync/optimizer.sock` — défaut. Optimiseur local. Droits : socket possédé par `root:powersync`, mode `0660`. + **Le répertoire `/run/powersync` appartient au paquet de l'optimiseur** (1.2) : + `RuntimeDirectory=powersync` de son unité systemd, groupe `powersync`. Le moteur ne le crée + ni ne le suppose : sans optimiseur, il n'en a pas besoin — et il ne se connecte à rien tant + qu'aucun endpoint n'est configuré (§7, `optimizerExpected=false`). - `tcp://:` — optimiseur sur le LAN (NAS, serveur). Pour les implémentations communautaires (ex. pont Akkudoktor-EOS, annexe B). - Protocole applicatif : **JSON-RPC 2.0, un message par ligne (NDJSON), UTF-8.** +- **Pas de lot, dans les deux sens** (1.2). Chaque ligne porte un seul objet JSON-RPC ; un lot + (`[…]`) est interdit en émission. Reçu par l'optimiseur : erreur `-32600` à `id: null`, aucun + message du lot n'est traité, connexion conservée. Reçu par le moteur : ligne écartée + (`invalid-json` : ce n'est pas un objet), comptée, connexion conservée ; une réponse contenue + dans le lot n'est pas lue, et la requête qu'elle visait finit en délai dépassé (§4). +- **Longueur de ligne : `maxLineBytes` = 1 Mio (1 048 576 octets, `\n` non compté), dans les deux + sens** (1.2). Reçue trop longue par l'optimiseur : jetée sans être gardée en mémoire, erreur + `-32600` à `id: null`, connexion conservée. Reçue par le moteur : ligne écartée + (`line-too-long`), comptée, connexion conservée ; la requête en vol continue d'attendre — si sa + réponse était cette ligne, elle finit en délai dépassé (10 s, échec compté, §4). + > **Écart connu du moteur (1.2)** : il n'applique pas encore la borne à ce qu'il **émet**. + > Rien ne borne la taille d'un contexte `GetPlan` (un `label` de charge très long suffit) ; une + > telle requête part sur une seule ligne de plus de 1 Mio, un optimiseur conforme la refuse, et + > la requête finit en délai dépassé. Éprouvé par `testAContextOverTheLineLimitIsStillSent` + > (suite `optimizerclient`, échec attendu tant que l'écart vit). - Le contrat est transport-agnostique : mêmes messages sur unix et tcp. - Reconnexion : backoff exponentiel (1 s → 60 s max). Pendant la déconnexion : stratégie de repli active, `optimizerAlive = false`. @@ -123,7 +176,7 @@ valeur avait été écrite sans définition ; **aucun émetteur ne l'a jamais pr ```json {"jsonrpc":"2.0","id":1,"method":"Handshake","params":{ - "protocolVersion":"1.1", + "protocolVersion":"1.2", "engine":"etm-powersync-energy-plugin-etm/", "token":"" }} @@ -133,7 +186,7 @@ Réponse attendue : ```json {"jsonrpc":"2.0","id":1,"result":{ - "protocolVersion":"1.1", + "protocolVersion":"1.2", "name":"etm-optimizer", "planning":true, "capabilities":{ @@ -146,14 +199,16 @@ Réponse attendue : }} ``` -- `protocolVersion` : même **majeure** requise. Majeure différente → le plugin refuse, - log explicite, repli rule-based. +- `protocolVersion` : même **majeure** requise, **des deux côtés**. Majeure différente dans la + réponse → le plugin refuse, log explicite, repli rule-based. Majeure différente dans la requête + → l'optimiseur refuse (1.2) : erreur `-32602`, puis fermeture. Une autre mineure est acceptée + des deux côtés. - `planning` : `true` si l'optimiseur produit des plans multi-créneaux ; `false` s'il ne répond qu'au présent (plan à un créneau). Informationnel. -- `token` : si configuré côté plugin et absent/incorrect côté serveur → réponse `error` - (code `-32001` recommandé ; le moteur traite **toute** réponse `error` au handshake comme un - refus, `handshake-refused`) puis connexion fermée. Recommandé pour tout endpoint `tcp://`. **Forme** (E-T2-8) : chaîne de **16 caractères au - moins** et de 4 096 octets au plus, imprimable, sans blanc ni caractère de contrôle (UTF-8 +- `token` : si configuré côté optimiseur et absent ou différent dans la requête → réponse `error` + **`-32001`** (normé en 1.2 ; « recommandé » en 1.1) puis connexion fermée. **Un token envoyé + alors que l'optimiseur n'en exige pas est accepté**, sans erreur (1.2). Recommandé pour tout + endpoint `tcp://`. **Forme** (E-T2-8) : chaîne de **16 caractères au moins** et de 4 096 octets au plus, imprimable, sans blanc ni caractère de contrôle (UTF-8 admis). Le plugin refuse d'en poser un plus court, et ne se connecte pas avec un fichier de token plus court (ni sans token) : il masque toute occurrence du token dans ce que l'optimiseur renvoie, et un token court corromprait un identifiant qui le contiendrait. @@ -165,6 +220,14 @@ Réponse attendue : `result` non objet, `protocolVersion` absent ou vide, `capabilities` présent mais non objet (`invalid-handshake`) ; majeure différente (`major-mismatch`). - `name` : optionnel, informatif, affiché en mode installateur ; jamais contrôlé. +- **Refus et fermeture** (1.2). Un handshake refusé — token (`-32001`) ou majeure (`-32602`) — + est fermé **des deux côtés** : l'optimiseur ferme après avoir envoyé l'erreur, le moteur à sa + réception. Le moteur traite **toute** réponse `error` au handshake comme un refus, quel que soit + son code (`handshake-refused`), et retente au backoff (§2). +- **`GetPlan` avant un handshake réussi** (1.2) : l'optimiseur répond `-32600` et **garde** la + connexion — le client peut encore envoyer `Handshake`. Le moteur n'en envoie jamais : il + n'appelle `GetPlan` qu'une fois le handshake accepté. +- Les codes d'erreur de l'optimiseur sont rassemblés au §7, « Erreurs renvoyées par l'optimiseur ». @@ -173,7 +236,7 @@ Réponse attendue : | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| -| `protocolVersion` | `ProtocolVersion` | oui | `"1.1"` | Version du moteur ; seule la majeure est comparée. | +| `protocolVersion` | `ProtocolVersion` | oui | `"1.2"` | Version du moteur ; seule la majeure est comparée, des deux côtés (une autre majeure : l'optimiseur répond -32602 et ferme). | | `engine` | string | oui | motif `^etm-powersync-energy-plugin-etm/` | Moteur et sa version. | | `token` | string | non | ≥ 16 car. | Secret partagé (§3, E-T2-8) : 16 caractères au moins, 4 096 octets au plus (borne en octets non exprimable ici : contrôlée par le moteur), imprimable, sans blanc ni caractère de contrôle, UTF-8 admis. Envoyé seulement s'il est configuré ; jamais journalisé. | @@ -1152,6 +1215,42 @@ requête remplacée par une plus récente n'est pas un échec. Avant le premier `optimizerAlive` vaut `false`. Un plan invalide remplace le cache (l'ancien plan valide n'est plus appliqué) et un socket fermé l'abandonne (E-T3-9). +**Erreurs renvoyées par l'optimiseur** (1.2). Un optimiseur répond une erreur JSON-RPC, jamais +une abstention, à ce qu'il ne peut pas traiter. En particulier : + +- **Contexte `GetPlan` invalide → `-32602`**, pas `abstain`. `error.data` liste les problèmes : + pour chacun, le **chemin** du champ fautif et la **nature** du problème (obligatoire absent, + type faux, valeur hors domaine…) — **jamais la valeur reçue**. Un contexte invalide est un défaut + du moteur ; une abstention le cacherait. Le moteur compte cette erreur comme un **échec** de la + requête (`rpc-error`), selon la règle des deux échecs consécutifs ci-dessus, inchangée. +- Les codes sont ceux de JSON-RPC 2.0, plus `-32001` (token refusé), seul code propre au + protocole. Une situation que le tableau ne nomme pas prend le code standard qui lui correspond + (`-32603` erreur interne, par exemple). + + + + +| Code | Méthode | `message` | Connexion après l'erreur | Quand | +|---|---|---|---|---| +| `-32001` | Handshake | token refusé | fermée | Token configuré côté optimiseur, et absent ou différent dans le Handshake. Un token envoyé alors que l'optimiseur n'en exige pas est ACCEPTÉ. | +| `-32602` | Handshake | majeure incompatible | fermée | Majeure de params.protocolVersion différente de celle de l'optimiseur (1.2). | +| `-32600` | GetPlan | handshake requis | conservée | GetPlan reçu avant un Handshake réussi sur cette connexion (1.2). Le moteur n'en envoie jamais : il attend d'être prêt. | +| `-32602` | GetPlan | contexte invalide | conservée | Contexte GetPlan non conforme au contrat (1.2). error.data liste les problèmes : pour chacun, le chemin du champ fautif et la nature du problème, JAMAIS la valeur reçue. Une erreur et non une abstention : un contexte invalide est un défaut du moteur, une abstention le cacherait. | +| `-32603` | GetPlan | erreur interne | conservée | Code JSON-RPC 2.0 standard pour toute autre panne de l'optimiseur. | +| `-32700` | toute | erreur d'analyse | conservée | Ligne qui n'est pas du JSON (ou pas de l'UTF-8). Réponse à id null. Code standard JSON-RPC 2.0. | +| `-32600` | toute | lot interdit | conservée | Ligne qui est un lot JSON-RPC ([…]) (1.2). Une seule réponse, à id null ; aucun message du lot n'est traité. | +| `-32600` | toute | ligne trop longue | conservée | Ligne de plus de maxLineBytes octets, '\n' non compté (1.2). Jetée sans être analysée ni gardée en mémoire ; réponse à id null. | +| `-32601` | toute | méthode inconnue | conservée | Requête (avec id) d'une méthode que l'optimiseur ne sert pas — dont UpdateLearned, qui ne va que de l'optimiseur au moteur (1.2). Une notification (sans id) : ignorée, sans réponse. | + +Ce que l'OPTIMISEUR répond à un message qu'il ne peut pas traiter, quelle que soit la méthode (1.2). Connexion conservée dans tous les cas. Le moteur n'émet que des objets, un par ligne, des méthodes Handshake et GetPlan : il n'en provoque aucune, sauf la ligne trop longue (écart connu du moteur 1.2 : il ne borne pas encore ce qu'il émet, §2). Reçue par le moteur, une réponse error à id null est une ligne écartée (missing-id), connexion conservée. Toute autre erreur : un code JSON-RPC 2.0 standard. + +Ce que fait le moteur d'une réponse error : + +- **Handshake** : Toute réponse error au Handshake, quel que soit son code : handshake-refused, le moteur ferme, nouvelle tentative au backoff. +- **GetPlan** : Toute réponse error à GetPlan, quel que soit son code : échec de la requête (rpc-error), compté par la règle des échecs consécutifs (§7). + + + **Ce que le validateur refuse en plus de la forme.** Le JSON Schema du contrat dit la forme ; le moteur juge ensuite chaque réponse sur le contexte de SA requête, dans cet ordre, et le premier contrôle qui échoue donne son code (`fallbackCode` vers l'app, `lastErrorCode` pour un refus) : @@ -1179,7 +1278,7 @@ Ce que le moteur refuse EN PLUS de la forme, dans l'ordre de ses contrôles (Opt | `expired` | GetPlan | expired | `expired` | non | request | non | validUntil ≤ timestamp de la requête. | | `no-current-slot` | GetPlan | abstention | `no-current-slot` | non | request | non | aucun créneau ne couvre le timestamp de la requête. | | `incomplete-slot` | GetPlan | invalid | `incomplete-slot` | oui | request | non | une charge available de la requête n'a pas d'action dans le créneau courant (décision Q1). | -| `handshake-refused` | Handshake | connection-refused | `no-plan` | non | — | non | réponse error (token absent ou différent : -32001 recommandé) → connexion fermée, nouvelle tentative au backoff. | +| `handshake-refused` | Handshake | connection-refused | `no-plan` | non | — | non | réponse error, quel que soit son code (token refusé : -32001 ; majeure incompatible : -32602, Handshake.errors) → le moteur ferme, nouvelle tentative au backoff. | | `invalid-handshake` | Handshake | connection-refused | `no-plan` | non | — | oui | result non objet, protocolVersion absent ou vide, capabilities présent et non objet. | | `major-mismatch` | Handshake | connection-refused | `no-plan` | non | — | non | MAJEURE de protocolVersion (ce qui précède le premier point) différente de celle du moteur. | @@ -1194,8 +1293,14 @@ qu'elle affiche de ces trois champs. - L'optimiseur peut renvoyer ses estimations (`learned` + `confidence`) dans une notification `UpdateLearned` (sans id, fire-and-forget) ; le plugin les persiste - et les retransmet dans les contextes suivants. **État 1.1 du moteur** : reçue, journalisée + et les retransmet dans les contextes suivants. **État du moteur** : reçue, journalisée une fois, ignorée — la persistance est un lot ultérieur (`INTERFACE.md`). +- **Sens unique : de l'optimiseur vers le moteur** (1.2). Le moteur n'en envoie jamais. Reçue par + un optimiseur : en notification (sans `id`), ignorée sans réponse ; en requête (avec `id`), + erreur `-32601` (méthode inconnue). +- **La forme d'`UpdateLearned` n'est pas normée en 1.2.** Le tableau ci-dessous dit ce que le + contrat admet, pas ce qu'un moteur en fera : la forme sera fixée avec la première stratégie qui + apprend. - Tant que `confidence < 0.7` (seuil indicatif), l'app peut afficher un badge « profil en apprentissage ». - Les données d'apprentissage restent **locales**. Rien ne quitte le site. @@ -1207,7 +1312,7 @@ qu'elle affiche de ces trois champs. | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| -| `loads` | [object] | non | | Non normé en 1.1 : intention §8 (learned + confidence par charge). | +| `loads` | [object] | non | | Non normé en 1.2 : intention §8 (learned + confidence par charge). | Champs non déclarés : **refusés** (forme émise par le moteur, fermée). @@ -1217,7 +1322,12 @@ Champs non déclarés : **refusés** (forme émise par le moteur, fermée). ## 9. Versionnement -- `protocolVersion` en semver. Même majeure = compatible. +- `protocolVersion` en semver. Même majeure = compatible. **Les deux côtés comparent la + majeure** et elle seule (§3) : le moteur refuse un optimiseur d'une autre majeure, et + l'optimiseur un moteur d'une autre majeure (`-32602`, 1.2). Toute mineure est acceptée. +- Une mineure n'ajoute que des champs optionnels, ou des règles sur des situations que l'autre + côté conforme ne produit pas (1.2 en est un exemple, §0). Elle se lit comme une liste de choses + à aligner avant de s'annoncer de cette mineure, pas comme une rupture. - Champs inconnus dans `params`/`result` : **ignorés silencieusement**. - Changements incompatibles (renommage, sémantique) = majeure + entrée changelog §0. @@ -1226,14 +1336,26 @@ Champs non déclarés : **refusés** (forme émise par le moteur, fermée). ## Annexe A — Session minimale ``` -→ {"jsonrpc":"2.0","id":1,"method":"Handshake","params":{"protocolVersion":"1.1","engine":"etm-powersync-energy-plugin-etm/1.15.2+etm70"}} -← {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.1","name":"mon-optimiseur","planning":false}} +→ {"jsonrpc":"2.0","id":1,"method":"Handshake","params":{"protocolVersion":"1.2","engine":"etm-powersync-energy-plugin-etm/1.15.2+etm71"}} +← {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.2","name":"mon-optimiseur","planning":false}} → {"jsonrpc":"2.0","id":2,"method":"GetPlan","params":{ ...SurplusContext... }} ← {"jsonrpc":"2.0","id":2,"result":{"planId":"x","strategy":"custom","slots":[{ "from":"...","to":"...","actions":[...] }]}} ``` +Ce que l'optimiseur répond à ce que le moteur n'envoie jamais (1.2, §2, §3, §7) — c'est ce +qu'éprouve la suite de conformité, par une connexion de sonde : + +``` +→ {"jsonrpc":"2.0","id":1,"method":"GetPlan","params":{}} (avant le handshake) +← {"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"handshake requis"}} connexion gardée +→ [{"jsonrpc":"2.0","id":2,"method":"UpdateLearned","params":{}}] (un lot) +← {"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"lot interdit"}} connexion gardée +→ {"jsonrpc":"2.0","id":3,"method":"Handshake","params":{"protocolVersion":"2.0","engine":"…"}} +← {"jsonrpc":"2.0","id":3,"error":{"code":-32602,"message":"majeure incompatible"}} puis fermeture +``` + Un optimiseur conforme minimal tient en ~50 lignes de Python : répondre au handshake, -renvoyer un plan à un créneau (ou `abstain`). +renvoyer un plan à un créneau (ou `abstain`), et les erreurs du §7. ## Annexe B — Exemple : brancher Akkudoktor-EOS @@ -1301,7 +1423,7 @@ But : comparer des optimiseurs **sur les mêmes entrées** et rejouer un inciden ## Annexe E — Optimiseur de référence `tools/etm-powersync-ref-optimizer/` : serveur Python minimal (bibliothèque standard seule) -conforme 1.1, `unix:///run/powersync/optimizer.sock` par défaut, `tcp://` accepté ; répondant un +conforme 1.2, `unix:///run/powersync/optimizer.sock` par défaut, `tcp://` accepté ; répondant un plan à un créneau qui reproduit le waterfall rule-based. Usages : test de bout en bout du `SocketScheduler` (suite `etm-powersync-refoptimizer`) ; point de comparaison du banc ; exemple communautaire. Mode d'emploi et modes d'injection de défauts : son `README.md`. @@ -1338,7 +1460,9 @@ Ils deviendront normatifs avec le lot qui les implémente. - **Le contrat** : `docs/etm-powersync-optimizer-protocol.openrpc.json` (OpenRPC 1.3). Méthodes `Handshake`, `GetPlan`, `UpdateLearned` ; forme des messages en JSON Schema (`components.schemas`) ; ce qu'OpenRPC ne sait pas dire en extensions `x-etm-…` : - `x-etm-transport` (unix/tcp, NDJSON, rôles, délais), `x-etm-direction` (sens d'une méthode), + `x-etm-transport` (unix/tcp, NDJSON, rôles, délais, longueur de ligne, lots, répertoire du + socket), `x-etm-direction` (sens d'une méthode), `methods[].errors` et `x-etm-message-errors` + (erreurs de l'optimiseur, 1.2), `x-etm-errors-engine` (ce que le moteur fait d'une erreur), `x-etm-fallback-machine` (§7), `x-etm-semantic-rules` (ce que le validateur refuse en plus, avec `fallbackCode`), `x-etm-tolerances` (ce que le moteur accepte au-delà), `x-etm-reason-params`, `x-etm-generated` (listes fermées régénérées depuis le code). Forme côté moteur (requêtes) @@ -1349,5 +1473,6 @@ Ils deviendront normatifs avec le lot qui les implémente. une session NDJSON (les deux sens) ou des traces (annexe D) ; `--semantic` applique les règles. - **Conformité d'un optimiseur quelconque** : suite `etm-powersync-optimizer-conformance` (`tests/auto/conformance/README.md`) — le vrai client du moteur branché sur l'optimiseur que - lance `ETM_POWERSYNC_OPTIMIZER_CMD`. + lance `ETM_POWERSYNC_OPTIMIZER_CMD`. Depuis 1.2, elle éprouve aussi, par une connexion de sonde, + les erreurs du §7 que le moteur ne provoque pas (annexe A). - **Prose générée** : `tools/etm-powersync-gen-protocol-doc` (`--check` dans `tools/ci-quality.sh`). diff --git a/docs/etm-powersync-optimizer-protocol.openrpc.json b/docs/etm-powersync-optimizer-protocol.openrpc.json index 801705b..b387e36 100644 --- a/docs/etm-powersync-optimizer-protocol.openrpc.json +++ b/docs/etm-powersync-optimizer-protocol.openrpc.json @@ -2,18 +2,18 @@ "openrpc": "1.3.2", "info": { "title": "etm-powersync — protocole moteur ↔ optimiseur", - "version": "1.1", + "version": "1.2", "license": { "name": "GPL-3.0-or-later", "url": "https://www.gnu.org/licenses/gpl-3.0.html" }, - "description": "Contrat MACHINE du protocole entre le moteur d'énergie etm-powersync-energy-plugin-etm (client) et un optimiseur externe (serveur) — docs/OPTIMIZER_PROTOCOL.md en est la prose. Ce document est la source de la forme des messages ; il est servi par le moteur qui tourne (NymeaEnergy.GetOptimizerProtocol) et les deux côtés sont testés contre lui.\n\nCe qu'OpenRPC ne dit pas, et où cela se trouve :\n- TRANSPORT : unix:// (défaut, socket local 0660) ou tcp:// (LAN), JSON-RPC 2.0, un message par ligne (NDJSON), UTF-8 — x-etm-transport ;\n- SENS : le moteur est le CLIENT, il se connecte et envoie Handshake puis GetPlan ; la box n'ouvre aucun port. UpdateLearned va dans l'autre sens (x-etm-direction) ;\n- HEARTBEAT IMPLICITE et machine de repli §7 — x-etm-fallback-machine ;\n- VALIDATION SÉMANTIQUE du plan : le JSON Schema dit la forme ; ce que le validateur du moteur refuse en plus (charge inconnue, créneau courant incomplet…) est listé, règle par règle, dans x-etm-semantic-rules avec son fallbackCode ;\n- listes fermées : régénérées DEPUIS LE CODE (x-etm-generated), jamais recopiées à la main.\n\nForme côté moteur (requêtes) : STRICTE — additionalProperties: false ; un champ ajouté au sérialiseur sans le schéma fait échouer les tests. Forme côté optimiseur (réponses) : ouverte — un champ inconnu est ignoré (§9) ; le schéma dit ce qu'un optimiseur CONFORME envoie, x-etm-tolerances ce que le moteur accepte en plus." + "description": "Contrat MACHINE du protocole entre le moteur d'énergie etm-powersync-energy-plugin-etm (client) et un optimiseur externe (serveur) — docs/OPTIMIZER_PROTOCOL.md en est la prose. Ce document est la source de la forme des messages ; il est servi par le moteur qui tourne (NymeaEnergy.GetOptimizerProtocol) et les deux côtés sont testés contre lui.\n\nCe qu'OpenRPC ne dit pas, et où cela se trouve :\n- TRANSPORT : unix:// (défaut, socket local 0660) ou tcp:// (LAN), JSON-RPC 2.0, un message par ligne (NDJSON), UTF-8 — x-etm-transport ;\n- SENS : le moteur est le CLIENT, il se connecte et envoie Handshake puis GetPlan ; la box n'ouvre aucun port. UpdateLearned va dans l'autre sens (x-etm-direction) ;\n- ERREURS de l'optimiseur (1.2) : par méthode dans methods[].errors, et pour un message qu'il ne peut pas traiter (ligne illisible, lot, ligne trop longue, méthode inconnue) dans x-etm-message-errors ;\n- HEARTBEAT IMPLICITE et machine de repli §7 — x-etm-fallback-machine ;\n- VALIDATION SÉMANTIQUE du plan : le JSON Schema dit la forme ; ce que le validateur du moteur refuse en plus (charge inconnue, créneau courant incomplet…) est listé, règle par règle, dans x-etm-semantic-rules avec son fallbackCode ;\n- listes fermées : régénérées DEPUIS LE CODE (x-etm-generated), jamais recopiées à la main.\n\nForme côté moteur (requêtes) : STRICTE — additionalProperties: false ; un champ ajouté au sérialiseur sans le schéma fait échouer les tests. Forme côté optimiseur (réponses) : ouverte — un champ inconnu est ignoré (§9) ; le schéma dit ce qu'un optimiseur CONFORME envoie, x-etm-tolerances ce que le moteur accepte en plus." }, "servers": [ { "name": "unix", "url": "unix:///run/powersync/optimizer.sock", - "summary": "Défaut : optimiseur local, socket possédé par root:powersync, mode 0660." + "summary": "Défaut : optimiseur local, socket possédé par root:powersync, mode 0660. Le répertoire /run/powersync appartient au paquet de l'optimiseur (x-etm-transport.runtimeDirectory)." }, { "name": "tcp", @@ -33,7 +33,7 @@ { "name": "Handshake", "summary": "Ouverture de session, à chaque (re)connexion (§3).", - "description": "Envoyé par le moteur (client) dès la connexion. Délai : 5 s depuis le DÉBUT de la tentative ; au-delà, repli et nouvelle tentative au backoff. Le message porte le secret : il n'est jamais journalisé.", + "description": "Envoyé par le moteur (client) dès la connexion. Délai : 5 s depuis le DÉBUT de la tentative ; au-delà, repli et nouvelle tentative au backoff. Le message porte le secret : il n'est jamais journalisé. Refus (1.2) : token absent ou différent → -32001 ; majeure incompatible → -32602 ; l'optimiseur ferme après avoir envoyé l'erreur, le moteur à sa réception. Un token envoyé alors que l'optimiseur n'en exige pas est accepté, sans erreur.", "paramStructure": "by-name", "params": [ { @@ -42,7 +42,7 @@ "schema": { "$ref": "#/components/schemas/ProtocolVersion" }, - "description": "Version du moteur ; seule la majeure est comparée." + "description": "Version du moteur ; seule la majeure est comparée, des deux côtés (une autre majeure : l'optimiseur répond -32602 et ferme)." }, { "name": "engine", @@ -71,11 +71,19 @@ { "code": -32001, "message": "token refusé", - "data": "Recommandé (optimiseur de référence). Toute réponse error est traitée pareil : handshake-refused, connexion fermée." + "data": "Token configuré côté optimiseur, et absent ou différent dans le Handshake. Un token envoyé alors que l'optimiseur n'en exige pas est ACCEPTÉ.", + "x-etm-connection": "fermée" + }, + { + "code": -32602, + "message": "majeure incompatible", + "data": "Majeure de params.protocolVersion différente de celle de l'optimiseur (1.2).", + "x-etm-connection": "fermée" } ], "x-etm-direction": "engine→optimizer", - "x-etm-timeout-s": 5 + "x-etm-timeout-s": 5, + "x-etm-errors-engine": "Toute réponse error au Handshake, quel que soit son code : handshake-refused, le moteur ferme, nouvelle tentative au backoff." }, { "name": "GetPlan", @@ -176,19 +184,33 @@ } }, "errors": [ + { + "code": -32600, + "message": "handshake requis", + "data": "GetPlan reçu avant un Handshake réussi sur cette connexion (1.2). Le moteur n'en envoie jamais : il attend d'être prêt.", + "x-etm-connection": "conservée" + }, + { + "code": -32602, + "message": "contexte invalide", + "data": "Contexte GetPlan non conforme au contrat (1.2). error.data liste les problèmes : pour chacun, le chemin du champ fautif et la nature du problème, JAMAIS la valeur reçue. Une erreur et non une abstention : un contexte invalide est un défaut du moteur, une abstention le cacherait.", + "x-etm-connection": "conservée" + }, { "code": -32603, "message": "erreur interne", - "data": "Toute réponse error compte comme un échec de la requête (rpc-error, §7) ; le code n'est pas lu." + "data": "Code JSON-RPC 2.0 standard pour toute autre panne de l'optimiseur.", + "x-etm-connection": "conservée" } ], "x-etm-direction": "engine→optimizer", - "x-etm-timeout-s": 10 + "x-etm-timeout-s": 10, + "x-etm-errors-engine": "Toute réponse error à GetPlan, quel que soit son code : échec de la requête (rpc-error), compté par la règle des échecs consécutifs (§7)." }, { "name": "UpdateLearned", "summary": "Estimations apprises de l'optimiseur (§8) — notification, sans id.", - "description": "Envoyée par l'OPTIMISEUR (sens inverse des deux autres), sans id, sans réponse. État 1.1 du moteur : reçue, journalisée une fois, IGNORÉE — rien n'est persisté, le contenu n'est pas lu. Forme non normée en 1.1.", + "description": "Envoyée par l'OPTIMISEUR, et seulement par lui (1.2) : sens inverse des deux autres, sans id, sans réponse. Reçue PAR UN OPTIMISEUR : notification ignorée ; requête avec id → -32601 (x-etm-message-errors). État du moteur : reçue, journalisée une fois, IGNORÉE — rien n'est persisté, le contenu n'est pas lu. Forme NON NORMÉE en 1.2 : elle sera fixée avec la première stratégie qui apprend.", "paramStructure": "by-name", "params": [ { @@ -201,7 +223,7 @@ "additionalProperties": true } }, - "description": "Non normé en 1.1 : intention §8 (learned + confidence par charge)." + "description": "Non normé en 1.2 : intention §8 (learned + confidence par charge)." } ], "x-etm-direction": "optimizer→engine", @@ -212,7 +234,7 @@ "schemas": { "ProtocolVersion": { "description": "Version du protocole parlée par le moteur (GEN : OptimizerProtocol::ProtocolVersion).", - "const": "1.1" + "const": "1.2" }, "Trigger": { "description": "Cause de la requête (§5.1). GEN : SocketScheduler::knownTriggers() — du moins au plus significatif.", @@ -1799,6 +1821,8 @@ "server": "optimizer" }, "maxLineBytes": 1048576, + "maxLineBytesScope": "les deux sens (1.2) : octets de la ligne, '\\n' non compté. Reçue trop longue : côté optimiseur -32600 (id null), connexion conservée ; côté moteur, ligne écartée (line-too-long), connexion conservée.", + "batches": "interdits dans les deux sens (1.2) : un message JSON-RPC, un objet, par ligne. Un lot reçu : côté optimiseur -32600 (id null), aucun de ses messages traité ; côté moteur, ligne écartée (invalid-json), connexion conservée.", "handshakeTimeoutS": 5, "requestTimeoutS": 10, "backoffS": { @@ -1808,8 +1832,38 @@ }, "requestsInFlight": 1, "ids": "entiers croissants sur toute la vie du client, à travers les reconnexions", - "lineErrors": "ligne non JSON, sans id, id jamais émis, ligne > maxLineBytes : comptée, connexion CONSERVÉE", - "tokenMasking": "toute occurrence du token dans un message reçu est remplacée par « [token masqué] » dès l'analyse" + "lineErrors": "ligne non JSON, sans id, id jamais émis, ligne > maxLineBytes : comptée, connexion CONSERVÉE (côté moteur ; côté optimiseur : x-etm-message-errors)", + "tokenMasking": "toute occurrence du token dans un message reçu est remplacée par « [token masqué] » dès l'analyse", + "runtimeDirectory": "/run/powersync appartient au paquet de l'OPTIMISEUR (RuntimeDirectory=powersync de son unité, groupe powersync) (1.2). Le moteur ne le crée ni ne le suppose : sans optimiseur, il n'en a pas besoin." + }, + "x-etm-message-errors": { + "description": "Ce que l'OPTIMISEUR répond à un message qu'il ne peut pas traiter, quelle que soit la méthode (1.2). Connexion conservée dans tous les cas. Le moteur n'émet que des objets, un par ligne, des méthodes Handshake et GetPlan : il n'en provoque aucune, sauf la ligne trop longue (écart connu du moteur 1.2 : il ne borne pas encore ce qu'il émet, §2). Reçue par le moteur, une réponse error à id null est une ligne écartée (missing-id), connexion conservée. Toute autre erreur : un code JSON-RPC 2.0 standard.", + "items": [ + { + "code": -32700, + "message": "erreur d'analyse", + "data": "Ligne qui n'est pas du JSON (ou pas de l'UTF-8). Réponse à id null. Code standard JSON-RPC 2.0.", + "x-etm-connection": "conservée" + }, + { + "code": -32600, + "message": "lot interdit", + "data": "Ligne qui est un lot JSON-RPC ([…]) (1.2). Une seule réponse, à id null ; aucun message du lot n'est traité.", + "x-etm-connection": "conservée" + }, + { + "code": -32600, + "message": "ligne trop longue", + "data": "Ligne de plus de maxLineBytes octets, '\\n' non compté (1.2). Jetée sans être analysée ni gardée en mémoire ; réponse à id null.", + "x-etm-connection": "conservée" + }, + { + "code": -32601, + "message": "méthode inconnue", + "data": "Requête (avec id) d'une méthode que l'optimiseur ne sert pas — dont UpdateLearned, qui ne va que de l'optimiseur au moteur (1.2). Une notification (sans id) : ignorée, sans réponse.", + "x-etm-connection": "conservée" + } + ] }, "x-etm-fallback-machine": { "description": "Machine de repli §7 (OptimizerStatus) : heartbeat implicite — une réponse valide à GetPlan = optimiseur vivant. Les échecs se comptent PAR REQUÊTE.", @@ -2050,7 +2104,7 @@ "countsAsFailure": false, "needs": [], "schemaAlsoCatches": false, - "description": "réponse error (token absent ou différent : -32001 recommandé) → connexion fermée, nouvelle tentative au backoff." + "description": "réponse error, quel que soit son code (token refusé : -32001 ; majeure incompatible : -32602, Handshake.errors) → le moteur ferme, nouvelle tentative au backoff." }, { "name": "invalid-handshake", diff --git a/energyplugin/etm/scheduler/optimizerclient.h b/energyplugin/etm/scheduler/optimizerclient.h index 3c81ab0..5c1c890 100644 --- a/energyplugin/etm/scheduler/optimizerclient.h +++ b/energyplugin/etm/scheduler/optimizerclient.h @@ -144,7 +144,7 @@ public: Q_ENUM(State) /*! \brief Version du protocole annoncée au handshake. Seule la majeure est comparée. */ - static constexpr const char *kProtocolVersion = "1.1"; + static constexpr const char *kProtocolVersion = "1.2"; static constexpr qint64 kHandshakeTimeoutMs = 5000; //!< §3, depuis le début de la tentative. static constexpr qint64 kRequestTimeoutMs = 10000; //!< §4, par requête \c GetPlan. static constexpr qint64 kBackoffInitialMs = 1000; //!< §2, premier délai de reconnexion. diff --git a/energyplugin/etm/scheduler/optimizerprotocol.h b/energyplugin/etm/scheduler/optimizerprotocol.h index 4c43dc2..2ddfff1 100644 --- a/energyplugin/etm/scheduler/optimizerprotocol.h +++ b/energyplugin/etm/scheduler/optimizerprotocol.h @@ -28,7 +28,7 @@ namespace OptimizerProtocol { //! Version du protocole parlée par ce sérialiseur (§3, §9). -constexpr const char *ProtocolVersion = "1.1"; +constexpr const char *ProtocolVersion = "1.2"; //! Ressource Qt du contrat OpenRPC embarqué (\c docs/etm-powersync-optimizer-protocol.openrpc.json). constexpr const char *SchemaResource = ":/etm-powersync/optimizer-protocol.openrpc.json"; diff --git a/tests/auto/optimizerclient/testoptimizerclient.cpp b/tests/auto/optimizerclient/testoptimizerclient.cpp index 8d7fd80..b869b6a 100644 --- a/tests/auto/optimizerclient/testoptimizerclient.cpp +++ b/tests/auto/optimizerclient/testoptimizerclient.cpp @@ -6,6 +6,7 @@ // des sockets, jamais à laisser s'écouler un délai du client. #include "etm/scheduler/optimizerclient.h" +#include "etm/scheduler/optimizerprotocol.h" #include "../common/etm-powersync-fakeoptimizer.h" #include "../common/etm-powersync-contrat.h" @@ -285,7 +286,10 @@ void TestOptimizerClient::testHandshakeAndPlan() QCOMPARE(hs.value("method").toString(), QStringLiteral("Handshake")); QCOMPARE(hs.value("id").toInteger(), 1); const QJsonObject params = hs.value("params").toObject(); - QCOMPARE(params.value("protocolVersion").toString(), QStringLiteral("1.1")); + // Protocole 1.2 : la version ANNONCÉE au handshake est celle que le moteur sert + // (GetOptimizerProtocol) et celle du contrat — deux constantes, une seule valeur. + QCOMPARE(params.value("protocolVersion").toString(), QStringLiteral("1.2")); + QCOMPARE(QString::fromLatin1(OptimizerClient::kProtocolVersion), QString::fromLatin1(OptimizerProtocol::ProtocolVersion)); QCOMPARE(params.value("engine").toString(), QStringLiteral("etm-powersync-energy-plugin-etm/9.9")); QVERIFY(!params.contains("token")); // Seule l'échéance du handshake est armée, à 5 s depuis le début de la tentative. @@ -348,7 +352,7 @@ void TestOptimizerClient::testWrongMajorIsRefused() QCOMPARE(readySpy.count(), 0); QCOMPARE(client.requestPlan({}), quint64(0)); QVERIFY(waitFor([&] { return server.disconnections == 1; })); - QCOMPARE(countLog("optimiseur en 2.0, moteur en 1.1 — majeures différentes, refusé"), 1); + QCOMPARE(countLog("optimiseur en 2.0, moteur en 1.2 — majeures différentes, refusé"), 1); // Nouvelle tentative au backoff (§3), pas avant. clock.advance(999); diff --git a/tests/auto/socketscheduler/testsocketscheduler.cpp b/tests/auto/socketscheduler/testsocketscheduler.cpp index d9a08f6..e44bb24 100644 --- a/tests/auto/socketscheduler/testsocketscheduler.cpp +++ b/tests/auto/socketscheduler/testsocketscheduler.cpp @@ -2166,7 +2166,7 @@ void TestSocketScheduler::testTheEngineSpeaksTheContractItServes() cycle(t0.addSecs(60 * i), -3000); const QVariantMap r = rpc("GetOptimizerProtocol"); - QCOMPARE(r.value("protocolVersion").toString(), QString("1.1")); + QCOMPARE(r.value("protocolVersion").toString(), QString("1.2")); const QJsonObject servi = QJsonObject::fromVariantMap(r.value("schema").toMap()); const QJsonObject embarque = QJsonDocument::fromJson(OptimizerProtocol::schemaDocumentBytes()).object(); QVERIFY(!embarque.isEmpty()); diff --git a/tests/tools/etm-powersync-gen-protocol-doc-test.py b/tests/tools/etm-powersync-gen-protocol-doc-test.py index 28b0b4b..b1814ee 100644 --- a/tests/tools/etm-powersync-gen-protocol-doc-test.py +++ b/tests/tools/etm-powersync-gen-protocol-doc-test.py @@ -106,6 +106,22 @@ class TestGenProtocolDoc(unittest.TestCase): self.assertEqual(code, 2, sortie) self.assertIn('orpheline', sortie) + def test_le_tableau_des_erreurs_suit_le_contrat(self): + # Protocole 1.2 : methods[].errors puis x-etm-message-errors, et ce que fait le moteur. + p = self.lire(self.prose) + self.assertIn('| `-32602` | Handshake | majeure incompatible | fermée |', p) + self.assertIn('| `-32600` | GetPlan | handshake requis | conservée |', p) + self.assertIn('| `-32600` | toute | lot interdit | conservée |', p) + self.assertIn('- **GetPlan** : Toute réponse error à GetPlan', p) + doc = json.loads(self.lire(self.schema)) + doc['x-etm-message-errors']['items'].append({'code': -32099, 'message': 'erreur ajoutée', + 'data': 'contre-épreuve', 'x-etm-connection': 'conservée'}) + self.ecrire(self.schema, json.dumps(doc, ensure_ascii=False, indent=2) + '\n') + code, sortie = lancer(*self.args('--check')) + self.assertEqual(code, 1, sortie) + self.assertEqual(lancer(*self.args())[0], 0) + self.assertIn('| `-32099` | toute | erreur ajoutée | conservée | contre-épreuve |', self.lire(self.prose)) + def test_des_balises_cassees_sont_une_erreur(self): p = self.lire(self.prose) i = p.index('') diff --git a/tests/tools/etm-powersync-schema-check-test.py b/tests/tools/etm-powersync-schema-check-test.py index 7e661b4..7e2994b 100644 --- a/tests/tools/etm-powersync-schema-check-test.py +++ b/tests/tools/etm-powersync-schema-check-test.py @@ -100,7 +100,7 @@ def session(mode, dossier): with open(REQUETE, encoding='utf-8') as f: params = json.load(f) s.sendall(json.dumps({'jsonrpc': '2.0', 'id': 1, 'method': 'Handshake', - 'params': {'protocolVersion': '1.1', 'engine': 'etm-powersync-energy-plugin-etm/test', + 'params': {'protocolVersion': '1.2', 'engine': 'etm-powersync-energy-plugin-etm/test', 'token': TOKEN}}).encode() + b'\n') hs = lire_ligne(s, tampon) if hs and str(hs.get('result', {}).get('protocolVersion', '')).startswith('1'):