optimiseur de référence : protocole 1.2 — codes d'erreur normés, lots, lignes trop longues

Il s'annonce 1.2 et s'aligne sur les règles écrites par ce lot :

- Q3 : GetPlan avant un handshake réussi → -32600 (était -32002, hors norme),
  connexion gardée ; Handshake d'une autre majeure → -32602 puis fermeture
  (il ne comparait pas la majeure du moteur) ; token refusé → -32001, inchangé.
- Q2 : un timestamp illisible ou sans décalage n'est plus une abstention mais
  -32602, error.data.problems = [{path, problem}], jamais la valeur reçue.
- Q7 : une ligne de plus de 1 Mio ('\n' non compté) reçoit -32600 à id null.
  Correctif au passage : readline(LIGNE_MAX + 2) rendait le reste d'une
  ligne trop longue comme des lignes suivantes, et comptait le '\n' (une
  ligne d'exactement 1 Mio était refusée). La ligne est désormais lue jusqu'à
  sa fin par morceaux, sans être gardée.
- Q8 : un lot reçoit -32600 à id null, aucun de ses messages n'est traité
  (il était ignoré en silence).
- -32700 à id null pour une ligne non JSON (ignorée en silence avant).
- Q5 et Q6 étaient déjà tenus : token accepté sans --token-file,
  UpdateLearned en notification ignorée, avec id → -32601.

README : tableau des erreurs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-21 14:01:25 +02:00
parent 952f64d24b
commit 4cbbb81802
2 changed files with 102 additions and 20 deletions

View File

@ -1,7 +1,7 @@
# `etm-powersync-ref-optimizer` — optimiseur de référence # `etm-powersync-ref-optimizer` — optimiseur de référence
Python 3, bibliothèque standard seule, GPL-3.0 comme le dépôt. Protocole : `docs/OPTIMIZER_PROTOCOL.md` Python 3, bibliothèque standard seule, GPL-3.0 comme le dépôt. Protocole : `docs/OPTIMIZER_PROTOCOL.md`
1.1 (annexe E). Il répond un plan à **un créneau** qui reproduit le waterfall du rule-based (passe 1.2 (annexe E). Il répond un plan à **un créneau** qui reproduit le waterfall du rule-based (passe
confort : même ordre, même budget, mêmes écrêtages) en ne lisant **que** le contexte `GetPlan` — aucun confort : même ordre, même budget, mêmes écrêtages) en ne lisant **que** le contexte `GetPlan` — aucun
accès réseau sortant, aucun fichier hors le token. Usages : test de bout en bout du moteur, point de accès réseau sortant, aucun fichier hors le token. Usages : test de bout en bout du moteur, point de
comparaison d'un banc, exemple pour qui écrit son propre optimiseur. comparaison d'un banc, exemple pour qui écrit son propre optimiseur.
@ -29,6 +29,24 @@ Il écrit `ÉCOUTE <endpoint>` sur la sortie standard quand il écoute (le port
et son journal sur la sortie d'erreur. Plusieurs clients à la fois (un fil par connexion, aucun état et son journal sur la sortie d'erreur. Plusieurs clients à la fois (un fil par connexion, aucun état
partagé) : le moteur et un rejeu peuvent lui parler ensemble. SIGTERM l'arrête proprement. partagé) : le moteur et un rejeu peuvent lui parler ensemble. SIGTERM l'arrête proprement.
## Erreurs (protocole 1.2)
Codes JSON-RPC 2.0, plus `-32001` (protocole §7) :
| Reçu | Réponse | Connexion |
|---|---|---|
| `Handshake` sans le token attendu, ou avec un autre | `-32001` | fermée |
| `Handshake` d'une autre majeure | `-32602` | fermée |
| `GetPlan` avant un handshake réussi | `-32600` | gardée |
| `GetPlan` dont le `timestamp` est absent, illisible ou sans décalage | `-32602`, `error.data.problems` = `[{path, problem}]`, jamais la valeur | gardée |
| ligne non JSON | `-32700`, `id: null` | gardée |
| lot `[…]` | `-32600`, `id: null`, aucun message du lot traité | gardée |
| ligne de plus de 1 Mio (`\n` non compté) | `-32600`, `id: null`, jetée sans être gardée en mémoire | gardée |
| requête d'une autre méthode, dont `UpdateLearned` avec `id` | `-32601` | gardée |
Sans `--token-file`, un token reçu au handshake est accepté sans être lu. Une notification (sans
`id`) est ignorée.
## Pointer le moteur dessus ## Pointer le moteur dessus
```sh ```sh

View File

@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync # Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
"""Optimiseur de référence (OPTIMIZER_PROTOCOL.md 1.1, annexe E). """Optimiseur de référence (OPTIMIZER_PROTOCOL.md 1.2, annexe E).
etm-powersync-ref-optimizer [--listen unix:///run/powersync/optimizer.sock | tcp://hôte:port] etm-powersync-ref-optimizer [--listen unix:///run/powersync/optimizer.sock | tcp://hôte:port]
[--token-file FICHIER] [--mode MODE | --mode-file FICHIER] [--token-file FICHIER] [--mode MODE | --mode-file FICHIER]
@ -52,7 +52,20 @@ même optimiseur, et une connexion morte n'en bloque pas une neuve.
Le token se lit dans un FICHIER (--token-file), relu à chaque handshake, jamais en argument (il Le token se lit dans un FICHIER (--token-file), relu à chaque handshake, jamais en argument (il
se verrait dans `ps`) et jamais affiché. S'il est configuré, un handshake sans lui, ou avec un se verrait dans `ps`) et jamais affiché. S'il est configuré, un handshake sans lui, ou avec un
autre, reçoit une erreur JSON-RPC -32001 puis la connexion est fermée. autre, reçoit une erreur JSON-RPC -32001 puis la connexion est fermée. Sans --token-file, un
token reçu est accepté sans être lu.
Erreurs (protocole 1.2, §2, §3, §7 ; codes JSON-RPC 2.0, plus -32001) :
Handshake d'une autre majeure -32602, puis fermeture (comme -32001 : les deux côtés ferment)
GetPlan avant un handshake réussi -32600, connexion gardée
contexte GetPlan invalide -32602, `error.data.problems` = [{path, problem}], jamais la
valeur reçue — une erreur, pas une abstention
ligne non JSON -32700, id null
lot JSON-RPC (`[…]`) -32600, id null, aucun message du lot traité
ligne de plus de 1 Mio -32600, id null, jetée sans être gardée en mémoire
requête d'une méthode non servie -32601 (dont UpdateLearned, qui ne va que vers le moteur) ;
une notification (sans id) est ignorée
Hors handshake refusé, la connexion est toujours gardée.
Au handshake, il s'annonce `powersync-optimizer` (exemple de référence GPL) ; `etm-optimizer` est Au handshake, il s'annonce `powersync-optimizer` (exemple de référence GPL) ; `etm-optimizer` est
l'optimiseur ETM, propriétaire, hors de ce dépôt. l'optimiseur ETM, propriétaire, hors de ce dépôt.
@ -75,13 +88,19 @@ import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
ENDPOINT_DEFAUT = 'unix:///run/powersync/optimizer.sock' ENDPOINT_DEFAUT = 'unix:///run/powersync/optimizer.sock'
VERSION = '1.1' VERSION = '1.2'
MAJEURE = VERSION.split('.')[0]
NOM = 'etm-powersync-ref-optimizer' NOM = 'etm-powersync-ref-optimizer'
# Nom annoncé au handshake : `powersync-optimizer`, l'exemple GPL de référence. `etm-optimizer` # Nom annoncé au handshake : `powersync-optimizer`, l'exemple GPL de référence. `etm-optimizer`
# est le nom de l'optimiseur ETM, propriétaire, hors de ce dépôt. # est le nom de l'optimiseur ETM, propriétaire, hors de ce dépôt.
NOM_ANNONCE = 'powersync-optimizer' NOM_ANNONCE = 'powersync-optimizer'
LIGNE_MAX = 1024 * 1024 # E-T2-1 : une ligne de plus de 1 Mio est écartée LIGNE_MAX = 1024 * 1024 # E-T2-1, §2 : 1 Mio par ligne, '\n' non compté, dans les deux sens
CODE_TOKEN_REFUSE = -32001 # E-T2-6 CODE_TOKEN_REFUSE = -32001 # E-T2-6, §3
CODE_ANALYSE = -32700 # JSON-RPC 2.0 ; codes 1.2 : OPTIMIZER_PROTOCOL.md §7
CODE_REQUETE_INVALIDE = -32600
CODE_METHODE_INCONNUE = -32601
CODE_PARAMS_INVALIDES = -32602
TROP_LONGUE = object() # lire_ligne() : ligne de plus de LIGNE_MAX octets, déjà jetée
MODES = ['normal', 'abstain', 'slow', 'invalid', 'expired', 'wrong-major', 'drop', 'unknown-load', MODES = ['normal', 'abstain', 'slow', 'invalid', 'expired', 'wrong-major', 'drop', 'unknown-load',
'forbidden-kind', 'missing-load', 'echo-token', 'protocol-1.0', 'deviate', 'wrong-type'] 'forbidden-kind', 'missing-load', 'echo-token', 'protocol-1.0', 'deviate', 'wrong-type']
@ -308,12 +327,18 @@ def iso(t):
return t.isoformat(timespec='seconds') return t.isoformat(timespec='seconds')
def problemes_contexte(ctx):
"""Ce que cet optimiseur ne peut pas lire dans un contexte GetPlan : [{path, problem}], jamais
la valeur reçue (§7, 1.2). Seul `timestamp` est exigé — tout le reste se lit avec un défaut."""
if instant(ctx.get('timestamp')) is None:
return [{'path': '/timestamp', 'problem': 'absent, illisible ou sans décalage horaire'}]
return []
def plan(ctx, mode, horizon_s, token): def plan(ctx, mode, horizon_s, token):
"""La réponse à un GetPlan (`result`), ou None pour « ne pas répondre » (drop).""" """La réponse à un GetPlan (`result`) ; le contexte a passé problemes_contexte()."""
t0 = time.monotonic() t0 = time.monotonic()
maintenant = instant(ctx.get('timestamp')) maintenant = instant(ctx.get('timestamp'))
if maintenant is None:
return {'abstain': True, 'reason': 'timestamp illisible ou sans décalage'}
if mode == 'abstain': if mode == 'abstain':
return {'abstain': True, 'reason': 'mode abstain (test)'} return {'abstain': True, 'reason': 'mode abstain (test)'}
actions = waterfall(ctx, inverse=(mode == 'deviate')) actions = waterfall(ctx, inverse=(mode == 'deviate'))
@ -412,28 +437,60 @@ class Connexion(socketserver.StreamRequestHandler):
self.wfile.write(ligne) self.wfile.write(ligne)
self.wfile.flush() self.wfile.flush()
def erreur(self, ident, code, message, data=None):
e = {'code': code, 'message': message}
if data is not None:
e['data'] = data
self.envoyer({'jsonrpc': '2.0', 'id': ident, 'error': e})
def lire_ligne(self):
"""Une ligne sans son '\n' ; None en fin de flux ; TROP_LONGUE pour une ligne de plus de
LIGNE_MAX octets, lue jusqu'à sa fin par morceaux, sans jamais être gardée entière."""
morceau = self.rfile.readline(LIGNE_MAX + 1)
if not morceau:
return None
if morceau.endswith(b'\n'):
return morceau[:-1]
if len(morceau) <= LIGNE_MAX:
return morceau # dernière ligne, sans '\n', avant la fin du flux
while True:
morceau = self.rfile.readline(LIGNE_MAX + 1)
if not morceau or morceau.endswith(b'\n'):
return TROP_LONGUE
def handle(self): def handle(self):
cfg = self.server.cfg cfg = self.server.cfg
pret = False pret = False
token = None token = None
while True: while True:
try: try:
ligne = self.rfile.readline(LIGNE_MAX + 2) ligne = self.lire_ligne()
except (OSError, ValueError): except (OSError, ValueError):
return return
if not ligne: if ligne is None:
return return
if len(ligne) > LIGNE_MAX: if ligne is TROP_LONGUE:
journal('ligne trop longue écartée') journal('ligne trop longue écartée')
self.erreur(None, CODE_REQUETE_INVALIDE, 'ligne trop longue')
continue
if not ligne.strip():
continue continue
try: try:
message = json.loads(ligne) message = json.loads(ligne)
except ValueError: except ValueError:
journal('ligne non JSON écartée') journal('ligne non JSON écartée')
self.erreur(None, CODE_ANALYSE, "erreur d'analyse")
continue continue
capturer(cfg, message) capturer(cfg, message)
if not isinstance(message, dict) or 'method' not in message: if isinstance(message, list):
journal('lot JSON-RPC écarté : un message par ligne')
self.erreur(None, CODE_REQUETE_INVALIDE, 'lot interdit')
continue continue
if not isinstance(message, dict):
self.erreur(None, CODE_REQUETE_INVALIDE, 'requête invalide')
continue
if 'method' not in message:
continue # une réponse : le moteur n'en envoie pas, rien à répondre
ident, methode = message.get('id'), message.get('method') ident, methode = message.get('id'), message.get('method')
params = message.get('params') if isinstance(message.get('params'), dict) else {} params = message.get('params') if isinstance(message.get('params'), dict) else {}
if ident is None: if ident is None:
@ -444,8 +501,12 @@ class Connexion(socketserver.StreamRequestHandler):
if attendu is not None and params.get('token') != attendu: if attendu is not None and params.get('token') != attendu:
# Le token reçu n'est jamais recopié ni journalisé. # Le token reçu n'est jamais recopié ni journalisé.
journal('handshake refusé : token absent ou différent') journal('handshake refusé : token absent ou différent')
self.envoyer({'jsonrpc': '2.0', 'id': ident, self.erreur(ident, CODE_TOKEN_REFUSE, 'token refusé')
'error': {'code': CODE_TOKEN_REFUSE, 'message': 'token refusé'}}) return
version = params.get('protocolVersion')
if not isinstance(version, str) or version.split('.')[0] != MAJEURE:
journal(f'handshake refusé : majeure incompatible ({str(version)[:32]})')
self.erreur(ident, CODE_PARAMS_INVALIDES, 'majeure incompatible')
return return
token = attendu token = attendu
pret = True pret = True
@ -461,8 +522,12 @@ class Connexion(socketserver.StreamRequestHandler):
journal(f'handshake accepté ({str(params.get("engine", "?"))[:80]}), mode {mode}') journal(f'handshake accepté ({str(params.get("engine", "?"))[:80]}), mode {mode}')
elif methode == 'GetPlan': elif methode == 'GetPlan':
if not pret: if not pret:
self.envoyer({'jsonrpc': '2.0', 'id': ident, self.erreur(ident, CODE_REQUETE_INVALIDE, 'handshake requis')
'error': {'code': -32002, 'message': 'handshake attendu'}}) continue
problemes = problemes_contexte(params)
if problemes:
journal(f'contexte invalide : {", ".join(p["path"] for p in problemes)}')
self.erreur(ident, CODE_PARAMS_INVALIDES, 'contexte invalide', {'problems': problemes})
continue continue
if mode == 'drop': if mode == 'drop':
journal('mode drop : connexion fermée sans réponse') journal('mode drop : connexion fermée sans réponse')
@ -472,8 +537,7 @@ class Connexion(socketserver.StreamRequestHandler):
self.envoyer({'jsonrpc': '2.0', 'id': ident, self.envoyer({'jsonrpc': '2.0', 'id': ident,
'result': plan(params, mode, cfg.horizon_s, token)}) 'result': plan(params, mode, cfg.horizon_s, token)})
else: else:
self.envoyer({'jsonrpc': '2.0', 'id': ident, self.erreur(ident, CODE_METHODE_INCONNUE, 'méthode inconnue')
'error': {'code': -32601, 'message': 'méthode inconnue'}})
class ServeurUnix(socketserver.ThreadingMixIn, socketserver.UnixStreamServer): class ServeurUnix(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
@ -518,7 +582,7 @@ def ecouter(endpoint):
def main(argv=None): def main(argv=None):
p = argparse.ArgumentParser(prog=NOM, description='Optimiseur de référence (protocole 1.1, annexe E).') p = argparse.ArgumentParser(prog=NOM, description='Optimiseur de référence (protocole 1.2, annexe E).')
p.add_argument('--listen', default=ENDPOINT_DEFAUT, p.add_argument('--listen', default=ENDPOINT_DEFAUT,
help=f'unix:///chemin ou tcp://hôte:port (défaut : {ENDPOINT_DEFAUT})') help=f'unix:///chemin ou tcp://hôte:port (défaut : {ENDPOINT_DEFAUT})')
p.add_argument('--token-file', help='fichier contenant le token attendu (jamais en argument)') p.add_argument('--token-file', help='fichier contenant le token attendu (jamais en argument)')