A Path to Services - Part 2 - Synchronous vs. Asynchronous
This article was originally posted on the PipelineDeals Engineering Blog
In the previous article in this series, we moved the responsibility of emails to a separate Rails application. In order to leverage this new service, we created a PORO to encapsulate the specifics of communicating with our new service by taking advantage of Sidekiq’s built-in retry mechanism to protect from intermittent network issues.
Communication between microservices can be broken down in to 2 categories: synchronous and asynchronous. Understanding when to use each is critical in maintaining a healthy infrastructure. This post will explore details about these two methods of communication and their associated use cases.
Background
Continuing the discussion of our architecture from last time, we have a primary Rails web application serving the majority of our business logic. We now have an additional application that’s only responsibility is the formatting and sending of emails.
In this article, we’ll discuss the addition of our Billing service. The service’s responsibility to is to process transactions related to money. This can come in the form of a trial conversion, adding a seat to an additional account, or deleting users from an existing account, among others.
Like many SaaS applications, PipelineDeals has multiple tiers of service. The most expensive intended for customers needing advanced functionality. Part of the billing service’s responsibility is to manage the knowledge of which features an account can access.
So stepping back to the main PipelineDeals web application, the app has to decide which features to render at page load. Because the billing service is our source of truth for this information, a page load will now require a call to this service to understand which features to render.
This new dependency looks a little different than the email dependency from the previous article. Email has the luxury of not being in the dependency path of a page load. Very few customers will complain if an email is 10 seconds late. On the other hand, they’ll complain immediately if their account won’t load, and rightfully so.
An interesting benefit from having already extracted the email service is that the billing service sends email regarding financial transactions and account changes. Typically, we would have done the same thing for every other Rails app that needed to send email, which was integrate ActionMailer
and setup the templates and mailers needed to do the work. In this case, we can add those emails to the email service and use the same communication patterns we do from the main web application to trigger the sending of an email from the billing service. This does require making changes to 2 different projects for a single feature (business logic in billing and mailer in email), but removes the necessity to configure another app to send email properly. We viewed this as a benefit.
Asynchronous Events
As the easier of the two, asynchronous would be any communication not necessary for the request/response cycle to complete. Email is the perfect example. Logging also falls in to this category.
For the networks gurus out there, this would be similar to UDP communication. More of a fire-and-forget approach.
An email, in this case, is triggered due to something like an account sign up. We send a welcome email thanking the customer for signing up and giving them some guidance on how to get the most benefit from the application. Somewhere in the process of signing up, the code triggers an email and passes along the data needed for email template.
As shown in the previous article, the call to send the email looks something like this:
The value in this call is that under the covers, it’s enqueuing a Sidekiq job:
where opts
is a hash of data related to the email and the variables needed for the template.
Note: Because the options are serialized to JSON, values in hash must be simple structures. Objects won’t work here.
As you can see above, the code invoking the Email.to
method doesn’t care about what it returns. In fact, it doesn’t return anything we care about at this point. So as long as the method is called, the code can move forward without waiting for the email to finish sending.
Extracting asynchronous operations like this that exist in a code path is a great way to improve performance. There are times, though, where deferring an operation to background job might not make sense.
For example, imagine a user changes the name of a person. They click one of their contact’s names, enter a new name, and click “Save”. It doesn’t make sense to send the task of updating the actual name in the database to a background job because depending on what else is in the queue at that time, the update might not complete until after the next refresh, which would make the user believe their update wasn’t successful. This would be incredibly confusing.
Logging is another perfect candidate for asynchronicity. In most cases, our users don’t care if a log of their actions has been written to the database before their next refresh. It’s information we may want to store, and as a result, can be a fire-and-forget operation. We can rest easy knowing we’ll have that information, soon-ish, and it won’t add any additional overhead to the end user’s request cycle.
The opposite of asynchronous events like this are synchronous events! (surprise right?). Let’s explore how they’re different.
Synchronous Events
We can look at synchronous events as dependencies of the request cycle. We use MySQL as a backend for the main PipelineDeals web application and queries to MySQL would be considered synchronous. In that, in order to successfully fulfill the current request, we require the information returned from MySQL before we can respond.
In most cases, we don’t think of our main datastore as a service. It doesn’t necessarily have a separate application layer on top of it, but it’s behavior and requirements are very much like a service.
If we consider the addition of our billing service above, we require information about the features allowed for a particular account before we can render the page. This allows us to include/exclude modules they should or should not see. The flow goes something like this:
Web request -> lookup account in DB -> Request features from Billing service -> render page
If the request to the billing service didn’t require a response, we would consider this to be an asynchronous service, which might change how we invoke the request for data.
Synchronous communication can happen over a variety of protocols. The most common is a JSON payload over HTTP. In general, it’s not the most performant, but it’s one of the easier to debug and is human-readable, so it tends to be pretty popular.
The synchronous services we’ve setup all communicate over HTTP. Rails APIs are a known thing. We’re familiar with the stack and the dependencies required to set up a common JSON API, which is a large part of the reason it’s our preferred communication protocol between services.
Summary
We’ve simplified the communication between services into these two categories. Knowing this helps dictate the infrastructure and configuration of the applications.
Next time, we’ll take a closer look at the synchronous side and the specifics about the JSON payloads involved to send an email successfully.