Az AsyncLocalStorage nem a requestet követi – csak az async láncot

Az AsyncLocalStorage request contextet ad a Node.js-ben, de nem az eseményre feliratkozást, hanem az aszinkron láncot követi. Megnézzük, miért tűnik el egy megosztott EventEmitter listenerében a request ID, és mikor segít a bind().

Van az a hibajegy, amelyben a logokból pont az a request ID hiányzik, amelyikre szükség lenne. A handler elején még ott volt, két await-tel később meg eltűnt, mint a felelős a retrospektíven. A Node.js AsyncLocalStorage-a erre ad kényelmes megoldást: kérésenként eltehetjük a trace ID-t, a tenantot vagy a felhasználó azonosítóját, és a mélyen hívott logger nem kap plusz tíz paramétert.

Csakhogy az AsyncLocalStorage nem a HTTP-kérést követi. Azokat az aszinkron erőforrásokat követi, amelyeket az aktív contextben hoztunk létre. A különbség nem nyelvészkedés: egy megosztott EventEmitter, queue vagy saját callback-API mellett ettől lesz a kontextus hiányzó, rosszabb esetben pedig egy másik kérésé.

Sematikus adatfolyam két különböző kérés kontextusával: az egyik ág egy eseménykezelőn át megszakad, a másik kötött callbackkel megőrzi a kapcsolatot.

A jó eset tényleg ennyire kényelmes

Az AsyncLocalStorage a node:async_hooks része, és stabil API. A run() egy store-t rendel a callbackhez; a callbackben létrehozott promise-ok, timer-ek és a belőlük továbbinduló munka ugyanazt a store-t látja. Ez a Node-féle thread-local storage megfelelője, csak itt nem thread, hanem aszinkron végrehajtási lánc van.

Egy HTTP belépési ponton általában elég egyszer meghívni: context.run({ requestId }, () => handler(req, res)). Innen a context.getStore()?.requestId elérhető az adatbázisrétegben, a loggerben és a hibakezelőben is. Nem kell globális változóval zsonglőrködni, ami Node-nál nagy terhelésen nagyjából olyan, mintha minden vendég kabátját egy közös kupacba dobnánk.

Fontos részlet: a store objektumát kérésenként hozzuk létre, és kezeljük olvasható contextként. Ha egy megosztott objektumot módosítunk benne, az AsyncLocalStorage nem ad hozzá mutexet, mély másolatot vagy erkölcsi felügyeletet.

A törés nem az await-nél van

Az alábbi kódot Node 22.22.3-mal futtattam. A listener az egyik esetben az emit() hívásakor aktív contextben fut; a másiknál a regisztráláskor érvényes contextet köti magához.

import { AsyncLocalStorage } from 'node:async_hooks';
import { EventEmitter } from 'node:events';

const context = new AsyncLocalStorage();
const bus = new EventEmitter();

context.run({ requestId: 'plain' }, () => {
  bus.once('plain', () => console.log('plain:', context.getStore()));
});

context.run({ requestId: 'bound' }, () => {
  bus.once('bound', AsyncLocalStorage.bind(() => {
    console.log('bound:', context.getStore());
  }));
});

setImmediate(() => {
  bus.emit('plain');
  bus.emit('bound');
});

// plain: undefined
// bound: { requestId: 'bound' }

Az EventEmitter a listenereket szinkron hívja meg abban a contextben, amelyben az emit() történik. A setImmediate itt a két run()-on kívül jött létre, ezért a sima listenerben nincs store. Na, ezt nem tudtam: nem az számít, hol iratkoztunk fel az eseményre, hanem hogy milyen aszinkron erőforrásból érkezett az esemény.

Az AsyncLocalStorage.bind() erre a rövid, célzott javítás. A függvényt a pillanatnyi végrehajtási contexthez köti, tehát a második listener akkor is a bound store-t látja, amikor a busz később, máshonnan hívja meg. A metódus Node 22.15.0-tól stabil; régebbi támogatott Node-nál ugyanezt egy saját closure-rel, vagy alacsonyabb szinten az AsyncResource-szal lehet megoldani.

Hol bukkan fel ez a valóságban?

Nem a framework saját promise-lánca a gyanús: Express-, Fastify- és adatbázis-kliens kódnál az alap eset általában működik. A határ a saját aszinkron absztrakciónk: processz-szintű EventEmitter, connection pool callbackje, üzenetfogyasztó, régi callbackes SDK vagy egy házilag összerakott job scheduler. Ezek közül nem mind akar és nem is tud egyetlen HTTP-kéréshez tartozni.

Ezért egy globális eseményre ne vigyük rá vakon az aktuális request contextet. Ha a busz egy rendszerállapot-változást közvetít, a listenerhez kötött eredeti request ID hasznos lehet naplózáshoz, de félrevezető lehet üzleti döntéshez. A context legyen megfigyelhetőségi adat, ne rejtett jogosultsági csatorna. A felhasználót, tenantot és tranzakciós határt továbbra is expliciten adjuk át oda, ahol az integritás múlik rajta.

run(), nem enterWith()

A enterWith() csábító, mert nem kell callbacket köré építeni, de a jelenlegi szinkron végrehajtás hátralévő részére is bekapcsolja a store-t, és az abból indított aszinkron munkára is továbbviszi. Egy EventEmitter több listenerénél ez könnyen olyan contextet hagy maga után, amelyet senki nem kért. A Node dokumentációja ezért is a run()-t javasolja, ha nincs nagyon konkrét okunk másra.

Productionben használnám: request ID, trace context és strukturált logolás esetén kifejezetten jó eszköz, mert eltünteti a paraméterlisták egy teljes kategóriáját. De a belépési ponton lezárnám run()-nal, az idegen vagy megosztott callback-határokat teszttel ellenőrizném, és csak ott kötném meg a callbacket, ahol az eredeti kéréshez tartozás valódi jelentéssel bír. Az AsyncLocalStorage nem telepátia; rendes async lánc kell hozzá.

Források

Leave a Reply

Az e-mail címet nem tesszük közzé. A kötelező mezőket * karakterrel jelöltük