Promise.allSettled

Автор оригинала: Матиас Байненс
  • Перевод


На 71-м митинге Ecma TC39 будет рассматриваться проект и эталонная реализация Promise.allSettled — третьего из четырех основных комбинаторов промисов.


Авторы: Джейсон Вильямс (BBC), Роберт Памли (Bloomberg), Матиас Байненс (Google)
Чемпион: Матиас Байненс (Google)
Этап: 3


Для любителей подкастов, продублировано на YouTube.


Введение и мотивация


В мире промисов существует четыре основных комбинатора:


  • Promise.all. ES2015. Замыкается на первом отклоненном/rejected промисе.
  • Promise.race. ES2015. Замыкается на первом хоть как-то разрешенном/settled промисе.
  • Promise.any. Stage 1. Замыкается на первом удовлетворенном/fulfilled промисе.
  • Promise.allSettled. Stage 3 → Stage 4. Не замыкается.

Все они широко представлены в обычных пользовательских библиотеках, каждый из них полезен сам по себе и подходит в различных ситуациях.


Основное применение этого комбинатора наступает, когда хочется выполнить действие сразу после завершения множества запросов, вне зависимости, закончились ли они успехом или неудачей. Остальные комбинаторы промисов замыкаются (short-circuit), выбрасывая результаты входящих значений, проигравших в гонке за определённым состоянием системы. Promise.allSettled уникален тем, что всегда ожидает всех, за кого отвечает.


Promise.allSettled возвращает промис, который выполняется с возвращением массива снапшотов состояний промисов, но лишь только после того, как совершенно все исходные промисы разрешены (settled).


Откуда взялось название allSettled?


Мы говорим, что промис разрешен (settled), если он не подвис в ожидании (pending), т.е. когда он либо удовлетворён, либо отклонён — одно из двух. Чтобы разобраться в терминологии, взгляните на старый документ States and Fates.


А ещё, это имя, allSettled, широко используется в существующих библиотеках, реализующих данную функциональность. Список будет ниже.


Примеры


Представьте, вам нужно проитерироваться по массиву промисов и вернуть новое значение с известным статусом (которое возникает в любом из двух возможных ответвлений логики).


function reflect(promise) {
  return promise.then(
    (v) => {
      return { status: 'fulfilled', value: v };
    },
    (error) => {
      return { status: 'rejected', reason: error };
    }
  );
}

const promises = [ fetch('index.html'), fetch('https://does-not-exist/') ];
const results = await Promise.all(promises.map(reflect));
const successfulPromises = results.filter(p => p.status === 'fulfilled');

Предлагаемое API позволяет разработчику обработать эти варианты, без необходимости создавать функцию reflect самостоятельно, или заниматься хранением результатов во временных переменных. Новое API выглядит так:


const promises = [ fetch('index.html'), fetch('https://does-not-exist/') ];
const results = await Promise.allSettled(promises);
const successfulPromises = results.filter(p => p.status === 'fulfilled');

Если же нам почему-то нужны отклонённые промисы, то вероятно, нужно собрать причины произошедшего. allSettled позволяет сделать это так же просто.


const promises = [ fetch('index.html'), fetch('https://does-not-exist/') ];

const results = await Promise.allSettled(promises);
const errors = results
  .filter(p => p.status === 'rejected')
  .map(p => p.reason);

Реальные примеры


Довольно распространённым является желание знать, что все запросы выполнились, вне зависимости от состояния каждого из них. Это важно, когда хочется в будущем заняться постепенным улучшением. Не всегда нам нужно получить от API ответ.


const urls = [ /* ... */ ];
const requests = urls.map(x => fetch(x)); // Представьте, что-то из этого увенчается успехом, а что-то - нет.

// Вот этот комбинатор остановится на первом же отказе, а ответы потеряются.
try {
  await Promise.all(requests);
  console.log('Все запросы вернулись, можно убрать полоску загрузки.');
} catch {
  console.log('Какой-то из запросов явно отвалился, но другие могут продолжать работать. Ой.');
}

С использованием Promise.allSettled можно написать нечто, что больше соответствует нашим ожиданиям.


// Мы точно знаем, что все запросы к API уже отработали.
Promise.allSettled(requests).finally(() => {
  console.log('Все запросы завершены: успешно или с ошибкой, сейчас всё равно');
  removeLoadingIndicator();
});

Пользовательские реализации



В других языках


Похожая функциональность существует и в других языках, под другими именами. Поскольку не существует универсального механизма, совместимого сразу со множеством языков, этот документ следует прмеру наименований из библиотек, перечисленных выше. Вот как это выглядит в других языках:


  • Rust — futures::join;
  • C# — Task.WhenAll. Можно использовать либо try/catch, либо TaskContinuationOptions.OnlyOnFaulted;
  • Python — asyncio.wait с опцией ALL_COMPLETED
  • Java — CompletableFuture.allOf

Материалы для дальнейшего изучения



Минутки со встреч TC39



Спецификация



Реализации


Поделиться публикацией
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 37

    +4
    Promise.race (ES2015; замыкается на первом разрешенном промисе)
    Promise.any (Stage 1; замыкается на первом удовлетворенном промисе)

    не очень понятна разница между "удовлетворенным" и "разрешенным". Лучше было бы сказать "race — на первом хоть как-то завершенном", "any – на первом успешно завершенном"

      +1

      Наверное, вы правы. Указал английские варианты. Разница между терминами хорошо описана в документе States and Fates, который являлся частью пропозала промисов для ES2015.

      +2
      А метода, который ожидает разрешения всех (как и allSettled), но потом (после окончания ожидания) выбрасывает первое исключение из случившихся, пока не завезли?

      Просто не очень понятно, как можно использовать Promise.all() на практике с async-await, если ошибки там чреваты «висящими в воздухе» оставшимися промисами, продолжающими работать непредсказуемое время. И allSettled() тут не спасает от бойлерплейта.

      В своем коде пришлось в свое время по этой причине выпилить все-все Promise.all() и заменить их на собственноручную joinSafe(), которая делает ровно это: ждет всех, но бросает первое. Насколько я понимаю, это поведение является также дефолтным в await genv() в Hack’е.
        0

        Кажется, не завезли: единстенный кто ждёт всех — allSettled. С другой стороны, теперь можно проапдейтить ваш бойлерплейт :)

          0
          Со всеми подробностями звучит как вполне себе основание для нового предложения в TC39.
          0

          Я правильно понимаю, что ненавязчиво хотят ввести публичный статус промиса? Или я что-то пропустил и он давно есть?

            +2

            С чего вы это взяли? Статус промиса не вводили ранее по той причине, что хотели сделать статус pending ненаблюдаемым. И новое api ничего в этом плане не меняет.

              –2

              Из кода p.status === 'fulfilled'

                +3

                И что дальше?

              +1

              В самом промисе будет скрытое поле [[PromiseStatus]], но можно получить эти значения в then:


              var assert = require('assert');
              var allSettled = require('promise.allsettled');
              
              var resolved = Promise.resolve(42);
              var rejected = Promise.reject(-1);
              
              function prettyJSON(obj) {
                  console.log(JSON.stringify(obj, null, 2));
              }
              
              allSettled([resolved, rejected]).then(function (results) {
                  prettyJSON(results);
                  assert.deepEqual(results, [
                      { status: 'fulfilled', value: 42 },
                      { status: 'rejected', reason: -1 }
                  ]);
              });

              Протестить этот код можно или через Ноду (npm i promise.allsettled) или в каком-нибудь свежем Chrome Canary (заменив вызов allSettled на Promise.allSettled)

                +4

                allSettled возвращает не массив промисов, а обертки promise+status. В Readme есть пример как добиться того же самого сегодняшними средствами


                function reflect(promise) {
                  return promise.then(
                    (v) => {
                      return { status: 'fulfilled', value: v };
                    },
                    (error) => {
                      return { status: 'rejected', reason: error };
                    }
                  );
                }

                так что ничего нового нам из приватных свойств не открывают

                  +1

                  Да обёртки и сам делал. Думал не нужно теперь будет. Хотя Promise.allSettled([fetch(...)])[0] по сути будет полунативной обёрткой.

                    +1

                    Ну что же, будем ждать Promise.settled() который будет делать то же самое, только для одного :)

                –3

                В оригинале про Promise.all написана ерунда, ну и в переводе аналогично.

                +1
                В ES2015 оба эквивалента C#-комбинаторов ущербные.
                Promise.raсe: так как в JS нельзя результом промиса делать промис, то в штатной функции невозможно определить, какой конкретно промис завершился.
                Обход:
                /**
                 * Возвращаемый промис будет завершен, когда любой из последовательности промисов завершен. 
                 * Возвращаемый промис всегда будет завершаться в состоянии resolved. 
                 * Это справедливо, даже если первый завершенный промис находится в состоянии rejected.
                 * 
                 * Поскольку в JavaScript результат промиса не может быть промисом, то 
                 * результат возвращенного промиса — массив из одного элемента: первого завершенного промиса.
                 */
                Promise.whenAny = promises => new Promise((resolve, reject) => {
                    let result;
                    for (let promise of promises) {
                        if (result) break;
                        let func = () => { if (!result) { result = [promise]; resolve(result); } };
                        promise.then(func, func);
                    }
                });

                Promise.all: как выше было замечено, слишком рано вылетает на ошибке, поэтому не достигает функциональности Task.WhenAll()
                Обход:
                /**
                 * Аналог черновика Promise.allSettled(). Не полностью соответствует Task.WhenAll()
                 * Возвращаемый промис будет завершен, когда все из последовательности промисов будут завершены. 
                 * Возвращаемый промис всегда будет завершаться в состоянии resolved, в отличие от Task.WhenAll()  
                 * Это справедливо, даже если завершенные промисы будут находиться в состоянии rejected.
                 * 
                 * @returns {Array} Массив объектов {value, reason, status: "fulfilled" или "rejected"}
                 * Свойства value или reason могут отсутствовать.
                 */
                if (!Promise.allSettled)
                    Promise.allSettled = promises => new Promise(async (resolve, reject) => {
                        let array = [];
                        for (let promise of promises) {
                            try {
                                let result = await promise;
                                array.push({ status: "fulfilled", value: result });
                            }
                            catch (ex) {
                                array.push({ status: "rejected", reason: ex });
                            }
                        }
                        resolve(array);
                    });

                В сущности прямой эквивалент Task.WhenAll() уже не нужен. Мы можем перебрать исходную коллекцию промисов и оттуда забрать нужные результаты и ошибки.

                Можно отметить, что свойства возвращаемого объекта в Promise.allSettled не соответствуют функции reflect() из npm promise-reflect.
                Обход:
                /**
                 * Получить информацию о промисе после того, как дождались его завершения.
                 * @param {Promise} promise Промис, для которого нужно получить информацию.
                 * @returns {Promise<{status:String,value:Any}|{status:String,reason:Error}>} Промис с объектом информации.
                 */
                Promise.reflect = promise => {
                    return promise.then(
                        (v) => {
                            return { status: 'fulfilled', value: v };
                        },
                        (error) => {
                            return { status: 'rejected', reason: error };
                        }
                    );
                }

                Соответственно, теперь уже будет можно по-человечески реализовать Task Asynchronous Pattern из:
                devblogs.microsoft.com/pfxteam/processing-tasks-as-they-complete
                codeblog.jonskeet.uk/2012/01/16/eduasync-part-19-ordering-by-completion-ahead-of-time
                github.com/StephenCleary/AsyncEx/blob/master/src/Nito.AsyncEx.Tasks/TaskExtensions.cs
                  +1

                  Ваша реализация whenAny содержит избыточную переменную result — она просто не нужна.


                  А ваша реализация allSettled оставит мусор в консоли. Лучше использовать способ из статьи (Promise.all(promises.map(...)))

                    +1
                    Мусор в консоли при использовании try..catch оставляет браузер MS Edge.
                    А например браузер Opera или NodeJS не оставляют мусор.
                    Получается, если бояться мусора, то невозможно использовать try..catch.
                    В общем и целом варианты с then() и try..catch эквивалентны. Допустим, в MS Edge мы будем использовать только then().
                    Тест в NodeJS:
                    /**
                     * Получить информацию о промисе после того, как дождались его завершения.
                     * @param {Promise} promise Промис, для которого нужно получить информацию.
                     * @returns {Promise<{status:String,value:Any}|{status:String,reason:Error}>} Промис с объектом информации.
                     */
                    Promise.reflect = promise => {
                        return promise.then(
                            (v) => {
                                return { status: 'fulfilled', value: v };
                            },
                            (error) => {
                                return { status: 'rejected', reason: error };
                            }
                        );
                    }
                    
                    if (!Promise.allSettled){
                        console.log("Не было Promise.allSettled");
                    
                        Promise.allSettled = promises => new Promise(async (resolve, reject) => {
                            let array = [];
                            for (let promise of promises) {
                                array.push(await Promise.reflect(promise));
                            }
                            resolve(array);
                        });
                    
                        // Promise.allSettled = promises => new Promise(async (resolve, reject) => {
                        //     let array = [];
                        //     for (let promise of promises) {
                        //         try {
                        //             let result = await promise;
                        //             array.push({ status: "fulfilled", value: result });
                        //         }
                        //         catch (ex) {
                        //             array.push({ status: "rejected", reason: ex });
                        //         }
                        //     }
                        //     resolve(array);
                        // });
                    }
                    // 4) ОБРАБОТКА ИСКЛЮЧЕНИЙ.
                    
                    async function ThrowNotImplementedExceptionAsync() {
                        throw new Error("Not implemented");
                    }
                    async function ThrowInvalidOperationExceptionAsync() {
                        throw new Error("Invalid operation");
                    }
                    
                    /**
                     * Дождаться завершения всех промисов и отдельно перебрать исключения.
                     */
                    async function ObserveAllExceptionsAsync() {
                        let task1 = ThrowNotImplementedExceptionAsync();
                        let task2 = ThrowInvalidOperationExceptionAsync();
                        let array = await Promise.allSettled([task1, task2]);
                        let allExceptions = array.filter(e => e.status === "rejected").map(e => e.reason);
                        for (let e of allExceptions)
                            console.log(e.message);
                    }
                    async function Method4Async() {
                        await ObserveAllExceptionsAsync();
                    }
                    
                    (async function main() {
                        // 4) ОБРАБОТКА ИСКЛЮЧЕНИЙ.
                        await Method4Async();
                    })();
                      +2

                      Вот вам другой тест:


                      Promise.allSettled([
                          new Promise(r => setTimeout(r, 1000, 1)),
                          Promise.reject(2),
                      ])

                      В течении секунды в консоли Хрома наблюдается ошибка "Uncaught (in promise): 2".

                        +1
                        Дурдом на выезде. NodeJS пишет:
                        Не было Promise.allSettled
                        (node:26108) UnhandledPromiseRejectionWarning: 2
                        (node:26108) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). (rejection id: 1)
                        (node:26108) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.
                        [ { status: 'fulfilled', value: 1 },
                        { status: 'rejected', reason: 2 } ]
                        (node:26108) PromiseRejectionHandledWarning: Promise rejection was handled asynchronously (rejection id: 1)

                        Сначала матерится, потом увидел, что исключение обработано, и заткнулся )))
                          +1

                          Это не дурдом, а полезная диагностика, помогающая отлавливать забытые ошибки. Но да, у нее есть один недостаток: на любой промис нужно подписываться сразу же, до того как он выполнится с ошибкой, иначе эта диагностика сработает ложноположительно.

                            +1
                            Большое спасибо за ценные примеры.
                            В C# первая инструкция уже падает с ошибкой. Там не допускается запуск задач, завершающихся ошибкой вне try...catch или без ContinueWith().
                                const promises = [new Promise(r => setTimeout(r, 1000, 1)), 
                            ThrowNotImplementedExceptionAsync()];
                                const results = await Promise.all(promises.map(Promise.reflect));
                                console.log(results);

                            А JavaScript непонятно почему допускает.
                              +1
                              Там не допускается запуск задач, завершающихся ошибкой вне try...catch или без ContinueWith().

                              Не верю, вы что-то путаете. Там проверка идет в момент сборки задачи сборщиком мусора, и там как раз ваш алгоритм будет работать без проблем.

                                0
                                Зачем верить на слово? Всё тестируется:
                                private static Task<int> ThrowNotImplementedExceptionAsync()
                                {
                                    throw new Exception("Ошибка2");
                                }
                                
                                private static async Task<int> DelayAndReturnAsync(int val)
                                {
                                    await Task.Delay(val * 1000);
                                    return val;
                                }
                                
                                private static async Task TestTryCatchAsync2()
                                {
                                    var promises = new Task<int>[] {
                                        DelayAndReturnAsync(1), // Задача без ошибки
                                        ThrowNotImplementedExceptionAsync() // Задача с ошибкой
                                    };
                                    async Task<dynamic> reflect(Task<int> task)
                                    {
                                        try
                                        {
                                            var result = await task;
                                            return new { value = result, status = "rejected" };
                                        }
                                        catch (Exception e)
                                        {
                                            return new { reason = e, status = "rejected" };
                                        }
                                    }
                                    var results = await Task.WhenAll(promises.Select(reflect).ToArray());
                                    Console.WriteLine(results[0].value);
                                }

                                Падение именно при исполнении инструкции объявления массива задач.
                                Достаточно убрать задачу с ошибкой, и всё выполнится.
                                  +2
                                  Виноват. Всё правильно. Я async забыл дописать. Всё работает хорошо и с ошибкой, как надо:
                                  private static async Task<int> ThrowNotImplementedExceptionAsync()
                                  {
                                      throw new Exception("Ошибка2");
                                  }
                      +1
                      С учётом ваших замечаний. Прошу потестить.
                      if (!Promise.allSettled) {
                          console.log("Не было Promise.allSettled");
                      
                          Promise.allSettled = promises => new Promise((resolve, reject) => {
                              let inputTasks = Array.from(promises);
                              let array = [],
                                  count = 0,
                                  len = inputTasks.length;
                              for (let i = 0; i < len; ++i) {
                                  inputTasks[i].then(
                                      value => {
                                          array[i] = { status: "fulfilled", value: value };
                                          if (++count === len) resolve(array);
                                      },
                                      reason => {
                                          array[i] = { status: "rejected", reason: reason };
                                          if (++count === len) resolve(array);
                                      }
                                  );
                              }
                          });
                      }
                      
                      /**
                       * Возвращаемый промис будет завершен, когда любой из последовательности промисов завершен. 
                       * Возвращаемый промис всегда будет завершаться в состоянии resolved. 
                       * Это справедливо, даже если первый завершенный промис находится в состоянии rejected.
                       * 
                       * Поскольку в JavaScript результат промиса не может быть промисом, то 
                       * результат возвращенного промиса — массив из одного элемента: первого завершенного промиса.
                       */
                      Promise.whenAny = promises => new Promise((resolve, reject) => {
                          for (let promise of promises) {
                              let func = () => { resolve([promise]); };
                              promise.then(func, func);
                          }
                      });
                      
                      /*
                       * Task.Delay()
                       */
                      Promise.delay = ms => new Promise(resolve => { setTimeout(resolve, ms); });
                      
                      
                      // ОБРАБОТКА ИСКЛЮЧЕНИЙ.
                      
                      async function ThrowNotImplementedExceptionAsync() {
                          throw new Error("Not implemented");
                      }
                      async function ThrowInvalidOperationExceptionAsync() {
                          throw new Error("Invalid operation");
                      }
                      
                      /**
                       * Тестовая функция. Подождать определенное время и вернуть результат.
                       * @param {Number} val Возвращаемое число, одновременно являющееся задержкой в секундах.
                       * @returns {Promise<Number>} Возвращаемое число, взятое из параметра функции.
                       */
                      async function delayAndReturnAsync(/*int*/ val) {
                          await Promise.delay(val * 1000);
                          return val;
                      }
                      
                      /**
                       * Дождаться завершения всех промисов и отдельно перебрать исключения.
                       */
                      async function ObserveAllExceptionsAsync() {
                          let task1 = ThrowNotImplementedExceptionAsync();
                          let task2 = ThrowInvalidOperationExceptionAsync();
                          let array = await Promise.allSettled([task1, task2]);
                          let allExceptions = array.filter(e => e.status === "rejected").map(e => e.reason);
                          for (let e of allExceptions)
                              console.log(e.message);
                      }
                      async function Method4Async() {
                          await ObserveAllExceptionsAsync();
                      }
                      
                      (async function main() {
                          await Method4Async();
                      
                          console.log(await Promise.allSettled([
                              new Promise(r => setTimeout(r, 1000, 1)),
                              Promise.reject(2),
                          ]));
                      })();

                        +1

                        Да, так лучше.

                      +2

                      Лучше все-таки не навешивать свои кастомные методы на нативный объект Promise. Особенно, подкладывать реализацию allSettled, которая отличается от стандарта.


                      "ущербность" нынешних методов – это ваше личное мнение, а если какой-то код захочет использовать стандартный метод и не получит ожидаемый результат, будет очень плохо.


                      Лучше создать npm-модуль, например promises-with-blackjack (имя свободно, если что) и положить свои реализации туда, не перекрывая нативные.

                        +1

                        Ну не все так плохо. Его реализация allSettled стандарту соответствует, отличия в поведении наблюдаемы только отладчиком.


                        Вот с reflect и whenAny всё уже хуже, но их вроде и не планируют добавлять в стандарт, так что конфликт возможен лишь с другим таким же "патчером"...

                          +2

                          Действительно, более-менее похожая реализация.


                          В отладчике мы увидем сообщение об unhandledRejection, я так понимаю? Это может выстрелить в ногу, потому что в Node.js 12 добавили новый флаг --unhandled-rejections=strict, который может завершить процесс в этой ситуации.

                      +1

                      (комментарий был удален)

                        +1

                        А вот мне кажется, что оба новых метода Promise.allSettled / Promise.any принесут больше проблем, чем пользы.


                        • Promise.allSettled добавляет новый уровень абстракции, заворачивая результаты в объект вида {status, reason}.
                        • Promise.any декларирует новый тип ошибки AggregateError.
                        • И оба метода фактически позволяют игнорировать ошибки в коде, что рано или поздно аукнется. Лучше бы это оставалось в области библиотек, но не core Promise API.

                        Буквально сегодня выложил на эту тему статью.

                          +1
                          Promise.allSettled добавляет новый уровень абстракции, заворачивая результаты в объект вида {status, reason}

                          А в чем проблема? И почему простейшая структура данных называется "уровнем абстракции"?


                          Promise.any декларирует новый тип ошибки AggregateError.

                          И в чем проблема?


                          И оба метода фактически позволяют игнорировать ошибки в коде, что рано или поздно аукнется.

                          И оба метода следует применять в случае, когда ошибка должна быть проигнорирована при соответствующих условиях.


                          Лучше бы это оставалось в области библиотек, но не core Promise API.

                          Почему лучше?

                            +1
                            новый тип ошибки AggregateError

                            Это старый тип ошибки в C#. Но с ним много возни в коде. Стивен Клири, автор книги «Concurrency in C# Cookbook», не любит этот массив ошибок.
                            В принципе метод Promise.allSettled() был бы неплохим способом не корячиться с этим массивом ошибок.
                            Но опять же есть желающие и полностью эмулировать Task.WhenAll().
                              +2

                              По вашей статье:


                              In how many projects did you use the pattern make several parallel requests to identical endpoints for the same data?

                              Не обязательно это должны быть одинаковые конечные точки. И не обязательно там должны быть одинаковые данные.


                              Вы же сами писали в примере всякие .then(() => 'a') и .then(() => 'b') — это разве одинаковые данные?


                              I agree sometimes it may be useful. But how often?

                              Вопрос не в том, насколько часто — а в том, что делать когда эта возможность нужна. А делать нечего, текущее api не предоставляет нужной функции понятным путём (и нет, вариант с reverse непонятен!).


                              I think the core API should expose all errors.

                              В таком случае надо запретить метод catch — он ведь тоже относится к core API и тоже позволяет проигнорировать ошибку.

                            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                            Самое читаемое