Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,9 @@
"swagger": {
"basePath": "/$api-docs",
"diagram": true
},
"fiori": {
"draft_admin_integrity": false
}
},
"mcp": {
Expand Down
23 changes: 12 additions & 11 deletions srv/jobs/scheduler.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@ import { sendNotificationEmail, retryFailedEmails } from '../lib/mail-client.js'
import { sendFeedbackDigests } from '../lib/feedback-digest.js';
import { resolveDisplaySettings } from '../lib/runtime-config/display-settings.js';
import { logPipelineStart, logPipelineEnd, logJobItem } from '../lib/pipeline-log.js';
import { deleteStuckOutboxRow } from '../lib/scheduler-wedge.js';
import * as alerting from '../lib/alerting.js';
import cds from '@sap/cds';

Expand Down Expand Up @@ -210,16 +209,18 @@ async function runWithLock(jobName, durationMs, fn, opts = {}) {
resource: { resourceName: jobName, resourceType: 'job' }
}); // fail-open, non-blocking
} finally {
// #1021: belt-and-suspenders — clear any stuck cds.outbox.Messages
// row for this jobName before recording JobLastRun. Runs on every
// tick (success and failure) so that a future framework bug that
// fails to flip status on success still cannot wedge us. Wrapped
// in try/catch inside the helper — never fails the cron.
try {
await deleteStuckOutboxRow(jobName);
} catch (err) {
LOG.warn(`deleteStuckOutboxRow(${jobName}) threw unexpectedly: ${err.message}`);
}
// #2975: the #1021 per-tick deleteStuckOutboxRow() call was REMOVED here.
// CAP 10.2.0 made the singleton `.every().as()` completion delete-then-
// reseed GUARDED: it recreates the next occurrence only if ITS OWN delete
// of the status='processing' row removed it (libx/queue/processing.js —
// `_cnt > 0`). Our finally deleted that same row on every tick, racing
// CAP's complete-tx; when ours won, CAP saw _cnt=0, assumed the task was
// unscheduled concurrently, and skipped re-seeding — so each job ran once
// then never rescheduled until a process restart. Harmless pre-10.2 (the
// next occurrence was recreated unconditionally), fatal from 10.2 on. CAP
// already guarantees exactly-once deletion in its complete-tx, so the
// belt-and-suspenders is obsolete. Genuine mid-flight tombstones are still
// handled out-of-band by scheduler-reaper.js.
try {
await recordJobLastRun(jobName, outcome, errorMessage);
} catch (err) {
Expand Down
47 changes: 22 additions & 25 deletions test/unit/srv/run-with-lock.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,16 @@ describe('runWithLock — JobLastRun retrofit + manual-trigger opts', () => {
});
});

// #1021: belt-and-suspenders — DELETE the stuck outbox row on every
// tick (success or failure). The DELETE call must happen inside
// runWithLock's finally block. In-memory SQLite has no cds.outbox
// schema so the DELETE is a no-op here; we spy on the helper to
// assert it was invoked in the right order.
// #2975: the #1021 per-tick deleteStuckOutboxRow() DELETE was REMOVED from
// runWithLock's finally. Under CAP 10.2's guarded singleton re-seed, deleting
// the status='processing' row per tick raced CAP's complete-tx and stopped the
// next occurrence from being scheduled (job ran once then never rescheduled).
// These tests pin the fix: runWithLock must NOT call deleteStuckOutboxRow, and
// must still record JobLastRun on both success and failure.
import * as wedge from '../../../srv/lib/scheduler-wedge.js';
import { vi } from 'vitest';

describe('runWithLock — belt-and-suspenders outbox wedge recovery (#1021)', () => {
describe('runWithLock — must not delete the outbox row per tick (#2975)', () => {
beforeAll(async () => {
await cds.deploy([
path.join(process.cwd(), 'db'),
Expand All @@ -115,50 +116,46 @@ describe('runWithLock — belt-and-suspenders outbox wedge recovery (#1021)', ()
vi.restoreAllMocks();
});

it('runWithLock invokes deleteStuckOutboxRow in finally on success', async () => {
it('does NOT call deleteStuckOutboxRow on a successful tick (would break CAP 10.2 re-seed)', async () => {
const spy = vi.spyOn(wedge, 'deleteStuckOutboxRow').mockResolvedValue(false);
registerJob({
jobName: 'test-belt-success',
jobName: 'test-noreap-success',
schedule: '0 0 1 1 *',
ttlMs: 60000,
description: 'unit test — belt on success',
description: 'unit test — no per-tick reap on success',
fn: async () => ({ ok: true }),
});
await runJobByName('test-belt-success');
expect(spy).toHaveBeenCalledWith('test-belt-success');
await runJobByName('test-noreap-success');
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
});

it('runWithLock invokes deleteStuckOutboxRow in finally on failure', async () => {
it('does NOT call deleteStuckOutboxRow on a failed tick', async () => {
const spy = vi.spyOn(wedge, 'deleteStuckOutboxRow').mockResolvedValue(false);
registerJob({
jobName: 'test-belt-fail',
jobName: 'test-noreap-fail',
schedule: '0 0 1 1 *',
ttlMs: 60000,
description: 'unit test — belt on failure',
description: 'unit test — no per-tick reap on failure',
fn: async () => { throw new Error('boom'); },
});
await runJobByName('test-belt-fail');
expect(spy).toHaveBeenCalledWith('test-belt-fail');
await runJobByName('test-noreap-fail');
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
});

it('runWithLock still records JobLastRun even if deleteStuckOutboxRow throws', async () => {
const spy = vi.spyOn(wedge, 'deleteStuckOutboxRow')
.mockRejectedValue(new Error('DELETE went sideways'));
it('still records JobLastRun on a successful tick', async () => {
registerJob({
jobName: 'test-belt-throws',
jobName: 'test-noreap-records',
schedule: '0 0 1 1 *',
ttlMs: 60000,
description: 'unit test — DELETE error does not skip JobLastRun',
description: 'unit test — JobLastRun still lands',
fn: async () => ({ ok: true }),
});
await runJobByName('test-belt-throws');
// Even though the DELETE spy rejected, JobLastRun must land.
await runJobByName('test-noreap-records');
const { JobLastRun } = cds.entities('com.sap.developers.ims');
const row = await SELECT.one.from(JobLastRun).where({ jobName: 'test-belt-throws' });
const row = await SELECT.one.from(JobLastRun).where({ jobName: 'test-noreap-records' });
expect(row).toBeTruthy();
expect(row.lastSuccessAt).toBeTruthy();
spy.mockRestore();
});
});
Loading