Skip to content

Instantly share code, notes, and snippets.

@eliasmalik
Last active August 25, 2019 14:47
Show Gist options
  • Save eliasmalik/115b534b1f22be94d3e0341612319b10 to your computer and use it in GitHub Desktop.
Save eliasmalik/115b534b1f22be94d3e0341612319b10 to your computer and use it in GitHub Desktop.
Piped promise-returning functions with undo steps

Problem

I want a function to pipe together a series of promise returning functions. However, I also want to specify an undo function for each piped function. If any function returns a rejected promise, the undo function corresponding to that step should be called.

I'm free to let my undo function return a resolved promise, which indicates a recovery and continues down the pipe, or a rejected promise, which results in breaking the promise chain and skipping to the catch statement (if any).

Based on this SO answer, this is the form of the solution if you know how many steps you have:

steps[1]
  .then(
    (...args) => steps[2](...args).then(null, undos[2]),
    undos[1]
  )
  .then(
    (...args) => steps[3](...args).then(null, undos[3]),
    undos[2]
  )
  .then(
    (...args) => steps[4](...args).then(null, undos[4]),
    undos[3]
  );

Seems like there's some redundancy in this though, I think it can be simplified as:

steps[1]
  .then((...args) => steps[2](...args).then(null, undos[2]), undos[1])
  .then((...args) => steps[3](...args).then(null, undos[3]))
  .then((...args) => steps[4](...args).then(null, undos[4]));

or even (just for symmetry):

Promise.resolve()
  .then((...args) => steps[1](...args).then(null, undos[1]))
  .then((...args) => steps[2](...args).then(null, undos[2]))
  .then((...args) => steps[3](...args).then(null, undos[3]))
  .then((...args) => steps[4](...args).then(null, undos[4]));

I want a general solution that will allow me to pass an arbitrary number of functions in.

Current Solution

I think this can be generalised using the following function:

const head = (xs) => xs[0];
const tail = (xs) => xs.slice(1);

const pipePUndo = (steps, undos) => (...args) => {
  const piper = (p, s, u) =>
    s.length === 0
      ? p
      : piper(
          p.then((...vargs) => head(s)(...vargs).then(null, head(u))),
          tail(s),
          tail(u)
        );
  return piper(Promise.resolve(...args), steps, undos);
};

But this just runs a single undo function. What if I wanted a more "transaction"-like behaviour, where all undo functions for steps up and including the failing one are run (in reverse order)? Then I'd need something closer to this:

const { pipeP } = require('ramda');

/**
 * @param {Object[]} steps
 * @param {Function} steps[].do
 * @param {Function} steps[].undo
 * @returns {Function}
 */
const pipePUndo = (steps) => (...args) => {
  const helper = (p, s, u) =>
    s.length === 0
      ? p
      : helper(
          p.then((...vargs) => head(s).do(...vargs).then(null, pipeP(...[head(s).undo].concat(u)))),
          tail(s),
          [head(s).undo].concat(u)
        );
  return helper(Promise.resolve(...args), steps, [(...vargs) => Promise.reject(...vargs)]);
};

EDIT:

Turns out the above isn't quite right and is also over-complicated. The following actually works:

const { pipeP } = require('ramda');

// Used to force the Promise returned by pipePWithUndo reject if there have been any failures
// in the "do" tasks, even if all the "undo" tasks resolve successfully.
const abort = (fn) => (err) => fn(err).then(() => Promise.reject(err));

const pipePWithUndo = (steps) => {
  const dos = steps.map((step) => step.do);
  const undos = steps.map((step) => step.undo);
  const undoChain = undos.map((_, i) => pipeP(...undos.slice(0, i + 1).reverse()));

  return _piper(dos, [null, ...undoChain]);
}

const _piper = (dos, undos, promise = Promise.resolve()) => {
  if (dos.length !== undos.length - 1) {
    throw new Error('Undos array must have prepended "null" value');
  }

  if (dos.length === 1) {
    return promise
      .then((res) => dos[0](res).then(null, abort(undos[1])), abort(undos[0]));
  }

  return _piper(
    dos.slice(1),
    undos.slice(1),
    promise.then(dos[0], undos[0])
  )
}

The undo functions should normally return resolving promises. If they return rejected promises, the chain will be broken. For example, if steps[3].do rejects, and steps[1].undo rejects, the order of execution will be:

steps[0].do
steps[1].do
steps[2].do
steps[3].do
steps[3].undo
steps[2].undo
steps[1].undo

This is kind of cool, but it seems like too much can still go wrong with this approach -- it shouldn't be relied on for anything serious.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment