6

I have a function F that starts an asynchronous process X. The function returns a promise that is resolved when X ends (which I learn by means of a promise returned by X).

While the (w.l.o.g.) first instance of X, X1, is running, there may be more calls to F. Each of these will spawn a new instance of X, e.g. X2, X3, and so on.

Now, here's the difficulty: When X2 is created, depending on the state of X1, X1 should conclude or be aborted. X2 should start working only once X1 is not active any more. In any case, the unresolved promises returned from all previous calls to F should be resolved only once X2 has concluded, as well - or, any later instance of X, if F gets called again while X2 is running.

So far, the first call to F invokes $q.defer() to created a deferred whose promise is returned by all calls to F until the last X has concluded. (Then, the deferred should be resolved and the field holding it should be reset to null, waiting for the next cluster of calls to F.)

Now, my issue is waiting until all instances of X have finished. I know that I could use $q.all if I had the full list of X instances beforehand, but as I have to consider later calls to F, this is not a solution here. Ideally, I should probably then-chain something to the promise returned by X to resolve the deferred, and "unchain" that function as soon as I chain it to a later instance of X.

I imagine that something like this:

var currentDeferred = null;

function F() {
    if (!currentDeferred) {
        currentDeferred = $q.defer();
    }

    // if previous X then "unchain" its promise handler
    X().then(function () {
        var curDef = currentDeferred;
        currentDeferred = null;
        curDef.resolve();
    });

    return currentDeferred.promise;
}

However, I don't know how to perform that "unchaining", if that is even the right solution.

How do I go about this? Am I missing some common pattern or even built-in feature of promises, or am I on the wrong track altogether?


To add a little context: F is called to load data (asynchronously) and updating some visual output. F returns a promise that should only be resolved once the visual output is updated to a stable state again (i.e. with no more updates pending).

O. R. Mapper
  • 20,083
  • 9
  • 69
  • 114
  • @T.J.Crowder: I just added a sample of how I imagine this to work. – O. R. Mapper Feb 27 '18 at 08:38
  • @t.niese: I just did. I think from the code sample, it becomes apparent that it is not as easy as chaining the new promise to the last one, because once the previous promise has run, my reference to `currentDeferred` is gone, and `currentDeferred` has been resolved (even though it should not if another instance of X is about to follow). – O. R. Mapper Feb 27 '18 at 08:39
  • @T.J.Crowder: No, I'm not saying X1 should wait for X2. I'm saying the promise returned from the call to F that spawned X1 should wait for X2. I have added the word *returned* to clarify that in the second sentence you cite, I am just referringt to the promises returned from calls to `F`, not to the "internal" promises obtained from X. Thanks for the hint. – O. R. Mapper Feb 27 '18 at 08:42
  • Sounds a bit like an XY Problem. The `[...] In any case, the unresolved promises returned from all previous calls to F should be resolved only once X2 has concluded, as well - or, any later instance of X, if F gets called again while X2 is running.[...]` is fishy, because it could lead to infinite pile up. – t.niese Feb 27 '18 at 08:46
  • 1
    "*I should probably `then`-chain something to the promise, and later "unchain" that function*" - yes, that's how it ideally would work, but unfortunately native promises do not support cancellation as "un-chaining" callbacks. You can however try [my promise library `Creed`](https://github.com/bergus/creed) that does support this usage, and [here's an example](https://github.com/bergus/promise-cancellation/blob/master/examples.md#lostfound) that does exactly what you want. – Bergi Feb 27 '18 at 08:46
  • @T.J.Crowder: "It's also impossible, in the general case; X1's processing may finish before the call to F that produces X2." - that's why I wrote "until the last X has concluded. (Then, the deferred should be resolved and the field holding it should be reset to null, waiting for the next cluster of calls to F.)". If X1 finishes before F is called again, the promise returned by X1's F call is resolved, and the system goes inactive until the next call to F. – O. R. Mapper Feb 27 '18 at 08:47
  • "Sounds a bit like an XY Problem." - I have added a brief paragraph on the concrete context. "it could lead to infinite pile up." - I'm not quite seeing that issue. A data update does not automatically cause another data update; only user interaction does. – O. R. Mapper Feb 27 '18 at 08:49
  • 1
    If I understand correctly, you have a variant of the "Chinese plate spinner" problem. In this variant, (1) plates, once started, may not be revived but new plates may be added to keep the act alive, (2) the act is over when all plates have fallen. – Roamer-1888 Feb 27 '18 at 09:53
  • @Roamer-1888: This sounds like a fitting description, but is that the "official" name of said logical/concurrency problem? I can't find anything online about it. – O. R. Mapper Feb 27 '18 at 10:14
  • "Chinese plate spinner" was an old coding exercise given to students years ago, years before promises, or even the web, were ever conceived. The other, more popular one, from the same era was the "Dining philosophers problem". I recall making a real hash of both of them. – Roamer-1888 Feb 27 '18 at 10:31
  • Hava a look at [getting-the-latest-data-from-a-promise-returning-service-called-repeatedly](https://stackoverflow.com/questions/39824408/getting-the-latest-data-from-a-promise-returning-service-called-repeatedly/39872802#39872802). [In my answer](https://stackoverflow.com/a/39872802/4543207) i show a simple implementation of cancellable promises as an extention to the native promises. You may not need a library just for this job. – Redu Feb 27 '18 at 13:20
  • If your `X1` is a special case - an "umbrella" for X2, X3 et seq - then you may be better off with a constructor that you call initially (with or without `new`) to obtain an instance, eg `var foo = new Foo()`. Then, from the instance obtain your umbrella, eg `var X1 = foo.promise()`. Then call some other method to obtain further promises, eg `var X2 = foo.add()`. In addition to the two methods, `Foo()` will consist primarily of the solution offered by T J Crowder. – Roamer-1888 Feb 27 '18 at 14:13
  • @Roamer-1888: No, none of the instances of X is inherently a special case. Arguably, only the last one in a cluster of calls to F happens to be a bit special, in that its conclusion leads to the resolution of the promise returned by F. But basically, each X is exactly the same process. – O. R. Mapper Feb 27 '18 at 14:31
  • Ah, Ok, in that case I have misread the question; or rather X1 being a special case is one interpretation that I can put on it. – Roamer-1888 Feb 27 '18 at 16:36
  • 1
    @Roamer-1888: Thanks for pointing it out. I have added "w.l.o.g." in order to possibly reduce the confusion for readers. – O. R. Mapper Feb 27 '18 at 16:51

1 Answers1

3

F is called to load data (asynchronously) and updating some visual output. F returns a promise that should only be resolved once the visual output is updated to a stable state again (i.e. with no more updates pending).

Since all callers of F will receive a promise they need to consume, but you only want to update the UI when all stacked calls have completed, the simplest thing is to have each promise resolve (or reject) with a value telling the caller not to update the UI if there's another "get more data" call pending; that way, only the caller whose promise resolves last will update the UI. You can do that by keeping track of outstanding calls:

let accumulator = [];
let outstanding = 0;
function F(val) {
  ++outstanding;
  return getData(val)
    .then(data => {
      accumulator.push(data);
      return --outstanding == 0 ? accumulator.slice() : null;
    })
    .catch(error => {
      --outstanding;
      throw error;
    });
}

// Fake data load
function getData(val) {
  return new Promise(resolve => {
    setTimeout(resolve, Math.random() * 500, "data for " + val);
  });
}

let accumulator = [];
let outstanding = 0;
function F(val) {
  ++outstanding;
  return getData(val)
    .then(data => {
      accumulator.push(data);
      return --outstanding == 0 ? accumulator.slice() : null;
    })
    .catch(error => {
      --outstanding;
      throw error;
    });
}

// Resolution and rejection handlers for our test calls below
const resolved = data => {
  console.log("chain done:", data ? ("update: " + data.join(", ")) : "don't update");
};
const rejected = error => { // This never gets called, we don't reject
  console.error(error);
};

// A single call:
F("a").then(resolved).catch(rejected);

setTimeout(() => {
  // One subsequent call
  console.log("----");
  F("b1").then(resolved).catch(rejected);
  F("b2").then(resolved).catch(rejected);
}, 600);

setTimeout(() => {
  // Two subsequent calls
  console.log("----");
  F("c1").then(resolved).catch(rejected);
  F("c2").then(resolved).catch(rejected);
  F("c3").then(resolved).catch(rejected);
}, 1200);
.as-console-wrapper {
  max-height: 100% !important;
}

(That's with native promises; adjust as necessary for $q.)

To me, "don't update" is different from "failed," so I used a flag value (null) rather than a rejection to signal it. But of course, you can use rejection with a flag value as well, it's up to you. (And that would have the benefit of putting the conditional logic ["Is this a real error or just a "don't update"?] in your catch handler rather than your then [is this real data or not?]... Hmmm, I might go the other way now I think of it. But that's trivial change.)

Obviously accumulator in the above is just a crude placeholder for your real data structures (and it makes no attempt to keep the data received in the order it was requested).

I'm having the promise resolve with a copy of the data in the above (accumulator.slice()) but that may not be necessary in your case.

T.J. Crowder
  • 1,031,962
  • 187
  • 1,923
  • 1,875
  • This looks promising (no pun intended). Indeed, I think I can resolve my deferred only if outstanding is 0 rather than signal anything back to earlier callers of F at all, while using this technique to keep track of whether there are any pending invocations of F. – O. R. Mapper Feb 27 '18 at 10:16
  • @O.R.Mapper: Glad that helpes. I believe not resolving the deferred at all would be violating the contract of a promise, which is that it **will** resolve or reject. Having said that, I don't see it in the [Promises/A+ spec](https://promisesaplus.com/). That spec is intentionally minimal, though, focussing on an interoperable `then` method. I also wonder about memory implications, though that may be paranoia on my part (you'll be able to tell easily enough with memory profiling). – T.J. Crowder Feb 27 '18 at 10:29
  • Oh, I *will* resolve the promise returned by the F call that spawned X1, but not in my promise handler at the end of X1 (but only in my promise handler at the end of the last concurrently launched X). Remember that each call of F returns the same promise until there are no more pending calls and the promise gets resolved once. – O. R. Mapper Feb 27 '18 at 16:53