Feat(): distributed caching (#13435)

RESOLVES CORE-1153

**What**
- This pr mainly lay the foundation the caching layer. It comes with a modules (built in memory cache) and a redis provider.
- Apply caching to few touch point to test

Co-authored-by: Carlos R. L. Rodrigues <37986729+carlos-r-l-rodrigues@users.noreply.github.com>
This commit is contained in:
Adrien de Peretti
2025-09-30 16:19:06 +00:00
committed by GitHub
co-authored by Carlos R. L. Rodrigues
parent 5b135a41fe
commit b9d6f73320
117 changed files with 5741 additions and 530 deletions
@@ -1,8 +1,9 @@
import {
IRegionModuleService,
IStoreModuleService,
MedusaContainer,
} from "@medusajs/framework/types"
import { MedusaError, Modules } from "@medusajs/framework/utils"
import { MedusaError, Modules, useCache } from "@medusajs/framework/utils"
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
/**
@@ -15,6 +16,48 @@ export type FindOneOrAnyRegionStepInput = {
regionId?: string
}
async function fetchRegionById(regionId: string, container: MedusaContainer) {
const service = container.resolve<IRegionModuleService>(Modules.REGION)
const args = [
regionId,
{
relations: ["countries"],
},
] as Parameters<IRegionModuleService["retrieveRegion"]>
return await useCache(async () => service.retrieveRegion(...args), {
container,
key: args,
})
}
async function fetchDefaultStore(container: MedusaContainer) {
const storeModule = container.resolve<IStoreModuleService>(Modules.STORE)
return await useCache(async () => storeModule.listStores(), {
container,
key: "find-one-or-any-region-default-store",
})
}
async function fetchDefaultRegion(
defaultRegionId: string,
container: MedusaContainer
) {
const service = container.resolve<IRegionModuleService>(Modules.REGION)
const args = [
{ id: defaultRegionId },
{ relations: ["countries"] },
] as Parameters<IRegionModuleService["listRegions"]>
return await useCache(async () => service.listRegions(...args), {
container,
key: args,
})
}
export const findOneOrAnyRegionStepId = "find-one-or-any-region"
/**
* This step retrieves a region either by the provided ID or the first region in the first store.
@@ -22,32 +65,24 @@ export const findOneOrAnyRegionStepId = "find-one-or-any-region"
export const findOneOrAnyRegionStep = createStep(
findOneOrAnyRegionStepId,
async (data: FindOneOrAnyRegionStepInput, { container }) => {
const service = container.resolve<IRegionModuleService>(Modules.REGION)
const storeModule = container.resolve<IStoreModuleService>(Modules.STORE)
if (data.regionId) {
try {
const region = await service.retrieveRegion(data.regionId, {
relations: ["countries"],
})
const region = await fetchRegionById(data.regionId, container)
return new StepResponse(region)
} catch (error) {
return new StepResponse(null)
}
}
const [store] = await storeModule.listStores()
const [store] = await fetchDefaultStore(container)
if (!store) {
throw new MedusaError(MedusaError.Types.NOT_FOUND, "Store not found")
}
const [region] = await service.listRegions(
{
id: store.default_region_id,
},
{ relations: ["countries"] }
const [region] = await fetchDefaultRegion(
store.default_region_id!,
container
)
if (!region) {
@@ -1,8 +1,14 @@
import type {
CustomerDTO,
ICustomerModuleService,
MedusaContainer,
} from "@medusajs/framework/types"
import { isDefined, Modules, validateEmail } from "@medusajs/framework/utils"
import {
isDefined,
Modules,
useCache,
validateEmail,
} from "@medusajs/framework/utils"
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
/**
@@ -39,6 +45,40 @@ interface StepCompensateInput {
customerWasCreated: boolean
}
async function fetchCustomerById(
customerId: string,
container: MedusaContainer
): Promise<CustomerDTO> {
const service = container.resolve<ICustomerModuleService>(Modules.CUSTOMER)
return await useCache<CustomerDTO>(
async () => service.retrieveCustomer(customerId),
{
container,
key: ["find-or-create-customer-by-id", customerId],
}
)
}
async function fetchCustomersByEmail(
email: string,
container: MedusaContainer,
hasAccount?: boolean
): Promise<CustomerDTO[]> {
const service = container.resolve<ICustomerModuleService>(Modules.CUSTOMER)
const filters =
hasAccount !== undefined ? { email, has_account: hasAccount } : { email }
return await useCache<CustomerDTO[]>(
async () => service.listCustomers(filters),
{
container,
key: ["find-or-create-customer-by-email", filters],
}
)
}
export const findOrCreateCustomerStepId = "find-or-create-customer"
/**
* This step finds or creates a customer based on the provided ID or email. It prioritizes finding the customer by ID, then by email.
@@ -75,7 +115,7 @@ export const findOrCreateCustomerStep = createStep(
let customerWasCreated = false
if (data.customerId) {
originalCustomer = await service.retrieveCustomer(data.customerId)
originalCustomer = await fetchCustomerById(data.customerId, container)
customerData.customer = originalCustomer
customerData.email = originalCustomer.email
}
@@ -85,9 +125,7 @@ export const findOrCreateCustomerStep = createStep(
let [customer] = originalCustomer
? [originalCustomer]
: await service.listCustomers({
email: validatedEmail,
})
: await fetchCustomersByEmail(validatedEmail, container)
// if NOT a guest customer, return it
if (customer?.has_account) {
@@ -100,10 +138,11 @@ export const findOrCreateCustomerStep = createStep(
}
if (customer && customer.email !== validatedEmail) {
;[customer] = await service.listCustomers({
email: validatedEmail,
has_account: false,
})
;[customer] = await fetchCustomersByEmail(
validatedEmail,
container,
false
)
}
if (!customer) {
@@ -1,9 +1,15 @@
import {
ISalesChannelModuleService,
IStoreModuleService,
MedusaContainer,
SalesChannelDTO,
} from "@medusajs/framework/types"
import { MedusaError, Modules, isDefined } from "@medusajs/framework/utils"
import {
MedusaError,
Modules,
isDefined,
useCache,
} from "@medusajs/framework/utils"
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
/**
@@ -16,6 +22,34 @@ export interface FindSalesChannelStepInput {
salesChannelId?: string | null
}
async function fetchSalesChannel(
salesChannelId: string,
container: MedusaContainer
) {
const salesChannelService = container.resolve<ISalesChannelModuleService>(
Modules.SALES_CHANNEL
)
return await useCache<
Awaited<ReturnType<typeof salesChannelService.retrieveSalesChannel>>
>(async () => salesChannelService.retrieveSalesChannel(salesChannelId), {
container,
key: ["find-sales-channel", salesChannelId],
})
}
async function fetchStore(container: MedusaContainer) {
const storeModule = container.resolve<IStoreModuleService>(Modules.STORE)
return await useCache<Awaited<ReturnType<typeof storeModule.listStores>>>(
async () =>
storeModule.listStores(
{},
{ select: ["id", "default_sales_channel_id"] }
),
{ key: "find-sales-channel-default-store", container }
)
}
export const findSalesChannelStepId = "find-sales-channel"
/**
* This step either retrieves a sales channel either using the ID provided as an input, or, if no ID
@@ -24,26 +58,17 @@ export const findSalesChannelStepId = "find-sales-channel"
export const findSalesChannelStep = createStep(
findSalesChannelStepId,
async (data: FindSalesChannelStepInput, { container }) => {
const salesChannelService = container.resolve<ISalesChannelModuleService>(
Modules.SALES_CHANNEL
)
const storeModule = container.resolve<IStoreModuleService>(Modules.STORE)
let salesChannel: SalesChannelDTO | undefined
if (data.salesChannelId) {
salesChannel = await salesChannelService.retrieveSalesChannel(
data.salesChannelId
)
salesChannel = await fetchSalesChannel(data.salesChannelId, container)
} else if (!isDefined(data.salesChannelId)) {
const [store] = await storeModule.listStores(
{},
{ select: ["default_sales_channel_id"] }
)
const [store] = await fetchStore(container)
if (store?.default_sales_channel_id) {
salesChannel = await salesChannelService.retrieveSalesChannel(
store.default_sales_channel_id
salesChannel = await fetchSalesChannel(
store.default_sales_channel_id,
container
)
}
}
@@ -1,7 +1,6 @@
import type { IPromotionModuleService } from "@medusajs/framework/types"
import {
ContainerRegistrationKeys,
MedusaError,
Modules,
PromotionActions,
} from "@medusajs/framework/utils"
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
@@ -72,9 +71,6 @@ export const getPromotionCodesToApply = createStep(
async (data: GetPromotionCodesToApplyStepInput, { container }) => {
const { promo_codes = [], cart, action = PromotionActions.ADD } = data
const { items = [], shipping_methods = [] } = cart
const promotionService = container.resolve<IPromotionModuleService>(
Modules.PROMOTION
)
const adjustmentCodes: string[] = []
items.concat(shipping_methods).forEach((object) => {
@@ -99,14 +95,23 @@ export const getPromotionCodesToApply = createStep(
action === PromotionActions.ADD ||
action === PromotionActions.REPLACE
) {
const query = container.resolve(ContainerRegistrationKeys.QUERY)
const validPromoCodes: Set<string> = new Set(
promo_codes.length
? (
await promotionService.listPromotions(
{ code: promo_codes },
{ select: ["code"] }
await query.graph(
{
entity: "promotion",
fields: ["id", "code"],
filters: { code: promo_codes },
},
{
cache: {
enable: true,
},
}
)
).map((p) => p.code!)
).data.map((p) => p.code!)
: []
)
@@ -1,4 +1,4 @@
import { Query } from "@medusajs/framework"
import { MedusaContainer, Query } from "@medusajs/framework"
import {
CalculatedPriceSet,
IPricingModuleService,
@@ -75,11 +75,18 @@ async function fetchVariantPriceSets(
variantIds: string[]
): Promise<VariantPriceSetData[]> {
return (
await query.graph({
entity: "variant",
fields: ["id", "price_set.id"],
filters: { id: variantIds },
})
await query.graph(
{
entity: "variant",
fields: ["id", "price_set.id"],
filters: { id: variantIds },
},
{
cache: {
enable: true,
},
}
)
).data
}
@@ -108,7 +115,8 @@ function validateVariantPriceSets(
*/
async function processVariantPriceSets(
pricingService: IPricingModuleService,
items: PriceCalculationItem[]
items: PriceCalculationItem[],
container: MedusaContainer
): Promise<GetVariantPriceSetsStepOutput> {
const result: GetVariantPriceSetsStepOutput = {}
@@ -298,7 +306,8 @@ export const getVariantPriceSetsStep = createStep(
// Use unified processing logic for both input types
const result = await processVariantPriceSets(
pricingModuleService,
calculationItems
calculationItems,
container
)
return new StepResponse(result)
@@ -1,4 +1,3 @@
import type { IPromotionModuleService } from "@medusajs/framework/types"
import {
ContainerRegistrationKeys,
Modules,
@@ -40,9 +39,6 @@ export const updateCartPromotionsStep = createStep(
const remoteQuery = container.resolve(
ContainerRegistrationKeys.REMOTE_QUERY
)
const promotionService = container.resolve<IPromotionModuleService>(
Modules.PROMOTION
)
const existingCartPromotionLinks = await remoteQuery({
entryPoint: "cart_promotion",
@@ -60,9 +56,18 @@ export const updateCartPromotionsStep = createStep(
const linksToDismiss: any[] = []
if (promo_codes?.length) {
const promotions = await promotionService.listPromotions(
{ code: promo_codes },
{ select: ["id"] }
const query = container.resolve(ContainerRegistrationKeys.QUERY)
const { data: promotions } = await query.graph(
{
entity: "promotion",
fields: ["id", "code"],
filters: { code: promo_codes },
},
{
cache: {
enable: true,
},
}
)
for (const promotion of promotions) {
@@ -205,6 +205,11 @@ export const addToCartWorkflow = createWorkflow(
filters: {
id: variantIds,
},
options: {
cache: {
enable: true,
},
},
}).config({ name: "fetch-variants" })
})
@@ -142,6 +142,11 @@ export const getVariantsAndItemsWithPrices = createWorkflow(
filters: {
id: variantIds,
},
options: {
cache: {
enable: true,
},
},
}).config({ name: "fetch-variants" })
const calculatedPriceSets = getVariantPriceSetsStep({
@@ -75,26 +75,26 @@ export const listShippingOptionsForCartWithPricingWorkflowId =
* @summary
*
* List a cart's shipping options with prices.
*
*
* @property hooks.setShippingOptionsContext - This hook is executed after the cart is retrieved and before the shipping options are queried. You can consume this hook to return any custom context useful for the shipping options retrieval.
*
* For example, you can consume the hook to add the customer Id to the context:
*
*
* ```ts
* import { listShippingOptionsForCartWithPricingWorkflow } from "@medusajs/medusa/core-flows"
* import { StepResponse } from "@medusajs/workflows-sdk"
*
*
* listShippingOptionsForCartWithPricingWorkflow.hooks.setShippingOptionsContext(
* async ({ cart }, { container }) => {
*
*
* if (cart.customer_id) {
* return new StepResponse({
* customer_id: cart.customer_id,
* })
* }
*
*
* const query = container.resolve("query")
*
*
* const { data: carts } = await query.graph({
* entity: "cart",
* filters: {
@@ -102,16 +102,16 @@ export const listShippingOptionsForCartWithPricingWorkflowId =
* },
* fields: ["customer_id"],
* })
*
*
* return new StepResponse({
* customer_id: carts[0].customer_id,
* })
* }
* )
* ```
*
*
* The `customer_id` property will be added to the context along with other properties such as `is_return` and `enabled_in_store`.
*
*
* :::note
*
* You should also consume the `setShippingOptionsContext` hook in the {@link listShippingOptionsForCartWorkflow} workflow to ensure that the context is consistent when listing shipping options across workflows.
@@ -120,7 +120,11 @@ export const listShippingOptionsForCartWithPricingWorkflowId =
*/
export const listShippingOptionsForCartWithPricingWorkflow = createWorkflow(
listShippingOptionsForCartWithPricingWorkflowId,
(input: WorkflowData<ListShippingOptionsForCartWithPricingWorkflowInput & AdditionalData>) => {
(
input: WorkflowData<
ListShippingOptionsForCartWithPricingWorkflowInput & AdditionalData
>
) => {
const optionIds = transform({ input }, ({ input }) =>
(input.options ?? []).map(({ id }) => id)
)
@@ -155,6 +159,11 @@ export const listShippingOptionsForCartWithPricingWorkflow = createWorkflow(
"stock_locations.address.*",
"stock_locations.fulfillment_sets.id",
],
options: {
cache: {
enable: true,
},
},
}).config({ name: "sales_channels-fulfillment-query" })
const scFulfillmentSets = transform(
@@ -193,13 +202,21 @@ export const listShippingOptionsForCartWithPricingWorkflow = createWorkflow(
resultValidator: shippingOptionsContextResult,
}
)
const setShippingOptionsContextResult = setShippingOptionsContext.getResult()
const setShippingOptionsContextResult =
setShippingOptionsContext.getResult()
const commonOptions = transform(
{ input, cart, fulfillmentSetIds, setShippingOptionsContextResult },
({ input, cart, fulfillmentSetIds, setShippingOptionsContextResult }) => ({
({
input,
cart,
fulfillmentSetIds,
setShippingOptionsContextResult,
}) => ({
context: {
...(setShippingOptionsContextResult ? setShippingOptionsContextResult : {}),
...(setShippingOptionsContextResult
? setShippingOptionsContextResult
: {}),
is_return: input.is_return ? "true" : "false",
enabled_in_store: !isDefined(input.enabled_in_store)
? "true"
@@ -168,6 +168,11 @@ export const listShippingOptionsForCartWorkflow = createWorkflow(
"stock_locations.name",
"stock_locations.address.*",
],
options: {
cache: {
enable: true,
},
},
}).config({ name: "sales_channels-fulfillment-query" })
const scFulfillmentSets = transform(
@@ -148,6 +148,9 @@ export const updateCartWorkflow = createWorkflow(
options: {
throwIfKeyNotFound: true,
isList: false,
cache: {
enable: true,
},
},
}).config({ name: "get-region" })
@@ -247,6 +247,11 @@ export const createOrderWorkflow = createWorkflow(
filters: {
id: variantIdsWithoutCalculatedPrice,
},
options: {
cache: {
enable: true,
},
},
}).config({ name: "query-variants-without-calculated-price" })
/**