Le cycle de vie
Une valeur de configuration est de l'une ou l'autre sorte, et tout ce qui suit en découle :
Une valeur CONSULTÉE peut changer sous une app qui tourne. Une valeur CONSOMMÉE pour construire quelque chose ne le peut pas, sans reconstruire ce qu'elle a construit.
logLevel est consulté à chaque émission. db a ouvert une connexion, ports: a
enregistré une classe, les entités ont construit des façades — ceux-là ont été consommés.
La montée
dispose() a toujours eu une forme : ordre inverse, uniquement ce qu'il a construit. La
montée n'en avait aucune — elle vivait à quatre endroits sous le nom afterBoot, avec deux
sens différents, et un hôte qui voulait son propre seeding devait s'approprier TOUT
l'après-boot pour l'obtenir.
La paire est donc une seule valeur. Une extension énonce ce qu'elle fait à une app et ce qu'elle défait :
const app = await createApp({
root,
createContainer,
extensions: [observability()],
});
up tourne avant que createApp ne rende la main, dans l'ordre déclaré ; down tourne
dans dispose(), en ordre inverse. C'est aussi le seul point du boot qui peut attendre,
et c'est là qu'appartient un provider qui doit ouvrir quelque chose — un constructeur ne
peut pas.
Deux membres sont ceux du framework, et ce sont des membres ordinaires :
| nom | ce qu'il fait |
|---|---|
migrate | met le schéma à jour — les tables avant les lignes. Le créneau est déclaré même quand rien ne migre, pour qu'un hôte qui le remplit REMPLACE ce membre au lieu d'atterrir après les seeds |
seeds | plante ce que seeds/ déclare, dans l'ordre des dépendances |
Un nom déjà déclaré est remplacé, pas refusé. C'est ainsi qu'un hôte dit le seeding, mais le mien :
// ce que @fougere/nuxt génère : un bundler veut ses modules de seed en imports statiques
extensions: [
migrating(storage.migrate),
{ name: 'seeds', up: (app) => runSeeds(app, [/* importés au-dessus */]) },
]
La montée et la descente refusent de façons opposées, exprès :
ups'arrête au premier refus. Un seed qui suppose qu'une migration a tourné ne doit pas tourner quand elle n'a pas tourné, et une app à moitié démarrée ne doit pas être rendue.downrelâche chaque membre même si l'un jette, et les refus partent ensemble dans unAggregateError— un relâchement qui abandonne au premier échec fuit tout ce qui suit.
Une extension n'est pas un Frond et ne peut pas en devenir un : un Frond a des entités et
peut partir derrière remotes:, alors qu'une extension appartient au processus qui les
héberge. observability() est le cas qui le prouve — déplacée, elle rapporterait
l'observateur au lieu de l'observé.
Relire la configuration
applyConfig est le seul endroit qui répond à ce qu'une relecture change dans une app qui
tourne. Il applique ce qui est consulté et signale le reste plutôt que de l'ignorer :
// l'hôte possède le process, donc l'hôte possède le signal — Fougere n'en attrape aucun
process.on('SIGHUP', async () => {
const next = await loadConfig(root, { fresh: true });
const { applied, pending } = applyConfig(next, inForce);
inForce = next;
// applied : ['logLevel: warn → debug']
// pending : ['db'] — changé dans le fichier, et il a ouvert une connexion
});
{ fresh: true } n'est pas décoratif : un module est mis en cache par son specifier, donc
une deuxième lecture d'un fichier modifié rend la première.
La liste des clés consultées n'est déclarée nulle part — une clé est consultée quand
applyConfig en fait quelque chose, la seule définition qui ne peut pas devenir périmée.
Aujourd'hui c'est logLevel, et un logger construit avant le changement l'applique,
enfants compris : le niveau est tenu une fois pour le process au lieu d'être recopié dans
chaque instance.
Tourner l'anneau
Pour tout le reste, la valeur ne peut pas bouger sous ce qu'elle a construit — donc la chose est reconstruite. Une app est instanciée, la précédente laissée partir :
await reloadFougere(); // instancier, drainer l'ancienne, la relâcher
Ça marche parce que chaque porte atteint l'app par useFougereApp() à l'intérieur de la
requête qu'elle sert, et qu'aucune ne la garde d'une requête à l'autre. La nouvelle est
debout avant que l'ancienne soit relâchée, donc un boot raté laisse la précédente servir.
Drainer
Relâcher une app ferme sa connexion de stockage et tous les scopes de Frond — c'est-à-dire ce sur quoi les appels en cours reposent. Donc on les attend :
await app.drain(); // fermer la porte, attendre les appels en cours
await app.dispose(); // puis fermer les scopes et la connexion
app.inFlight() en est le compte, pris au seul chemin que les trois portes et le fil
partagent, donc un seul nombre les couvre. Un appel imbriqué compte deux fois — l'app n'est
pas au repos tant qu'une op interne tourne.
Deux refus, tous deux délibérés :
drain(timeoutMs)refuse à son échéance, en nommant combien d'appels restent, plutôt que de répondre comme si ça avait marché. Son appelant s'apprête à fermer une connexion sous ce qui reste : la décision lui revient.- Un appel qui arrive après la fermeture de la porte est refusé en
SERVICE_UNAVAILABLE. La poignée pointe déjà sur la nouvelle app, donc cet appelant avait gardé une référence à travers le tour.
Attendre et relâcher restent deux gestes : un script relâche tout de suite, un hôte qui tourne l'anneau draine d'abord.
S'arrêter
Les deux mêmes gestes répondent à SIGTERM, et rien d'autre n'est nécessaire :
process.on('SIGTERM', async () => {
const app = await useFougereApp();
await app.drain(10_000);
await app.dispose();
process.exit(0);
});
dispose() relâche en ordre inverse de la construction, à trois niveaux : le down de
chaque extension (construite en dernier), puis le conteneur — chaque scope jetant les scopes
qu'il a ouverts, du plus profond d'abord, puis ce qu'il a construit lui-même — puis la
connexion de stockage qu'on lui avait remise, ouverte avant que tout cela existe. Un provider qui tient quelque chose le dit en
ayant une méthode dispose() — rien ne le déclare, le conteneur cherche la méthode.
Les échecs voyagent ensemble dans un AggregateError plutôt que le premier ne fasse taire
les autres : tout est prévenu de se fermer.
Ce qui n'est pas ici
Fougere n'attrape aucun signal. Un process appartient à son hôte, et le logger qu'il livre
tourne sur Cloudflare Workers, où process.on n'existe pas.
Retour aux sources, ou aux ports
qu'une ligne ports: départage.