UNPKG

@cap-js-community/event-queue

Version:

An event queue that enables secure transactional processing of asynchronous and periodic events, featuring instant event processing with Redis Pub/Sub and load distribution across all application instances.

439 lines (390 loc) 15.9 kB
"use strict"; const cds = require("@sap/cds"); const EventQueueBaseClass = require("../EventQueueProcessorBase"); const { EventProcessingStatus } = require("../constants"); const common = require("../shared/common"); const config = require("../config"); const EventQueueError = require("../EventQueueError"); const COMPONENT_NAME = "/eventQueue/outbox/generic"; const EVENT_QUEUE_ACTIONS = { EXCEEDED: "eventQueueRetriesExceeded", CLUSTER: "eventQueueCluster", CHECK_AND_ADJUST: "eventQueueCheckAndAdjustPayload", }; class EventQueueGenericOutboxHandler extends EventQueueBaseClass { constructor(context, eventType, eventSubType, config) { super(context, eventType, eventSubType, config); this.logger = cds.log(`${COMPONENT_NAME}/${eventSubType}`); } async getQueueEntriesAndSetToInProgress() { const { srvName } = config.normalizeSubType(this.eventType, this.eventSubType); this.__srv = await cds.connect.to(srvName); this.__srvUnboxed = cds.unboxed(this.__srv); const { handlers, clusterRelevant, specificClusterRelevant } = this.__srvUnboxed.handlers.on.reduce( (result, handler) => { if (handler.on.startsWith(EVENT_QUEUE_ACTIONS.CLUSTER)) { if (handler.on.split(".").length === 2) { result.specificClusterRelevant = true; } else { result.clusterRelevant = true; } } result.handlers[handler.on] = handler.on; return result; }, { handlers: {}, clusterRelevant: false, specificClusterRelevant: false } ); this.__onHandlers = handlers; this.__genericClusterHandler = clusterRelevant; this.__specificClusterHandler = specificClusterRelevant; await this.#setContextUser(this.context, config.userId); return await super.getQueueEntriesAndSetToInProgress(); } async clusterQueueEntries(queueEntriesWithPayloadMap) { if (!this.__genericClusterRelevantAndAvailable && !this.__specificClusterRelevantAndAvailable) { return super.clusterQueueEntries(queueEntriesWithPayloadMap); } const { genericClusterEvents, specificClusterEvents } = this.#clusterByAction(queueEntriesWithPayloadMap); const clusterMap = {}; if (Object.keys(genericClusterEvents).length) { if (!this.__genericClusterRelevantAndAvailable) { for (const actionName in genericClusterEvents) { await super.clusterQueueEntries(genericClusterEvents[actionName]); } } else { for (const actionName in genericClusterEvents) { const reg = new cds.Request({ event: EVENT_QUEUE_ACTIONS.CLUSTER, user: this.context.user, eventQueue: { processor: this, clusterByPayloadProperty: (propertyName, cb) => this.#clusterByPayloadProperty(actionName, genericClusterEvents[actionName], propertyName, cb), clusterByEventProperty: (propertyName, cb) => this.#clusterByEventProperty(actionName, genericClusterEvents[actionName], propertyName, cb), clusterByDataProperty: (propertyName, cb) => this.#clusterByDataProperty(actionName, genericClusterEvents[actionName], propertyName, cb), }, }); const clusterResult = await this.__srvUnboxed.tx(this.context).send(reg); if (this.#validateCluster(clusterResult)) { Object.assign(clusterMap, clusterResult); } else { this.logger.error( "cluster result of handler is not valid. Check the documentation for the expected structure. Continuing without clustering!", { handler: reg.event, clusterResult: JSON.stringify(clusterResult), } ); return super.clusterQueueEntries(queueEntriesWithPayloadMap); } } } } for (const actionName in specificClusterEvents) { const reg = new cds.Request({ event: `${EVENT_QUEUE_ACTIONS.CLUSTER}.${actionName}`, user: this.context.user, eventQueue: { processor: this, clusterByPayloadProperty: (propertyName, cb) => this.#clusterByPayloadProperty(actionName, specificClusterEvents[actionName], propertyName, cb), clusterByEventProperty: (propertyName, cb) => this.#clusterByEventProperty(actionName, specificClusterEvents[actionName], propertyName, cb), clusterByDataProperty: (propertyName, cb) => this.#clusterByDataProperty(actionName, specificClusterEvents[actionName], propertyName, cb), }, }); const clusterResult = await this.__srvUnboxed.tx(this.context).send(reg); if (this.#validateCluster(clusterResult)) { Object.assign(clusterMap, clusterResult); } else { this.logger.error( "cluster result of handler is not valid. Check the documentation for the expected structure. Continuing without clustering!", { handler: reg.event, clusterResult: JSON.stringify(clusterResult), } ); return super.clusterQueueEntries(queueEntriesWithPayloadMap); } } this.#addToProcessingMap(clusterMap); } #validateCluster(obj) { if (typeof obj !== "object" || obj === null || Array.isArray(obj)) { return false; } for (const key of Object.keys(obj)) { const clusterEntry = obj[key]; if (typeof clusterEntry !== "object" || clusterEntry === null || Array.isArray(obj)) { return false; } if (!Array.isArray(clusterEntry.queueEntries)) { return false; } if ( typeof clusterEntry.payload !== "object" || clusterEntry.payload === null || Array.isArray(clusterEntry.payload) ) { return false; } } return true; } clusterBase(queueEntriesWithPayloadMap, propertyName, refCb, cb) { const clusters = Object.entries(queueEntriesWithPayloadMap).reduce((result, [, { queueEntry, payload }]) => { const ref = refCb(result, payload, queueEntry); ref.queueEntries.push(queueEntry); return result; }, {}); if (cb) { for (const clustersKey in clusters) { const clusterData = clusters[clustersKey]; const clusterResult = cb( clustersKey.split("##").pop(), clusterData.queueEntries.map((entry) => entry.payload.data) ); if (!clusterResult) { throw EventQueueError.invalidClusterHandlerResult(clustersKey, propertyName); } clusterData.payload.data = clusterResult; } } return clusters; } #resolveRefBase(result, propertyName, actionName, payload, startRef) { const parts = propertyName.split("."); const data = JSON.parse(JSON.stringify(payload.data)); let ref = startRef; for (const part of parts) { ref = ref[part]; } const key = [actionName, ref].join("##"); result[key] ??= { queueEntries: [], payload: { ...payload, data }, }; return result[key]; } #clusterByPayloadProperty(actionName, queueEntriesWithPayloadMap, propertyName, cb) { return this.clusterBase( queueEntriesWithPayloadMap, propertyName, (result, payload) => this.#resolveRefBase(result, propertyName, actionName, payload, payload), cb ); } #clusterByEventProperty(actionName, queueEntriesWithPayloadMap, propertyName, cb) { return this.clusterBase( queueEntriesWithPayloadMap, propertyName, (result, payload, queueEntry) => this.#resolveRefBase(result, propertyName, actionName, payload, queueEntry), cb ); } #clusterByDataProperty(actionName, queueEntriesWithPayloadMap, propertyName, cb) { return this.clusterBase( queueEntriesWithPayloadMap, propertyName, (result, payload) => this.#resolveRefBase(result, propertyName, actionName, payload, payload.data), cb ); } #clusterByAction(queueEntriesWithPayloadMap) { return Object.entries(queueEntriesWithPayloadMap).reduce( (result, [eventId, clusterData]) => { const hasSpecificClusterHandler = this.#hasEventSpecificClusterHandler(clusterData.queueEntry); if (hasSpecificClusterHandler && this.__specificClusterRelevantAndAvailable) { result.specificClusterEvents[clusterData.payload.event] ??= {}; result.specificClusterEvents[clusterData.payload.event][eventId] = clusterData; } else { result.genericClusterEvents[clusterData.payload.event] ??= {}; result.genericClusterEvents[clusterData.payload.event][eventId] = clusterData; } return result; }, { genericClusterEvents: {}, specificClusterEvents: {} } ); } #addToProcessingMap(handlerCluster) { for (const clusterKey in handlerCluster) { const { payload, queueEntries } = handlerCluster[clusterKey]; for (const queueEntry of queueEntries) { this.addEntryToProcessingMap(clusterKey, queueEntry, payload); } } } // NOTE: Currently not exposed to CAP service; I don't see any valid use case at this time modifyQueueEntry(queueEntry) { super.modifyQueueEntry(queueEntry); const hasSpecificClusterHandler = this.#hasEventSpecificClusterHandler(queueEntry); if (this.__specificClusterHandler && hasSpecificClusterHandler) { this.__specificClusterRelevantAndAvailable = true; } if (this.__genericClusterHandler && !hasSpecificClusterHandler) { this.__genericClusterRelevantAndAvailable = true; } } #hasEventSpecificClusterHandler(queueEntry) { return !!this.__onHandlers[[EVENT_QUEUE_ACTIONS.CLUSTER, queueEntry.payload.event].join(".")]; } async checkEventAndGeneratePayload(queueEntry) { const payload = await super.checkEventAndGeneratePayload(queueEntry); const { event } = payload; const handlerName = this.#checkHandlerExists(EVENT_QUEUE_ACTIONS.CHECK_AND_ADJUST, event); if (!handlerName) { return payload; } const { reg, userId } = this.#buildDispatchData(this.context, payload, { queueEntries: [queueEntry], }); reg.event = handlerName; await this.#setContextUser(this.context, userId, reg); const data = await this.__srvUnboxed.tx(this.context).send(reg); if (data) { payload.data = data; return payload; } else { return null; } } async hookForExceededEvents(exceededEvent) { const { event } = exceededEvent.payload; const handlerName = this.#checkHandlerExists(EVENT_QUEUE_ACTIONS.EXCEEDED, event); if (!handlerName) { return await super.hookForExceededEvents(exceededEvent); } const { reg, userId } = this.#buildDispatchData(this.context, exceededEvent.payload, { queueEntries: [exceededEvent], }); await this.#setContextUser(this.context, userId, reg); reg.event = handlerName; await this.__srvUnboxed.tx(this.context).send(reg); } // NOTE: Currently not exposed to CAP service; we wait for a valid use case async beforeProcessingEvents() { return await super.beforeProcessingEvents(); } #checkHandlerExists(eventQueueFn, event) { const specificHandler = this.__onHandlers[[eventQueueFn, event].join(".")]; if (specificHandler) { return specificHandler; } const genericHandler = this.__onHandlers[eventQueueFn]; return genericHandler ?? null; } async processPeriodicEvent(processContext, key, queueEntry) { const { actionName } = config.normalizeSubType(this.eventType, this.eventSubType); const reg = new cds.Event({ event: actionName, eventQueue: { processor: this, key, queueEntries: [queueEntry] } }); await this.#setContextUser(processContext, config.userId, reg); await this.__srvUnboxed.tx(processContext).emit(reg); } #buildDispatchData(context, payload, { key, queueEntries } = {}) { const { useEventQueueUser } = this.eventConfig; const userId = useEventQueueUser ? config.userId : payload.contextUser; const reg = payload._fromSend ? new cds.Request(payload) : new cds.Event(payload); const invocationFn = payload._fromSend ? "send" : "emit"; delete reg._fromSend; delete reg.contextUser; reg.eventQueue = { processor: this, key, queueEntries, payload }; return { reg, userId, invocationFn }; } async #setContextUser(context, userId, reg) { const authInfo = await common.getAuthContext(context.tenant); context.user = new cds.User.Privileged({ id: userId, authInfo, tokenInfo: authInfo?.token, }); if (reg) { reg.user = context.user; } } async processEvent(processContext, key, queueEntries, payload) { try { const { userId, invocationFn, reg } = this.#buildDispatchData(processContext, payload, { key, queueEntries }); await this.#setContextUser(processContext, userId, reg); const result = await this.__srvUnboxed.tx(processContext)[invocationFn](reg); return this.#determineResultStatus(result, queueEntries); } catch (err) { this.logger.error("error processing outboxed service call", err, { serviceName: this.eventSubType, }); return queueEntries.map((queueEntry) => [ queueEntry.ID, { status: EventProcessingStatus.Error, error: err, }, ]); } } #determineResultStatus(result, queueEntries) { const validStatusValues = Object.values(EventProcessingStatus); const validStatus = validStatusValues.includes(result); if (validStatus) { return queueEntries.map((queueEntry) => [queueEntry.ID, result]); } if (result instanceof Object && !Array.isArray(result)) { const allAllowed = !Object.keys(result).some((name) => !this.allowedFieldsEventHandler.includes(name)); return queueEntries.map((queueEntry) => [ queueEntry.ID, allAllowed ? result : { status: EventProcessingStatus.Done }, ]); } if (!Array.isArray(result)) { return queueEntries.map((queueEntry) => [queueEntry.ID, EventProcessingStatus.Done]); } const [firstEntry] = result; if (Array.isArray(firstEntry)) { const [, innerResult] = firstEntry; if (innerResult instanceof Object) { const allAllowed = !Object.keys(innerResult).some((name) => !this.allowedFieldsEventHandler.includes(name)); if (allAllowed) { return result; } return queueEntries.map((queueEntry) => [queueEntry.ID, { status: EventProcessingStatus.Done }]); } else { return result.map(([id, status]) => { return [id, { status }]; }); } } else if (firstEntry instanceof Object) { return result.reduce((result, entry) => { let { ID } = entry; if (!ID) { if (queueEntries.length > 1) { throw new Error( "The CAP handler return value does not match the event-queue specification. Please check the documentation" ); } else { ID = queueEntries[0].ID; } } delete entry.ID; const allAllowed = !Object.keys(entry).some((name) => !this.allowedFieldsEventHandler.includes(name)); if (!allAllowed) { result.push([ID, { status: EventProcessingStatus.Done }]); } if (!("status" in entry)) { entry.status = EventProcessingStatus.Done; } result.push([ID, entry]); return result; }, []); } const valid = !result.some((entry) => { const [, status] = entry; return !validStatusValues.includes(status); }); if (valid) { return result; } else { return queueEntries.map((queueEntry) => [queueEntry.ID, EventProcessingStatus.Done]); } } } module.exports = EventQueueGenericOutboxHandler;