Patrick Schurig 693e01579f fix(optimiseur): un token recopié par l'optimiseur ne sort par aucun journal (brief 4 T7, correctif A)
Constat de T6 (question 2) : la trace masquait un token recopié dans reason ou planId, mais
les lignes [Optimiseur] (planId, texte, détail des refus, nom du handshake) et
GetOptimizerStatus.name le portaient en clair.

- OptimizerSecret : UNE fonction de masquage (texte brut et échappé JSON, objet JSON à toute
  profondeur, clés comprises ; ligne de trace sérialisée), partagée par le client et les traces.
- OptimizerClient masque chaque message reçu dès son analyse, avec le token envoyé sur cette
  connexion : rien en aval (journal, traces, app) ne voit le secret ; dit une fois.
- Test testAnEchoedTokenLeaksNowhere : secret avec " et \ recopié partout ; aucune ligne de
  journal (tous niveaux), aucune trace, aucune réponse RPC. 4 contre-épreuves mordent.

decisions sans endpoint : IDENTIQUE (23 792 lignes), 0 rejeu.

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

235 lines
11 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QByteArray>
#include <QDate>
#include <QDateTime>
#include <QFile>
#include <QHash>
#include <QObject>
#include <QSet>
#include <QString>
#include <QThread>
#include <QVariantMap>
#include <atomic>
#include <memory>
#include "../types/plan.h"
/*!
* \file optimizertrace.h
* \brief Traces de décision (OPTIMIZER_PROTOCOL.md annexe D, brief 4 T6) : une ligne NDJSON par
* cycle, dans un fichier par jour LOCAL sous \c NymeaSettings::storagePath().
*
* Trois étages, chacun avec sa règle :
* - \c OptimizerTrace (fonctions PURES) : ce qu'une ligne contient ;
* - \c OptimizerTracer (fil de l'arbitre) : assemble la ligne d'un cycle, attend la réponse à SA
* requête, la confie à l'écrivain — sans jamais toucher au disque ni attendre ;
* - \c OptimizerTraceWriter (fil dédié) : toutes les E/S — rotation, purge, plafond, échecs.
*
* \par Ce qu'une ligne porte (annexe D)
* \c t (instant du cycle, heure locale avec décalage), \c request (les paramètres \c GetPlan
* envoyés, ou ceux qui l'auraient été), \c response (ce qui a répondu à CETTE requête),
* \c executed (actions appliquées au cycle), \c strategy (\c optimizer | \c rules | \c none),
* \c latencyMs (optimiseur seulement). Jamais le token : il ne voyage que dans le handshake ;
* le client masque toute réponse qui le recopierait (\c OptimizerSecret), et l'écrivain masque
* encore la ligne sérialisée, avec la même fonction.
*
* \invariant Les traces restent sur le site (invariant 10 du moteur) : aucun envoi, export manuel.
* \invariant Rien ici ne commande : une trace activée ou non, en échec ou non, ne change aucune
* décision (preuve \c decisions traces activées).
*/
namespace OptimizerTrace {
//! Préfixe des fichiers : \c etm-powersync-optimizer-trace-AAAA-MM-JJ.ndjson.
constexpr const char *PrefixeFichier = "etm-powersync-optimizer-trace-";
//! Suffixe des fichiers.
constexpr const char *SuffixeFichier = ".ndjson";
//! Rétention par défaut (jours, fichier du jour compris) — annexe D.
constexpr int RetentionDefautJours = 14;
//! Rétention minimale acceptée (jours).
constexpr int RetentionMinJours = 1;
//! Rétention maximale acceptée (jours).
constexpr int RetentionMaxJours = 365;
//! Plafond par défaut d'un fichier journalier (octets). Mesuré (brief 4 T6, \c testTraceLineSize) :
//! ~5 Ko par ligne rule-based, ~39 Ko sous un optimiseur qui rend 48 créneaux × 4 actions ; à
//! 1 cycle/min (banc), 7,6 et 56 Mo par jour. 64 Mio tient la journée d'optimiseur avec ~15 % de
//! marge ; borne totale, à 14 jours : 896 Mio.
constexpr qint64 PlafondDefautOctets = 64ll * 1024 * 1024;
//! Plafond journalier minimal accepté (octets).
constexpr qint64 PlafondMinOctets = 1024ll * 1024;
//! Plafond journalier maximal accepté (octets).
constexpr qint64 PlafondMaxOctets = 1024ll * 1024 * 1024;
//! Lignes confiées à l'écrivain et pas encore écrites, au-delà desquelles une ligne est
//! abandonnée plutôt que de laisser la mémoire croître derrière un support qui n'écrit plus.
constexpr int FileMaxLignes = 256;
//! Code de \c response quand la requête n'a eu aucune réponse avant la suivante (remplacée).
constexpr const char *SansReponse = "no-response";
/*! \brief \return Le nom du fichier du jour \p jour (date LOCALE). \param jour Jour local. */
QString nomFichier(const QDate &jour);
/*!
* \brief Lit la date d'un nom de fichier de trace.
* \param nom Nom de fichier, sans chemin. \return La date, invalide si \p nom n'en est pas un.
*/
QDate dateDuFichier(const QString &nom);
/*! \brief \param t Instant. \return \p t à l'heure LOCALE, décalage explicite (\c 2026-10-12T13:42:00+02:00). */
QString horodatage(const QDateTime &t);
/*!
* \brief Le plan du rule-based comme RÉPONSE à la requête du cycle (format \c GetPlan §6) :
* \c strategy \c rules, \c computedAt, \c validUntil, créneaux et actions DÉCIDÉES
* (\c powerW, \c state, \c reasonCode, \c reasonParams — des codes, jamais de texte).
* \param plan Plan rendu par la stratégie. \param now Instant du cycle.
* \return \c {"result": {...}}.
*/
QVariantMap reponseRegles(const Plan &plan, const QDateTime &now);
/*!
* \brief Les actions APPLIQUÉES au cycle : \c loadId, \c kind, \c powerW, \c state (SG-Ready),
* \c clamped, et \c shed quand L4 a délesté la charge.
* \param slot Créneau dispatché, délestage L4 fusionné. \param appliquees Retours
* d'\c applyAction(). \param delestees Charges revendiquées par L4.
* \return La liste, dans l'ordre du créneau.
*/
QVariantList executees(const Slot &slot, const QHash<QString, LoadAction> &appliquees,
const QSet<QString> &delestees);
/*! \brief Réglage des traces (\c Optimizer/trace* dans \c energy.conf). */
struct Reglage {
bool active = false; //!< \c Optimizer/trace — défaut : désactivé.
int retentionJours = RetentionDefautJours; //!< \c Optimizer/traceRetentionDays.
qint64 plafondOctetsJour = PlafondDefautOctets; //!< \c Optimizer/traceMaxBytesPerDay.
QString dossier; //!< Répertoire ; vide = \c storagePath().
QString cheminToken; //!< Fichier du token, pour le masquage.
/*! \brief Lu dans \c energy.conf ; une valeur hors bornes reprend son défaut. \return Le réglage. */
static Reglage lire();
//! \return Vrai si les deux réglages produisent les mêmes fichiers. \param o Autre réglage.
bool operator==(const Reglage &o) const;
};
} // namespace OptimizerTrace
/*!
* \brief L'écrivain des traces — vit sur son PROPRE fil : toutes les E/S disque sont ici.
*
* - **Rotation** : le fichier est choisi par la date LOCALE de la ligne (\c jour), donc par
* l'horloge du cycle — c'est elle qu'un test injecte.
* - **Purge** : à chaque changement de jour et à chaque réglage, les fichiers de plus de
* \c retentionJours jours (celui du jour compris) sont supprimés.
* - **Plafond** : un fichier qui atteindrait \c plafondOctetsJour n'est plus écrit jusqu'au
* lendemain ; dit UNE fois.
* - **Échec** (répertoire absent, en lecture seule, support plein) : dit UNE fois, puis plus
* rien jusqu'à la première écriture réussie, qui est dite aussi. Jamais d'exception, jamais
* d'attente du côté de l'arbitre.
*/
class OptimizerTraceWriter : public QObject
{
Q_OBJECT
public:
/*! \brief \param enAttente Compteur partagé avec le traceur : lignes confiées, pas encore traitées. */
explicit OptimizerTraceWriter(std::shared_ptr<std::atomic<int>> enAttente);
public slots:
/*! \brief Applique un réglage (dossier, rétention, plafond, token) et purge. \param r Réglage. */
void configurer(const OptimizerTrace::Reglage &r);
/*!
* \brief Sérialise et écrit une ligne dans le fichier de \p jour.
* \param ligne Contenu de la ligne. \param jour Jour LOCAL de la ligne.
*/
void ecrire(const QVariantMap &ligne, const QDate &jour);
private:
void ouvrir(const QDate &jour);
void purger(const QDate &aujourdhui);
void echec(const QString &detail);
QByteArray token();
std::shared_ptr<std::atomic<int>> m_enAttente;
OptimizerTrace::Reglage m_reglage;
QFile m_fichier;
QDate m_jour; // jour du fichier ouvert (ou tenté)
qint64 m_taille = 0;
QDate m_plafondAtteint; // jour dont le plafond est atteint : plus rien ce jour-là
bool m_echecSignale = false;
bool m_masqueSignale = false;
QByteArray m_token;
QDateTime m_tokenLuA; // date de modification du fichier lu
qint64 m_tokenTaille = -1;
};
/*!
* \brief Le traceur, côté arbitre : une ligne par cycle, confiée à l'écrivain.
*
* N'existe que si \c Optimizer/trace est vrai : désactivé, l'arbitre ne construit ni cet objet
* ni son fil, et aucun fichier n'est créé.
*
* **Appariement requête → réponse.** La réponse à la requête du cycle N arrive APRÈS le cycle
* (elle sert au cycle N+1, modèle cache). La ligne N attend donc sa réponse : elle part à la
* réponse, ou au cycle suivant avec \c {"error": {"code": "no-response"}} si la requête a été
* remplacée sans réponse. Une ligne sans requête envoyée (pas d'endpoint, client non connecté)
* porte la réponse du rule-based et part tout de suite ; une ligne L2 aussi.
*
* \invariant Aucune E/S disque, aucune attente : \c noterCycle() et \c noterReponse() ne font
* que poster vers le fil de l'écrivain.
*/
class OptimizerTracer : public QObject
{
Q_OBJECT
public:
/*! \brief Démarre le fil de l'écrivain. \param r Réglage. \param parent Propriétaire Qt. */
explicit OptimizerTracer(const OptimizerTrace::Reglage &r, QObject *parent = nullptr);
/*! \brief Termine la ligne en attente, vide la file, arrête le fil. */
~OptimizerTracer() override;
/*! \brief Change rétention, plafond ou dossier, à chaud. \param r Réglage. */
void configurer(const OptimizerTrace::Reglage &r);
/*! \brief \return Le réglage appliqué. */
const OptimizerTrace::Reglage &reglage() const { return m_reglage; }
/*!
* \brief Ligne d'un cycle.
* \param ligne \c t, \c request, \c response (si connue), \c executed, \c strategy.
* \param t Instant du cycle (choisit le fichier).
* \param idRequete Identifiant de la requête envoyée ce cycle ; 0 = aucune (la ligne part).
*/
void noterCycle(const QVariantMap &ligne, const QDateTime &t, quint64 idRequete);
/*!
* \brief Réponse (ou échec) à une requête.
* \param id Identifiant de la requête. \param reponse \c {"result": …} ou \c {"error": {"code": …}}.
* \param latenceMs Délai envoi → réponse.
*/
void noterReponse(quint64 id, const QVariantMap &reponse, qint64 latenceMs);
/*! \brief \return Lignes abandonnées parce que la file de l'écrivain était pleine. */
int lignesAbandonnees() const { return m_abandonnees; }
signals:
//! \cond INTERNE
void versEcrivain(const QVariantMap &ligne, const QDate &jour);
void reglageVersEcrivain(const OptimizerTrace::Reglage &r);
//! \endcond
private:
void poster(const QVariantMap &ligne, const QDateTime &t);
void terminerEnAttente();
OptimizerTrace::Reglage m_reglage;
QThread m_fil;
OptimizerTraceWriter *m_ecrivain = nullptr; // vit sur m_fil, détruit à sa fin
std::shared_ptr<std::atomic<int>> m_enAttente;
QVariantMap m_ligneEnAttente;
QDateTime m_tEnAttente;
quint64 m_idEnAttente = 0;
int m_abandonnees = 0;
bool m_fileSignalee = false;
};
Q_DECLARE_METATYPE(OptimizerTrace::Reglage)