@sap/cds
Version:
SAP Cloud Application Programming Model - CDS for Node.js
646 lines (550 loc) • 26 kB
JavaScript
/*
* new scheduling-based event queue processing
*/
const cds = require('../../lib')
const LOG = cds.log('queue|outbox')
const { inspect } = require('util')
const { INTERNAL_USER, OUTBOX_MESSAGES, PROCESSING, QUEUE, SCHEDULER_SERVICE } = require('./consts')
const { create_task, is_provider_tenant } = require('./utils')
const JobExecutor = require('./JobExecutor')
const jobExecutor = new JobExecutor()
cds.on('shutdown', () => (jobExecutor._shutdown = true))
const { expBkfFix: waitingTime } = require('../_runtime/common/utils/waitingTime')
const $queued = Symbol('queued')
const $unqueued = Symbol('unqueued')
const $stored_reqs = Symbol('stored_reqs')
const $error = Symbol('error')
const $service = Symbol('service')
const MAX_WAITING_TIME = 1480000
const CAPATTEMPTS = Math.ceil(Math.log(MAX_WAITING_TIME / 1000 + 1) / Math.log(1.5))
let _processableTasksQuery = {}
const _addParamFalse = w => {
if (w.xpr) return w.xpr.map(xpr => _addParamFalse(xpr))
if (w.val === undefined) return w
w.param = false
return w
}
const _getProcessableTasksQuery = ({ maxAttempts, chunkSize }, queueName) => {
if (_processableTasksQuery[queueName]) return _processableTasksQuery[queueName]
const entity = cds.model.definitions[OUTBOX_MESSAGES]
// Build attempt filter conditions bounded by maxAttempts
const attemptConditions = [`attempts = 0 AND timestamp < ?`]
if (maxAttempts > 1) {
const to = maxAttempts > CAPATTEMPTS ? CAPATTEMPTS - 1 : maxAttempts - 2
for (let i = 1; i <= to; i++) {
attemptConditions.push(`attempts = ${i} AND lastAttemptTimestamp <= ?`)
}
attemptConditions.push(`attempts > ${to} AND lastAttemptTimestamp <= ?`)
}
const appid = cds.env.appid
const sql = `
SELECT ID FROM ${entity.name}
WHERE ${appid ? `appid = '${appid}' AND ` : ''}target = '${queueName}'
AND attempts < ${maxAttempts}
AND (status is NULL OR lastAttemptTimestamp < ?)
AND (${attemptConditions.join(' OR ')})
ORDER BY timestamp ASC, ID ASC
LIMIT ${chunkSize}`
_processableTasksQuery[queueName] = cds.ql(sql)
_processableTasksQuery[queueName].SELECT.limit.rows.param = false
_processableTasksQuery[queueName].SELECT.where.map(w => _addParamFalse(w))
_processableTasksQuery[queueName].SELECT.columns = Object.keys(entity.elements).map(k => ({ ref: [k] }))
_processableTasksQuery[queueName].forUpdate()
_processableTasksQuery[queueName].SELECT.forUpdate.ignoreLocked = true
return _processableTasksQuery[queueName]
}
const _getProcessableTasksQueryParams = (now, shifted, maxAttempts) => {
const maxWaitingTime = maxAttempts > CAPATTEMPTS ? MAX_WAITING_TIME : (Math.pow(1.5, maxAttempts - 1) - 1) * 1000
const params = [shifted, new Date(now).toISOString()]
if (maxAttempts > 1) {
const to = maxAttempts > CAPATTEMPTS ? CAPATTEMPTS - 1 : maxAttempts - 2
params.push(
...[...Array(to)].map((_, i) => new Date(now - (Math.pow(1.5, i + 1) - 1) * 1000).toISOString()),
new Date(now - maxWaitingTime).toISOString()
)
}
return params
}
let _flushTasksQuery = {}
const _getFlushTasksQuery = ({ maxAttempts, chunkSize }, queueName) => {
if (_flushTasksQuery[queueName]) return _flushTasksQuery[queueName]
else {
const entity = cds.model.definitions[OUTBOX_MESSAGES]
const appid = cds.env.appid
const targetFilter = appid ? `appid = '${appid}' AND target = '${queueName}'` : `target = '${queueName}'`
_flushTasksQuery[queueName] = cds.ql(`
SELECT ID FROM ${entity.name}
WHERE ${targetFilter}
AND attempts < ${maxAttempts}
AND (status is NULL OR lastAttemptTimestamp < ?)
AND timestamp < ?
AND msg LIKE '%"event":"flush"%'
AND msg LIKE '%"target":"${queueName}"%'
ORDER BY timestamp ASC, ID ASC
LIMIT ${chunkSize}`)
_flushTasksQuery[queueName].SELECT.limit.rows.param = false
_flushTasksQuery[queueName].SELECT.where.map(w => _addParamFalse(w))
_flushTasksQuery[queueName].SELECT.columns = Object.keys(entity.elements).map(k => ({ ref: [k] }))
_flushTasksQuery[queueName].forUpdate()
_flushTasksQuery[queueName].SELECT.forUpdate.ignoreLocked = true
}
return _flushTasksQuery[queueName]
}
const _safeJSONParse = string => {
try {
return string && JSON.parse(string)
} catch {
// Don't throw
}
}
// REVISIT: move _processTasks to task runnter/ a new task processor
// REVISIT: update the description below
// Note: This function can also run for each tenant on startup
//
// tx1: Fetch messages which are not in process and are not locked (SELECT FOR UPDATE)
// tx1: Set those which are processable (not 'processing' or timed out) to 'processing'
// Process messages (in parallel or sequentially)
// tx2 : Update/Delete messages based on outcome, set status to null
//
const _processTasks = (queueName, tenant, _opts = {}) => {
const opts = Object.assign({ attempt: 0 }, _opts)
if (!opts.parallel) opts.chunkSize = 1
// Add the current done callback if provided
if (opts.done) {
jobExecutor.addCallback({ target: queueName, tenant }, opts.done)
delete opts.done // Remove to avoid re-adding in recursive calls
}
const OUTBOX = cds.model.definitions[OUTBOX_MESSAGES]
let letAppCrash = false
const _done = () => {
if (letAppCrash) cds.exit(1)
// REVISIT: In case of an error this keeps scheduling the processing, even though it will likely fail again.
// We should have a better strategy for that (e.g., max attempts for scheduling as well, or a delay which increases with each attempt).
// REVISIT: Why do we need to provide a callback here at all?
jobExecutor.end({ target: queueName, tenant }, () => _processTasks(queueName, tenant, opts))
}
return jobExecutor.run({ target: queueName, tenant }, () => {
const config = tenant ? { tenant, user: cds.User.privileged } : { user: cds.User.privileged }
config.after = 1 // make sure spawn puts its cb on the `timer` queue (via setTimeout), which is also used by `taskRunner`
// REVISIT: do we really need cds.spawn (e.g., for extended model)?
const _spawn = (fn, o) => setTimeout(() => cds._with(o, fn), 1).unref()
const _begin = cds.db.kind === 'sqlite' ? _spawn : cds.spawn.bind(cds)
const _tx = cds.tx.bind(cds)
const processing_fn = async () => {
const scheduler = await cds.connect.to(SCHEDULER_SERVICE)
let selectedTasks
let more_chunks
let currMinWaitingTime
const is_t0 = is_provider_tenant(tenant)
const _now = Date.now()
const _now_iso_string = new Date(_now).toISOString()
const _timeout = cds.utils.ms4(opts.timeout)
const _setWaitingTime = time => {
if (time <= 0) return
if (currMinWaitingTime === undefined) currMinWaitingTime = time
else currMinWaitingTime = Math.min(currMinWaitingTime, time)
}
/*
* SELECT tasks for processing
*/
const _shifted = new Date(Math.max(0, _now - _timeout)).toISOString()
LOG._debug && LOG.debug(`${tenant ? tenant + ' ' : ''}${queueName}: Fetch messages`)
try {
// Use dedicated transaction to fetch relevant messages
// and _immediately_ set their status to 'processing' and commit
// thus keeping the database lock as short as possible
selectedTasks = await _tx(async () => {
let selectedTasks
try {
if (is_t0) {
const query = _getFlushTasksQuery(opts, queueName)
const params = [_shifted, _now_iso_string]
selectedTasks = await cds.db.run(query, params)
} else {
const query = _getProcessableTasksQuery(opts, queueName)
const params = _getProcessableTasksQueryParams(_now, _shifted, opts.maxAttempts)
selectedTasks = await cds.db.run(query, params)
}
} catch (e) {
LOG.warn(`${queueName}: Failed to fetch messages for processing`, e)
throw e
}
if (!selectedTasks.length) return []
// for triggering the next chunk later
more_chunks = selectedTasks.length === opts.chunkSize
// dedupe tenants to flush
if (is_t0) {
const tenants = new Set()
selectedTasks = selectedTasks.filter(task => {
const tenant = JSON.parse(task.msg).data.tenant
if (tenants.has(tenant)) return false
tenants.add(tenant)
return true
})
}
// prettier-ignore
LOG._debug && LOG.debug(`${tenant ? tenant + ' ' : ''}${queueName}: Process ${selectedTasks.length} ${selectedTasks.length > 1 ? 'messages' : 'message'}`)
// set to processing and commit as fast as possible to release locks
const where = { ID: { in: selectedTasks.map(t => t.ID) } }
const data = { status: PROCESSING }
await UPDATE(OUTBOX).set(data).where(where)
return selectedTasks
})
} catch (e) {
// server is shutting down, no need to reschedule, just exit
if (jobExecutor._shutdown) return _done()
// could potentially be a timeout
const attempt = opts.attempt + 1
const _waitingTime = waitingTime(attempt)
// prettier-ignore
LOG.error(`${queueName}: Message retrieval failed`, e, `Retry attempt ${attempt} in ${Math.round(_waitingTime / 1000)} s`)
// Note: This must not be awaited!
scheduler.scheduleFlush({
target: queueName,
tenant,
flushOpts: { after: _waitingTime === 0 ? undefined : _waitingTime, attempt }
})
return _done()
}
const tasksGen = function* () {
for (const _task of selectedTasks) {
const _msg = _safeJSONParse(_task.msg)
const context = _msg.context || {}
// REVISIT: controlled context propagation
// delete _msg.context
const userId = _msg[INTERNAL_USER]
delete _msg[INTERNAL_USER]
const user = new cds.User.Privileged(userId)
context.user = user
// The latter is for backwards compatibility where `service` was not stored and `target` was the service name
const srvName = _msg.service || _task.target
delete _msg.service
const fromSend = _msg._fromSend
delete _msg._fromSend
const msg = fromSend ? new cds.Request(_msg) : new cds.Event(_msg)
if (!msg) continue
const task = {
ID: _task.ID,
msg,
_msg: _task.msg,
context,
srvName,
attempts: _task.attempts || 0,
_task
}
yield task
}
}
const succeeded = []
const toBeUpdated = []
const afters = new Set()
// Helper to create a new message from an existing one for callbacks
const _newMsgForCallbackFrom = msg => {
const _fromSend = msg instanceof cds.Request
const newMsg = { ...msg }
if (msg.entity) newMsg.entity = msg.entity // needed for proper `h.for(msg)` handling
newMsg._fromSend = _fromSend
if (!newMsg.queue) return newMsg
newMsg.queue = { ...newMsg.queue }
delete newMsg.queue.task // follow-ups must not inherit the singleton task ID
delete newMsg.queue.every
delete newMsg.queue.after
return newMsg
}
try {
const _handleWithErr = async task => {
const service = cds.unqueued(await cds.connect.to(task.srvName))
try {
if (task.msg.query) {
const q = (task.msg.query = cds.ql(task.msg.query))
q.bind(service)
task.msg.target = cds.infer.target(q)
}
// REVISIT: Shouldn't that work like a standard inbound adapter? I.e. either of:
// - cds._with({...}, ()=> srv.dispatch(task.msg)) // instead of srv.handle(task.msg)
// - cds.tx({...}, ()=> srv.dispatch(task.msg)) // instead of srv.handle(task.msg)
// Problem: If task involves db, dedicated transactions will block on SQLite if an outer transaction is open
const _run = service.tx.bind(service)
const result = await _run({ ...task.context, tenant }, async () => {
// REVISIT: why the queued service?
// -> looks like for "custom processor" -> remove
const queued = cds.queued(service)
const result = queued.outboxed.handle
? await queued.outboxed.handle.call(service, task.msg)
: await service.handle(task.msg)
// delete after successful processing in same tx for exactly once guarantee
if (is_t0) {
await DELETE.from(OUTBOX)
.where({ timestamp: { '<': _now_iso_string } })
.where({ msg: { like: '%"event":"flush"%' } })
.where({ msg: { like: `%"tenant":"${task.msg.data.tenant}"%` } })
.where({ msg: { like: `%"target":"${task.msg.data.target}"%` } })
} else {
await DELETE.from(OUTBOX).where({ ID: task.ID })
// For atomicity: create follow-up tasks in same transaction as delete
const follow_ups = []
// #succeeded callback (only if not already a callback event)
if (!task.msg.event.endsWith('/#succeeded') && !task.msg.event.endsWith('/#failed')) {
const succeededMsg = _newMsgForCallbackFrom(task.msg)
succeededMsg.event = succeededMsg.event + '/#succeeded'
succeededMsg.results = result
if (
service.handlers.on.some(h => h.for(succeededMsg)) ||
service.handlers.after.some(h => h.for(succeededMsg))
) {
follow_ups.push({
task: create_task(queueName, succeededMsg, task.context, task.srvName, opts),
after: 0
})
afters.add(0)
}
}
// Next recurrence for .every() tasks
if (task.msg.queue?.every) {
const _m = { ...task.msg }
_m._fromSend = task.msg instanceof cds.Request
const next_iteration_task = create_task(queueName, _m, task.context, task.srvName, opts, true)
// Date.parse() only accepts up to 3 ms digits -> first 23 chars
const after = Math.max(Date.parse(next_iteration_task.timestamp.slice(0, 23) + 'Z') - Date.now(), 0)
follow_ups.push({ task: next_iteration_task, after })
afters.add(after)
}
if (follow_ups.length) {
await scheduler.scheduleTask(
follow_ups.map(f => f.task),
queueName,
{ after: follow_ups.map(f => f.after) }
)
// prettier-ignore
LOG._debug && LOG.debug(`${tenant ? tenant + ' ' : ''}${queueName}: Messages modified (-1, ~0, +${follow_ups.length})`)
}
}
return result
})
task.results = result
succeeded.push(task)
} catch (e) {
task[$error] = e
if (cds.error.isSystemError(e)) {
LOG.error(`${service.name}: Programming error detected:`, e)
task.updateData = { attempts: opts.maxAttempts }
toBeUpdated.push(task)
throw new Error(`${service.name}: Programming error detected.`, { cause: e })
}
if (e.unrecoverable) {
LOG.error(`${service.name}: Unrecoverable error:`, e)
task.updateData = { attempts: opts.maxAttempts }
toBeUpdated.push(task)
} else {
cds.repl || LOG.error(`${service.name}: Emit failed:`, e.message)
task.updateData = { attempts: task.attempts + 1 }
toBeUpdated.push(task)
return false
}
}
}
const tasks = tasksGen()
const res = await Promise.allSettled([...tasks].map(_handleWithErr))
const errors = res.filter(r => r.status === 'rejected').map(r => r.reason)
if (errors.length) throw new Error(`${queueName}: Programming errors detected.`)
} catch (e) {
LOG.error(e)
letAppCrash = true
}
// Handle failed tasks: update attempts and create #failed callbacks
if (toBeUpdated.length) {
await _tx(async () => {
const follow_up_tasks = []
for (const each of toBeUpdated) {
// REVISIT: when is this the case?
if (succeeded.some(d => d.ID === each.ID)) continue
each.updateData.status = null
if (opts.storeLastError !== false) each.updateData.lastError = inspect(each[$error])
if (each.updateData.lastError && typeof each.updateData.lastError !== 'string') {
each.updateData.lastError = inspect(each.updateData.lastError)
}
each.updateData.lastAttemptTimestamp = cds.context.timestamp
follow_up_tasks.push(Object.assign({}, each._task, each.updateData))
afters.add(waitingTime(each.updateData.attempts))
}
const _errorToObj = error => {
if (typeof error === 'string') return { message: error }
return {
name: error.name,
message: error.message,
stack: error.stack,
code: error.code,
...error
}
}
// Create #failed callbacks (only if max attempts is reached)
for (const task of toBeUpdated) {
if (
!is_t0 &&
!task.msg.event.endsWith('/#succeeded') &&
!task.msg.event.endsWith('/#failed') &&
task.updateData.attempts >= opts.maxAttempts
) {
const msg = _newMsgForCallbackFrom(task.msg)
msg.event = msg.event + '/#failed'
msg.results = _errorToObj(task[$error])
// No local cache needed as cds.connect.to caches services in cds.services
const service = await cds.connect.to(task.srvName)
if (service.handlers.on.some(h => h.for(msg)) || service.handlers.after.some(h => h.for(msg))) {
const failed_task = create_task(queueName, msg, task.context, task.srvName, opts)
follow_up_tasks.push(failed_task)
afters.add(0)
}
_setWaitingTime(waitingTime(1))
}
}
if (follow_up_tasks.length) {
await scheduler.scheduleTask(follow_up_tasks, queueName, { after: Array.from(afters).sort() })
}
})
}
// prettier-ignore
LOG._debug && LOG.debug(`${tenant ? tenant + ' ' : ''}${queueName}: Messages modified (-${succeeded.length}, ~${toBeUpdated.length})`)
if (letAppCrash) return _done()
if (toBeUpdated.length) LOG.warn(`${queueName}: Some messages could not be processed`)
// continue with next chunk?
if (more_chunks) _setWaitingTime(1) //> 1 to have a tiny waitingTime
// do not retry when triggered by task runner t0
if (currMinWaitingTime !== undefined) {
// Note: This must not be awaited!
scheduler.scheduleFlush({ target: queueName, tenant, flushOpts: { after: currMinWaitingTime } })
return _done()
}
LOG._debug && LOG.debug(`${tenant ? tenant + ' ' : ''}${queueName}: Done`)
return _done()
}
_begin(processing_fn, config)
})
}
const _hasPersistentQueue = tenant => {
if (!cds.db) return false // no persistence configured
if (cds.requires.multitenancy && tenant && is_provider_tenant(tenant) && !cds.env.requires.scheduling) return false // no persistence for provider account
return true
}
exports.queued = function queued(srv, opts) {
if (srv[$queued]) return srv[$queued]
if (opts && typeof opts !== 'object') cds.error(`Queue 'opts' must be an object, but got ${typeof opts}.`)
// Have the original & queued service reference each other
const originalSrv = srv[$unqueued] ?? srv
const queuedSrv = Object.create(originalSrv)
Object.defineProperty(queuedSrv, $unqueued, { value: originalSrv })
Object.defineProperty(srv, $queued, { value: queuedSrv })
// Get service-specific queued configurations
// REVISIT: remove with cds^11
if (queuedSrv.options?.outbox)
cds.utils.deprecated({ kind: 'Config', old: 'cds.requires.<srv>.outbox', use: 'cds.requires.<srv>.outboxed' })
if (queuedSrv.options?.outboxed && queuedSrv.options?.queued)
LOG.warn('Options "outboxed" and "queued" are both set. Using "outboxed" and ignoring "queued".')
let serviceQOpts = queuedSrv.options?.outbox ?? queuedSrv.options?.outboxed ?? queuedSrv.options?.queued
// > At this point serviceQOpts can conceivably be either object, string, or nullish
let queueName, queueOpts
// NOTE: only `outboxed: true` is officially supported
if (typeof serviceQOpts === 'string') {
queueName = serviceQOpts
queueOpts = Object.assign({}, cds.env.requires.queue, cds.env.requires[queueName])
} else if (!serviceQOpts || typeof serviceQOpts !== 'object') {
queueName = QUEUE
queueOpts = Object.assign({}, cds.env.requires.queue, opts)
} else {
queueName = srv.name
queueOpts = Object.assign({}, cds.env.requires.queue, serviceQOpts, opts)
}
// REVISIT: Deprecate 'persistent-outbox' and remove this normalization
if (queueOpts.kind === 'persistent-outbox') queueOpts.kind = 'persistent-queue'
if (queueOpts.kind !== 'persistent-queue')
throw new Error(`Unsupported queue kind: ${queueOpts.kind}. Only 'persistent-queue' is supported.`)
if (queueOpts.targetPrefix) throw new Error('Unsupported queue config "targetPrefix". Use "cds.appid" instead.')
// Store effective queue configuration at queued and outboxed (e.g. used in telemetry)
queuedSrv.queued = queuedSrv.outboxed = queueOpts
// REVISIT: remove in-memory queue support
queuedSrv.handle = async function (req) {
// REVISIT: why is req.context preferred and when would they differ?
const context = req.context || cds.context
// persistent queue
if (queueOpts.kind === 'persistent-queue' && _hasPersistentQueue(context.tenant)) {
const scheduler = await cds.connect.to(SCHEDULER_SERVICE)
const task = create_task(queueName, req, context, srv.name, queueOpts)
await scheduler.scheduleTask(task, queueName, { after: req.queue?.after }, context)
return
}
// in-memory queue
if (!context[$stored_reqs]) {
context[$stored_reqs] = []
context.on('succeeded', async () => {
// REVISIT: Also allow maxAttempts for in-memory queue?
for (const _req of context[$stored_reqs]) {
try {
if (_req.reply) await originalSrv.send(_req)
else await originalSrv.emit(_req)
} catch (e) {
LOG.error('Emit failed', { event: _req.event, cause: e })
if (cds.error.isSystemError(e)) {
await cds.shutdown(e)
return //> ?
}
}
}
delete context[$stored_reqs]
})
}
context[$stored_reqs].push(req)
}
// REVISIT: do we even need srv.flush() or is cds.flush() sufficient?
// REVISIT:
// Not great to have `flush` on the service, because it will flush the corresponding queue.
// A better API would be: (await cds.connect.to('queueName')).flush()
// But the current logic of connect.to doesn't allow having a model but no service.
queuedSrv.flush = function flush(opts) {
queueOpts[$service] = srv.name
const tenant = cds.context?.tenant
return new Promise(resolve => _processTasks(queueName, tenant, Object.assign({ done: resolve }, queueOpts, opts)))
}
queuedSrv._unschedule = async function (task) {
const query = DELETE.from(OUTBOX_MESSAGES).where({ task })
if (cds.env.appid) query.where({ appid: cds.env.appid })
// NOTE: target should and will be the service name in the future instead of the "queue name".
// until then, the service name is encoded in msg. selecting with OR ensures it works for both variants.
query.where`target = ${srv.name} OR msg LIKE ${`%"service":"${srv.name}"%`}`
return await query
}
return queuedSrv
}
exports.unqueued = function unqueued(srv) {
return srv[$unqueued] || srv
}
exports.cdsFlush = async function cdsFlush(target, opts) {
// NOTE: in cds-serve tests, credentials === {} and the SELECT below never comes back
// should never be the case in a real project, but flushing without a db doesn't make sense so check is fine
if (!cds.db?.options.credentials || !Object.keys(cds.db.options.credentials).length) return
if (!opts && typeof target !== 'string') {
opts = target
target = undefined
}
const tenant = cds.context?.tenant
let queues
if (target) {
queues = [target]
} else {
// REVISIT: why not simply await SELECT?
queues = await cds.tx(async () => {
try {
return await SELECT.from(OUTBOX_MESSAGES).groupBy('target').columns('target')
} catch (e) {
LOG.warn('Failed to fetch targets for flush', e)
return []
}
})
queues = queues.map(t => t.target)
}
const promises = []
for (const queueName of queues) {
const queueOpts = Object.assign({}, cds.requires.queue, cds.requires[queueName] || {}, opts || {})
promises.push(new Promise(resolve => _processTasks(queueName, tenant, Object.assign({ done: resolve }, queueOpts))))
}
await Promise.all(promises)
}