@quark-fw/plugin-cron 0.0.0-stage → 0.1.19
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 +32 -2
- package/package.json +36 -5
- package/src/CronScheduler.test.ts +118 -0
- package/src/CronScheduler.ts +226 -0
- package/src/cronExpression.ts +97 -0
- package/src/global.d.ts +16 -0
- package/src/index.ts +36 -0
- package/src/types.ts +37 -0
package/README.md
CHANGED
|
@@ -1,3 +1,33 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @quark-fw/plugin-cron
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
In-process cron scheduler for Quark apps: 5-field expressions (UTC), a Redis lock so only one instance runs a job, and per-job status (last run, success, error, duration) stored in Redis.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm i @quark-fw/plugin-cron
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Peers: `@quark-fw/core`, `@quark-fw/plugin-redis`, `ioredis`.
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import "@quark-fw/plugin-redis";
|
|
17
|
+
import "@quark-fw/plugin-cron"; // provides quark.cron
|
|
18
|
+
|
|
19
|
+
quark.cron.schedule("parse-listings", "7 * * * *", () => runParser(), {
|
|
20
|
+
lockTtlMs: 30 * 60 * 1000, // longer than the slowest run
|
|
21
|
+
maxAgeMs: 3 * 60 * 60 * 1000, // plugin-health reports the job stale after 3h without success
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
await quark.cron.run("parse-listings"); // manual trigger (false if another instance holds the lock)
|
|
25
|
+
await quark.cron.status(); // [{ name, lastRunAt, lastSuccessAt, lastError, stale, … }]
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
- Expressions: `*`, `a,b`, `a-b`, `*/n`, `a-b/n`, aliases `@hourly @daily @weekly @monthly`; evaluated in UTC.
|
|
29
|
+
- Heavy work (downloads, LLM calls) belongs in a child process spawned from the handler, not in the web process.
|
|
30
|
+
- Env: `CRON_PREFIX` (default `quark:cron:`), `CRON_DISABLED=1` to register jobs without ticking.
|
|
31
|
+
- If Redis is down, the job still runs on this instance and the error goes to `onError`.
|
|
32
|
+
|
|
33
|
+
Part of the [Quark framework](https://www.npmjs.com/package/@quark-fw/core).
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@quark-fw/plugin-cron",
|
|
3
|
+
"version": "0.1.19",
|
|
4
|
+
"main": "src/index.ts",
|
|
5
|
+
"types": "src/index.ts",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "echo raw-src package, no build",
|
|
9
|
+
"test": "vitest run"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src"
|
|
13
|
+
],
|
|
14
|
+
"keywords": [
|
|
15
|
+
"quark",
|
|
16
|
+
"cron",
|
|
17
|
+
"scheduler"
|
|
18
|
+
],
|
|
19
|
+
"author": "",
|
|
20
|
+
"license": "ISC",
|
|
21
|
+
"description": "In-process cron scheduler for the Quark framework with a Redis lock across instances and per-job status (published as raw TS sources; consumers run tsx)",
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@quark-fw/atom": "0.1.19",
|
|
27
|
+
"@quark-fw/plugin": "0.1.19"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"@quark-fw/core": "0.1.19",
|
|
31
|
+
"@quark-fw/plugin-redis": "0.1.19",
|
|
32
|
+
"ioredis": "^5.9.3"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"vitest": "^5.0.0"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from "vitest";
|
|
2
|
+
import type { Redis } from "ioredis";
|
|
3
|
+
import { CronScheduler } from "./CronScheduler.js";
|
|
4
|
+
import { cronMatches, parseCron } from "./cronExpression.js";
|
|
5
|
+
|
|
6
|
+
function fakeRedis() {
|
|
7
|
+
const kv = new Map<string, string>();
|
|
8
|
+
const hashes = new Map<string, Record<string, string>>();
|
|
9
|
+
const client = {
|
|
10
|
+
set: vi.fn(async (key: string, value: string, ..._args: unknown[]) => {
|
|
11
|
+
const nx = _args.includes("NX");
|
|
12
|
+
if (nx && kv.has(key)) return null;
|
|
13
|
+
kv.set(key, value);
|
|
14
|
+
return "OK";
|
|
15
|
+
}),
|
|
16
|
+
get: vi.fn(async (key: string) => kv.get(key) ?? null),
|
|
17
|
+
del: vi.fn(async (key: string) => (kv.delete(key) ? 1 : 0)),
|
|
18
|
+
hset: vi.fn(async (key: string, fields: Record<string, string>) => {
|
|
19
|
+
hashes.set(key, { ...(hashes.get(key) ?? {}), ...fields });
|
|
20
|
+
return 1;
|
|
21
|
+
}),
|
|
22
|
+
hgetall: vi.fn(async (key: string) => hashes.get(key) ?? {}),
|
|
23
|
+
};
|
|
24
|
+
return { client: client as unknown as Redis, kv, hashes };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const at = (iso: string) => new Date(iso);
|
|
28
|
+
|
|
29
|
+
describe("cronExpression", () => {
|
|
30
|
+
test("разбирает звёздочки, шаги, диапазоны, списки и алиасы", () => {
|
|
31
|
+
const f = parseCron("7 */6 1-3,15 * 1-5");
|
|
32
|
+
expect([...f.minute]).toEqual([7]);
|
|
33
|
+
expect([...f.hour]).toEqual([0, 6, 12, 18]);
|
|
34
|
+
expect([...f.dayOfMonth]).toEqual([1, 2, 3, 15]);
|
|
35
|
+
expect([...f.dayOfWeek]).toEqual([1, 2, 3, 4, 5]);
|
|
36
|
+
expect([...parseCron("@hourly").minute]).toEqual([0]);
|
|
37
|
+
// 7 — тоже воскресенье
|
|
38
|
+
expect([...parseCron("0 0 * * 7").dayOfWeek]).toEqual([0]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("матчит минуту в UTC", () => {
|
|
42
|
+
const f = parseCron("7 * * * *");
|
|
43
|
+
expect(cronMatches(f, at("2026-10-06T12:07:30Z"))).toBe(true);
|
|
44
|
+
expect(cronMatches(f, at("2026-10-06T12:08:00Z"))).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("отвергает мусор", () => {
|
|
48
|
+
expect(() => parseCron("* * *")).toThrow();
|
|
49
|
+
expect(() => parseCron("61 * * * *")).toThrow();
|
|
50
|
+
expect(() => parseCron("*/0 * * * *")).toThrow();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("CronScheduler", () => {
|
|
55
|
+
test("tick запускает совпавшую задачу один раз за минуту и пишет статус", async () => {
|
|
56
|
+
const { client } = fakeRedis();
|
|
57
|
+
let now = at("2026-10-06T12:07:05Z");
|
|
58
|
+
const cron = new CronScheduler({
|
|
59
|
+
client: () => client,
|
|
60
|
+
now: () => now,
|
|
61
|
+
prefix: "t:",
|
|
62
|
+
});
|
|
63
|
+
const handler = vi.fn(async () => {});
|
|
64
|
+
cron.schedule("job", "7 * * * *", handler, { maxAgeMs: 3_600_000 });
|
|
65
|
+
|
|
66
|
+
await cron.tick();
|
|
67
|
+
await cron.tick(at("2026-10-06T12:07:40Z"));
|
|
68
|
+
await cron.tick(at("2026-10-06T12:08:05Z"));
|
|
69
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
70
|
+
|
|
71
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
72
|
+
const [status] = await cron.status();
|
|
73
|
+
expect(status!.lastSuccessAt).toBe("2026-10-06T12:07:05.000Z");
|
|
74
|
+
expect(status!.lastError).toBeNull();
|
|
75
|
+
expect(status!.stale).toBe(false);
|
|
76
|
+
|
|
77
|
+
now = at("2026-10-06T14:00:00Z");
|
|
78
|
+
expect((await cron.status())[0]!.stale).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("лок в Redis: второй инстанс не запускает ту же задачу", async () => {
|
|
82
|
+
const { client } = fakeRedis();
|
|
83
|
+
const a = new CronScheduler({ client: () => client, prefix: "t:" });
|
|
84
|
+
const b = new CronScheduler({ client: () => client, prefix: "t:" });
|
|
85
|
+
let release!: () => void;
|
|
86
|
+
const slow = () => new Promise<void>((resolve) => (release = resolve));
|
|
87
|
+
const fast = vi.fn(async () => {});
|
|
88
|
+
a.schedule("job", "* * * * *", slow);
|
|
89
|
+
b.schedule("job", "* * * * *", fast);
|
|
90
|
+
|
|
91
|
+
const first = a.run("job");
|
|
92
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
93
|
+
const second = await b.run("job");
|
|
94
|
+
|
|
95
|
+
expect(second).toBe(false);
|
|
96
|
+
expect(fast).not.toHaveBeenCalled();
|
|
97
|
+
release();
|
|
98
|
+
expect(await first).toBe(true);
|
|
99
|
+
// лок снят владельцем — теперь b может запустить
|
|
100
|
+
expect(await b.run("job")).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("ошибка задачи попадает в статус и не роняет планировщик", async () => {
|
|
104
|
+
const { client } = fakeRedis();
|
|
105
|
+
const onError = vi.fn();
|
|
106
|
+
const cron = new CronScheduler({
|
|
107
|
+
client: () => client,
|
|
108
|
+
onError,
|
|
109
|
+
prefix: "t:",
|
|
110
|
+
});
|
|
111
|
+
cron.schedule("boom", "* * * * *", async () => {
|
|
112
|
+
throw new Error("нет связи");
|
|
113
|
+
});
|
|
114
|
+
expect(await cron.run("boom")).toBe(true);
|
|
115
|
+
expect(onError).toHaveBeenCalledWith("boom", expect.any(Error));
|
|
116
|
+
expect((await cron.status())[0]!.lastError).toBe("нет связи");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import type { Redis } from "ioredis";
|
|
3
|
+
import {
|
|
4
|
+
cronMatches,
|
|
5
|
+
minuteKey,
|
|
6
|
+
parseCron,
|
|
7
|
+
type CronFields,
|
|
8
|
+
} from "./cronExpression.js";
|
|
9
|
+
import type {
|
|
10
|
+
CronHandler,
|
|
11
|
+
CronJob,
|
|
12
|
+
CronJobOptions,
|
|
13
|
+
CronJobStatus,
|
|
14
|
+
} from "./types.js";
|
|
15
|
+
|
|
16
|
+
// команды ioredis, которыми пользуется планировщик (тесты подсовывают фейк)
|
|
17
|
+
type RedisLike = Pick<Redis, "set" | "get" | "del" | "hset" | "hgetall">;
|
|
18
|
+
|
|
19
|
+
export type CronSchedulerConfig = {
|
|
20
|
+
client: () => RedisLike;
|
|
21
|
+
prefix?: string;
|
|
22
|
+
/** период проверки расписания, мс (минутная сетка — 20 с с запасом) */
|
|
23
|
+
tickMs?: number;
|
|
24
|
+
now?: () => Date;
|
|
25
|
+
onError?: (job: string, error: unknown) => void;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const DEFAULT_LOCK_TTL = 10 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Планировщик cron-задач внутри приложения.
|
|
32
|
+
*
|
|
33
|
+
* Каждая минута проверяется на совпадение с выражениями; совпавшая задача
|
|
34
|
+
* берёт Redis-лок (`SET NX PX`), поэтому при нескольких инстансах приложения
|
|
35
|
+
* её выполнит один. Статус (последний запуск, успех, ошибка, длительность)
|
|
36
|
+
* лежит в Redis — его видят все инстансы и проверка здоровья.
|
|
37
|
+
*/
|
|
38
|
+
export class CronScheduler {
|
|
39
|
+
private readonly client: () => RedisLike;
|
|
40
|
+
private readonly prefix: string;
|
|
41
|
+
private readonly tickMs: number;
|
|
42
|
+
private readonly now: () => Date;
|
|
43
|
+
private readonly onError: (job: string, error: unknown) => void;
|
|
44
|
+
private readonly jobs = new Map<
|
|
45
|
+
string,
|
|
46
|
+
CronJob & { fields: CronFields; firedMinute?: string }
|
|
47
|
+
>();
|
|
48
|
+
private readonly running = new Set<string>();
|
|
49
|
+
private timer: NodeJS.Timeout | undefined;
|
|
50
|
+
readonly runnerId = `${os.hostname()}:${process.pid}`;
|
|
51
|
+
|
|
52
|
+
constructor({
|
|
53
|
+
client,
|
|
54
|
+
prefix = "quark:cron:",
|
|
55
|
+
tickMs = 20_000,
|
|
56
|
+
now = () => new Date(),
|
|
57
|
+
onError = (job, error) => console.error(`[cron] ${job}:`, error),
|
|
58
|
+
}: CronSchedulerConfig) {
|
|
59
|
+
this.client = client;
|
|
60
|
+
this.prefix = prefix;
|
|
61
|
+
this.tickMs = tickMs;
|
|
62
|
+
this.now = now;
|
|
63
|
+
this.onError = onError;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Регистрирует задачу; выражение проверяется сразу. */
|
|
67
|
+
schedule(
|
|
68
|
+
name: string,
|
|
69
|
+
expression: string,
|
|
70
|
+
handler: CronHandler,
|
|
71
|
+
options: CronJobOptions = {},
|
|
72
|
+
): void {
|
|
73
|
+
if (this.jobs.has(name))
|
|
74
|
+
throw new Error(`cron: job "${name}" is already scheduled`);
|
|
75
|
+
this.jobs.set(name, {
|
|
76
|
+
name,
|
|
77
|
+
expression,
|
|
78
|
+
handler,
|
|
79
|
+
options: { lockTtlMs: DEFAULT_LOCK_TTL, ...options },
|
|
80
|
+
fields: parseCron(expression),
|
|
81
|
+
});
|
|
82
|
+
if (options.runOnStart) void this.run(name);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
unschedule(name: string): void {
|
|
86
|
+
this.jobs.delete(name);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
names(): string[] {
|
|
90
|
+
return [...this.jobs.keys()];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
start(): void {
|
|
94
|
+
if (this.timer) return;
|
|
95
|
+
this.timer = setInterval(() => void this.tick(), this.tickMs);
|
|
96
|
+
// таймер не держит процесс живым — приложение завершается штатно
|
|
97
|
+
this.timer.unref?.();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
stop(): void {
|
|
101
|
+
if (this.timer) clearInterval(this.timer);
|
|
102
|
+
this.timer = undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Один проход по расписанию: запускает задачи, чья минута совпала. */
|
|
106
|
+
async tick(date = this.now()): Promise<void> {
|
|
107
|
+
const key = minuteKey(date);
|
|
108
|
+
for (const job of this.jobs.values()) {
|
|
109
|
+
if (job.firedMinute === key || !cronMatches(job.fields, date))
|
|
110
|
+
continue;
|
|
111
|
+
job.firedMinute = key;
|
|
112
|
+
void this.run(job.name);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Запускает задачу вне расписания (или по нему). Возвращает false, если
|
|
118
|
+
* лок держит другой инстанс или задача уже выполняется здесь.
|
|
119
|
+
*/
|
|
120
|
+
async run(name: string): Promise<boolean> {
|
|
121
|
+
const job = this.jobs.get(name);
|
|
122
|
+
if (!job) throw new Error(`cron: unknown job "${name}"`);
|
|
123
|
+
if (this.running.has(name)) return false;
|
|
124
|
+
|
|
125
|
+
const lockKey = `${this.prefix}lock:${name}`;
|
|
126
|
+
// без Redis задача всё равно выполняется: одиночный инстанс важнее
|
|
127
|
+
// строгой взаимной блокировки
|
|
128
|
+
const locked = await this.client()
|
|
129
|
+
.set(lockKey, this.runnerId, "PX", job.options.lockTtlMs, "NX")
|
|
130
|
+
.then((reply) => reply === "OK")
|
|
131
|
+
.catch((error) => {
|
|
132
|
+
this.onError(name, error);
|
|
133
|
+
return true;
|
|
134
|
+
});
|
|
135
|
+
if (!locked) return false;
|
|
136
|
+
|
|
137
|
+
this.running.add(name);
|
|
138
|
+
const startedAt = this.now();
|
|
139
|
+
await this.writeStatus(name, {
|
|
140
|
+
lastRunAt: startedAt.toISOString(),
|
|
141
|
+
lastRunner: this.runnerId,
|
|
142
|
+
running: "1",
|
|
143
|
+
});
|
|
144
|
+
try {
|
|
145
|
+
await job.handler();
|
|
146
|
+
await this.writeStatus(name, {
|
|
147
|
+
lastSuccessAt: this.now().toISOString(),
|
|
148
|
+
lastDurationMs: String(
|
|
149
|
+
this.now().getTime() - startedAt.getTime(),
|
|
150
|
+
),
|
|
151
|
+
lastError: "",
|
|
152
|
+
running: "0",
|
|
153
|
+
});
|
|
154
|
+
} catch (error) {
|
|
155
|
+
this.onError(name, error);
|
|
156
|
+
await this.writeStatus(name, {
|
|
157
|
+
lastError:
|
|
158
|
+
error instanceof Error ? error.message : String(error),
|
|
159
|
+
lastDurationMs: String(
|
|
160
|
+
this.now().getTime() - startedAt.getTime(),
|
|
161
|
+
),
|
|
162
|
+
running: "0",
|
|
163
|
+
});
|
|
164
|
+
} finally {
|
|
165
|
+
this.running.delete(name);
|
|
166
|
+
// лок снимается только своим владельцем: чужой (после истечения
|
|
167
|
+
// TTL и перезахвата) трогать нельзя
|
|
168
|
+
try {
|
|
169
|
+
const client = this.client();
|
|
170
|
+
if ((await client.get(lockKey)) === this.runnerId)
|
|
171
|
+
await client.del(lockKey);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
this.onError(name, error);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Статусы всех задач (из Redis) с признаком протухания. */
|
|
180
|
+
async status(): Promise<CronJobStatus[]> {
|
|
181
|
+
const result: CronJobStatus[] = [];
|
|
182
|
+
for (const job of this.jobs.values()) {
|
|
183
|
+
let raw: Record<string, string> = {};
|
|
184
|
+
try {
|
|
185
|
+
raw = await this.client().hgetall(
|
|
186
|
+
`${this.prefix}job:${job.name}`,
|
|
187
|
+
);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
this.onError(job.name, error);
|
|
190
|
+
}
|
|
191
|
+
const lastSuccessAt = raw.lastSuccessAt || null;
|
|
192
|
+
const stale =
|
|
193
|
+
job.options.maxAgeMs === undefined
|
|
194
|
+
? undefined
|
|
195
|
+
: !lastSuccessAt ||
|
|
196
|
+
this.now().getTime() -
|
|
197
|
+
new Date(lastSuccessAt).getTime() >
|
|
198
|
+
job.options.maxAgeMs;
|
|
199
|
+
result.push({
|
|
200
|
+
name: job.name,
|
|
201
|
+
expression: job.expression,
|
|
202
|
+
lastRunAt: raw.lastRunAt || null,
|
|
203
|
+
lastSuccessAt,
|
|
204
|
+
lastDurationMs: raw.lastDurationMs
|
|
205
|
+
? Number(raw.lastDurationMs)
|
|
206
|
+
: null,
|
|
207
|
+
lastError: raw.lastError || null,
|
|
208
|
+
lastRunner: raw.lastRunner || null,
|
|
209
|
+
running: raw.running === "1" || this.running.has(job.name),
|
|
210
|
+
stale,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
return result;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private async writeStatus(
|
|
217
|
+
name: string,
|
|
218
|
+
fields: Record<string, string>,
|
|
219
|
+
): Promise<void> {
|
|
220
|
+
try {
|
|
221
|
+
await this.client().hset(`${this.prefix}job:${name}`, fields);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
this.onError(name, error);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Разбор 5-полевого cron-выражения (минута час день месяц день-недели) без
|
|
2
|
+
// зависимостей. Поддерживаются `*`, списки `a,b`, диапазоны `a-b`, шаги
|
|
3
|
+
// `*/n` и `a-b/n`; алиасы `@hourly`, `@daily`, `@weekly`, `@monthly`.
|
|
4
|
+
// Время — UTC: расписание не зависит от TZ контейнера.
|
|
5
|
+
|
|
6
|
+
export type CronFields = {
|
|
7
|
+
minute: Set<number>;
|
|
8
|
+
hour: Set<number>;
|
|
9
|
+
dayOfMonth: Set<number>;
|
|
10
|
+
month: Set<number>;
|
|
11
|
+
dayOfWeek: Set<number>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const ALIASES: Record<string, string> = {
|
|
15
|
+
"@hourly": "0 * * * *",
|
|
16
|
+
"@daily": "0 0 * * *",
|
|
17
|
+
"@midnight": "0 0 * * *",
|
|
18
|
+
"@weekly": "0 0 * * 0",
|
|
19
|
+
"@monthly": "0 0 1 * *",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function parseField(
|
|
23
|
+
field: string,
|
|
24
|
+
min: number,
|
|
25
|
+
max: number,
|
|
26
|
+
name: string,
|
|
27
|
+
): Set<number> {
|
|
28
|
+
const values = new Set<number>();
|
|
29
|
+
for (const part of field.split(",")) {
|
|
30
|
+
const [rangePart, stepPart] = part.split("/");
|
|
31
|
+
const step = stepPart === undefined ? 1 : Number(stepPart);
|
|
32
|
+
if (!Number.isInteger(step) || step < 1) {
|
|
33
|
+
throw new Error(`cron: bad step "${part}" in ${name}`);
|
|
34
|
+
}
|
|
35
|
+
let from = min;
|
|
36
|
+
let to = max;
|
|
37
|
+
if (rangePart !== "*") {
|
|
38
|
+
const [a, b] = rangePart!.split("-").map(Number);
|
|
39
|
+
if (!Number.isInteger(a))
|
|
40
|
+
throw new Error(`cron: bad value "${part}" in ${name}`);
|
|
41
|
+
from = a!;
|
|
42
|
+
// `a/n` без диапазона означает «от a до максимума с шагом n»
|
|
43
|
+
to = b === undefined ? (stepPart === undefined ? a! : max) : b;
|
|
44
|
+
if (!Number.isInteger(to))
|
|
45
|
+
throw new Error(`cron: bad range "${part}" in ${name}`);
|
|
46
|
+
}
|
|
47
|
+
if (from < min || to > max || from > to) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`cron: "${part}" out of range ${min}-${max} in ${name}`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
for (let v = from; v <= to; v += step) values.add(v);
|
|
53
|
+
}
|
|
54
|
+
return values;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function parseCron(expression: string): CronFields {
|
|
58
|
+
const expr = (ALIASES[expression.trim()] ?? expression).trim().split(/\s+/);
|
|
59
|
+
if (expr.length !== 5) {
|
|
60
|
+
throw new Error(`cron: expected 5 fields, got "${expression}"`);
|
|
61
|
+
}
|
|
62
|
+
const [minute, hour, dayOfMonth, month, dayOfWeek] = expr as [
|
|
63
|
+
string,
|
|
64
|
+
string,
|
|
65
|
+
string,
|
|
66
|
+
string,
|
|
67
|
+
string,
|
|
68
|
+
];
|
|
69
|
+
const dow = parseField(
|
|
70
|
+
dayOfWeek.replace(/\b7\b/g, "0"),
|
|
71
|
+
0,
|
|
72
|
+
6,
|
|
73
|
+
"day-of-week",
|
|
74
|
+
);
|
|
75
|
+
return {
|
|
76
|
+
minute: parseField(minute, 0, 59, "minute"),
|
|
77
|
+
hour: parseField(hour, 0, 23, "hour"),
|
|
78
|
+
dayOfMonth: parseField(dayOfMonth, 1, 31, "day-of-month"),
|
|
79
|
+
month: parseField(month, 1, 12, "month"),
|
|
80
|
+
dayOfWeek: dow,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Совпадает ли минута `date` (UTC) с выражением. */
|
|
85
|
+
export function cronMatches(fields: CronFields, date: Date): boolean {
|
|
86
|
+
return (
|
|
87
|
+
fields.minute.has(date.getUTCMinutes()) &&
|
|
88
|
+
fields.hour.has(date.getUTCHours()) &&
|
|
89
|
+
fields.dayOfMonth.has(date.getUTCDate()) &&
|
|
90
|
+
fields.month.has(date.getUTCMonth() + 1) &&
|
|
91
|
+
fields.dayOfWeek.has(date.getUTCDay())
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Ключ минуты — чтобы задача не сработала дважды за одну минуту. */
|
|
96
|
+
export const minuteKey = (date: Date): string =>
|
|
97
|
+
date.toISOString().slice(0, 16);
|
package/src/global.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import "@quark-fw/core/src/global.d.ts";
|
|
2
|
+
import type { CronScheduler } from "./CronScheduler.js";
|
|
3
|
+
|
|
4
|
+
export {};
|
|
5
|
+
|
|
6
|
+
declare global {
|
|
7
|
+
interface Quark {
|
|
8
|
+
/** cron-задачи внутри приложения с Redis-локом между инстансами */
|
|
9
|
+
cron: CronScheduler;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface Events {
|
|
13
|
+
"cron:started": [name: string];
|
|
14
|
+
"cron:finished": [name: string, ok: boolean];
|
|
15
|
+
}
|
|
16
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { QuarkPlugin } from "@quark-fw/plugin";
|
|
2
|
+
import {} from "@quark-fw/plugin-redis/src/global.js";
|
|
3
|
+
import {} from "@quark-fw/plugin-cron/src/global.js";
|
|
4
|
+
import { CronScheduler } from "./CronScheduler.js";
|
|
5
|
+
|
|
6
|
+
export { CronScheduler } from "./CronScheduler.js";
|
|
7
|
+
export { parseCron, cronMatches } from "./cronExpression.js";
|
|
8
|
+
export type { CronHandler, CronJobOptions, CronJobStatus } from "./types.js";
|
|
9
|
+
|
|
10
|
+
// Плагин: quark.cron поверх клиента plugin-redis. Планировщик стартует в
|
|
11
|
+
// onReady — к этому моменту приложение уже зарегистрировало задачи в своих
|
|
12
|
+
// модулях. CRON_DISABLED=1 оставляет регистрацию, но не запускает тики
|
|
13
|
+
// (локальная разработка, воркеры без расписания).
|
|
14
|
+
await quark.registerPlugin({
|
|
15
|
+
name: "cron",
|
|
16
|
+
dependsOn: ["redis"],
|
|
17
|
+
extend(quark) {
|
|
18
|
+
quark.cron = new CronScheduler({
|
|
19
|
+
client: () => quark.redis.client.value,
|
|
20
|
+
prefix: process.env.CRON_PREFIX || "quark:cron:",
|
|
21
|
+
});
|
|
22
|
+
},
|
|
23
|
+
onReady(quark) {
|
|
24
|
+
if (process.env.CRON_DISABLED === "1") {
|
|
25
|
+
console.log("[cron] disabled (CRON_DISABLED=1)");
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
quark.cron.start();
|
|
29
|
+
console.log(
|
|
30
|
+
`[cron] scheduled: ${quark.cron.names().join(", ") || "(none)"}`,
|
|
31
|
+
);
|
|
32
|
+
},
|
|
33
|
+
onDestroy(quark) {
|
|
34
|
+
quark.cron.stop();
|
|
35
|
+
},
|
|
36
|
+
} satisfies QuarkPlugin);
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export type CronJobOptions = {
|
|
2
|
+
/**
|
|
3
|
+
* TTL Redis-лока в мс. Должен превышать худшее время задачи: пока лок
|
|
4
|
+
* жив, ни один инстанс не запустит её повторно. По умолчанию 10 минут.
|
|
5
|
+
*/
|
|
6
|
+
lockTtlMs?: number;
|
|
7
|
+
/**
|
|
8
|
+
* Максимальный возраст последнего успешного запуска для проверки
|
|
9
|
+
* здоровья (plugin-health): старше — задача считается протухшей.
|
|
10
|
+
*/
|
|
11
|
+
maxAgeMs?: number;
|
|
12
|
+
/** запустить сразу при старте, не дожидаясь расписания */
|
|
13
|
+
runOnStart?: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type CronJobStatus = {
|
|
17
|
+
name: string;
|
|
18
|
+
expression: string;
|
|
19
|
+
lastRunAt: string | null;
|
|
20
|
+
lastSuccessAt: string | null;
|
|
21
|
+
lastDurationMs: number | null;
|
|
22
|
+
lastError: string | null;
|
|
23
|
+
/** инстанс (hostname:pid), выполнявший задачу в последний раз */
|
|
24
|
+
lastRunner: string | null;
|
|
25
|
+
running: boolean;
|
|
26
|
+
/** протухла по maxAgeMs (undefined — maxAgeMs не задан) */
|
|
27
|
+
stale?: boolean;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type CronHandler = () => Promise<void> | void;
|
|
31
|
+
|
|
32
|
+
export type CronJob = {
|
|
33
|
+
name: string;
|
|
34
|
+
expression: string;
|
|
35
|
+
handler: CronHandler;
|
|
36
|
+
options: Required<Pick<CronJobOptions, "lockTtlMs">> & CronJobOptions;
|
|
37
|
+
};
|