UNPKG

@sap/cds

Version:

SAP Cloud Application Programming Model - CDS for Node.js

214 lines (179 loc) 7.89 kB
const cds = require('../../lib') const LOG = cds.log('scheduling') const { OUTBOX_MESSAGES } = require('./consts') const { deterministic_uuid } = require('./utils') const $after = Symbol('after') const $taskProcessorRegistered = Symbol('task processor registered') const FLUSH = 'flush' let T0 module.exports = class SchedulingService extends cds.Service { constructor(...args) { if (cds.env.requires.queue?.kind !== 'persistent-queue') throw new Error('SchedulingService requires persistent-queue to be enabled') super(...args) T0 = cds.env.requires.multitenancy?.t0 this.marker_interval_ms = typeof this.options.markerInterval === 'string' ? cds.utils.ms4(this.options.markerInterval) : this.options.markerInterval // make writing markers a static function this._write_marker = () => {} if (cds.env.requires.multitenancy) { if (this.options._optimisticMarkers) { this._write_marker = function (context, queueName) { // on succeeded handler for optimistic markers context.on('succeeded', () => { const afters = context[$after] ? Array.from(context[$after]) : [0] for (const after of afters) { this._upsert_marker({ queueName, tenant: context.tenant, after: after }).catch(LOG.error) } }) } } else { this._write_marker = function (context, queueName) { // before commit handler for pessimistic markers context.before('commit', async () => { const afters = context[$after] ? Array.from(context[$after]) : [0] const promises = [] for (const after of afters) { promises.push(this._upsert_marker({ queueName, tenant: context.tenant, after: after })) } await Promise.all(promises) }) } } } // flush on startup and periodically const flush_interval_ms = typeof this.options.flushInterval === 'string' ? cds.utils.ms4(this.options.flushInterval) : this.options.flushInterval cds.on('listening', () => { const _flush = async tenant => { if (tenant) cds.context = { tenant: tenant === T0 ? 't0' : tenant } //> for logging const id = cds.utils.uuid() try { let msg if (tenant) { if (tenant === T0) msg = `Running CAP-internal periodic message processing` else msg = `Running periodic message processing for tenant ${tenant}` } else { msg = `Running periodic message processing` } LOG._debug && LOG.debug(`[${id}] ${msg}`) await cds.tx(new cds.EventContext({ id, tenant }), () => cds.flush()) LOG._debug && LOG.debug(`[${id}] Message processing completed`) } catch (err) { LOG.error(`[${id}] Message processing failed:`, err) } } if (!cds.env.requires.multitenancy) { // REVISIT: should we have this periodic check in non-mtx? const periodic_flush = setInterval(_flush, flush_interval_ms).unref() cds.on('shutdown', () => clearInterval(periodic_flush)) _flush() return } // NOTE: in the future, we also need to trigger task processing in sidecar for mtx if (cds.env.profile !== 'mtx-sidecar') { const periodic_flush = setInterval(() => _flush(T0), flush_interval_ms).unref() cds.on('shutdown', () => clearInterval(periodic_flush)) _flush(T0) } }) } init() { this.on(FLUSH, async msg => { LOG._info && LOG.info(`flush ${msg.data.target} for tenant ${msg.data.tenant}`) // Flush is awaited, that means the `flush` event only finishes, when all non-planned messages of that tenant's service are processed. // If that weren't the case, there wouldn't be any backpressure and the scheduler would just flush many services in a short period of time. // How many parallel tasks are handled is a configuration of the scheduler. const opts = Object.assign({}, msg.flushOpts || {}) // REVISIT: why are we not using official cds.spawn()? await cds._with(new cds.EventContext({ tenant: msg.data.tenant }), () => cds.flush(msg.data.target, opts)) }) } scheduleFlush({ target, tenant, flushOpts }) { const cb = () => { const opts = flushOpts || {} // REVISIT: why are we not using official cds.spawn()? if (tenant) cds._with(new cds.EventContext({ tenant }), () => cds.flush(target, opts)) else cds.flush(target, opts) } if (!flushOpts?.after) return cb() setTimeout(cb, flushOpts?.after).unref() } scheduleTask = async (tasks, queueName, taskOpts, context = cds.context) => { tasks = Array.isArray(tasks) ? tasks : [tasks] context[$after] ??= new Set() if (Array.isArray(taskOpts.after)) taskOpts.after.forEach(a => context[$after].add(a)) else context[$after].add(taskOpts.after ?? 0) this._setupTaskProcessing({ queueName, context }) // prettier-ignore LOG._debug && LOG.debug(`${context.tenant ? context.tenant + ' ' : ''}${queueName}: Add ${tasks.length} task${tasks.length !== 1 ? 's' : ''} to queue`) return await UPSERT.into(cds.model.definitions[OUTBOX_MESSAGES]).entries(tasks) } _setupTaskProcessing({ queueName, context }) { if (!_registerTaskProcessor(queueName, context)) return this._write_marker(context, queueName) context.on('succeeded', () => { const afters = context[$after] ? Array.from(context[$after]) : [0] for (const after of afters) { this.scheduleFlush({ target: queueName, tenant: context.tenant, flushOpts: { after } }) } }) } async _upsert_marker({ queueName, tenant, after = 0 }) { const marker = this._get_marker({ queueName, tenant, after }) this._markers ??= new Set() if (this._markers.has(marker.ID)) return this._markers.add(marker.ID) setTimeout(() => this._markers.delete(marker.ID), after + this.marker_interval_ms * 2).unref() await cds.tx(new cds.EventContext({ tenant: T0 }), async () => { LOG._debug && LOG.debug(`Write marker for queue ${queueName} of tenant ${tenant} at ${marker.timestamp}`) await UPSERT.into(cds.model.definitions[OUTBOX_MESSAGES]).entries(marker) }) } _get_marker({ queueName, tenant, after = 0 }) { const next_firing_time = _next_firing_time_on_grid(Date.now() + after, this.marker_interval_ms, tenant) const timestamp = new Date(next_firing_time).toISOString() const ID = deterministic_uuid(`${tenant}:${timestamp}`) const marker = { ID, timestamp, target: 'queue', msg: JSON.stringify({ 'cds.internal.user': 'privileged', service: 'scheduling', context: { tenant: 't0' }, event: 'flush', data: { target: queueName, tenant } }) } if (cds.env.appid) marker.appid = cds.env.appid return marker } } const _registerTaskProcessor = (name, context) => { const registry = context[$taskProcessorRegistered] || (context[$taskProcessorRegistered] = new Set()) if (!registry.has(name)) { registry.add(name) return true } return false } const _deterministic_jitter = str => { let h = 2166136261 for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i) h = Math.imul(h, 16777619) } return (h >>> 0) / 4294967295 } const _next_firing_time_on_grid = (timestamp, interval, min_delta_fraction = 0.2, tenant) => { if (typeof min_delta_fraction === 'string') [min_delta_fraction, tenant] = [0.2, min_delta_fraction] let next = Math.ceil(timestamp / interval) * interval if (next === timestamp || next - timestamp < min_delta_fraction * interval) next = next + interval if (tenant) next = next + Math.round(_deterministic_jitter(tenant) * interval) return next }