Search code examples
javascriptloopsasynchronousasync-awaitpromise

Wait for a function with an asynchronous call to complete before continuing the loop


I'm trying to synchronize calendars, but the receiving server only wants "bite-sized" data, so I decided to sync PER USER PER MONTH.

Thus, I made a double loop:

First I loop through all users, and within that loop, I loop through the months if the date-range spans multiple months.

However, because the sync function is asynchronous, it executes multiple resources and months at the same time, because the loop doesn't wait for completion.

I know similar questions have been asked before, but for some reason I just cannot get it to work.

Here are my functions:

function loopThroughMonths(resourceIds, startDate, endDate) {
  startDateObject = new Date(startDate);
  endDateObject = new Date(endDate);

  // Check how many months our date range spans:

  var dateRangeMonths = monthDiff(startDateObject, endDateObject);

  if (dateRangeMonths > 0) {
    // Loop through each month

    for (let i = 0; i <= dateRangeMonths; i++) {
      if (i == 0) {
        // For the first month, the starting date is equal to the start of the date range

        var loopStartDate = startDate;
        var loopEndDate = formatDate(
          lastDayOfMonth(
            startDateObject.getFullYear(),
            startDateObject.getMonth(),
          ),
        );
      }

      if (i != 0 && i != dateRangeMonths) {
        var loopMonth = new Date(
          startDateObject.getFullYear(),
          startDateObject.getMonth() + i,
          1,
        );
        var loopStartDate = formatDate(
          firstDayOfMonth(loopMonth.getFullYear(), loopMonth.getMonth()),
        );
        var loopEndDate = formatDate(
          lastDayOfMonth(loopMonth.getFullYear(), loopMonth.getMonth()),
        );
      }

      if (i == dateRangeMonths) {
        // For the last month, the end date is equal to the last date of the date range.

        var loopStartDate = formatDate(
          firstDayOfMonth(
            endDateObject.getFullYear(),
            endDateObject.getMonth(),
          ),
        );
        var loopEndDate = endDate;
      }

      loopThroughResources(resourceIds, 0, loopStartDate, loopEndDate);
    }
  } else {
    // Date range falls within 1 month, just proceed to looping through resources

    loopThroughResources(resourceIds, 0, startDate, endDate);
  }
}

function loopThroughResources(resourceIds, i, loopStartDate, loopEndDate) {
  if (i == resourceIds.length) {
    $("#start_exchange")
      .text("Synchroniseren")
      .removeAttr("disabled")
      .css("cursor", "pointer");
    return;
  }
  var resourceId = resourceIds[i];

  $("#exchange_output").append(
    "Start synchroniseren naar Outlook-agenda van " +
      resourceNames.get(resourceId) +
      " van " +
      loopStartDate +
      " tot " +
      loopEndDate +
      "...<br>",
  );

  $.post(
    "sync.php",
    {
      resourceId: resourceId,
      startDate: loopStartDate,
      endDate: loopEndDate,
    },
    function (response) {
      $("#exchange_output").append(response);
      i = i + 1;
      loopThroughResources(resourceIds, i, loopStartDate, loopEndDate);
    },
  );
}

So to explain:

loopThroughMonths first checks if startDate and endDate differ more than 0 months. If so, it looks through each month. If not, it just immediately executes loopThroughResources.

In case the dateRangeMonths spans multiple months, we loop through them using a for loop and perform the loopThroughResources function for every month.

Thus, if we say:

Synchronise resources A, B, C from 2023-12-27 till 2024-02-16

It will do:

Sync 2023-12-27 till 2023-12-31 (part of december 2023) for resource A
Sync 2023-12-27 till 2023-12-31 (part of december 2023) for resource B
Sync 2023-12-27 till 2023-12-31 (part of december 2023) for resource C
Sync 2024-01-01 till 2024-01-31 (whole january 2023) for resource A
Sync 2024-01-01 till 2024-01-31 (whole january 2023) for resource B
Sync 2024-01-01 till 2024-01-31 (whole january 2023) for resource C
Sync 2024-02-01 till 2024-02-16 (part of february 2023) for resource A
Sync 2024-02-01 till 2024-02-16 (part of february 2023) for resource B
Sync 2024-02-01 till 2024-02-16 (part of february 2023) for resource C

The code works, but it is not waiting for all resources to complete (i.e. before the loopThroughResources function is done) before moving on to the next month.

For the resources, I even made it so that it waits until syncing resource A is complete before it proceeds to resource B, by calling the function from the $.post complete function, but I basically need another wait for the ENTIRE loopThroughResources function (and I'm guessing it needs to be something with Promises.all...)

I know I have to do something with promises, but I just can't get it to work.... Any help would be greatly appreciated.


Solution

  • You'll have a better time if you separate the concerns of what you want to do and doing it:

    • figuring out the "date chunks" for the given range (my date math may be off here, especially sans external libraries)
    • figuring out the jobs, i.e. combinations of resource IDs + chunks
    • sending the requests

    function toDate(start) {
      return start.toISOString().split("T")[0];
    }
    
    function getFirstAndLastDayOfMonth(date) {
      let start = new Date(date);
      let end = new Date(date);
      end.setMonth(end.getMonth() + 1);
      end.setDate(0);
      return {
        start: toDate(start),
        end: toDate(end),
      };
    }
    
    function getDateRanges(startDate, endDate) {
      let startDateObject = new Date(startDate);
      let endDateObject = new Date(endDate);
      let ranges = [];
      let date = new Date(startDateObject);
      date.setDate(15);
      while (date < endDateObject) {
        ranges.push(getFirstAndLastDayOfMonth(date));
        date.setMonth(date.getMonth() + 1);
      }
      // Adjust start and end
      ranges[0].start = toDate(startDateObject);
      ranges[ranges.length - 1].end = toDate(endDateObject);
      return ranges;
    }
    
    function getSyncJobs(resourceIds, startDate, endDate) {
      const jobs = [];
      const ranges = getDateRanges(startDate, endDate);
      for (let resourceId of resourceIds) {
        for (let range of ranges) {
          jobs.push({
            resourceId,
            startDate: range.start,
            endDate: range.end,
          });
        }
      }
      return jobs;
    }
    
    function doJob(params, done) {
      // (do $.post here instead of `setTimeout`...)
      console.log(params);
      setTimeout(done, 500);
    }
    
    function go(done) {
      // Build a queue of sync jobs we consume in `doNextJob`
      const syncJobs = getSyncJobs(
        ["A", "B", "C"],
        "2023-12-27",
        "2024-02-16",
      );
    
      function doNextJob() {
        console.log(`Remaining jobs: ${syncJobs.length}`);
        if (syncJobs.length === 0) {
          return done();
        }
        const job = syncJobs.shift();
        doJob(job, doNextJob);
      }
    
      doNextJob();
    }
    
    go(function () {
      console.log("All done!");
    });

    If you can promisify $.post into something you can await, you can get rid of the callbacks altogether and the execution becomes just

    async function doJob(job) {
        console.log(job);
        await new Promise(resolve => setTimeout(resolve, 500));
    }
    
    async function go() {
      // Build a queue of sync jobs
      const syncJobs = getSyncJobs(
        ["A", "B", "C"],
        "2023-12-27",
        "2024-02-16",
      );
    
      for(let job of syncJobs) {
          await doJob(job);
      }
      console.log("All done!");
    }