protocole optimiseur 1.2 : les règles Q2 à Q9, et le moteur annonce 1.2

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 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-21 14:01:46 +02:00
parent 7bb2b4a194
commit 8f24709874
8 changed files with 241 additions and 42 deletions

View File

@ -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://<host>:<port>` — 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/<version>",
"token":"<secret partagé, optionnel — requis si configuré côté plugin>"
}}
@ -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 ».
<!-- BEGIN GENERATED table:Handshake.params -->
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
@ -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).
<!-- BEGIN GENERATED errors -->
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
| 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).
<!-- END GENERATED -->
**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`).

View File

@ -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",

View File

@ -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.

View File

@ -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";

View File

@ -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);

View File

@ -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());

View File

@ -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('<!-- END GENERATED -->')

View File

@ -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'):