[SDKs & UI Libraries] Shared Resources and Asynchronous Locks
With this post, I’m continuing publishing the v2 of my book dedicated to APIs. If you like this book, please rate it on GitHub, Amazon, or Goodreads and
Another important pattern we need to discuss is accessing shared resources. Imagine that in our study application, opening an offer panel required making an additional request to the server and thus became asynchronous. Let's modify the OfferPanelComponent
code:
class OfferPanelComponent {
…
show (offer) {
let fullData = await api
.getFullOfferData(offer);
…
}
}
A question arises: what should happen if the user or the developers tries to select another offerId
while the server response for the previous one hasn't been received yet? Obviously, we must choose which of the two openings needs to be suppressed. Let's say we decided to block the interface during the data load and by doing so, prohibit selecting another offer. To implement this functionality, we need to notify parent components about data requests being initiated or fulfilled:
class OfferPanelComponent {
…
show () {
this.events.emit('beginDataLoad');
let fullData = await api
.getFullOfferData(offer);
this.events.emit('endDataLoad');
…
}
}
// `Composer` listens to the panel events
// and sets the value of the
// corresponding flag
class SearchBoxComposer {
…
constructor () {
…
this.offerPanel.events.on(
'beginDataLoad', () => {
this.isDataLoading = true;
}
);
this.offerPanel.events.on(
'endDataLoad', () => {
this.isDataLoading = false;
}
);
}
selectOffer (offer) {
if (this.isDataLoading) {
return;
}
…
}
}
However, this code is flawed for several reasons:
There is no obvious way to modify it if different types of data loads occur, and some of them require blocking the UI while others do not.
It is poorly readable because it is rather difficult to comprehend why data load events on one component affect the user-facing functionality of the other component.
If an exception is thrown while loading the data, the
endDataLoad
event will never happen and the interface will remain blocked indefinitely.
If you have read the previous chapters thoroughly, the solution to these problems should be obvious. We need to abstract from the fact of loading data and reformulate the issue in high-level terms. We have a shared resource: the space on the screen. Only one offer can be displayed at a time. This means that every actor needing lasting access to the panel must explicitly obtain it. This entails two conclusions:
The access flag must have an explicit name, such as
offerFullViewLocked
, and notisDataLoading
.Composer
must control this flag, not the offer panel itself (additionally, because preparing data for displaying in the panel isComposer
's responsibility).
class SearchBoxComposer {
constructor () {
…
this.offerFullViewLocked = false;
}
…
selectOffer (offer) {
if (this.offerFullViewLocked) {
return;
}
this.offerFullViewLocked = true;
let fullData = await api
.getFullOfferData(offer);
this.events.emit(
'offerFullViewChange',
this.generateOfferFullView(fullData)
);
this.offerFullViewLocked = false;
}
}
This approach improves readability but doesn't help with parallel access and error recovery. To address these issues, we must take the next step: not just create a flag but introduce a procedure for capturing it (quite classically, similar to managing exclusive access to shared resources in system programming):
class SearchBoxComposer {
…
selectOffer (offer) {
let lock;
try {
// Trying to capture the
// `offerFullView` resource
lock = await this.acquireLock(
'offerFullView', '10s'
);
let fullData = await api
.getFullOfferData(offer);
this.events.emit(
'offerFullViewChange',
this.generateOfferFullView(fullData)
);
lock.release();
} catch (e) {
// If we were unable to get access
return;
} finally {
// Don't forget to free the resource
// in the case of an exception
if (lock) {
lock.release();
}
}
}
}
NB: the second argument to the acquireLock
function is the lock's lifespan (10 seconds, in our case). This implies that the lock will be automatically released after this timeout has passed (which is useful in case we have forgotten to catch an exception or set a timeout for a data load request), thus unblocking the UI.
With this approach, we can implement not only locks, but also various scenarios to flexibly manage them. Let's add data regarding the acquirer in the lock function:
lock = await this.acquireLock(
'offerFullView', '10s', {
// Who is trying to acquire a lock
// and for what reason
reason: 'userSelectOffer',
offer
}
);
Then the current lock holder (or a lock dispatcher, if we implement it) could either relinquish control over the resource or prevent interception depending on the situation. For example, if opening the panel is initiated by the developer by calling an API method (rather than a user selecting another offer from the list), we could prioritize it and grant it the right to seize control:
lock.events.on('tryAcquire', (actor) => {
if (sender.reason == 'apiSelectOffer') {
lock.release();
} else {
// Otherwise, prevent interception
// of the lock
return false;
}
});
Additionally, we might add a handler to react to losing control — for example, to cancel the request for data if it is no longer needed:
lock.events.on('lost', () => {
this.cancelFullOfferDataLoad();
});
The shared resource access control partner aligns well with the “model” pattern: actors can acquire read and/or write locks for specific data fields (or groups of fields) of the model.
NB: we could have addressed the data load problem differently:
Open the offer panel
Display a spinner or a placeholder instead of the real data
Asynchronously update the view once the data is loaded.
However, this doesn't change the problem definition: we still need a conflict resolution policy if another actor attempts to open the panel while the data is still loading, and for this we need shared resources and mechanisms for gaining exclusive access to them.
Regrettably, in modern frontend development, such techniques involving taking control over UI elements while data is loading or animations are being performed are rarely used. Such asynchronous operations are considered fast enough to not be concerned about access collisions. However, if latencies are high (e.g., complex or multi-staged requests or animations occur), neglecting access management could be a major UX problem.
This is Chapter 47 of “The API” book being written by Sergey Konstantinov. I also have a book on the history of beer and historical beer styles, a Telegram channel on interesting classical music recordings, a travel photo blog on Unsplash, and a website with ranking fantasy & science fiction novels based on awards they received.