@prairielearn/migrations 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +145 -0
- package/dist/batched-migrations/batched-migration-job.d.ts +42 -0
- package/dist/batched-migrations/batched-migration-job.js +25 -0
- package/dist/batched-migrations/batched-migration-job.js.map +1 -0
- package/dist/batched-migrations/batched-migration-job.sql +12 -0
- package/dist/batched-migrations/batched-migration-runner.d.ts +28 -0
- package/dist/batched-migrations/batched-migration-runner.js +136 -0
- package/dist/batched-migrations/batched-migration-runner.js.map +1 -0
- package/dist/batched-migrations/batched-migration-runner.sql +93 -0
- package/dist/batched-migrations/batched-migration-runner.test.js +185 -0
- package/dist/batched-migrations/batched-migration-runner.test.js.map +1 -0
- package/dist/batched-migrations/batched-migration.d.ts +79 -0
- package/dist/batched-migrations/batched-migration.js +73 -0
- package/dist/batched-migrations/batched-migration.js.map +1 -0
- package/dist/batched-migrations/batched-migration.sql +95 -0
- package/dist/batched-migrations/batched-migrations-runner.d.ts +63 -0
- package/dist/batched-migrations/batched-migrations-runner.js +273 -0
- package/dist/batched-migrations/batched-migrations-runner.js.map +1 -0
- package/dist/batched-migrations/batched-migrations-runner.sql +35 -0
- package/dist/batched-migrations/batched-migrations-runner.test.d.ts +1 -0
- package/dist/batched-migrations/batched-migrations-runner.test.js +116 -0
- package/dist/batched-migrations/batched-migrations-runner.test.js.map +1 -0
- package/dist/batched-migrations/fixtures/20230406184103_successful_migration.d.ts +9 -0
- package/dist/batched-migrations/fixtures/20230406184103_successful_migration.js +14 -0
- package/dist/batched-migrations/fixtures/20230406184103_successful_migration.js.map +1 -0
- package/dist/batched-migrations/fixtures/20230407230446_no_rows_migration.d.ts +8 -0
- package/dist/batched-migrations/fixtures/20230407230446_no_rows_migration.js +16 -0
- package/dist/batched-migrations/fixtures/20230407230446_no_rows_migration.js.map +1 -0
- package/dist/batched-migrations/index.d.ts +3 -0
- package/dist/batched-migrations/index.js +18 -0
- package/dist/batched-migrations/index.js.map +1 -0
- package/dist/index.d.ts +3 -11
- package/dist/index.js +15 -167
- package/dist/index.js.map +1 -1
- package/dist/load-migrations.d.ts +8 -0
- package/dist/load-migrations.js +60 -0
- package/dist/load-migrations.js.map +1 -0
- package/dist/load-migrations.test.d.ts +1 -0
- package/dist/{index.test.js → load-migrations.test.js} +12 -44
- package/dist/load-migrations.test.js.map +1 -0
- package/dist/migrations/fixtures/20230407210409_create_users.sql +2 -0
- package/dist/migrations/fixtures/20230407210430_insert_user.d.ts +1 -0
- package/dist/migrations/fixtures/20230407210430_insert_user.js +7 -0
- package/dist/migrations/fixtures/20230407210430_insert_user.js.map +1 -0
- package/dist/migrations/index.d.ts +1 -0
- package/dist/migrations/index.js +6 -0
- package/dist/migrations/index.js.map +1 -0
- package/dist/migrations/migrations.d.ts +6 -0
- package/dist/migrations/migrations.js +159 -0
- package/dist/migrations/migrations.js.map +1 -0
- package/dist/migrations/migrations.test.d.ts +1 -0
- package/dist/migrations/migrations.test.js +78 -0
- package/dist/migrations/migrations.test.js.map +1 -0
- package/package.json +16 -8
- package/schema-migrations/20230303193423_batched_migrations__create.sql +49 -0
- package/src/batched-migrations/batched-migration-job.sql +12 -0
- package/src/batched-migrations/batched-migration-job.ts +34 -0
- package/src/batched-migrations/batched-migration-runner.sql +93 -0
- package/src/batched-migrations/batched-migration-runner.test.ts +208 -0
- package/src/batched-migrations/batched-migration-runner.ts +215 -0
- package/src/batched-migrations/batched-migration.sql +95 -0
- package/src/batched-migrations/batched-migration.ts +129 -0
- package/src/batched-migrations/batched-migrations-runner.sql +35 -0
- package/src/batched-migrations/batched-migrations-runner.test.ts +111 -0
- package/src/batched-migrations/batched-migrations-runner.ts +327 -0
- package/src/batched-migrations/fixtures/20230406184103_successful_migration.ts +13 -0
- package/src/batched-migrations/fixtures/20230406184107_failing_migration.js +16 -0
- package/src/batched-migrations/fixtures/20230407230446_no_rows_migration.ts +15 -0
- package/src/batched-migrations/index.ts +21 -0
- package/src/index.ts +20 -173
- package/src/{index.test.ts → load-migrations.test.ts} +10 -48
- package/src/load-migrations.ts +76 -0
- package/src/migrations/fixtures/20230407210409_create_users.sql +2 -0
- package/src/migrations/fixtures/20230407210430_insert_user.ts +5 -0
- package/src/migrations/index.ts +1 -0
- package/src/migrations/migrations.test.ts +80 -0
- package/src/migrations/migrations.ts +149 -0
- package/dist/index.test.js.map +0 -1
- /package/dist/{index.test.d.ts → batched-migrations/batched-migration-runner.test.d.ts} +0 -0
- /package/dist/{index.sql → migrations/migrations.sql} +0 -0
- /package/src/{index.sql → migrations/migrations.sql} +0 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import {
|
|
2
|
+
loadSqlEquiv,
|
|
3
|
+
queryAsync,
|
|
4
|
+
queryValidatedOneRow,
|
|
5
|
+
queryValidatedSingleColumnOneRow,
|
|
6
|
+
queryValidatedZeroOrOneRow,
|
|
7
|
+
} from '@prairielearn/postgres';
|
|
8
|
+
import { logger } from '@prairielearn/logger';
|
|
9
|
+
import { serializeError } from 'serialize-error';
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
BatchedMigrationStatus,
|
|
14
|
+
BatchedMigrationRow,
|
|
15
|
+
updateBatchedMigrationStatus,
|
|
16
|
+
BatchedMigrationStatusSchema,
|
|
17
|
+
BatchedMigrationImplementation,
|
|
18
|
+
} from './batched-migration';
|
|
19
|
+
import {
|
|
20
|
+
BatchedMigrationJobRowSchema,
|
|
21
|
+
BatchedMigrationJobStatus,
|
|
22
|
+
BatchedMigrationJobRow,
|
|
23
|
+
} from './batched-migration-job';
|
|
24
|
+
|
|
25
|
+
const sql = loadSqlEquiv(__filename);
|
|
26
|
+
|
|
27
|
+
interface BatchedMigrationRunnerOptions {
|
|
28
|
+
logProgress?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class BatchedMigrationRunner {
|
|
32
|
+
private options: BatchedMigrationRunnerOptions;
|
|
33
|
+
private migration: BatchedMigrationRow;
|
|
34
|
+
private migrationImplementation: BatchedMigrationImplementation;
|
|
35
|
+
private migrationStatus: BatchedMigrationStatus;
|
|
36
|
+
|
|
37
|
+
constructor(
|
|
38
|
+
migration: BatchedMigrationRow,
|
|
39
|
+
migrationImplementation: BatchedMigrationImplementation,
|
|
40
|
+
options: BatchedMigrationRunnerOptions = {}
|
|
41
|
+
) {
|
|
42
|
+
this.options = options;
|
|
43
|
+
this.migration = migration;
|
|
44
|
+
this.migrationImplementation = migrationImplementation;
|
|
45
|
+
this.migrationStatus = migration.status;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private log(message: string, ...meta: any[]) {
|
|
49
|
+
if (this.options.logProgress) {
|
|
50
|
+
logger.info(`[${this.migration.filename}] ${message}`, ...meta);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private async hasIncompleteJobs(migration: BatchedMigrationRow): Promise<boolean> {
|
|
55
|
+
return queryValidatedSingleColumnOneRow(
|
|
56
|
+
sql.batched_migration_has_incomplete_jobs,
|
|
57
|
+
{ batched_migration_id: migration.id },
|
|
58
|
+
z.boolean()
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private async hasFailedJobs(migration: BatchedMigrationRow): Promise<boolean> {
|
|
63
|
+
return queryValidatedSingleColumnOneRow(
|
|
64
|
+
sql.batched_migration_has_failed_jobs,
|
|
65
|
+
{ batched_migration_id: migration.id },
|
|
66
|
+
z.boolean()
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private async refreshMigrationStatus(migration: BatchedMigrationRow) {
|
|
71
|
+
this.migrationStatus = await queryValidatedSingleColumnOneRow(
|
|
72
|
+
sql.get_migration_status,
|
|
73
|
+
{
|
|
74
|
+
id: migration.id,
|
|
75
|
+
},
|
|
76
|
+
BatchedMigrationStatusSchema
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private async finishRunningMigration(migration: BatchedMigrationRow) {
|
|
81
|
+
// Safety check: if there are any pending jobs, don't mark this
|
|
82
|
+
// migration as finished.
|
|
83
|
+
if (await this.hasIncompleteJobs(migration)) {
|
|
84
|
+
this.log(`Incomplete jobs found, not marking as finished`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const hasFailedJobs = await this.hasFailedJobs(migration);
|
|
89
|
+
const finalStatus = hasFailedJobs ? 'failed' : 'succeeded';
|
|
90
|
+
await updateBatchedMigrationStatus(migration.id, finalStatus);
|
|
91
|
+
this.log(`Finished with status '${finalStatus}'`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private async getNextBatchBounds(
|
|
95
|
+
migration: BatchedMigrationRow
|
|
96
|
+
): Promise<null | [bigint, bigint]> {
|
|
97
|
+
const lastJob = await queryValidatedZeroOrOneRow(
|
|
98
|
+
sql.select_last_batched_migration_job,
|
|
99
|
+
{ batched_migration_id: migration.id },
|
|
100
|
+
BatchedMigrationJobRowSchema
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const nextMin = lastJob ? lastJob.max_value + 1n : migration.min_value;
|
|
104
|
+
if (nextMin > migration.max_value) return null;
|
|
105
|
+
|
|
106
|
+
let nextMax = nextMin + BigInt(migration.batch_size) - 1n;
|
|
107
|
+
if (nextMax > migration.max_value) nextMax = migration.max_value;
|
|
108
|
+
|
|
109
|
+
return [nextMin, nextMax];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private async startJob(job: BatchedMigrationJobRow) {
|
|
113
|
+
await queryAsync(sql.start_batched_migration_job, { id: job.id });
|
|
114
|
+
const jobRange = `[${job.min_value}, ${job.max_value}]`;
|
|
115
|
+
const migrationRange = `[${this.migration.min_value}, ${this.migration.max_value}]`;
|
|
116
|
+
this.log(`Started job ${job.id} for range ${jobRange} in ${migrationRange}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private serializeJobData(data: unknown) {
|
|
120
|
+
if (data == null) return null;
|
|
121
|
+
|
|
122
|
+
// Return JSON-stringified data. Convert BigInts to strings.
|
|
123
|
+
return JSON.stringify(data, (_key, value) => {
|
|
124
|
+
if (typeof value === 'bigint') return value.toString();
|
|
125
|
+
return value;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private async finishJob(
|
|
130
|
+
job: BatchedMigrationJobRow,
|
|
131
|
+
status: Extract<BatchedMigrationJobStatus, 'failed' | 'succeeded'>,
|
|
132
|
+
data?: unknown
|
|
133
|
+
) {
|
|
134
|
+
await queryAsync(sql.finish_batched_migration_job, {
|
|
135
|
+
id: job.id,
|
|
136
|
+
status,
|
|
137
|
+
data: this.serializeJobData(data),
|
|
138
|
+
});
|
|
139
|
+
this.log(`Job ${job.id} finished with status '${status}'`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private async getOrCreateNextMigrationJob(
|
|
143
|
+
migration: BatchedMigrationRow
|
|
144
|
+
): Promise<BatchedMigrationJobRow | null> {
|
|
145
|
+
const nextBatchBounds = await this.getNextBatchBounds(migration);
|
|
146
|
+
if (nextBatchBounds) {
|
|
147
|
+
return queryValidatedOneRow(
|
|
148
|
+
sql.insert_batched_migration_job,
|
|
149
|
+
{
|
|
150
|
+
batched_migration_id: migration.id,
|
|
151
|
+
min_value: nextBatchBounds[0],
|
|
152
|
+
max_value: nextBatchBounds[1],
|
|
153
|
+
},
|
|
154
|
+
BatchedMigrationJobRowSchema
|
|
155
|
+
);
|
|
156
|
+
} else {
|
|
157
|
+
// Pick up any old pending jobs from this migration. These will only exist if
|
|
158
|
+
// an admin manually elected to retry all failed jobs; we'll never automatically
|
|
159
|
+
// transition failed jobs back to pending.
|
|
160
|
+
return queryValidatedZeroOrOneRow(
|
|
161
|
+
sql.select_first_pending_batched_migration_job,
|
|
162
|
+
{ batched_migration_id: migration.id },
|
|
163
|
+
BatchedMigrationJobRowSchema
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private async runMigrationJob(
|
|
169
|
+
migration: BatchedMigrationRow,
|
|
170
|
+
migrationImplementation: BatchedMigrationImplementation
|
|
171
|
+
) {
|
|
172
|
+
const nextJob = await this.getOrCreateNextMigrationJob(migration);
|
|
173
|
+
if (nextJob) {
|
|
174
|
+
await this.startJob(nextJob);
|
|
175
|
+
|
|
176
|
+
let error = null;
|
|
177
|
+
try {
|
|
178
|
+
// We'll only handle errors thrown by the migration itself. If any of
|
|
179
|
+
// our own execution machinery throws an error, we'll let it bubble up.
|
|
180
|
+
await migrationImplementation.execute(nextJob.min_value, nextJob.max_value);
|
|
181
|
+
} catch (err) {
|
|
182
|
+
error = err;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (error) {
|
|
186
|
+
await this.finishJob(nextJob, 'failed', { error: serializeError(error) });
|
|
187
|
+
} else {
|
|
188
|
+
await this.finishJob(nextJob, 'succeeded');
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
await this.finishRunningMigration(migration);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async run({
|
|
196
|
+
signal,
|
|
197
|
+
iterations,
|
|
198
|
+
durationMs,
|
|
199
|
+
}: { signal?: AbortSignal; iterations?: number; durationMs?: number } = {}) {
|
|
200
|
+
let iterationCount = 0;
|
|
201
|
+
const endTime = durationMs ? Date.now() + durationMs : null;
|
|
202
|
+
while (
|
|
203
|
+
!signal?.aborted &&
|
|
204
|
+
(iterations ? iterationCount < iterations : true) &&
|
|
205
|
+
(endTime ? Date.now() < endTime : true) &&
|
|
206
|
+
(this.migrationStatus === 'running' || this.migrationStatus === 'finalizing')
|
|
207
|
+
) {
|
|
208
|
+
await this.runMigrationJob(this.migration, this.migrationImplementation);
|
|
209
|
+
iterationCount += 1;
|
|
210
|
+
// Always refresh the status so we can detect if the migration was marked
|
|
211
|
+
// as paused by another process.
|
|
212
|
+
await this.refreshMigrationStatus(this.migration);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
-- BLOCK insert_batched_migration
|
|
2
|
+
INSERT INTO
|
|
3
|
+
batched_migrations (
|
|
4
|
+
project,
|
|
5
|
+
filename,
|
|
6
|
+
timestamp,
|
|
7
|
+
batch_size,
|
|
8
|
+
min_value,
|
|
9
|
+
max_value,
|
|
10
|
+
status,
|
|
11
|
+
started_at
|
|
12
|
+
)
|
|
13
|
+
VALUES
|
|
14
|
+
(
|
|
15
|
+
$project,
|
|
16
|
+
$filename,
|
|
17
|
+
$timestamp,
|
|
18
|
+
$batch_size,
|
|
19
|
+
$min_value,
|
|
20
|
+
$max_value,
|
|
21
|
+
$status,
|
|
22
|
+
-- If the migration is marked as already having succeeded, set `started_at`
|
|
23
|
+
-- since the migration did technically start.
|
|
24
|
+
CASE
|
|
25
|
+
WHEN $status::enum_batched_migration_status = 'succeeded' THEN CURRENT_TIMESTAMP
|
|
26
|
+
ELSE NULL
|
|
27
|
+
END
|
|
28
|
+
)
|
|
29
|
+
ON CONFLICT DO NOTHING
|
|
30
|
+
RETURNING
|
|
31
|
+
*;
|
|
32
|
+
|
|
33
|
+
-- BLOCK select_all_batched_migrations
|
|
34
|
+
SELECT
|
|
35
|
+
*
|
|
36
|
+
FROM
|
|
37
|
+
batched_migrations
|
|
38
|
+
WHERE
|
|
39
|
+
project = $project
|
|
40
|
+
ORDER BY
|
|
41
|
+
id ASC;
|
|
42
|
+
|
|
43
|
+
-- BLOCK select_batched_migration
|
|
44
|
+
SELECT
|
|
45
|
+
*
|
|
46
|
+
FROM
|
|
47
|
+
batched_migrations
|
|
48
|
+
WHERE
|
|
49
|
+
project = $project
|
|
50
|
+
AND id = $id;
|
|
51
|
+
|
|
52
|
+
-- BLOCK select_batched_migration_for_timestamp
|
|
53
|
+
SELECT
|
|
54
|
+
*
|
|
55
|
+
FROM
|
|
56
|
+
batched_migrations
|
|
57
|
+
WHERE
|
|
58
|
+
project = $project
|
|
59
|
+
AND timestamp = $timestamp;
|
|
60
|
+
|
|
61
|
+
-- BLOCK update_batched_migration_status
|
|
62
|
+
UPDATE batched_migrations
|
|
63
|
+
SET
|
|
64
|
+
status = $status,
|
|
65
|
+
updated_at = CURRENT_TIMESTAMP
|
|
66
|
+
WHERE
|
|
67
|
+
id = $id
|
|
68
|
+
RETURNING
|
|
69
|
+
*;
|
|
70
|
+
|
|
71
|
+
-- BLOCK retry_failed_jobs
|
|
72
|
+
WITH
|
|
73
|
+
updated_batched_migration AS (
|
|
74
|
+
UPDATE batched_migrations
|
|
75
|
+
SET
|
|
76
|
+
status = 'running',
|
|
77
|
+
started_at = CURRENT_TIMESTAMP,
|
|
78
|
+
updated_at = CURRENT_TIMESTAMP
|
|
79
|
+
WHERE
|
|
80
|
+
project = $project
|
|
81
|
+
AND id = $id
|
|
82
|
+
RETURNING
|
|
83
|
+
*
|
|
84
|
+
)
|
|
85
|
+
UPDATE batched_migration_jobs
|
|
86
|
+
SET
|
|
87
|
+
status = 'pending',
|
|
88
|
+
started_at = NULL,
|
|
89
|
+
finished_at = NULL,
|
|
90
|
+
updated_at = CURRENT_TIMESTAMP
|
|
91
|
+
FROM
|
|
92
|
+
updated_batched_migration
|
|
93
|
+
WHERE
|
|
94
|
+
batched_migration_id = updated_batched_migration.id
|
|
95
|
+
AND batched_migration_jobs.status = 'failed';
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import {
|
|
2
|
+
loadSqlEquiv,
|
|
3
|
+
queryAsync,
|
|
4
|
+
queryValidatedOneRow,
|
|
5
|
+
queryValidatedRows,
|
|
6
|
+
queryValidatedZeroOrOneRow,
|
|
7
|
+
} from '@prairielearn/postgres';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
|
|
10
|
+
const sql = loadSqlEquiv(__filename);
|
|
11
|
+
|
|
12
|
+
export const BatchedMigrationStatusSchema = z.enum([
|
|
13
|
+
'pending',
|
|
14
|
+
'paused',
|
|
15
|
+
'running',
|
|
16
|
+
'finalizing',
|
|
17
|
+
'failed',
|
|
18
|
+
'succeeded',
|
|
19
|
+
]);
|
|
20
|
+
export type BatchedMigrationStatus = z.infer<typeof BatchedMigrationStatusSchema>;
|
|
21
|
+
|
|
22
|
+
export const BatchedMigrationRowSchema = z.object({
|
|
23
|
+
id: z.string(),
|
|
24
|
+
project: z.string(),
|
|
25
|
+
filename: z.string(),
|
|
26
|
+
timestamp: z.string(),
|
|
27
|
+
batch_size: z.number(),
|
|
28
|
+
min_value: z.bigint({ coerce: true }),
|
|
29
|
+
max_value: z.bigint({ coerce: true }),
|
|
30
|
+
status: BatchedMigrationStatusSchema,
|
|
31
|
+
created_at: z.date(),
|
|
32
|
+
updated_at: z.date(),
|
|
33
|
+
started_at: z.date().nullable(),
|
|
34
|
+
});
|
|
35
|
+
export type BatchedMigrationRow = z.infer<typeof BatchedMigrationRowSchema>;
|
|
36
|
+
|
|
37
|
+
export interface BatchedMigrationParameters {
|
|
38
|
+
min?: bigint | null;
|
|
39
|
+
max: bigint | null;
|
|
40
|
+
batchSize?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface BatchedMigrationImplementation {
|
|
44
|
+
getParameters(): Promise<BatchedMigrationParameters>;
|
|
45
|
+
execute(start: bigint, end: bigint): Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Identity function that helps to write correct batched migrations.
|
|
50
|
+
*/
|
|
51
|
+
export function makeBatchedMigration<T extends BatchedMigrationImplementation>(fns: T): T {
|
|
52
|
+
validateBatchedMigrationImplementation(fns);
|
|
53
|
+
return fns;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function validateBatchedMigrationImplementation(
|
|
57
|
+
fns: BatchedMigrationImplementation
|
|
58
|
+
): asserts fns is BatchedMigrationImplementation {
|
|
59
|
+
if (typeof fns.getParameters !== 'function') {
|
|
60
|
+
throw new Error('getParameters() must be a function');
|
|
61
|
+
}
|
|
62
|
+
if (typeof fns.execute !== 'function') {
|
|
63
|
+
throw new Error('execute() must be a function');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type NewBatchedMigration = Pick<
|
|
68
|
+
BatchedMigrationRow,
|
|
69
|
+
'project' | 'filename' | 'timestamp' | 'batch_size' | 'min_value' | 'max_value' | 'status'
|
|
70
|
+
>;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Inserts a new batched migration. If one already exists for the given
|
|
74
|
+
* project/timestamp pair, returns null, otherwise returns the inserted row.
|
|
75
|
+
*/
|
|
76
|
+
export async function insertBatchedMigration(
|
|
77
|
+
migration: NewBatchedMigration
|
|
78
|
+
): Promise<BatchedMigrationRow | null> {
|
|
79
|
+
return queryValidatedZeroOrOneRow(
|
|
80
|
+
sql.insert_batched_migration,
|
|
81
|
+
migration,
|
|
82
|
+
BatchedMigrationRowSchema
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function selectAllBatchedMigrations(project: string) {
|
|
87
|
+
return queryValidatedRows(
|
|
88
|
+
sql.select_all_batched_migrations,
|
|
89
|
+
{ project },
|
|
90
|
+
BatchedMigrationRowSchema
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function selectBatchedMigration(
|
|
95
|
+
project: string,
|
|
96
|
+
id: string
|
|
97
|
+
): Promise<BatchedMigrationRow> {
|
|
98
|
+
return queryValidatedOneRow(
|
|
99
|
+
sql.select_batched_migration,
|
|
100
|
+
{ project, id },
|
|
101
|
+
BatchedMigrationRowSchema
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function selectBatchedMigrationForTimestamp(
|
|
106
|
+
project: string,
|
|
107
|
+
timestamp: string
|
|
108
|
+
): Promise<BatchedMigrationRow> {
|
|
109
|
+
return queryValidatedOneRow(
|
|
110
|
+
sql.select_batched_migration_for_timestamp,
|
|
111
|
+
{ project, timestamp },
|
|
112
|
+
BatchedMigrationRowSchema
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function updateBatchedMigrationStatus(
|
|
117
|
+
id: string,
|
|
118
|
+
status: BatchedMigrationStatus
|
|
119
|
+
): Promise<BatchedMigrationRow> {
|
|
120
|
+
return queryValidatedOneRow(
|
|
121
|
+
sql.update_batched_migration_status,
|
|
122
|
+
{ id, status },
|
|
123
|
+
BatchedMigrationRowSchema
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function retryFailedBatchedMigrationJobs(project: string, id: string): Promise<void> {
|
|
128
|
+
await queryAsync(sql.retry_failed_jobs, { project, id });
|
|
129
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
-- BLOCK select_running_migration
|
|
2
|
+
SELECT
|
|
3
|
+
*
|
|
4
|
+
FROM
|
|
5
|
+
batched_migrations
|
|
6
|
+
WHERE
|
|
7
|
+
status = 'running'
|
|
8
|
+
AND project = $project
|
|
9
|
+
ORDER BY
|
|
10
|
+
id ASC
|
|
11
|
+
LIMIT
|
|
12
|
+
1;
|
|
13
|
+
|
|
14
|
+
-- BLOCK start_next_pending_migration
|
|
15
|
+
UPDATE batched_migrations
|
|
16
|
+
SET
|
|
17
|
+
status = 'running',
|
|
18
|
+
started_at = CURRENT_TIMESTAMP,
|
|
19
|
+
updated_at = CURRENT_TIMESTAMP
|
|
20
|
+
WHERE
|
|
21
|
+
id = (
|
|
22
|
+
SELECT
|
|
23
|
+
id
|
|
24
|
+
FROM
|
|
25
|
+
batched_migrations
|
|
26
|
+
WHERE
|
|
27
|
+
status = 'pending'
|
|
28
|
+
AND project = $project
|
|
29
|
+
ORDER BY
|
|
30
|
+
id ASC
|
|
31
|
+
LIMIT
|
|
32
|
+
1
|
|
33
|
+
)
|
|
34
|
+
RETURNING
|
|
35
|
+
*;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import chai, { assert } from 'chai';
|
|
2
|
+
import chaiAsPromised from 'chai-as-promised';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { makePostgresTestUtils } from '@prairielearn/postgres';
|
|
5
|
+
import * as namedLocks from '@prairielearn/named-locks';
|
|
6
|
+
|
|
7
|
+
import { SCHEMA_MIGRATIONS_PATH, init } from '../index';
|
|
8
|
+
import { BatchedMigrationsRunner } from './batched-migrations-runner';
|
|
9
|
+
import { selectAllBatchedMigrations } from './batched-migration';
|
|
10
|
+
|
|
11
|
+
chai.use(chaiAsPromised);
|
|
12
|
+
|
|
13
|
+
const postgresTestUtils = makePostgresTestUtils({
|
|
14
|
+
database: 'prairielearn_migrations',
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('BatchedMigrationsRunner', () => {
|
|
18
|
+
before(async () => {
|
|
19
|
+
await postgresTestUtils.createDatabase();
|
|
20
|
+
await namedLocks.init(postgresTestUtils.getPoolConfig(), (err) => {
|
|
21
|
+
throw err;
|
|
22
|
+
});
|
|
23
|
+
await init([SCHEMA_MIGRATIONS_PATH], 'prairielearn_migrations');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(async () => {
|
|
27
|
+
await postgresTestUtils.resetDatabase();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
after(async () => {
|
|
31
|
+
await namedLocks.close();
|
|
32
|
+
await postgresTestUtils.dropDatabase();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('enqueues migrations', async () => {
|
|
36
|
+
const runner = new BatchedMigrationsRunner({
|
|
37
|
+
project: 'test',
|
|
38
|
+
directories: [path.join(__dirname, 'fixtures')],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
await runner.enqueueBatchedMigration('20230406184103_successful_migration');
|
|
42
|
+
await runner.enqueueBatchedMigration('20230406184107_failing_migration');
|
|
43
|
+
await runner.enqueueBatchedMigration('20230407230446_no_rows_migration');
|
|
44
|
+
|
|
45
|
+
const migrations = await selectAllBatchedMigrations('test');
|
|
46
|
+
|
|
47
|
+
assert.lengthOf(migrations, 3);
|
|
48
|
+
assert.equal(migrations[0].timestamp, '20230406184103');
|
|
49
|
+
assert.equal(migrations[0].filename, '20230406184103_successful_migration.ts');
|
|
50
|
+
assert.equal(migrations[0].status, 'pending');
|
|
51
|
+
assert.equal(migrations[1].timestamp, '20230406184107');
|
|
52
|
+
assert.equal(migrations[1].filename, '20230406184107_failing_migration.js');
|
|
53
|
+
assert.equal(migrations[1].status, 'pending');
|
|
54
|
+
assert.equal(migrations[2].timestamp, '20230407230446');
|
|
55
|
+
assert.equal(migrations[2].filename, '20230407230446_no_rows_migration.ts');
|
|
56
|
+
assert.equal(migrations[2].status, 'succeeded');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('safely enqueues migrations multiple times', async () => {
|
|
60
|
+
const runner = new BatchedMigrationsRunner({
|
|
61
|
+
project: 'test',
|
|
62
|
+
directories: [path.join(__dirname, 'fixtures')],
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
await runner.enqueueBatchedMigration('20230406184103_successful_migration');
|
|
66
|
+
await runner.enqueueBatchedMigration('20230406184103_successful_migration');
|
|
67
|
+
await runner.enqueueBatchedMigration('20230406184103_successful_migration');
|
|
68
|
+
|
|
69
|
+
const migrations = await selectAllBatchedMigrations('test');
|
|
70
|
+
|
|
71
|
+
assert.lengthOf(migrations, 1);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('finalizes a successful migration', async () => {
|
|
75
|
+
const runner = new BatchedMigrationsRunner({
|
|
76
|
+
project: 'test',
|
|
77
|
+
directories: [path.join(__dirname, 'fixtures')],
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await runner.enqueueBatchedMigration('20230406184103_successful_migration');
|
|
81
|
+
await runner.finalizeBatchedMigration('20230406184103_successful_migration', {
|
|
82
|
+
logProgress: false,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const migrations = await selectAllBatchedMigrations('test');
|
|
86
|
+
assert.lengthOf(migrations, 1);
|
|
87
|
+
assert.equal(migrations[0].timestamp, '20230406184103');
|
|
88
|
+
assert.equal(migrations[0].status, 'succeeded');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('finalizes a failing migration', async () => {
|
|
92
|
+
const runner = new BatchedMigrationsRunner({
|
|
93
|
+
project: 'test',
|
|
94
|
+
directories: [path.join(__dirname, 'fixtures')],
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
await runner.enqueueBatchedMigration('20230406184107_failing_migration');
|
|
98
|
+
|
|
99
|
+
await assert.isRejected(
|
|
100
|
+
runner.finalizeBatchedMigration('20230406184107_failing_migration', {
|
|
101
|
+
logProgress: false,
|
|
102
|
+
}),
|
|
103
|
+
"but it is 'failed'"
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const migrations = await selectAllBatchedMigrations('test');
|
|
107
|
+
assert.lengthOf(migrations, 1);
|
|
108
|
+
assert.equal(migrations[0].timestamp, '20230406184107');
|
|
109
|
+
assert.equal(migrations[0].status, 'failed');
|
|
110
|
+
});
|
|
111
|
+
});
|