@procedures/schedule 0.4.0 → 0.6.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +55 -5
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/cron.d.ts +36 -6
  5. package/dist/cron.d.ts.map +1 -1
  6. package/dist/cron.js +265 -41
  7. package/dist/cron.js.map +1 -1
  8. package/dist/errors.d.ts +4 -0
  9. package/dist/errors.d.ts.map +1 -1
  10. package/dist/errors.js.map +1 -1
  11. package/dist/index.d.ts +2 -2
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +2 -2
  14. package/dist/index.js.map +1 -1
  15. package/dist/module.d.ts +5 -0
  16. package/dist/module.d.ts.map +1 -1
  17. package/dist/module.js +8 -2
  18. package/dist/module.js.map +1 -1
  19. package/dist/procedures/schedule/add.d.ts +5 -2
  20. package/dist/procedures/schedule/add.d.ts.map +1 -1
  21. package/dist/procedures/schedule/add.js +15 -10
  22. package/dist/procedures/schedule/add.js.map +1 -1
  23. package/dist/procedures/schedule/list.js +1 -1
  24. package/dist/procedures/schedule/list.js.map +1 -1
  25. package/dist/procedures/schedule/next.d.ts +1 -1
  26. package/dist/procedures/schedule/next.d.ts.map +1 -1
  27. package/dist/procedures/schedule/next.js +2 -2
  28. package/dist/procedures/schedule/next.js.map +1 -1
  29. package/dist/programs.d.ts +3 -1
  30. package/dist/programs.d.ts.map +1 -1
  31. package/dist/programs.js.map +1 -1
  32. package/dist/scheduler.d.ts +9 -0
  33. package/dist/scheduler.d.ts.map +1 -1
  34. package/dist/scheduler.js +11 -6
  35. package/dist/scheduler.js.map +1 -1
  36. package/dist/store.d.ts +50 -5
  37. package/dist/store.d.ts.map +1 -1
  38. package/dist/store.js +161 -26
  39. package/dist/store.js.map +1 -1
  40. package/dist/types.d.ts +8 -4
  41. package/dist/types.d.ts.map +1 -1
  42. package/package.json +4 -4
  43. package/src/cron.ts +277 -48
  44. package/src/errors.ts +4 -0
  45. package/src/index.ts +10 -2
  46. package/src/module.ts +13 -2
  47. package/src/procedures/schedule/add.ts +15 -10
  48. package/src/procedures/schedule/list.ts +1 -1
  49. package/src/procedures/schedule/next.ts +3 -3
  50. package/src/programs.ts +3 -1
  51. package/src/scheduler.ts +16 -6
  52. package/src/store.ts +166 -24
  53. 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 !== "utc" && record.timeZone !== "local") throw new ScheduleError("BAD_STORE", `The schedule ${record.id} needs the time zone utc or local`);
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
- * never sees a part of a write. The writes of this process go one at a time. The store is for
103
- * one process: two processes on one file can lose a write.
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
- /** @param path - The JSON file. The store makes it, and its folder, at the first write. */
110
- constructor(readonly path: string) {}
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
- /** One change of the file, after the changes before it. `edit` gives false for no change. */
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 file = await this.read();
189
- if (!edit(file)) return;
190
- await mkdir(dirname(this.path), { recursive: true });
191
- const temporary = `${this.path}.${randomBytes(6).toString("hex")}.tmp`;
192
- await writeFile(temporary, JSON.stringify(file, null, 2) + "\n", "utf8");
193
- for (let attempt = 1; ; attempt++) {
194
- try {
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
- /** The time zone of a cron expression: the UTC time, or the local time of the host. */
11
- export type ScheduleTimeZone = "utc" | "local";
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. The default is `local`. */
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. The default is `local`. */
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