@sap/cds
Version:
SAP Cloud Application Programming Model - CDS for Node.js
214 lines (179 loc) • 7.89 kB
JavaScript
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
}