// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync #pragma once #include #include #include #include #include #include #include #include #include #include #include #include #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 &appliquees, const QSet &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> 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> 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> 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)