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 }