Az IndexedDB-tranzakció nem vár rád: egy rossz await és már bezárt

Az IndexedDB-tranzakciót nem lehet hálózati várakozással nyitva tartani. Egy rossz helyre tett await miatt a következő írás már inaktív tranzakcióra érkezhet.

Az IndexedDB-t sokan úgy kezelik, mintha egy kicsit kényelmetlen, de azért rendes adatbázis lenne a böngészőben. Aztán jön egy offline mentés, egy szerverről kért token vagy egy gyors await fetch(), és a következő put() már TransactionInactiveError-ral dől el. Nem a hálózat a hibás, és nem is az a gép, amelyikre persze „nálam működött”. A tranzakció közben csendben befejeződött.

Ez nem IndexedDB-ízű véletlen: az API tranzakciói szándékosan rövid, eseményciklushoz kötött egységek. Ha lokális adatból számolsz új értéket, majd a szervertől még kérsz valamit, nem kapsz egy kényelmes, hosszan nyitva tartott SQL-tranzakciót. És ez fontosabb különbség, mint amilyennek elsőre hangzik.

Az IndexedDB-tranzakció életciklusa: egy hálózati várakozás után lezárt, illetve rövid, sikeresen végrehajtott írási szakasz sematikus ábrája.

A tranzakció nem egy hosszú async függvény

Az IndexedDB specifikációja szerint új kérést akkor tehetsz a tranzakcióba, amikor az aktív. Kezdetben aktív, majd egy hozzá tartozó kérés siker- vagy hibaeseményének kezelése alatt ismét az. Ha nincs függő kérés, a vezérlés visszakerül az eseményciklushoz, és nem bővítetted tovább, a tranzakció inaktívvá válhat, majd automatikusan commitol. Az MDN útmutatója ezt meglepően őszintén fogalmazza: a tranzakció élettartama szorosan az event loophoz kötött.

Ezért működik az, hogy egy IndexedDB-kérés eredményét await-eled, majd ugyanabban a folytatásban új kérést indítasz. A kérés még „életben tartja” a tranzakciót. Viszont egy hálózati kérés, időzítő, animációs frame, üzenet egy workertől vagy bármi más, nem IndexedDB-s aszinkron munka alatt nincs ilyen függő IDB-kérés. Mire visszaérsz, már elkéstél.

const tx = db.transaction("drafts", "readwrite");
const store = tx.objectStore("drafts");

const draft = await requestAsPromise(store.get(id)); // ez még rendben van
const policy = await fetch("/api/editor-policy").then((r) => r.json());

store.put({ ...draft, policy }); // TransactionInactiveError lehet

Na, ezt nem tudtam, amikor először belefutottam: nem az számít, hogy ugyanabban az async függvényben vagy-e. Az számít, hogy a böngésző kapott-e közben alkalmat lezárni a tranzakciót. Az idb könyvtár dokumentációja is külön kiemeli ezt a csapdát; a promise-os API nem írja át az IndexedDB időmodelljét.

A jó minta: előbb a lassú világ, utána a rövid írás

Az egyszerű szabály: hálózati és egyéb külső await legyen a tranzakción kívül. Utána nyiss rövid readwrite tranzakciót, indítsd el benne az összetartozó írásokat, végül a tranzakció végét várd meg. Egy offline szerkesztő például együtt mentheti a lokális dokumentumot és a későbbi szinkronhoz tartozó outbox-bejegyzést:

function transactionDone(tx) {
  return new Promise((resolve, reject) => {
    tx.addEventListener("complete", resolve, { once: true });
    tx.addEventListener("abort", () => reject(tx.error), { once: true });
  });
}

async function saveDraft(db, id) {
  const policy = await fetch("/api/editor-policy").then((r) => r.json());

  const tx = db.transaction(["drafts", "outbox"], "readwrite");
  const drafts = tx.objectStore("drafts");
  const outbox = tx.objectStore("outbox");

  drafts.put({ id, policy, savedAt: Date.now() });
  outbox.add({ type: "sync-draft", id, policy });

  await transactionDone(tx);
}

Az egyes put()/add() hívásokhoz külön kérésobjektum tartozik; ha hibára pontosan szeretnél reagálni, azokat is kezeld. A végső sikerjelzés mégis a tranzakció complete eseménye: csak ekkor tudod, hogy az egység egésze nem abortált. Az explicit tx.commit() sem varázsragasztó: gyorsabban elindíthatja a lezárást, de nem tartja nyitva a tranzakciót egy fetch() idejére.

Ha a szerver válaszához kell a lokális adat

Itt szokott előkerülni a vágy, hogy „akkor tartsuk nyitva, amíg visszaér a szerver”. Ne. Egyrészt nem erre való, másrészt a readwrite tranzakciók ugyanazon object store-on sorosíthatók; az MDN szerint egy store-hoz egyszerre csak egy ilyen írási tranzakció fut. Egy percekig hálózatra váró tranzakció pont úgy hiányzott a UI-ból, mint egy villogó loading overlay 2012-ből.

Inkább két fázisban gondolkodj: egy rövid readonly olvasás, aztán a hálózati kérés, majd egy rövid readwrite tranzakció. Ha az első olvasás alapján számított válasz versenyezhet másik fül vagy worker módosításával, a második fázisban olvasd vissza a rekord verzióját, és csak egyezéskor írj. Ez nem egyetlen, böngészőn átívelő atomikus tranzakció — olyan nincs —, de legalább láthatóvá teszi a versenyhelyzetet ahelyett, hogy egy véletlen await mögé rejtené.

Mikor használnám?

Offline-first felületen, kliensoldali outboxnál, letöltési metaadatoknál és összetartozó cache-bejegyzéseknél igen: egy rövid IndexedDB-tranzakció jó eszköz arra, hogy a kapcsolódó lokális változások együtt történjenek meg vagy egyik se. A tranzakción belüli hálózati workflow-ra viszont nem használnám productionben. A legtöbb ilyen kód valójában túlkomplikált kétfázisú folyamat, amelyet egy túl hosszú tranzakciónak öltöztettek fel. Vedd le róla a jelmezt: a lassú munkát intézd előbb, az adatbázisban pedig írj gyorsan, egyben, majd várd meg a complete-et.

Források

Leave a Reply

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