( cron: string, fromMs: number, taskId: string, cfg: CronJitterConfig = DEFAULT_CRON_JITTER_CONFIG, )
| 419 | * inside its own jitter window doesn't fire before it was created. |
| 420 | */ |
| 421 | export function oneShotJitteredNextCronRunMs( |
| 422 | cron: string, |
| 423 | fromMs: number, |
| 424 | taskId: string, |
| 425 | cfg: CronJitterConfig = DEFAULT_CRON_JITTER_CONFIG, |
| 426 | ): number | null { |
| 427 | const t1 = nextCronRunMs(cron, fromMs) |
| 428 | if (t1 === null) return null |
| 429 | // Cron resolution is 1 minute → computed times always have :00 seconds, |
| 430 | // so a minute-field check is sufficient to identify the hot marks. |
| 431 | // getMinutes() (local), not getUTCMinutes(): cron is evaluated in local |
| 432 | // time, and "user picked a round time" means round in *their* TZ. In |
| 433 | // half-hour-offset zones (India UTC+5:30) local :00 is UTC :30 — the |
| 434 | // UTC check would jitter the wrong marks. |
| 435 | if (new Date(t1).getMinutes() % cfg.oneShotMinuteMod !== 0) return t1 |
| 436 | // floor + frac * (max - floor) → uniform over [floor, max). With floor=0 |
| 437 | // this reduces to the original frac * max. With floor>0, even a taskId |
| 438 | // hashing to 0 gets `floor` ms of lead — nobody fires on the exact mark. |
| 439 | const lead = |
| 440 | cfg.oneShotFloorMs + |
| 441 | jitterFrac(taskId) * (cfg.oneShotMaxMs - cfg.oneShotFloorMs) |
| 442 | // t1 > fromMs is guaranteed by nextCronRunMs (strictly after), so the |
| 443 | // max() only bites when the task was created inside its own lead window. |
| 444 | return Math.max(t1 - lead, fromMs) |
| 445 | } |
| 446 | |
| 447 | /** |
| 448 | * A task is "missed" when its next scheduled run (computed from createdAt) |
no test coverage detected