fix(server): dispatch each cron trigger exactly once (#22113)

## Problem

App/logic-function crons occasionally fire **twice, ~1 minute apart**.
The most visible symptom is a notification cron sending the same Discord
DM (or channel post) at e.g. `17:00` and again at `17:01`.

## Root cause

`CronTriggerCronJob` runs every minute (`* * * * *`) and re-dispatches
any logic function whose pattern is "due" according to `shouldRunNow`:

```ts
const diff = Math.abs(prevTriggerDate.getTime() - now.getTime());
return diff < rootCronIntervalMs; // 60_000
```

The detection window (`60_000ms`) is **equal to** the 60s tick interval.
So when a root tick drifts across a minute boundary (runs slightly
early/late, or BullMQ fires a catch-up), two adjacent ticks can both see
the *same* trigger as "within the last 60s" and each enqueue a
`LogicFunctionTriggerJob`. The dispatch isn't idempotent, so the
function runs twice.

## Fix

Make dispatch idempotent, keyed on the trigger itself:

- New `getMatchingTriggerTimestamp(pattern, now)` returns the epoch-ms
of the matched trigger (stable regardless of *when* within the window
the root job runs), or `null`. `shouldRunNow` now delegates to it —
behaviour unchanged.
- Before enqueuing, `CronTriggerCronJob` claims a
`logic-function-cron:{workspace}:{function}:{triggerTs}` key in the
`EngineLock` cache. A second tick that resolves to the same trigger
finds the key and skips.

Distinct triggers always have distinct timestamps (hence distinct keys),
so a later legitimate run is never suppressed. The TTL (2 min) only
needs to outlive the detection window.

## Notes

- `WorkflowCronTriggerCronJob` uses the same `shouldRunNow` pattern and
has the same latent double-dispatch; left out of this PR to keep it
focused, but the new helper makes the same guard a small follow-up.
- The cache `get`-then-`set` isn't atomic; for the observed failure mode
(ticks ~1 min apart, sequential) it's reliable. A Redis `SET NX` would
also close the rare concurrent-multi-instance race.

## Test plan

- [x] `should-run-now.utils.spec.ts` extended: two ticks within one
window resolve to the same timestamp; out-of-window and invalid patterns
return `null`. All 8 pass.
- [x] `oxlint --type-aware` + `oxfmt` clean on changed files.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22113?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Charles Bochet
2026-06-24 19:14:19 +02:00
committed by GitHub
parent 02120aac42
commit ad61d6d8a3
9 changed files with 105 additions and 73 deletions
@@ -1,47 +0,0 @@
import { shouldRunNow } from 'src/utils/should-run-now.utils';
const getNowDate = (hour: string) => {
return new Date(`2025-01-01T${hour}.100Z`);
};
describe('shouldRunNow', () => {
it('returns true when now matches cron pattern */1 * * * *', () => {
const cron = '*/1 * * * *';
expect(shouldRunNow(cron, getNowDate('10:00:00'))).toBe(true);
});
it('returns true with a 50s root cron delay', () => {
const cron = '*/1 * * * *';
expect(shouldRunNow(cron, getNowDate('10:00:50'))).toBe(true);
});
it('returns true 5 times in a row for a */5 pattern', () => {
const cron = '*/5 * * * *'; // every 5 minutes
expect(shouldRunNow(cron, getNowDate('09:59:00'))).toBe(false);
expect(shouldRunNow(cron, getNowDate('10:00:00'))).toBe(true);
expect(shouldRunNow(cron, getNowDate('10:01:00'))).toBe(false);
expect(shouldRunNow(cron, getNowDate('10:02:00'))).toBe(false);
expect(shouldRunNow(cron, getNowDate('10:03:00'))).toBe(false);
expect(shouldRunNow(cron, getNowDate('10:04:00'))).toBe(false);
expect(shouldRunNow(cron, getNowDate('10:05:00'))).toBe(true);
expect(shouldRunNow(cron, getNowDate('10:06:00'))).toBe(false);
});
it('returns false for invalid cron pattern', () => {
const cron = 'invalid-cron';
expect(shouldRunNow(cron, getNowDate('10:00:00'))).toBe(false);
});
it('returns false if the next run is outside the interval window (2 minutes)', () => {
const cron = '*/10 * * * *'; // every 10 minutes
const interval2min = 2 * 60_000;
expect(shouldRunNow(cron, getNowDate('10:06:00'), interval2min)).toBe(
false,
);
});
});
@@ -1,20 +0,0 @@
import { CronExpressionParser } from 'cron-parser';
export const shouldRunNow = (
pattern: string,
now: Date,
rootCronIntervalMs = 60_000,
) => {
try {
const interval = CronExpressionParser.parse(pattern, {
currentDate: now,
});
const prevTriggerDate = interval.prev();
const diff = Math.abs(prevTriggerDate.getTime() - now.getTime());
return diff < rootCronIntervalMs;
} catch {
return false;
}
};