feat(medusa): Improve add line item to cart perf and transaction management + clustering start command (#5701)

This commit is contained in:
Adrien de Peretti authored and GitHub committed 2023-12-06 08:53:35 +01:00
1 parent a458cd144d
commit 6975eacb33
14 files changed
+293 -99

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
"@medusajs/medusa": patch
"@medusajs/medusa-cli": patch
"@medusajs/admin-ui": patch
---
feat(medusa, medusa-cli): Improve add line item + cluster starting with medusa cli
@@ -145,27 +145,23 @@ module.exports = async (dataSource, data = {}) => {
name: "Test Region", name: "Test Region",
currency_code: "usd", currency_code: "usd",
tax_rate: 0, tax_rate: 0,
payment_providers: [
{
id: "test-pay",
is_installed: true,
},
],
}) })
await manager.query(
`insert into region_payment_providers values ('test-region', 'test-pay');`
)
await manager.insert(Region, { await manager.insert(Region, {
id: "test-region-2", id: "test-region-2",
name: "Test Region 2", name: "Test Region 2",
currency_code: "eur", currency_code: "eur",
tax_rate: 0, tax_rate: 0,
payment_providers: [
{
id: "test-pay",
is_installed: true,
},
],
}) })
await manager.query(
`insert into region_payment_providers values ('test-region-2', 'test-pay');`
)
await manager.insert(DiscountRule, { await manager.insert(DiscountRule, {
id: "discount_rule_id", id: "discount_rule_id",
description: "test description", description: "test description",
@@ -17,7 +17,7 @@ const {
getContainer, getContainer,
} = require("../../../../environment-helpers/use-container") } = require("../../../../environment-helpers/use-container")
jest.setTimeout(30000) jest.setTimeout(60000)
const adminHeaders = { headers: { "x-medusa-access-token": "test_token" } } const adminHeaders = { headers: { "x-medusa-access-token": "test_token" } }
@@ -15,7 +15,9 @@ export async function build({
options, options,
reporting = "fancy", reporting = "fancy",
}: BuildArgs) { }: BuildArgs) {
if (!process.env.PLUGIN_ADMIN_UI_SKIP_CACHE) {
await createCacheDir({ appDir, plugins }) await createCacheDir({ appDir, plugins })
}
const cacheDir = path.resolve(appDir, ".cache") const cacheDir = path.resolve(appDir, ".cache")
const entry = path.resolve(cacheDir, "admin", "src", "main.tsx") const entry = path.resolve(cacheDir, "admin", "src", "main.tsx")
@@ -5,6 +5,10 @@ import { createEntry } from "./create-entry"
import { logger } from "./logger" import { logger } from "./logger"
async function copyAdmin(dest: string) { async function copyAdmin(dest: string) {
if (process.env.PLUGIN_ADMIN_UI_SKIP_CACHE) {
return true
}
const adminDir = path.resolve(__dirname, "..", "ui") const adminDir = path.resolve(__dirname, "..", "ui")
const destDir = path.resolve(dest, "admin") const destDir = path.resolve(dest, "admin")
@@ -13,6 +13,10 @@ import {
const FILE_EXT_REGEX = /\.[^/.]+$/ const FILE_EXT_REGEX = /\.[^/.]+$/
async function copyLocalExtensions(src: string, dest: string) { async function copyLocalExtensions(src: string, dest: string) {
if (process.env.PLUGIN_ADMIN_UI_SKIP_CACHE) {
return true
}
try { try {
await fse.copy(src, dest, { await fse.copy(src, dest, {
filter: copyFilter, filter: copyFilter,
+40 -4
View File
@@ -1,13 +1,13 @@
import path from "path"
import resolveCwd from "resolve-cwd"
import { sync as existsSync } from "fs-exists-cached" import { sync as existsSync } from "fs-exists-cached"
import { setTelemetryEnabled } from "medusa-telemetry" import { setTelemetryEnabled } from "medusa-telemetry"
import path from "path"
import resolveCwd from "resolve-cwd"
import { getLocalMedusaVersion } from "./util/version"
import { didYouMean } from "./did-you-mean" import { didYouMean } from "./did-you-mean"
import { getLocalMedusaVersion } from "./util/version"
import reporter from "./reporter"
import { newStarter } from "./commands/new" import { newStarter } from "./commands/new"
import reporter from "./reporter"
const yargs = require(`yargs`) const yargs = require(`yargs`)
@@ -233,6 +233,42 @@ function buildLocalCommands(cli, isLocalProject) {
}) })
), ),
}) })
.command({
command: `start-cluster`,
desc: `Start development server in cluster mode (beta).`,
builder: (_) =>
_.option(`H`, {
alias: `host`,
type: `string`,
default: defaultHost,
describe: `Set host. Defaults to ${defaultHost}`,
})
.option(`p`, {
alias: `port`,
type: `string`,
default: process.env.PORT || defaultPort,
describe: process.env.PORT
? `Set port. Defaults to ${process.env.PORT} (set by env.PORT) (otherwise defaults ${defaultPort})`
: `Set port. Defaults to ${defaultPort}`,
})
.option(`c`, {
alias: `cpus`,
type: `number`,
default: process.env.CPUS,
describe:
"Set number of cpus to use. Defaults to max number of cpus available on the system (set by env.CPUS)",
}),
handler: handlerP(
getCommandHandler(`start-cluster`, (args, cmd) => {
process.env.NODE_ENV = process.env.NODE_ENV || `development`
cmd(args)
// Return an empty promise to prevent handlerP from exiting early.
// The development server shouldn't ever exit until the user directly
// kills it so this is fine.
return new Promise((resolve) => {})
})
),
})
.command({ .command({
command: `user`, command: `user`,
desc: `Create a user`, desc: `Create a user`,
@@ -2,16 +2,18 @@ import { IsInt, IsOptional, IsString } from "class-validator"
import { EntityManager } from "typeorm" import { EntityManager } from "typeorm"
import { validator } from "../../../../../utils/validator" import { validator } from "../../../../../utils/validator"
import { import {
addOrUpdateLineItem,
CreateLineItemSteps, CreateLineItemSteps,
handleAddOrUpdateLineItem, setPaymentSession,
setVariantAvailability,
} from "./utils/handler-steps" } from "./utils/handler-steps"
import { IdempotencyKey } from "../../../../../models" import { IdempotencyKey } from "../../../../../models"
import { import { initializeIdempotencyRequest } from "../../../../../utils/idempotency"
initializeIdempotencyRequest,
runIdempotencyStep,
RunIdempotencyStepOptions,
} from "../../../../../utils/idempotency"
import { cleanResponseData } from "../../../../../utils/clean-response-data" import { cleanResponseData } from "../../../../../utils/clean-response-data"
import IdempotencyKeyService from "../../../../../services/idempotency-key"
import { defaultStoreCartFields, defaultStoreCartRelations } from "../index"
import { CartService } from "../../../../../services"
import { promiseAll } from "@medusajs/utils"
/** /**
* @oas [post] /store/carts/{id}/line-items * @oas [post] /store/carts/{id}/line-items
@@ -89,34 +91,82 @@ export default async (req, res) => {
let inProgress = true let inProgress = true
let err: unknown = false let err: unknown = false
const stepOptions: RunIdempotencyStepOptions = { const idempotencyKeyService: IdempotencyKeyService = req.scope.resolve(
manager, "idempotencyKeyService"
idempotencyKey, )
container: req.scope,
isolationLevel: "SERIALIZABLE",
}
while (inProgress) { while (inProgress) {
switch (idempotencyKey.recovery_point) { switch (idempotencyKey.recovery_point) {
case CreateLineItemSteps.STARTED: { case CreateLineItemSteps.STARTED: {
await runIdempotencyStep(async ({ manager }) => { try {
return await handleAddOrUpdateLineItem( const cartId = id
id, const data = {
{
customer_id: customerId, customer_id: customerId,
metadata: validated.metadata, metadata: validated.metadata,
quantity: validated.quantity, quantity: validated.quantity,
variant_id: validated.variant_id, variant_id: validated.variant_id,
},
{
manager,
container: req.scope,
} }
)
}, stepOptions).catch((e) => { await addOrUpdateLineItem({
cartId,
container: req.scope,
manager,
data,
})
idempotencyKey = await idempotencyKeyService
.withTransaction(manager)
.update(idempotencyKey.idempotency_key, {
recovery_point: CreateLineItemSteps.SET_PAYMENT_SESSIONS,
})
} catch (e) {
inProgress = false inProgress = false
err = e err = e
}
break
}
case CreateLineItemSteps.SET_PAYMENT_SESSIONS: {
try {
const cartService: CartService = req.scope.resolve("cartService")
const cart = await cartService
.withTransaction(manager)
.retrieveWithTotals(id, {
select: defaultStoreCartFields,
relations: [
...defaultStoreCartRelations,
"billing_address",
"region.payment_providers",
"payment_sessions",
"customer",
],
}) })
const args = {
cart,
container: req.scope,
manager,
}
await promiseAll([
setVariantAvailability(args),
setPaymentSession(args),
])
idempotencyKey = await idempotencyKeyService
.withTransaction(manager)
.update(idempotencyKey.idempotency_key, {
recovery_point: CreateLineItemSteps.FINISHED,
response_code: 200,
response_body: { cart },
})
} catch (e) {
inProgress = false
err = e
}
break break
} }
@@ -1,6 +1,3 @@
import { FlagRouter } from "@medusajs/utils"
import { AwilixContainer } from "awilix"
import { EntityManager } from "typeorm"
import { Cart } from "../../../../../../models" import { Cart } from "../../../../../../models"
import { import {
CartService, CartService,
@@ -8,36 +5,25 @@ import {
ProductVariantInventoryService, ProductVariantInventoryService,
} from "../../../../../../services" } from "../../../../../../services"
import { WithRequiredProperty } from "../../../../../../types/common" import { WithRequiredProperty } from "../../../../../../types/common"
import { IdempotencyCallbackResult } from "../../../../../../types/idempotency-key"
import { defaultStoreCartFields, defaultStoreCartRelations } from "../../index"
import SalesChannelFeatureFlag from "../../../../../../loaders/feature-flags/sales-channels" import SalesChannelFeatureFlag from "../../../../../../loaders/feature-flags/sales-channels"
import { MedusaError } from "medusa-core-utils" import { featureFlagRouter } from "../../../../../../loaders/feature-flags"
export const CreateLineItemSteps = { export const CreateLineItemSteps = {
STARTED: "started", STARTED: "started",
SET_PAYMENT_SESSIONS: "set-payment-sessions",
FINISHED: "finished", FINISHED: "finished",
} }
export async function handleAddOrUpdateLineItem( export async function addOrUpdateLineItem({
cartId: string, cartId,
data: { container,
metadata?: Record<string, unknown> manager,
customer_id?: string data,
variant_id: string }) {
quantity: number
},
{ container, manager }: { container: AwilixContainer; manager: EntityManager }
): Promise<IdempotencyCallbackResult> {
const cartService: CartService = container.resolve("cartService") const cartService: CartService = container.resolve("cartService")
const lineItemService: LineItemService = container.resolve("lineItemService") const lineItemService: LineItemService = container.resolve("lineItemService")
const featureFlagRouter: FlagRouter = container.resolve("featureFlagRouter")
const productVariantInventoryService: ProductVariantInventoryService = const cart = await cartService.retrieve(cartId, {
container.resolve("productVariantInventoryService")
const txCartService = cartService.withTransaction(manager)
let cart = await txCartService.retrieve(cartId, {
select: ["id", "region_id", "customer_id"], select: ["id", "region_id", "customer_id"],
}) })
@@ -48,42 +34,46 @@ export async function handleAddOrUpdateLineItem(
metadata: data.metadata, metadata: data.metadata,
}) })
await txCartService.addOrUpdateLineItems(cart.id, line, { await manager.transaction(async (transactionManager) => {
validateSalesChannels: featureFlagRouter.isFeatureEnabled("sales_channels"), const txCartService = cartService.withTransaction(transactionManager)
})
const relations = [ await txCartService.addOrUpdateLineItems(cart.id, line, {
...defaultStoreCartRelations, validateSalesChannels:
"billing_address", featureFlagRouter.isFeatureEnabled("sales_channels"),
"region.payment_providers", })
"payment_sessions", })
"customer", }
]
export async function setPaymentSession({ cart, container, manager }) {
const cartService: CartService = container.resolve("cartService")
const txCartService = cartService.withTransaction(manager)
if (!cart.payment_sessions?.length) {
return
}
return await txCartService.setPaymentSessions(
cart as WithRequiredProperty<Cart, "total">
)
}
export async function setVariantAvailability({ cart, container, manager }) {
const productVariantInventoryService: ProductVariantInventoryService =
container.resolve("productVariantInventoryService")
const shouldSetAvailability = const shouldSetAvailability =
relations?.some((rel) => rel.includes("variant")) && cart.items?.some((item) => !!item.variant) &&
featureFlagRouter.isFeatureEnabled(SalesChannelFeatureFlag.key) featureFlagRouter.isFeatureEnabled(SalesChannelFeatureFlag.key)
cart = await txCartService.retrieveWithTotals(cart.id, { if (!shouldSetAvailability) {
select: defaultStoreCartFields, return
relations, }
})
if (shouldSetAvailability) { return await productVariantInventoryService
await productVariantInventoryService.setVariantAvailability( .withTransaction(manager)
.setVariantAvailability(
cart.items.map((i) => i.variant), cart.items.map((i) => i.variant),
cart.sales_channel_id! cart.sales_channel_id!
) )
}
if (cart.payment_sessions?.length) {
await txCartService.setPaymentSessions(
cart as WithRequiredProperty<Cart, "total">
)
}
return {
response_code: 200,
response_body: { cart },
}
} }
@@ -8,7 +8,6 @@ import { defaultStoreCartFields, defaultStoreCartRelations } from "."
import { EntityManager } from "typeorm" import { EntityManager } from "typeorm"
import { MedusaError } from "medusa-core-utils" import { MedusaError } from "medusa-core-utils"
import { cleanResponseData } from "../../../../utils/clean-response-data" import { cleanResponseData } from "../../../../utils/clean-response-data"
import { handleAddOrUpdateLineItem } from "./create-line-item/utils/handler-steps"
/** /**
* @oas [post] /store/carts/{id}/line-items/{line_id} * @oas [post] /store/carts/{id}/line-items/{line_id}
@@ -0,0 +1,94 @@
import "core-js/stable"
import "regenerator-runtime/runtime"
import cluster from "cluster"
import express from "express"
import { GracefulShutdownServer } from "medusa-core-utils"
import { track } from "medusa-telemetry"
import { scheduleJob } from "node-schedule"
import os from "os"
import loaders from "../loaders"
import Logger from "../loaders/logger"
const EVERY_SIXTH_HOUR = "0 */6 * * *"
const CRON_SCHEDULE = EVERY_SIXTH_HOUR
let isShuttingDown = false
export default async function ({ port, cpus, directory }) {
if (cluster.isPrimary) {
const killMainProccess = () => process.exit(0)
cpus ??= os.cpus().length
const numCPUs = Math.min(os.cpus().length, cpus)
for (let index = 0; index < numCPUs; index++) {
const worker = cluster.fork()
worker.send({ index })
}
cluster.on("exit", (worker) => {
if (!isShuttingDown) {
cluster.fork()
} else if (Object.keys(cluster.workers).length === 0) {
setTimeout(killMainProccess, 100)
}
})
const gracefulShutDown = () => {
isShuttingDown = true
for (const id of Object.keys(cluster.workers)) {
cluster.workers[id].kill("SIGTERM")
}
}
scheduleJob(CRON_SCHEDULE, () => {
track("PING")
})
process.on("SIGTERM", gracefulShutDown)
process.on("SIGINT", gracefulShutDown)
} else {
const start = async () => {
track("CLI_START")
const app = express()
const { dbConnection } = await loaders({ directory, expressApp: app })
const serverActivity = Logger.activity(`Creating server`)
const server = GracefulShutdownServer.create(
app.listen(port, (err) => {
if (err) {
return
}
Logger.success(serverActivity, `Server is ready on port: ${port}`)
track("CLI_START_COMPLETED")
})
)
const gracefulShutDown = () => {
server
.shutdown()
.then(() => {
process.exit(0)
})
.catch((e) => {
process.exit(1)
})
}
process.on("SIGTERM", gracefulShutDown)
process.on("SIGINT", gracefulShutDown)
return { dbConnection, server }
}
process.on("message", async (msg) => {
if (msg.index > 0) {
process.env.PLUGIN_ADMIN_UI_SKIP_CACHE = true
}
await start()
})
}
}
@@ -27,6 +27,12 @@ export const IdempotencyKeyService = {
} }
} }
}), }),
update: jest.fn().mockImplementation(async (key, data) => {
return {
...data,
idempotency_key: key,
}
}),
} }
const mock = jest.fn().mockImplementation(() => { const mock = jest.fn().mockImplementation(() => {
@@ -797,8 +797,7 @@ class ProductVariantInventoryService extends TransactionBaseService {
availabilityContext availabilityContext
) )
return await promiseAll( return variants.map((variant) => {
variants.map(async (variant) => {
if (!variant.id) { if (!variant.id) {
return variant return variant
} }
@@ -837,7 +836,6 @@ class ProductVariantInventoryService extends TransactionBaseService {
return variant return variant
}) })
)
} }
private async getAvailabilityContext( private async getAvailabilityContext(
@@ -244,9 +244,17 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy {
return res return res
} }
const cart = await this.cartService_ const txCartService = this.cartService_.withTransaction(manager)
.withTransaction(manager)
.authorizePayment(id, { let cart = await txCartService.retrieve(id, {
relations: ["payment_sessions"],
})
if (cart.payment_sessions?.length) {
await txCartService.setPaymentSessions(id)
}
cart = await txCartService.authorizePayment(id, {
...context, ...context,
cart_id: id, cart_id: id,
idempotency_key: idempotencyKey, idempotency_key: idempotencyKey,
@@ -257,7 +265,7 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy {
cart.payment_session.status === "requires_more" || cart.payment_session.status === "requires_more" ||
cart.payment_session.status === "pending" cart.payment_session.status === "pending"
) { ) {
await this.cartService_.withTransaction(manager).deleteTaxLines(id) await txCartService.deleteTaxLines(id)
return { return {
response_code: 200, response_code: 200,