Register scheduled jobs programmatically, implement secure job endpoints, and monitor run history.
Running background tasks with setInterval() inside a Node.js process works locally but breaks in Cloud Foundry the moment your app scales to multiple instances — every instance fires the same job. SAP Job Scheduling Service solves this: it calls your HTTP endpoint on a schedule, handles retries and SLA monitoring, and shows run history in the cockpit. It is the standard BTP approach to reliable recurring jobs.
JSS is a scheduler as a service. You register jobs — each job is an HTTP endpoint on your app plus a cron or recurring schedule. JSS calls your endpoint when the job fires, passing a JobID and ScheduleID in the body. Your endpoint does the work and returns 200 OK or an error; JSS logs the result and retries on failure.
BTP Job Scheduling Service
│ POST /jobs/run/nightly-rollup
▼
Your CAP service endpoint
│ do work
│ return 200 OK
▼
JSS records success / failure, retries up to N times
cf create-service jobscheduler standard my-jobscheduler
cf bind-service my-cap-app my-jobscheduler
MTA descriptor:
resources:
- name: my-jobscheduler
type: org.cloudfoundry.managed-service
parameters:
service: jobscheduler
service-plan: standard
JSS exposes a REST API for managing jobs. Register jobs on application startup:
// srv/jobs/job-registry.js
const cds = require('@sap/cds');
module.exports = async function registerJobs() {
const jssCredentials = getJSSCredentials();
const token = await getJSSToken(jssCredentials);
const baseUrl = jssCredentials.endpoints.scheduler_rest_url;
// Check if job already exists
const existing = await fetch(`${baseUrl}/scheduler/jobs?name=nightly-rollup`, {
headers: { Authorization: `Bearer ${token}` }
}).then(r => r.json());
if (existing.total > 0) {
console.log('[Jobs] nightly-rollup already registered');
return;
}
// Register the job
await fetch(`${baseUrl}/scheduler/jobs`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'nightly-rollup',
description: 'Nightly aggregation of daily metrics',
action: `${process.env.APP_URL}/jobs/run/nightly-rollup`,
active: true,
httpMethod: 'POST',
schedules: [{
cron: '0 2 * * *', // 02:00 UTC every day
active: true,
description: 'Nightly at 02:00 UTC'
}]
})
});
console.log('[Jobs] nightly-rollup registered');
};
function getJSSCredentials() {
const vcap = JSON.parse(process.env.VCAP_SERVICES || '{}');
return vcap.jobscheduler?.[0]?.credentials;
}
async function getJSSToken(creds) {
const res = await fetch(`${creds.uaa.url}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: creds.uaa.clientid,
client_secret: creds.uaa.clientsecret
})
});
return (await res.json()).access_token;
}
Call registerJobs() from server.js after the app starts:
// srv/server.js
const cds = require('@sap/cds');
cds.on('served', async () => {
const { registerJobs } = require('./jobs/job-registry');
await registerJobs();
});
// srv/jobs/nightly-rollup.js — registered as a custom express route
module.exports = function (app) {
app.post('/jobs/run/nightly-rollup', verifyJSSRequest, async (req, res) => {
const { JobID, ScheduleID, RunID } = req.body;
const logger = cds.log('jobs');
logger.info(`[nightly-rollup] Starting — RunID: ${RunID}`);
try {
const db = await cds.connect.to('db');
// Example: aggregate yesterday's orders
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const dateStr = yesterday.toISOString().slice(0, 10);
await db.run(`
INSERT INTO DailyMetrics (date, totalOrders, totalRevenue)
SELECT '${dateStr}', COUNT(*), SUM(amount)
FROM Orders
WHERE DATE(createdAt) = '${dateStr}'
ON CONFLICT (date) DO UPDATE SET
totalOrders = EXCLUDED.totalOrders,
totalRevenue = EXCLUDED.totalRevenue
`);
logger.info(`[nightly-rollup] Done — RunID: ${RunID}`);
res.status(200).json({ message: 'OK', runId: RunID });
} catch (err) {
logger.error(`[nightly-rollup] Failed — RunID: ${RunID}`, err);
res.status(500).json({ error: err.message }); // JSS will retry
}
});
};
// Verify the request comes from JSS (check the Authorization header)
function verifyJSSRequest(req, res, next) {
const auth = req.headers.authorization;
if (!auth?.startsWith('Bearer ')) return res.status(401).send('Unauthorized');
// In production: verify JWT signature against JSS XSUAA instance
next();
}
Register the route in server.js:
cds.on('bootstrap', app => {
require('./jobs/nightly-rollup')(app);
});
In the BTP Cockpit navigate to Job Scheduling Service → your instance → Jobs. Each job shows:
- Last run timestamp and status
- Run history (up to 30 days)
- Retry count per run
- Response body from your endpoint
You can also query the JSS API:
GET /scheduler/jobs/{jobId}/runs?page=1&results=20
| Mistake | Fix |
|---|---|
Using setInterval() for production jobs |
Use JSS — setInterval fires on every CF instance |
| Not verifying the JSS JWT | Anyone can hit your /jobs/run/* endpoint |
| Returning 200 before the work is done | Do the work synchronously in the handler; return 200 only on success |
| Not idempotent job handlers | JSS retries on failure — ensure re-running the same job is safe |
| Hardcoding the APP_URL | Use process.env.APP_URL which CF sets from the application route |
You can bind JSS, register jobs programmatically on app startup, implement a secure job endpoint that returns 200 on success and 500 to trigger retries, and monitor run history in the cockpit. You understand why setInterval() breaks on multi-instance CF deployments.