From 919605e259b746a36d5a3bd0164d80db62ef9582 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 1 Nov 2026 22:08:32 -0600 Subject: [PATCH 1/2] fix(cap): disable draft_admin_integrity under [development] profile (SQLite) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cds.fiori.draft_admin_integrity: true requires DB-level ON DELETE CASCADE constraints on every draft table's DraftAdministrativeData_DraftUUID FK (generated as .hdbconstraint, HANA-only — see srv/lib/draft-integrity-preflight.js, which is explicitly isHanaKind-gated). Unit tests run under the `development` profile on in-memory SQLite, where those constraints do not exist. Without the cascade, activating a draft leaves the active row with HasDraftEntity=true and DELETE(IsActiveEntity=true) returns 403 DRAFT_ACTIVE_DELETE_FORBIDDEN_DRAFT_EXISTS. This is universal to every draft-enabled entity (reproduced on Missions as well as GridTemplates), not a per-test cleanup gap — the discard-then-delete band-aid in #2909 only passed where the delete sat in a swallowed afterAll. Scope the flag off under [development] only. hybrid (--profile hybrid) and production run on HANA and keep the flag ON, so the optimized-deletion behavior that #2909 depends on is unchanged in every environment that has the constraints. Fixes the puzzle-grid-templates unit failure introduced by the CAP 10.2 upgrade. --- package.json | 3 +++ 1 file changed, 3 insertions(+) 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": { From 59cf870a8ba1a876fed27d638829ac9fa526d1af Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 05:59:34 -0600 Subject: [PATCH 2/2] fix(scheduler): stop deleting the singleton outbox row per tick (#2975) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CAP 10.2.0 made the `.every().as()` singleton completion delete-then-reseed guarded: the next occurrence is recreated only if CAP's own delete of the status='processing' row removed it (_cnt > 0). runWithLock's #1021 belt-and-suspenders deleted that same row in its finally on every tick, racing CAP's complete-tx; when our delete won, CAP saw _cnt=0, treated the task as unscheduled concurrently, and skipped re-seeding — so every scheduled job ran once then never rescheduled until a process restart. Harmless pre-10.2 (next occurrence was recreated unconditionally), fatal from 10.2 on. Remove the per-tick deleteStuckOutboxRow call (and its now-unused import); CAP 10.2 already guarantees exactly-once deletion in its complete-tx. Genuine mid-flight tombstones remain covered by scheduler-reaper.js and the operator forceUnwedge action (which still uses deleteStuckOutboxRow). Tests updated to assert the row is NOT deleted per tick while JobLastRun still records. --- srv/jobs/scheduler.js | 23 +++++++------- test/unit/srv/run-with-lock.test.js | 47 ++++++++++++++--------------- 2 files changed, 34 insertions(+), 36 deletions(-) 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(); }); });