Nebezpečný Promise.all
Promise.all je skvělý způsob, jak paralelizovat vícenásobné operace. Obzvláště se hodí, když potřebujeme vytvořit paralelní požadavky na více služeb.
Skrývá se v něm však nebezpečí. V této úloze uvidíme jeho příklad a prozkoumáme, jak se mu vyhnout.
Dejme tomu, že máme připojení ke vzdálené službě, například k databázi.
Máme pro něj dvě funkce: připoj() a odpoj().
Když se připojíme, můžeme posílat požadavky voláním databáze.dotaz(...) – asynchronní funkce, která obvykle vrátí výsledek, ale může také vygenerovat chybu.
Jednoduchá implementace:
let databáze;
function připoj() {
databáze = {
async dotaz(jeOk) {
if (!jeOk) throw new Error('Dotaz selhal');
}
};
}
function odpoj() {
databáze = null;
}
// zamýšlené použití:
// připoj()
// ...
// databáze.dotaz(true) pro emulaci úspěšného volání
// databáze.dotaz(false) pro emulaci neúspěšného volání
// ...
// odpoj()
Nyní zde máme problém.
Napíšeme kód pro připojení a pošleme paralelně 3 dotazy (každý z nich trvá jinou dobu, např. 100, 200 a 300 ms), pak se odpojíme:
// pomocná funkce pro volání asynchronní funkce `fn` za `ms` milisekund
function čekej(fn, ms) {
return new Promise((splň, zamítni) => {
setTimeout(() => fn().then(splň, zamítni), ms);
});
}
async function spusť() {
připoj();
try {
await Promise.all([
// tyto 3 paralelní úkoly trvají každý jinou dobu: 100, 200 a 300 ms
// k dosažení tohoto efektu použijeme pomocnou funkci `čekej`
čekej(() => databáze.dotaz(true), 100),
čekej(() => databáze.dotaz(false), 200),
čekej(() => databáze.dotaz(false), 300)
]);
} catch(chyba) {
console.log('Chyba zpracována (opravdu?)');
}
odpoj();
}
spusť();
Dva z těchto dotazů byly neúspěšné, ale my jsme natolik chytří, že jsme volání Promise.all umístili do bloku try..catch.
Ale to nepomůže! Tento skript ve skutečnosti vyvolá nezachycenou chybu v konzoli!
Proč? Jak se tomu vyhnout?
Jádro problému spočívá v tom, že Promise.all se okamžitě zamítne, když bude zamítnut jeden z jeho příslibů, ale neudělá nic, aby zrušilo ostatní přísliby.
V našem případě selže druhý dotaz, takže Promise.all se zamítne a blok try...catch tuto chybu zachytí. Mezitím však ostatní přísliby nejsou ovlivněny – jejich vykonávání nezávisle pokračuje. V našem případě třetí příslib za nějakou dobu vygeneruje chybu sám o sobě. A tato chyba není ničím zachycena a my ji uvidíme v konzoli.
Tento problém je obzvláště nebezpečný v prostředích na straně serveru, například Node.js, kde nezachycená chyba může vést k havárii procesu.
Jak to opravit?
Ideální řešení by bylo zrušit všechny nedokončené dotazy ve chvíli, kdy jeden z nich selže. Tímto způsobem se vyhneme všem potenciálním chybám.
Špatná zpráva však je, že volání služeb (např. databáze.dotaz) je často implementováno knihovnou třetí strany, která nepodporuje rušení. Pak neexistuje žádný způsob, jak volání zrušit.
Jako alternativu si můžeme napsat vlastní obalovou funkci kolem Promise.all, která přidá ke každému příslibu vlastní handler then/catch, který je bude sledovat: výsledky se shromáždí, a pokud dojde k chybě, všechny ostatní přísliby jsou ignorovány.
function vlastníPromiseAll(přísliby) {
return new Promise((splň, zamítni) => {
const výsledky = [];
let početVýsledků = 0;
let mámeChybu = false; // po první chybě to nastavíme na true
přísliby.forEach((příslib, index) => {
příslib
.then(výsledek => {
if (mámeChybu) return; // pokud už máme chybu, příslib ignorujeme
výsledky[index] = výsledek;
početVýsledků++;
if (početVýsledků === přísliby.length) {
splň(výsledky); // když jsou všechny výsledky připraveny - úspěch
}
})
.catch(chyba => {
if (mámeChybu) return; // pokud už máme chybu, příslib ignorujeme
mámeChybu = true; // ouha, chyba!
zamítni(chyba); // zamítnutí při selhání
});
});
});
}
Tento přístup má sám o sobě problém – často není žádoucí volat odpoj(), když jsou dotazy ještě zpracovávány.
Může být důležité, aby se všechny dotazy zpracovaly, zvláště když některé z nich provádějí důležité zápisy do databáze.
Než tedy budeme pokračovat v provádění a nakonec se odpojíme, měli bychom počkat, než budou všechny přísliby usazeny.
Zde je jiná implementace. Chová se podobně jako Promise.all – také se vyhodnotí při první chybě, ale počká, dokud nebudou všechny přísliby usazeny.
function vlastníPromiseAllSČekáním(přísliby) {
return new Promise((splň, zamítni) => {
const výsledky = new Array(přísliby.length);
let početUsazených = 0;
let prvníChyba = null;
přísliby.forEach((příslib, index) => {
Promise.splň(příslib)
.then(výsledek => {
výsledky[index] = výsledek;
})
.catch(chyba => {
if (prvníChyba === null) {
prvníChyba = chyba;
}
})
.finally(() => {
početUsazených++;
if (početUsazených === přísliby.length) {
if (prvníChyba !== null) {
zamítni(prvníChyba);
} else {
splň(výsledky);
}
}
});
});
});
}
Nyní await vlastníPromiseAllSČekáním(...) pozastaví provádění, dokud nebudou všechny dotazy zpracovány.
Tento přístup je spolehlivější, neboť zaručuje předvídatelný průběh provádění.
Nakonec, kdybychom chtěli zpracovat všechny chyby, můžeme buď použít Promise.allSettled, nebo kolem něj napsat obal, který shromáždí všechny chyby do jediného objektu AggregateError a zamítne se s ním.
// počkáme na usazení všech příslibů
// pokud nebyla žádná chyba, vrátíme výsledky
// pokud byly chyby, vygenerujeme AggregateError se všemi chybami
function všechnoNeboAggregateError(přísliby) {
return Promise.allSettled(přísliby).then(výsledky => {
const chyby = [];
const hodnoty = [];
výsledky.forEach((výsl, i) => {
if (výsl.status === 'fulfilled') {
hodnoty[i] = výsl.value;
} else {
chyby.push(výsl.reason);
}
});
if (chyby.length > 0) {
throw new AggregateError(chyby, 'Jeden nebo více příslibů selhalo');
}
return hodnoty;
});
}