javascriptnode.jsasync-awaitdispose

Convert async callback to an async generator pattern


I have below function from a 3rd party package which I can't modify

async function runTransaction(callback) {
   const client = await createClient();
   try {
       await client.query("BEGIN");
       await callback(client);
   } finally {
       await client.query("COMMIT");
   }
}

Normally I have to create an async function and pass it to the runTransaction

async function update(client) {
   await client.query("UPDATE ...")
}


await runTransaction(update);

I wonder if there is a way to use runTransaction in an async generator pattern. Here is my sketch. The end result I want is BEGIN then UPDATE then COMMIT


async *clientGenerator() {
   await runTransaction(async client => {
      yield client
   });
   // this will not work as yield can't be used in a callback
}


async function main() {
   const dbClientGenerator = await clientGenerator();
   const dbClientHolder = await dbClientGenerator.next();
   await dbClientHolder.value.query("UPDATE ...");
}

I also tried below, but the callback gets called at the same time as await client.query("COMMIT"); in runTransaction.

async *clientGenerator() {
   yield new Promise(resolve => {
      runTransaction(async client => {
          resolve(sql);
      })
    })
}

Any idea how to achieve this?


Solution

  • You could write1

    async function* transaction() {
        const client = await createClient();
        try {
            await client.query("BEGIN");
            yield client;
        } finally {
            await client.query("COMMIT");
        }
    }
    

    and use it as

    for await (const client of transaction()) {
        await client.query("UPDATE ...");
    }
    

    but this is no better (and much more confusing) than the current

    await runTransaction(async client => {
        await client.query("UPDATE ...");
    });
    

    Really there is nothing wrong with this. It is known as the promise disposer pattern and guarantees proper cleanup of resources after you're done. Your main function that manually advances the async iterator is a prime example of what's wrong with this approach: it fails to do error handling and would never commit the transaction. The promise disposer pattern prevents such abuse.

    The pattern may get superseded by disposables from the explicit resource management proposal which will allow writing

    {
        await using client = transaction();
        await client.query("UPDATE ...");
    }
    

    1: or even wrap the runTransaction you're given into an equivalent of this, instead of rewriting it, but this is complicated and I wanted to make the point clear.


    If you absolutely have to do this, here's how:

    async function* transaction() {
        // © Bergi, https://stackoverflow.com/a/78043294/1048572, CC BY-SA 4.0
        let final, result, resume; 
        const client = await new Promise((resolve, reject) => {
            final = runTransaction(client => {
                resolve(client);
                return new Promise(resolve => {
                    resume = resolve;
                });
            })
            final.catch(reject);
        })
        try {
            result = yield client;
        } catch(err) {
            result = Promise.reject(err);
        } finally {
            resume(result);
            return final;
        }
    }
    

    This handles all kinds of possible failure cases in runTransaction and in the callback iteration, but relies on runTransaction calling the callback exactly once (unless there's an error in createClient).