/ utils / cronJitterConfig.ts
cronJitterConfig.ts
 1  // GrowthBook-backed cron jitter configuration.
 2  //
 3  // Separated from cronScheduler.ts so the scheduler can be bundled in the
 4  // Agent SDK public build without pulling in analytics/growthbook.ts and
 5  // its large transitive dependency set (settings/hooks/config cycle).
 6  //
 7  // Usage:
 8  //   REPL (useScheduledTasks.ts): pass `getJitterConfig: getCronJitterConfig`
 9  //   Daemon/SDK: omit getJitterConfig → DEFAULT_CRON_JITTER_CONFIG applies.
10  
11  import { z } from 'zod/v4'
12  import { getFeatureValue_CACHED_WITH_REFRESH } from '../services/analytics/growthbook.js'
13  import {
14    type CronJitterConfig,
15    DEFAULT_CRON_JITTER_CONFIG,
16  } from './cronTasks.js'
17  import { lazySchema } from './lazySchema.js'
18  
19  // How often to re-fetch tengu_kairos_cron_config from GrowthBook. Short because
20  // this is an incident lever — when we push a config change to shed :00 load,
21  // we want the fleet to converge within a minute, not on the next process
22  // restart. The underlying call is a synchronous cache read; the refresh just
23  // clears the memoized entry so the next read triggers a background fetch.
24  const JITTER_CONFIG_REFRESH_MS = 60 * 1000
25  
26  // Upper bounds here are defense-in-depth against fat-fingered GrowthBook
27  // pushes. Like pollConfig.ts, Zod rejects the whole object on any violation
28  // rather than partially trusting it — a config with one bad field falls back
29  // to DEFAULT_CRON_JITTER_CONFIG entirely. oneShotFloorMs shares oneShotMaxMs's
30  // ceiling (floor > max would invert the jitter range) and is cross-checked in
31  // the refine; the shared ceiling keeps the individual bound explicit in the
32  // error path. recurringMaxAgeMs uses .default() so a pre-existing GB config
33  // without the field doesn't get wholesale-rejected — the other fields were
34  // added together at config inception and don't need this.
35  const HALF_HOUR_MS = 30 * 60 * 1000
36  const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000
37  const cronJitterConfigSchema = lazySchema(() =>
38    z
39      .object({
40        recurringFrac: z.number().min(0).max(1),
41        recurringCapMs: z.number().int().min(0).max(HALF_HOUR_MS),
42        oneShotMaxMs: z.number().int().min(0).max(HALF_HOUR_MS),
43        oneShotFloorMs: z.number().int().min(0).max(HALF_HOUR_MS),
44        oneShotMinuteMod: z.number().int().min(1).max(60),
45        recurringMaxAgeMs: z
46          .number()
47          .int()
48          .min(0)
49          .max(THIRTY_DAYS_MS)
50          .default(DEFAULT_CRON_JITTER_CONFIG.recurringMaxAgeMs),
51      })
52      .refine(c => c.oneShotFloorMs <= c.oneShotMaxMs),
53  )
54  
55  /**
56   * Read `tengu_kairos_cron_config` from GrowthBook, validate, fall back to
57   * defaults on absent/malformed/out-of-bounds config. Called from check()
58   * every tick via the `getJitterConfig` callback — cheap (synchronous cache
59   * hit). Refresh window: JITTER_CONFIG_REFRESH_MS.
60   *
61   * Exported so ops runbooks can point at a single function when documenting
62   * the lever, and so tests can spy on it without mocking GrowthBook itself.
63   *
64   * Pass this as `getJitterConfig` when calling createCronScheduler in REPL
65   * contexts. Daemon/SDK callers omit getJitterConfig and get defaults.
66   */
67  export function getCronJitterConfig(): CronJitterConfig {
68    const raw = getFeatureValue_CACHED_WITH_REFRESH<unknown>(
69      'tengu_kairos_cron_config',
70      DEFAULT_CRON_JITTER_CONFIG,
71      JITTER_CONFIG_REFRESH_MS,
72    )
73    const parsed = cronJitterConfigSchema().safeParse(raw)
74    return parsed.success ? parsed.data : DEFAULT_CRON_JITTER_CONFIG
75  }