@sentry/junior-scheduler 0.74.1 → 0.75.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/dist/db/schema.d.ts +223 -0
- package/dist/index.d.ts +1 -2
- package/dist/index.js +595 -444
- package/dist/plugin.d.ts +1 -1
- package/dist/schedule-tools.d.ts +9 -8
- package/dist/store.d.ts +17 -3
- package/dist/types.d.ts +2 -5
- package/migrations/0001_scheduler.sql +35 -0
- package/package.json +6 -2
- package/src/db/schema.ts +53 -0
- package/src/index.ts +5 -2
- package/src/plugin.ts +97 -21
- package/src/schedule-tools.ts +57 -29
- package/src/store.ts +822 -47
- package/src/types.ts +2 -5
- package/dist/prompt.d.ts +0 -7
- package/src/prompt.ts +0 -90
package/src/store.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
2
|
+
pluginCredentialSubjectSchema,
|
|
3
3
|
destinationSchema,
|
|
4
4
|
isSlackDestination,
|
|
5
|
-
type
|
|
6
|
-
type
|
|
5
|
+
type PluginDb,
|
|
6
|
+
type PluginReadState,
|
|
7
|
+
type PluginState,
|
|
7
8
|
} from "@sentry/junior-plugin-api";
|
|
9
|
+
import { z } from "zod";
|
|
8
10
|
import { getNextRunAtMs } from "./cadence";
|
|
9
11
|
import type { ScheduledRun, ScheduledTask } from "./types";
|
|
10
12
|
|
|
@@ -15,6 +17,101 @@ const CLAIM_TTL_MS = 6 * 60 * 60 * 1000;
|
|
|
15
17
|
const PENDING_CLAIM_STALE_MS = 60_000;
|
|
16
18
|
const MISSED_RUN_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
17
19
|
const LOCK_TTL_MS = 10_000;
|
|
20
|
+
const SQL_INCOMPLETE_RUN_STATUSES = ["pending", "running"] as const;
|
|
21
|
+
const slackDestinationSchema = destinationSchema.refine(isSlackDestination);
|
|
22
|
+
const taskPrincipalSchema = z
|
|
23
|
+
.object({
|
|
24
|
+
slackUserId: z.string(),
|
|
25
|
+
fullName: z.string().optional(),
|
|
26
|
+
userName: z.string().optional(),
|
|
27
|
+
})
|
|
28
|
+
.strict();
|
|
29
|
+
const recurrenceSchema = z
|
|
30
|
+
.object({
|
|
31
|
+
dayOfMonth: z.number().optional(),
|
|
32
|
+
frequency: z.enum(["daily", "weekly", "monthly", "yearly"]),
|
|
33
|
+
interval: z.number(),
|
|
34
|
+
month: z.number().optional(),
|
|
35
|
+
startDate: z.string(),
|
|
36
|
+
time: z
|
|
37
|
+
.object({
|
|
38
|
+
hour: z.number(),
|
|
39
|
+
minute: z.number(),
|
|
40
|
+
})
|
|
41
|
+
.strict(),
|
|
42
|
+
weekdays: z.array(z.number()).optional(),
|
|
43
|
+
})
|
|
44
|
+
.strict();
|
|
45
|
+
const taskScheduleSchema = z
|
|
46
|
+
.object({
|
|
47
|
+
description: z.string(),
|
|
48
|
+
kind: z.enum(["one_off", "recurring"]),
|
|
49
|
+
recurrence: recurrenceSchema.optional(),
|
|
50
|
+
timezone: z.string(),
|
|
51
|
+
})
|
|
52
|
+
.strict();
|
|
53
|
+
const taskSpecSchema = z
|
|
54
|
+
.object({
|
|
55
|
+
text: z.string(),
|
|
56
|
+
})
|
|
57
|
+
.strict();
|
|
58
|
+
const taskRecordSchema = z
|
|
59
|
+
.object({
|
|
60
|
+
id: z.string(),
|
|
61
|
+
conversationAccess: z
|
|
62
|
+
.object({
|
|
63
|
+
audience: z.enum(["direct", "group", "channel"]),
|
|
64
|
+
visibility: z.enum(["private", "public", "unknown"]),
|
|
65
|
+
})
|
|
66
|
+
.strict()
|
|
67
|
+
.optional(),
|
|
68
|
+
createdAtMs: z.number(),
|
|
69
|
+
createdBy: taskPrincipalSchema,
|
|
70
|
+
credentialSubject: pluginCredentialSubjectSchema.optional(),
|
|
71
|
+
destination: slackDestinationSchema,
|
|
72
|
+
executionActor: z
|
|
73
|
+
.object({
|
|
74
|
+
type: z.literal("system"),
|
|
75
|
+
id: z.string(),
|
|
76
|
+
})
|
|
77
|
+
.strict()
|
|
78
|
+
.optional(),
|
|
79
|
+
lastRunAtMs: z.number().optional(),
|
|
80
|
+
nextRunAtMs: z.number().optional(),
|
|
81
|
+
originalRequest: z.string().optional(),
|
|
82
|
+
runNowAtMs: z.number().optional(),
|
|
83
|
+
schedule: taskScheduleSchema,
|
|
84
|
+
status: z.enum(["active", "paused", "blocked", "deleted"]),
|
|
85
|
+
statusReason: z.string().optional(),
|
|
86
|
+
task: taskSpecSchema,
|
|
87
|
+
updatedAtMs: z.number(),
|
|
88
|
+
version: z.number().optional(),
|
|
89
|
+
})
|
|
90
|
+
.strict();
|
|
91
|
+
const runRecordSchema = z
|
|
92
|
+
.object({
|
|
93
|
+
id: z.string(),
|
|
94
|
+
attempt: z.number(),
|
|
95
|
+
claimedAtMs: z.number(),
|
|
96
|
+
completedAtMs: z.number().optional(),
|
|
97
|
+
dispatchId: z.string().optional(),
|
|
98
|
+
errorMessage: z.string().optional(),
|
|
99
|
+
idempotencyKey: z.string().optional(),
|
|
100
|
+
resultMessageTs: z.string().optional(),
|
|
101
|
+
scheduledForMs: z.number(),
|
|
102
|
+
startedAtMs: z.number().optional(),
|
|
103
|
+
status: z.enum([
|
|
104
|
+
"pending",
|
|
105
|
+
"running",
|
|
106
|
+
"completed",
|
|
107
|
+
"failed",
|
|
108
|
+
"blocked",
|
|
109
|
+
"skipped",
|
|
110
|
+
]),
|
|
111
|
+
taskId: z.string(),
|
|
112
|
+
taskVersion: z.number().optional(),
|
|
113
|
+
})
|
|
114
|
+
.strict();
|
|
18
115
|
|
|
19
116
|
export interface SchedulerStore {
|
|
20
117
|
claimDueRun(args: { nowMs: number }): Promise<ScheduledRun | undefined>;
|
|
@@ -107,7 +204,7 @@ function unique(values: string[]): string[] {
|
|
|
107
204
|
}
|
|
108
205
|
|
|
109
206
|
async function withLock<T>(
|
|
110
|
-
state:
|
|
207
|
+
state: PluginState,
|
|
111
208
|
key: string,
|
|
112
209
|
callback: () => Promise<T>,
|
|
113
210
|
): Promise<T> {
|
|
@@ -115,7 +212,7 @@ async function withLock<T>(
|
|
|
115
212
|
}
|
|
116
213
|
|
|
117
214
|
async function addToIndex(
|
|
118
|
-
state:
|
|
215
|
+
state: PluginState,
|
|
119
216
|
key: string,
|
|
120
217
|
taskId: string,
|
|
121
218
|
): Promise<void> {
|
|
@@ -128,7 +225,7 @@ async function addToIndex(
|
|
|
128
225
|
}
|
|
129
226
|
|
|
130
227
|
async function removeFromIndex(
|
|
131
|
-
state:
|
|
228
|
+
state: PluginState,
|
|
132
229
|
key: string,
|
|
133
230
|
taskId: string,
|
|
134
231
|
): Promise<void> {
|
|
@@ -151,7 +248,7 @@ async function removeFromIndex(
|
|
|
151
248
|
}
|
|
152
249
|
|
|
153
250
|
async function getIndex(
|
|
154
|
-
state:
|
|
251
|
+
state: PluginReadState,
|
|
155
252
|
key: string,
|
|
156
253
|
): Promise<string[]> {
|
|
157
254
|
const values = (await state.get<string[]>(key)) ?? [];
|
|
@@ -161,7 +258,7 @@ async function getIndex(
|
|
|
161
258
|
}
|
|
162
259
|
|
|
163
260
|
async function clearActiveRun(
|
|
164
|
-
state:
|
|
261
|
+
state: PluginState,
|
|
165
262
|
taskId: string,
|
|
166
263
|
runId: string,
|
|
167
264
|
): Promise<void> {
|
|
@@ -174,7 +271,7 @@ async function clearActiveRun(
|
|
|
174
271
|
}
|
|
175
272
|
|
|
176
273
|
async function clearStaleActiveRun(
|
|
177
|
-
state:
|
|
274
|
+
state: PluginState,
|
|
178
275
|
taskId: string,
|
|
179
276
|
nowMs: number,
|
|
180
277
|
): Promise<boolean> {
|
|
@@ -188,8 +285,7 @@ async function clearStaleActiveRun(
|
|
|
188
285
|
return true;
|
|
189
286
|
}
|
|
190
287
|
|
|
191
|
-
const activeRun =
|
|
192
|
-
(await state.get<ScheduledRun>(runKey(active.runId))) ?? undefined;
|
|
288
|
+
const activeRun = parseStoredRun(await state.get(runKey(active.runId)));
|
|
193
289
|
if (!isStaleActiveRun(active, activeRun, nowMs)) {
|
|
194
290
|
return false;
|
|
195
291
|
}
|
|
@@ -276,16 +372,13 @@ function buildScheduledRun(args: {
|
|
|
276
372
|
scheduledForMs: number;
|
|
277
373
|
task: ScheduledTask;
|
|
278
374
|
}): ScheduledRun {
|
|
279
|
-
const idempotencyKey = `${args.task.id}:${args.scheduledForMs}`;
|
|
280
375
|
return {
|
|
281
376
|
id: buildRunId(args.task.id, args.scheduledForMs),
|
|
282
377
|
attempt: 1,
|
|
283
378
|
claimedAtMs: args.claimedAtMs,
|
|
284
|
-
idempotencyKey,
|
|
285
379
|
scheduledForMs: args.scheduledForMs,
|
|
286
380
|
status: "pending",
|
|
287
381
|
taskId: args.task.id,
|
|
288
|
-
taskVersion: args.task.version,
|
|
289
382
|
};
|
|
290
383
|
}
|
|
291
384
|
|
|
@@ -358,27 +451,55 @@ function canFinishRun(
|
|
|
358
451
|
return run.status === "running" && run.startedAtMs === startedAtMs;
|
|
359
452
|
}
|
|
360
453
|
|
|
454
|
+
/** Decode retained scheduler task state, skipping invalid legacy records. */
|
|
361
455
|
function parseStoredTask(value: unknown): ScheduledTask | undefined {
|
|
362
|
-
|
|
363
|
-
|
|
456
|
+
const parsed = taskRecordSchema.safeParse(parseJsonRecord(value));
|
|
457
|
+
return parsed.success ? stripLegacyTaskFields(parsed.data) : undefined;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Decode retained scheduler run state, skipping invalid legacy records. */
|
|
461
|
+
function parseStoredRun(value: unknown): ScheduledRun | undefined {
|
|
462
|
+
const parsed = runRecordSchema.safeParse(parseJsonRecord(value));
|
|
463
|
+
return parsed.success ? stripLegacyRunFields(parsed.data) : undefined;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function stripLegacyTaskFields(
|
|
467
|
+
task: ScheduledTask & { version?: number },
|
|
468
|
+
): ScheduledTask {
|
|
469
|
+
const { version: _version, ...current } = task;
|
|
470
|
+
return current;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function stripLegacyRunFields(
|
|
474
|
+
run: ScheduledRun & {
|
|
475
|
+
idempotencyKey?: string;
|
|
476
|
+
taskVersion?: number;
|
|
477
|
+
},
|
|
478
|
+
): ScheduledRun {
|
|
479
|
+
const {
|
|
480
|
+
idempotencyKey: _idempotencyKey,
|
|
481
|
+
taskVersion: _taskVersion,
|
|
482
|
+
...current
|
|
483
|
+
} = run;
|
|
484
|
+
return current;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function parseJsonRecord<T>(value: unknown): T | undefined {
|
|
488
|
+
if (typeof value === "string") {
|
|
489
|
+
try {
|
|
490
|
+
return JSON.parse(value) as T;
|
|
491
|
+
} catch {
|
|
492
|
+
return undefined;
|
|
493
|
+
}
|
|
364
494
|
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
if (!destination.success || !isSlackDestination(destination.data)) {
|
|
368
|
-
return undefined;
|
|
495
|
+
if (value && typeof value === "object") {
|
|
496
|
+
return value as T;
|
|
369
497
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
return undefined;
|
|
376
|
-
}
|
|
377
|
-
return {
|
|
378
|
-
...(record as ScheduledTask),
|
|
379
|
-
destination: destination.data,
|
|
380
|
-
...(credentialSubject ? { credentialSubject: credentialSubject.data } : {}),
|
|
381
|
-
};
|
|
498
|
+
return undefined;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function present<T>(value: T | undefined): value is T {
|
|
502
|
+
return value !== undefined;
|
|
382
503
|
}
|
|
383
504
|
|
|
384
505
|
function requireStoredTask(task: ScheduledTask): ScheduledTask {
|
|
@@ -390,14 +511,14 @@ function requireStoredTask(task: ScheduledTask): ScheduledTask {
|
|
|
390
511
|
}
|
|
391
512
|
|
|
392
513
|
async function getTaskFromState(
|
|
393
|
-
state:
|
|
514
|
+
state: PluginReadState,
|
|
394
515
|
taskId: string,
|
|
395
516
|
): Promise<ScheduledTask | undefined> {
|
|
396
517
|
return parseStoredTask(await state.get(taskKey(taskId)));
|
|
397
518
|
}
|
|
398
519
|
|
|
399
520
|
async function listTasksFromState(
|
|
400
|
-
state:
|
|
521
|
+
state: PluginReadState,
|
|
401
522
|
indexKey: string,
|
|
402
523
|
): Promise<ScheduledTask[]> {
|
|
403
524
|
const ids = await getIndex(state, indexKey);
|
|
@@ -409,14 +530,14 @@ async function listTasksFromState(
|
|
|
409
530
|
}
|
|
410
531
|
|
|
411
532
|
async function getRunFromState(
|
|
412
|
-
state:
|
|
533
|
+
state: PluginReadState,
|
|
413
534
|
runId: string,
|
|
414
535
|
): Promise<ScheduledRun | undefined> {
|
|
415
|
-
return (await state.get
|
|
536
|
+
return parseStoredRun(await state.get(runKey(runId)));
|
|
416
537
|
}
|
|
417
538
|
|
|
418
539
|
async function listIncompleteRunsForTasksFromState(
|
|
419
|
-
state:
|
|
540
|
+
state: PluginReadState,
|
|
420
541
|
tasks: ScheduledTask[],
|
|
421
542
|
): Promise<ScheduledRun[]> {
|
|
422
543
|
const runs: ScheduledRun[] = [];
|
|
@@ -434,9 +555,9 @@ async function listIncompleteRunsForTasksFromState(
|
|
|
434
555
|
}
|
|
435
556
|
|
|
436
557
|
class PluginStateSchedulerOperationalStore implements SchedulerOperationalStore {
|
|
437
|
-
private readonly state:
|
|
558
|
+
private readonly state: PluginReadState;
|
|
438
559
|
|
|
439
|
-
constructor(state:
|
|
560
|
+
constructor(state: PluginReadState) {
|
|
440
561
|
this.state = state;
|
|
441
562
|
}
|
|
442
563
|
|
|
@@ -452,9 +573,9 @@ class PluginStateSchedulerOperationalStore implements SchedulerOperationalStore
|
|
|
452
573
|
}
|
|
453
574
|
|
|
454
575
|
class PluginStateSchedulerStore implements SchedulerStore {
|
|
455
|
-
private readonly state:
|
|
576
|
+
private readonly state: PluginState;
|
|
456
577
|
|
|
457
|
-
constructor(state:
|
|
578
|
+
constructor(state: PluginState) {
|
|
458
579
|
this.state = state;
|
|
459
580
|
}
|
|
460
581
|
|
|
@@ -648,7 +769,6 @@ class PluginStateSchedulerStore implements SchedulerStore {
|
|
|
648
769
|
status: nextStatus,
|
|
649
770
|
statusReason: nextStatus === "paused" ? errorMessage : undefined,
|
|
650
771
|
updatedAtMs: args.nowMs,
|
|
651
|
-
version: current.version + 1,
|
|
652
772
|
},
|
|
653
773
|
current,
|
|
654
774
|
);
|
|
@@ -834,7 +954,6 @@ class PluginStateSchedulerStore implements SchedulerStore {
|
|
|
834
954
|
statusReason:
|
|
835
955
|
args.status === "blocked" ? args.errorMessage : undefined,
|
|
836
956
|
updatedAtMs: args.nowMs,
|
|
837
|
-
version: current.version + 1,
|
|
838
957
|
},
|
|
839
958
|
current,
|
|
840
959
|
);
|
|
@@ -850,7 +969,6 @@ class PluginStateSchedulerStore implements SchedulerStore {
|
|
|
850
969
|
...current,
|
|
851
970
|
lastRunAtMs: args.run.scheduledForMs,
|
|
852
971
|
updatedAtMs: args.nowMs,
|
|
853
|
-
version: current.version + 1,
|
|
854
972
|
},
|
|
855
973
|
current,
|
|
856
974
|
);
|
|
@@ -876,7 +994,6 @@ class PluginStateSchedulerStore implements SchedulerStore {
|
|
|
876
994
|
statusReason:
|
|
877
995
|
args.status === "blocked" ? args.errorMessage : undefined,
|
|
878
996
|
updatedAtMs: args.nowMs,
|
|
879
|
-
version: current.version + 1,
|
|
880
997
|
},
|
|
881
998
|
current,
|
|
882
999
|
);
|
|
@@ -903,13 +1020,671 @@ class PluginStateSchedulerStore implements SchedulerStore {
|
|
|
903
1020
|
}
|
|
904
1021
|
|
|
905
1022
|
/** Create a scheduler store backed by this plugin's durable state namespace. */
|
|
906
|
-
export function createSchedulerStore(state:
|
|
1023
|
+
export function createSchedulerStore(state: PluginState): SchedulerStore {
|
|
907
1024
|
return new PluginStateSchedulerStore(state);
|
|
908
1025
|
}
|
|
909
1026
|
|
|
910
1027
|
/** Create a read-only scheduler store for operational reporting. */
|
|
911
1028
|
export function createSchedulerOperationalStore(
|
|
912
|
-
state:
|
|
1029
|
+
state: PluginReadState,
|
|
913
1030
|
): SchedulerOperationalStore {
|
|
914
1031
|
return new PluginStateSchedulerOperationalStore(state);
|
|
915
1032
|
}
|
|
1033
|
+
|
|
1034
|
+
type SchedulerTaskRow = {
|
|
1035
|
+
record: unknown;
|
|
1036
|
+
};
|
|
1037
|
+
|
|
1038
|
+
type SchedulerRunRow = {
|
|
1039
|
+
record: unknown;
|
|
1040
|
+
};
|
|
1041
|
+
|
|
1042
|
+
/** Decode scheduler SQL task records and reject rows unsafe for scan paths. */
|
|
1043
|
+
function parseSqlTaskRecord(value: unknown): ScheduledTask | undefined {
|
|
1044
|
+
const parsed = taskRecordSchema.safeParse(parseJsonRecord(value));
|
|
1045
|
+
return parsed.success ? stripLegacyTaskFields(parsed.data) : undefined;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function parseSqlTaskRow(row: SchedulerTaskRow): ScheduledTask | undefined {
|
|
1049
|
+
return parseSqlTaskRecord(row.record);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/** Decode scheduler SQL run records and reject rows unsafe for scan paths. */
|
|
1053
|
+
function parseSqlRunRow(row: SchedulerRunRow): ScheduledRun | undefined {
|
|
1054
|
+
const parsed = runRecordSchema.safeParse(parseJsonRecord(row.record));
|
|
1055
|
+
return parsed.success ? stripLegacyRunFields(parsed.data) : undefined;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
function json(value: unknown): string {
|
|
1059
|
+
return JSON.stringify(value);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
async function withSqlLock<T>(
|
|
1063
|
+
db: PluginDb,
|
|
1064
|
+
key: string,
|
|
1065
|
+
callback: (db: PluginDb) => Promise<T>,
|
|
1066
|
+
): Promise<T> {
|
|
1067
|
+
return await db.transaction(async (tx) => {
|
|
1068
|
+
await tx.execute("SELECT pg_advisory_xact_lock(hashtext($1))", [key]);
|
|
1069
|
+
return await callback(tx);
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
async function upsertSqlTask(db: PluginDb, task: ScheduledTask): Promise<void> {
|
|
1074
|
+
await db.execute(
|
|
1075
|
+
`
|
|
1076
|
+
INSERT INTO junior_scheduler_tasks (
|
|
1077
|
+
id,
|
|
1078
|
+
team_id,
|
|
1079
|
+
status,
|
|
1080
|
+
next_run_at_ms,
|
|
1081
|
+
run_now_at_ms,
|
|
1082
|
+
created_at_ms,
|
|
1083
|
+
record
|
|
1084
|
+
) VALUES (
|
|
1085
|
+
$1, $2, $3, $4, $5, $6, $7::jsonb
|
|
1086
|
+
)
|
|
1087
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
1088
|
+
team_id = EXCLUDED.team_id,
|
|
1089
|
+
status = EXCLUDED.status,
|
|
1090
|
+
next_run_at_ms = EXCLUDED.next_run_at_ms,
|
|
1091
|
+
run_now_at_ms = EXCLUDED.run_now_at_ms,
|
|
1092
|
+
created_at_ms = EXCLUDED.created_at_ms,
|
|
1093
|
+
record = EXCLUDED.record
|
|
1094
|
+
`,
|
|
1095
|
+
[
|
|
1096
|
+
task.id,
|
|
1097
|
+
task.destination.teamId,
|
|
1098
|
+
task.status,
|
|
1099
|
+
task.nextRunAtMs ?? null,
|
|
1100
|
+
task.runNowAtMs ?? null,
|
|
1101
|
+
task.createdAtMs,
|
|
1102
|
+
json(task),
|
|
1103
|
+
],
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
async function upsertSqlRun(db: PluginDb, run: ScheduledRun): Promise<void> {
|
|
1108
|
+
await db.execute(
|
|
1109
|
+
`
|
|
1110
|
+
INSERT INTO junior_scheduler_runs (
|
|
1111
|
+
id,
|
|
1112
|
+
task_id,
|
|
1113
|
+
status,
|
|
1114
|
+
scheduled_for_ms,
|
|
1115
|
+
record
|
|
1116
|
+
) VALUES (
|
|
1117
|
+
$1, $2, $3, $4, $5::jsonb
|
|
1118
|
+
)
|
|
1119
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
1120
|
+
task_id = EXCLUDED.task_id,
|
|
1121
|
+
status = EXCLUDED.status,
|
|
1122
|
+
scheduled_for_ms = EXCLUDED.scheduled_for_ms,
|
|
1123
|
+
record = EXCLUDED.record
|
|
1124
|
+
`,
|
|
1125
|
+
[run.id, run.taskId, run.status, run.scheduledForMs, json(run)],
|
|
1126
|
+
);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
async function getTaskFromSql(
|
|
1130
|
+
db: PluginDb,
|
|
1131
|
+
taskId: string,
|
|
1132
|
+
): Promise<ScheduledTask | undefined> {
|
|
1133
|
+
const rows = await db.query<SchedulerTaskRow>(
|
|
1134
|
+
"SELECT record FROM junior_scheduler_tasks WHERE id = $1",
|
|
1135
|
+
[taskId],
|
|
1136
|
+
);
|
|
1137
|
+
return rows[0] ? parseSqlTaskRow(rows[0]) : undefined;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
async function getRunFromSql(
|
|
1141
|
+
db: PluginDb,
|
|
1142
|
+
runId: string,
|
|
1143
|
+
): Promise<ScheduledRun | undefined> {
|
|
1144
|
+
const rows = await db.query<SchedulerRunRow>(
|
|
1145
|
+
"SELECT record FROM junior_scheduler_runs WHERE id = $1",
|
|
1146
|
+
[runId],
|
|
1147
|
+
);
|
|
1148
|
+
return rows[0] ? parseSqlRunRow(rows[0]) : undefined;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
async function getClaimedRunSlotFromSql(
|
|
1152
|
+
db: PluginDb,
|
|
1153
|
+
runId: string,
|
|
1154
|
+
): Promise<ScheduledRun | undefined> {
|
|
1155
|
+
const rows = await db.query<SchedulerRunRow>(
|
|
1156
|
+
"SELECT record FROM junior_scheduler_runs WHERE id = $1",
|
|
1157
|
+
[runId],
|
|
1158
|
+
);
|
|
1159
|
+
return rows[0] ? parseSqlRunRow(rows[0]) : undefined;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
async function listTasksFromSql(db: PluginDb): Promise<ScheduledTask[]> {
|
|
1163
|
+
const rows = await db.query<SchedulerTaskRow>(
|
|
1164
|
+
`
|
|
1165
|
+
SELECT record
|
|
1166
|
+
FROM junior_scheduler_tasks
|
|
1167
|
+
WHERE status <> 'deleted'
|
|
1168
|
+
ORDER BY created_at_ms ASC, id ASC
|
|
1169
|
+
`,
|
|
1170
|
+
);
|
|
1171
|
+
return rows.map(parseSqlTaskRow).filter(present);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
async function listTasksForTeamFromSql(
|
|
1175
|
+
db: PluginDb,
|
|
1176
|
+
teamId: string,
|
|
1177
|
+
): Promise<ScheduledTask[]> {
|
|
1178
|
+
const rows = await db.query<SchedulerTaskRow>(
|
|
1179
|
+
`
|
|
1180
|
+
SELECT record
|
|
1181
|
+
FROM junior_scheduler_tasks
|
|
1182
|
+
WHERE team_id = $1
|
|
1183
|
+
AND status <> 'deleted'
|
|
1184
|
+
ORDER BY created_at_ms ASC, id ASC
|
|
1185
|
+
`,
|
|
1186
|
+
[teamId],
|
|
1187
|
+
);
|
|
1188
|
+
return rows.map(parseSqlTaskRow).filter(present);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
async function listIncompleteRunsForTasksFromSql(
|
|
1192
|
+
db: PluginDb,
|
|
1193
|
+
tasks: ScheduledTask[],
|
|
1194
|
+
): Promise<ScheduledRun[]> {
|
|
1195
|
+
if (tasks.length === 0) {
|
|
1196
|
+
return [];
|
|
1197
|
+
}
|
|
1198
|
+
const rows = await db.query<SchedulerRunRow>(
|
|
1199
|
+
`
|
|
1200
|
+
SELECT record
|
|
1201
|
+
FROM junior_scheduler_runs
|
|
1202
|
+
WHERE task_id = ANY($1)
|
|
1203
|
+
AND status = ANY($2)
|
|
1204
|
+
ORDER BY scheduled_for_ms ASC, id ASC
|
|
1205
|
+
`,
|
|
1206
|
+
[tasks.map((task) => task.id), [...SQL_INCOMPLETE_RUN_STATUSES]],
|
|
1207
|
+
);
|
|
1208
|
+
return rows.map(parseSqlRunRow).filter(present);
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
class SqlSchedulerStore implements SchedulerStore, SchedulerOperationalStore {
|
|
1212
|
+
constructor(private readonly db: PluginDb) {}
|
|
1213
|
+
|
|
1214
|
+
async saveTask(task: ScheduledTask): Promise<void> {
|
|
1215
|
+
const next = requireStoredTask(task);
|
|
1216
|
+
await withSqlLock(this.db, taskLockKey(task.id), async (db) => {
|
|
1217
|
+
const current = await getTaskFromSql(db, task.id);
|
|
1218
|
+
await this.saveTaskRecord(db, next, current);
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
private async saveTaskRecord(
|
|
1223
|
+
db: PluginDb,
|
|
1224
|
+
task: ScheduledTask,
|
|
1225
|
+
current: ScheduledTask | undefined,
|
|
1226
|
+
): Promise<void> {
|
|
1227
|
+
// Reactivation intentionally forgets the blocked slot so authorization or
|
|
1228
|
+
// configuration fixes can dispatch the same scheduled occurrence again.
|
|
1229
|
+
if (
|
|
1230
|
+
current?.status === "blocked" &&
|
|
1231
|
+
task.status === "active" &&
|
|
1232
|
+
typeof task.nextRunAtMs === "number" &&
|
|
1233
|
+
Number.isFinite(task.nextRunAtMs)
|
|
1234
|
+
) {
|
|
1235
|
+
await db.execute(
|
|
1236
|
+
"DELETE FROM junior_scheduler_runs WHERE id = $1 AND status = 'blocked'",
|
|
1237
|
+
[buildRunId(task.id, task.nextRunAtMs)],
|
|
1238
|
+
);
|
|
1239
|
+
}
|
|
1240
|
+
await upsertSqlTask(db, task);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
async getTask(taskId: string): Promise<ScheduledTask | undefined> {
|
|
1244
|
+
return await getTaskFromSql(this.db, taskId);
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
async listTasks(): Promise<ScheduledTask[]> {
|
|
1248
|
+
return await listTasksFromSql(this.db);
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
async listTasksForTeam(teamId: string): Promise<ScheduledTask[]> {
|
|
1252
|
+
return await listTasksForTeamFromSql(this.db, teamId);
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
async claimDueRun(args: {
|
|
1256
|
+
nowMs: number;
|
|
1257
|
+
}): Promise<ScheduledRun | undefined> {
|
|
1258
|
+
return await withSqlLock(this.db, "junior:scheduler:claim", async (db) => {
|
|
1259
|
+
const rows = await db.query<SchedulerTaskRow>(
|
|
1260
|
+
`
|
|
1261
|
+
SELECT record
|
|
1262
|
+
FROM junior_scheduler_tasks
|
|
1263
|
+
WHERE status = 'active'
|
|
1264
|
+
AND (
|
|
1265
|
+
(run_now_at_ms IS NOT NULL AND run_now_at_ms <= $1)
|
|
1266
|
+
OR (next_run_at_ms IS NOT NULL AND next_run_at_ms <= $1)
|
|
1267
|
+
)
|
|
1268
|
+
ORDER BY created_at_ms ASC, id ASC
|
|
1269
|
+
`,
|
|
1270
|
+
[args.nowMs],
|
|
1271
|
+
);
|
|
1272
|
+
|
|
1273
|
+
for (const row of rows) {
|
|
1274
|
+
const task = parseSqlTaskRow(row);
|
|
1275
|
+
if (!task) {
|
|
1276
|
+
continue;
|
|
1277
|
+
}
|
|
1278
|
+
const scheduledForMs = getDueRunAtMs(task, args.nowMs);
|
|
1279
|
+
if (scheduledForMs === undefined) {
|
|
1280
|
+
continue;
|
|
1281
|
+
}
|
|
1282
|
+
const runId = buildRunId(task.id, scheduledForMs);
|
|
1283
|
+
const incompleteRuns = await listIncompleteRunsForTasksFromSql(db, [
|
|
1284
|
+
task,
|
|
1285
|
+
]);
|
|
1286
|
+
const incompleteRun = incompleteRuns.find((run) => run.id === runId);
|
|
1287
|
+
const blockingRun = incompleteRuns.find(
|
|
1288
|
+
(run) => run.id !== runId && !isStalePendingRun(run, args.nowMs),
|
|
1289
|
+
);
|
|
1290
|
+
if (blockingRun) {
|
|
1291
|
+
continue;
|
|
1292
|
+
}
|
|
1293
|
+
if (incompleteRun) {
|
|
1294
|
+
if (!isStalePendingRun(incompleteRun, args.nowMs)) {
|
|
1295
|
+
continue;
|
|
1296
|
+
}
|
|
1297
|
+
const reclaimed = {
|
|
1298
|
+
...incompleteRun,
|
|
1299
|
+
attempt: incompleteRun.attempt + 1,
|
|
1300
|
+
claimedAtMs: args.nowMs,
|
|
1301
|
+
};
|
|
1302
|
+
await upsertSqlRun(db, reclaimed);
|
|
1303
|
+
return reclaimed;
|
|
1304
|
+
}
|
|
1305
|
+
const existingRun = await getClaimedRunSlotFromSql(db, runId);
|
|
1306
|
+
if (existingRun) {
|
|
1307
|
+
continue;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
if (isMissedRunTooOld({ nowMs: args.nowMs, scheduledForMs })) {
|
|
1311
|
+
await this.skipMissedRun(db, {
|
|
1312
|
+
nowMs: args.nowMs,
|
|
1313
|
+
scheduledForMs,
|
|
1314
|
+
task,
|
|
1315
|
+
});
|
|
1316
|
+
continue;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
const run = buildScheduledRun({
|
|
1320
|
+
claimedAtMs: args.nowMs,
|
|
1321
|
+
scheduledForMs,
|
|
1322
|
+
task,
|
|
1323
|
+
});
|
|
1324
|
+
await upsertSqlRun(db, run);
|
|
1325
|
+
return run;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
return undefined;
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
private async skipMissedRun(
|
|
1333
|
+
db: PluginDb,
|
|
1334
|
+
args: {
|
|
1335
|
+
nowMs: number;
|
|
1336
|
+
scheduledForMs: number;
|
|
1337
|
+
task: ScheduledTask;
|
|
1338
|
+
},
|
|
1339
|
+
): Promise<void> {
|
|
1340
|
+
const current = await getTaskFromSql(db, args.task.id);
|
|
1341
|
+
if (
|
|
1342
|
+
!current ||
|
|
1343
|
+
current.status !== "active" ||
|
|
1344
|
+
getDueRunAtMs(current, args.nowMs) !== args.scheduledForMs
|
|
1345
|
+
) {
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
const duplicateOf = await this.findStaleRecoveryCanonicalTask(db, current);
|
|
1350
|
+
const errorMessage = duplicateOf
|
|
1351
|
+
? `Duplicate stale scheduled task was skipped without dispatch. Canonical task: ${duplicateOf.id}.`
|
|
1352
|
+
: "Scheduled occurrence was more than 24 hours late and was skipped without dispatch.";
|
|
1353
|
+
await upsertSqlRun(
|
|
1354
|
+
db,
|
|
1355
|
+
buildSkippedScheduledRun({
|
|
1356
|
+
completedAtMs: args.nowMs,
|
|
1357
|
+
errorMessage,
|
|
1358
|
+
scheduledForMs: args.scheduledForMs,
|
|
1359
|
+
task: current,
|
|
1360
|
+
}),
|
|
1361
|
+
);
|
|
1362
|
+
|
|
1363
|
+
const isRunNow = current.runNowAtMs === args.scheduledForMs;
|
|
1364
|
+
let nextRunAtMs: number | undefined;
|
|
1365
|
+
if (!duplicateOf) {
|
|
1366
|
+
nextRunAtMs =
|
|
1367
|
+
isRunNow && current.nextRunAtMs !== args.scheduledForMs
|
|
1368
|
+
? current.nextRunAtMs
|
|
1369
|
+
: current.schedule.kind === "recurring"
|
|
1370
|
+
? getNextRunAtMs(current, args.scheduledForMs, args.nowMs)
|
|
1371
|
+
: undefined;
|
|
1372
|
+
}
|
|
1373
|
+
const nextStatus = nextRunAtMs ? "active" : "paused";
|
|
1374
|
+
|
|
1375
|
+
await this.saveTaskRecord(
|
|
1376
|
+
db,
|
|
1377
|
+
{
|
|
1378
|
+
...current,
|
|
1379
|
+
nextRunAtMs,
|
|
1380
|
+
runNowAtMs: isRunNow ? undefined : current.runNowAtMs,
|
|
1381
|
+
status: nextStatus,
|
|
1382
|
+
statusReason: nextStatus === "paused" ? errorMessage : undefined,
|
|
1383
|
+
updatedAtMs: args.nowMs,
|
|
1384
|
+
},
|
|
1385
|
+
current,
|
|
1386
|
+
);
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
private async findStaleRecoveryCanonicalTask(
|
|
1390
|
+
db: PluginDb,
|
|
1391
|
+
task: ScheduledTask,
|
|
1392
|
+
): Promise<ScheduledTask | undefined> {
|
|
1393
|
+
const fingerprint = taskDedupeFingerprint(task);
|
|
1394
|
+
const tasks = await listTasksForTeamFromSql(db, task.destination.teamId);
|
|
1395
|
+
return tasks
|
|
1396
|
+
.filter((candidate) => candidate.id !== task.id)
|
|
1397
|
+
.filter(
|
|
1398
|
+
(candidate) =>
|
|
1399
|
+
candidate.status === "active" &&
|
|
1400
|
+
isEarlierTask(candidate, task) &&
|
|
1401
|
+
taskDedupeFingerprint(candidate) === fingerprint,
|
|
1402
|
+
)
|
|
1403
|
+
.sort((a, b) => a.createdAtMs - b.createdAtMs || a.id.localeCompare(b.id))
|
|
1404
|
+
.at(0);
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
async getRun(runId: string): Promise<ScheduledRun | undefined> {
|
|
1408
|
+
return await getRunFromSql(this.db, runId);
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
async listIncompleteRuns(): Promise<ScheduledRun[]> {
|
|
1412
|
+
return await listIncompleteRunsForTasksFromSql(
|
|
1413
|
+
this.db,
|
|
1414
|
+
await this.listTasks(),
|
|
1415
|
+
);
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
async listIncompleteRunsForTasks(
|
|
1419
|
+
tasks: ScheduledTask[],
|
|
1420
|
+
): Promise<ScheduledRun[]> {
|
|
1421
|
+
return await listIncompleteRunsForTasksFromSql(this.db, tasks);
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
async markRunDispatched(args: {
|
|
1425
|
+
claimedAtMs: number;
|
|
1426
|
+
dispatchId: string;
|
|
1427
|
+
nowMs: number;
|
|
1428
|
+
runId: string;
|
|
1429
|
+
}): Promise<ScheduledRun | undefined> {
|
|
1430
|
+
return await this.updateRun(args.runId, (run) =>
|
|
1431
|
+
run.status === "pending" && run.claimedAtMs === args.claimedAtMs
|
|
1432
|
+
? {
|
|
1433
|
+
...run,
|
|
1434
|
+
dispatchId: args.dispatchId,
|
|
1435
|
+
startedAtMs: args.nowMs,
|
|
1436
|
+
status: "running",
|
|
1437
|
+
}
|
|
1438
|
+
: undefined,
|
|
1439
|
+
);
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
async markRunCompleted(args: {
|
|
1443
|
+
completedAtMs: number;
|
|
1444
|
+
resultMessageTs?: string;
|
|
1445
|
+
runId: string;
|
|
1446
|
+
startedAtMs: number;
|
|
1447
|
+
}): Promise<ScheduledRun | undefined> {
|
|
1448
|
+
const next = await this.updateRun(args.runId, (run) =>
|
|
1449
|
+
canFinishRun(run, args.startedAtMs)
|
|
1450
|
+
? {
|
|
1451
|
+
...run,
|
|
1452
|
+
completedAtMs: args.completedAtMs,
|
|
1453
|
+
resultMessageTs: args.resultMessageTs,
|
|
1454
|
+
status: "completed",
|
|
1455
|
+
}
|
|
1456
|
+
: undefined,
|
|
1457
|
+
);
|
|
1458
|
+
return next;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
async markRunFailed(args: {
|
|
1462
|
+
completedAtMs: number;
|
|
1463
|
+
errorMessage: string;
|
|
1464
|
+
startedAtMs?: number;
|
|
1465
|
+
runId: string;
|
|
1466
|
+
}): Promise<ScheduledRun | undefined> {
|
|
1467
|
+
return await this.updateRun(args.runId, (run) =>
|
|
1468
|
+
canFinishRun(run, args.startedAtMs)
|
|
1469
|
+
? {
|
|
1470
|
+
...run,
|
|
1471
|
+
completedAtMs: args.completedAtMs,
|
|
1472
|
+
errorMessage: args.errorMessage,
|
|
1473
|
+
status: "failed",
|
|
1474
|
+
}
|
|
1475
|
+
: undefined,
|
|
1476
|
+
);
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
async markRunSkipped(args: {
|
|
1480
|
+
completedAtMs: number;
|
|
1481
|
+
errorMessage: string;
|
|
1482
|
+
runId: string;
|
|
1483
|
+
}): Promise<ScheduledRun | undefined> {
|
|
1484
|
+
return await this.updateRun(args.runId, (run) =>
|
|
1485
|
+
run.status === "pending"
|
|
1486
|
+
? {
|
|
1487
|
+
...run,
|
|
1488
|
+
completedAtMs: args.completedAtMs,
|
|
1489
|
+
errorMessage: args.errorMessage,
|
|
1490
|
+
status: "skipped",
|
|
1491
|
+
}
|
|
1492
|
+
: undefined,
|
|
1493
|
+
);
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
async markRunBlocked(args: {
|
|
1497
|
+
completedAtMs: number;
|
|
1498
|
+
errorMessage: string;
|
|
1499
|
+
runId: string;
|
|
1500
|
+
startedAtMs?: number;
|
|
1501
|
+
}): Promise<ScheduledRun | undefined> {
|
|
1502
|
+
return await this.updateRun(args.runId, (run) =>
|
|
1503
|
+
canFinishRun(run, args.startedAtMs)
|
|
1504
|
+
? {
|
|
1505
|
+
...run,
|
|
1506
|
+
completedAtMs: args.completedAtMs,
|
|
1507
|
+
errorMessage: args.errorMessage,
|
|
1508
|
+
status: "blocked",
|
|
1509
|
+
}
|
|
1510
|
+
: undefined,
|
|
1511
|
+
);
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
async updateTaskAfterRun(args: {
|
|
1515
|
+
errorMessage?: string;
|
|
1516
|
+
nowMs: number;
|
|
1517
|
+
run: ScheduledRun;
|
|
1518
|
+
status: "blocked" | "completed" | "failed";
|
|
1519
|
+
}): Promise<void> {
|
|
1520
|
+
await withSqlLock(this.db, taskLockKey(args.run.taskId), async (db) => {
|
|
1521
|
+
const current = await getTaskFromSql(db, args.run.taskId);
|
|
1522
|
+
if (!current || current.status === "deleted") {
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
const isRunNow = current.runNowAtMs === args.run.scheduledForMs;
|
|
1527
|
+
if (isRunNow) {
|
|
1528
|
+
let nextRunAtMs = current.nextRunAtMs;
|
|
1529
|
+
if (
|
|
1530
|
+
args.status !== "blocked" &&
|
|
1531
|
+
typeof current.nextRunAtMs === "number" &&
|
|
1532
|
+
current.nextRunAtMs <= args.run.scheduledForMs
|
|
1533
|
+
) {
|
|
1534
|
+
nextRunAtMs = getNextRunAtMs(
|
|
1535
|
+
current,
|
|
1536
|
+
current.nextRunAtMs,
|
|
1537
|
+
args.nowMs,
|
|
1538
|
+
);
|
|
1539
|
+
}
|
|
1540
|
+
await this.saveTaskRecord(
|
|
1541
|
+
db,
|
|
1542
|
+
{
|
|
1543
|
+
...current,
|
|
1544
|
+
lastRunAtMs: args.run.scheduledForMs,
|
|
1545
|
+
nextRunAtMs,
|
|
1546
|
+
runNowAtMs: undefined,
|
|
1547
|
+
status:
|
|
1548
|
+
args.status === "blocked"
|
|
1549
|
+
? "blocked"
|
|
1550
|
+
: nextRunAtMs
|
|
1551
|
+
? current.status
|
|
1552
|
+
: "paused",
|
|
1553
|
+
statusReason:
|
|
1554
|
+
args.status === "blocked" ? args.errorMessage : undefined,
|
|
1555
|
+
updatedAtMs: args.nowMs,
|
|
1556
|
+
},
|
|
1557
|
+
current,
|
|
1558
|
+
);
|
|
1559
|
+
return;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
if (
|
|
1563
|
+
current.status !== "active" ||
|
|
1564
|
+
current.nextRunAtMs !== args.run.scheduledForMs
|
|
1565
|
+
) {
|
|
1566
|
+
await this.saveTaskRecord(
|
|
1567
|
+
db,
|
|
1568
|
+
{
|
|
1569
|
+
...current,
|
|
1570
|
+
lastRunAtMs: args.run.scheduledForMs,
|
|
1571
|
+
updatedAtMs: args.nowMs,
|
|
1572
|
+
},
|
|
1573
|
+
current,
|
|
1574
|
+
);
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
const nextRunAtMs =
|
|
1579
|
+
args.status === "blocked"
|
|
1580
|
+
? undefined
|
|
1581
|
+
: getNextRunAtMs(current, args.run.scheduledForMs, args.nowMs);
|
|
1582
|
+
|
|
1583
|
+
await this.saveTaskRecord(
|
|
1584
|
+
db,
|
|
1585
|
+
{
|
|
1586
|
+
...current,
|
|
1587
|
+
lastRunAtMs: args.run.scheduledForMs,
|
|
1588
|
+
nextRunAtMs,
|
|
1589
|
+
status:
|
|
1590
|
+
args.status === "blocked"
|
|
1591
|
+
? "blocked"
|
|
1592
|
+
: nextRunAtMs
|
|
1593
|
+
? "active"
|
|
1594
|
+
: "paused",
|
|
1595
|
+
statusReason:
|
|
1596
|
+
args.status === "blocked" ? args.errorMessage : undefined,
|
|
1597
|
+
updatedAtMs: args.nowMs,
|
|
1598
|
+
},
|
|
1599
|
+
current,
|
|
1600
|
+
);
|
|
1601
|
+
});
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
private async updateRun(
|
|
1605
|
+
runId: string,
|
|
1606
|
+
update: (run: ScheduledRun) => ScheduledRun | undefined,
|
|
1607
|
+
): Promise<ScheduledRun | undefined> {
|
|
1608
|
+
return await withSqlLock(
|
|
1609
|
+
this.db,
|
|
1610
|
+
indexLockKey(runKey(runId)),
|
|
1611
|
+
async (db) => {
|
|
1612
|
+
const current = await getRunFromSql(db, runId);
|
|
1613
|
+
if (!current) {
|
|
1614
|
+
return undefined;
|
|
1615
|
+
}
|
|
1616
|
+
const next = update(current);
|
|
1617
|
+
if (!next) {
|
|
1618
|
+
return undefined;
|
|
1619
|
+
}
|
|
1620
|
+
await upsertSqlRun(db, next);
|
|
1621
|
+
return next;
|
|
1622
|
+
},
|
|
1623
|
+
);
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
/** Create a scheduler store backed by the plugin SQL database. */
|
|
1628
|
+
export function createSchedulerSqlStore(db: PluginDb): SchedulerStore {
|
|
1629
|
+
return new SqlSchedulerStore(db);
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
/** Create a read-only scheduler operational store backed by SQL. */
|
|
1633
|
+
export function createSchedulerOperationalSqlStore(
|
|
1634
|
+
db: PluginDb,
|
|
1635
|
+
): SchedulerOperationalStore {
|
|
1636
|
+
return new SqlSchedulerStore(db);
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
/** Copy retained scheduler plugin-state records into the scheduler SQL tables. */
|
|
1640
|
+
export async function migrateSchedulerStateToSql(args: {
|
|
1641
|
+
db: PluginDb;
|
|
1642
|
+
state: PluginState;
|
|
1643
|
+
}): Promise<{
|
|
1644
|
+
existing: number;
|
|
1645
|
+
migrated: number;
|
|
1646
|
+
missing: number;
|
|
1647
|
+
scanned: number;
|
|
1648
|
+
}> {
|
|
1649
|
+
const store = createSchedulerSqlStore(args.db);
|
|
1650
|
+
const ids = await getIndex(args.state, globalTaskIndexKey());
|
|
1651
|
+
let existing = 0;
|
|
1652
|
+
let migrated = 0;
|
|
1653
|
+
let missing = 0;
|
|
1654
|
+
const migratedTasks: ScheduledTask[] = [];
|
|
1655
|
+
|
|
1656
|
+
for (const id of ids) {
|
|
1657
|
+
const task = await getTaskFromState(args.state, id);
|
|
1658
|
+
if (!task) {
|
|
1659
|
+
missing += 1;
|
|
1660
|
+
continue;
|
|
1661
|
+
}
|
|
1662
|
+
migratedTasks.push(task);
|
|
1663
|
+
if (await store.getTask(task.id)) {
|
|
1664
|
+
existing += 1;
|
|
1665
|
+
continue;
|
|
1666
|
+
}
|
|
1667
|
+
await store.saveTask(task);
|
|
1668
|
+
migrated += 1;
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
const runs = await listIncompleteRunsForTasksFromState(
|
|
1672
|
+
args.state,
|
|
1673
|
+
migratedTasks,
|
|
1674
|
+
);
|
|
1675
|
+
for (const run of runs) {
|
|
1676
|
+
if (await store.getRun(run.id)) {
|
|
1677
|
+
existing += 1;
|
|
1678
|
+
continue;
|
|
1679
|
+
}
|
|
1680
|
+
await upsertSqlRun(args.db, run);
|
|
1681
|
+
migrated += 1;
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
return {
|
|
1685
|
+
existing,
|
|
1686
|
+
migrated,
|
|
1687
|
+
missing,
|
|
1688
|
+
scanned: ids.length + runs.length,
|
|
1689
|
+
};
|
|
1690
|
+
}
|