@stonyx/cron 0.2.1-beta.13 → 0.2.1-beta.130
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 +104 -2
- package/dist/cron-parser.d.ts +30 -0
- package/dist/cron-parser.js +200 -0
- package/dist/job.d.ts +72 -0
- package/dist/job.js +172 -0
- package/dist/locked.d.ts +13 -0
- package/dist/locked.js +27 -0
- package/dist/main.d.ts +66 -0
- package/dist/main.js +292 -0
- package/dist/min-heap.d.ts +13 -0
- package/dist/min-heap.js +67 -0
- package/dist/normalize.d.ts +49 -0
- package/dist/normalize.js +148 -0
- package/dist/run-log.d.ts +44 -0
- package/dist/run-log.js +60 -0
- package/dist/schedule.d.ts +23 -0
- package/dist/schedule.js +65 -0
- package/dist/service.d.ts +182 -0
- package/dist/service.js +746 -0
- package/package.json +53 -9
- package/.claude/architecture.md +0 -215
- package/.claude/extension-guide.md +0 -291
- package/.claude/improvements.md +0 -53
- package/.claude/project-structure.md +0 -139
- package/.claude/testing.md +0 -85
- package/.git/config +0 -18
- package/.github/workflows/ci.yml +0 -16
- package/.github/workflows/publish.yml +0 -51
- package/.gitignore +0 -16
- package/.npmignore +0 -5
- package/logs/error.log +0 -2
- package/pnpm-lock.yaml +0 -370
- package/src/main.js +0 -112
- package/src/min-heap.js +0 -73
package/dist/run-log.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory run log for job execution history.
|
|
3
|
+
* Stores recent execution results per job with auto-pruning.
|
|
4
|
+
*/
|
|
5
|
+
const DEFAULT_MAX_ENTRIES_PER_JOB = 100;
|
|
6
|
+
export default class RunLog {
|
|
7
|
+
maxEntries;
|
|
8
|
+
entries;
|
|
9
|
+
constructor(maxEntriesPerJob = DEFAULT_MAX_ENTRIES_PER_JOB) {
|
|
10
|
+
this.maxEntries = maxEntriesPerJob;
|
|
11
|
+
this.entries = new Map();
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Record a job execution result.
|
|
15
|
+
*/
|
|
16
|
+
record(entry) {
|
|
17
|
+
const log = {
|
|
18
|
+
ts: Date.now(),
|
|
19
|
+
jobId: entry.jobId,
|
|
20
|
+
status: entry.status,
|
|
21
|
+
error: entry.error,
|
|
22
|
+
summary: entry.summary,
|
|
23
|
+
runAtMs: entry.runAtMs,
|
|
24
|
+
durationMs: entry.durationMs,
|
|
25
|
+
nextRunAtMs: entry.nextRunAtMs,
|
|
26
|
+
};
|
|
27
|
+
if (!this.entries.has(entry.jobId)) {
|
|
28
|
+
this.entries.set(entry.jobId, []);
|
|
29
|
+
}
|
|
30
|
+
const logs = this.entries.get(entry.jobId);
|
|
31
|
+
if (!logs)
|
|
32
|
+
return;
|
|
33
|
+
logs.push(log);
|
|
34
|
+
// Auto-prune
|
|
35
|
+
if (logs.length > this.maxEntries) {
|
|
36
|
+
logs.splice(0, logs.length - this.maxEntries);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Get run history for a job.
|
|
41
|
+
*/
|
|
42
|
+
get(jobId, limit = 20) {
|
|
43
|
+
const logs = this.entries.get(jobId);
|
|
44
|
+
if (!logs)
|
|
45
|
+
return [];
|
|
46
|
+
return logs.slice(-limit).reverse();
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Remove all entries for a job.
|
|
50
|
+
*/
|
|
51
|
+
removeJob(jobId) {
|
|
52
|
+
this.entries.delete(jobId);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Clear all entries.
|
|
56
|
+
*/
|
|
57
|
+
clear() {
|
|
58
|
+
this.entries.clear();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface AtSchedule {
|
|
2
|
+
kind: 'at';
|
|
3
|
+
at: string | number;
|
|
4
|
+
}
|
|
5
|
+
export interface EverySchedule {
|
|
6
|
+
kind: 'every';
|
|
7
|
+
everyMs: number;
|
|
8
|
+
anchorMs?: number;
|
|
9
|
+
}
|
|
10
|
+
export interface CronSchedule {
|
|
11
|
+
kind: 'cron';
|
|
12
|
+
expr: string;
|
|
13
|
+
tz?: string;
|
|
14
|
+
}
|
|
15
|
+
export type Schedule = AtSchedule | EverySchedule | CronSchedule;
|
|
16
|
+
/**
|
|
17
|
+
* Compute the next run time for a schedule.
|
|
18
|
+
*/
|
|
19
|
+
export declare function computeNextRunAtMs(schedule: Schedule, nowMs: number): number | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* Validate a schedule definition.
|
|
22
|
+
*/
|
|
23
|
+
export declare function validateSchedule(schedule: Schedule): void;
|
package/dist/schedule.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schedule types and next-run computation.
|
|
3
|
+
*
|
|
4
|
+
* Three schedule kinds:
|
|
5
|
+
* - "at": One-shot at an absolute ISO-8601 timestamp
|
|
6
|
+
* - "every": Recurring interval in milliseconds
|
|
7
|
+
* - "cron": 5-field cron expression with optional timezone
|
|
8
|
+
*/
|
|
9
|
+
import { nextOccurrence, validateCronExpression } from './cron-parser.js';
|
|
10
|
+
/**
|
|
11
|
+
* Compute the next run time for a schedule.
|
|
12
|
+
*/
|
|
13
|
+
export function computeNextRunAtMs(schedule, nowMs) {
|
|
14
|
+
if (schedule.kind === 'at') {
|
|
15
|
+
const atMs = typeof schedule.at === 'number' ? schedule.at : Date.parse(schedule.at);
|
|
16
|
+
if (!Number.isFinite(atMs))
|
|
17
|
+
return undefined;
|
|
18
|
+
return atMs > nowMs ? atMs : undefined;
|
|
19
|
+
}
|
|
20
|
+
if (schedule.kind === 'every') {
|
|
21
|
+
const everyMs = Math.max(1, Math.floor(schedule.everyMs));
|
|
22
|
+
const anchor = Math.max(0, Math.floor(schedule.anchorMs ?? nowMs));
|
|
23
|
+
if (nowMs < anchor)
|
|
24
|
+
return anchor;
|
|
25
|
+
const elapsed = nowMs - anchor;
|
|
26
|
+
const steps = Math.max(1, Math.floor((elapsed + everyMs - 1) / everyMs));
|
|
27
|
+
return anchor + steps * everyMs;
|
|
28
|
+
}
|
|
29
|
+
if (schedule.kind === 'cron') {
|
|
30
|
+
const tz = schedule.tz?.trim() || undefined;
|
|
31
|
+
// Round nowMs down to the current second to avoid sub-second drift
|
|
32
|
+
const nowSecondMs = Math.floor(nowMs / 1000) * 1000;
|
|
33
|
+
return nextOccurrence(schedule.expr.trim(), nowSecondMs, tz);
|
|
34
|
+
}
|
|
35
|
+
throw new Error(`Unknown schedule kind: "${schedule.kind}"`);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Validate a schedule definition.
|
|
39
|
+
*/
|
|
40
|
+
export function validateSchedule(schedule) {
|
|
41
|
+
if (!schedule || typeof schedule !== 'object') {
|
|
42
|
+
throw new Error('Schedule must be an object');
|
|
43
|
+
}
|
|
44
|
+
if (schedule.kind === 'at') {
|
|
45
|
+
const atMs = typeof schedule.at === 'number' ? schedule.at : Date.parse(schedule.at);
|
|
46
|
+
if (!Number.isFinite(atMs)) {
|
|
47
|
+
throw new Error(`Invalid "at" timestamp: "${schedule.at}"`);
|
|
48
|
+
}
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (schedule.kind === 'every') {
|
|
52
|
+
if (typeof schedule.everyMs !== 'number' || schedule.everyMs < 1) {
|
|
53
|
+
throw new Error(`"every" schedule requires everyMs >= 1, got: ${schedule.everyMs}`);
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (schedule.kind === 'cron') {
|
|
58
|
+
if (typeof schedule.expr !== 'string' || !schedule.expr.trim()) {
|
|
59
|
+
throw new Error('"cron" schedule requires a non-empty expr string');
|
|
60
|
+
}
|
|
61
|
+
validateCronExpression(schedule.expr.trim());
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`Unknown schedule kind: "${schedule.kind}"`);
|
|
65
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import MinHeap, { type HeapItem } from './min-heap.js';
|
|
2
|
+
import { type Job, type JobPatch } from './job.js';
|
|
3
|
+
import RunLog from './run-log.js';
|
|
4
|
+
interface HeapEntry extends HeapItem {
|
|
5
|
+
key: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Why a `run()` did not invoke the callback. Exported so a consumer can write a
|
|
9
|
+
* total handler over it: the union is closed and narrowed (it was `string`
|
|
10
|
+
* before #34), so an exhaustive `switch` is now both possible and expected.
|
|
11
|
+
*/
|
|
12
|
+
export type SkipReason = 'not due' | 'already running' | 'removed';
|
|
13
|
+
export interface JobDueResult {
|
|
14
|
+
status?: string;
|
|
15
|
+
error?: string;
|
|
16
|
+
summary?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface ExecuteResult {
|
|
19
|
+
status: string;
|
|
20
|
+
error?: string;
|
|
21
|
+
summary?: string;
|
|
22
|
+
durationMs?: number;
|
|
23
|
+
deleted?: boolean;
|
|
24
|
+
/** Only set when `status` is `'skipped'`. */
|
|
25
|
+
reason?: SkipReason;
|
|
26
|
+
}
|
|
27
|
+
export interface ServiceStatus {
|
|
28
|
+
started: boolean;
|
|
29
|
+
jobCount: number;
|
|
30
|
+
nextWakeAtMs: number | undefined;
|
|
31
|
+
}
|
|
32
|
+
export interface ListOptions {
|
|
33
|
+
includeDisabled?: boolean;
|
|
34
|
+
}
|
|
35
|
+
export type OnJobDueCallback = (job: Job) => Promise<JobDueResult | void> | JobDueResult | void;
|
|
36
|
+
export default class CronService {
|
|
37
|
+
#private;
|
|
38
|
+
jobs: Map<string, Job>;
|
|
39
|
+
heap: MinHeap<HeapEntry>;
|
|
40
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
41
|
+
running: boolean;
|
|
42
|
+
runLog: RunLog;
|
|
43
|
+
started: boolean;
|
|
44
|
+
onJobDue: OnJobDueCallback | null;
|
|
45
|
+
constructor();
|
|
46
|
+
/**
|
|
47
|
+
* Start the service. Loads jobs from store (if any), arms timer. A no-op if
|
|
48
|
+
* already started.
|
|
49
|
+
*
|
|
50
|
+
* `initialJobs` crosses a serialization boundary — it is whatever the
|
|
51
|
+
* consumer's store handed back — so `Job[]` is a compile-time claim about
|
|
52
|
+
* runtime data. Three behaviours follow from that and are worth knowing
|
|
53
|
+
* before you call this, because all three are deliberate and two of them
|
|
54
|
+
* differ from a plain "load and arm":
|
|
55
|
+
*
|
|
56
|
+
* 1. WRITES TO `row.state`. A STALE claim (`state.runningAtMs` set by a
|
|
57
|
+
* process that is gone) is released, because nothing else ever will —
|
|
58
|
+
* there is no lease on the field (#35) — and left in place it is a job
|
|
59
|
+
* that is dead forever while `status()` reports it healthy. A LIVE claim,
|
|
60
|
+
* held by an invocation still running in this process, is left alone:
|
|
61
|
+
* releasing it would let the timer start a second concurrent invocation of
|
|
62
|
+
* a job that is already running.
|
|
63
|
+
*
|
|
64
|
+
* 2. THROWS on a row this class cannot use, rather than accepting it. A row
|
|
65
|
+
* whose `state` is missing, or frozen (`structuredClone` + `Object.freeze`
|
|
66
|
+
* is an ordinary defensive rehydration), throws out of `start()` where the
|
|
67
|
+
* caller's own `await` can catch it. The alternative is a `TypeError` from
|
|
68
|
+
* inside a bare timer callback later — an unhandled rejection, and
|
|
69
|
+
* process-fatal under Node's default.
|
|
70
|
+
*
|
|
71
|
+
* 3. ARMS THE TIMER EVEN IF IT THROWS. The rows loaded before the throw are
|
|
72
|
+
* registered and scheduled. Without this, a throw leaves `started: true`
|
|
73
|
+
* (so a retry is a no-op) with jobs in the heap and no timer: nothing ever
|
|
74
|
+
* fires and `status()` still reports healthy.
|
|
75
|
+
*
|
|
76
|
+
* Which of the three you can observe depends on the CONTENT of the rows, not
|
|
77
|
+
* on whether they were deserialized — 1 fires only on a row that already
|
|
78
|
+
* carries a claim, and 2/3 only on a row this class cannot use. Hand it
|
|
79
|
+
* well-formed deserialized rows with no claim set and none of the three is
|
|
80
|
+
* observable. Hand it a deserialized row that DOES carry one and 1 and 2 are
|
|
81
|
+
* exactly what you get: measured, a stale `state.runningAtMs` of 1 comes back
|
|
82
|
+
* `undefined`, and a `structuredClone` + `Object.freeze` row makes `start()`
|
|
83
|
+
* throw `TypeError: Cannot assign to read only property 'runningAtMs'` with
|
|
84
|
+
* the timer still armed behind it. Hand it live `Job` objects this service is
|
|
85
|
+
* currently executing and only 1 is in play, by design — and on those it
|
|
86
|
+
* deliberately does nothing.
|
|
87
|
+
*/
|
|
88
|
+
start(initialJobs?: Job[]): Promise<void>;
|
|
89
|
+
/**
|
|
90
|
+
* Stop the service. Clears timer.
|
|
91
|
+
*/
|
|
92
|
+
stop(): void;
|
|
93
|
+
/**
|
|
94
|
+
* Get service status.
|
|
95
|
+
*/
|
|
96
|
+
status(): ServiceStatus;
|
|
97
|
+
/**
|
|
98
|
+
* List jobs, optionally including disabled ones.
|
|
99
|
+
*/
|
|
100
|
+
list(opts?: ListOptions): Job[];
|
|
101
|
+
/**
|
|
102
|
+
* Get a single job by ID.
|
|
103
|
+
*/
|
|
104
|
+
get(id: string): Job | null;
|
|
105
|
+
/**
|
|
106
|
+
* Add a new job. Input is normalized for AI compatibility.
|
|
107
|
+
*/
|
|
108
|
+
add(rawInput: Record<string, unknown>): Promise<Job>;
|
|
109
|
+
/**
|
|
110
|
+
* Update an existing job.
|
|
111
|
+
*/
|
|
112
|
+
update(id: string, patch: JobPatch): Promise<Job>;
|
|
113
|
+
/**
|
|
114
|
+
* Remove a job.
|
|
115
|
+
*/
|
|
116
|
+
remove(id: string): Promise<void>;
|
|
117
|
+
/**
|
|
118
|
+
* Manually trigger a job.
|
|
119
|
+
*
|
|
120
|
+
* Returns `{ status: 'skipped', reason }` without invoking the callback when
|
|
121
|
+
* the job is not due (`mode: 'due'`), is already in flight
|
|
122
|
+
* (`'already running'`), or was removed before the claim landed
|
|
123
|
+
* (`'removed'`). Before the phase split, a forced run against an in-flight
|
|
124
|
+
* job launched a second concurrent invocation.
|
|
125
|
+
*
|
|
126
|
+
* THROWS (rather than returning a skip) when `id` is not a registered job:
|
|
127
|
+
* `Error("Job not found: <id>")`. A job that disappears between this lookup
|
|
128
|
+
* and the claim is the `'removed'` skip above, not a throw — the two differ
|
|
129
|
+
* only by the timing of a race, and the second is a legitimate outcome
|
|
130
|
+
* whereas the first is a caller error.
|
|
131
|
+
*
|
|
132
|
+
* CONCURRENCY: the same job is bounded to one in-flight invocation on every
|
|
133
|
+
* path, and the timer path invokes due jobs one at a time. `run()` fan-out
|
|
134
|
+
* across DIFFERENT jobs is deliberately unbounded — N concurrent `run()`
|
|
135
|
+
* calls produce N concurrent consumer callbacks. Before the phase split
|
|
136
|
+
* these serialized behind the module-global lock; that serialization was the
|
|
137
|
+
* bug rather than the feature (one hung callback wedged every other caller),
|
|
138
|
+
* so it is not restored here. The fan-out is caller-driven and the scheduler
|
|
139
|
+
* never produces it on its own. A per-invoke bound belongs above this layer;
|
|
140
|
+
* it is tracked on stonyx-cron#35 alongside the execution timeout.
|
|
141
|
+
*
|
|
142
|
+
* NOT FIXED HERE: the phase split fixes the LOCK wedge, not the TIMER. A
|
|
143
|
+
* callback that never settles still stops `onTimer`'s sequential loop
|
|
144
|
+
* forever — `running` stays true, every later tick early-returns and re-arms,
|
|
145
|
+
* and the hung job's batch siblings stay claimed and off-heap having never
|
|
146
|
+
* been invoked. CRUD still resolves and `status()` still reports
|
|
147
|
+
* `started: true`, so that failure is now silent where it used to be loud.
|
|
148
|
+
* Bounding the callback and releasing batch siblings is stonyx-cron#35. Do
|
|
149
|
+
* not read this method's doc as "the hang is fixed".
|
|
150
|
+
*/
|
|
151
|
+
run(id: string, mode?: 'due' | 'force'): Promise<ExecuteResult>;
|
|
152
|
+
/**
|
|
153
|
+
* Get run history for a job.
|
|
154
|
+
*/
|
|
155
|
+
runs(id: string, limit?: number): ReturnType<RunLog['get']>;
|
|
156
|
+
armTimer(): void;
|
|
157
|
+
onTimer(): Promise<void>;
|
|
158
|
+
findDueJobs(nowMs: number): Job[];
|
|
159
|
+
/**
|
|
160
|
+
* Execute a job in three phases:
|
|
161
|
+
*
|
|
162
|
+
* 1. claim (locked) — take ownership of the job, detach it from the heap
|
|
163
|
+
* 2. invoke (UNLOCKED) — await the consumer callback
|
|
164
|
+
* 3. settle (locked) — apply the result, log it, re-insert into the heap
|
|
165
|
+
*
|
|
166
|
+
* The critical section deliberately excludes phase 2. `onJobDue` is
|
|
167
|
+
* arbitrary, unbounded consumer code; awaiting it under the module-global
|
|
168
|
+
* lock is what wedged every subsequent `locked()` call (add/update/remove)
|
|
169
|
+
* when a callback never settled.
|
|
170
|
+
*
|
|
171
|
+
* `onTimer` performs the batch claim (`findDueJobs` + `markRunning`) for all
|
|
172
|
+
* due jobs under a single lock and then enters at phase 2 via
|
|
173
|
+
* `#executeClaimed`. That entry point is `#private` rather than a parameter
|
|
174
|
+
* on this method: as a published `alreadyClaimed` flag it would be a
|
|
175
|
+
* supported way to skip phase 1 entirely, defeating the claim guard and
|
|
176
|
+
* allowing concurrent `onJobDue` invocations for the same job.
|
|
177
|
+
*/
|
|
178
|
+
executeJob(job: Job): Promise<ExecuteResult>;
|
|
179
|
+
removeFromHeap(id: string): void;
|
|
180
|
+
log(message: string): void;
|
|
181
|
+
}
|
|
182
|
+
export {};
|