All At Once.

All At Once.

Oct 05, 2023

image

At my company we have a Slack bot that manages a WAF IP block list across various of our cloud provider's accounts and regions. This tool was mostly used by the security team, but we wanted to expand its use to other important stakeholders, such as the product engineering teams and the support engineers. They are often the first to notice when an IP is generating too much noise, or is abusing a system.

The only thing that held us back was how slow the tool was in responding to requests. As I mentioned, our WAF operates across various accounts and regions, therefore multiple API calls must be made to authenticate and then perform the pertinent operations on every account/region in scope. Here is a simplified example of a O(n^2) quadratic time complexity operation with both calls to the cloud provider and to the database.

for (const accountRegion of accountRegions) {
  const credentials = await assumeRole(accountRegion)
  const addresses = await getAddresses(credentials)
  if (addresses) {
    if ('addresses' in addresses) {
      for (const address of addresses.addresses) {
          await getAddressFromDb(address)
        }
      }
    }
  }
}


This is another simplified example, notwithstanding it's complexity is linear, O(n), iterating through all regions takes time and detracts from the user experience.

for (const accountRegion of accountRegions) {
  const credentials = await assumeRole(accountRegion)
  const addresses = await getAddresses(credentials)
  if (addresses) {
    // Do something with addresses          
    await updateIpSet(credentials, addresses)
  }
}

I Got Bored One Day And Put Everything On A Bagel.

image

One of my weaknesses in JavaScript is not taking full advantage of its asynchronous operations features, which enable non-blocking and concurrent execution. By underusing promises and overusing the await keyword, I end up introducing linear programming patterns (synchronous, blocking) and negating the advantages of asynchronicity.

Let's take a look at the refactored versions of the code above and compare total runtime benchmarks.

In the refactored code:

  1. We use Promise.all along with map to iterate over accountRegions and execute the asynchronous operations concurrently.

  2. Each iteration of the loop handles one accountRegion and performs the necessary asynchronous tasks.

  3. Using await inside the loop ensures that the operations within the loop are executed in the desired order, but multiple iterations can run concurrently.

This approach makes the loop more asynchronous and potentially improves performance by allowing multiple iterations to run concurrently.

Old time: 18241.70 ms
New time: 2470.22 ms 🎉

await Promise.all(accountRegions.map(async (accountRegion) => {
  const credentials = await assumeRole(accountRegion)
  const addresses = await getAddresses(credentials)
  if (addresses) {
    if ('addresses' in addresses) {
      for (const address of addresses.addresses) {
          await getAddressFromDb(address)
        }
      }
    }
  }
}))

Old time: 29401.77 ms
New time: 3343.70 ms 🚀

await Promise.all(accountRegions.map(async (accountRegion) => {
  const credentials = await assumeRole(accountRegion)
  const addresses = await getAddresses(credentials)
  if (addresses) {
    // Do something with addresses          
    await updateIpSet(credentials, addresses)
  }
}))

Awesome! Now these are numbers we can live with. Our new changes have yielded an improvement factor of 7.4 and 8.8, respectively, rounded to the nearest tenth.

With snappier response times our product engineers and support teams will feel much more confident in using the Slack bot to block an IP when trouble arises.

Enjoy this post?

Buy LispDev a coffee

More from LispDev