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