Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Optimización de claimUserTickets: Mejora de rendimiento y refactorización #273

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/datasources/db/userTickets.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { relations } from "drizzle-orm";
import { index, pgTable, text, uuid } from "drizzle-orm/pg-core";
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
import { z } from "zod";

import { purchaseOrdersSchema, ticketsSchema, usersSchema } from "./schema";
import { createdAndUpdatedAtFields } from "./shared";
Expand Down Expand Up @@ -69,3 +70,7 @@ export const insertUserTicketsSchema = createInsertSchema(userTicketsSchema);
export const approveUserTicketsSchema = selectUserTicketsSchema.pick({
approvalStatus: true,
});

export type SelectUserTicketSchema = z.infer<typeof selectUserTicketsSchema>;

export type InsertUserTicketSchema = z.infer<typeof insertUserTicketsSchema>;
28 changes: 15 additions & 13 deletions src/schema/userTickets/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,21 @@ export const assertCanStartTicketClaimingForEvent = async ({
logger: Logger;
}) => {
const ticketIds = Object.keys(purchaseOrderByTickets);
const events = await eventsFetcher.searchEvents({
DB,
search: {
ticketIds,
},
});
const [events, tickets] = await Promise.all([
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Se paralelizaron las consultas de eventos y tickets usando Promise.all.

eventsFetcher.searchEvents({
DB,
search: {
ticketIds,
},
}),

ticketsFetcher.searchTickets({
DB,
search: {
ticketIds,
},
}),
]);

if (events.length > 1) {
throw applicationError(
Expand Down Expand Up @@ -73,13 +82,6 @@ export const assertCanStartTicketClaimingForEvent = async ({
);
}

const tickets = await ticketsFetcher.searchTickets({
DB,
search: {
ticketIds,
},
});

if (tickets.length !== ticketIds.length) {
throw applicationError(
"Not all tickets found for event",
Expand Down
222 changes: 108 additions & 114 deletions src/schema/userTickets/mutations.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { eq } from "drizzle-orm";
import { and, count, eq, inArray } from "drizzle-orm";
import { GraphQLError } from "graphql";

import { builder } from "~/builder";
import {
insertUserTicketsSchema,
InsertUserTicketSchema,
selectPurchaseOrdersSchema,
selectUserTicketsSchema,
userTicketsSchema,
Expand Down Expand Up @@ -256,7 +256,7 @@ builder.mutationField("redeemUserTicket", (t) =>

builder.mutationField("claimUserTicket", (t) =>
t.field({
description: "Attempt to claim a certain ammount of tickets",
description: "Attempt to claim a certain amount of tickets",
type: RedeemUserTicketResponse,
args: {
input: t.arg({
Expand Down Expand Up @@ -300,6 +300,7 @@ builder.mutationField("claimUserTicket", (t) =>
);
}

// Aggregate purchase order items by ticket ID
const purchaseOrderByTickets: Record<
string,
{
Expand Down Expand Up @@ -344,22 +345,17 @@ builder.mutationField("claimUserTicket", (t) =>
purchaseOrderByTickets,
logger,
});
const createdPurchaseOrder = await createInitialPurchaseOrder({
DB: trx,
userId: USER.id,
logger,
});

let claimedTickets: Array<typeof insertUserTicketsSchema._type> =
[];
const ticketTemplatesIds = Object.keys(purchaseOrderByTickets);

// TODO: Measure and consider parallelizing this.
for (const { quantity, ticketId } of Object.values(
purchaseOrderByTickets,
)) {
// We pull the ticket template to see if it exists.
const ticketTemplate = await trx.query.ticketsSchema.findFirst({
where: (t, { eq }) => eq(t.id, ticketId),
const [createdPurchaseOrder, ticketTemplates] = await Promise.all([
createInitialPurchaseOrder({
DB: trx,
userId: USER.id,
logger,
}),
trx.query.ticketsSchema.findMany({
where: (t, { inArray }) => inArray(t.id, ticketTemplatesIds),
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Se eliminó el bucle for que iteraba sobre cada ticket. Ahora procesamos los tickets en lote, lo que reduce el número de consultas a la base de datos y mejora el rendimiento.

with: {
event: true,
ticketsPrices: {
Expand All @@ -368,27 +364,30 @@ builder.mutationField("claimUserTicket", (t) =>
},
},
},
});

// If the ticket template does not exist, we throw an error.
if (!ticketTemplate) {
throw applicationError(
`Ticket template with id ${ticketId} not found`,
ServiceErrors.NOT_FOUND,
logger,
);
}
}),
]);

const notFoundTicketTemplatesIds = ticketTemplatesIds.filter(
(ticketId) => !ticketTemplates.find((t) => t.id === ticketId),
);

if (notFoundTicketTemplatesIds.length > 0) {
throw applicationError(
`Tickets with ids ${notFoundTicketTemplatesIds.join(
", ",
)} not found`,
ServiceErrors.NOT_FOUND,
logger,
);
}

const requiresPayment =
ticketTemplate.ticketsPrices &&
ticketTemplate.ticketsPrices.length > 0 &&
ticketTemplate.ticketsPrices.some(
(tp) =>
tp?.price?.price_in_cents !== null &&
tp?.price?.price_in_cents > 0,
);
const claimedTickets: InsertUserTicketSchema[] = [];

// Process each ticket template
for (const ticketTemplate of ticketTemplates) {
const { event } = ticketTemplate;
const quantityToPurchase =
purchaseOrderByTickets[ticketTemplate.id].quantity;

// If the event is not active, we throw an error.
if (event.status === "inactive") {
Expand All @@ -399,92 +398,92 @@ builder.mutationField("claimUserTicket", (t) =>
);
}

// If the ticket template has a quantity field, means there's a
// limit to the amount of tickets that can be created. So we check
// if we have enough tickets to fulfill the purchase order.
if (ticketTemplate.quantity) {
// We pull the tickets that are already reserved or in-process to
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Se eliminó este check inicial ya que estamos haciendo un check final en el recuento de los userTickets existentes.

Esto mejora el rendimiento para el caso de uso más común de éxito en el que no se rebasa la cantidad máxima de stock

// see if we have enough to fulfill the purchase order
const tickets = await trx.query.userTicketsSchema.findMany({
where: (uts, { eq, and, notInArray }) =>
and(
eq(uts.ticketTemplateId, ticketId),
notInArray(uts.approvalStatus, ["rejected", "cancelled"]),
),
columns: {
id: true,
},
});

// If we would be going over the limit of tickets, we throw an
// error.
if (tickets.length + quantity > ticketTemplate.quantity) {
throw new Error(
`Not enough tickets for ticket template with id ${ticketId}`,
);
}
}

const isApproved =
ticketTemplate.isFree && !ticketTemplate.requiresApproval;

// If no errors were thrown, we can proceed to reserve the
// tickets.
const newTickets = new Array(quantity)
const newTickets = new Array(quantityToPurchase)
.fill(false)
.map(() => {
const result = insertUserTicketsSchema.safeParse({
const next: InsertUserTicketSchema = {
userId: USER.id,
purchaseOrderId: createdPurchaseOrder.id,
ticketTemplateId: ticketTemplate.id,
paymentStatus: requiresPayment ? "unpaid" : "not_required",
approvalStatus: isApproved ? "approved" : "pending",
});
};

return next;
});

if (result.success) {
return result.data;
}
claimedTickets.push(...newTickets);
}

logger.error("Could not parse user ticket", result.error);
})
.filter(Boolean);
for (const ticketTemplateId of ticketTemplatesIds) {
const newTickets = claimedTickets.filter(
(ticket) => ticket.ticketTemplateId === ticketTemplateId,
);

logger.info(
`Creating ${newTickets.length} user tickets for ticket template with id ${ticketId}`,
{ newTickets },
`Creating ${newTickets.length} user tickets for ticket template with id ${ticketTemplateId}`,
);
}

if (newTickets.length === 0) {
throw new Error("Could not create user tickets");
}
// Bulk insert claimed tickets
const createdUserTickets = await trx
.insert(userTicketsSchema)
.values(claimedTickets)
.returning()
.execute();

// Bulk query for existing ticket counts
const finalTicketsCount = await trx
.select({
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Reemplazamos múltiples consultas individuales por una única consulta en lote para obtener el recuento de tickets. Esto reduce la carga en la base de datos y mejora el tiempo de respuesta.

ticketTemplateId: userTicketsSchema.ticketTemplateId,
count: count(userTicketsSchema.id),
})
.from(userTicketsSchema)
.where(
and(
inArray(
userTicketsSchema.ticketTemplateId,
ticketTemplates.map((t) => t.id),
),
inArray(userTicketsSchema.approvalStatus, [
"approved",
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Aquí me basé en el código anterior en el que se tomaban en cuenta solo estos dos estados, approved y pending, pero tal vez deberíamos tambien tomar en cuenta a:

  • Gifted
  • Gifted accepted
  • Not required

¿Que piensan?

"pending",
]),
),
)
.groupBy(userTicketsSchema.ticketTemplateId);

for (const ticketTemplate of ticketTemplates) {
const existingCount =
finalTicketsCount.find(
(count) => count.ticketTemplateId === ticketTemplate.id,
)?.count || 0;

const limitAlreadyReached = ticketTemplate.quantity
? existingCount >= ticketTemplate.quantity
: false;

const createdUserTickets = await trx
.insert(userTicketsSchema)
.values(newTickets)
.returning()
.execute();
logger.info(
`Ticket template with id ${
ticketTemplate.id
} has ${existingCount} tickets ${
limitAlreadyReached ? "and has reached its limit" : ""
}
`,
);

// if the ticket has a quantity field, we do a last check to see
// if we have enough gone over the limit of tickets.
const finalTickets = await trx.query.userTicketsSchema.findMany({
where: (uts, { eq, and, inArray }) =>
and(
eq(uts.ticketTemplateId, ticketId),
inArray(uts.approvalStatus, ["approved", "pending"]),
),
});

if (ticketTemplate.quantity) {
if (finalTickets.length > ticketTemplate.quantity) {
throw new Error(
`We have gone over the limit of tickets for ticket template with id ${ticketId}`,
);
}
if (limitAlreadyReached) {
throw new Error(
`We have gone over the limit of tickets for ticket template with id ${ticketTemplate.id}`,
);
}

claimedTickets = [...claimedTickets, ...createdUserTickets];
}

// Fetch the created purchase order
const foundPurchaseOrder =
await trx.query.purchaseOrdersSchema.findFirst({
where: (po, { eq }) => eq(po.id, createdPurchaseOrder.id),
Expand All @@ -497,6 +496,7 @@ builder.mutationField("claimUserTicket", (t) =>
const selectedPurchaseOrder =
selectPurchaseOrdersSchema.parse(foundPurchaseOrder);

// Generate payment link if requested
if (generatePaymentLink) {
logger.info("Extracting redirect URLs for purchase order");
const { paymentSuccessRedirectURL, paymentCancelRedirectURL } =
Expand All @@ -518,17 +518,17 @@ builder.mutationField("claimUserTicket", (t) =>
logger,
transactionalEmailService: RPC_SERVICE_EMAIL,
});
const tickets = await trx.query.userTicketsSchema.findMany({
where: (uts, { inArray }) => inArray(uts.id, ticketsIds),
});

return {
selectedPurchaseOrder: purchaseOrder,
claimedTickets: tickets,
purchaseOrder,
ticketsIds,
};
}

return { selectedPurchaseOrder, claimedTickets };
return {
purchaseOrder: selectedPurchaseOrder,
ticketsIds: createdUserTickets.map((ticket) => ticket.id),
};
} catch (e) {
logger.error((e as Error).message);

Expand All @@ -544,17 +544,11 @@ builder.mutationField("claimUserTicket", (t) =>
}
});

const { claimedTickets, selectedPurchaseOrder } = transactionResults;
const ticketsIds = claimedTickets.flatMap((t) => (t.id ? [t.id] : []));

return {
purchaseOrder: selectedPurchaseOrder,
ticketsIds,
};
return transactionResults;
} catch (e: unknown) {
if (transactionError) {
logger.error("Error claiming usertickets", transactionError);
logger.error("Error claiming user tickets", e);

if (transactionError) {
return {
error: true as const,
errorMessage: (transactionError as GraphQLError).message,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,7 @@ describe("Claim a user ticket", () => {
) {
assert.equal(
response.data?.claimUserTicket.errorMessage,
`Not enough tickets for ticket template with id ${ticketTemplate.id}`,
`We have gone over the limit of tickets for ticket template with id ${ticketTemplate.id}`,
);
}
});
Expand Down
Loading