[ADD] storage: measured cost of a complete save on the big demo

§ 8.2 makes the cost of a full save a measured threshold, since every
gesture rewrites the whole state file. The node-long test times a swap in
the retained plan of the big demo with twenty propositions — journal line,
.precedent, state, on the real file system — and the gesture that prunes
the journal past 600 entries. It keeps sampling while the minimum stays
above the threshold, within a bounded window, so a burst of load passes and
a uniform regression fails. Each gesture's files and every revision are
checked, so a save that skips a file cannot lower the measure.
Checked: 3 node-long tests; red under a ×5 slowdown of every gesture.

--- FR ---

[ADD] stockage : coût mesuré d'un enregistrement complet, grande démo

Le § 8.2 fait du coût d'un enregistrement complet un seuil mesuré, puisque
chaque geste réécrit tout le fichier d'état. L'épreuve node-long chronomètre
un échange dans le plan retenu de la grande démonstration à vingt
propositions — ligne de journal, .precedent, état, sur le vrai système de
fichiers — et le geste qui élague le journal au-delà de 600 entrées. Elle
mesure encore tant que le minimum dépasse le seuil, dans une fenêtre bornée :
une rafale de charge passe, une régression uniforme échoue. Les fichiers de
chaque geste et chaque révision sont vérifiés.
Vérifié : 3 épreuves node-long ; rouge sous un ralentissement ×5.

Assisted-by: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
This commit is contained in:
Mathieu Benoit 2026-10-06 23:05:57 -04:00
parent fa2fc0eb2b
commit 220814a673

View file

@ -0,0 +1,548 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le coût d'un enregistrement complet (§ 8.2, § 8.9, § 14.4) : la durée d'un
// geste qu'enregistrer (depot.js) écrit sur un vrai système de fichiers — le
// journal, le .precedent, l'état — pour la grande démonstration livrée
// portant vingt propositions, dont une retenue. Deux gestes se mesurent,
// chacun sous son seuil : le geste ordinaire, dont la ligne s'ajoute en fin
// de journal, et le geste dont l'entrée fait passer le journal au-delà de
// PLAFOND_ENTREES, qui le réécrit en entier, élagué. Le système de fichiers
// est celui de la plateforme electron : la page (fichiers_electron.js) devant
// les primitives de la coquille (electron/fichiers.js), sous un dossier
// temporaire neuf, retiré à la fin de l'épreuve ; seul l'IPC d'Electron y est
// remplacé, par une copie des arguments et des réponses.
//
// La charge mesurée se construit une fois pour le fichier (chargeMesuree) :
// le moteur produit ses vingt plans, et une épreuve affirme que l'analyse
// l'admet et que la forme canonique la réécrit telle quelle. Chaque geste
// mesuré retouche le placement retenu — deux personnes changent de table à
// un tour — ; son entrée porte un correctif qui touche deux listes de table,
// ou l'instantané de la charge à la cadence du journal. Après chaque geste,
// hors de la mesure, l'état et le .precedent sur le disque sont, octet pour
// octet, ceux que le geste écrit ; à la fin, le journal relu porte une
// entrée par révision et restitue la charge de révisions choisies : la durée
// retenue vient toujours d'un geste qui a écrit tout ce qu'écrit un
// enregistrement. Un geste qui n'écrirait plus les
// propositions, plus le .precedent ou plus le journal élagué mesurerait
// autre chose, et passerait sous le seuil (§ 14.2).
import assert from 'node:assert/strict';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { PREFIXE_CANAL, PRIMITIVES, creerFichiers, decrireSysteme, servir } from '../../electron/fichiers.js';
import { describe, test } from '../../test/lanceur.js';
import { texteLivre } from '../demo/livrees.js';
import { regenerer } from '../moteur/recherche.js';
import { VERSION } from '../version.genere.js';
import { canoniser, serialiser } from './canonique.js';
import { creer, enregistrer, ouvrir } from './depot.js';
import { analyser, configurationDepuisCharge } from './document.js';
import { creerFichiersElectron } from './fichiers_electron.js';
import {
INTERVALLE_INSTANTANE,
PLAFOND_ENTREES,
PLANCHER_ENTREES,
estInstantane,
ligneEntree,
lireJournal,
reconstruire,
} from './journal.js';
import { SUFFIXES } from './noms.js';
import { examiner, versFichier } from './placements.js';
const V = VERSION.affichee;
const NOM_PRODUIT = 'Gestion table tournante Libre';
// Les propositions de la charge mesurée, la vingtaine du § 8.9 sur la grande
// démonstration, aux graines 1 à PROPOSITIONS.
const PROPOSITIONS = 20;
// Le compte d'arrêt de chaque proposition, réduit à côté de celui des
// réglages : la taille d'un placement ne dépend que de sa forme, non de sa
// qualité, et la construction de la charge reste brève.
const ARRET_REDUIT = 2_000;
// L'échantillonnage, contre une charge passagère de la machine. Après un
// geste de chauffe, chaque épreuve mesure son nombre de base de gestes, et
// son seuil porte sur le plus court : le minimum écarte ce qu'une machine
// occupée ajoute à un geste, pourvu qu'un geste au moins s'exécute quand la
// charge se relâche. Tant que le plus court dépasse le seuil, l'épreuve
// mesure d'autres gestes, jusqu'à son plafond de gestes ou jusqu'à ce que
// FENETRE_MESURE_MS se soient écoulées depuis le premier geste mesuré, ce
// qui vient d'abord. Une charge plus courte que la fenêtre laisse ainsi
// passer un geste sous le seuil, et l'épreuve passe ; une régression, qui
// allonge chaque geste, le laisse au-delà à chaque geste, et l'épreuve
// échoue au plafond. Sous la charge que couvre MARGE, le nombre de base
// suffit : l'épreuve ne s'allonge qu'au-delà.
//
// GESTES_MESURES, le nombre de base des gestes ordinaires : vingt tiennent
// sous la charge que couvre MARGE, là où cinq n'y tiennent pas.
// GESTES_AU_PLUS, cinq fois autant, borne les gestes ordinaires mesurés.
const GESTES_MESURES = 20;
const GESTES_AU_PLUS = 100;
// ESSAIS_ELAGAGE, le nombre de base des essais du geste qui élague : cinq
// tiennent sous la charge que couvre MARGE. Chaque essai pose un journal
// plein, d'environ 2 Mo, et relit celui qu'il écrit. ESSAIS_ELAGAGE_AU_PLUS,
// cinq fois autant, borne les essais mesurés.
const ESSAIS_ELAGAGE = 5;
const ESSAIS_ELAGAGE_AU_PLUS = 25;
// La fenêtre de mesure, en millisecondes, au mur, vérifications hors mesure
// comprises : la charge passagère que l'échantillonnage écarte dure moins
// qu'elle. Elle borne aussi la durée d'une épreuve qui échoue : une mesure
// s'arrête au premier geste qui finit au-delà, et la fenêtre vaut un sixième
// du plafond de la série node-long (test/rapporteur.js).
const FENETRE_MESURE_MS = 10_000;
// Les seuils du coût d'un enregistrement complet (§ 8.2, § 14.1), en
// millisecondes, chacun au minimum de ses gestes mesurés, qui garde le coût
// du geste. Le § 8.2 réécrit l'état entier à chaque geste : c'est ce coût
// qui gouverne la forme du fichier (§ 8.9).
//
// SEUIL_MS borne le geste ordinaire : correctif depuis l'entrée qui précède,
// état sérialisé puis relu, ligne du journal ajoutée et vidée sur le disque,
// .precedent puis état réécrits par écriture atomique. L'entrée qui porte un
// instantané, une sur INTERVALLE_INSTANTANE, coûte autant : canoniser la
// charge y remplace le calcul du correctif, et sa ligne s'ajoute de même.
// SEUIL_ELAGAGE_MS borne le geste dont l'entrée fait passer le journal
// au-delà de PLAFOND_ENTREES : le journal se réécrit en entier — son texte
// recomposé, lu par l'élagage, puis relu une fois élagué — par une écriture
// atomique de la taille d'une dizaine d'instantanés de la charge, avant le
// .precedent et l'état. Le plafond atteint, ce geste revient environ une
// fois par centaine de gestes : l'élagage garde un peu plus de
// PLANCHER_ENTREES entrées. Les autres réécritures entières du journal —
// une entrée de reprise, des lignes écartées à la lecture, un journal qui
// manque (depot.js) — réparent un événement sorti du cours ordinaire des
// gestes, et ces épreuves ne les mesurent pas.
//
// REFERENCE_MS et REFERENCE_ELAGAGE_MS sont les minimums que relèvent ces
// épreuves, le fichier lancé seul, médiane de plusieurs lancements arrondie
// à la milliseconde supérieure, sur un processeur x86-64 de bureau de
// génération Zen 3, seize cœurs virtuels sous KVM, le dossier temporaire en
// mémoire (tmpfs), sous Linux et Node 26.
// MARGE, le facteur qui tire chaque seuil de sa référence, couvre, sur la
// machine des références, quatre séries node-long lancées ensemble —
// autant que de conteneurs simultanés par défaut sous make
// verifier_systemes (GTT_PARALLELES) —, chacune son dossier temporaire sur
// disque, où vider sur le disque s'ajoute au calcul. Sous cette charge, le
// plus court des GESTES_MESURES gestes ordinaires monte jusqu'à trois fois
// REFERENCE_MS, et le plus court des ESSAIS_ELAGAGE essais jusqu'à trois
// fois REFERENCE_ELAGAGE_MS : il reste un tiers de marge. MARGE ne couvre
// pas ce qu'un autre conteneur de make verifier_systemes peut exécuter en
// même temps — make test, npm ci, Chromium —, ni davantage de conteneurs à
// la fois, ni une machine plus lente d'un tiers sous la même charge : contre
// une telle charge, quand elle passe, l'échantillonnage mesure d'autres
// gestes jusqu'à son plafond. Elle reste assez basse pour qu'un état dont
// les placements pèsent dix fois plus, comme sous leur forme nommée (§ 8.9),
// dépasse chacun des deux seuils.
//
// Propriétaire (§ 14.1) : la série node-long, où ces épreuves relèvent la
// mesure à chaque exécution et la nomment quand elle dépasse son seuil. Le
// relevé sur la plateforme livrée — l'exécutable Windows, sous un antivirus
// qui inspecte chaque fichier temporaire — appartient au niveau manuel,
// avant livraison : tant qu'il n'a pas eu lieu, ces seuils sont des défauts
// de travail relevés sous Linux.
const REFERENCE_MS = 20;
const REFERENCE_ELAGAGE_MS = 87;
const MARGE = 4;
const SEUIL_MS = REFERENCE_MS * MARGE;
const SEUIL_ELAGAGE_MS = REFERENCE_ELAGAGE_MS * MARGE;
// Durée affichée, une décimale, virgule décimale.
const ms = (duree) => `${duree.toFixed(1).replace('.', ',')} ms`;
const croissant = (a, b) => a - b;
// Un nombre sur deux chiffres.
const deux = (n) => String(n).padStart(2, '0');
// Horodatage d'affichage de l'instant n, une minute par instant depuis 8 h ;
// il n'entre dans aucune mesure.
const heure = (n) => `2031-11-08T${deux(8 + Math.floor(n / 60))}:${deux(n % 60)}:00-05:00`;
const CREATION = { libelle: "Création de l'événement", nomJalon: 'Création', horodatage: heure(0), produitVersion: V };
const geste = (n) => ({ libelle: `Retouche ${n} du placement retenu`, horodatage: heure(n), produitVersion: V });
// Le retenu repris d'une proposition, dont l'identifiant devient l'origine ;
// son placement est une copie, qu'une retouche modifie sans toucher la
// proposition.
function retenuDe({ id, siegesAttribues, tables, capacites, tours, participants, placement }) {
return {
proposition: id,
siegesAttribues,
tables: [...tables],
capacites: [...capacites],
tours,
participants: [...participants],
placement: structuredClone(placement),
};
}
// La grande démonstration livrée, portant PROPOSITIONS propositions que le
// moteur produit aux graines 1 à PROPOSITIONS sous ARRET_REDUIT, numérotées
// depuis le compteur de la charge, qui passe au-delà de la dernière ; la
// première est retenue, et l'événement dans l'état retenu.
function construire() {
const charge = analyser(texteLivre('grande')).charge;
const configuration = configurationDepuisCharge(charge);
const { historique } = charge.reglages.generation;
const decalage = charge.prochainsIds.proposition - 1;
charge.propositions = Array.from({ length: PROPOSITIONS }, (_, rang) => {
const graine = rang + 1;
const plan = regenerer(configuration, { graine, arret: ARRET_REDUIT, historique });
return versFichier({ id: graine, graine, arret: ARRET_REDUIT, historique, plan }, configuration, {
produitVersion: V,
attribuerSieges: charge.reglages.attribuerSieges,
decalage,
});
});
charge.prochainsIds.proposition = decalage + PROPOSITIONS + 1;
charge.retenu = retenuDe(charge.propositions[0]);
charge.evenement.etat = 'retenu';
return charge;
}
// La charge mesurée, construite au premier appel et partagée par les
// épreuves du fichier, qui ne la modifient pas.
let construite = null;
const chargeMesuree = () => (construite ??= construire());
// La charge reçue, son placement retenu retouché au tour d'index tour : la
// première personne sans réservation de la première table et celle de la
// deuxième changent de table, comme une retouche à la main (§ 5.8). Une
// personne réservée ne s'échange pas, ce que le geste refuserait (§ 5.9).
// Les sièges n'étant pas attribués, chaque liste reste triée par
// identifiant (§ 8.9).
function echanger(charge, tour) {
const apres = structuredClone(charge);
const reservees = new Set(charge.reservations.map(({ participant }) => participant));
const [premiere, deuxieme] = apres.retenu.placement[tour].sieges;
const a = premiere.find((id) => !reservees.has(id));
const b = deuxieme.find((id) => !reservees.has(id));
assert.ok(a !== undefined && b !== undefined, `tour ${tour + 1} : aucune personne à échanger`);
premiere.splice(premiere.indexOf(a), 1, b);
deuxieme.splice(deuxieme.indexOf(b), 1, a);
premiere.sort(croissant);
deuxieme.sort(croissant);
return apres;
}
// Les textes d'un événement dont le journal est plein, à PLAFOND_ENTREES
// entrées : le journal que creer a écrit pour la charge a — ouverture,
// entrée 1 en instantané, jalon de la création —, suivi des entrées 2 à
// PLAFOND_ENTREES, écrites ligne à ligne plutôt que par autant de gestes
// enregistrés. Les charges alternent : a aux révisions impaires, b, son
// retenu retouché au premier tour, aux paires. Chaque ligne a la forme que
// ligneEntree donne à son passage — instantané à la cadence du journal
// (estInstantane), correctif de a vers b ou de b vers a sinon —, calculée
// une fois par sorte de passage, sous le libellé et l'horodatage de sa
// révision : les clés remplacées gardent leur place, et chaque ligne est
// celle qu'écrirait ligneEntree. Rend le journal, l'état à la dernière
// révision, le .precedent à celle d'avant, et chargeDe, la charge d'une
// révision.
function evenementPlein(journalCree, a) {
const b = echanger(a, 0);
const chargeDe = (revision) => (revision % 2 === 1 ? a : b);
const formes = new Map();
const lignes = [];
for (let revision = 2; revision <= PLAFOND_ENTREES; revision += 1) {
const { libelle, horodatage } = geste(revision);
const instantane = estInstantane(revision, 1);
const sorte = `${instantane} ${revision % 2}`;
if (!formes.has(sorte)) {
const ligne = ligneEntree({
revision,
libelle,
horodatage,
avant: chargeDe(revision - 1),
apres: chargeDe(revision),
instantane,
});
formes.set(sorte, JSON.parse(ligne));
}
lignes.push(JSON.stringify({ ...formes.get(sorte), revision, libelle, horodatage }));
}
const etat = (revision) => serialiser(chargeDe(revision), { revision, produitVersion: V });
return {
journal: `${journalCree}${lignes.join('\n')}\n`,
etat: etat(PLAFOND_ENTREES),
precedent: etat(PLAFOND_ENTREES - 1),
chargeDe,
};
}
// Les dialogues et l'explorateur ne servent pas à un enregistrement : un
// appel lèverait TypeError.
const SANS_DIALOGUE = Object.freeze({});
// Le système de fichiers de la plateforme electron sur dossier : la page
// (creerFichiersElectron) devant les primitives de la coquille
// (creerFichiers), que servir offre canal par canal, comme le processus
// principal. L'IPC d'Electron y est remplacé par une copie structurée des
// arguments et de la réponse, comme il les copie. Les Documents et l'AppData
// du poste sont des dossiers de dossier ; rend le système et la racine
// documents, créée.
async function systemeSur(dossier) {
const systeme = decrireSysteme({
env: {},
plateforme: process.platform,
cheminSysteme: (nom) => (nom === 'temp' ? tmpdir() : join(dossier, nom)),
nomProduit: NOM_PRODUIT,
});
const gestionnaires = new Map();
servir(creerFichiers({ ...systeme, dialog: SANS_DIALOGUE, shell: SANS_DIALOGUE }), (canal, gestionnaire) =>
gestionnaires.set(canal, gestionnaire),
);
const pont = Object.fromEntries(
PRIMITIVES.map((primitive) => {
const gestionnaire = gestionnaires.get(`${PREFIXE_CANAL}${primitive}`);
const appeler = async (...parametres) =>
structuredClone(await gestionnaire({ sender: null }, ...structuredClone(parametres)));
return [primitive, appeler];
}),
);
const fs = creerFichiersElectron(pont);
const { documents } = await fs.racines();
mkdirSync(documents.chemin, { recursive: true });
return { fs, racine: documents };
}
// Chemin, sur le disque, du fichier de suffixe de l'événement base sous
// racine.
const cheminDe = (racine, base, suffixe) => join(racine.chemin, `${base}${suffixe}`);
// Texte du fichier chemin, ou null quand il manque.
const lireSiPresent = (chemin) => (existsSync(chemin) ? readFileSync(chemin, 'utf8') : null);
// Révision de l'en-tête d'un texte d'état, ou null sans texte.
const revisionDe = (texte) => (texte === null ? null : analyser(texte).entete.revision);
// Affirme que le disque porte ce qu'écrit le geste de la révision revision :
// l'état au texte etat, le .precedent au texte precedent, celui de l'état
// d'avant le geste. Les révisions des en-têtes se comparent d'abord, null
// pour un fichier qui manque : un fichier que le geste n'a pas écrit se
// nomme par la révision qu'il garde, avant toute différence de deux textes.
function affirmerEcrits(racine, base, revision, { etat, precedent }) {
const ecrits = {
etat: lireSiPresent(cheminDe(racine, base, SUFFIXES.etat)),
precedent: lireSiPresent(cheminDe(racine, base, SUFFIXES.precedent)),
};
assert.deepEqual(
{ etat: revisionDe(ecrits.etat), precedent: revisionDe(ecrits.precedent) },
{ etat: revision, precedent: revision - 1 },
`révisions de l'état et du .precedent sur le disque après le geste de la révision ${revision}`,
);
assert.ok(ecrits.etat === etat, `l'état sur le disque n'est pas celui qu'écrit le geste de la révision ${revision}`);
assert.ok(
ecrits.precedent === precedent,
`le .precedent sur le disque n'est pas l'état d'avant le geste de la révision ${revision}`,
);
}
// La mesure échantillonnée d'un geste (GESTES_MESURES) : faire exécute un
// geste et ses vérifications, et rend la durée du seul geste. Un premier
// appel chauffe, sans compter ; suivent base gestes mesurés, puis d'autres
// tant que le plus court dépasse seuil, jusqu'à auPlus gestes mesurés ou la
// fin de FENETRE_MESURE_MS, comptée depuis le premier. Rend les durées, dans
// l'ordre des gestes, et arret, ce qui a clos une mesure restée au-delà du
// seuil.
async function mesurer(faire, { base, auPlus, seuil }) {
await faire();
const durees = [];
const debut = performance.now();
const ouverte = () => performance.now() - debut < FENETRE_MESURE_MS;
while (durees.length < base || (Math.min(...durees) > seuil && durees.length < auPlus && ouverte())) {
durees.push(await faire());
}
const arret =
durees.length >= auPlus
? `plafond de ${auPlus} mesures atteint`
: `fenêtre de ${FENETRE_MESURE_MS / 1000} s écoulée`;
return { durees, arret };
}
// Le message d'un seuil dépassé : le minimum, le nombre de mesures, leur
// médiane et la plus longue, ce qui a clos la mesure, le seuil, et la
// référence dont MARGE le tire.
function depasse(quoi, { durees, arret }, seuil, reference) {
const triees = [...durees].sort(croissant);
return (
`${quoi} s'enregistre en ${ms(triees[0])} au minimum de ${durees.length} mesures ` +
`(médiane ${ms(triees[Math.floor((triees.length - 1) / 2)])}, la plus longue ${ms(triees.at(-1))} ; ` +
`${arret}), au-delà du seuil de ${ms(seuil)} (§ 8.2) : ${reference}, MARGE ${MARGE}`
);
}
describe("dépôt : le coût d'un enregistrement complet, sur un vrai système de fichiers (§ 8.2)", () => {
test("la charge mesurée est la grande démonstration portant vingt propositions du moteur, la première retenue : l'analyse l'admet, la forme canonique la réécrit octet pour octet, et aucune proposition n'est fautive ni périmée", () => {
const texte = serialiser(chargeMesuree(), { revision: 1, produitVersion: V });
const lu = analyser(texte);
const { participants, propositions, retenu } = lu.entete.comptes;
assert.deepEqual(
{ participants, propositions, retenu },
{ participants: 260, propositions: PROPOSITIONS, retenu: 1 },
);
assert.deepEqual(
lu.charge.propositions.map(({ graine }) => graine),
Array.from({ length: PROPOSITIONS }, (_, rang) => rang + 1),
);
assert.equal(serialiser(lu.charge, lu.entete), texte);
const { fautives, derives, retenu: examenRetenu } = examiner(lu.charge);
assert.deepEqual(
{ fautives, derives, retenu: examenRetenu },
{ fautives: [], derives: [], retenu: { fautes: [], raisons: [] } },
);
});
test(`un geste qui retouche le placement retenu s'enregistre — journal, .precedent, état — en au plus ${ms(SEUIL_MS)}, au minimum de ${GESTES_MESURES} à ${GESTES_AU_PLUS} mesures après un geste de chauffe ; chaque geste laisse sur le disque l'état et le .precedent qu'il écrit, et l'événement rouvert est celui du dernier geste, dont le journal porte chaque révision`, async () => {
const dossier = mkdtempSync(join(tmpdir(), 'gtt-cout-'));
try {
const { fs, racine } = await systemeSur(dossier);
let ouvert = await creer(fs, racine, chargeMesuree(), CREATION);
// La charge de chaque révision, depuis la création, et le texte de
// l'état à la dernière. Chaque geste retouche le tour qui suit celui du
// geste d'avant.
const charges = [chargeMesuree()];
let texteEtat = serialiser(chargeMesuree(), { revision: 1, produitVersion: V });
const faire = async () => {
const revision = charges.length + 1;
const avant = charges.at(-1);
const apres = echanger(avant, (revision - 2) % avant.evenement.tours);
const debut = performance.now();
ouvert = await enregistrer(ouvert, apres, geste(revision));
const duree = performance.now() - debut;
const texteApres = serialiser(apres, { revision, produitVersion: V });
assert.equal(ouvert.revision, revision);
affirmerEcrits(racine, ouvert.base, revision, { etat: texteApres, precedent: texteEtat });
charges.push(apres);
texteEtat = texteApres;
return duree;
};
const mesure = await mesurer(faire, { base: GESTES_MESURES, auPlus: GESTES_AU_PLUS, seuil: SEUIL_MS });
// L'événement se rouvre sans rien rattraper ni signaler, à la charge
// du dernier geste ; l'état sur le disque porte les vingt propositions
// et le retenu ; le journal porte une entrée par révision.
const revision = charges.length;
const rouvert = await ouvrir(fs, racine, ouvert.base);
assert.deepEqual(rouvert.avertissements, []);
assert.equal(rouvert.revision, revision);
assert.deepEqual(rouvert.charge, canoniser(charges.at(-1)));
const etat = analyser(readFileSync(cheminDe(racine, ouvert.base, SUFFIXES.etat), 'utf8'));
const { propositions, retenu } = etat.entete.comptes;
assert.deepEqual({ propositions, retenu }, { propositions: PROPOSITIONS, retenu: 1 });
const journal = lireJournal(readFileSync(cheminDe(racine, ouvert.base, SUFFIXES.journal), 'utf8'));
assert.deepEqual(
journal.entrees.map((entree) => entree.revision),
Array.from({ length: revision }, (_, rang) => rang + 1),
);
// Le journal restitue la charge de chaque révision jusqu'au dernier des
// GESTES_MESURES gestes de base, puis celle de la dernière.
// Reconstruire une révision rejoue chaque correctif depuis l'instantané
// qui la précède : le coût de relire chaque révision croît comme le
// carré du nombre de gestes entre deux instantanés. Les gestes de base,
// en nombre fixe, se relisent donc tous, et les gestes de plus de
// l'échantillonnage par le dernier. Un correctif faux se voit à sa
// révision : un geste ultérieur qui récrit les mêmes listes de table
// l'efface des suivantes.
const relues = Array.from({ length: GESTES_MESURES + 2 }, (_, rang) => rang + 1);
if (revision > relues.length) relues.push(revision);
for (const relue of relues) {
assert.deepEqual(reconstruire(journal, relue), canoniser(charges[relue - 1]), `révision ${relue}`);
}
assert.ok(
Math.min(...mesure.durees) <= SEUIL_MS,
depasse('un geste sur la grande démonstration', mesure, SEUIL_MS, `REFERENCE_MS ${REFERENCE_MS} ms`),
);
} finally {
rmSync(dossier, { recursive: true, force: true });
}
});
test(`le geste dont l'entrée fait passer le journal au-delà de ${PLAFOND_ENTREES} entrées le réécrit élagué — journal, .precedent, état — en au plus ${ms(SEUIL_ELAGAGE_MS)}, au minimum de ${ESSAIS_ELAGAGE} à ${ESSAIS_ELAGAGE_AU_PLUS} essais après un essai de chauffe, chacun sur une copie neuve du journal plein ; le journal écrit part de l'instantané de la coupe et restitue le geste`, async () => {
const dossier = mkdtempSync(join(tmpdir(), 'gtt-elagage-'));
try {
const { fs, racine } = await systemeSur(dossier);
const { base } = await creer(fs, racine, chargeMesuree(), CREATION);
const chemin = (suffixe) => cheminDe(racine, base, suffixe);
const plein = evenementPlein(readFileSync(chemin(SUFFIXES.journal), 'utf8'), chargeMesuree());
const poser = () => {
writeFileSync(chemin(SUFFIXES.journal), plein.journal);
writeFileSync(chemin(SUFFIXES.precedent), plein.precedent);
writeFileSync(chemin(SUFFIXES.etat), plein.etat);
};
// L'événement plein s'ouvre sans rien signaler, son journal à
// PLAFOND_ENTREES entrées. enregistrer ne modifie pas l'Ouvert reçu :
// chaque essai part du même, sur le journal plein reposé, la chauffe
// comprise.
poser();
const ouvert = await ouvrir(fs, racine, base);
assert.deepEqual(
{ avertissements: ouvert.avertissements, revision: ouvert.revision, entrees: ouvert.journal.entrees.length },
{ avertissements: [], revision: PLAFOND_ENTREES, entrees: PLAFOND_ENTREES },
);
// La coupe de l'élagage : l'instantané le plus récent — révision 1,
// puis toutes les INTERVALLE_INSTANTANE — d'où le journal garde au
// moins PLANCHER_ENTREES entrées jusqu'au geste.
const revision = PLAFOND_ENTREES + 1;
const coupe = 1 + Math.floor((revision - PLANCHER_ENTREES) / INTERVALLE_INSTANTANE) * INTERVALLE_INSTANTANE;
const apres = echanger(plein.chargeDe(PLAFOND_ENTREES), 1);
const texteApres = serialiser(apres, { revision, produitVersion: V });
let essai = 0;
let journal = null;
const faire = async () => {
poser();
const debut = performance.now();
await enregistrer(ouvert, apres, geste(revision));
const duree = performance.now() - debut;
affirmerEcrits(racine, base, revision, { etat: texteApres, precedent: plein.etat });
journal = lireJournal(readFileSync(chemin(SUFFIXES.journal), 'utf8'));
assert.deepEqual(
{
premiere: journal.entrees[0].revision,
entrees: journal.entrees.length,
jalons: journal.jalons.length,
ecartees: journal.ecartees,
},
{ premiere: coupe, entrees: revision - coupe + 1, jalons: 0, ecartees: 0 },
`journal écrit par l'essai ${essai}`,
);
assert.deepEqual(reconstruire(journal, revision), canoniser(apres));
essai += 1;
return duree;
};
const mesure = await mesurer(faire, {
base: ESSAIS_ELAGAGE,
auPlus: ESSAIS_ELAGAGE_AU_PLUS,
seuil: SEUIL_ELAGAGE_MS,
});
// Le journal élagué restitue encore l'instant d'avant le geste, et
// l'événement se rouvre sur le geste sans rien rattraper ni signaler.
assert.deepEqual(reconstruire(journal, PLAFOND_ENTREES), canoniser(plein.chargeDe(PLAFOND_ENTREES)));
const rouvert = await ouvrir(fs, racine, base);
assert.deepEqual(
{ avertissements: rouvert.avertissements, revision: rouvert.revision },
{ avertissements: [], revision },
);
assert.deepEqual(rouvert.charge, canoniser(apres));
assert.ok(
Math.min(...mesure.durees) <= SEUIL_ELAGAGE_MS,
depasse(
'le geste qui élague le journal plein',
mesure,
SEUIL_ELAGAGE_MS,
`REFERENCE_ELAGAGE_MS ${REFERENCE_ELAGAGE_MS} ms`,
),
);
} finally {
rmSync(dossier, { recursive: true, force: true });
}
});
});