65 · SAP Job Scheduling Service

Register scheduled jobs programmatically, implement secure job endpoints, and monitor run history.

Episode 65 — SAP Job Scheduling Service

What you'll learn

Why this matters

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.

How JSS works

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

Binding the service

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

Registering a job programmatically

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();
});

Implementing the job endpoint

// 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);
});

Monitoring job runs

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

Common mistakes

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

Checkpoint ✓

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.

Sign in for progress tracking & Q&A →