Patrick Schurig 483bbcb027 feat(traces): traces de décision NDJSON, une ligne par cycle (brief 4 T6, annexe D)
Réglage optimizerTrace (energy.conf Optimizer/trace, …RetentionDays, …MaxBytesPerDay), défaut
DÉSACTIVÉ : ni objet, ni fil, ni fichier. NymeaEnergy.GetOptimizerTrace / SetOptimizerTrace,
PermissionScopeAdmin comme SetOptimizerEndpoint, type contrôlé par le moteur, appliqué à chaud.

Une ligne par cycle — rule-based, optimiseur, et L2 (strategy none, sans requête) : t (heure
locale), request (params GetPlan envoyés, ou qui l'auraient été), response (ce qui a répondu
à CETTE requête : result brut, error.code, no-response ; sinon le plan du rule-based en codes),
executed (appliqué, clamped, shed), strategy, latencyMs. La ligne N attend la réponse à la
requête N (elle sert au cycle N+1).

Toutes les E/S sur un fil dédié (OptimizerTraceWriter) : rotation à minuit heure locale, purge
au-delà de la rétention (14 j), plafond par fichier journalier (64 Mio, mesuré : 5 Ko/ligne
rule-based, 39 Ko sous un optimiseur à 48 créneaux, 1 cycle/min), file bornée à 256 lignes.
Échec d'écriture et plafond dits une fois. update() ne fait que poster. Le token n'est jamais
dans une ligne ; recopié par une réponse d'optimiseur, il est masqué.

Preuves : decisions IDENTIQUE traces désactivées ET activées (21 132 lignes de trace écrites,
ETM_DECISIONS_TRACES=1). socketscheduler 33 fonctions, 114/0 (+10 fonctions T6).

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

245 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,
* et l'écrivain masque en plus toute occurrence que la réponse brute d'un optimiseur porterait.
*
* \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 Remplace, dans une ligne déjà sérialisée, chaque occurrence du token par
* \c "[token masqué]".
* \param ligne Ligne JSON. \param token Le secret, brut ; vide = rien à faire.
* \param[out] masques Nombre de remplacements (peut être \c nullptr).
* \return La ligne masquée.
* \note Le token est cherché sous sa forme ÉCHAPPÉE JSON (\c " et \c \\ ) : c'est ainsi qu'il
* apparaîtrait dans la ligne.
*/
QByteArray masquerToken(const QByteArray &ligne, const QByteArray &token, int *masques = nullptr);
/*! \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)