@procedures/schedule 0.4.0 → 0.5.0
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/CHANGELOG.md +26 -0
- package/README.md +55 -5
- package/dist/.tsbuildinfo +1 -1
- package/dist/cron.d.ts +36 -6
- package/dist/cron.d.ts.map +1 -1
- package/dist/cron.js +265 -41
- package/dist/cron.js.map +1 -1
- package/dist/errors.d.ts +4 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/module.d.ts +5 -0
- package/dist/module.d.ts.map +1 -1
- package/dist/module.js +8 -2
- package/dist/module.js.map +1 -1
- package/dist/procedures/schedule/add.d.ts +5 -2
- package/dist/procedures/schedule/add.d.ts.map +1 -1
- package/dist/procedures/schedule/add.js +15 -10
- package/dist/procedures/schedule/add.js.map +1 -1
- package/dist/procedures/schedule/list.js +1 -1
- package/dist/procedures/schedule/list.js.map +1 -1
- package/dist/procedures/schedule/next.d.ts +1 -1
- package/dist/procedures/schedule/next.d.ts.map +1 -1
- package/dist/procedures/schedule/next.js +2 -2
- package/dist/procedures/schedule/next.js.map +1 -1
- package/dist/programs.d.ts +3 -1
- package/dist/programs.d.ts.map +1 -1
- package/dist/programs.js.map +1 -1
- package/dist/scheduler.d.ts +9 -0
- package/dist/scheduler.d.ts.map +1 -1
- package/dist/scheduler.js +11 -6
- package/dist/scheduler.js.map +1 -1
- package/dist/store.d.ts +50 -5
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +161 -26
- package/dist/store.js.map +1 -1
- package/dist/types.d.ts +8 -4
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/cron.ts +277 -48
- package/src/errors.ts +4 -0
- package/src/index.ts +10 -2
- package/src/module.ts +13 -2
- package/src/procedures/schedule/add.ts +15 -10
- package/src/procedures/schedule/list.ts +1 -1
- package/src/procedures/schedule/next.ts +3 -3
- package/src/programs.ts +3 -1
- package/src/scheduler.ts +16 -6
- package/src/store.ts +166 -24
- package/src/types.ts +8 -4
package/src/store.ts
CHANGED
|
@@ -4,11 +4,15 @@
|
|
|
4
4
|
* A host gives one store to the module of the procedures (`createScheduleModule({ store })`) and
|
|
5
5
|
* the same store to its `Scheduler`. `update()` changes one schedule in one step, so the record of
|
|
6
6
|
* a run does not lose a change of `schedule.add` that came at the same time.
|
|
7
|
+
*
|
|
8
|
+
* Two processes can share the file of a `FileScheduleStore`: a lock file next to it keeps their
|
|
9
|
+
* changes apart (see the comment of the class).
|
|
7
10
|
*/
|
|
8
11
|
|
|
9
|
-
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
12
|
+
import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
10
13
|
import { dirname } from "node:path";
|
|
11
14
|
import { randomBytes } from "node:crypto";
|
|
15
|
+
import { checkTimeZone } from "./cron.js";
|
|
12
16
|
import { ScheduleError } from "./errors.js";
|
|
13
17
|
import type { Schedule } from "./types.js";
|
|
14
18
|
|
|
@@ -34,6 +38,16 @@ const byId = (a: Schedule, b: Schedule): number => (a.id < b.id ? -1 : a.id > b.
|
|
|
34
38
|
|
|
35
39
|
const isCount = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
36
40
|
|
|
41
|
+
/** True for `utc`, `local` and an IANA name that `Intl` knows. */
|
|
42
|
+
function isTimeZone(timeZone: string): boolean {
|
|
43
|
+
try {
|
|
44
|
+
checkTimeZone(timeZone);
|
|
45
|
+
return true;
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
37
51
|
/** The schedule of a record that came from a file: its shape is not trusted. */
|
|
38
52
|
export function checkSchedule(value: unknown): Schedule {
|
|
39
53
|
const record = value as Partial<Schedule> | null;
|
|
@@ -42,7 +56,9 @@ export function checkSchedule(value: unknown): Schedule {
|
|
|
42
56
|
if (typeof record.cron !== "string" || typeof record.program !== "string" || record.program === "") {
|
|
43
57
|
throw new ScheduleError("BAD_STORE", `The schedule ${record.id} needs a cron expression and a program`);
|
|
44
58
|
}
|
|
45
|
-
if (record.timeZone !== "
|
|
59
|
+
if (typeof record.timeZone !== "string" || !isTimeZone(record.timeZone)) {
|
|
60
|
+
throw new ScheduleError("BAD_STORE", `The schedule ${record.id} needs the time zone utc, local or an IANA name that Intl knows`);
|
|
61
|
+
}
|
|
46
62
|
if (typeof record.createdAt !== "number" || !Number.isFinite(record.createdAt)) throw new ScheduleError("BAD_STORE", `The schedule ${record.id} needs createdAt`);
|
|
47
63
|
if (!isCount(record.runs) || !isCount(record.failures) || !isCount(record.skipped)) throw new ScheduleError("BAD_STORE", `The schedule ${record.id} needs the counts of its runs`);
|
|
48
64
|
return record as Schedule;
|
|
@@ -96,18 +112,64 @@ function isContention(error: unknown): boolean {
|
|
|
96
112
|
|
|
97
113
|
const pause = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
|
98
114
|
|
|
115
|
+
/** This function starts `operation` again while Windows holds the file for a moment: at most 20 times. */
|
|
116
|
+
async function retried<T>(operation: () => Promise<T>): Promise<T> {
|
|
117
|
+
for (let attempt = 1; ; attempt++) {
|
|
118
|
+
try {
|
|
119
|
+
return await operation();
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (attempt >= 20 || !isContention(error)) throw error;
|
|
122
|
+
await pause(10 * attempt);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** This function removes a file. A file that does not exist is not an error. */
|
|
128
|
+
const removeFile = (path: string): Promise<void> => retried(() => rm(path, { force: true }));
|
|
129
|
+
|
|
130
|
+
/** The default age of a stale lock file: 30 seconds. */
|
|
131
|
+
export const DEFAULT_LOCK_STALE_MS = 30_000;
|
|
132
|
+
|
|
133
|
+
/** The options of a `FileScheduleStore`. */
|
|
134
|
+
export interface FileScheduleStoreOptions {
|
|
135
|
+
/**
|
|
136
|
+
* The age in milliseconds after which a lock file is stale: its process stopped, and another
|
|
137
|
+
* store takes it over. The default is 30 seconds. A change holds the lock for one read and one write.
|
|
138
|
+
*/
|
|
139
|
+
readonly staleMs?: number | undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
99
142
|
/**
|
|
100
143
|
* A store in one JSON file: `{ "schemaVersion": 1, "schedules": { "<id>": <Schedule> } }`. Each
|
|
101
144
|
* operation reads the file again. A write goes through a temporary file and a rename, so a reader
|
|
102
|
-
*
|
|
103
|
-
*
|
|
145
|
+
* does not see a part of a write. The changes of this process go one at a time.
|
|
146
|
+
*
|
|
147
|
+
* Two processes can share the file. Each change holds a lock file next to it (`lockPath`):
|
|
148
|
+
* - The store makes the lock file with the flag `wx`. Only one process can make it.
|
|
149
|
+
* - Under the lock, the store reads the file again, changes it, writes it, and then removes the lock.
|
|
150
|
+
* - A lock file older than `staleMs` (default 30 seconds) is stale, and the store takes it over.
|
|
151
|
+
* - A store that waits for twice `staleMs` gives up with `LOCKED`.
|
|
152
|
+
* - The store removes only its own lock: the lock file holds a token of the store.
|
|
104
153
|
*/
|
|
105
154
|
export class FileScheduleStore implements ScheduleStore {
|
|
106
155
|
readonly kind = "file";
|
|
156
|
+
/** The lock file: the path of the store with `.lock`. */
|
|
157
|
+
readonly lockPath: string;
|
|
158
|
+
private readonly staleMs: number;
|
|
107
159
|
private queue: Promise<unknown> = Promise.resolve();
|
|
108
160
|
|
|
109
|
-
/**
|
|
110
|
-
|
|
161
|
+
/**
|
|
162
|
+
* @param path - The JSON file. The store makes it, and its folder, at the first write.
|
|
163
|
+
* @param options - The age of a stale lock file.
|
|
164
|
+
*/
|
|
165
|
+
constructor(
|
|
166
|
+
readonly path: string,
|
|
167
|
+
options: FileScheduleStoreOptions = {},
|
|
168
|
+
) {
|
|
169
|
+
this.lockPath = `${path}.lock`;
|
|
170
|
+
this.staleMs = options.staleMs ?? DEFAULT_LOCK_STALE_MS;
|
|
171
|
+
if (!(this.staleMs > 0) || !Number.isFinite(this.staleMs)) throw new TypeError("FileScheduleStore: staleMs must be a number above 0");
|
|
172
|
+
}
|
|
111
173
|
|
|
112
174
|
async get(id: string): Promise<Schedule | undefined> {
|
|
113
175
|
return (await this.read()).schedules[id];
|
|
@@ -158,7 +220,7 @@ export class FileScheduleStore implements ScheduleStore {
|
|
|
158
220
|
private async read(): Promise<ScheduleFile> {
|
|
159
221
|
let text: string;
|
|
160
222
|
try {
|
|
161
|
-
text = await readFile(this.path, "utf8");
|
|
223
|
+
text = await retried(() => readFile(this.path, "utf8"));
|
|
162
224
|
} catch (error) {
|
|
163
225
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { schemaVersion: 1, schedules: {} };
|
|
164
226
|
throw error;
|
|
@@ -182,31 +244,111 @@ export class FileScheduleStore implements ScheduleStore {
|
|
|
182
244
|
return { schemaVersion: 1, schedules };
|
|
183
245
|
}
|
|
184
246
|
|
|
185
|
-
/**
|
|
247
|
+
/**
|
|
248
|
+
* One change of the file, after the changes before it, under the lock file. The read under the
|
|
249
|
+
* lock gets the changes of the other processes. `edit` gives false for no change.
|
|
250
|
+
*/
|
|
186
251
|
private change(edit: (file: ScheduleFile) => boolean): Promise<void> {
|
|
187
252
|
const run = this.queue.then(async () => {
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
await rename(temporary, this.path);
|
|
196
|
-
return;
|
|
197
|
-
} catch (error) {
|
|
198
|
-
if (attempt >= 20 || !isContention(error)) {
|
|
199
|
-
await rm(temporary, { force: true });
|
|
200
|
-
throw error;
|
|
201
|
-
}
|
|
202
|
-
await pause(10 * attempt);
|
|
203
|
-
}
|
|
253
|
+
const token = await this.lock();
|
|
254
|
+
try {
|
|
255
|
+
const file = await this.read();
|
|
256
|
+
if (!edit(file)) return;
|
|
257
|
+
await this.write(file);
|
|
258
|
+
} finally {
|
|
259
|
+
await this.unlock(token);
|
|
204
260
|
}
|
|
205
261
|
});
|
|
206
262
|
// A failed change does not stop the next one
|
|
207
263
|
this.queue = run.catch(() => undefined);
|
|
208
264
|
return run;
|
|
209
265
|
}
|
|
266
|
+
|
|
267
|
+
/** This method writes the file through a temporary file and a rename. */
|
|
268
|
+
private async write(file: ScheduleFile): Promise<void> {
|
|
269
|
+
const temporary = `${this.path}.${randomBytes(6).toString("hex")}.tmp`;
|
|
270
|
+
await writeFile(temporary, JSON.stringify(file, null, 2) + "\n", "utf8");
|
|
271
|
+
try {
|
|
272
|
+
await retried(() => rename(temporary, this.path));
|
|
273
|
+
} catch (error) {
|
|
274
|
+
await rm(temporary, { force: true });
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* This method makes the lock file and gives its token. It waits while another store holds the
|
|
281
|
+
* lock, and it takes over a stale lock. After twice `staleMs`, it throws `LOCKED`.
|
|
282
|
+
*/
|
|
283
|
+
private async lock(): Promise<string> {
|
|
284
|
+
const token = `${process.pid}:${randomBytes(8).toString("hex")}`;
|
|
285
|
+
const deadline = Date.now() + 2 * this.staleMs;
|
|
286
|
+
for (let attempt = 0; ; attempt++) {
|
|
287
|
+
try {
|
|
288
|
+
await writeFile(this.lockPath, token, { encoding: "utf8", flag: "wx" });
|
|
289
|
+
return token;
|
|
290
|
+
} catch (error) {
|
|
291
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
292
|
+
if (code === "ENOENT" && attempt === 0) {
|
|
293
|
+
// The folder of the store does not exist yet
|
|
294
|
+
await mkdir(dirname(this.lockPath), { recursive: true });
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (code !== "EEXIST" && !isContention(error)) throw error;
|
|
298
|
+
}
|
|
299
|
+
if (await this.breakStale()) continue;
|
|
300
|
+
if (Date.now() >= deadline) {
|
|
301
|
+
throw new ScheduleError("LOCKED", `The lock file ${this.lockPath} stays held: another process holds it for more than ${2 * this.staleMs} ms`);
|
|
302
|
+
}
|
|
303
|
+
// A short wait with a random part: two waiting processes do not try again at the same time
|
|
304
|
+
await pause(Math.min(50, 2 + attempt) + Math.random() * 5);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** This method removes the lock file when it holds the token of this store. A lock that another store took over stays. */
|
|
309
|
+
private async unlock(token: string): Promise<void> {
|
|
310
|
+
let content: string;
|
|
311
|
+
try {
|
|
312
|
+
content = await retried(() => readFile(this.lockPath, "utf8"));
|
|
313
|
+
} catch {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (content === token) await removeFile(this.lockPath);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** True when the file exists and its last change is older than `staleMs`. */
|
|
320
|
+
private async isStale(path: string): Promise<boolean> {
|
|
321
|
+
try {
|
|
322
|
+
return Date.now() - (await stat(path)).mtimeMs > this.staleMs;
|
|
323
|
+
} catch {
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* This method removes a stale lock file, and gives true then. A second lock file (`.lock.break`)
|
|
330
|
+
* lets one store at a time examine and remove a stale lock. A second store thus finds the new
|
|
331
|
+
* lock of the first store, not the stale lock, and does not remove it.
|
|
332
|
+
*/
|
|
333
|
+
private async breakStale(): Promise<boolean> {
|
|
334
|
+
if (!(await this.isStale(this.lockPath))) return false;
|
|
335
|
+
const breaker = `${this.lockPath}.break`;
|
|
336
|
+
try {
|
|
337
|
+
await writeFile(breaker, String(process.pid), { encoding: "utf8", flag: "wx" });
|
|
338
|
+
} catch {
|
|
339
|
+
// Another store examines the lock now. The breaker of a process that stopped is stale too.
|
|
340
|
+
if (await this.isStale(breaker)) await removeFile(breaker).catch(() => undefined);
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
try {
|
|
344
|
+
// The examination again, under the breaker: another store can have taken over the lock
|
|
345
|
+
if (!(await this.isStale(this.lockPath))) return false;
|
|
346
|
+
await removeFile(this.lockPath);
|
|
347
|
+
return true;
|
|
348
|
+
} finally {
|
|
349
|
+
await removeFile(breaker).catch(() => undefined);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
210
352
|
}
|
|
211
353
|
|
|
212
354
|
/** The store of the default module and of a `Scheduler` without a store: in memory, one for the process. */
|
package/src/types.ts
CHANGED
|
@@ -7,8 +7,11 @@
|
|
|
7
7
|
|
|
8
8
|
import type { PermittedEffects } from "@procedures/client";
|
|
9
9
|
|
|
10
|
-
/**
|
|
11
|
-
|
|
10
|
+
/**
|
|
11
|
+
* The time zone of a cron expression: `utc`, `local` (the time zone of the host), or an IANA name
|
|
12
|
+
* that `Intl` knows, for example `Europe/Berlin`.
|
|
13
|
+
*/
|
|
14
|
+
export type ScheduleTimeZone = "utc" | "local" | (string & {});
|
|
12
15
|
|
|
13
16
|
/** The last run of a schedule. */
|
|
14
17
|
export interface ScheduleRun {
|
|
@@ -31,6 +34,7 @@ export interface Schedule {
|
|
|
31
34
|
program: string;
|
|
32
35
|
/** The input of the program. */
|
|
33
36
|
input?: unknown;
|
|
37
|
+
/** The time zone of the expression: `utc`, `local` or an IANA name. */
|
|
34
38
|
timeZone: ScheduleTimeZone;
|
|
35
39
|
/** When the schedule was added, in milliseconds since 1970. A schedule starts no run for a time before it. */
|
|
36
40
|
createdAt: number;
|
|
@@ -65,7 +69,7 @@ export interface ScheduleAddInput {
|
|
|
65
69
|
/** The dotted key of the stored program. */
|
|
66
70
|
program: string;
|
|
67
71
|
input?: unknown;
|
|
68
|
-
/** The time zone of the expression
|
|
72
|
+
/** The time zone of the expression: `utc`, `local` or an IANA name such as `Europe/Berlin`. The default is `local`. */
|
|
69
73
|
timeZone?: ScheduleTimeZone | undefined;
|
|
70
74
|
/** True: replace a schedule of the same name. The default is false: a second schedule of a name is an error. */
|
|
71
75
|
replace?: boolean | undefined;
|
|
@@ -112,7 +116,7 @@ export interface ScheduleNextInput {
|
|
|
112
116
|
count?: number | undefined;
|
|
113
117
|
/** The times come after this time: an ISO text. The default is now. */
|
|
114
118
|
after?: string | undefined;
|
|
115
|
-
/** The time zone of the expression
|
|
119
|
+
/** The time zone of the expression: `utc`, `local` or an IANA name such as `Europe/Berlin`. The default is `local`. */
|
|
116
120
|
timeZone?: ScheduleTimeZone | undefined;
|
|
117
121
|
}
|
|
118
122
|
|