Skip to content

Commit

Permalink
Add task concurrency to speed up card generation
Browse files Browse the repository at this point in the history
  • Loading branch information
magic-ike committed Mar 16, 2024
1 parent 96581d7 commit 194a0d6
Show file tree
Hide file tree
Showing 6 changed files with 113 additions and 164 deletions.
155 changes: 50 additions & 105 deletions src/controllers/api/card.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import User from '../../models/user.model';
import Image from '../../models/image.model';
import CardGetRequestQueryParams from '../../interfaces/card-get-request-query-params.interface';
import CardDeleteRequestQueryParams from '../../interfaces/card-delete-request-query-params.interface';
import UserProfileResponseBody from '../../interfaces/user-profile-response-body.interface';
import Track from '../../interfaces/track.interface';
import Artist from '../../interfaces/artist.interface';
import DataCardProps from '../../interfaces/data-card-props.interface';
Expand All @@ -24,7 +25,7 @@ export const card_get: RequestHandler = async (req, res) => {
const browserIsBuggy = detectBuggyBrowser(req);

// validate user id query param
const cardReqBody = req.query as unknown as CardGetRequestQueryParams;
const cardReqBody = req.query as CardGetRequestQueryParams;
const { user_id: userId, show_border } = cardReqBody;
const showBorder = boolFromString(show_border);
if (!userId) {
Expand All @@ -51,21 +52,6 @@ export const card_get: RequestHandler = async (req, res) => {
return;
}

// fetch user display name
let userDisplayName;
try {
const { display_name } = await User.getUserProfile(accessToken);
userDisplayName = display_name;
} catch (error) {
renderErrorCard(
res,
getGenericErrorMessage(userId),
showBorder,
browserIsBuggy
);
return;
}

// get options from query params
const {
show_date,
Expand Down Expand Up @@ -95,6 +81,40 @@ export const card_get: RequestHandler = async (req, res) => {
limit
);

// create data-fetching tasks
const tasks: (Promise<any> | (Track | Artist)[] | null)[] = [
User.getUserProfile(accessToken),
showNowPlaying ? User.getNowPlaying(accessToken, hideExplicit) : null,
showRecentlyPlayed
? User.getRecentlyPlayed(accessToken, hideExplicit, itemLimit)
: [],
showTopTracks
? User.getTopTracks(userId, accessToken, hideExplicit, itemLimit)
: [],
showTopArtists ? User.getTopArtists(userId, accessToken, itemLimit) : []
];

// run all tasks concurrently
let userProfile: UserProfileResponseBody | null = null;
let userDisplayName: string | null = null;
let nowPlaying: Track | null = null;
let recentlyPlayed: Track[] = [];
let topTracks: Track[] = [];
let topArtists: Artist[] = [];
try {
[userProfile, nowPlaying, recentlyPlayed, topTracks, topArtists] =
await Promise.all(tasks);
userDisplayName = userProfile!.display_name;
} catch (error) {
renderErrorCard(
res,
getGenericErrorMessage(userId),
showBorder,
browserIsBuggy
);
return;
}

// render error card if no data is visible
if (
!showNowPlaying &&
Expand All @@ -111,79 +131,6 @@ export const card_get: RequestHandler = async (req, res) => {
return;
}

// fetch currently playing track
let nowPlaying = null;
if (showNowPlaying) {
try {
nowPlaying = await User.getNowPlaying(accessToken, hideExplicit);
} catch (error) {
renderErrorCard(
res,
getGenericErrorMessage(userId, userDisplayName),
showBorder,
browserIsBuggy
);
return;
}
}

// fetch recently played tracks
let recentlyPlayed: Track[] = [];
if (showRecentlyPlayed) {
try {
recentlyPlayed = await User.getRecentlyPlayed(
accessToken,
hideExplicit,
itemLimit
);
} catch (error) {
renderErrorCard(
res,
getGenericErrorMessage(userId, userDisplayName),
showBorder,
browserIsBuggy
);
return;
}
}

// fetch top tracks
let topTracks: Track[] = [];
if (showTopTracks) {
try {
topTracks = await User.getTopTracks(
userId,
accessToken,
hideExplicit,
itemLimit
);
} catch (error) {
renderErrorCard(
res,
getGenericErrorMessage(userId, userDisplayName),
showBorder,
browserIsBuggy
);
return;
}
}

// fetch top artists
let topArtists: Artist[] = [];
if (showTopArtists) {
try {
topArtists = await User.getTopArtists(userId, accessToken, itemLimit);
} catch (error) {
renderErrorCard(
res,
getGenericErrorMessage(userId, userDisplayName),
showBorder,
browserIsBuggy
);
return;
}
}

// disable http caching if real-time data is requested
if (showNowPlaying || showRecentlyPlayed) disableHttpCaching(res);

Expand Down Expand Up @@ -217,21 +164,10 @@ export const card_get: RequestHandler = async (req, res) => {
res.render(CARD_API_VIEW_PATH, dataCardProps);
};

export const CARD_API_ERROR_MESSAGE = {
NO_USER_ID: 'Missing required parameter: user_id',
NO_TOKEN: 'No token provided.',
INVALID_AUTH: 'Only valid bearer authentication supported.',
CARD_NOT_FOUND: 'Data card not found.',
INVALID_TOKEN: 'Invalid token.'
};
export const CARD_API_DELETION_SUCCESS_MESSAGE =
'Data card deleted successfully.';

// deletes a data card
export const card_delete: RequestHandler = async (req, res) => {
// validate user id query param
const { user_id: userId } =
req.query as unknown as CardDeleteRequestQueryParams;
const { user_id: userId } = req.query as CardDeleteRequestQueryParams;
if (!userId) {
res.status(400).send(CARD_API_ERROR_MESSAGE.NO_USER_ID);
return;
Expand Down Expand Up @@ -286,6 +222,17 @@ export const card_delete: RequestHandler = async (req, res) => {
res.send(CARD_API_DELETION_SUCCESS_MESSAGE);
};

export const CARD_API_ERROR_MESSAGE = {
NO_USER_ID: 'Missing required parameter: user_id',
NO_TOKEN: 'No token provided.',
INVALID_AUTH: 'Only valid bearer authentication supported.',
CARD_NOT_FOUND: 'Data card not found.',
INVALID_TOKEN: 'Invalid token.'
};

export const CARD_API_DELETION_SUCCESS_MESSAGE =
'Data card deleted successfully.';

// helpers

const detectBuggyBrowser = (req: Request) => {
Expand All @@ -312,8 +259,6 @@ const renderErrorCard = (
});
};

const getGenericErrorMessage = (userId: string, userDisplayName?: string) => {
return `Card not found! ${
userDisplayName || `The user with ID ${userId}`
} may need to generate/re-generate a data card at ${SHORT_URL}.`;
const getGenericErrorMessage = (userId: string) => {
return `Card not found! The user with ID ${userId} may need to generate/re-generate a data card at ${SHORT_URL}.`;
};
3 changes: 1 addition & 2 deletions src/controllers/auth/index.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,7 @@ export const auth_callback: RequestHandler = async (req, res) => {
const refreshToken = refresh_token!;
let userId;
try {
const { id } = await User.getUserProfile(access_token);
userId = id;
({ id: userId } = await User.getUserProfile(access_token));
} catch (error) {
redirectToHomePageWithError(res, error as string);
return;
Expand Down
10 changes: 7 additions & 3 deletions src/models/image.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,19 @@ import { getBase64DataFromImageUrl } from '../utils/image.util';
export default class Image {
static async getImageDataMap(items: Item[]) {
const map: StringMap = {};
const tasks: Promise<string>[] = [];
for (const item of items) {
if (!item) continue;
const imageUrl = isTrack(item) ? item.albumImageUrl : item.imageUrl;
const imageUrlArray = imageUrl.split('/');
const imageId = imageUrlArray[imageUrlArray.length - 1];
map[imageUrl] = await Redis.getImageDataFromOrSaveToCache(imageId, () => {
return getBase64DataFromImageUrl(imageUrl);
});
tasks.push(
Redis.getImageDataFromCacheOrGetAndSaveToCache(imageId, () =>
getBase64DataFromImageUrl(imageUrl)
).then((imageData) => (map[imageUrl] = imageData))
);
}
await Promise.allSettled(tasks);
return map;
}
}
35 changes: 18 additions & 17 deletions src/models/redis.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export default class Redis {

static #client = createClient({ url: REDIS_URI });

static async #getFromOrSaveToCache<T>(
static async #getFromCacheOrGetAndSaveToCache<T>(
key: string,
fallbackFunction: Function,
expiration?: number
Expand All @@ -32,19 +32,18 @@ export default class Redis {
// don't throw
console.log(error);
}
if (data) {
return JSON.parse(data);
}
if (data) return JSON.parse(data);

// run fallback function
const freshData = await fallbackFunction();

// save to cache
try {
typeof expiration === 'number'
? await this.#client.setEx(key, expiration, JSON.stringify(freshData))
: await this.#client.set(key, JSON.stringify(freshData));
? this.#client.setEx(key, expiration, JSON.stringify(freshData))
: this.#client.set(key, JSON.stringify(freshData));
} catch (error) {
// don't throw
console.log(error);
}

Expand Down Expand Up @@ -95,25 +94,25 @@ export default class Redis {
return JSON.parse(profile || 'null');
}

static getTopTracksFromOrSaveToCache(
static getTopTracksFromCacheOrGetAndSaveToCache(
userId: string,
hideExplicit: boolean,
limit: number,
fallbackFunction: Function
): Promise<Track[]> {
return this.#getFromOrSaveToCache(
return this.#getFromCacheOrGetAndSaveToCache(
getTopTracksCacheKey(userId, hideExplicit, limit),
fallbackFunction,
DEFAULT_EXPIRATION
);
}

static getTopArtistsFromOrSaveToCache(
static getTopArtistsFromCacheOrGetAndSaveToCache(
userId: string,
limit: number,
fallbackFunction: Function
): Promise<Artist[]> {
return this.#getFromOrSaveToCache(
return this.#getFromCacheOrGetAndSaveToCache(
getTopArtistsCacheKey(userId, limit),
fallbackFunction,
DEFAULT_EXPIRATION
Expand All @@ -122,11 +121,11 @@ export default class Redis {

// images

static getImageDataFromOrSaveToCache(
static getImageDataFromCacheOrGetAndSaveToCache(
imageId: string,
fallbackFunction: Function
): Promise<string> {
return this.#getFromOrSaveToCache(
return this.#getFromCacheOrGetAndSaveToCache(
getImageCacheKey(imageId),
fallbackFunction
);
Expand All @@ -136,11 +135,13 @@ export default class Redis {

static async deleteTokenMapAndUserDataFromCache(
userId: string
): Promise<void> {
await this.#client.del(getTokenMapCacheKey(userId));
await this.#client.del(getUserProfileCacheKey(userId));
await this.#client.eval(getTopItemCacheDeletionScript(userId, 'Track'));
await this.#client.eval(getTopItemCacheDeletionScript(userId, 'Artist'));
): Promise<any[]> {
return Promise.all([
this.#client.del(getTokenMapCacheKey(userId)),
this.#client.del(getUserProfileCacheKey(userId)),
this.#client.eval(getTopItemCacheDeletionScript(userId, 'Track')),
this.#client.eval(getTopItemCacheDeletionScript(userId, 'Artist'))
]);
}
}

Expand Down
18 changes: 8 additions & 10 deletions src/models/token-map.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export default class TokenMap extends MongoTokenMap {

// save to cache
try {
await Redis.saveTokenMapToCache(userId, tokenMap);
Redis.saveTokenMapToCache(userId, tokenMap);
} catch (error) {
console.log(error);
}
Expand Down Expand Up @@ -103,7 +103,7 @@ export default class TokenMap extends MongoTokenMap {

// save to cache
try {
await Redis.saveTokenMapToCache(userId, tokenMap);
Redis.saveTokenMapToCache(userId, tokenMap);
} catch (error) {
console.log(error);
}
Expand All @@ -118,15 +118,13 @@ export default class TokenMap extends MongoTokenMap {
} catch (error) {
throw (error as Error).message;
}
if (!tokenMap) {
throw `Token map with user ID '${userId}' didn't exist.`;
}
if (!tokenMap) throw `Token map with user ID '${userId}' didn't exist.`;

// delete from cache
// delete from cache (await)
try {
await Redis.deleteTokenMapAndUserDataFromCache(userId);
} catch (error) {
console.log(error);
throw 'Failed to delete token map from cache server.';
}

return tokenMap;
Expand Down Expand Up @@ -162,17 +160,17 @@ TokenMap.getLatestAccessToken = async function (
// get tokens from token map and update access token if it's expired
let { accessToken } = tokenMap;
const { refreshToken, accessTokenExpiresAt } = tokenMap;
if (accessTokenExpiresAt <= Date.now()) {
if ((accessTokenExpiresAt as number) <= Date.now()) {
const { access_token, expires_in } =
await Auth.getAccessTokenWithRefreshToken(refreshToken);
await this.updateAccessTokenInTokenMap(userId, access_token, expires_in);
this.updateAccessTokenInTokenMap(userId, access_token, expires_in);
accessToken = access_token;
}

// save token map to cache if necessary
if (!cacheHit) {
try {
await Redis.saveTokenMapToCache(userId, tokenMap);
Redis.saveTokenMapToCache(userId, tokenMap);
} catch (error) {
console.log(error);
}
Expand Down
Loading

0 comments on commit 194a0d6

Please sign in to comment.