Files
medusa-store/packages/modules/index/src/services/data-synchronizer.ts
Adrien de Peretti 065df75e7d fix(): handle empty q filters - allow to query deleted records from graph API - staled_at fixes (#11544)
* fix(): Allow to query deleted records from graph API

* fix(): Allow to query deleted records from graph API

* handle empty q value

* update staled at sync

* rename integration tests file

* Create strong-houses-marry.md

* try to fix flacky tests

* fix pricing context

* update changeset

* update changeset

* fix import

* skip test for now

---------

Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
2025-02-21 13:24:12 +01:00

350 lines
8.6 KiB
TypeScript

import {
CommonEvents,
ContainerRegistrationKeys,
Modules,
promiseAll,
} from "@medusajs/framework/utils"
import {
Event,
ILockingModule,
IndexTypes,
Logger,
ModulesSdkTypes,
RemoteQueryFunction,
SchemaObjectEntityRepresentation,
} from "@medusajs/types"
import { IndexMetadataStatus, Orchestrator } from "@utils"
import { setTimeout } from "timers/promises"
export class DataSynchronizer {
#container: Record<string, any>
#isReady: boolean = false
#schemaObjectRepresentation: IndexTypes.SchemaObjectRepresentation
#storageProvider: IndexTypes.StorageProvider
#orchestrator!: Orchestrator
get #query() {
return this.#container[
ContainerRegistrationKeys.QUERY
] as RemoteQueryFunction
}
get #locking() {
return this.#container[Modules.LOCKING] as ILockingModule
}
get #indexMetadataService(): ModulesSdkTypes.IMedusaInternalService<any> {
return this.#container.indexMetadataService
}
get #indexSyncService(): ModulesSdkTypes.IMedusaInternalService<any> {
return this.#container.indexSyncService
}
// @ts-ignore
get #indexRelationService(): ModulesSdkTypes.IMedusaInternalService<any> {
return this.#container.indexRelationService
}
get #logger(): Logger {
try {
return this.#container.logger
} catch (err) {
return console as unknown as Logger
}
}
constructor(container: Record<string, any>) {
this.#container = container
}
#isReadyOrThrow() {
if (!this.#isReady) {
throw new Error(
"DataSynchronizer is not ready. Call onApplicationStart first."
)
}
}
onApplicationStart({
schemaObjectRepresentation,
storageProvider,
}: {
lockDuration?: number
schemaObjectRepresentation: IndexTypes.SchemaObjectRepresentation
storageProvider: IndexTypes.StorageProvider
}) {
this.#storageProvider = storageProvider
this.#schemaObjectRepresentation = schemaObjectRepresentation
this.#isReady = true
}
async syncEntities(
entities: {
entity: string
fields: string
fields_hash: string
}[],
lockDuration: number = 1000 * 60 * 5
) {
this.#isReadyOrThrow()
const entitiesToSync = entities.map((entity) => entity.entity)
this.#orchestrator = new Orchestrator(this.#locking, entitiesToSync, {
lockDuration,
})
await this.#orchestrator.process(this.#taskRunner.bind(this))
}
async removeEntities(entities: string[], staleOnly: boolean = false) {
this.#isReadyOrThrow()
const staleCondition = staleOnly ? "staled_at IS NOT NULL" : ""
for (const entity of entities) {
await this.#container.manager.execute(
`WITH deleted_data AS (
DELETE FROM "index_data"
WHERE "name" = ? ${staleCondition ? `AND ${staleCondition}` : ""}
RETURNING id
)
DELETE FROM "index_relation"
WHERE ("parent_name" = ? AND "parent_id" IN (SELECT id FROM deleted_data))
OR ("child_name" = ? AND "child_id" IN (SELECT id FROM deleted_data))`,
[entity, entity, entity]
)
}
}
async #updatedStatus(entity: string, status: IndexMetadataStatus) {
await this.#indexMetadataService.update({
data: {
status,
},
selector: {
entity,
},
})
}
async #taskRunner(entity: string) {
this.#logger.info(`[Index engine] syncing entity '${entity}'`)
const [[lastCursor]] = await promiseAll([
this.#indexSyncService.list(
{
entity,
},
{
select: ["last_key"],
}
),
this.#updatedStatus(entity, IndexMetadataStatus.PROCESSING),
this.#container.manager.execute(
`UPDATE "index_data" SET "staled_at" = NOW() WHERE "name" = ?`,
[entity]
),
])
let startTime = performance.now()
let chunkStartTime = startTime
const finalAcknoledgement = await this.syncEntity({
entityName: entity,
pagination: {
cursor: lastCursor?.last_key,
},
ack: async (ack) => {
const endTime = performance.now()
const chunkElapsedTime = (endTime - chunkStartTime).toFixed(2)
const promises: Promise<any>[] = []
if (ack.lastCursor) {
this.#logger.debug(
`[Index engine] syncing entity '${entity}' updating last cursor to ${ack.lastCursor} (+${chunkElapsedTime}ms)`
)
promises.push(
this.#indexSyncService.update({
data: {
last_key: ack.lastCursor,
},
selector: {
entity,
},
})
)
if (!ack.done && !ack.err) {
promises.push(this.#orchestrator.renewLock(entity))
}
}
if (ack.err) {
this.#logger.error(
`[Index engine] syncing entity '${entity}' failed with error (+${chunkElapsedTime}ms):\n${ack.err.message}`
)
}
if (ack.done) {
const elapsedTime = (endTime - startTime).toFixed(2)
this.#logger.info(
`[Index engine] syncing entity '${entity}' done (+${elapsedTime}ms)`
)
}
await promiseAll(promises)
chunkStartTime = performance.now()
},
})
if (finalAcknoledgement.done) {
await promiseAll([
this.#updatedStatus(entity, IndexMetadataStatus.DONE),
this.#indexSyncService.update({
data: {
last_key: finalAcknoledgement.lastCursor,
},
selector: {
entity,
},
}),
this.removeEntities([entity], true),
])
}
if (finalAcknoledgement.err) {
await this.#updatedStatus(entity, IndexMetadataStatus.ERROR)
}
}
async syncEntity({
entityName,
pagination = {},
ack,
}: {
entityName: string
pagination?: {
cursor?: string
updated_at?: string | Date
limit?: number
batchSize?: number
}
ack: (ack: {
lastCursor: string | null
done?: boolean
err?: Error
}) => Promise<void>
}): Promise<{
lastCursor: string | null
done?: boolean
err?: Error
}> {
this.#isReadyOrThrow()
const schemaEntityObjectRepresentation = this.#schemaObjectRepresentation[
entityName
] as SchemaObjectEntityRepresentation
const { alias, moduleConfig } = schemaEntityObjectRepresentation
const isLink = !!moduleConfig?.isLink
if (!alias) {
const acknoledgement = {
lastCursor: pagination.cursor ?? null,
done: true,
}
await ack(acknoledgement)
return acknoledgement
}
const entityPrimaryKey = "id"
const moduleHasId = !!moduleConfig?.primaryKeys?.includes("id")
if (!moduleHasId) {
const acknoledgement = {
lastCursor: pagination.cursor ?? null,
err: new Error(
`Entity ${entityName} does not have a property 'id'. The 'id' must be provided and must be orderable (e.g ulid)`
),
}
await ack(acknoledgement)
return acknoledgement
}
let processed = 0
let currentCursor = pagination.cursor!
const batchSize = Math.min(pagination.batchSize ?? 100, 100)
const limit = pagination.limit ?? Infinity
let error = null
while (processed < limit) {
const filters: Record<string, any> = {}
if (currentCursor) {
filters[entityPrimaryKey] = { $gt: currentCursor }
}
if (pagination.updated_at) {
filters["updated_at"] = { $gt: pagination.updated_at }
}
const { data } = await this.#query.graph({
entity: alias,
fields: [entityPrimaryKey],
filters,
pagination: {
order: {
[entityPrimaryKey]: "asc",
},
take: batchSize,
},
})
if (!data.length) {
break
}
const envelop: Event = {
data,
name: !isLink
? `*.${CommonEvents.CREATED}`
: `*.${CommonEvents.ATTACHED}`,
}
try {
await this.#storageProvider.consumeEvent(
schemaEntityObjectRepresentation
)(envelop)
currentCursor = data[data.length - 1][entityPrimaryKey]
processed += data.length
await ack({ lastCursor: currentCursor })
} catch (err) {
error = err
break
}
await setTimeout(0)
}
let acknoledgement: { lastCursor: string; done?: boolean; err?: Error } = {
lastCursor: currentCursor,
done: true,
}
if (error) {
acknoledgement = {
lastCursor: currentCursor,
err: error,
}
await ack(acknoledgement)
return acknoledgement
}
await ack(acknoledgement)
return acknoledgement
}
}