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:
parent
952f64d24b
commit
4cbbb81802
@ -1,7 +1,7 @@
|
||||
# `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`
|
||||
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
|
||||
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.
|
||||
@ -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
|
||||
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
|
||||
|
||||
```sh
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# 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]
|
||||
[--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
|
||||
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
|
||||
l'optimiseur ETM, propriétaire, hors de ce dépôt.
|
||||
@ -75,13 +88,19 @@ import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
ENDPOINT_DEFAUT = 'unix:///run/powersync/optimizer.sock'
|
||||
VERSION = '1.1'
|
||||
VERSION = '1.2'
|
||||
MAJEURE = VERSION.split('.')[0]
|
||||
NOM = 'etm-powersync-ref-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.
|
||||
NOM_ANNONCE = 'powersync-optimizer'
|
||||
LIGNE_MAX = 1024 * 1024 # E-T2-1 : une ligne de plus de 1 Mio est écartée
|
||||
CODE_TOKEN_REFUSE = -32001 # E-T2-6
|
||||
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, §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',
|
||||
'forbidden-kind', 'missing-load', 'echo-token', 'protocol-1.0', 'deviate', 'wrong-type']
|
||||
@ -308,12 +327,18 @@ def iso(t):
|
||||
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):
|
||||
"""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()
|
||||
maintenant = instant(ctx.get('timestamp'))
|
||||
if maintenant is None:
|
||||
return {'abstain': True, 'reason': 'timestamp illisible ou sans décalage'}
|
||||
if mode == 'abstain':
|
||||
return {'abstain': True, 'reason': 'mode abstain (test)'}
|
||||
actions = waterfall(ctx, inverse=(mode == 'deviate'))
|
||||
@ -412,28 +437,60 @@ class Connexion(socketserver.StreamRequestHandler):
|
||||
self.wfile.write(ligne)
|
||||
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):
|
||||
cfg = self.server.cfg
|
||||
pret = False
|
||||
token = None
|
||||
while True:
|
||||
try:
|
||||
ligne = self.rfile.readline(LIGNE_MAX + 2)
|
||||
ligne = self.lire_ligne()
|
||||
except (OSError, ValueError):
|
||||
return
|
||||
if not ligne:
|
||||
if ligne is None:
|
||||
return
|
||||
if len(ligne) > LIGNE_MAX:
|
||||
if ligne is TROP_LONGUE:
|
||||
journal('ligne trop longue écartée')
|
||||
self.erreur(None, CODE_REQUETE_INVALIDE, 'ligne trop longue')
|
||||
continue
|
||||
if not ligne.strip():
|
||||
continue
|
||||
try:
|
||||
message = json.loads(ligne)
|
||||
except ValueError:
|
||||
journal('ligne non JSON écartée')
|
||||
self.erreur(None, CODE_ANALYSE, "erreur d'analyse")
|
||||
continue
|
||||
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
|
||||
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')
|
||||
params = message.get('params') if isinstance(message.get('params'), dict) else {}
|
||||
if ident is None:
|
||||
@ -444,8 +501,12 @@ class Connexion(socketserver.StreamRequestHandler):
|
||||
if attendu is not None and params.get('token') != attendu:
|
||||
# Le token reçu n'est jamais recopié ni journalisé.
|
||||
journal('handshake refusé : token absent ou différent')
|
||||
self.envoyer({'jsonrpc': '2.0', 'id': ident,
|
||||
'error': {'code': CODE_TOKEN_REFUSE, 'message': 'token refusé'}})
|
||||
self.erreur(ident, CODE_TOKEN_REFUSE, '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
|
||||
token = attendu
|
||||
pret = True
|
||||
@ -461,8 +522,12 @@ class Connexion(socketserver.StreamRequestHandler):
|
||||
journal(f'handshake accepté ({str(params.get("engine", "?"))[:80]}), mode {mode}')
|
||||
elif methode == 'GetPlan':
|
||||
if not pret:
|
||||
self.envoyer({'jsonrpc': '2.0', 'id': ident,
|
||||
'error': {'code': -32002, 'message': 'handshake attendu'}})
|
||||
self.erreur(ident, CODE_REQUETE_INVALIDE, 'handshake requis')
|
||||
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
|
||||
if mode == 'drop':
|
||||
journal('mode drop : connexion fermée sans réponse')
|
||||
@ -472,8 +537,7 @@ class Connexion(socketserver.StreamRequestHandler):
|
||||
self.envoyer({'jsonrpc': '2.0', 'id': ident,
|
||||
'result': plan(params, mode, cfg.horizon_s, token)})
|
||||
else:
|
||||
self.envoyer({'jsonrpc': '2.0', 'id': ident,
|
||||
'error': {'code': -32601, 'message': 'méthode inconnue'}})
|
||||
self.erreur(ident, CODE_METHODE_INCONNUE, 'méthode inconnue')
|
||||
|
||||
|
||||
class ServeurUnix(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
|
||||
@ -518,7 +582,7 @@ def ecouter(endpoint):
|
||||
|
||||
|
||||
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,
|
||||
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)')
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user