@nest-batch/typeorm 0.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/LICENSE +21 -0
- package/README.md +263 -0
- package/dist/src/adapters/index.d.ts +18 -0
- package/dist/src/adapters/index.d.ts.map +1 -0
- package/dist/src/adapters/index.js +35 -0
- package/dist/src/adapters/index.js.map +1 -0
- package/dist/src/adapters/typeorm.adapter.d.ts +42 -0
- package/dist/src/adapters/typeorm.adapter.d.ts.map +1 -0
- package/dist/src/adapters/typeorm.adapter.js +85 -0
- package/dist/src/adapters/typeorm.adapter.js.map +1 -0
- package/dist/src/entities/index.d.ts +2 -0
- package/dist/src/entities/index.d.ts.map +1 -0
- package/dist/src/entities/index.js +20 -0
- package/dist/src/entities/index.js.map +1 -0
- package/dist/src/entities/job-meta.entities.d.ts +96 -0
- package/dist/src/entities/job-meta.entities.d.ts.map +1 -0
- package/dist/src/entities/job-meta.entities.js +357 -0
- package/dist/src/entities/job-meta.entities.js.map +1 -0
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +74 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/migrations/1700000000000-CreateBatchMeta.d.ts +28 -0
- package/dist/src/migrations/1700000000000-CreateBatchMeta.d.ts.map +1 -0
- package/dist/src/migrations/1700000000000-CreateBatchMeta.js +83 -0
- package/dist/src/migrations/1700000000000-CreateBatchMeta.js.map +1 -0
- package/dist/src/repository/typeorm-job-repository.d.ts +57 -0
- package/dist/src/repository/typeorm-job-repository.d.ts.map +1 -0
- package/dist/src/repository/typeorm-job-repository.js +489 -0
- package/dist/src/repository/typeorm-job-repository.js.map +1 -0
- package/dist/src/transaction/typeorm-transaction-manager.d.ts +24 -0
- package/dist/src/transaction/typeorm-transaction-manager.d.ts.map +1 -0
- package/dist/src/transaction/typeorm-transaction-manager.js +55 -0
- package/dist/src/transaction/typeorm-transaction-manager.js.map +1 -0
- package/dist/src/typeorm.driver-provider.d.ts +22 -0
- package/dist/src/typeorm.driver-provider.d.ts.map +1 -0
- package/dist/src/typeorm.driver-provider.js +32 -0
- package/dist/src/typeorm.driver-provider.js.map +1 -0
- package/package.json +69 -0
- package/src/adapters/index.ts +17 -0
- package/src/adapters/typeorm.adapter.ts +82 -0
- package/src/entities/index.ts +1 -0
- package/src/entities/job-meta.entities.ts +184 -0
- package/src/index.ts +42 -0
- package/src/migrations/1700000000000-CreateBatchMeta.ts +100 -0
- package/src/repository/typeorm-job-repository.ts +548 -0
- package/src/transaction/typeorm-transaction-manager.ts +47 -0
- package/src/typeorm.driver-provider.ts +23 -0
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
import { Inject, Injectable } from '@nestjs/common';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { DataSource, EntityManager, In } from 'typeorm';
|
|
4
|
+
import {
|
|
5
|
+
JobRepository,
|
|
6
|
+
JobExecutionAlreadyRunningError,
|
|
7
|
+
assertJsonSerializable,
|
|
8
|
+
serializeContext,
|
|
9
|
+
deserializeContext,
|
|
10
|
+
JobStatus,
|
|
11
|
+
StepStatus,
|
|
12
|
+
} from '@nest-batch/core';
|
|
13
|
+
import type {
|
|
14
|
+
JobInstance,
|
|
15
|
+
JobExecution,
|
|
16
|
+
JobExecutionPatch,
|
|
17
|
+
JobParameters,
|
|
18
|
+
StepExecution,
|
|
19
|
+
StepExecutionPatch,
|
|
20
|
+
ExecutionContext,
|
|
21
|
+
ExecutionScope,
|
|
22
|
+
JobInstanceFilter,
|
|
23
|
+
JobExecutionFilter,
|
|
24
|
+
} from '@nest-batch/core';
|
|
25
|
+
import { TypeOrmDriverProvider } from '../typeorm.driver-provider';
|
|
26
|
+
|
|
27
|
+
function scopeKey(scope: ExecutionScope): string {
|
|
28
|
+
if ('jobExecutionId' in scope) return `job::${scope.jobExecutionId}`;
|
|
29
|
+
return `step::${scope.stepExecutionId}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function deepClone<T>(value: T): T {
|
|
33
|
+
if (value === null || typeof value !== 'object') return value;
|
|
34
|
+
if (value instanceof Date) return new Date(value.getTime()) as unknown as T;
|
|
35
|
+
if (Array.isArray(value)) return value.map((v) => deepClone(v)) as unknown as T;
|
|
36
|
+
const out: Record<string, unknown> = {};
|
|
37
|
+
for (const k of Object.keys(value as Record<string, unknown>)) {
|
|
38
|
+
out[k] = deepClone((value as Record<string, unknown>)[k]);
|
|
39
|
+
}
|
|
40
|
+
return out as T;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface JobInstanceRow {
|
|
44
|
+
id: string;
|
|
45
|
+
job_name: string;
|
|
46
|
+
job_key: string;
|
|
47
|
+
created_at: string | Date;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface JobExecutionRow {
|
|
51
|
+
id: string;
|
|
52
|
+
job_instance_id: string;
|
|
53
|
+
status: string;
|
|
54
|
+
start_time: string | Date | null;
|
|
55
|
+
end_time: string | Date | null;
|
|
56
|
+
exit_code: string;
|
|
57
|
+
exit_message: string;
|
|
58
|
+
params: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface StepExecutionRow {
|
|
62
|
+
id: string;
|
|
63
|
+
job_execution_id: string;
|
|
64
|
+
step_name: string;
|
|
65
|
+
status: string;
|
|
66
|
+
read_count: number;
|
|
67
|
+
write_count: number;
|
|
68
|
+
skip_count: number;
|
|
69
|
+
rollback_count: number;
|
|
70
|
+
commit_count: number;
|
|
71
|
+
exit_code: string;
|
|
72
|
+
exit_message: string;
|
|
73
|
+
created_at: string | Date;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface ContextRow {
|
|
77
|
+
data: string;
|
|
78
|
+
version: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function mapJobInstance(r: JobInstanceRow): JobInstance {
|
|
82
|
+
return {
|
|
83
|
+
id: r.id,
|
|
84
|
+
jobName: r.job_name,
|
|
85
|
+
jobKey: r.job_key,
|
|
86
|
+
createdAt: r.created_at instanceof Date ? r.created_at : new Date(r.created_at),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function mapJobExecution(r: JobExecutionRow): JobExecution {
|
|
91
|
+
let params: JobParameters = {};
|
|
92
|
+
if (r.params && r.params.length > 0) {
|
|
93
|
+
try {
|
|
94
|
+
params = deserializeContext<JobParameters>(r.params);
|
|
95
|
+
} catch {
|
|
96
|
+
params = {};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
id: r.id,
|
|
101
|
+
jobInstanceId: r.job_instance_id,
|
|
102
|
+
status: r.status as JobStatus,
|
|
103
|
+
startTime: r.start_time ? (r.start_time instanceof Date ? r.start_time : new Date(r.start_time)) : null,
|
|
104
|
+
endTime: r.end_time ? (r.end_time instanceof Date ? r.end_time : new Date(r.end_time)) : null,
|
|
105
|
+
exitCode: r.exit_code,
|
|
106
|
+
exitMessage: r.exit_message,
|
|
107
|
+
params,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function mapStepExecution(r: StepExecutionRow): StepExecution {
|
|
112
|
+
return {
|
|
113
|
+
id: r.id,
|
|
114
|
+
jobExecutionId: r.job_execution_id,
|
|
115
|
+
stepName: r.step_name,
|
|
116
|
+
status: r.status as StepStatus,
|
|
117
|
+
readCount: r.read_count,
|
|
118
|
+
writeCount: r.write_count,
|
|
119
|
+
skipCount: r.skip_count,
|
|
120
|
+
rollbackCount: r.rollback_count,
|
|
121
|
+
commitCount: r.commit_count,
|
|
122
|
+
startTime: null,
|
|
123
|
+
endTime: null,
|
|
124
|
+
exitCode: r.exit_code,
|
|
125
|
+
exitMessage: r.exit_message,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* TypeORM 1.0.0-backed `JobRepository`.
|
|
131
|
+
*
|
|
132
|
+
* The package is driver-agnostic: the actual `DataSource` is
|
|
133
|
+
* provided by the `@nest-batch/postgresql` (or future
|
|
134
|
+
* `@nest-batch/mysql`) driver sibling via the `TypeOrmDriverProvider`
|
|
135
|
+
* token. The repository itself uses raw SQL via `EntityManager.query`
|
|
136
|
+
* so the column-shape contract is owned by the driver sibling
|
|
137
|
+
* (the bundled 6-table migration).
|
|
138
|
+
*
|
|
139
|
+
* The contract guarantees:
|
|
140
|
+
* - `getOrCreateJobInstance` is race-safe via the (jobName, jobKey)
|
|
141
|
+
* unique index.
|
|
142
|
+
* - `createExecutionAtomic` runs inside a single transaction that
|
|
143
|
+
* (a) idempotently upserts the instance row, (b) acquires a row
|
|
144
|
+
* lock with `SELECT ... FOR UPDATE SKIP LOCKED` (PostgreSQL) or
|
|
145
|
+
* a plain select (SQLite test driver), and (c) rejects with
|
|
146
|
+
* `JobExecutionAlreadyRunningError` if a STARTING/STARTED
|
|
147
|
+
* execution already exists.
|
|
148
|
+
* - `saveExecutionContext` deep-clones the data and auto-increments
|
|
149
|
+
* the version counter when `version` is omitted.
|
|
150
|
+
* - `findLatestStepExecution` orders by `created_at` descending.
|
|
151
|
+
*/
|
|
152
|
+
@Injectable()
|
|
153
|
+
export class TypeOrmJobRepository extends JobRepository {
|
|
154
|
+
constructor(
|
|
155
|
+
@Inject(TypeOrmDriverProvider) private readonly dataSource: DataSource,
|
|
156
|
+
) {
|
|
157
|
+
super();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private em(): EntityManager {
|
|
161
|
+
return this.dataSource.manager;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async getOrCreateJobInstance(name: string, jobKey: string): Promise<JobInstance> {
|
|
165
|
+
const existing = await this.em().query(
|
|
166
|
+
`SELECT "id", "job_name", "job_key", "created_at"
|
|
167
|
+
FROM "batch_job_instance"
|
|
168
|
+
WHERE "job_name" = $1 AND "job_key" = $2
|
|
169
|
+
LIMIT 1`,
|
|
170
|
+
[name, jobKey],
|
|
171
|
+
) as JobInstanceRow[];
|
|
172
|
+
if (existing.length > 0) return mapJobInstance(existing[0]!);
|
|
173
|
+
|
|
174
|
+
const id = randomUUID();
|
|
175
|
+
try {
|
|
176
|
+
const inserted = await this.em().query(
|
|
177
|
+
`INSERT INTO "batch_job_instance" ("id", "job_name", "job_key", "created_at")
|
|
178
|
+
VALUES ($1, $2, $3, NOW())
|
|
179
|
+
ON CONFLICT ("job_name", "job_key") DO NOTHING
|
|
180
|
+
RETURNING "id", "job_name", "job_key", "created_at"`,
|
|
181
|
+
[id, name, jobKey],
|
|
182
|
+
) as JobInstanceRow[];
|
|
183
|
+
if (inserted.length > 0) return mapJobInstance(inserted[0]!);
|
|
184
|
+
} catch {
|
|
185
|
+
// Fall through to read-back.
|
|
186
|
+
}
|
|
187
|
+
const winner = await this.em().query(
|
|
188
|
+
`SELECT "id", "job_name", "job_key", "created_at"
|
|
189
|
+
FROM "batch_job_instance"
|
|
190
|
+
WHERE "job_name" = $1 AND "job_key" = $2
|
|
191
|
+
LIMIT 1`,
|
|
192
|
+
[name, jobKey],
|
|
193
|
+
) as JobInstanceRow[];
|
|
194
|
+
if (winner.length === 0) {
|
|
195
|
+
throw new Error(
|
|
196
|
+
`Failed to upsert JobInstance (${name}, ${jobKey}) and could not read it back`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
return mapJobInstance(winner[0]!);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async createJobExecution(
|
|
203
|
+
jobInstanceId: string,
|
|
204
|
+
params: JobParameters,
|
|
205
|
+
): Promise<JobExecution> {
|
|
206
|
+
const exec = {
|
|
207
|
+
id: randomUUID(),
|
|
208
|
+
job_instance_id: jobInstanceId,
|
|
209
|
+
status: JobStatus.STARTING,
|
|
210
|
+
start_time: null as Date | null,
|
|
211
|
+
end_time: null as Date | null,
|
|
212
|
+
exit_code: '',
|
|
213
|
+
exit_message: '',
|
|
214
|
+
params: serializeContext(deepClone(params)),
|
|
215
|
+
};
|
|
216
|
+
const rows = await this.em().query(
|
|
217
|
+
`INSERT INTO "batch_job_execution" ("id", "job_instance_id", "status", "start_time", "end_time", "exit_code", "exit_message", "params")
|
|
218
|
+
VALUES ($1, $2, $3, NULL, NULL, $4, $5, $6)
|
|
219
|
+
RETURNING "id", "job_instance_id", "status", "start_time", "end_time", "exit_code", "exit_message", "params"`,
|
|
220
|
+
[exec.id, exec.job_instance_id, exec.status, exec.exit_code, exec.exit_message, exec.params],
|
|
221
|
+
) as JobExecutionRow[];
|
|
222
|
+
return mapJobExecution(rows[0]!);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async createExecutionAtomic(
|
|
226
|
+
name: string,
|
|
227
|
+
jobKey: string,
|
|
228
|
+
params: JobParameters,
|
|
229
|
+
): Promise<JobExecution> {
|
|
230
|
+
return this.dataSource.transaction(async (em) => {
|
|
231
|
+
// 1. Idempotent INSERT.
|
|
232
|
+
const instId = randomUUID();
|
|
233
|
+
await em.query(
|
|
234
|
+
`INSERT INTO "batch_job_instance" ("id", "job_name", "job_key", "created_at")
|
|
235
|
+
VALUES ($1, $2, $3, NOW())
|
|
236
|
+
ON CONFLICT ("job_name", "job_key") DO NOTHING`,
|
|
237
|
+
[instId, name, jobKey],
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
// 2. Lock the instance row.
|
|
241
|
+
const isSqlite = this.dataSource.options.type === 'better-sqlite3';
|
|
242
|
+
let instanceId: string;
|
|
243
|
+
if (isSqlite) {
|
|
244
|
+
const rows = await em.query(
|
|
245
|
+
`SELECT "id" FROM "batch_job_instance"
|
|
246
|
+
WHERE "job_name" = $1 AND "job_key" = $2
|
|
247
|
+
LIMIT 1`,
|
|
248
|
+
[name, jobKey],
|
|
249
|
+
) as Array<{ id: string }>;
|
|
250
|
+
if (rows.length === 0) {
|
|
251
|
+
throw new JobExecutionAlreadyRunningError(name);
|
|
252
|
+
}
|
|
253
|
+
instanceId = rows[0]!.id;
|
|
254
|
+
} else {
|
|
255
|
+
const rows = await em.query(
|
|
256
|
+
`SELECT "id" FROM "batch_job_instance"
|
|
257
|
+
WHERE "job_name" = $1 AND "job_key" = $2
|
|
258
|
+
FOR UPDATE SKIP LOCKED`,
|
|
259
|
+
[name, jobKey],
|
|
260
|
+
) as Array<{ id: string }>;
|
|
261
|
+
if (rows.length === 0) {
|
|
262
|
+
throw new JobExecutionAlreadyRunningError(name);
|
|
263
|
+
}
|
|
264
|
+
instanceId = rows[0]!.id;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// 3. Under the lock, verify no running execution.
|
|
268
|
+
const running = await em.query(
|
|
269
|
+
`SELECT "id" FROM "batch_job_execution"
|
|
270
|
+
WHERE "job_instance_id" = $1 AND "status" IN ($2, $3)
|
|
271
|
+
LIMIT 1`,
|
|
272
|
+
[instanceId, JobStatus.STARTING, JobStatus.STARTED],
|
|
273
|
+
) as Array<{ id: string }>;
|
|
274
|
+
if (running.length > 0) {
|
|
275
|
+
throw new JobExecutionAlreadyRunningError(running[0]!.id);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// 4. Create the new execution row.
|
|
279
|
+
const execId = randomUUID();
|
|
280
|
+
const inserted = await em.query(
|
|
281
|
+
`INSERT INTO "batch_job_execution" ("id", "job_instance_id", "status", "start_time", "end_time", "exit_code", "exit_message", "params")
|
|
282
|
+
VALUES ($1, $2, $3, NULL, NULL, '', '', $4)
|
|
283
|
+
RETURNING "id", "job_instance_id", "status", "start_time", "end_time", "exit_code", "exit_message", "params"`,
|
|
284
|
+
[execId, instanceId, JobStatus.STARTING, serializeContext(deepClone(params))],
|
|
285
|
+
) as JobExecutionRow[];
|
|
286
|
+
return mapJobExecution(inserted[0]!);
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async updateJobExecution(executionId: string, patch: JobExecutionPatch): Promise<void> {
|
|
291
|
+
const sets: string[] = [];
|
|
292
|
+
const values: unknown[] = [];
|
|
293
|
+
let i = 1;
|
|
294
|
+
if (patch.status !== undefined) { sets.push(`"status" = $${i++}`); values.push(patch.status); }
|
|
295
|
+
if (patch.startTime !== undefined) { sets.push(`"start_time" = $${i++}`); values.push(patch.startTime); }
|
|
296
|
+
if (patch.endTime !== undefined) { sets.push(`"end_time" = $${i++}`); values.push(patch.endTime); }
|
|
297
|
+
if (patch.exitCode !== undefined) { sets.push(`"exit_code" = $${i++}`); values.push(patch.exitCode); }
|
|
298
|
+
if (patch.exitMessage !== undefined) { sets.push(`"exit_message" = $${i++}`); values.push(patch.exitMessage); }
|
|
299
|
+
if (sets.length === 0) return;
|
|
300
|
+
values.push(executionId);
|
|
301
|
+
await this.em().query(
|
|
302
|
+
`UPDATE "batch_job_execution" SET ${sets.join(', ')} WHERE "id" = $${i}`,
|
|
303
|
+
values,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async getJobExecution(executionId: string): Promise<JobExecution | null> {
|
|
308
|
+
const rows = await this.em().query(
|
|
309
|
+
`SELECT "id", "job_instance_id", "status", "start_time", "end_time", "exit_code", "exit_message", "params"
|
|
310
|
+
FROM "batch_job_execution" WHERE "id" = $1 LIMIT 1`,
|
|
311
|
+
[executionId],
|
|
312
|
+
) as JobExecutionRow[];
|
|
313
|
+
return rows.length > 0 ? mapJobExecution(rows[0]!) : null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
override async getJobInstance(jobInstanceId: string): Promise<JobInstance | null> {
|
|
317
|
+
const rows = await this.em().query(
|
|
318
|
+
`SELECT "id", "job_name", "job_key", "created_at"
|
|
319
|
+
FROM "batch_job_instance"
|
|
320
|
+
WHERE "id" = $1
|
|
321
|
+
LIMIT 1`,
|
|
322
|
+
[jobInstanceId],
|
|
323
|
+
) as JobInstanceRow[];
|
|
324
|
+
return rows.length > 0 ? mapJobInstance(rows[0]!) : null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
override async findJobInstances(filter: JobInstanceFilter = {}): Promise<JobInstance[]> {
|
|
328
|
+
const where: string[] = [];
|
|
329
|
+
const values: unknown[] = [];
|
|
330
|
+
let i = 1;
|
|
331
|
+
if (filter.jobName !== undefined) {
|
|
332
|
+
where.push(`"job_name" = $${i++}`);
|
|
333
|
+
values.push(filter.jobName);
|
|
334
|
+
}
|
|
335
|
+
if (filter.jobKey !== undefined) {
|
|
336
|
+
where.push(`"job_key" = $${i++}`);
|
|
337
|
+
values.push(filter.jobKey);
|
|
338
|
+
}
|
|
339
|
+
const rows = await this.em().query(
|
|
340
|
+
`SELECT "id", "job_name", "job_key", "created_at"
|
|
341
|
+
FROM "batch_job_instance"
|
|
342
|
+
${where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''}
|
|
343
|
+
ORDER BY "created_at" ASC, "id" ASC`,
|
|
344
|
+
values,
|
|
345
|
+
) as JobInstanceRow[];
|
|
346
|
+
return rows.map(mapJobInstance);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
override async findJobExecutions(filter: JobExecutionFilter = {}): Promise<JobExecution[]> {
|
|
350
|
+
const where: string[] = [];
|
|
351
|
+
const values: unknown[] = [];
|
|
352
|
+
let i = 1;
|
|
353
|
+
if (filter.jobInstanceId !== undefined) {
|
|
354
|
+
where.push(`"job_instance_id" = $${i++}`);
|
|
355
|
+
values.push(filter.jobInstanceId);
|
|
356
|
+
}
|
|
357
|
+
if (filter.status !== undefined) {
|
|
358
|
+
const statuses = Array.isArray(filter.status) ? filter.status : [filter.status];
|
|
359
|
+
const placeholders = statuses.map(() => `$${i++}`);
|
|
360
|
+
where.push(`"status" IN (${placeholders.join(', ')})`);
|
|
361
|
+
values.push(...statuses);
|
|
362
|
+
}
|
|
363
|
+
if (filter.startedAfter !== undefined) {
|
|
364
|
+
where.push(`"start_time" >= $${i++}`);
|
|
365
|
+
values.push(filter.startedAfter);
|
|
366
|
+
}
|
|
367
|
+
if (filter.startedBefore !== undefined) {
|
|
368
|
+
where.push(`"start_time" <= $${i++}`);
|
|
369
|
+
values.push(filter.startedBefore);
|
|
370
|
+
}
|
|
371
|
+
const rows = await this.em().query(
|
|
372
|
+
`SELECT "id", "job_instance_id", "status", "start_time", "end_time", "exit_code", "exit_message", "params"
|
|
373
|
+
FROM "batch_job_execution"
|
|
374
|
+
${where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''}
|
|
375
|
+
ORDER BY "start_time" DESC NULLS LAST, "id" DESC`,
|
|
376
|
+
values,
|
|
377
|
+
) as JobExecutionRow[];
|
|
378
|
+
return rows.map(mapJobExecution);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async getRunningJobExecution(jobInstanceId: string): Promise<JobExecution | null> {
|
|
382
|
+
if (!jobInstanceId) return null;
|
|
383
|
+
const rows = await this.em().query(
|
|
384
|
+
`SELECT "id", "job_instance_id", "status", "start_time", "end_time", "exit_code", "exit_message", "params"
|
|
385
|
+
FROM "batch_job_execution"
|
|
386
|
+
WHERE "job_instance_id" = $1 AND "status" IN ($2, $3)
|
|
387
|
+
ORDER BY "start_time" DESC NULLS LAST LIMIT 1`,
|
|
388
|
+
[jobInstanceId, JobStatus.STARTING, JobStatus.STARTED],
|
|
389
|
+
) as JobExecutionRow[];
|
|
390
|
+
return rows.length > 0 ? mapJobExecution(rows[0]!) : null;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async createStepExecution(
|
|
394
|
+
jobExecutionId: string,
|
|
395
|
+
stepName: string,
|
|
396
|
+
): Promise<StepExecution> {
|
|
397
|
+
const stepId = randomUUID();
|
|
398
|
+
const rows = await this.em().query(
|
|
399
|
+
`INSERT INTO "batch_step_execution" ("id", "job_execution_id", "step_name", "status", "read_count", "write_count", "skip_count", "rollback_count", "commit_count", "exit_code", "exit_message", "created_at")
|
|
400
|
+
VALUES ($1, $2, $3, $4, 0, 0, 0, 0, 0, '', '', NOW())
|
|
401
|
+
RETURNING "id", "job_execution_id", "step_name", "status", "read_count", "write_count", "skip_count", "rollback_count", "commit_count", "exit_code", "exit_message", "created_at"`,
|
|
402
|
+
[stepId, jobExecutionId, stepName, StepStatus.STARTING],
|
|
403
|
+
) as StepExecutionRow[];
|
|
404
|
+
return mapStepExecution(rows[0]!);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async updateStepExecution(
|
|
408
|
+
stepExecutionId: string,
|
|
409
|
+
patch: StepExecutionPatch,
|
|
410
|
+
): Promise<void> {
|
|
411
|
+
const sets: string[] = [];
|
|
412
|
+
const values: unknown[] = [];
|
|
413
|
+
let i = 1;
|
|
414
|
+
if (patch.status !== undefined) { sets.push(`"status" = $${i++}`); values.push(patch.status); }
|
|
415
|
+
if (patch.readCount !== undefined) { sets.push(`"read_count" = $${i++}`); values.push(patch.readCount); }
|
|
416
|
+
if (patch.writeCount !== undefined) { sets.push(`"write_count" = $${i++}`); values.push(patch.writeCount); }
|
|
417
|
+
if (patch.skipCount !== undefined) { sets.push(`"skip_count" = $${i++}`); values.push(patch.skipCount); }
|
|
418
|
+
if (patch.rollbackCount !== undefined) { sets.push(`"rollback_count" = $${i++}`); values.push(patch.rollbackCount); }
|
|
419
|
+
if (patch.commitCount !== undefined) { sets.push(`"commit_count" = $${i++}`); values.push(patch.commitCount); }
|
|
420
|
+
if (patch.exitCode !== undefined) { sets.push(`"exit_code" = $${i++}`); values.push(patch.exitCode); }
|
|
421
|
+
if (patch.exitMessage !== undefined) { sets.push(`"exit_message" = $${i++}`); values.push(patch.exitMessage); }
|
|
422
|
+
if (sets.length === 0) return;
|
|
423
|
+
values.push(stepExecutionId);
|
|
424
|
+
await this.em().query(
|
|
425
|
+
`UPDATE "batch_step_execution" SET ${sets.join(', ')} WHERE "id" = $${i}`,
|
|
426
|
+
values,
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async getStepExecution(stepExecutionId: string): Promise<StepExecution | null> {
|
|
431
|
+
const rows = await this.em().query(
|
|
432
|
+
`SELECT "id", "job_execution_id", "step_name", "status", "read_count", "write_count", "skip_count", "rollback_count", "commit_count", "exit_code", "exit_message", "created_at"
|
|
433
|
+
FROM "batch_step_execution" WHERE "id" = $1 LIMIT 1`,
|
|
434
|
+
[stepExecutionId],
|
|
435
|
+
) as StepExecutionRow[];
|
|
436
|
+
return rows.length > 0 ? mapStepExecution(rows[0]!) : null;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
override async findStepExecutions(jobExecutionId: string): Promise<StepExecution[]> {
|
|
440
|
+
const rows = await this.em().query(
|
|
441
|
+
`SELECT "id", "job_execution_id", "step_name", "status", "read_count", "write_count", "skip_count", "rollback_count", "commit_count", "exit_code", "exit_message", "created_at"
|
|
442
|
+
FROM "batch_step_execution"
|
|
443
|
+
WHERE "job_execution_id" = $1
|
|
444
|
+
ORDER BY "created_at" ASC, "id" ASC`,
|
|
445
|
+
[jobExecutionId],
|
|
446
|
+
) as StepExecutionRow[];
|
|
447
|
+
return rows.map(mapStepExecution);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Find the most recently created step execution for the given
|
|
452
|
+
* `(jobExecutionId, stepName)` pair, or `null` when none exists.
|
|
453
|
+
* Insertion order is determined by the `created_at` column; the
|
|
454
|
+
* primary key is a v4 UUID which is random, so a `id DESC` order
|
|
455
|
+
* would not correspond to insertion time. The `created_at DESC,
|
|
456
|
+
* id DESC` secondary order keeps the result stable when two rows
|
|
457
|
+
* share the same `CURRENT_TIMESTAMP` resolution.
|
|
458
|
+
*/
|
|
459
|
+
async findLatestStepExecution(
|
|
460
|
+
jobExecutionId: string,
|
|
461
|
+
stepName: string,
|
|
462
|
+
): Promise<StepExecution | null> {
|
|
463
|
+
const rows = await this.em().query(
|
|
464
|
+
`SELECT "id", "job_execution_id", "step_name", "status", "read_count", "write_count", "skip_count", "rollback_count", "commit_count", "exit_code", "exit_message", "created_at"
|
|
465
|
+
FROM "batch_step_execution"
|
|
466
|
+
WHERE "job_execution_id" = $1 AND "step_name" = $2
|
|
467
|
+
ORDER BY "created_at" DESC, "id" DESC
|
|
468
|
+
LIMIT 1`,
|
|
469
|
+
[jobExecutionId, stepName],
|
|
470
|
+
) as StepExecutionRow[];
|
|
471
|
+
return rows.length > 0 ? mapStepExecution(rows[0]!) : null;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async getExecutionContext(scope: ExecutionScope): Promise<ExecutionContext> {
|
|
475
|
+
const key = scopeKey(scope);
|
|
476
|
+
if (key.startsWith('job::')) {
|
|
477
|
+
const rows = await this.em().query(
|
|
478
|
+
`SELECT "data", "version" FROM "batch_job_execution_context" WHERE "job_execution_id" = $1 LIMIT 1`,
|
|
479
|
+
[key.slice(5)],
|
|
480
|
+
) as ContextRow[];
|
|
481
|
+
if (rows.length > 0) {
|
|
482
|
+
return {
|
|
483
|
+
data: rows[0]!.data.length > 0 ? deserializeContext(rows[0]!.data) : null,
|
|
484
|
+
version: rows[0]!.version,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
} else {
|
|
488
|
+
const rows = await this.em().query(
|
|
489
|
+
`SELECT "data", "version" FROM "batch_step_execution_context" WHERE "step_execution_id" = $1 LIMIT 1`,
|
|
490
|
+
[key.slice(6)],
|
|
491
|
+
) as ContextRow[];
|
|
492
|
+
if (rows.length > 0) {
|
|
493
|
+
return {
|
|
494
|
+
data: rows[0]!.data.length > 0 ? deserializeContext(rows[0]!.data) : null,
|
|
495
|
+
version: rows[0]!.version,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return { data: null, version: 0 };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async saveExecutionContext(
|
|
503
|
+
scope: ExecutionScope,
|
|
504
|
+
ctx: ExecutionContext,
|
|
505
|
+
version?: number,
|
|
506
|
+
): Promise<void> {
|
|
507
|
+
assertJsonSerializable(ctx.data);
|
|
508
|
+
const key = scopeKey(scope);
|
|
509
|
+
const serialized = serializeContext(deepClone(ctx.data));
|
|
510
|
+
if (key.startsWith('job::')) {
|
|
511
|
+
const jobExecutionId = key.slice(5);
|
|
512
|
+
const existing = await this.em().query(
|
|
513
|
+
`SELECT "version" FROM "batch_job_execution_context" WHERE "job_execution_id" = $1 LIMIT 1`,
|
|
514
|
+
[jobExecutionId],
|
|
515
|
+
) as ContextRow[];
|
|
516
|
+
const nextVersion = version !== undefined ? version : (existing.length > 0 ? existing[0]!.version + 1 : 0);
|
|
517
|
+
if (existing.length > 0) {
|
|
518
|
+
await this.em().query(
|
|
519
|
+
`UPDATE "batch_job_execution_context" SET "data" = $1, "version" = $2 WHERE "job_execution_id" = $3`,
|
|
520
|
+
[serialized, nextVersion, jobExecutionId],
|
|
521
|
+
);
|
|
522
|
+
} else {
|
|
523
|
+
await this.em().query(
|
|
524
|
+
`INSERT INTO "batch_job_execution_context" ("job_execution_id", "data", "version") VALUES ($1, $2, $3)`,
|
|
525
|
+
[jobExecutionId, serialized, nextVersion],
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
} else {
|
|
529
|
+
const stepExecutionId = key.slice(6);
|
|
530
|
+
const existing = await this.em().query(
|
|
531
|
+
`SELECT "version" FROM "batch_step_execution_context" WHERE "step_execution_id" = $1 LIMIT 1`,
|
|
532
|
+
[stepExecutionId],
|
|
533
|
+
) as ContextRow[];
|
|
534
|
+
const nextVersion = version !== undefined ? version : (existing.length > 0 ? existing[0]!.version + 1 : 0);
|
|
535
|
+
if (existing.length > 0) {
|
|
536
|
+
await this.em().query(
|
|
537
|
+
`UPDATE "batch_step_execution_context" SET "data" = $1, "version" = $2 WHERE "step_execution_id" = $3`,
|
|
538
|
+
[serialized, nextVersion, stepExecutionId],
|
|
539
|
+
);
|
|
540
|
+
} else {
|
|
541
|
+
await this.em().query(
|
|
542
|
+
`INSERT INTO "batch_step_execution_context" ("step_execution_id", "data", "version") VALUES ($1, $2, $3)`,
|
|
543
|
+
[stepExecutionId, serialized, nextVersion],
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Inject, Injectable } from '@nestjs/common';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { DataSource, EntityManager } from 'typeorm';
|
|
4
|
+
import {
|
|
5
|
+
TransactionManager,
|
|
6
|
+
type TransactionContext,
|
|
7
|
+
} from '@nest-batch/core';
|
|
8
|
+
import { TypeOrmDriverProvider } from '../typeorm.driver-provider';
|
|
9
|
+
|
|
10
|
+
export interface TypeOrmTransactionContext extends TransactionContext {
|
|
11
|
+
readonly isActive: true;
|
|
12
|
+
readonly id: string;
|
|
13
|
+
readonly entityManager: EntityManager;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* TransactionManager bound to TypeORM 1.0.0's `DataSource.transaction()`.
|
|
18
|
+
*
|
|
19
|
+
* Wraps the user callback in a real DB transaction. On success the
|
|
20
|
+
* transaction commits; if `fn(ctx)` throws, the transaction rolls back.
|
|
21
|
+
*
|
|
22
|
+
* The transactional EM is the one passed to the callback —
|
|
23
|
+
* consumers should use that `entityManager` (not any
|
|
24
|
+
* globally-injected one) so that all reads and writes are part of
|
|
25
|
+
* the same transaction.
|
|
26
|
+
*/
|
|
27
|
+
@Injectable()
|
|
28
|
+
export class TypeOrmTransactionManager extends TransactionManager {
|
|
29
|
+
constructor(
|
|
30
|
+
@Inject(TypeOrmDriverProvider) private readonly dataSource: DataSource,
|
|
31
|
+
) {
|
|
32
|
+
super();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async withTransaction<T>(
|
|
36
|
+
fn: (ctx: TypeOrmTransactionContext) => Promise<T>,
|
|
37
|
+
): Promise<T> {
|
|
38
|
+
return this.dataSource.transaction(async (txEm) => {
|
|
39
|
+
const ctx: TypeOrmTransactionContext = {
|
|
40
|
+
isActive: true,
|
|
41
|
+
id: randomUUID(),
|
|
42
|
+
entityManager: txEm,
|
|
43
|
+
};
|
|
44
|
+
return fn(ctx);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `TypeOrmDriverProvider` — the abstract injection token the
|
|
3
|
+
* `@nest-batch/postgresql` (and future `@nest-batch/mysql`) driver
|
|
4
|
+
* sibling packages bind to the concrete `DataSource` for the chosen
|
|
5
|
+
* database.
|
|
6
|
+
*
|
|
7
|
+
* This package (`@nest-batch/typeorm`) is **driver-agnostic**: it
|
|
8
|
+
* does not import `@nestjs/typeorm` (which carries the Postgres
|
|
9
|
+
* driver) or any MySQL-specific `@nestjs/typeorm` companion. Instead,
|
|
10
|
+
* it exports the `TypeOrmDriverProvider` symbol as a `Provider`
|
|
11
|
+
* token; the driver sibling package binds the token to a concrete
|
|
12
|
+
* `DataSource` in its own `forRoot()` factory.
|
|
13
|
+
*
|
|
14
|
+
* The `TypeOrmJobRepository` / `TypeOrmTransactionManager` classes
|
|
15
|
+
* inject the token via the standard `@Inject(TypeOrmDriverProvider)`
|
|
16
|
+
* decorator and cast the resolved value to the host-owned
|
|
17
|
+
* `DataSource` shape. This mirrors the
|
|
18
|
+
* `@nestjs/typeorm` pattern of "host owns the connection, adapter
|
|
19
|
+
* owns the repository".
|
|
20
|
+
*/
|
|
21
|
+
export const TypeOrmDriverProvider: symbol = Symbol.for(
|
|
22
|
+
'@nest-batch/typeorm/TypeOrmDriverProvider',
|
|
23
|
+
);
|