diff --git a/package.json b/package.json index 864400628..93cddf313 100644 --- a/package.json +++ b/package.json @@ -481,6 +481,9 @@ "swagger": { "basePath": "/$api-docs", "diagram": true + }, + "fiori": { + "draft_admin_integrity": false } }, "mcp": { diff --git a/srv/jobs/scheduler.js b/srv/jobs/scheduler.js index 5899363f2..f75c27401 100644 --- a/srv/jobs/scheduler.js +++ b/srv/jobs/scheduler.js @@ -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'; @@ -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) { diff --git a/test/unit/srv/run-with-lock.test.js b/test/unit/srv/run-with-lock.test.js index 80dcc2e96..b87f6da78 100644 --- a/test/unit/srv/run-with-lock.test.js +++ b/test/unit/srv/run-with-lock.test.js @@ -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'), @@ -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(); }); });