1
\$\begingroup\$

The ideas are

  • When multiple async calls are made, to be able to start consuming from the first resolving one regardless in what order the promises are fired.
  • To construct a modern emitter of async values to be consumed by the for await loop.
  • One may achieve this functionality by making repeated (promise many times) Promise.race()s but i want to believe that no good programmer would feel comfortable with that.
  • Because both it's wasteful and i never liked Promise.race(). It's a misnomer. I mean where in the world there is a race that finalizes once somebody wins? It's an insult. It's like calling the second one "Hey.. you are at the top of all losers". Let's change that.

One implementation of this functionality could be starting to render a multipart entity earlier than otherwise. There might be other use cases too, since this will not reject altogether even if a promise in the system gets somehow rejected. We can easily handle rejections in an isolated manner, separatelly, perhaps for retrying or whatnot. Also the promises can be identified as they resolve and we can tell where to use their resolution.

The code below is just a skeleton.

  • It just shows the mechanism with a simple exception handling.
  • It doesn't include any retry or promise identification abilities.
  • It doesn't remove the consumed (fulfilled) promise from the structure. This can be iplemented in the finally block. Once it's implemented, the count variable will be redundant and we can then use the dynamic length property instead.
  • Being able to use the dynamic length property would allow us to add, remove or replace promises at any point in time just by using standard array methods like .push() or .splice() etc.
  • It uses the new Private Class Fields which i like a lot.

We will start with a new data type called SortedPromisesArray which in fact is an extension to the Array type. We will empower it with generator and asyncIterator abilities.

export default class SortedPromisesArray extends Array {
  #resolve;
  #reject;
  #count;
  constructor(...args){
    super(...args.filter(p => Object(p).constructor === Promise)); // Make sure everybody is a Promise
    this.#count = this.length;
    this.forEach(p => p.then(v => this.#resolve(v), e => this.#reject(e)));
  };
  async *[Symbol.asyncIterator]() {
    while(this.#count--) {
      try {
        yield new Promise((...rs) => [this.#resolve,this.#reject] = rs);
      }
      catch(e){
        console.log(`Caught an exception ${e}`);
      }
      finally{
        // a handy stage to do useful things
      };
    };
  };
};

and we can consume it like;

import SPA from "../lib/promise-sort.js";

var promise  = (val,delay,isOK) => new Promise((v,x) => setTimeout(_ => isOK ? v(val) : x(val), delay)),
    promises = [ promise("Third", 3000, true)
               , promise("First", 1000, true)
               , Promise.resolve("this is solved first @ microtask queue")
               , promise("Second", 2000, false) // NOTE: this one rejects!
               ],
    sortedPS = new SPA(...promises);

async function sink() {
  for await (let value of sortedPS){
      console.log(`Got: ${value}`);
  };
};

sink();

Any thoughts and recommendations are most welcome.

\$\endgroup\$
1
  • \$\begingroup\$ I would remove "speed' from the title. It's possible that a slow promise started a long time ago actually finishes first \$\endgroup\$ Apr 10, 2021 at 23:35

2 Answers 2

2
\$\begingroup\$

Issues

Lost resolution

You are issuing promises without a guarantee of being able to handle all or some of the resolving promises.

This will happen if promises resolve before you start the for await loop.

The offending code is in the line this.forEach(p => p.then(v => this.#resolve(v), e => this.#reject(e)));

The calls to this.#resolve and this.#reject require that the asyncIterator is run so that this.#resolve and this.#reject are assigned functions. If not they are undefined when a promise resolves.

It is also possible for a promise to overwrite the previous promise's resolved value without the iterator getting to see the value.

For example:

  • if you delay the call to sink(); with setTimeout(sink, 4000) none of the promises are handled within the function sink. The results are lost and can not be retrieved

  • If the iterator awaits content that allows more than one of the iterateable promises to resolve between iterations.

    Example the following function loses the second promise. (given your test data)

async function sink() {
    const p = new Promise(v=>setTimeout(v, 1200, "zz"))
    for await (const value of sortedPS){
       log(value);
       log(await p);
    };
};

Silencing errors!

Never silence errors that are not your responsibility.

You do this in the line:

super(...args.filter(p => Object(p).constructor === Promise)); // Make sure everybody is a Promise

Either throw an error or let the error happen naturally. If your code can not function correctly with the given arguments then throw

Example of vetting and throwing error:

constructor(...promises){
    super(...promises);
    if (promises.some(promise => !(promise instanceof Promise))) {
        throw new RangeError("Array can only contain Promises");
    }

Poor inheritance

You extend Array yet neglect to handle any of the inherited functions or properties.

For example one would expect that SortedPromisesArray.push would add a promise that will resolve. As it stands added promises are ignored.

Simple example of extending inherited function:

  push(...promises) {
      if (promises.some(promise => !(promise instanceof Promise))) {
          throw new RangeError("Array can only contain Promises");
      }      
      this.#count += promises.length;
      super.push(...promises);
      promises.forEach(p => p.then(v => this.#resolve(v), e => this.#reject(e)));
  }

The same would apply to any of the inherited functions that modify the arrays content.

Arrays are independent of content

However this leaves the problem of behaviors that can not be extended without the use of a proxy. Eg sortedPS[sortedPS.length] = new Promise(...)

Without a proxy you are left with an object that has inconsistent behavior.

It is therefor not advisable to extend Arrays if behavior of the extended array is dependent on the content of the array.

Update

I have added an example RaceAll as a factory that creates an async iterate-able object that races an array of promises in order of resolution.

This is achieved by marking promises with ids and using the id of resolved promises to remove then from the race array.

It does not include any vetting, nor does it resolve rejected / failed promises (both can easily be implemented).

It does guarantee no promises are lost and does not require iteration before any promises are resolved.

function RaceAll(...promises) {
    var racing = promises.map((promise, id) => Object.assign(
            new Promise(r => promise.then(result => r({id, result}))), {id}
        ));
    return Object.freeze({
            async *[Symbol.asyncIterator]() {
                while (racing.length) {
                    const resolved = await Promise.race(racing);
                    yield resolved.result;
                    racing = racing.filter(r => r.id !== resolved.id);
                }
            }
        });
}
\$\endgroup\$
5
  • \$\begingroup\$ Thank you. There are points i would agree and argue. The Lost resolution section is resonable but i think it's a a little hmm. The .sink() functionality should already be there and with no further awaiting context too. You may consider the whole thing like a Promise.race()` of which you can not mess with the inner workings. Still it gave me an idea how to fix it. Please check this and at the shell pane run it like ~/promise-race$ deno run ./promise-sort/test/promise-sort-test.js. \$\endgroup\$
    – Redu
    Apr 11, 2021 at 17:43
  • \$\begingroup\$ OTH yes i agree that Array is not the ideal data type to base this one upon. Perhaps a linked list or similar would be better. Would you care to drop me an email which is in my profile and easily decypherable. :) I just don't want to pollute the comments. \$\endgroup\$
    – Redu
    Apr 11, 2021 at 17:49
  • 1
    \$\begingroup\$ @Redu Sorry I limit communication to only comments and answers \$\endgroup\$
    – Blindman67
    Apr 12, 2021 at 10:39
  • \$\begingroup\$ I have just seen your update... It contradicts with my third idea. I decided to trash arrays and implement this functionality on an Async Queue which i have already finalized. Now the race functionality will not be an async iterator by itself at all... Already resolved or the resolving promises are to be enqueued to the Async Queue which is already an asynch iterator. I think that would be ideal. \$\endgroup\$
    – Redu
    Apr 21, 2021 at 19:17
  • \$\begingroup\$ So i tried to implement a reasonable solution in the form of a self answer below. \$\endgroup\$
    – Redu
    May 5, 2021 at 17:51
1
\$\begingroup\$

I accepted @Blindman67's answer despite I'd already mentioned in my question that using Promise.race() promises.length many times is hurting me. However he has a strong point on the usage of Arrays. I had to find a better data structure and i think this happens to be a Queue, preferably an Async Queue. An Async Queue, in my terms, is a type that do not have a .dequeue() functionality. It automatically dequeues once the head resolves. An Async Queue might come handy for many applications and one of them is this.

All we need is to store our promises somehow and once they resolve, simply .enqueue() them just to consume their resolution from the Async Queue with a for await of loop. So i implemented the AQ library to start with. It works as i have described above.

So just make a promiseRace function which .enqueue()s you promises' resolution into AQ at their .then() stage. Oh.. if the promise rejects then just don't race it because it's apparently a doped athelete or whatever.

import {AQ} from "https://gitlab.com/Redu/aq/-/raw/master/mod.ts"; // Good Deno
function promiseRace(...ps){
    var aq = new AQ();
    ps.forEach(p => p.then(v => aq.enqueue(v))
                     .catch(e => console.log( `%cErr: ${e} but got unlucky. No Race!`
                                            , `background-color: #e30a17; color: white`))
                                            )
    return aq;
};

an enjoy the outcome like;

var promise  = (val, delay, resolves) => new Promise((v,x) => setTimeout(_ => resolves ? v(val) : x(val), delay)),
    promises = Array.from( {length: 10}
                         , (_,i) => {
                             var delay = Math.random()*100;
                             return promise( `Promise # ${i.toString()
                                                           .padStart(3, " ")} expected to resolve @ ${delay.toFixed(2)
                                                                                                           .padStart(6," ")}`
                                           , delay
                                           , Math.random() > 0.05
                                           );
                           }
                         ),
    race     = promiseRace(...promises);

async function sink() {
  for await (let value of race){
      console.log(`Got: ${value}`);
  };
};

sink();

So for one case i have got

Got: Promise #   4 expected to resolve @   2.20
Got: Promise #   7 expected to resolve @   8.72
Got: Promise #   6 expected to resolve @  17.14
Got: Promise #   2 expected to resolve @  41.76
Err: Promise #   0 expected to resolve @  49.47 but got unlucky. No Race!
Got: Promise #   1 expected to resolve @  49.72
Got: Promise #   8 expected to resolve @  55.05
Got: Promise #   9 expected to resolve @  83.39
Err: Promise #   3 expected to resolve @  87.25 but got unlucky. No Race!
Got: Promise #   5 expected to resolve @  94.16

on my console. Cool..!

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.