Skip to content

Commit

Permalink
Implement caching and batching for performance
Browse files Browse the repository at this point in the history
- Cache event list and event details DB fetches for users
- Batch recomputing signup statuses/positions
  • Loading branch information
PurkkaKoodari committed Aug 14, 2024
1 parent ccf143e commit 60c4f3d
Show file tree
Hide file tree
Showing 11 changed files with 458 additions and 139 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { getSequelize } from "../../../models";
import { Event } from "../../../models/event";
import { Question } from "../../../models/question";
import { Quota } from "../../../models/quota";
import { eventDetailsForAdmin } from "../../events/getEventDetails";
import { basicEventInfoCached, eventDetailsForAdmin, eventDetailsForUserCached } from "../../events/getEventDetails";
import { eventsListForUserCached } from "../../events/getEventsList";
import { toDate } from "../../utils";

export default async function createEvent(
Expand Down Expand Up @@ -57,6 +58,10 @@ export default async function createEvent(
return created;
});

eventsListForUserCached.invalidate();
basicEventInfoCached.invalidate();
eventDetailsForUserCached.invalidate();

const eventDetails = await eventDetailsForAdmin(event.id);

response.status(201);
Expand Down
29 changes: 19 additions & 10 deletions packages/ilmomasiina-backend/src/routes/admin/events/deleteEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,33 @@ import { FastifyReply, FastifyRequest } from "fastify";

import type { AdminEventPathParams } from "@tietokilta/ilmomasiina-models";
import { AuditEvent } from "@tietokilta/ilmomasiina-models";
import { getSequelize } from "../../../models";
import { Event } from "../../../models/event";
import { basicEventInfoCached, eventDetailsForUserCached } from "../../events/getEventDetails";
import { eventsListForUserCached } from "../../events/getEventsList";

export default async function deleteEvent(
request: FastifyRequest<{ Params: AdminEventPathParams }>,
response: FastifyReply,
): Promise<void> {
const event = await Event.findByPk(request.params.id);
if (event === null) {
response.notFound("No event found with id");
return;
}
await getSequelize().transaction(async (transaction) => {
const event = await Event.findByPk(request.params.id);
if (event === null) {
response.notFound("No event found with id");
return;
}

// Delete the DB object
await event?.destroy();
// Delete the DB object
await event?.destroy({ transaction });

if (event) {
await request.logEvent(AuditEvent.DELETE_EVENT, { event });
}
if (event) {
await request.logEvent(AuditEvent.DELETE_EVENT, { event, transaction });
}
});

eventsListForUserCached.invalidate();
basicEventInfoCached.invalidate();
eventDetailsForUserCached.invalidate();

response.status(204);
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import { getSequelize } from "../../../models";
import { Event } from "../../../models/event";
import { Question } from "../../../models/question";
import { Quota } from "../../../models/quota";
import { eventDetailsForAdmin } from "../../events/getEventDetails";
import { basicEventInfoCached, eventDetailsForAdmin, eventDetailsForUserCached } from "../../events/getEventDetails";
import { eventsListForUserCached } from "../../events/getEventsList";
import { refreshSignupPositions } from "../../signups/computeSignupPosition";
import { toDate } from "../../utils";
import { EditConflict } from "./errors";
Expand Down Expand Up @@ -175,6 +176,10 @@ export default async function updateEvent(
await request.logEvent(action, { event, transaction });
});

eventsListForUserCached.invalidate();
basicEventInfoCached.invalidate();
eventDetailsForUserCached.invalidate();

const updatedEvent = await eventDetailsForAdmin(request.params.id);

response.status(200);
Expand Down
171 changes: 101 additions & 70 deletions packages/ilmomasiina-backend/src/routes/events/getEventDetails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,92 +23,123 @@ import { Event } from "../../models/event";
import { Question } from "../../models/question";
import { Quota } from "../../models/quota";
import { Signup } from "../../models/signup";
import createCache from "../../util/cache";
import { StringifyApi } from "../utils";

export async function eventDetailsForUser(eventSlug: EventSlug): Promise<UserEventResponse> {
// First query general event information
const event = await Event.scope("user").findOne({
where: { slug: eventSlug },
attributes: eventGetEventAttrs,
include: [
{
model: Question,
attributes: eventGetQuestionAttrs,
export const basicEventInfoCached = createCache({
maxAgeMs: 5000,
maxPendingAgeMs: 5000,
async get(eventSlug: EventSlug) {
// First query general event information
const event = await Event.scope("user").findOne({
where: { slug: eventSlug },
attributes: eventGetEventAttrs,
include: [
{
model: Question,
attributes: eventGetQuestionAttrs,
},
],
order: [[Question, "order", "ASC"]],
});

if (!event) {
// Event not found with id, probably deleted
throw new NotFound("No event found with slug");
}

// Only return answers to public questions
const publicQuestions = event.questions!.filter((question) => question.public).map((question) => question.id);

return {
event: {
...event.get({ plain: true }),
questions: event.questions!.map((question) => question.get({ plain: true })),
},
],
order: [[Question, "order", "ASC"]],
});

if (!event) {
// Event not found with id, probably deleted
throw new NotFound("No event found with slug");
}

// Only return answers to public questions
const publicQuestions = event.questions!.filter((question) => question.public).map((question) => question.id);

// Query all quotas for the event
const quotas = await Quota.findAll({
where: { eventId: event.id },
attributes: eventGetQuotaAttrs,
include: [
// Include all signups for the quota
{
model: Signup.scope("active"),
attributes: eventGetSignupAttrs,
required: false,
include: [
// ... and public answers of signups
{
model: Answer,
attributes: eventGetAnswerAttrs,
required: false,
where: { questionId: { [Op.in]: publicQuestions } },
},
],
publicQuestions,
};
},
});

export const eventDetailsForUserCached = createCache({
maxAgeMs: 1000,
maxPendingAgeMs: 1000,
async get(eventSlug: EventSlug) {
const { event, publicQuestions } = await basicEventInfoCached(eventSlug);

// Query all quotas for the event
const quotas = await Quota.findAll({
where: { eventId: event.id },
attributes: eventGetQuotaAttrs,
include: [
// Include all signups for the quota
{
model: Signup.scope("active"),
attributes: eventGetSignupAttrs,
required: false,
include: [
// ... and public answers of signups
{
model: Answer,
attributes: eventGetAnswerAttrs,
required: false,
where: { questionId: { [Op.in]: publicQuestions } },
},
],
},
],
// First sort by Quota order, then by signup creation date
order: [
["order", "ASC"],
[Signup, "createdAt", "ASC"],
],
});

return {
event: {
...event,
quotas: quotas.map((quota) => ({
...quota.get({ plain: true }),
signups: event.signupsPublic // Hide all signups from non-admins if answers are not public
? // When signups are public:
quota.signups!.map((signup) => ({
...signup.get({ plain: true }),
// Hide name if necessary
firstName: event.nameQuestion && signup.namePublic ? signup.firstName : null,
lastName: event.nameQuestion && signup.namePublic ? signup.lastName : null,
answers: signup.answers!,
status: signup.status,
confirmed: signup.confirmedAt !== null,
}))
: // When signups are not public:
[],
signupCount: quota.signups!.length,
})),
},
],
// First sort by Quota order, then by signup creation date
order: [
["order", "ASC"],
[Signup, "createdAt", "ASC"],
],
});
registrationStartDate: event.registrationStartDate && new Date(event.registrationStartDate),
registrationEndDate: event.registrationEndDate && new Date(event.registrationEndDate),
};
},
});

export async function eventDetailsForUser(eventSlug: EventSlug): Promise<UserEventResponse> {
const { event, registrationStartDate, registrationEndDate } = await eventDetailsForUserCached(eventSlug);

// Dynamic extra fields
let registrationClosed = true;
let millisTillOpening = null;

if (event.registrationStartDate !== null && event.registrationEndDate !== null) {
const startDate = new Date(event.registrationStartDate);
if (registrationStartDate !== null && registrationEndDate !== null) {
const startDate = new Date(registrationStartDate);
const now = new Date();
millisTillOpening = Math.max(0, startDate.getTime() - now.getTime());

const endDate = new Date(event.registrationEndDate);
const endDate = new Date(registrationEndDate);
registrationClosed = now > endDate;
}

const res = {
...event.get({ plain: true }),
questions: event.questions!.map((question) => question.get({ plain: true })),
quotas: quotas.map((quota) => ({
...quota.get({ plain: true }),
signups: event.signupsPublic // Hide all signups from non-admins if answers are not public
? // When signups are public:
quota.signups!.map((signup) => ({
...signup.get({ plain: true }),
// Hide name if necessary
firstName: event.nameQuestion && signup.namePublic ? signup.firstName : null,
lastName: event.nameQuestion && signup.namePublic ? signup.lastName : null,
answers: signup.answers!,
status: signup.status,
confirmed: signup.confirmedAt !== null,
}))
: // When signups are not public:
[],
signupCount: quota.signups!.length,
})),

...event,
millisTillOpening,
registrationClosed,
};
Expand Down
69 changes: 40 additions & 29 deletions packages/ilmomasiina-backend/src/routes/events/getEventsList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Event } from "../../models/event";
import { Quota } from "../../models/quota";
import { Signup } from "../../models/signup";
import { ascNullsFirst } from "../../models/util";
import createCache from "../../util/cache";
import { InitialSetupNeeded, isInitialSetupDone } from "../admin/users/createInitialUser";
import { StringifyApi } from "../utils";

Expand All @@ -20,6 +21,43 @@ function eventOrder(): Order {
];
}

export const eventsListForUserCached = createCache({
maxAgeMs: 1000,
maxPendingAgeMs: 2000,
async get(category?: string) {
const where = category ? { category } : {};

const events = await Event.scope("user").findAll({
attributes: eventListEventAttrs,
where: { listed: true, ...where },
// Include quotas of event and count of signups
include: [
{
model: Quota,
attributes: ["id", "title", "size", [fn("COUNT", col("quotas->signups.id")), "signupCount"]],
include: [
{
model: Signup.scope("active"),
required: false,
attributes: [],
},
],
},
],
group: [col("event.id"), col("quotas.id")],
order: eventOrder(),
});

return events.map((event) => ({
...event.get({ plain: true }),
quotas: event.quotas!.map((quota) => ({
...quota.get({ plain: true }),
signupCount: Number(quota.signupCount),
})),
}));
},
});

export async function getEventsListForUser(
this: FastifyInstance<any, any, any, any, any>,
request: FastifyRequest<{ Querystring: EventListQuery }>,
Expand All @@ -30,38 +68,11 @@ export async function getEventsListForUser(
throw new InitialSetupNeeded("Initial setup of Ilmomasiina is needed.");
}

const events = await Event.scope("user").findAll({
attributes: eventListEventAttrs,
where: { listed: true, ...request.query },
// Include quotas of event and count of signups
include: [
{
model: Quota,
attributes: ["id", "title", "size", [fn("COUNT", col("quotas->signups.id")), "signupCount"]],
include: [
{
model: Signup.scope("active"),
required: false,
attributes: [],
},
],
},
],
group: [col("event.id"), col("quotas.id")],
order: eventOrder(),
});

const res = events.map((event) => ({
...event.get({ plain: true }),
quotas: event.quotas!.map((quota) => ({
...quota.get({ plain: true }),
signupCount: Number(quota.signupCount),
})),
}));

const res = await eventsListForUserCached(request.query.category);
reply.status(200);
return res as StringifyApi<typeof res>;
}

export async function getEventsListForAdmin(
request: FastifyRequest<{ Querystring: EventListQuery }>,
reply: FastifyReply,
Expand Down
Loading

0 comments on commit 60c4f3d

Please sign in to comment.