Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Canceling flows #1542

Merged
merged 14 commits into from Jul 2, 2018
Merged

feat: Canceling flows #1542

merged 14 commits into from Jul 2, 2018

Conversation

aearly
Copy link
Collaborator

@aearly aearly commented Jun 5, 2018

Closes #1064

This allows you to signal that you have exited early from an async flow by callback(false) --- calling back with false as the error. All further callbacks will be ignored, and the final callback will not be called. This is handy to avoid a subtle memory leak when you exit early.

  • eachOfLimit - based methods (e.g. all collection-based methods)
  • compose/seq
  • waterfall
  • auto/autoInject
  • whilst, until, and family
  • forever
  • ?? (any I'm missing?)
  • Docs

@aearly aearly changed the title [wip] Canceling flows feat: Canceling flows Jun 11, 2018
@aearly aearly added the feature label Jun 11, 2018
@aearly aearly added this to the 3.0 milestone Jun 11, 2018
@aearly
Copy link
Collaborator Author

aearly commented Jun 11, 2018

This is ready!

Copy link
Collaborator

@hargasinski hargasinski left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is probably the change I'm looking forward to the most in v3, really excited about it!

The behaviour seems a little unexpected though. I assumed the final callback would still be called, with partial results, similar to the way breakLoop works for the collection methods?

I think the only function missing is eachOfArrayLike in lib/eachOf. Other than that, should retry/retryable also be cancelable?

@@ -189,6 +190,10 @@ export default function (tasks, concurrency, callback) {

var taskCallback = onlyOnce(function(err, result) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Higher up in the runTask function, we should change if (hasError) return; to if (hasError || canceled) return; so other tasks, not dependent on this one, won't continue running.

lib/waterfall.js Outdated
@@ -75,6 +76,10 @@ export default function(tasks, callback) {
}

function next(err/*, ...args*/) {
if (err === false || canceled) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I think this can be simplified to if (err === false) return. next is wrapped in onlyOnce so I don't believe there's a situation were it would be called twice.

@@ -168,6 +168,31 @@ describe('auto', function () {
setTimeout(done, 100);
});

it('auto canceled', function(done){
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should also add a test for ensuring other functions don't get started when one is canceled. Something along the lines of

it('does not start other tasks when it has been canceled', function(done) {
  const call_order = []
  async.auto({
    task1: function(callback) {
      call_order.push(1);
      // defer calling task2, so task3 has time to stop execution
      async.setImmediate(callback);
    },
    task2: ['task1', function(/*results, callback*/) {
      call_order.push(2);
      throw new Error('task2 should not be called');
    }],
    task3: function(callback) {
      call_order.push(3);
      callback(false);
    },
    task4: ['task3', function(/*results, callback*/) {
      call_order.push(4);
      throw new Error('task4 should not be called');
    }]
  },
  function() {
    throw new Error('should not get here')
  });

  setTimeout(() => {
    expect(call_order).to.eql([1,3])
    done()
  }, 25)
});

@@ -34,6 +34,22 @@ describe('during', function() {
);
});

it('during canceling', (done) => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we also add a test for canceling during in the check?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're going to deprecate during, making all whilst/until functions have an async test function, so no need for comprehensive test coverage.

Copy link
Collaborator

@megawac megawac left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left some minor nits but looks promising overall, I'm excited to try and find uses for this in my projects

intro.md Outdated
@@ -173,6 +173,40 @@ async.map([1, 2, 3], AsyncSquaringLibrary.square.bind(AsyncSquaringLibrary), fun
});
```

### Subtle Memory Leaks

There are cases where you might want to exit early from async flow, when calling an Async method insice another async function:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo -insice/+inside

else if (value === breakLoop || (done && running <= 0)) {
done = true;
if (canceled) return
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move to top of iterateeCallback for simplicity? (it would also let you avoid the !canceled check in the error condition)

@@ -102,6 +102,7 @@ export default function (tasks, concurrency, callback) {

var results = {};
var runningTasks = 0;
var canceled = false;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo should be cancelled

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Either spelling is correct. I chose one 'l' for consistency.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

damn americans

@aearly
Copy link
Collaborator Author

aearly commented Jul 1, 2018

Ok, I believe I've addressed all your concerns. Thanks for the review!

And yes, I believe not calling the final callback is what we want here, as a mechanism to avoid memory leaks. In what cases would you want to break iteration, and have the final callback called?

@aearly aearly merged commit 53f6130 into master Jul 2, 2018
@aearly aearly deleted the canceling branch July 2, 2018 00:12
Copy link
Collaborator

@megawac megawac left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know you've already merged this but can we try to be consistent about semi-colon usage please.


it('does not start other tasks when it has been canceled', function(done) {
const call_order = []
debugger
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Misc debugger statement

@hargasinski
Copy link
Collaborator

That's a good point @aearly. Thanks for making the changes, lgtm!

@nathaniel-holder
Copy link

Ok, I believe I've addressed all your concerns. Thanks for the review!

And yes, I believe not calling the final callback is what we want here, as a mechanism to avoid memory leaks. In what cases would you want to break iteration, and have the final callback called?

@aearly In my code, the final callback of a waterfall calls the parent callback, so it would break my existing code to use this cancel feature. For example:

function handler(params, done) {
    async.waterfall(
        [
            function (callback) {
                console.log('step 1');
                return callback();
            },
            function (callback) {
                console.log('step 2');
                // skip remaining steps
                return callback(false);
            },
            function (callback) {
                console.log('step 3');
                return callback();
            }
        ],
        function (err) {
            console.log('final callback');
            if (err) return done(err);
            return done(null, 'data');
        }
    );
}
handler(null, (err, data) => console.log('This will never be called. err: %j, data: %j', err, data));

Is this not a use case for others?

@yeya
Copy link
Contributor

yeya commented Feb 23, 2020

This feature breaks the basic node API, passing false to the callback should not be treated differently than null, undefined, or any other falsy item.
It's not enough to add a line in the docs, sometimes you pass functions you didn't write by yourself.
Is there an option to cancel this behavior?

@ghost
Copy link

ghost commented Jun 17, 2020

@aearly Can you take a look at @nathaniel-holder and @yeya comment?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

A solution for stoping a flow after an early exit
5 participants