Add milliseconds delay to Array.map calls which re

2019-02-14 06:45发布

问题:

My need is simple. I would like to delay calls to sendEmail by 100 milliseconds. The email service provider permits at most sending 10 emails per second.

Note, however, though .map is synchronous, it immediately returns a Promise.

I have tried setTimeout to no avail, such as setTimeout(() => resolve(x), 100) and setTimeout(() => {return new Promise....}, 100).

Thoughts?

const promises = userEmailArray.map((userEmail) => {
  return new Promise((resolve, reject) => {
      ....
      mailer.sendEmail(userEmail);
      return resolve();
    });
  });
});
...
Promise.all(promises).then(() => resolve()).catch(error => reject(error));

回答1:

There are a bunch of different ways to approach this. I'd probably just use a recursive chained promise myself and then you can more precisely use a timer based on the finish from the previous call and you can use promises for calling it and handling propagation of errors.

I've assumed here that your mailer.sendEmail() follows the node.js callback calling convention so we need to "promisify" it. If it already returns a promise, then you can use it directly instead of the sendEmail() function that I created.

Anyways, here are a bunch of different approaches.

Recall Same Function After Delay (delayed recursion)

// make promisified version - assumes it follows node.js async calling convention
let sendEmail = util.promisify(mailer.sendEmail);

function delay(t, data) {
    return new Promise(resolve => {
        setTimeout(resolve.bind(null, data), t);
    });
}

function sendAll(array) {
    let index = 0;
    function next() {
        if (index < array.length) {
            return sendEmail(array[index++]).then(function() {
                return delay(100).then(next);
            });
        }        
    }
    return Promise.resolve().then(next);
}

// usage
sendAll(userEmailArray).then(() => {
    // all done here
}).catch(err => {
    // process error here
});

Use setInterval to Control Pace

You could also just use a setInterval to just launch a new request every 100ms until the array was empty:

// promisify
let sendEmail = util.promisify(mailer.sendEmail);

function sendAll(array) {
    return new Promise((resolve, reject) => {
        let index = 0;
        let timer = setInterval(function() {
            if (index < array.length) {
                sendEmail(array[index++]).catch(() => {
                    clearInterval(timer);
                    reject();                        
                });
            } else {
                clearInterval(timer);
                resolve();
            }
        }, 100);
    })
}

Use await to Pause Loop

And, you could use await in ES6 to "pause" the loop:

// make promisified version - assumes it follows node.js async calling convention
let sendEmail = util.promisify(mailer.sendEmail);

function delay(t, data) {
    return new Promise(resolve => {
        setTimeout(resolve.bind(null, data), t);
    });
}

// assume this is inside an async function    
for (let userEmail of userEmailArray) {
    await sendEmail(userEmail).then(delay.bind(null, 100));
}

Use .reduce() with Promises to Sequence Access to Array

If you aren't trying to accumulate an array of results, but just want to sequence, then a canonical ways to do that is using a promise chain driven by .reduce():

// make promisified version - assumes it follows node.js async calling convention
let sendEmail = util.promisify(mailer.sendEmail);

function delay(t, data) {
    return new Promise(resolve => {
        setTimeout(resolve.bind(null, data), t);
    });
}

userEmailArray.reduce(function(p, userEmail) {
    return p.then(() => {
        return sendEmail(userEmail).then(delay.bind(null, 100));
    });
}, Promise.resolve()).then(() => {
    // all done here
}).catch(err => {
    // process error here
});

Using Bluebird Features for both Concurrency Control and Delay

The Bluebird promise library has a couple useful features built in that help here:

const Promise = require('Bluebird');
// make promisified version - assumes it follows node.js async calling convention
let sendEmail = Promise.promisify(mailer.sendEmail);

Promise.map(userEmailArray, userEmail => {
    return sendEmail(userEmail).delay(100);
}, {concurrency: 1}).then(() => {
    // all done here
}).catch(err => {
    // process error here
});

Note the use of both the {concurrency: 1} feature to control how many requests are in-flight at the same time and the built-in .delay(100) promise method.


Create Sequence Helper That Can Be Used Generally

And, it might be useful to just create a little helper function for sequencing an array with a delay between iterations:

function delay(t, data) {
    return new Promise(resolve => {
        setTimeout(resolve.bind(null, data), t);
    });
}

async function runSequence(array, delayT, fn) {
    for (item of array) {
        await fn(item).then(data => {
            return delay(delayT, data);
        });
    }
}

Then, you can just use that helper whenever you need it:

// make promisified version - assumes it follows node.js async calling convention
let sendEmail = util.promisify(mailer.sendEmail);

runSequence(userEmailArray, sendEmail, 100).then(() => {
    // all done here
}).catch(err => {
    // process error here
});


回答2:

You already have a 'queue' of sorts: a list of addresses to send to. All you really need to do now is pause before sending each one. However, you don't want to pause for the same length of time prior to each send. That'll result in a single pause of n ms, then a whole raft of messages being sent within a few ms of each other. Try running this and you'll see what I mean:

const userEmailArray = [ 'one', 'two', 'three' ]
const promises = userEmailArray.map(userEmail =>
  new Promise(resolve =>
    setTimeout(() => {
      console.log(userEmail)
      resolve()
    }, 1000)
  )
)
Promise.all(promises).then(() => console.log('done'))

Hopefully you saw a pause of about a second, then a bunch of messages appearing at once! Not really what we're after.

Ideally, you'd delegate this to a worker process in the background so as not to block. However, assuming you're not going to do that for now, one trick is to have each call delayed by a different amount of time. (Note that this does not solve the problem of multiple users trying to process large lists at once, which presumably is going to trigger the same API restrictions).

const userEmailArray = [ 'one', 'two', 'three' ]
const promises = userEmailArray.map((userEmail, i) =>
  new Promise(resolve =>
    setTimeout(() => {
      console.log(userEmail)
      resolve()
    }, 1000 * userEmailArray.length - 1000 * i)
  )
)
Promise.all(promises).then(() => console.log('done'))

Here, you should see each array element be processed in roughly staggered fashion. Again, this is not a scaleable solution but hopefully it demonstrates a bit about timing and promises.



回答3:

It's simpler to just do an async iteration of the array.

function send(i, arr, cb) {
  if (i >= arr.length) return cb();

  mailer.sendEmail(arr[i]);
  setTimeout(send, 100, i+1, arr, cb);
}
send(0, userEmailArray, function() { console.log("all done") });