fix(optimiseur): une tentative de reconnexion n'est pas une nouvelle perte (correctifs brief 4, point 2)
Constat `drop` de T7 : SocketScheduler notait ConnexionPerdue à CHAQUE état non connecté du client (Connecting, Waiting d'une tentative), réécrivait lastErrorCode avec celui du client et émettait etatChange() à chaque tentative au backoff (une par minute au plafond). La perte se note désormais à la transition : sortie de Ready ou de Handshaking (fermeture, handshake refusé), ou premier échec d'un client qui n'a jamais été connecté (sinon un optimiseur absent dès le départ n'aurait jamais de lastErrorCode). Les tentatives suivantes d'une panne déjà notée ne touchent ni au dernier code, ni aux échecs, ni à l'état publié. Publication seule : aucune décision ne change. Test socketscheduler::testAReconnectionAttemptIsNotANewLoss : optimiseur disparu, 3 tentatives au backoff → 1 seul etatChange, 1 seule notification, lastErrorCode `connection-lost` (le client en est à `connect-failed`) ; au retour, les transitions du §7 et rien d'autre. Ligne `drop` de refoptimizer : le dernier code reste `disconnected`. Contre-épreuve M2 (ancien comportement) : mord sur les deux. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8c0645d371
commit
ce323a6e1f
@ -30,11 +30,27 @@ SocketScheduler::SocketScheduler(IScheduler *ombre, OptimizerClient *client, Fou
|
||||
connect(m_client, &OptimizerClient::planFailed, this, &SocketScheduler::onPlanEchoue);
|
||||
// §7 « socket absent / fermé → rules, optimizerAlive = false » : un plan ne survit pas à la
|
||||
// connexion qui l'a porté, et un optimiseur qu'on n'atteint pas n'est pas vivant.
|
||||
//
|
||||
// La perte se note à la TRANSITION : sortie de Ready ou de Handshaking (fermeture, handshake
|
||||
// refusé), ou premier échec d'un client qui n'a pas encore été connecté (optimiseur absent dès
|
||||
// le départ : sans cela, lastErrorCode n'aurait jamais de valeur). Une tentative de reconnexion
|
||||
// qui échoue pendant une panne déjà notée n'en est pas une nouvelle : ni etatChange(), ni
|
||||
// dernier code réécrit (constat `drop`, brief 4 T7).
|
||||
connect(m_client, &OptimizerClient::stateChanged, this, [this](OptimizerClient::State s) {
|
||||
if (s != OptimizerClient::Ready && s != OptimizerClient::Handshaking) {
|
||||
oublierCache(QStringLiteral("connexion perdue"));
|
||||
noterVivacite(OptimizerStatus::Vivacite::Evenement::ConnexionPerdue, m_client->lastErrorCode());
|
||||
const bool etaitConnecte = m_etatClient == OptimizerClient::Ready
|
||||
|| m_etatClient == OptimizerClient::Handshaking;
|
||||
m_etatClient = s;
|
||||
if (s == OptimizerClient::Ready) {
|
||||
m_perteNotee = false;
|
||||
return;
|
||||
}
|
||||
if (s == OptimizerClient::Connecting || s == OptimizerClient::Handshaking)
|
||||
return; // une tentative en cours n'est pas une perte
|
||||
if (!etaitConnecte && m_perteNotee)
|
||||
return; // tentative échouée pendant une panne déjà notée
|
||||
m_perteNotee = true;
|
||||
oublierCache(QStringLiteral("connexion perdue"));
|
||||
noterVivacite(OptimizerStatus::Vivacite::Evenement::ConnexionPerdue, m_client->lastErrorCode());
|
||||
});
|
||||
// Handshake accepté : nom, version et capacités ont changé — l'état publié se recompose.
|
||||
connect(m_client, &OptimizerClient::ready, this, &SocketScheduler::etatChange);
|
||||
|
||||
@ -38,6 +38,9 @@
|
||||
* \c OptimizerStatus::Vivacite : deux requêtes consécutives en échec, ou une connexion perdue,
|
||||
* déclarent l'optimiseur mort et abandonnent le plan en cache ; le premier plan valide le rend
|
||||
* vivant. Chaque changement est signalé par \c etatChange() ; l'arbitre en compose l'état publié.
|
||||
* Une connexion perdue se note UNE fois par panne (sortie de \c Ready ou \c Handshaking, ou
|
||||
* premier échec d'un client jamais connecté) : les tentatives de reconnexion au backoff ne
|
||||
* réécrivent ni le dernier code ni l'état publié.
|
||||
*
|
||||
* \invariant \c getPlan() n'attend jamais : aucune E/S n'y est bloquante.
|
||||
* \invariant Le plan rendu est toujours valide : au pire, c'est celui du rule-based.
|
||||
@ -184,5 +187,7 @@ private:
|
||||
QHash<QString, QString> m_ecretageVu; // loadId → dernier écrêtage annoncé
|
||||
QSet<QString> m_reservesSignales; // une fois par session
|
||||
OptimizerStatus::Vivacite m_vivacite; // §7 : optimizerAlive, échecs consécutifs par requête
|
||||
OptimizerClient::State m_etatClient = OptimizerClient::Stopped; // dernier état vu du client
|
||||
bool m_perteNotee = false; // panne en cours déjà notée : ses tentatives n'en sont pas
|
||||
bool m_updateLearnedSignale = false; // UpdateLearned : dit une fois par session
|
||||
};
|
||||
|
||||
@ -117,6 +117,17 @@ public:
|
||||
local->abort();
|
||||
}
|
||||
|
||||
//! L'optimiseur disparaît : la connexion est coupée et le serveur cesse d'écouter — chaque
|
||||
//! tentative du client échoue jusqu'au prochain listen() (même endpoint, même port).
|
||||
void fermer()
|
||||
{
|
||||
abortClient();
|
||||
delete m_local;
|
||||
m_local = nullptr;
|
||||
delete m_tcp;
|
||||
m_tcp = nullptr;
|
||||
}
|
||||
|
||||
int connections = 0;
|
||||
int disconnections = 0;
|
||||
//! Répondeur automatique (brief 4 T3) : appelé sur chaque message reçu ; s'il rend vrai, le
|
||||
|
||||
@ -645,12 +645,11 @@ void TestRefOptimizer::testEachFaultModeGivesItsRow_data()
|
||||
"rules|vivant|0|timeout", "optimizer|vivant|0|timeout"}
|
||||
<< QStringList{"attendu|mort|rules", "attendu|vivant|rules", "attendu|vivant|optimizer"};
|
||||
// drop : la connexion fermée tue tout de suite (§7 « socket fermé »), l'échec de la requête
|
||||
// en vol est compté (`disconnected`). Lu juste après le cycle, avant la reconnexion (1 s) ;
|
||||
// la TENTATIVE de reconnexion réécrit ensuite le dernier code avec celui du client
|
||||
// (`connection-lost`) — constaté ici, décrit au rapport T7.
|
||||
// en vol est compté (`disconnected`). La reconnexion qui suit n'est pas une nouvelle perte :
|
||||
// le dernier code reste celui de l'incident.
|
||||
QTest::newRow("drop") << "drop" << false
|
||||
<< QStringList{"rules|mort|1|disconnected", "rules|mort|2|disconnected",
|
||||
"rules|vivant|0|connection-lost", "optimizer|vivant|0|connection-lost"}
|
||||
"rules|vivant|0|disconnected", "optimizer|vivant|0|disconnected"}
|
||||
<< QStringList{"attendu|mort|rules", "attendu|vivant|rules", "attendu|vivant|optimizer"};
|
||||
// wrong-major : au handshake suivant (optimiseur redémarré) ; aucune requête ne part.
|
||||
QTest::newRow("wrong-major") << "wrong-major" << true
|
||||
@ -744,8 +743,8 @@ void TestRefOptimizer::testAProtocol10OptimizerIsAccepted()
|
||||
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
|
||||
const QVariantMap st = rpc("GetOptimizerStatus");
|
||||
QCOMPARE(st.value("optimizerAlive").toBool(), true);
|
||||
QCOMPARE(st.value("name").toString(), QString("powersync-optimizer"));
|
||||
QCOMPARE(st.value("protocolVersion").toString(), QString("1.0"));
|
||||
QCOMPARE(st.value("name").toString(), QString("powersync-optimizer"));
|
||||
QVERIFY(!st.contains("capabilities"));
|
||||
QCOMPARE(cleEtat(etatApplique()), cleEtat(QVariantMap{{"var1", 2000.0}, {"var2", 3000.0}, {"pac", 3}}));
|
||||
// Sans reasonCode, le motif vers l'app est le code générique, sans paramètres.
|
||||
|
||||
@ -117,6 +117,7 @@ private slots:
|
||||
void testStatusFollowsTheFallbackTable_data();
|
||||
void testStatusFollowsTheFallbackTable();
|
||||
void testEachTransitionNotifiesOnce();
|
||||
void testAReconnectionAttemptIsNotANewLoss();
|
||||
void testEndpointIsSetByRpcAndAppliedHot();
|
||||
void testTokenIsWriteOnlyAndNeverLeaks();
|
||||
void testGetPlanPublishesThePlanInForce();
|
||||
@ -1150,6 +1151,58 @@ void TestSocketScheduler::testEachTransitionNotifiesOnce()
|
||||
QCOMPARE(notifie, lu);
|
||||
}
|
||||
|
||||
void TestSocketScheduler::testAReconnectionAttemptIsNotANewLoss()
|
||||
{
|
||||
// Constat `drop` (brief 4 T7) : pendant une panne, chaque tentative de reconnexion au backoff
|
||||
// n'est PAS une nouvelle perte. Une seule transition à la perte, une seule au retour ; entre
|
||||
// les deux, ni OptimizerStatusChanged, ni etatChange(), ni dernier code réécrit.
|
||||
monter();
|
||||
enableNotifications({"NymeaEnergy"});
|
||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||||
amenerAOptimiseur(t0);
|
||||
SocketScheduler *s = m_arb->socketScheduler();
|
||||
QSignalSpy paquets(m_mockTcpServer, &MockTcpServer::outgoingData);
|
||||
QSignalSpy spy(m_arb, &EnergyArbitrator::optimizerStatusChanged);
|
||||
QSignalSpy etats(s, &SocketScheduler::etatChange);
|
||||
QSignalSpy client(s->client(), &OptimizerClient::stateChanged);
|
||||
auto tentatives = [&client] {
|
||||
int n = 0;
|
||||
for (const QList<QVariant> &a : client)
|
||||
n += a.first().value<OptimizerClient::State>() == OptimizerClient::Connecting;
|
||||
return n;
|
||||
};
|
||||
|
||||
// Perte : l'optimiseur disparaît, puis N = 3 tentatives échouent au backoff (1, 2, 4 s).
|
||||
m_serveur->fermer();
|
||||
QVERIFY(waitFor([s] { return s->client()->state() == OptimizerClient::Waiting; }));
|
||||
QCOMPARE(etats.count(), 1);
|
||||
QCOMPARE(triplets(spy), QStringList{"attendu|mort|rules"});
|
||||
QVERIFY2(waitFor([&] { return tentatives() >= 3 && s->client()->state() == OptimizerClient::Waiting; }, 15000),
|
||||
qPrintable(QString::number(tentatives())));
|
||||
QCOMPARE(tentatives(), 3);
|
||||
// Les tentatives ont bien échoué, avec un autre code que la perte : le test serait aveugle sinon.
|
||||
QCOMPARE(s->client()->lastErrorCode(), QString("connect-failed"));
|
||||
QCOMPARE(etats.count(), 1);
|
||||
QCOMPARE(triplets(spy), QStringList{"attendu|mort|rules"});
|
||||
QVariantMap st = rpc("GetOptimizerStatus");
|
||||
QCOMPARE(st.value("lastErrorCode").toString(), QString("connection-lost"));
|
||||
QCOMPARE(st.value("consecutiveFailures").toInt(), 0);
|
||||
QCOMPARE(checkNotifications(paquets, "NymeaEnergy.OptimizerStatusChanged").count(), 1);
|
||||
|
||||
// Retour : l'optimiseur réécoute au même endpoint ; la tentative suivante aboutit.
|
||||
QVERIFY(m_serveur->listen());
|
||||
QVERIFY(waitFor([s] { return s->client()->state() == OptimizerClient::Ready; }, 20000));
|
||||
QCOMPARE(etats.count(), 2); // le handshake accepté (identité), et rien d'autre
|
||||
cycle(t0.addSecs(120), -3000);
|
||||
cycle(t0.addSecs(180), -3000);
|
||||
QVERIFY(s->optimiseurAuDernierCycle());
|
||||
QCOMPARE(triplets(spy), (QStringList{"attendu|mort|rules", "attendu|vivant|rules", "attendu|vivant|optimizer"}));
|
||||
QCOMPARE(checkNotifications(paquets, "NymeaEnergy.OptimizerStatusChanged").count(), 3);
|
||||
st = rpc("GetOptimizerStatus");
|
||||
QCOMPARE(st.value("lastErrorCode").toString(), QString("connection-lost")); // jamais effacé (T4 Q5)
|
||||
QCOMPARE(st.value("consecutiveFailures").toInt(), 0);
|
||||
}
|
||||
|
||||
void TestSocketScheduler::testEndpointIsSetByRpcAndAppliedHot()
|
||||
{
|
||||
monter(false);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user