@gobing-ai/ts-infra 0.4.49 → 0.4.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -4
- package/dist/application/index.d.ts +1 -0
- package/dist/application/index.d.ts.map +1 -1
- package/dist/application/index.js +1 -0
- package/dist/application/types.d.ts +9 -1
- package/dist/application/types.d.ts.map +1 -1
- package/dist/application-node.d.ts.map +1 -1
- package/dist/application-node.js +86 -5
- package/dist/scheduler/cron.d.ts +70 -0
- package/dist/scheduler/cron.d.ts.map +1 -0
- package/dist/scheduler/cron.js +175 -0
- package/dist/scheduler/index.d.ts +1 -1
- package/dist/scheduler/index.d.ts.map +1 -1
- package/dist/scheduler/node.d.ts +28 -4
- package/dist/scheduler/node.d.ts.map +1 -1
- package/dist/scheduler/node.js +130 -55
- package/dist/scheduler/types.d.ts +20 -0
- package/dist/scheduler/types.d.ts.map +1 -1
- package/package.json +6 -6
- package/src/application/index.ts +2 -0
- package/src/application/types.ts +9 -2
- package/src/application-node.ts +102 -5
- package/src/scheduler/cron.ts +203 -0
- package/src/scheduler/index.ts +1 -1
- package/src/scheduler/node.ts +163 -64
- package/src/scheduler/types.ts +13 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal cron expression grammar + matching (task 0734).
|
|
3
|
+
*
|
|
4
|
+
* This module is deliberately NOT exported from package.json — it is a shared
|
|
5
|
+
* internal seam between `application-node.ts` (SchedulerJobConfig validation)
|
|
6
|
+
* and `scheduler/node.ts` (adapter matching), so both consume the same grammar.
|
|
7
|
+
* The public surface remains `NodeSchedulerAdapter` and `SchedulerJobConfig`.
|
|
8
|
+
*
|
|
9
|
+
* Supported grammar (five fields, whitespace-separated):
|
|
10
|
+
* minute 0-59
|
|
11
|
+
* hour 0-23
|
|
12
|
+
* day-of-month 1-31
|
|
13
|
+
* month 1-12
|
|
14
|
+
* day-of-week 0-7 (0 and 7 are both Sunday)
|
|
15
|
+
*
|
|
16
|
+
* Each field accepts a wildcard, a step-N wildcard form, a comma-separated list
|
|
17
|
+
* of numbers or inclusive non-wrapping ranges, or a single number. Names/macros
|
|
18
|
+
* (`MON`, `JAN`, `@daily`) and operators (`?`, `L`, `W`, `#`) are rejected.
|
|
19
|
+
*
|
|
20
|
+
* Matching uses local wall-clock fields (ADR / task 0734 R2): the fall-back hour
|
|
21
|
+
* can fire twice at two distinct instants; a nonexistent spring-forward minute
|
|
22
|
+
* never fires. No per-job timezone.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** A single cron field: either a wildcard (whole range) or an explicit value set. */
|
|
26
|
+
export interface CronFieldSpec {
|
|
27
|
+
/** True when the whole field is `*` (matches every value in its range). */
|
|
28
|
+
readonly wildcard: boolean;
|
|
29
|
+
/** Allowed values when restricted. Day-of-week 7 is normalized into 0. */
|
|
30
|
+
readonly values: ReadonlySet<number>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A parsed five-field cron expression. */
|
|
34
|
+
export interface CronExpression {
|
|
35
|
+
readonly minute: CronFieldSpec;
|
|
36
|
+
readonly hour: CronFieldSpec;
|
|
37
|
+
readonly dayOfMonth: CronFieldSpec;
|
|
38
|
+
readonly month: CronFieldSpec;
|
|
39
|
+
readonly dayOfWeek: CronFieldSpec;
|
|
40
|
+
/** The original expression, for error messages and metrics. */
|
|
41
|
+
readonly source: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Upper bound on the forward minute scan (8 calendar years, upper-capped at 366 days). */
|
|
45
|
+
const MAX_SCAN_MINUTES = 8 * 366 * 24 * 60;
|
|
46
|
+
|
|
47
|
+
const DAYS_OF_WEEK = 7; // 0-7 accepted on input, 7 aliases to 0
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Parse a single cron field into its wildcard/values spec.
|
|
51
|
+
*
|
|
52
|
+
* Throws `RangeError` for anything outside the supported grammar: names/macros,
|
|
53
|
+
* operators, zero steps, empty list members, descending ranges, non-integer
|
|
54
|
+
* values, or out-of-range values. Nothing silently falls back (task 0060 F7).
|
|
55
|
+
*/
|
|
56
|
+
function parseField(raw: string, min: number, max: number): CronFieldSpec {
|
|
57
|
+
const trimmed = raw.trim();
|
|
58
|
+
if (trimmed === '*') {
|
|
59
|
+
return { wildcard: true, values: new Set<number>() };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Names ('MON', 'JAN'), macros ('@daily'), and '?' are unsupported operators.
|
|
63
|
+
if (/^[A-Za-z@?]/.test(trimmed)) {
|
|
64
|
+
throw new RangeError(`unsupported cron field "${raw}": names/macros are not supported`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Whole-field step form: */N
|
|
68
|
+
const stepMatch = trimmed.match(/^\*\/(\d+)$/);
|
|
69
|
+
if (stepMatch) {
|
|
70
|
+
const step = Number(stepMatch[1]);
|
|
71
|
+
if (step <= 0) {
|
|
72
|
+
throw new RangeError(`unsupported cron field "${raw}": step must be a positive integer`);
|
|
73
|
+
}
|
|
74
|
+
const values = new Set<number>();
|
|
75
|
+
for (let v = min; v <= max; v += step) {
|
|
76
|
+
values.add(v);
|
|
77
|
+
}
|
|
78
|
+
return { wildcard: false, values };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Comma-separated list of numbers or inclusive non-wrapping ranges.
|
|
82
|
+
const members = trimmed.split(',');
|
|
83
|
+
if (members.some((m) => m.trim() === '')) {
|
|
84
|
+
throw new RangeError(`unsupported cron field "${raw}": empty list member`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const values = new Set<number>();
|
|
88
|
+
for (const rawMember of members) {
|
|
89
|
+
const member = rawMember.trim();
|
|
90
|
+
if (member.includes('-')) {
|
|
91
|
+
const rangeParts = member.split('-');
|
|
92
|
+
if (rangeParts.length !== 2 || rangeParts.some((p) => p === '' || !/^\d+$/.test(p))) {
|
|
93
|
+
throw new RangeError(`unsupported cron field "${raw}": invalid range "${rawMember}"`);
|
|
94
|
+
}
|
|
95
|
+
const lo = Number(rangeParts[0]);
|
|
96
|
+
const hi = Number(rangeParts[1]);
|
|
97
|
+
if (lo > hi) {
|
|
98
|
+
throw new RangeError(`unsupported cron field "${raw}": descending range "${rawMember}"`);
|
|
99
|
+
}
|
|
100
|
+
if (lo < min || hi > max) {
|
|
101
|
+
throw new RangeError(`cron field "${raw}" value out of range [${min}-${max}] in range "${rawMember}"`);
|
|
102
|
+
}
|
|
103
|
+
for (let v = lo; v <= hi; v++) {
|
|
104
|
+
values.add(v);
|
|
105
|
+
}
|
|
106
|
+
} else {
|
|
107
|
+
if (!/^\d+$/.test(member)) {
|
|
108
|
+
throw new RangeError(`unsupported cron field "${raw}": invalid value "${rawMember}"`);
|
|
109
|
+
}
|
|
110
|
+
const v = Number(member);
|
|
111
|
+
if (v < min || v > max) {
|
|
112
|
+
throw new RangeError(`cron field "${raw}" value out of range [${min}-${max}]: ${rawMember}`);
|
|
113
|
+
}
|
|
114
|
+
values.add(v);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { wildcard: false, values };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Parse a five-field cron expression into its spec.
|
|
123
|
+
*
|
|
124
|
+
* Throws `RangeError` on a wrong field count, any unsupported field grammar, or
|
|
125
|
+
* out-of-range values. Day-of-week `7` normalizes to `0` (Sunday).
|
|
126
|
+
*/
|
|
127
|
+
export function parseCronExpression(source: string): CronExpression {
|
|
128
|
+
const trimmed = source.trim();
|
|
129
|
+
const parts = trimmed.split(/\s+/);
|
|
130
|
+
if (parts.length !== 5) {
|
|
131
|
+
throw new RangeError(`unsupported cron expression "${source}": expected exactly 5 fields, got ${parts.length}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const [rawMinute, rawHour, rawDom, rawMonth, rawDow] = parts as [string, string, string, string, string];
|
|
135
|
+
|
|
136
|
+
const minute = parseField(rawMinute, 0, 59);
|
|
137
|
+
const hour = parseField(rawHour, 0, 23);
|
|
138
|
+
const dayOfMonth = parseField(rawDom, 1, 31);
|
|
139
|
+
const month = parseField(rawMonth, 1, 12);
|
|
140
|
+
let dayOfWeek = parseField(rawDow, 0, DAYS_OF_WEEK);
|
|
141
|
+
|
|
142
|
+
// Standard cron: 7 is an alias for 0 (Sunday).
|
|
143
|
+
if (!dayOfWeek.wildcard && dayOfWeek.values.has(DAYS_OF_WEEK)) {
|
|
144
|
+
const values = new Set(dayOfWeek.values);
|
|
145
|
+
values.delete(DAYS_OF_WEEK);
|
|
146
|
+
values.add(0);
|
|
147
|
+
dayOfWeek = { wildcard: false, values };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { minute, hour, dayOfMonth, month, dayOfWeek, source: trimmed };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Whether the instant matches the expression (local wall-clock fields).
|
|
155
|
+
*
|
|
156
|
+
* Day semantics follow standard cron: when both day fields are wildcards both
|
|
157
|
+
* pass; when one is a wildcard the restricted field must match; when both are
|
|
158
|
+
* restricted, either may match (OR).
|
|
159
|
+
*/
|
|
160
|
+
export function matchesCron(date: Date, expr: CronExpression): boolean {
|
|
161
|
+
const minute = date.getMinutes();
|
|
162
|
+
if (!expr.minute.wildcard && !expr.minute.values.has(minute)) return false;
|
|
163
|
+
const hour = date.getHours();
|
|
164
|
+
if (!expr.hour.wildcard && !expr.hour.values.has(hour)) return false;
|
|
165
|
+
const month = date.getMonth() + 1;
|
|
166
|
+
if (!expr.month.wildcard && !expr.month.values.has(month)) return false;
|
|
167
|
+
|
|
168
|
+
const domWild = expr.dayOfMonth.wildcard;
|
|
169
|
+
const dowWild = expr.dayOfWeek.wildcard;
|
|
170
|
+
if (domWild && dowWild) return true;
|
|
171
|
+
const domMatch = domWild || expr.dayOfMonth.values.has(date.getDate());
|
|
172
|
+
const dowMatch = dowWild || expr.dayOfWeek.values.has(date.getDay());
|
|
173
|
+
if (domWild) return dowMatch;
|
|
174
|
+
if (dowWild) return domMatch;
|
|
175
|
+
return domMatch || dowMatch;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Compute the next matching minute strictly after `nowMs`, in local time.
|
|
180
|
+
*
|
|
181
|
+
* Starts at the next epoch-minute boundary and scans forward in one-minute
|
|
182
|
+
* steps (fixed epoch increments, local wall-clock fields read per instant), so
|
|
183
|
+
* DST fall-back selects both distinct instants and spring-forward skips
|
|
184
|
+
* nonexistent minutes. Bounded to eight calendar years so an unsatisfiable
|
|
185
|
+
* expression fails here (at registration) rather than silently never firing.
|
|
186
|
+
*
|
|
187
|
+
* # ponytail: O(minutes-to-next) forward scan, fine for small job counts; a
|
|
188
|
+
* field-jumping search would matter only if startup cost with hundreds of jobs
|
|
189
|
+
* ever shows up in measurements.
|
|
190
|
+
*/
|
|
191
|
+
export function nextCronTime(expr: CronExpression, nowMs: number): Date {
|
|
192
|
+
// Next epoch-minute boundary strictly after now (never the current minute).
|
|
193
|
+
let candidate = Math.floor(nowMs / 60_000) * 60_000 + 60_000;
|
|
194
|
+
const limit = candidate + MAX_SCAN_MINUTES * 60_000;
|
|
195
|
+
while (candidate < limit) {
|
|
196
|
+
const instant = new Date(candidate);
|
|
197
|
+
if (matchesCron(instant, expr)) {
|
|
198
|
+
return instant;
|
|
199
|
+
}
|
|
200
|
+
candidate += 60_000;
|
|
201
|
+
}
|
|
202
|
+
throw new RangeError(`unsupported cron expression "${expr.source}": no matching time within 8 years`);
|
|
203
|
+
}
|
package/src/scheduler/index.ts
CHANGED
|
@@ -12,5 +12,5 @@ export {
|
|
|
12
12
|
} from './action';
|
|
13
13
|
export { initScheduler } from './factory';
|
|
14
14
|
export { NoopSchedulerAdapter } from './noop';
|
|
15
|
-
export type { ScheduledAction, SchedulerAdapter } from './types';
|
|
15
|
+
export type { ScheduledAction, SchedulerAdapter, SchedulerJobConfig } from './types';
|
|
16
16
|
export { wrapScheduledHandler } from './wrap-handler';
|
package/src/scheduler/node.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Node.js scheduler adapter
|
|
3
|
-
*
|
|
2
|
+
* Node.js scheduler adapter — interval-based for the three legacy cadence
|
|
3
|
+
* forms, self-rescheduling setTimeout for real five-field cron (task 0734).
|
|
4
|
+
*
|
|
5
|
+
* No external cron library dependency — cron expressions are parsed by the
|
|
6
|
+
* internal `scheduler/cron.ts` grammar shared with `application-node.ts`.
|
|
4
7
|
*/
|
|
5
8
|
|
|
6
9
|
import { settleWithin } from '../internals/drain';
|
|
@@ -9,49 +12,65 @@ import {
|
|
|
9
12
|
getSchedulerJobExecutedTotal,
|
|
10
13
|
getSchedulerJobFailedTotal,
|
|
11
14
|
} from '../telemetry/metrics';
|
|
15
|
+
import { type CronExpression, nextCronTime, parseCronExpression } from './cron';
|
|
12
16
|
import type { ScheduledAction, SchedulerAdapter } from './types';
|
|
13
17
|
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
/** Platform timer maximum for `setTimeout` (2^31 - 1 ms). Longer delays are chunked. */
|
|
19
|
+
const MAX_TIMEOUT = 2_147_483_647;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parse the three legacy interval cadences, returning milliseconds, or
|
|
23
|
+
* `undefined` when the string is real cron (or invalid) and must route to the
|
|
24
|
+
* cron grammar instead.
|
|
25
|
+
*
|
|
26
|
+
* Legacy forms preserved verbatim (task 0734 R2): a positive millisecond
|
|
27
|
+
* number, `* * * * *` (60s), and the step-N wildcard form (N*60s) — all
|
|
28
|
+
* measured from adapter start as setInterval cadences.
|
|
29
|
+
*/
|
|
30
|
+
function parseInterval(cron: string): number | undefined {
|
|
18
31
|
const trimmed = cron.trim();
|
|
19
32
|
const num = Number(trimmed);
|
|
20
33
|
if (trimmed !== '' && !Number.isNaN(num)) {
|
|
21
34
|
// Guard against 0/negative intervals — setInterval would spin hot.
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
const parts = trimmed.split(/\s+/);
|
|
25
|
-
// Only the documented 5-field forms are every-N-minutes: "* * * * *" and
|
|
26
|
-
// "*/N * * * *". A first-field wildcard alone ("* 3 * * *") is real cron
|
|
27
|
-
// and must not silently fire every 60s (task 0060 F7).
|
|
28
|
-
const restWild =
|
|
29
|
-
parts.length === 5 && parts[1] === '*' && parts[2] === '*' && parts[3] === '*' && parts[4] === '*';
|
|
30
|
-
if (restWild && parts[0] === '*') {
|
|
31
|
-
return 60_000;
|
|
32
|
-
}
|
|
35
|
+
return num > 0 ? num : undefined;
|
|
36
|
+
}
|
|
33
37
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
const parts = trimmed.split(/\s+/);
|
|
39
|
+
// Only the documented 5-field forms are every-N-minutes: "* * * * *" and
|
|
40
|
+
// "*/N * * * *". A first-field wildcard alone ("* 3 * * *") is real cron
|
|
41
|
+
// and must not silently fire every 60s (task 0060 F7).
|
|
42
|
+
const restWild = parts.length === 5 && parts[1] === '*' && parts[2] === '*' && parts[3] === '*' && parts[4] === '*';
|
|
43
|
+
if (restWild && parts[0] === '*') {
|
|
44
|
+
return 60_000;
|
|
39
45
|
}
|
|
40
46
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
);
|
|
47
|
-
}
|
|
47
|
+
const nField = restWild ? parts[0] : parts.length === 1 ? parts[0] : undefined;
|
|
48
|
+
const match = nField?.match(/^\*\/(\d+)$/);
|
|
49
|
+
if (match && Number(match[1]) > 0) {
|
|
50
|
+
return Number(match[1]) * 60_000;
|
|
51
|
+
}
|
|
48
52
|
|
|
49
|
-
|
|
50
|
-
cron: string;
|
|
51
|
-
action: ScheduledAction;
|
|
52
|
-
timer?: ReturnType<typeof setInterval>;
|
|
53
|
+
return undefined;
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
/** A registered entry: an interval cadence or a self-rescheduling cron tick. */
|
|
57
|
+
type ScheduledEntry =
|
|
58
|
+
| {
|
|
59
|
+
kind: 'interval';
|
|
60
|
+
cron: string;
|
|
61
|
+
action: ScheduledAction;
|
|
62
|
+
intervalMs: number;
|
|
63
|
+
timer?: ReturnType<typeof setInterval>;
|
|
64
|
+
}
|
|
65
|
+
| {
|
|
66
|
+
kind: 'cron';
|
|
67
|
+
cron: string;
|
|
68
|
+
action: ScheduledAction;
|
|
69
|
+
expr: CronExpression;
|
|
70
|
+
target: number;
|
|
71
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
72
|
+
};
|
|
73
|
+
|
|
55
74
|
/** Constructor options for {@link NodeSchedulerAdapter}. */
|
|
56
75
|
export interface NodeSchedulerAdapterConfig {
|
|
57
76
|
/**
|
|
@@ -60,11 +79,17 @@ export interface NodeSchedulerAdapterConfig {
|
|
|
60
79
|
* Defaults to 30000 (matching `DBQueueConsumer`).
|
|
61
80
|
*/
|
|
62
81
|
readonly drainTimeoutMs?: number;
|
|
82
|
+
/**
|
|
83
|
+
* Deterministic clock seam (task 0734 R2). Returns epoch milliseconds;
|
|
84
|
+
* defaults to `Date.now`. Used for cron target computation and re-checks.
|
|
85
|
+
*/
|
|
86
|
+
readonly now?: () => number;
|
|
63
87
|
}
|
|
64
88
|
|
|
65
89
|
/**
|
|
66
|
-
* Scheduler adapter for Node.js
|
|
67
|
-
*
|
|
90
|
+
* Scheduler adapter for Node.js. Interval entries use `setInterval`; real cron
|
|
91
|
+
* entries self-reschedule with `setTimeout` so ticks never overlap and
|
|
92
|
+
* occurrences missed while a tick runs are skipped (task 0734 R2).
|
|
68
93
|
*
|
|
69
94
|
* `stop()` drains in-flight ticks, bounded by `drainTimeoutMs` (ADR-024): an
|
|
70
95
|
* action already running when `stop()` is called is awaited rather than torn
|
|
@@ -73,32 +98,44 @@ export interface NodeSchedulerAdapterConfig {
|
|
|
73
98
|
export class NodeSchedulerAdapter implements SchedulerAdapter {
|
|
74
99
|
private readonly entries: ScheduledEntry[] = [];
|
|
75
100
|
private readonly drainTimeoutMs: number;
|
|
101
|
+
private readonly now: () => number;
|
|
76
102
|
private running = false;
|
|
77
103
|
private readonly inflight = new Set<Promise<void>>();
|
|
78
104
|
|
|
79
105
|
constructor(config: NodeSchedulerAdapterConfig = {}) {
|
|
80
|
-
const { drainTimeoutMs } = config;
|
|
106
|
+
const { drainTimeoutMs, now } = config;
|
|
81
107
|
if (drainTimeoutMs !== undefined && (!Number.isFinite(drainTimeoutMs) || drainTimeoutMs < 0)) {
|
|
82
108
|
throw new RangeError(
|
|
83
109
|
`NodeSchedulerAdapter drainTimeoutMs must be a non-negative finite number; received ${drainTimeoutMs}`,
|
|
84
110
|
);
|
|
85
111
|
}
|
|
86
112
|
this.drainTimeoutMs = drainTimeoutMs ?? 30_000;
|
|
113
|
+
this.now = now ?? (() => Date.now());
|
|
87
114
|
}
|
|
88
115
|
|
|
89
116
|
register(cron: string, action: ScheduledAction): void {
|
|
90
|
-
// Fail at registration time: an unsupported expression must never reach
|
|
91
|
-
// where it would otherwise create a silently-wrong interval
|
|
92
|
-
|
|
93
|
-
this.
|
|
117
|
+
// Fail at registration time: an unsupported expression must never reach
|
|
118
|
+
// start(), where it would otherwise create a silently-wrong interval
|
|
119
|
+
// (task 0060 F7) or a never-firing cron (task 0734 R1).
|
|
120
|
+
const entry = this.parseEntry(cron, action);
|
|
121
|
+
this.entries.push(entry);
|
|
94
122
|
if (this.running) {
|
|
95
|
-
|
|
96
|
-
if (last) {
|
|
97
|
-
this.startEntry(last);
|
|
98
|
-
}
|
|
123
|
+
this.startEntry(entry);
|
|
99
124
|
}
|
|
100
125
|
}
|
|
101
126
|
|
|
127
|
+
private parseEntry(cron: string, action: ScheduledAction): ScheduledEntry {
|
|
128
|
+
const intervalMs = parseInterval(cron);
|
|
129
|
+
if (intervalMs !== undefined) {
|
|
130
|
+
return { kind: 'interval', cron, action, intervalMs };
|
|
131
|
+
}
|
|
132
|
+
// Real five-field cron. Validate and verify a next occurrence exists at
|
|
133
|
+
// registration (bounded scan) so an unsatisfiable expression fails here.
|
|
134
|
+
const expr = parseCronExpression(cron);
|
|
135
|
+
const target = nextCronTime(expr, this.now()).getTime();
|
|
136
|
+
return { kind: 'cron', cron, action, expr, target };
|
|
137
|
+
}
|
|
138
|
+
|
|
102
139
|
async start(): Promise<void> {
|
|
103
140
|
if (this.running) return;
|
|
104
141
|
|
|
@@ -111,16 +148,21 @@ export class NodeSchedulerAdapter implements SchedulerAdapter {
|
|
|
111
148
|
async stop(): Promise<void> {
|
|
112
149
|
this.running = false;
|
|
113
150
|
for (const entry of this.entries) {
|
|
114
|
-
if (entry.timer) {
|
|
115
|
-
|
|
151
|
+
if (entry.timer !== undefined) {
|
|
152
|
+
if (entry.kind === 'interval') {
|
|
153
|
+
clearInterval(entry.timer);
|
|
154
|
+
} else {
|
|
155
|
+
clearTimeout(entry.timer);
|
|
156
|
+
}
|
|
116
157
|
entry.timer = undefined;
|
|
117
158
|
}
|
|
118
159
|
}
|
|
119
160
|
|
|
120
|
-
// Drain in-flight ticks, bounded by a shared absolute deadline.
|
|
121
|
-
// cancels future ticks but not one already executing; without this
|
|
122
|
-
// mid-action when stop() is called would keep running after
|
|
123
|
-
// tearing down a half-written row or half-flushed batch
|
|
161
|
+
// Drain in-flight ticks, bounded by a shared absolute deadline. Clearing
|
|
162
|
+
// timers cancels future ticks but not one already executing; without this
|
|
163
|
+
// wait a tick mid-action when stop() is called would keep running after
|
|
164
|
+
// stop() resolves, tearing down a half-written row or half-flushed batch
|
|
165
|
+
// (ADR-024).
|
|
124
166
|
const deadline = Date.now() + this.drainTimeoutMs;
|
|
125
167
|
for (const p of [...this.inflight]) {
|
|
126
168
|
await settleWithin(p, deadline);
|
|
@@ -128,20 +170,77 @@ export class NodeSchedulerAdapter implements SchedulerAdapter {
|
|
|
128
170
|
}
|
|
129
171
|
|
|
130
172
|
private startEntry(entry: ScheduledEntry): void {
|
|
131
|
-
if (entry.timer) return;
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}
|
|
173
|
+
if (entry.timer !== undefined) return;
|
|
174
|
+
|
|
175
|
+
if (entry.kind === 'interval') {
|
|
176
|
+
entry.timer = setInterval(() => {
|
|
177
|
+
const tick = this._onScheduledTick(entry);
|
|
178
|
+
this.inflight.add(tick);
|
|
179
|
+
const cleanup = (): void => {
|
|
180
|
+
this.inflight.delete(tick);
|
|
181
|
+
};
|
|
182
|
+
tick.then(cleanup, cleanup);
|
|
183
|
+
}, entry.intervalMs);
|
|
184
|
+
} else {
|
|
185
|
+
this.armCron(entry);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Arm the cron entry's next `setTimeout`, chunking any delay beyond the
|
|
191
|
+
* platform timer maximum. On a chunk wake the wall clock is re-checked
|
|
192
|
+
* before firing, so an injected/real clock shift never fires early.
|
|
193
|
+
*/
|
|
194
|
+
private armCron(entry: Extract<ScheduledEntry, { kind: 'cron' }>): void {
|
|
195
|
+
if (!this.running) return;
|
|
196
|
+
|
|
197
|
+
let delay = entry.target - this.now();
|
|
198
|
+
if (delay <= 0) {
|
|
199
|
+
// Target already passed (clock shift): recompute strictly after now.
|
|
200
|
+
entry.target = nextCronTime(entry.expr, this.now()).getTime();
|
|
201
|
+
delay = entry.target - this.now();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (delay > MAX_TIMEOUT) {
|
|
205
|
+
// Delay exceeds the platform maximum — arm a safe chunk; on wake,
|
|
206
|
+
// re-check now() before firing.
|
|
207
|
+
entry.timer = setTimeout(() => {
|
|
208
|
+
this.armCron(entry);
|
|
209
|
+
}, MAX_TIMEOUT);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
entry.timer = setTimeout(() => {
|
|
214
|
+
void this.fireCron(entry);
|
|
215
|
+
}, delay);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Fire a cron tick: re-check the wall clock against the target, run the
|
|
220
|
+
* action once, then recompute the next occurrence strictly from `now()` and
|
|
221
|
+
* re-arm only while still running. Ticks never overlap and occurrences
|
|
222
|
+
* missed while a tick runs are skipped (task 0734 R2).
|
|
223
|
+
*/
|
|
224
|
+
private async fireCron(entry: Extract<ScheduledEntry, { kind: 'cron' }>): Promise<void> {
|
|
225
|
+
entry.timer = undefined;
|
|
226
|
+
|
|
227
|
+
if (this.now() < entry.target) {
|
|
228
|
+
// Not actually time yet (early chunk wake / clock shift) — re-arm.
|
|
229
|
+
this.armCron(entry);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const tick = this._onScheduledTick(entry);
|
|
234
|
+
this.inflight.add(tick);
|
|
235
|
+
const cleanup = (): void => {
|
|
236
|
+
this.inflight.delete(tick);
|
|
237
|
+
};
|
|
238
|
+
await tick.then(cleanup, cleanup);
|
|
239
|
+
|
|
240
|
+
if (this.running) {
|
|
241
|
+
entry.target = nextCronTime(entry.expr, this.now()).getTime();
|
|
242
|
+
this.armCron(entry);
|
|
243
|
+
}
|
|
145
244
|
}
|
|
146
245
|
|
|
147
246
|
private async _onScheduledTick(entry: ScheduledEntry): Promise<void> {
|
package/src/scheduler/types.ts
CHANGED
|
@@ -17,3 +17,16 @@ export interface SchedulerAdapter {
|
|
|
17
17
|
start(): Promise<void>;
|
|
18
18
|
stop(): Promise<void>;
|
|
19
19
|
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A declarative scheduler job (task 0734). Exactly one of `intervalMinutes` or
|
|
23
|
+
* `cron` is present — the schedule XOR is enforced by the Node subpath's
|
|
24
|
+
* `normalizeSchedulerJobs` before the user `start` callback runs.
|
|
25
|
+
*
|
|
26
|
+
* The `command` is declarative data: ts-infra validates and exposes it but
|
|
27
|
+
* never executes it. The consuming application binds it to a queue-backed
|
|
28
|
+
* command handler.
|
|
29
|
+
*/
|
|
30
|
+
export type SchedulerJobConfig =
|
|
31
|
+
| { readonly name: string; readonly command: string; readonly intervalMinutes: number; readonly cron?: never }
|
|
32
|
+
| { readonly name: string; readonly command: string; readonly cron: string; readonly intervalMinutes?: never };
|