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