Patrick Schurig ce323a6e1f fix(optimiseur): une tentative de reconnexion n'est pas une nouvelle perte (correctifs brief 4, point 2)
Constat `drop` de T7 : SocketScheduler notait ConnexionPerdue à CHAQUE état
non connecté du client (Connecting, Waiting d'une tentative), réécrivait
lastErrorCode avec celui du client et émettait etatChange() à chaque
tentative au backoff (une par minute au plafond).

La perte se note désormais à la transition : sortie de Ready ou de
Handshaking (fermeture, handshake refusé), ou premier échec d'un client qui
n'a jamais été connecté (sinon un optimiseur absent dès le départ
n'aurait jamais de lastErrorCode). Les tentatives suivantes d'une panne déjà
notée ne touchent ni au dernier code, ni aux échecs, ni à l'état publié.
Publication seule : aucune décision ne change.

Test socketscheduler::testAReconnectionAttemptIsNotANewLoss : optimiseur
disparu, 3 tentatives au backoff → 1 seul etatChange, 1 seule notification,
lastErrorCode `connection-lost` (le client en est à `connect-failed`) ; au
retour, les transitions du §7 et rien d'autre. Ligne `drop` de refoptimizer :
le dernier code reste `disconnected`. Contre-épreuve M2 (ancien comportement) :
mord sur les deux.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:34:38 +02:00

194 lines
9.9 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QElapsedTimer>
#include <QHash>
#include <QObject>
#include <QSet>
#include <QString>
#include <QVariantMap>
#include <functional>
#include "ischeduler.h"
#include "optimizerclient.h"
#include "optimizerplan.h"
#include "optimizerprotocol.h"
#include "optimizerstatus.h"
/*!
* \brief Stratégie « optimiseur externe » (OPTIMIZER_PROTOCOL.md 1.1, lot 3d, brief 4 T3).
*
* N'existe QUE si un endpoint est configuré (\c Optimizer/endpoint) : sans lui, l'arbitre
* n'instancie ni cette classe ni son \c OptimizerClient, et interroge le rule-based directement.
*
* **À chaque cycle**, \c getPlan() :
* 1. appelle le rule-based — le plan OMBRE. Il tourne toujours, parce que ses effets de bord sur
* l'arbitre (actions du proxy VE, comptabilité éco, réserve batterie) sont dus à chaque cycle,
* et parce qu'il est le repli immédiat ;
* 2. envoie \c GetPlan avec le contexte du cycle, SANS ATTENDRE : la réponse arrive par signal
* et sert au cycle SUIVANT (modèle cache, règle 5) ;
* 3. si le plan en cache est valide pour maintenant (\c OptimizerPlan::valider() sur le contexte
* du cycle : créneau courant, \c validUntil, couverture Q1), substitue ses actions à celles de
* l'ombre (\c OptimizerPlan::substituer()) ; sinon rend l'ombre telle quelle.
*
* Après le dispatch, \c apresDispatch() relit ce que les adaptateurs ont appliqué (Q3, Q4).
*
* **Repli (§7)** : plan absent, invalide, expiré, abstention, connexion perdue → le rule-based
* pour le cycle. La vivacité (\c optimizerAlive, brief 4 T4) est tenue par
* \c OptimizerStatus::Vivacite : deux requêtes consécutives en échec, ou une connexion perdue,
* déclarent l'optimiseur mort et abandonnent le plan en cache ; le premier plan valide le rend
* vivant. Chaque changement est signalé par \c etatChange() ; l'arbitre en compose l'état publié.
* Une connexion perdue se note UNE fois par panne (sortie de \c Ready ou \c Handshaking, ou
* premier échec d'un client jamais connecté) : les tentatives de reconnexion au backoff ne
* réécrivent ni le dernier code ni l'état publié.
*
* \invariant \c getPlan() n'attend jamais : aucune E/S n'y est bloquante.
* \invariant Le plan rendu est toujours valide : au pire, c'est celui du rule-based.
* \invariant Aucun appel à un adaptateur : les actions passent par le dispatch commun de
* l'arbitre (« un seul arbitre »), qui les écrête comme celles du rule-based.
* \invariant Le texte libre d'un optimiseur va au journal, jamais dans une action (invariant 12).
*/
class SocketScheduler : public QObject, public IScheduler
{
Q_OBJECT
public:
//! Ce que la requête porte en plus du contexte (déclencheur excepté) — fourni par l'arbitre.
using FournisseurFil = std::function<OptimizerProtocol::ContexteFil(const SurplusContext &)>;
/*!
* \brief Construit la stratégie et démarre le client.
* \param ombre Le rule-based, appelé à chaque cycle ; non possédé, doit survivre.
* \param client Transport vers l'optimiseur ; POSSÉDÉ (reparenté), démarré ici.
* \param fil Fournisseur du \c ContexteFil de la requête ; vide = tout inconnu.
* \param parent Propriétaire Qt.
*/
SocketScheduler(IScheduler *ombre, OptimizerClient *client, FournisseurFil fil,
QObject *parent = nullptr);
/*!
* \brief Plan du cycle — voir la description de la classe.
* \param ctx Contexte du cycle. \return Le plan d'optimiseur substitué, ou celui de l'ombre.
*/
Plan getPlan(const SurplusContext &ctx) override;
/*!
* \brief Après le dispatch : puissance appliquée, financement, budget et \c clamped (Q3, Q4).
*
* Sans effet si \p plan n'est pas un plan d'optimiseur. À appeler entre
* \c applyActionsToAdapters() et la fusion du délestage L4.
* \param plan Plan du cycle. \param slot Créneau dispatché.
* \param appliquees Retours d'\c applyAction(). \param delestees Charges revendiquées par L4.
* \param ctx Contexte du cycle.
*/
void apresDispatch(Plan &plan, Slot &slot, const QHash<QString, LoadAction> &appliquees,
const QSet<QString> &delestees, const SurplusContext &ctx);
/*!
* \brief Note la cause de la prochaine requête (§5.1 \c trigger).
*
* Plusieurs causes entre deux requêtes : la plus significative l'emporte —
* \c intent > \c load-change > \c config > \c forecast > \c cycle. Consommée par l'envoi.
* \param trigger Une des cinq valeurs ci-dessus ; toute autre est ignorée.
*/
void noterDeclencheur(const QString &trigger);
/*! \brief \return La cause que portera la prochaine requête. */
QString declencheurEnAttente() const { return m_declencheur; }
/*! \brief \return Le transport (lecture : état, capacités). */
OptimizerClient *client() const { return m_client; }
/*! \brief \return Vrai si un plan valide à sa réception est en cache. */
bool aUnPlanEnCache() const { return m_aCache; }
/*! \brief \return Vrai si le plan du dernier cycle était celui de l'optimiseur. */
bool optimiseurAuDernierCycle() const { return m_optimiseurAuCycle; }
/*! \brief \return Code du dernier cycle rendu au rule-based (vide si optimiseur). */
QString dernierCodeDeRepli() const { return m_codeCycle; }
/*!
* \brief Le dernier plan d'optimiseur appliqué, tel que reçu et converti.
* \return \c expected (par créneau) et \c diagnostics y sont CONSERVÉS pour l'app et les
* traces ; rien de ce qu'ils contiennent ne commande quoi que ce soit.
*/
OptimizerPlan::PlanRecu planApplique() const { return m_planApplique; }
/*! \brief \return La vivacité de l'optimiseur (§7) : vivant, échecs, dernier code, dernier plan. */
const OptimizerStatus::Vivacite &vivacite() const { return m_vivacite; }
/*! \brief \return Identifiant de la requête envoyée au DERNIER \c getPlan(), 0 si aucune n'est
* partie (client non connecté) — traces, brief 4 T6. */
quint64 idRequeteDuCycle() const { return m_idDuCycle; }
/*! \brief \return Les paramètres \c GetPlan envoyés au dernier \c getPlan() (vide si aucun). */
QVariantMap requeteDuCycle() const { return m_requeteDuCycle; }
signals:
/*!
* \brief Une réponse \c GetPlan a été jugée — accroche du compteur d'échecs §7 (T4).
* \param issue \c valide | \c abstention | \c expiré | \c invalide.
* \param code Code du verdict (\c OptimizerPlan::Verdict), vide si valide.
*/
void reponseJugee(const QString &issue, const QString &code);
/*! \brief La requête attendue n'a pas eu de réponse utilisable. \param code Code du client
* (\c timeout, \c disconnected, \c rpc-error, \c invalid-response). */
void echecRequete(const QString &code);
/*!
* \brief Ce qui a répondu à la requête \p id, pour les traces (brief 4 T6).
* \param id Identifiant de la requête. \param reponse \c {"result": …} BRUT (texte libre
* compris : il ne va qu'aux traces, qui restent sur le site) ou \c {"error": {"code": …}}.
* \param latenceMs Délai envoi → réponse.
*/
void reponseTracee(quint64 id, const QVariantMap &reponse, qint64 latenceMs);
/*! \brief Stratégie retenue au cycle. \param optimiseur Vrai si le plan de l'optimiseur a
* été substitué. \param code Motif du repli, vide si optimiseur. */
void strategieDuCycle(bool optimiseur, const QString &code);
/*! \brief La vivacité, les échecs, le dernier code d'erreur ou l'identité de l'optimiseur
* (handshake) ont pu changer — l'arbitre recompose l'état publié. */
void etatChange();
private:
void envoyer(const SurplusContext &ctx);
void onPlanRecu(quint64 id, const QVariantMap &result);
void onPlanEchoue(quint64 id, const QString &code);
void oublierCache(const QString &pourquoi);
void noterCycle(bool optimiseur, const QString &code, const QString &detail);
void signalerReserves(const QStringList &champs);
void journaliserCreneau(const OptimizerPlan::Verdict &v, const SurplusContext &ctx);
void noterVivacite(OptimizerStatus::Vivacite::Evenement e, const QString &code,
const QDateTime &instant = QDateTime());
void onNotification(const QString &method);
IScheduler *m_ombre = nullptr;
OptimizerClient *m_client = nullptr;
FournisseurFil m_fil;
QString m_declencheur = QStringLiteral("cycle");
quint64 m_idRequete = 0;
SurplusContext m_ctxRequete; // contexte de la requête en vol : juge sa réponse
QElapsedTimer m_chronoRequete; // latence de la requête en vol (traces)
quint64 m_idDuCycle = 0; // requête partie au dernier getPlan() (traces)
QVariantMap m_requeteDuCycle;
bool m_aCache = false;
QVariantMap m_cache; // dernier `result` valide à sa réception, brut
bool m_optimiseurAuCycle = false;
QString m_codeCycle = QStringLiteral("no-plan");
bool m_cycleNote = false;
OptimizerPlan::PlanRecu m_planApplique;
QString m_dernierPlanRecu; // planId de la dernière réponse valide annoncée
QHash<QString, QString> m_motifVu; // loadId → dernière ligne de motif annoncée
QSet<QString> m_indisponiblesVus; // charges indisponibles visées, dernier jeu annoncé
QHash<QString, QString> m_ecretageVu; // loadId → dernier écrêtage annoncé
QSet<QString> m_reservesSignales; // une fois par session
OptimizerStatus::Vivacite m_vivacite; // §7 : optimizerAlive, échecs consécutifs par requête
OptimizerClient::State m_etatClient = OptimizerClient::Stopped; // dernier état vu du client
bool m_perteNotee = false; // panne en cours déjà notée : ses tentatives n'en sont pas
bool m_updateLearnedSignale = false; // UpdateLearned : dit une fois par session
};