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
|
# `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
|
||||||
|
|||||||
@ -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)')
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user