feat(results): made the results scheduler window configurable

This commit is contained in:
Aswin B. S 2026-11-03 19:03:02 +05:30
parent f18779e182
commit 606583a1c5
7 changed files with 90 additions and 8 deletions

View File

@ -16,3 +16,8 @@ ORDER_CUTOFF=15:00
ORDER_ASK_UNTIL=15:30 ORDER_ASK_UNTIL=15:30
ORDER_REMIND_MINUTES=10 ORDER_REMIND_MINUTES=10
ORDER_EXPIRE_MINUTES=30 ORDER_EXPIRE_MINUTES=30
# Result PDF fetching (IST): check from START until a draw is stored, give up at END, retry every N minutes
RESULTS_WINDOW_START=16:00
RESULTS_WINDOW_END=19:00
RESULTS_RETRY_MINUTES=10

View File

@ -21,6 +21,30 @@ describe('loadConfig', () => {
expect(c.dbPath).toBe('/tmp/x.db'); expect(c.dbPath).toBe('/tmp/x.db');
}); });
describe('results settings', () => {
const base = { SRS_SECRET: 'a'.repeat(20) };
it('defaults to 16:00-19:00, retry 10', () => {
expect(loadConfig(base).results).toEqual({ windowStartMin: 960, windowEndMin: 1140, retryMin: 10 });
});
it('reads overrides', () => {
const c = loadConfig({
...base,
RESULTS_WINDOW_START: '16:30',
RESULTS_WINDOW_END: '20:00',
RESULTS_RETRY_MINUTES: '5',
});
expect(c.results).toEqual({ windowStartMin: 990, windowEndMin: 1200, retryMin: 5 });
});
it('rejects bad format, bad range and bad retry', () => {
expect(() => loadConfig({ ...base, RESULTS_WINDOW_START: '4pm' })).toThrow(/HH:MM/);
expect(() => loadConfig({ ...base, RESULTS_WINDOW_END: '16:00' })).toThrow(/later than RESULTS_WINDOW_START/);
expect(() => loadConfig({ ...base, RESULTS_RETRY_MINUTES: '0' })).toThrow(/RESULTS_RETRY_MINUTES/);
});
});
describe('order settings', () => { describe('order settings', () => {
const base = { SRS_SECRET: 'a'.repeat(20) }; const base = { SRS_SECRET: 'a'.repeat(20) };

View File

@ -21,6 +21,13 @@ const schema = z.object({
ORDER_ASK_UNTIL: hhmm('15:30'), ORDER_ASK_UNTIL: hhmm('15:30'),
ORDER_REMIND_MINUTES: z.coerce.number().int().min(1).max(1440).default(10), ORDER_REMIND_MINUTES: z.coerce.number().int().min(1).max(1440).default(10),
ORDER_EXPIRE_MINUTES: z.coerce.number().int().min(1).max(1440).default(30), ORDER_EXPIRE_MINUTES: z.coerce.number().int().min(1).max(1440).default(30),
RESULTS_WINDOW_START: hhmm('16:00'),
RESULTS_WINDOW_END: hhmm('19:00'),
RESULTS_RETRY_MINUTES: z.coerce.number().int().min(1).max(120).default(10),
})
.refine((e) => toMinutes(e.RESULTS_WINDOW_END) > toMinutes(e.RESULTS_WINDOW_START), {
path: ['RESULTS_WINDOW_END'],
message: 'must be later than RESULTS_WINDOW_START',
}) })
.refine((e) => toMinutes(e.ORDER_ASK_UNTIL) > toMinutes(e.ORDER_CUTOFF), { .refine((e) => toMinutes(e.ORDER_ASK_UNTIL) > toMinutes(e.ORDER_CUTOFF), {
path: ['ORDER_ASK_UNTIL'], path: ['ORDER_ASK_UNTIL'],
@ -38,6 +45,12 @@ export interface OrderSettings {
expireMin: number; expireMin: number;
} }
export interface ResultsSettings {
windowStartMin: number;
windowEndMin: number;
retryMin: number;
}
export interface Config { export interface Config {
port: number; port: number;
host: string; host: string;
@ -47,6 +60,7 @@ export interface Config {
bridgeUrl: string; bridgeUrl: string;
defaultCountryCode: string; defaultCountryCode: string;
orders: OrderSettings; orders: OrderSettings;
results: ResultsSettings;
} }
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
@ -75,5 +89,10 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
remindMin: e.ORDER_REMIND_MINUTES, remindMin: e.ORDER_REMIND_MINUTES,
expireMin: e.ORDER_EXPIRE_MINUTES, expireMin: e.ORDER_EXPIRE_MINUTES,
}, },
results: {
windowStartMin: toMinutes(e.RESULTS_WINDOW_START),
windowEndMin: toMinutes(e.RESULTS_WINDOW_END),
retryMin: e.RESULTS_RETRY_MINUTES,
},
}; };
} }

View File

@ -60,7 +60,34 @@ describe('results scheduler', () => {
await x.s.tick(); await x.s.tick();
await x.s.tick(); await x.s.tick();
expect(x.calls()).toBe(0); expect(x.calls()).toBe(0);
expect(x.logs.filter((l) => String(l.msg).includes('by 7 PM'))).toHaveLength(1); expect(x.logs.filter((l) => String(l.msg).includes('by end of window'))).toHaveLength(1);
});
it('honours a custom window and retry interval', async () => {
const db = makeDb();
let calls = 0;
let clock = z('09:59'); // 15:29 IST
const s = createResultsScheduler({
db,
log: () => {},
now: () => clock,
run: async () => {
calls++;
return [];
},
windowStartMin: 15 * 60 + 30,
windowEndMin: 15 * 60 + 50,
retryMin: 5,
});
await s.tick();
expect(calls).toBe(0);
clock = z('10:00'); // 15:30
await s.tick();
clock = z('10:05');
await s.tick();
clock = z('10:20'); // 15:50, window over
await s.tick();
expect(calls).toBe(2);
}); });
it('logs a held draw and survives a failing check', async () => { it('logs a held draw and survives a failing check', async () => {

View File

@ -3,13 +3,12 @@ import { runResultsJob, type DrawReport } from './job';
import { istMinutes } from '../services/ist'; import { istMinutes } from '../services/ist';
import { todayIst } from './portal'; import { todayIst } from './portal';
const WINDOW_START_MIN = 16 * 60; // 4 PM IST
const WINDOW_END_MIN = 19 * 60; // 7 PM IST
const EVERY_MS = 10 * 60_000;
export interface SchedulerOptions { export interface SchedulerOptions {
db: Db; db: Db;
log: (entry: Record<string, unknown>) => void; log: (entry: Record<string, unknown>) => void;
windowStartMin?: number;
windowEndMin?: number;
retryMin?: number;
now?: () => Date; now?: () => Date;
run?: (db: Db) => Promise<DrawReport[]>; run?: (db: Db) => Promise<DrawReport[]>;
} }
@ -19,6 +18,9 @@ function todaysDrawReady(db: Db, today: string): boolean {
} }
export function createResultsScheduler(opts: SchedulerOptions) { export function createResultsScheduler(opts: SchedulerOptions) {
const WINDOW_START_MIN = opts.windowStartMin ?? 16 * 60;
const WINDOW_END_MIN = opts.windowEndMin ?? 19 * 60;
const EVERY_MS = (opts.retryMin ?? 10) * 60_000;
const now = opts.now ?? (() => new Date()); const now = opts.now ?? (() => new Date());
const run = opts.run ?? ((db: Db) => runResultsJob({ db })); const run = opts.run ?? ((db: Db) => runResultsJob({ db }));
let lastRunAt = 0; let lastRunAt = 0;
@ -33,13 +35,13 @@ export function createResultsScheduler(opts: SchedulerOptions) {
if (minutes >= WINDOW_END_MIN) { if (minutes >= WINDOW_END_MIN) {
if (loggedGiveUpFor !== today && !todaysDrawReady(opts.db, today)) { if (loggedGiveUpFor !== today && !todaysDrawReady(opts.db, today)) {
loggedGiveUpFor = today; loggedGiveUpFor = today;
opts.log({ level: 'warn', msg: 'results: no ready draw for today by 7 PM', date: today }); opts.log({ level: 'warn', msg: 'results: no ready draw for today by end of window', date: today });
} }
return; return;
} }
if (minutes < WINDOW_START_MIN || running) return; if (minutes < WINDOW_START_MIN || running) return;
if (todaysDrawReady(opts.db, today)) return; if (todaysDrawReady(opts.db, today)) return;
if (t.getTime() - lastRunAt < EVERY_MS - 30_000) return; if (t.getTime() - lastRunAt < Math.max(0, EVERY_MS - 30_000)) return;
running = true; running = true;
lastRunAt = t.getTime(); lastRunAt = t.getTime();

View File

@ -22,7 +22,11 @@ async function main(): Promise<void> {
const app = createApp({ db, config, bridge, sendQueue }, { logger: true }); const app = createApp({ db, config, bridge, sendQueue }, { logger: true });
log = (e) => app.log.info(e); log = (e) => app.log.info(e);
const stopScheduler = createResultsScheduler({ db, log: (e) => app.log.info(e) }).start(); const stopScheduler = createResultsScheduler({
db,
log: (e) => app.log.info(e),
...config.results,
}).start();
const stopSweeper = createConversationSweeper({ const stopSweeper = createConversationSweeper({
db, db,
settings: config.orders, settings: config.orders,

View File

@ -10,6 +10,7 @@ export function testConfig(overrides: Partial<Config> = {}): Config {
bridgeUrl: 'http://127.0.0.1:3000', bridgeUrl: 'http://127.0.0.1:3000',
defaultCountryCode: '91', defaultCountryCode: '91',
orders: { cutoffMin: 15 * 60, askUntilMin: 15 * 60 + 30, remindMin: 10, expireMin: 30 }, orders: { cutoffMin: 15 * 60, askUntilMin: 15 * 60 + 30, remindMin: 10, expireMin: 30 },
results: { windowStartMin: 16 * 60, windowEndMin: 19 * 60, retryMin: 10 },
...overrides, ...overrides,
}; };
} }