Skip to content

fix: replace all async foreach callbacks#10157

Merged
zomars merged 7 commits intocalcom:mainfrom
nicktrn:fix/cq-async-foreach
Aug 26, 2023
Merged

fix: replace all async foreach callbacks#10157
zomars merged 7 commits intocalcom:mainfrom
nicktrn:fix/cq-async-foreach

Conversation

@nicktrn
Copy link
Copy Markdown
Contributor

@nicktrn nicktrn commented Jul 14, 2023

What does this PR do?

  • forEach(asyncFn) => await Promise.all(map(asyncFn))
  • some error logging and refactoring
  • add eslint rule to prevent reoccurence

Fixes #10156
/claim #10157

Also fixes:

  • flaky tests (possibly)
  • longterm outstanding issues (likely)
  • code quality (definitely)
  • tears, sweat, and banging your head against the wall (definitely)

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • Chore (refactoring code, technical debt, workflow improvements)

Caveat

Since we wait for promises to resolve, this will add parent function execution time - hopefully not much. In any case, this is likely preferable to unpredictable results.

Todo

  • add eslint rule to prevent reoccurence
  • fix similar issues
    • async function calls without await - there are many
    • implicit async .forEach with promise return
  • rfc: consider additional eslint rules
    • no-async-promise-executor
    • no-await-in-loop
    • no-return-await
    • require-await
  • rfc: task queues with error handling, concurrency limits, timeout, retry, rollback, etc
  • rfc: use queues to add real background task runners
  • rfc: standardise error logging / compliance

Mandatory Tasks

  • Make sure you have self-reviewed the code. A decent size PR without self-review might be rejected.

Checklist

  • I haven't added tests that prove my fix is effective or that my feature works
  • I haven't checked if new and existing unit tests pass locally with my changes

@vercel
Copy link
Copy Markdown

vercel bot commented Jul 14, 2023

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
ui ❌ Failed (Inspect) Aug 26, 2023 0:38am
1 Ignored Deployment
Name Status Preview Comments Updated (UTC)
cal ⬜️ Ignored (Inspect) Visit Preview Aug 26, 2023 0:38am

@vercel
Copy link
Copy Markdown

vercel bot commented Jul 14, 2023

@nicktrn is attempting to deploy a commit to the cal Team on Vercel.

A member of the Team first needs to authorize it.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Jul 14, 2023

Thank you for following the naming conventions! 🙏

@github-actions github-actions bot added the 🐛 bug Something isn't working label Jul 14, 2023
@nicktrn nicktrn changed the title Fix: WIP: replace all async foreach callbacks fix: WIP: replace all async foreach callbacks Jul 14, 2023
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Jul 14, 2023

📦 Next.js Bundle Analysis for @calcom/web

This analysis was generated by the Next.js Bundle Analysis action. 🤖

This PR introduced no changes to the JavaScript bundle! 🙌

);

const promises = bookingRefsFiltered.map(async (bookingRef) => {
if (!bookingRef.uid) return;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

early return is preferable

});
await Promise.all(promises);
} catch (error) {
// FIXME: error logging - non-Error type errors are currently discarded
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

probably just a way around ts complaints, Promise.all will always throw instanceof Error

) {
try {
//schedule job to call subscriber url at the end of meeting
// FIXME: in-process scheduling - job will vanish on server crash / restart
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

just for future reference

appId?: string | null,
isReschedule?: boolean
) {
try {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

less nesting

if (
callee.type === "MemberExpression" &&
callee.property.type === "Identifier" &&
callee.property.name === "forEach"
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

There is no type check here, so other non-Array forEach method calls may also be flagged. Should not be an issue for this project.

const bookingRefsFiltered: BookingReference[] = bookingToReschedule.references.filter((ref) =>
credentialsMap.has(ref.type)
);
const pCancellations = bookingRefsFiltered.map(async (bookingRef) => {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

another async forEach callback

Comment on lines -259 to -273
steps.forEach(async (step) => {
if (step.action !== WorkflowActions.SMS_ATTENDEE && step.action !== WorkflowActions.WHATSAPP_ATTENDEE) {
const pSteps = steps.map(async (step) => {
if (
step.action !== WorkflowActions.SMS_ATTENDEE &&
step.action !== WorkflowActions.WHATSAPP_ATTENDEE
) {
//as we do not have attendees phone number (user is notified about that when setting this action)
bookingsForReminders.forEach(async (booking) => {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

nested async forEach, when you think you've seen it all

Comment on lines +366 to +380
await ctx.prisma.workflowsOnEventTypes.createMany({
data: activeOnEventTypes.map((eventType) => ({
workflowId: id,
eventTypeId: eventType.id,
})),
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

this should run a lot quicker - only one query

Copy link
Copy Markdown
Member

@hariombalhara hariombalhara Aug 24, 2023

Choose a reason for hiding this comment

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

Let's do it as a separate performance optimization in another PR.
-- Edit
Nevermind, I think it's needed to solve the problem correctly. So, let's keep it.

Comment on lines +372 to +383
const pChildren = activeOnEventTypes.map((eventType) =>
ctx.prisma.workflowsOnEventTypes.createMany({
data: eventType.children.map((chEventType) => ({
workflowId: id,
eventTypeId: chEventType.id,
})),
})
);
await Promise.all(pChildren);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

so should this

Comment on lines +458 to +474
} else if (reminder.method === WorkflowMethods.WHATSAPP) {
deleteScheduledWhatsappReminder(reminder.id, reminder.referenceId);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

seems this was forgotten here, but similar calls found elsewhere in the file

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

That's interesting. @CarinaWolli can you confirm that the flow was missing whatsapp here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@nicktrn it can be a separate PR though to fix it, so that this change doesn't block this PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think there have been quite a few changes here since the last merge, probably fixed now!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not fixed on main, will revert it for now.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I was also thinking that maybe classes would make sense here. We can have classes for all these channels(Whatsapp, SMS, Email) and the code here would simply do channel.deleteReminder. This would avoid having these if elses everywhere. This code is just too much involved with what's going on.

But classes might increase complexity in some other way.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

That's interesting. @CarinaWolli can you confirm that the flow was missing whatsapp here.

Yes, definitely looks like this was missing here. Thanks for finding that 🙏

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Back in it goes 😄

@nicktrn
Copy link
Copy Markdown
Contributor Author

nicktrn commented Jul 17, 2023

Came across even more instances of this in different forms. It's impossible to tell in many places if this is desired behaviour or not.

Might make sense to be explicit by requiring the use of a wrapper function like this:

type NoArgFunction = () => unknown;

const asyncWrap = async (fn: NoArgFunction) => fn();

export const sendToBackgroundAndNeverThrow = (fn: NoArgFunction | NoArgFunction[]): void => {
  if (typeof fn === "function") {
    Promise.allSettled([asyncWrap(fn)]);
  } else {
    Promise.allSettled(fn.map(asyncWrap));
  }
};

Having to call sendToBackgroundAndNeverThrow(() => someTask(args)) would make the intentions very clear. Those calls can then also be more easily found and migrated whenever background task queues are implemented.

@nicktrn
Copy link
Copy Markdown
Contributor Author

nicktrn commented Aug 10, 2023

Closing as this was rather premature, and now outdated.

@keithwillcode
Copy link
Copy Markdown
Contributor

Closing as this was rather premature, and now outdated.

This is a great initiative @nicktrn. Want to pick it back up?

/bounty 50

@algora-pbc
Copy link
Copy Markdown

algora-pbc bot commented Aug 24, 2023

💎 $50 bounty created by keithwillcode
👉 No need to comment asking to work on it. Just open a PR and claim the bounty with /claim #10157 inside the PR
📝 Before proceeding, please make sure you can receive payouts in your country
💵 Payment arrives in your account 2-5 days after the bounty is rewarded
💯 You keep 100% of the bounty award
🙏 Thank you for contributing to calcom/cal.com!

@algora-pbc algora-pbc bot added the 💎 Bounty A bounty on Algora.io label Aug 24, 2023
});

steps.forEach(async (step) => {
const pSteps = steps.map(async (step) => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
const pSteps = steps.map(async (step) => {
const promiseSteps = steps.map(async (step) => {

Let's be clear here.

@nicktrn
Copy link
Copy Markdown
Contributor Author

nicktrn commented Aug 24, 2023

@keithwillcode Sure thing, will try to resolve those merge conflicts first of all. It's been a while.

There are some decisions to be made on how to proceed though:

  1. Should this PR only deal with async forEach or un-awaited promises in general?
  2. I can replace all occurences with await Promise.all() but this could mean adding execution time to very commonly accessed routes. Is this desired?

An alternative could be to add a wrapper as suggested in #10157 (comment) for now, and include error logging.

@nicktrn
Copy link
Copy Markdown
Contributor Author

nicktrn commented Aug 24, 2023

@hariombalhara thanks for the review pointers so far, will help to keep this focused.

deleteScheduledWhatsappReminder(reminder.id, reminder.referenceId);
}
});
await Promise.all(pDeleteReminders);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

How do you think about wrapping the code itself in Promise.all vs storing the result in pDeleteReminders and then doing await.

I find the earlier approach easier to read.

Copy link
Copy Markdown
Member

@hariombalhara hariombalhara Aug 24, 2023

Choose a reason for hiding this comment

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

So this becomes,

	await Promise.all(remindersToUpdate.map((reminder) => {
	        if (reminder.method === WorkflowMethods.EMAIL) {
	          deleteScheduledEmailReminder(reminder.id, reminder.referenceId);
	        } else if (reminder.method === WorkflowMethods.SMS) {
	          deleteScheduledSMSReminder(reminder.id, reminder.referenceId);
	        } else if (reminder.method === WorkflowMethods.WHATSAPP) {
	          deleteScheduledWhatsappReminder(reminder.id, reminder.referenceId)
               }
        })

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Sure that works. For longer mapping functions I tend to prefer prior assignment, but this is rather short.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Will actually revert the whole thing. One of those implicit async calls that could probably do better with Promise.allSettled() in another PR. Error handling should really be discussed.

try {
let scheduledJobs = booking.scheduledJobs || [];
let scheduledJobs = booking.scheduledJobs || [];
if (!booking.scheduledJobs) return;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What's the point of fallback initialization with empty array if we are still early returning in that case.

@hariombalhara
Copy link
Copy Markdown
Member

@keithwillcode Sure thing, will try to resolve those merge conflicts first of all. It's been a while.

There are some decisions to be made on how to proceed though:

  1. Should this PR only deal with async forEach or un-awaited promises in general?
  2. I can replace all occurences with await Promise.all() but this could mean adding execution time to very commonly accessed routes. Is this desired?

An alternative could be to add a wrapper as suggested in #10157 (comment) for now, and include error logging.

  1. I think we can do it in two parts because unawaited promises in general is difficult to find out without eslint plugin setup(I guess?)
  2. That should be fine IMHO. The alternate approach isn't easy to read and difficult to reason a bit. We should go for that if that really makes sense.

});
await Promise.all(pCancellations);
// FIXME: error-handling
await Promise.allSettled(
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Think this one is better suited for allSettled. Failure to delete a meeting should probably not prevent reschedule.

@nicktrn
Copy link
Copy Markdown
Contributor Author

nicktrn commented Aug 24, 2023

  1. I think we can do it in two parts because unawaited promises in general is difficult to find out without eslint plugin setup(I guess?)
  2. That should be fine IMHO. The alternate approach isn't easy to read and difficult to reason a bit. We should go for that if that really makes sense.
  1. ESLint will definitely make this easier!
  2. Have gone with that for now.

Have to say I'm quite worried by this PR. Will definitely need to be followed up with proper error handling / retries, at the very least additional logging.

A big issue is that those async calls are now back in function scope, i.e. any rejected promise will prevent normal return. The good thing is this should highlight previously hidden errors.

Are there any plans for background tasks? Think I saw some mention of redis-backed solutions a while back. Could also consider Trigger.dev.

@nicktrn nicktrn marked this pull request as ready for review August 24, 2023 23:53
@nicktrn nicktrn changed the title fix: [RFC] replace all async foreach callbacks fix: replace all async foreach callbacks Aug 24, 2023
@keithwillcode
Copy link
Copy Markdown
Contributor

@keithwillcode Sure thing, will try to resolve those merge conflicts first of all. It's been a while.
There are some decisions to be made on how to proceed though:

  1. Should this PR only deal with async forEach or un-awaited promises in general?
  2. I can replace all occurences with await Promise.all() but this could mean adding execution time to very commonly accessed routes. Is this desired?

An alternative could be to add a wrapper as suggested in #10157 (comment) for now, and include error logging.

  1. I think we can do it in two parts because unawaited promises in general is difficult to find out without eslint plugin setup(I guess?)
  2. That should be fine IMHO. The alternate approach isn't easy to read and difficult to reason a bit. We should go for that if that really makes sense.

Agree with this approach 🙏🏼. @nicktrn make sure you claim this PR so you get the bounty pay out when it merges

@nicktrn
Copy link
Copy Markdown
Contributor Author

nicktrn commented Aug 25, 2023

Ah yes, thanks @keithwillcode. Appreciate the gesture but really wasn't necessary!

@algora-pbc
Copy link
Copy Markdown

algora-pbc bot commented Aug 25, 2023

💡 @nicktrn submitted a pull request that claims the bounty. You can visit your org dashboard to reward.

zomars
zomars previously approved these changes Aug 26, 2023
Copy link
Copy Markdown
Contributor

@zomars zomars left a comment

Choose a reason for hiding this comment

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

LGTM. Thank you for your contribution 🙏

Ship it

@zomars zomars enabled auto-merge (squash) August 26, 2023 00:34
@zomars zomars merged commit c1bcfcf into calcom:main Aug 26, 2023
@algora-pbc
Copy link
Copy Markdown

algora-pbc bot commented Aug 26, 2023

🎉🎈 @nicktrn has been awarded $50! 🎈🎊

@algora-pbc algora-pbc bot added the 💰 Rewarded Rewarded bounties on Algora.io label Aug 26, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

💎 Bounty A bounty on Algora.io 🐛 bug Something isn't working 💰 Rewarded Rewarded bounties on Algora.io

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Async forEach callbacks cause unpredictable results and prevent error handling

5 participants