From 14b86de3c99e18cd5a041bf66cbfb9929e5de3d3 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Thu, 22 Oct 2026 20:26:26 +0200 Subject: [PATCH] =?UTF-8?q?trace=20:=20une=20requ=C3=AAte=20non=20envoy?= =?UTF-8?q?=C3=A9e=20n'est=20trac=C3=A9e=20que=20par=20sa=20taille=20(Q7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avec les traces actives, la ligne d'un cycle recopiait la requête GetPlan même quand le client ne l'avait pas écrite (ligne de plus de 1 Mio, request-too-long) : environ 1 Mio par cycle jusqu'au plafond journalier, pour une requête que personne n'a reçue, et que le rejeu aurait renvoyée. - La ligne porte alors `requestBytes` (taille de la ligne, octets) à la place de `request` ; sa réponse reste l'erreur request-too-long. Même règle pour la requête qui SERAIT partie sans optimiseur. - La décision « part ou ne part pas » est celle du client : OptimizerClient::getPlanLine(), extraite de requestPlan() sans changer son comportement, est lue par la trace — pas une seconde estimation. - Le commentaire « la requête RÉELLEMENT envoyée » est corrigé, la doc de la ligne de trace (optimizertrace.h) aussi. Preuve : testARequestNotSentIsTracedBySizeOnly (avec et sans optimiseur ; contre-épreuve : la requête envoyée est recopiée). Co-Authored-By: Claude Opus 5.5 --- energyplugin/etm/energyarbitrator.cpp | 24 ++++++-- .../etm/scheduler/optimizerclient.cpp | 17 ++++-- energyplugin/etm/scheduler/optimizerclient.h | 9 +++ energyplugin/etm/scheduler/optimizertrace.h | 7 ++- .../socketscheduler/testsocketscheduler.cpp | 61 +++++++++++++++++++ 5 files changed, 105 insertions(+), 13 deletions(-) diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 1731cd5..ab5cda3 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -1733,7 +1733,7 @@ void EnergyArbitrator::provisionEvChargerConfig(EvCharger *ev) } m_provisionRefuse.remove(id); - qCInfo(dcNymeaEnergy()) << "[EnergyArbitrator] Borne" << ev->name() + qCInfo(dcNymeaEnergy()) << "[EnergyArbitrator] Borne" << c.label() << "ajoutée à la configuration des charges (rang" << c.priority() << ", domaine ev, rankOrigin=auto) — entrée créée automatiquement, " "insérée EN QUEUE sans déplacer personne. Le rang est un défaut, " @@ -3122,15 +3122,29 @@ void EnergyArbitrator::tracerCycle(const SurplusContext &ctx, const Plan &plan, ligne.insert(QStringLiteral("t"), OptimizerTrace::horodatage(now)); const bool optimiseur = plan.strategy == QLatin1String(OptimizerPlan::StrategieOptimiseur); const quint64 id = m_socketScheduler ? m_socketScheduler->idRequeteDuCycle() : 0; + // Q7 — une requête trop longue pour la ligne du protocole n'est PAS partie : la trace n'en + // garde que la TAILLE (`requestBytes`, octets de la ligne). La recopier écrirait plus de 1 Mio + // par cycle, jusqu'au plafond journalier, pour une requête que personne n'a reçue — et le + // rejeu la renverrait à un optimiseur qui la refuserait. La règle est celle du client + // (getPlanLine, kMaxLineBytes), pas une seconde estimation. + auto requeteOuTaille = [&ligne](quint64 idFil, const QVariantMap &requete) { + const qsizetype octets = OptimizerClient::getPlanLine(idFil, requete).size(); + if (octets > OptimizerClient::kMaxLineBytes) + ligne.insert(QStringLiteral("requestBytes"), octets); + else + ligne.insert(QStringLiteral("request"), requete); + }; if (id != 0) { - // La requête RÉELLEMENT envoyée ; sa réponse complétera la ligne (ou « no-response »). - ligne.insert(QStringLiteral("request"), m_socketScheduler->requeteDuCycle()); + // La requête soumise au client ; sa réponse complétera la ligne (ou « no-response »). + // Trop longue, elle n'a pas été écrite et sa réponse sera l'erreur `request-too-long`. + requeteOuTaille(id, m_socketScheduler->requeteDuCycle()); } else { // Aucune n'est partie (pas d'endpoint, client non connecté) : celle qui serait partie, - // même fonction, même fil — et c'est le rule-based qui y a répondu. + // même fonction, même fil — et c'est le rule-based qui y a répondu. Même règle de taille, + // calculée sous un identifiant nul : aucun n'a été attribué. OptimizerProtocol::ContexteFil fil = contexteFil(ctx); fil.trigger = m_socketScheduler ? m_socketScheduler->declencheurEnAttente() : QStringLiteral("cycle"); - ligne.insert(QStringLiteral("request"), OptimizerProtocol::getPlanParams(ctx, fil)); + requeteOuTaille(0, OptimizerProtocol::getPlanParams(ctx, fil)); ligne.insert(QStringLiteral("response"), optimiseur ? QVariantMap{{QStringLiteral("error"), QVariantMap{{QStringLiteral("code"), QStringLiteral("not-sent")}}}} diff --git a/energyplugin/etm/scheduler/optimizerclient.cpp b/energyplugin/etm/scheduler/optimizerclient.cpp index bd04902..8bc4a3e 100644 --- a/energyplugin/etm/scheduler/optimizerclient.cpp +++ b/energyplugin/etm/scheduler/optimizerclient.cpp @@ -254,12 +254,7 @@ quint64 OptimizerClient::requestPlan(const QVariantMap ¶ms) const quint64 id = ++m_lastId; m_pendingId = id; - const QByteArray line = serialize(QVariantMap{ - {QStringLiteral("jsonrpc"), QStringLiteral("2.0")}, - {QStringLiteral("id"), id}, - {QStringLiteral("method"), QStringLiteral("GetPlan")}, - {QStringLiteral("params"), params}, - }); + const QByteArray line = getPlanLine(id, params); // §2 (1.2, Q7) : le moteur n'émet jamais de ligne de plus de maxLineBytes. Un optimiseur // conforme la jetterait (-32600 à id null) et la requête finirait en délai dépassé, la cause @@ -704,6 +699,16 @@ void OptimizerClient::countProtocolError(const QString &code, const QString &det emit protocolError(code); } +QByteArray OptimizerClient::getPlanLine(quint64 id, const QVariantMap ¶ms) +{ + return serialize(QVariantMap{ + {QStringLiteral("jsonrpc"), QStringLiteral("2.0")}, + {QStringLiteral("id"), id}, + {QStringLiteral("method"), QStringLiteral("GetPlan")}, + {QStringLiteral("params"), params}, + }); +} + QByteArray OptimizerClient::serialize(const QVariantMap &message) { return QJsonDocument(QJsonObject::fromVariantMap(message)).toJson(QJsonDocument::Compact); diff --git a/energyplugin/etm/scheduler/optimizerclient.h b/energyplugin/etm/scheduler/optimizerclient.h index 7526e9c..c3a14bf 100644 --- a/energyplugin/etm/scheduler/optimizerclient.h +++ b/energyplugin/etm/scheduler/optimizerclient.h @@ -235,6 +235,15 @@ public: */ quint64 requestPlan(const QVariantMap ¶ms); + /*! + * \brief La ligne \c GetPlan exacte que \c requestPlan() écrirait, sans son \c '\\n'. + * \param id Identifiant JSON-RPC de la requête. \param params Ses \c params. + * \return La ligne sérialisée. Sa taille comparée à \c kMaxLineBytes dit si la requête part : + * c'est la règle de \c requestPlan(), qui l'appelle — la trace de décision la lit aussi, + * pour ne pas recopier une requête qui n'est pas partie. + */ + static QByteArray getPlanLine(quint64 id, const QVariantMap ¶ms); + /*! \brief \return Identifiant de la requête attendue, 0 si aucune. */ quint64 pendingRequestId() const { return m_pendingId; } diff --git a/energyplugin/etm/scheduler/optimizertrace.h b/energyplugin/etm/scheduler/optimizertrace.h index 2803cc5..c35ab9e 100644 --- a/energyplugin/etm/scheduler/optimizertrace.h +++ b/energyplugin/etm/scheduler/optimizertrace.h @@ -30,7 +30,9 @@ * * \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), + * envoyés, ou ceux qui l'auraient été) — ou, à sa place, \c requestBytes (taille de la ligne, + * en octets) pour une requête trop longue pour partir (\c request-too-long, Q7), \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 @@ -195,7 +197,8 @@ public: /*! * \brief Ligne d'un cycle. - * \param ligne \c t, \c request, \c response (si connue), \c executed, \c strategy. + * \param ligne \c t, \c request (ou \c requestBytes), \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). */ diff --git a/tests/auto/socketscheduler/testsocketscheduler.cpp b/tests/auto/socketscheduler/testsocketscheduler.cpp index 51dc161..f978a17 100644 --- a/tests/auto/socketscheduler/testsocketscheduler.cpp +++ b/tests/auto/socketscheduler/testsocketscheduler.cpp @@ -14,6 +14,7 @@ #include "energysettings.h" #include "etm/energyarbitrator.h" #include "etm/config/loadconfigstore.h" +#include "etm/scheduler/optimizerclient.h" #include "etm/scheduler/socketscheduler.h" #include "etm/scheduler/optimizerstatus.h" #include "etm/scheduler/publishedplan.h" @@ -143,6 +144,8 @@ private slots: void testTokenIsInNoTrace(); void testTraceLineSize(); void testTracerPairsTheResponseByRequestId(); + //! Q7, suite : une requête trop longue pour partir n'est tracée que par sa TAILLE. + void testARequestNotSentIsTracedBySizeOnly(); // Brief 4 T7 — correctif A : un token recopié par l'optimiseur ne sort nulle part void testAnEchoedTokenLeaksNowhere(); // Lot contrat OpenRPC — le moteur parle le contrat qu'il sert @@ -1629,6 +1632,64 @@ void TestSocketScheduler::testInheritedOutOfRuleConfigWhatGetPlanSends() jugerLesRequetesDuMoteur(); } +void TestSocketScheduler::testARequestNotSentIsTracedBySizeOnly() +{ + // Q7, suite : la trace recopiait la requête du cycle, même quand le client ne l'avait PAS + // écrite (ligne de plus de 1 Mio, request-too-long) — environ 1 Mio par cycle jusqu'au plafond + // journalier, pour une requête que personne n'a reçue. Seule sa TAILLE y figure désormais. + monter(); + const QString d = activerTraces(); + m_repondre = [](const QVariantMap &) { return QVariantMap{{"abstain", true}, {"reason", "test"}}; }; + const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); + cycle(t0, -3000); // CONTRE-ÉPREUVE : une requête envoyée est recopiée + const int recues = m_requetes.count(); + + // Une configuration héritée au libellé démesuré : SetLoadConfig la refuserait, le chargement + // la garde, et le contexte dépasse la ligne du protocole. + const QString demesure(OptimizerClient::kMaxLineBytes + 1000, QLatin1Char('L')); + const QVariantList charges{ + QVariantMap{{"id", "ecs"}, {"label", "ECS"}, {"adapter", "relay-router"}, {"mode", "fixed"}, + {"priority", 1}, {"enabled", true}, {"minOnS", 0}, {"minOffS", 0}, + {"relays", QVariantList{QVariantMap{{"thingId", m_relais.toString()}, {"powerW", 1000}}}}}, + QVariantMap{{"id", m_var}, {"label", demesure}, {"adapter", "etmvariableload"}, {"mode", "dynamic"}, + {"maxPowerW", 3000}, {"priority", 2}, {"enabled", true}}}; + chargerConfigHeritee(charges); + m_compteur->setStateValue("currentPower", -3000); + m_arb->recordMeterUpdate(t0.addSecs(60)); + m_arb->simulationCallUpdate(t0.addSecs(60)); + QVERIFY(waitFor([&] { return m_codesEchec.contains(QStringLiteral("request-too-long")); })); + settle(); + QCOMPARE(m_requetes.count(), recues); // rien n'est parti + + QList l = traces(d, 2); + QCOMPARE(l.count(), 2); + QVERIFY(l.at(0).contains("request")); + QVERIFY(!l.at(0).contains("requestBytes")); + QVERIFY(!l.at(1).contains("request")); + const qint64 octets = l.at(1).value("requestBytes").toLongLong(); + QVERIFY2(octets > OptimizerClient::kMaxLineBytes && octets < 2 * OptimizerClient::kMaxLineBytes, + qPrintable(QString::number(octets))); + QCOMPARE(l.at(1).value("response").toMap(), + (QVariantMap{{"error", QVariantMap{{"code", "request-too-long"}}}})); + // Le libellé n'est NULLE PART dans la trace : le fichier entier est loin du mégaoctet. + qint64 taille = 0; + for (const QString &f : fichiersTrace(d)) + taille += QFileInfo(QDir(d).filePath(f)).size(); + QVERIFY2(taille < 200 * 1024, qPrintable(QString::number(taille))); + + // Sans optimiseur : la requête qui SERAIT partie suit la même règle. + monter(false); + const QString d2 = activerTraces(); + chargerConfigHeritee(charges); + cycle(t0.addSecs(120), -3000); + l = traces(d2, 1); + QCOMPARE(l.count(), 1); + QVERIFY(!l.at(0).contains("request")); + QVERIFY(l.at(0).value("requestBytes").toLongLong() > OptimizerClient::kMaxLineBytes); + QCOMPARE(l.at(0).value("strategy").toString(), QStringLiteral("rules")); + QVERIFY(l.at(0).value("response").toMap().contains("result")); +} + void TestSocketScheduler::testARequestTooLongCountsAsAFailure() { // Protocole 1.2, Q7 : une requête que le client n'émet pas (ligne de plus de 1 Mio) est un