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>
235 lines
11 KiB
C++
235 lines
11 KiB
C++
// 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 ®lage() 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)
|