5

I've been playing around with async generators in an attempt to make a "promise ordering" generator which takes an array of promises and yields out promises one by one in the order they resolve or reject. So something like:

async function* orderProms(prom_arr) {

    // Make a copy so the splices don't mess it up.
    const proms = [...prom_arr];

    while (proms.length) {
        // Tag each promise with it's index, so that we can remove it for the next loop.
        const {prom, index} = await Promise.race(proms.map((prom, index) => prom.then(
            () => ({prom, index}),
            () => ({prom, index})
        )));

        proms.splice(index, 1);
        yield prom;
    }
}

With the idea to consume this generator like so:

const resAfter = (val, delay) => new Promise(res => setTimeout(() => res(val), delay));
const rejAfter = (val, delay) => new Promise((_, rej) => setTimeout(() => rej(val), delay));

const promises = [
    resAfter("Third", 3000),
    resAfter("First", 1000),
    rejAfter("Second", 2000), // NOTE: this one rejects!
];

(async () => {

    let ordered = orderProms(promises);

    let done = false;
    for (let next_promise = ordered.next(); !done; next_promise = ordered.next()) {
        const next = await next_promise
            .catch(err => ({done: false, value: `Caught error: ${err}`}));

        done = next.done;
        if (!done) console.log(next.value);
    }
})()

However, I've noticed that this will reach up to the second promise, then the generator will halt. It seems to be because of the rejected "second" promise. Calling yield prom in the generator will create an exception in the generator when the prom is rejected.

But this is the source of my confusion. I do not want to create an exception here, I just want to yield the rejected promise as the value of the iterator result. I don't want it to be unwrapped. It's almost like this is being treated as yield await prom;, but as you can see there is no await call.

What is going on here and how can I simply yield a rejected promise as-is from this generator.


Here's the above code in a runnable snippet:

async function* orderProms(prom_arr) {

    // Make a copy so the splices don't mess it up.
    const proms = [...prom_arr];

    while (proms.length) {
        // Tag each promise with it's index, so that we can remove it for the next loop.
        const {prom, index} = await Promise.race(proms.map((prom, index) => prom.then(
            () => ({prom, index}),
            () => ({prom, index})
        )));

        proms.splice(index, 1);
        yield prom;
    }
}

const resAfter = (val, delay) => new Promise(res => setTimeout(() => res(val), delay));
const rejAfter = (val, delay) => new Promise((_, rej) => setTimeout(() => rej(val), delay));

const promises = [
    resAfter("Third", 3000),
    resAfter("First", 1000),
    rejAfter("Second", 2000), // NOTE: this one rejects!
];

(async () => {

    let ordered = orderProms(promises);

    let done = false;
    for (let next_promise = ordered.next(); !done; next_promise = ordered.next()) {
        const next = await next_promise
            .catch(err => ({done: false, value: `Caught error: ${err}`}));

        done = next.done;
        if (!done) console.log(next.value);
    }
})()
Bergi
  • 630,263
  • 148
  • 957
  • 1,375
CRice
  • 29,968
  • 4
  • 57
  • 70

3 Answers3

5

It's almost like this is being treated as yield await prom. What is going on here?

Exactly that is how async generators behave.

how can I simply yield a rejected promise as-is from this generator.

You cannot. Notice that an async iterator is expected to be consumed by

try {
    for await (const value of orderProms(promises)) {
        console.log(value);
    }
} catch(err) {
    console.error('Caught error: ', err);
}

There is no facilitation for individual error handling in the syntax. When there's an exception, the loop stops, the generator is done. Point.

So what can you do? I see three choices:

  • just keep it as is and treat failing early as a feature (similar to Promise.all)
  • handle errors (either in orderProms or before passing promises into it) and yield tuples of promise status and value

    for await (const value of orderProms(promises.map(prom =>
        prom.catch(err => `Caught error: ${err}`)
    ))) {
        console.log(value);
    }
    
  • use a normal (non-async) generator from which you yield one promise after the other manually, to be able to use it in the way you want
Bergi
  • 630,263
  • 148
  • 957
  • 1,375
  • A shame. I'm usually quite happy with the decisions made by TC39, but this one is a mistake imo. `yield foo` and `yield await foo` ought to be different constructions, but I'm not on the committee so ¯\\_(ツ)_/¯. Thanks for the answer, I'll try going with option three. – CRice Jun 15 '20 at 20:45
1

You could let the promises resolve to something similar as you get from Promise.allSettled:

async function* orderProms(prom_arr) {
    // Make a copy so the splices don't mess it up.
    const proms = new Set(prom_arr.map((prom, index) => ({prom, index})));
    while (proms.size) {
        const settled = await Promise.race(Array.from(proms, obj => obj.prom.then(
            value => Object.assign(obj, { value, status: "fulfilled" }),
            error => Object.assign(obj, { error, status: "rejected" }),
        )));
        proms.delete(settled);
        let { prom, ...rest } = settled;
        yield rest;
    }
}

const resAfter = (val, delay) => new Promise(res => setTimeout(() => res(val), delay));
const rejAfter = (val, delay) => new Promise((_, rej) => setTimeout(() => rej(val), delay));

const promises = [
    resAfter("Third", 3000),
    resAfter("First", 1000),
    rejAfter("Second", 2000), // NOTE: this one rejects!
];

(async () => {
    for await (let result of orderProms(promises)) {
        console.log(JSON.stringify(result));
    }
})().catch(err => console.log(err.message));
trincot
  • 317,000
  • 35
  • 244
  • 286
1

I can not say the accepted answer is wrong but it is not very correct either. Especially the

When there's an exception, the loop stops, the generator is done. Point.

part is problematic.

As per your problem, while modern JS allows us to make some elegant approaches to this problem, in the way you ask, we can still make it work even though i believe it's not... so nice.

PART I - Answer to the Original Question

I will not go into any details but just notice the usage of finally in the generator function orderProms where things still happen (the generator is not done) after an exception is thrown from within. So.. one approach could be like;

async function* orderProms(prom_arr) {

    // Make a copy so the splices don't mess it up.
    var proms = [...prom_arr];

        // Tag each promise with it's index, so that we can remove it for the next loop.
     try {
      while (proms.length) {
            var {prom, index} = await Promise.race(proms.map((prom, index) => prom.then(
                () => ({prom, index}),
                () => ({prom, index})
            )));
            
            proms.splice(index, 1);
            yield prom;
          }
    } finally {
        proms.length && (ordered = orderProms(proms));
      }
}

var resAfter = (val, delay) => new Promise(res => setTimeout(() => res(val), delay)),
    rejAfter = (val, delay) => new Promise((_, rej) => setTimeout(() => rej(val), delay)),
    promises = [ resAfter("Third", 3000)
               , resAfter("First", 1000)
               , rejAfter("Second", 2000) // NOTE: this one rejects!
               ],
    ordered  = orderProms(promises);

async function endPoint() {
    try {
      for await (var value of ordered) {
        console.log(value)
      }
    }
    catch(e){
      console.log(`caught rejection ${e} at endpoint`);
      endPoint();
    }
}

endPoint();

PART II - An Elegeant Solution to the Problem

Now imagine.. what if we have an array which we can populate with promises just the ways we do with an ordinary array and the promises in it automatically gets sorted according to their resolution / rejection time.

To start with let's extend the Array type and give it special async powers. The following code defines the SortedAsyncArray type and just a skeleton. It is not throughly tested but should be sufficient enough to give an idea. Again, pay attention to the finally part since it gets executed only when the yield hangs up due to an exception or exhaustion (generator done case).

class SortedPromisesArray extends Array {
  constructor(...args){
    super(...args);
  };
  async *[Symbol.asyncIterator]() {
    try {
      while(this.length){
        var {v,i} = await Promise.race(this.map((p,i) => p.then(v => ({v,i}))));
        this.splice(i,1);
        yield v;
      }
    } finally {
        this.length && this.splice(i,1);
    };
  };
};

How are we supposed to consume this async array then? The way that i have come up with is as follows.

var promise  = (val, delay, resolves) => new Promise((v,x) => setTimeout(() => resolves ? v(val) : x(val), delay)),
    promises = [ promise("Third",  3000, true)
               , promise("First",  1000, true)
               , promise("Second", 2000, false) // NOTE: this one rejects!
               ],
    sortedPS = new SortedPromisesArray(...promises);

async function sink() {
  try {
    for await (let value of sortedPS){
      console.log(`Got: ${value}`);
    }
  } catch(err) {
    console.log(`caught at endpoint --> exception ${err}`);
    sink();
  };
};
sink();

PART III The Proper Way

What we see in Part II is just fine and elegant however it is abusing Promise.race(). Had this not been an async code that would be a sin. Well... in my book it is still a sin. Why would you race the slowest promise .length many times?

Now we are going to attempt fixing such silliness properly by diving into

  • Private Class Fields to hide those variables that we don't want anybody to mess with.
  • We will even check the provided values to be real promises and throw away the ones which are not promises. Though after checking, you may promisify them if you would like to with a Promise.resolve(notAPromise) statement.
  • Get rid of the finally block in the async *[asyncIterator]() part.

Our magically sorted async array now becomes

class SortedPromisesArray extends Array {
  #RESOLVE;
  #REJECT;
  #COUNT;
  constructor(...args){
    super(...args.filter(p => Object(p).constructor === Promise));
    this.#COUNT = this.length;
    this.forEach(p => p.then(v => this.#RESOLVE(v), e => this.#REJECT(e)));
  };
  async *[Symbol.asyncIterator]() {
    while(this.#COUNT--) {
      yield new Promise((resolve,reject) => ( this.#RESOLVE = resolve
                                            , this.#REJECT  = reject
                                            ));
    };
  };
};

And the sink() function merely stays the same as it was in Part II.

var promise  = (val, delay, resolves) => new Promise((v,x) => setTimeout(() => resolves ? v(val) : x(val), delay)),
    promises = [ promise("Third", 3000, true)
               , promise("First", 1000, true)
               , promise("Second", 2000, false) // NOTE: this one rejects!
               ],
    sortedPS = new SPA(...promises);

async function sink() {
  try {
    for await (let value of sortedPS){
      console.log(`Got: ${value}`);
    }
  } catch(err) {
    console.log(`caught at endpoint --> exception ${err}`);
    sink();
  }
}

sink();

Again... this is not a production code. It's here just for demonstration purposes only yet it is a step forward to a nice design pattern in modern JS/TS.

Redu
  • 25,060
  • 6
  • 56
  • 76
  • @CRice The `Promise.race()` part was annoying me a lot. So i added **Part III** for what i think being the ultima solution. Thanks for the nice question. – Redu Mar 23 '21 at 18:44