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.

1,299 lines (1,176 loc) 46.3 kB
"use strict"; const cds = require("@sap/cds"); const { CronExpressionParser } = require("cron-parser"); const { executeInNewTransaction } = require("./shared/cdsHelper"); const { EventProcessingStatus, TransactionMode } = require("./constants"); const distributedLock = require("./shared/distributedLock"); const EventQueueError = require("./EventQueueError"); const { arrayToFlatMap } = require("./shared/common"); const eventScheduler = require("./shared/eventScheduler"); const eventConfig = require("./config"); const PerformanceTracer = require("./shared/PerformanceTracer"); const { trace } = require("./shared/openTelemetry"); const SetIntervalDriftSafe = require("./shared/SetIntervalDriftSafe"); const IMPLEMENT_ERROR_MESSAGE = "needs to be reimplemented"; const COMPONENT_NAME = "/eventQueue/EventQueueProcessorBase"; const DEFAULT_RETRY_ATTEMPTS = 3; const DEFAULT_PARALLEL_EVENT_PROCESSING = 1; const LIMIT_PARALLEL_EVENT_PROCESSING = 10; const SELECT_LIMIT_EVENTS_PER_TICK = 100; const TRIES_FOR_EXCEEDED_EVENTS = 3; const EVENT_START_AFTER_HEADROOM = 3 * 1000; const SUFFIX_PERIODIC = "_PERIODIC"; const DEFAULT_RETRY_AFTER = 5 * 60 * 1000; class EventQueueProcessorBase { #eventsWithExceededTries = []; #exceededTriesExceeded = []; #selectedEventMap = {}; #queueEntriesWithPayloadMap = {}; #eventType = null; #eventSubType = null; #config = null; #eventSchedulerInstance = null; #eventConfig; #isPeriodic; #lastSuccessfulRunTimestamp; #retryFailedAfter; #keepAliveRunner; #currentKeepAlivePromise = Promise.resolve(); #etagMap; constructor(context, eventType, eventSubType, config) { this.__context = context; this.__baseContext = context; this.__tx = cds.tx(context); this.__baseLogger = cds.log(COMPONENT_NAME); this.#eventSchedulerInstance = eventScheduler.getInstance(); this.#config = eventConfig; this.#isPeriodic = this.#config.isPeriodicEvent(eventType, eventSubType); this.__logger = null; this.__eventProcessingMap = {}; this.__statusMap = {}; this.__commitedStatusMap = {}; this.__notCommitedStatusMap = {}; this.__outdatedEventMap = {}; this.#eventType = eventType; this.#eventSubType = eventSubType; this.#eventConfig = config ?? {}; this.__parallelEventProcessing = this.#eventConfig.parallelEventProcessing ?? DEFAULT_PARALLEL_EVENT_PROCESSING; if (this.__parallelEventProcessing > LIMIT_PARALLEL_EVENT_PROCESSING) { this.__parallelEventProcessing = LIMIT_PARALLEL_EVENT_PROCESSING; } this.#retryFailedAfter = this.#eventConfig.retryFailedAfter ?? DEFAULT_RETRY_AFTER; this.__concurrentEventProcessing = this.#eventConfig.multiInstanceProcessing; this.__retryAttempts = this.#isPeriodic ? 1 : this.#eventConfig.retryAttempts ?? DEFAULT_RETRY_ATTEMPTS; this.__selectMaxChunkSize = this.#eventConfig.selectMaxChunkSize ?? SELECT_LIMIT_EVENTS_PER_TICK; this.__selectNextChunk = !!this.#eventConfig.checkForNextChunk; this.__transactionMode = this.#eventConfig.transactionMode ?? TransactionMode.isolated; this.__emptyChunkSelected = false; this.__lockAcquired = false; this.__txUsageAllowed = true; this.__txMap = {}; this.__txRollback = {}; this.__queueEntries = []; this.#keepAliveRunner = new SetIntervalDriftSafe(this.#eventConfig.keepAliveInterval * 1000); } /** * Process one or multiple events depending on the clustering algorithm by default there it's one event * @param processContext the context valid for the event processing. This context is associated with a valid transaction * Access to the context is also possible with this.getContextForEventProcessing(key). * The associated tx can be accessed with this.getTxForEventProcessing(key). * @param {string} key cluster key generated during the clustering step. By default, this is ID of the event queue entry * @param {Array<Object>} queueEntries this are the queueEntries which are collected during the clustering step for the given * clustering key * @param {Object} payload resulting from the functions checkEventAndGeneratePayload and the clustering function * @returns {Promise<Array <Array <String, Number>>>} Must return an array of the length of passed queueEntries * This array needs to be nested based on the following structure: [ ["eventId1", EventProcessingStatus.Done], * ["eventId2", EventProcessingStatus.Error] ] */ // eslint-disable-next-line no-unused-vars async processEvent(processContext, key, queueEntries, payload) { throw new Error(IMPLEMENT_ERROR_MESSAGE); } /** * Process one periodic event * @param processContext the context valid for the event processing. This context is associated with a valid transaction * Access to the context is also possible with this.getContextForEventProcessing(key). * The associated tx can be accessed with this.getTxForEventProcessing(key). * @param {string} key cluster key generated during the clustering step. By default, this is ID of the event queue entry * @param {Object} queueEntry this is the queueEntry which should be processed * @returns {Promise<undefined>} */ // eslint-disable-next-line no-unused-vars async processPeriodicEvent(processContext, key, queueEntry) { throw new Error(IMPLEMENT_ERROR_MESSAGE); } startPerformanceTracerEvents() { this.__performanceLoggerEvents = new PerformanceTracer(this.logger, "Processing events", { properties: { type: this.eventType, subType: this.eventSubType, }, }); } startPerformanceTracerPeriodicEvents() { this.__performanceLoggerPeriodicEvents = new PerformanceTracer(this.logger, "Processing periodic event", { properties: { type: this.eventType, subType: this.eventSubType, }, }); } startPerformanceTracerPreprocessing() { this.__performanceLoggerPreprocessing = new PerformanceTracer(this.logger, "Preprocessing events", { properties: { type: this.eventType, subType: this.eventSubType, }, }); } endPerformanceTracerEvents() { this.__performanceLoggerEvents?.endPerformanceTrace( { threshold: this.#config.thresholdLoggingEventProcessing }, { eventType: this.#eventType, eventSubType: this.#eventSubType, } ); } endPerformanceTracerPeriodicEvents() { this.__performanceLoggerPeriodicEvents?.endPerformanceTrace( { threshold: this.#config.thresholdLoggingEventProcessing }, { eventType: this.#eventType, eventSubType: this.#eventSubType, } ); } endPerformanceTracerPreprocessing() { this.__performanceLoggerPreprocessing?.endPerformanceTrace( { threshold: this.#config.thresholdLoggingEventProcessing }, { eventType: this.#eventType, eventSubType: this.#eventSubType, } ); } logTimeExceededAndPublishContinue(iterationCounter) { this.logger.info("Exiting event queue processing as max time exceeded - but broadcast to trigger processing", { eventType: this.#eventType, eventSubType: this.#eventSubType, iterationCounter, }); this.#eventSchedulerInstance.scheduleEvent( this.__context.tenant, this.#eventType, this.#eventSubType, new Date(Date.now() + 5 * 1000) // add some offset to make sure all locks are released ); } logStartMessage() { this.logger.info("Processing queue event", { numberClusterEntries: Object.keys(this.eventProcessingMap).length, eventType: this.#eventType, eventSubType: this.#eventSubType, }); } /** * This function will be called for every event which should to be processed. Within this function basic validations * should be done, e.g. is the event still valid and should be processed. Also, this step should be used to gather the * required data for the clustering step. Keep in mind that this function will be called for every event and not once * for all events. Mass data select should be done later (beforeProcessingEvents). * If no payload is returned the status will be set to done. Transaction is available with this.tx; * this transaction will always be rollbacked so do not use this transaction persisting data. * @param {Object} queueEntry which has been selected from event queue table and been modified by modifyQueueEntry * @returns {Promise<Object>} payload which is needed for clustering the events. */ async checkEventAndGeneratePayload(queueEntry) { return queueEntry.payload; } /** * This function will be called for every event which should to be processed. This functions sets for every event * the payload which will be passed to the clustering functions. * @param {Object} queueEntry which has been selected from event queue table and been modified by modifyQueueEntry * @param {Object} payload which is the result of checkEventAndGeneratePayload */ addEventWithPayloadForProcessing(queueEntry, payload) { if (!this.__queueEntriesMap[queueEntry.ID]) { this.logger.error( "The supplied queueEntry has not been selected before and should not be processed. Entry will not be processed.", { eventType: this.#eventType, eventSubType: this.#eventSubType, queueEntryId: queueEntry.ID, } ); return; } this.#queueEntriesWithPayloadMap[queueEntry.ID] = { queueEntry, payload, }; } /** * This function sets the status of an queueEntry to done * @param {Object} queueEntry which has been selected from event queue table and been modified by modifyQueueEntry */ setStatusToDone(queueEntry) { this.logger.debug("setting status for queueEntry to done", { id: queueEntry.ID, eventType: this.#eventType, eventSubType: this.#eventSubType, }); this.#determineAndAddEventStatusToMap(queueEntry.ID, EventProcessingStatus.Done); } /** * This function allows to cluster multiple events so that they will be processed together. By default, there is no * clustering happening. Therefore, the cluster key is the ID of the event. If an alternative clustering is needed * this function should be overwritten. For every cluster-key the function processEvent will be called once. * This can be useful for e.g. multiple tasks have been scheduled and always the same user should be informed. * In this case the events should be clustered together and only one mail should be sent. */ async clusterQueueEntries(queueEntriesWithPayloadMap) { Object.entries(queueEntriesWithPayloadMap).forEach(([key, { queueEntry, payload }]) => { this.addEntryToProcessingMap(key, queueEntry, payload); }); } /** * This function allows to add entries to the process map. This function is needed if the function clusterQueueEntries * is redefined. For each entry in the processing map the processEvent function will be called once. * @param {String} key key for event * @param {Object} queueEntry queueEntry which should be clustered with this key * @param {Object} payload payload which should be clustered with this key */ addEntryToProcessingMap(key, queueEntry, payload) { this.logger.debug("add entry to processing map", { key, queueEntry, eventType: this.#eventType, eventSubType: this.#eventSubType, }); this.__eventProcessingMap[key] = this.__eventProcessingMap[key] ?? { queueEntries: [], payload, }; this.__eventProcessingMap[key].queueEntries.push(queueEntry); } /** * This function sets the status of multiple events to a given status. If the structure of queueEntryProcessingStatusTuple * is not as expected all events will be set to error. The function respects the config transactionMode. If * transactionMode is isolated the status will be written to a dedicated map and returned afterwards to handle concurrent * event processing. * @param {Array} queueEntries which has been selected from event queue table and been modified by modifyQueueEntry * @param {Array<Object>} queueEntryProcessingStatusTuple Array of tuple <queueEntryId, processingStatus> * @param {boolean} returnMap Allows the function to allow the result as map * @return {Object} statusMap Map which contains all events for which a status has been set so far */ setEventStatus(queueEntries, queueEntryProcessingStatusTuple, returnMap = false) { this.logger.debug("setting event status for entries", { queueEntryProcessingStatusTuple: JSON.stringify(queueEntryProcessingStatusTuple), eventType: this.#eventType, eventSubType: this.#eventSubType, }); const statusMap = this.commitOnEventLevel || returnMap ? {} : this.__statusMap; const errorHandler = (error) => { queueEntries.forEach((queueEntry) => this.#determineAndAddEventStatusToMap(queueEntry.ID, EventProcessingStatus.Error, statusMap) ); this.logger.error( "The supplied status tuple doesn't have the required structure. Setting all entries to error.", ...[ error, { eventType: this.#eventType, eventSubType: this.#eventSubType, }, ].filter((a) => a) ); }; if (!queueEntryProcessingStatusTuple) { errorHandler(); return statusMap; } try { queueEntryProcessingStatusTuple.forEach(([id, processingStatus]) => this.#determineAndAddEventStatusToMap(id, processingStatus, statusMap) ); } catch (error) { errorHandler(error); } return statusMap; } /** * This function allows to modify a select queueEntry (event) before processing. By default, the payload of the event * is parsed. The return value of the function is ignored, it's required to modify the reference which is passed into * the function. * @param {Object} queueEntry which has been selected from event queue table */ modifyQueueEntry(queueEntry) { try { queueEntry.payload = JSON.parse(queueEntry.payload); } catch { /* empty */ } try { queueEntry.context = JSON.parse(queueEntry.context); } catch { /* empty */ } } #determineAndAddEventStatusToMap(id, processingStatus, statusMap = this.__statusMap) { if (!statusMap[id]) { statusMap[id] = processingStatus; return; } if ([EventProcessingStatus.Error, EventProcessingStatus.Exceeded].includes(statusMap[id])) { // NOTE: worst aggregation --> if already error|exceeded keep this state return; } if (statusMap[id] >= 0) { statusMap[id] = processingStatus; } } handleErrorDuringProcessing(error, queueEntries) { queueEntries = Array.isArray(queueEntries) ? queueEntries : [queueEntries]; this.logger.error( "Caught error during event processing - setting queue entry to error. Please catch your promises/exceptions", error, { eventType: this.#eventType, eventSubType: this.#eventSubType, queueEntriesIds: queueEntries.map(({ ID }) => ID), } ); queueEntries.forEach((queueEntry) => this.#determineAndAddEventStatusToMap(queueEntry.ID, EventProcessingStatus.Error) ); return Object.fromEntries(queueEntries.map((queueEntry) => [queueEntry.ID, EventProcessingStatus.Error])); } handleErrorDuringPeriodicEventProcessing(error, queueEntry) { this.logger.error("Caught error during event periodic processing. Please catch your promises/exceptions.", error, { eventType: this.#eventType, eventSubType: this.#eventSubType, queueEntryId: queueEntry.ID, }); } async setPeriodicEventStatus(queueEntryIds, status) { await this.tx.run( UPDATE.entity(this.#config.tableNameEventQueue) .set({ status: status, }) .where({ ID: queueEntryIds, }) ); } /** * This function validates for all selected events one status has been submitted. It's also validated that only for * selected events a status has been submitted. Persisting the status of events is done in a dedicated database tx. * The function accepts no arguments as there are dedicated functions to set the status of events (e.g. setEventStatus) */ async persistEventStatus(tx, { skipChecks, statusMap = this.__statusMap } = {}) { this.logger.debug("entering persistEventStatus", { eventType: this.#eventType, eventSubType: this.#eventSubType, }); this.#ensureOnlySelectedQueueEntries(statusMap); if (!skipChecks) { this.#ensureEveryQueueEntryHasStatus(); } this.#ensureEveryStatusIsAllowed(statusMap); const { success, failed, exceeded, invalidAttempts } = Object.entries(statusMap).reduce( (result, [queueEntryId, processingStatus]) => { this.__commitedStatusMap[queueEntryId] = processingStatus; delete this.__notCommitedStatusMap[queueEntryId]; if (processingStatus === EventProcessingStatus.Open) { result.invalidAttempts.push(queueEntryId); } else if (processingStatus === EventProcessingStatus.Done) { result.success.push(queueEntryId); } else if (processingStatus === EventProcessingStatus.Error) { result.failed.push(queueEntryId); } else if (processingStatus === EventProcessingStatus.Exceeded) { result.exceeded.push(queueEntryId); } return result; }, { success: [], failed: [], exceeded: [], invalidAttempts: [], } ); if (![success, failed, exceeded, invalidAttempts].some((statusArray) => statusArray.length)) { this.logger.debug("exiting persistEventStatus", { eventType: this.#eventType, eventSubType: this.#eventSubType, }); return; } return await trace(this.baseContext, "persist-event-status", async () => { this.logger.debug("persistEventStatus for entries", { eventType: this.#eventType, eventSubType: this.#eventSubType, invalidAttempts, failed, exceeded, success, }); if (invalidAttempts.length) { await tx.run( UPDATE.entity(this.#config.tableNameEventQueue) .set({ status: EventProcessingStatus.Open, lastAttemptTimestamp: new Date().toISOString(), attempts: { "-=": 1 }, }) .where("ID IN", invalidAttempts) ); } const ts = new Date().toISOString(); const updateTuples = [ [success, EventProcessingStatus.Done], [failed, EventProcessingStatus.Error], [exceeded, EventProcessingStatus.Exceeded], ]; for (const [eventIds, status] of updateTuples) { if (!eventIds.length) { continue; } let startAfter; if (status === EventProcessingStatus.Error) { startAfter = new Date(Date.now() + this.#retryFailedAfter); this.#eventSchedulerInstance.scheduleEvent( this.__context.tenant, this.#eventType, this.#eventSubType, startAfter ); } await tx.run( UPDATE.entity(this.#config.tableNameEventQueue) .set({ status: status, lastAttemptTimestamp: ts, ...(status === EventProcessingStatus.Error ? { startAfter: startAfter.toISOString() } : {}), }) .where("ID IN", eventIds) ); } }); } #ensureEveryQueueEntryHasStatus() { this.__queueEntries.forEach((queueEntry) => { if ( queueEntry.ID in this.__statusMap || queueEntry.ID in this.__commitedStatusMap || queueEntry.ID in this.__outdatedEventMap ) { return; } this.logger.error("Missing status for selected event entry. Setting status to error", { eventType: this.#eventType, eventSubType: this.#eventSubType, queueEntry, }); this.#determineAndAddEventStatusToMap(queueEntry.ID, EventProcessingStatus.Error); }); } #ensureEveryStatusIsAllowed(statusMap) { Object.entries(statusMap).forEach(([queueEntryId, status]) => { if ( [ EventProcessingStatus.Open, EventProcessingStatus.Done, EventProcessingStatus.Error, EventProcessingStatus.Exceeded, ].includes(status) ) { return; } this.logger.error("Not allowed event status returned. Only Open, Done, Error is allowed!", { eventType: this.#eventType, eventSubType: this.#eventSubType, queueEntryId, status: statusMap[queueEntryId], }); delete statusMap[queueEntryId]; }); } #ensureOnlySelectedQueueEntries(statusMap) { Object.keys(statusMap).forEach((queueEntryId) => { if (this.#selectedEventMap[queueEntryId]) { return; } this.logger.error( "Status reported for event queue entry which haven't be selected before. Removing the status.", { eventType: this.#eventType, eventSubType: this.#eventSubType, queueEntryId, } ); delete statusMap[queueEntryId]; }); } handleErrorDuringClustering(error) { this.logger.error("Error during clustering of events - setting all queue entries to error.", error, { eventType: this.#eventType, eventSubType: this.#eventSubType, }); this.__queueEntries.forEach((queueEntry) => { this.#determineAndAddEventStatusToMap(queueEntry.ID, EventProcessingStatus.Error); }); } handleErrorTx(error) { this.logger.error("Error in commit|rollback transaction, check handlers and constraints!", error, { eventType: this.#eventType, eventSubType: this.#eventSubType, }); this.__queueEntries.forEach((queueEntry) => { this.#determineAndAddEventStatusToMap(queueEntry.ID, EventProcessingStatus.Error); }); } handleInvalidPayloadReturned(queueEntry) { this.logger.error( "Undefined payload is not allowed. If status should be done, nulls needs to be returned" + " - setting queue entry to error", { eventType: this.#eventType, eventSubType: this.#eventSubType, } ); this.#determineAndAddEventStatusToMap(queueEntry.ID, EventProcessingStatus.Error); } /** * This function selects all relevant events based on the eventType and eventSubType supplied through the constructor * during initialization of the class. * Relevant Events for selection are: open events, error events if the number retry attempts has not been succeeded or * events which are in progress for longer than 30 minutes. * @return {Promise<Array<Object>>} all relevant events for processing for the given eventType and eventSubType */ async getQueueEntriesAndSetToInProgress() { let result = []; const baseDate = Date.now(); const refDateStartAfter = new Date(baseDate + this.#config.runInterval * 1.2); await executeInNewTransaction(this.__baseContext, "eventQueue-getQueueEntriesAndSetToInProgress", async (tx) => { const entries = await tx.run( SELECT.from(this.#config.tableNameEventQueue) .forUpdate({ wait: this.#config.forUpdateTimeout }) .limit(this.selectMaxChunkSize) .where( "type =", this.#eventType, "AND subType=", this.#eventSubType, "AND ( startAfter IS NULL OR startAfter <=", refDateStartAfter.toISOString(), " ) AND ( status =", EventProcessingStatus.Open, "AND ( lastAttemptTimestamp <=", this.startTime.toISOString(), ...(this.isPeriodicEvent ? [ "OR lastAttemptTimestamp IS NULL ) OR ( status =", EventProcessingStatus.InProgress, "AND lastAttemptTimestamp <=", new Date(baseDate - this.#eventConfig.keepAliveMaxInProgressTime * 1000).toISOString(), ") )", ] : [ "OR lastAttemptTimestamp IS NULL ) OR ( status =", EventProcessingStatus.Error, "AND lastAttemptTimestamp <=", this.startTime.toISOString(), ") OR ( status =", EventProcessingStatus.InProgress, "AND lastAttemptTimestamp <=", new Date(baseDate - this.#eventConfig.keepAliveMaxInProgressTime * 1000).toISOString(), ") )", ]) ) .orderBy("createdAt", "ID") ); if (!entries.length) { this.logger.debug("no entries available for processing", { eventType: this.#eventType, eventSubType: this.#eventSubType, }); this.__emptyChunkSelected = true; return; } const { exceededTries, openEvents, exceededTriesExceeded, delayedEvents } = this.#clusterEvents( entries, refDateStartAfter ); const eventsForProcessing = openEvents .concat(exceededTriesExceeded) .concat(this.#isPeriodic ? [] : exceededTries); this.#selectedEventMap = arrayToFlatMap(eventsForProcessing); if (!this.#isPeriodic && exceededTries.length) { this.#eventsWithExceededTries = exceededTries; } if (exceededTriesExceeded.length) { this.#exceededTriesExceeded = exceededTriesExceeded; } this.#handleDelayedEvents(delayedEvents); result = openEvents; this.logger[eventsForProcessing.length && !this.isPeriodicEvent ? "info" : "debug"]( "Selected event queue entries for processing", { openEvents: openEvents.length, ...(delayedEvents.length && { delayedEvents: delayedEvents.length }), ...(exceededTries.length && { exceededTries: exceededTries.length }), eventType: this.#eventType, eventSubType: this.#eventSubType, } ); if (this.#isPeriodic && exceededTries.length) { await tx.run( UPDATE.entity(this.#config.tableNameEventQueue) .set({ status: EventProcessingStatus.Error, lastAttemptTimestamp: new Date(), }) .where( "ID IN", exceededTries.map(({ ID }) => ID) ) ); } if (!eventsForProcessing.length) { this.__emptyChunkSelected = true; return; } const isoTimestamp = new Date().toISOString(); await tx.run( UPDATE.entity(this.#config.tableNameEventQueue) .with({ status: EventProcessingStatus.InProgress, lastAttemptTimestamp: isoTimestamp, attempts: { "+=": 1 }, }) .where( "ID IN", eventsForProcessing.map(({ ID }) => ID) ) ); eventsForProcessing.forEach((entry) => { entry.lastAttemptTimestamp = isoTimestamp; // NOTE: empty payloads are supported on DB-Level. // Behaviour of event queue is: null as payload is treated as obsolete/done // For supporting this convert null to empty string --> "" as payload will be processed normally if (entry.payload === null) { entry.payload = ""; } }); this.__queueEntries = result; this.__queueEntriesMap = arrayToFlatMap(result); this.__notCommitedStatusMap = arrayToFlatMap(result); this.#etagMap = Object.fromEntries(result.map((event) => [event.ID, event.lastAttemptTimestamp])); if (this.#isPeriodic && this.#eventConfig.lastSuccessfulRunTimestamp) { this.#lastSuccessfulRunTimestamp = await this.#selectLastSuccessfulPeriodicTimestamp(tx); } }); return result; } async #selectLastSuccessfulPeriodicTimestamp() { const entry = await SELECT.one .from(this.#config.tableNameEventQueue) .where({ type: this.#eventType, subType: this.#eventSubType, status: EventProcessingStatus.Done, }) .columns("max (lastAttemptTimestamp) as lastAttemptsTs"); return entry.lastAttemptsTs; } #handleDelayedEvents(delayedEvents) { for (const delayedEvent of delayedEvents) { this.#eventSchedulerInstance.scheduleEvent( this.__context.tenant, this.#eventType, this.#eventSubType, delayedEvent.startAfter ); } } #clusterEvents(events, refDateStartAfter) { const refDate = new Date(refDateStartAfter.getTime() - this.#config.runInterval * 1.2 + EVENT_START_AFTER_HEADROOM); return events.reduce( (result, event) => { if (event.attempts === this.__retryAttempts + TRIES_FOR_EXCEEDED_EVENTS) { result.exceededTriesExceeded.push(event); } else if (event.attempts >= this.__retryAttempts) { result.exceededTries.push(event); } else if (this.#isDelayedEvent(event, refDate)) { result.delayedEvents.push(event); } else { result.openEvents.push(event); } return result; }, { exceededTries: [], openEvents: [], exceededTriesExceeded: [], delayedEvents: [] } ); } #isDelayedEvent(event, refDate) { if (!event.startAfter) { return false; } event.startAfter = new Date(event.startAfter); return !(refDate >= event.startAfter); } async handleExceededEvents() { await this.#handleExceededTriesExceeded(); if (!this.#eventsWithExceededTries.length) { return; } return await trace(this.baseContext, "handle-exceeded-events", async () => { for (const exceededEvent of this.#eventsWithExceededTries) { await executeInNewTransaction( this.__baseContext, `eventQueue-handleExceededEvents-${this.#eventType}##${this.#eventSubType}`, async (tx) => { try { this.processEventContext = tx.context; this.modifyQueueEntry(exceededEvent); await this.hookForExceededEvents({ ...exceededEvent }); this.logger.warn("The retry attempts for the following events are exceeded", { eventType: this.#eventType, eventSubType: this.#eventSubType, retryAttempts: this.__retryAttempts, queueEntriesId: exceededEvent.ID, currentAttempt: exceededEvent.attempts, }); await this.#persistEventQueueStatusForExceeded(this.tx, [exceededEvent], EventProcessingStatus.Exceeded); } catch (err) { this.logger.error( "Caught error during hook for exceeded events - setting queue entry to error. Please catch your promises/exceptions.", err, { eventType: this.#eventType, eventSubType: this.#eventSubType, retryAttempts: this.__retryAttempts, queueEntriesId: exceededEvent.ID, currentAttempt: exceededEvent.attempts, } ); await tx.rollback(); await executeInNewTransaction(this.__baseContext, "error-hookForExceededEvents", async (tx) => this.#persistEventQueueStatusForExceeded(tx, [exceededEvent], EventProcessingStatus.Error) ); } } ); } }); } async #handleExceededTriesExceeded() { if (this.#exceededTriesExceeded.length) { this.logger.error("Event hook failure exceeded, status set to 'exceeded' without invoking hook again!", { eventType: this.#eventType, eventSubType: this.#eventSubType, queueEntriesIds: this.#eventsWithExceededTries.map(({ ID }) => ID), }); await executeInNewTransaction(this.__baseContext, "exceededTriesExceeded", async (tx) => { await this.#persistEventQueueStatusForExceeded(tx, this.#exceededTriesExceeded, EventProcessingStatus.Exceeded); }); } } async #persistEventQueueStatusForExceeded(tx, events, status) { const statusMap = this.setEventStatus( events, events.map((e) => [e.ID, status]), true ); await this.persistEventStatus(tx, { statusMap, skipChecks: true }); } /** * This function enables the possibility to execute custom actions for events for which the retry attempts have been * exceeded. As always a valid transaction is available with this.tx. This transaction will be committed after the * execution of this function. * @param {Object} exceededEvent exceeded event queue entry */ // eslint-disable-next-line no-unused-vars async hookForExceededEvents(exceededEvent) {} /** * This function serves the purpose of mass enabled preloading data for processing the events which are added with * the function 'addEventWithPayloadForProcessing'. This function is called after the clustering and before the * process-events-steps. The event data is available with this.eventProcessingMap. */ // eslint-disable-next-line no-unused-vars async beforeProcessingEvents() {} async isOutdatedAndKeepAlive() { if (this.__keepAliveViolated) { return true; } } continuesKeepAlive() { if (Date.now() - this.lockAcquiredTime.getTime() >= this.#eventConfig.keepAliveInterval * 1000) { trace(this.baseContext, "keepAlive-between-iterations", async () => { await this.#renewDistributedLock(); }).catch((err) => this.logger.error("renewing lock between intervals failed!", err)); } this.#keepAliveRunner.start(async () => { await this.#currentKeepAlivePromise; this.#currentKeepAlivePromise = executeInNewTransaction(this.__baseContext, "keepAlive", async (tx) => { await trace(tx.context, "keepAlive", async () => { const ids = Object.values(this.__notCommitedStatusMap).map(({ ID }) => ID); if (!ids.length) { return; } this.logger.info("keep alive triggered for events", { numberOfEvents: ids.length }); await this.#renewDistributedLock(); // we make sure to always keep alive the global event lock; but we do not modify the events itself anymore if (this.__keepAliveViolated) { return; } const events = await tx.run( SELECT.from(this.#config.tableNameEventQueue) .forUpdate({ wait: this.#config.forUpdateTimeout }) .where("ID IN", ids, "AND status =", EventProcessingStatus.InProgress) .columns("ID", "lastAttemptTimestamp") ); const newTs = new Date().toISOString(); const outdatedEvents = []; const validEventIds = []; for (const event of events) { const etag = this.#etagMap[event.ID]; if (etag !== event.lastAttemptTimestamp) { outdatedEvents.push(event); this.__keepAliveViolated = true; } else { validEventIds.push(event.ID); this.#etagMap[event.ID] = newTs; } } if (outdatedEvents.length) { // NOTE: we stop right here something is really off outdatedEvents.forEach(({ ID: queueEntryId }) => { delete this.__queueEntriesMap[queueEntryId]; this.__outdatedEventMap[queueEntryId] = 1; }); this.logger.warn( "Event data has been modified on the database. Further processing skipped. Parallel running events might have already commited status!", { eventType: this.#eventType, eventSubType: this.#eventSubType, queueEntriesIds: outdatedEvents.map(({ ID }) => ID), } ); } if (validEventIds.length) { await tx.run( UPDATE.entity(this.#config.tableNameEventQueue) .set("lastAttemptTimestamp =", newTs) .where("ID IN", validEventIds) ); // NOTE: update internal map after tx is successfully commited! tx.context.on("succeeded", () => { for (const event of events) { const etag = this.#etagMap[event.ID]; if (etag === event.lastAttemptTimestamp) { this.#etagMap[event.ID] = newTs; } } }); } this.logger.info("keep alive finished!", { numberOfEvents: ids.length }); }); }).catch((err) => { this.logger.error("keep alive handling failed!", err); }); await this.#currentKeepAlivePromise; }); } async acquireDistributedLock() { if (this.concurrentEventProcessing) { return true; } return await trace(this.baseContext, "acquire-lock", async () => { const lockAcquired = await distributedLock.acquireLock( this.__context, [this.#eventType, this.#eventSubType].join("##"), { keepTrackOfLock: true, expiryTime: this.#eventConfig.keepAliveMaxInProgressTime * 1000 } ); if (!lockAcquired) { this.logger.debug("no lock available, exit processing", { type: this.#eventType, subType: this.#eventSubType, }); return false; } this.__lockAcquired = true; return true; }); } async #renewDistributedLock() { if (this.concurrentEventProcessing) { return true; } const lockAcquired = await distributedLock.renewLock( this.__context, [this.#eventType, this.#eventSubType].join("##"), { expiryTime: this.#eventConfig.keepAliveMaxInProgressTime * 1000 } ); if (!lockAcquired) { this.logger.error("renewing distributed lock failed!", { type: this.#eventType, subType: this.#eventSubType, }); return false; } this.lockAcquiredTime = new Date(); return true; } async handleReleaseLock() { if (!this.__lockAcquired) { return; } try { await trace(this.baseContext, "release-lock", async () => { await distributedLock.releaseLock(this.context, [this.#eventType, this.#eventSubType].join("##")); }); } catch (err) { this.logger.error("Releasing distributed lock failed.", err); } } #calculateCronDates() { if (!this.#eventConfig.cron) { return null; } // NOTE: do not pass current date as we always want to calc. a future date const cronExpression = CronExpressionParser.parse(this.#eventConfig.cron, { tz: this.#eventConfig.tz, }); return cronExpression.next(); } async scheduleNextPeriodEvent(queueEntry) { const intervalInMs = this.#eventConfig.cron ? null : this.#eventConfig.interval * 1000; const next = this.#calculateCronDates(); let newStartAfter; if (this.#eventConfig.cron) { newStartAfter = next.getTime() + this.#calculateRandomOffset(); } else { newStartAfter = new Date(queueEntry.startAfter).getTime() + intervalInMs + this.#calculateRandomOffset(); } const newEvent = { type: this.#eventType, subType: this.#eventSubType, startAfter: new Date(newStartAfter), }; const { relative } = this.#eventSchedulerInstance.calculateOffset( this.#eventType, this.#eventSubType, newEvent.startAfter ); // more than one interval behind - shift tick to keep up // cron package always calc the next future date --> not needed for crone if (relative < 0 && Math.abs(relative) >= intervalInMs) { const plannedStartAfter = newEvent.startAfter; newEvent.startAfter = new Date(Date.now() + 5 * 1000); this.logger.info("interval adjusted because shifted more than one interval", { eventType: this.#eventType, eventSubType: this.#eventSubType, plannedStartAfter, newStartAfter: newEvent.startAfter, }); } this.tx._skipEventQueueBroadcase = true; await this.tx.run( INSERT.into(this.#config.tableNameEventQueue).entries({ ...newEvent, startAfter: newEvent.startAfter.toISOString(), }) ); this.tx._skipEventQueueBroadcase = false; if (intervalInMs < this.#config.runInterval * 1.5) { this.#handleDelayedEvents([newEvent]); const { relative: relativeAfterSchedule } = this.#eventSchedulerInstance.calculateOffset( this.#eventType, this.#eventSubType, newEvent.startAfter ); // NOTE: can only happen for interval events: next tick is already behind schedule --> execute direct if (relativeAfterSchedule <= 0) { this.logger.info("running behind schedule - executing next tick immediately", { eventType: this.#eventType, eventSubType: this.#eventSubType, newStartAfter: newEvent.startAfter, }); return true; } } } #calculateRandomOffset() { const offset = this.#eventConfig.randomOffset ?? this.#config.randomOffsetPeriodicEvents; if (!offset) { return 0; } return Math.floor(Math.random() * offset) * 1000; } async handleDuplicatedPeriodicEventEntry(queueEntries) { this.logger.error("More than one open events for the same configuration which is not allowed!", { eventType: this.#eventType, eventSubType: this.#eventSubType, queueEntriesIds: queueEntries.map(({ ID }) => ID), }); let queueEntryToUse; const obsoleteEntries = []; for (const queueEntry of queueEntries) { if (!queueEntryToUse) { queueEntryToUse = queueEntry; continue; } if (queueEntryToUse.startAfter <= queueEntry.queueEntry) { obsoleteEntries.push(queueEntryToUse); queueEntryToUse = queueEntry; } else { obsoleteEntries.push(queueEntry); } } await this.setPeriodicEventStatus( obsoleteEntries.map(({ ID }) => ID), EventProcessingStatus.Done ); return queueEntryToUse; } /** * Asynchronously gets the timestamp of the last successful run. * * @returns {Promise<string|null>} A Promise that resolves to a string representation of the timestamp * of the last successful run (in ISO 8601 format: YYYY-MM-DDTHH:mm:ss.sss), * or null if there has been no successful run yet. * * @example * const timestamp = await instance.getLastSuccessfulRunTimestamp(); * console.log(timestamp); // Outputs: 2023-12-07T09:15:44.237 * * @throws {Error} If an error occurs while fetching the timestamp. */ async getLastSuccessfulRunTimestamp() { if (!this.#isPeriodic) { return null; } if (this.#lastSuccessfulRunTimestamp === undefined) { this.#lastSuccessfulRunTimestamp = await this.#selectLastSuccessfulPeriodicTimestamp(); } return this.#lastSuccessfulRunTimestamp; } statusMapContainsError(statusMap) { return Object.values(statusMap).includes(EventProcessingStatus.Error); } clearEventProcessingContext() { this.__processContext = null; this.__processTx = null; } stopKeepAlive() { this.#keepAliveRunner.stop(); } get keepAlivePromise() { return this.#currentKeepAlivePromise; } get logger() { return this.__logger ?? this.__baseLogger; } set logger(value) { this.__logger = value; } get queueEntriesWithPayloadMap() { return this.#queueEntriesWithPayloadMap; } get eventProcessingMap() { return this.__eventProcessingMap; } get parallelEventProcessing() { return this.__parallelEventProcessing; } get concurrentEventProcessing() { return this.__concurrentEventProcessing; } set processEventContext(context) { if (!context) { this.__processContext = null; this.__processTx = null; return; } this.__processContext = context; this.__processTx = cds.tx(context); } get tx() { if (!this.__txUsageAllowed && this.__parallelEventProcessing > 1) { throw EventQueueError.wrongTxUsage(this.#eventType, this.#eventSubType); } return this.__processTx ?? this.__tx; } get context() { if (!this.__txUsageAllowed && this.__parallelEventProcessing > 1) { throw EventQueueError.wrongTxUsage(this.#eventType, this.#eventSubType); } return this.__processContext ?? this.__context; } get baseContext() { return this.__baseContext; } get commitOnEventLevel() { return this.__transactionMode === TransactionMode.isolated; } get transactionMode() { return this.__transactionMode; } get eventType() { return this.#eventType.replace(SUFFIX_PERIODIC, ""); } get rawEventType() { return this.#eventType; } get eventSubType() { return this.#eventSubType; } get emptyChunkSelected() { return this.__emptyChunkSelected; } get selectNextChunk() { return this.__selectNextChunk; } get selectMaxChunkSize() { return this.__selectMaxChunkSize; } set txUsageAllowed(value) { this.__txUsageAllowed = value; } getContextForEventProcessing(key) { return this.__txMap[key]?.context; } getTxForEventProcessing(key) { return this.__txMap[key]; } setShouldRollbackTransaction(key) { this.__txRollback[key] = true; } shouldRollbackTransaction(key) { return this.__txRollback[key]; } setTxForEventProcessing(key, tx) { this.__txMap[key] = tx; } get isPeriodicEvent() { return this.#eventConfig.isPeriodic; } get eventConfig() { return this.#eventConfig; } get lockAcquiredTime() { return this.#eventConfig.lockAcquiredTime; } get startTime() { return this.#eventConfig.startTime; } set lockAcquiredTime(value) { this.#eventConfig.lockAcquiredTime = value; } get inheritTraceContext() { return this.#eventConfig.inheritTraceContext; } } module.exports = EventQueueProcessorBase;