@ultimat3/jobs 11.3.0 → 13.0.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/CLAUDE.md +80 -0
- package/README.md +149 -0
- package/package.json +5 -5
- package/src/driver-memory.ts +7 -3
- package/src/driver-pg.ts +3 -3
- package/src/driver.ts +21 -0
- package/src/errors.ts +66 -0
- package/src/export-errors.ts +50 -0
- package/src/export-format.ts +132 -0
- package/src/export-pass.ts +178 -0
- package/src/export-sink.ts +105 -0
- package/src/export.ts +187 -0
- package/src/index.ts +52 -1
- package/src/webhook-errors.ts +170 -0
- package/src/webhook-ledger.ts +102 -0
- package/src/webhook.ts +378 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// One `exportRows()` pass: the iteration, its durable checkpoints, and the manifest that closes
|
|
2
|
+
// the artifact. Split out of `export.ts` for the reason `backfill-pass.ts` is split out of
|
|
3
|
+
// `backfill.ts` — that file declares an export, this one runs it.
|
|
4
|
+
//
|
|
5
|
+
// THE PAGING IS `backfill-pass.ts`'s, not a second idiom. `inBatches()` reads the source one
|
|
6
|
+
// statement per page, each page runs inside its own `step.run`, and what a step persists is the
|
|
7
|
+
// CURSOR and two counters, never the page: `steps.ts` hands a completed step's output back for the
|
|
8
|
+
// whole run, so checkpointing rows would retain every row already exported until the job ended —
|
|
9
|
+
// which is the leak that turns an export of a large table into an OOM.
|
|
10
|
+
//
|
|
11
|
+
// THE PART KEY IS WHAT MAKES AT-LEAST-ONCE SAFE. A page is named by its INDEX, so a page that ran
|
|
12
|
+
// twice rewrites the same object with the same bytes. There is no idempotency argument to make
|
|
13
|
+
// about the app's rows and no append anywhere, because a duplicate part cannot be expressed.
|
|
14
|
+
|
|
15
|
+
import { assert, logger } from '@ultimat3/core';
|
|
16
|
+
import type { BatchIterator } from '@ultimat3/entity';
|
|
17
|
+
import { JobAbortedError } from './errors';
|
|
18
|
+
import type { ExportPlan, ExportReport } from './export';
|
|
19
|
+
import { ExportPartTooLargeError } from './export-errors';
|
|
20
|
+
import { encodeExportPage } from './export-format';
|
|
21
|
+
import type { ExportManifest } from './export-sink';
|
|
22
|
+
import { exportManifestKey, exportPartKey } from './export-sink';
|
|
23
|
+
import type { JobRunArgs } from './job';
|
|
24
|
+
|
|
25
|
+
/** Every page is a step, and this is the name it is checkpointed under. */
|
|
26
|
+
const STEP_PREFIX = 'page:';
|
|
27
|
+
/** The manifest is a step too, so a retry after a written manifest does not write a second one. */
|
|
28
|
+
const MANIFEST_STEP = 'manifest';
|
|
29
|
+
|
|
30
|
+
/** What a page persists: a bounded position and two counters. Never the page. See the header. */
|
|
31
|
+
interface Checkpoint {
|
|
32
|
+
/** Where the next page starts; `null` once the source is exhausted. */
|
|
33
|
+
readonly cursor: string | null;
|
|
34
|
+
readonly rows: number;
|
|
35
|
+
readonly bytes: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface Iteration<Row> {
|
|
39
|
+
readonly batches: BatchIterator<Row>;
|
|
40
|
+
readonly pull: AsyncIterator<readonly Row[]>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const fieldOf = (value: unknown, key: string): unknown =>
|
|
44
|
+
typeof value === 'object' && value !== null ? (value as Record<string, unknown>)[key] : undefined;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* `steps.ts` hands a completed step's output back through an unchecked `as T`, so a checkpoint READ
|
|
48
|
+
* from storage is this shape by claim and never by check. Checked here because the failure is
|
|
49
|
+
* silent and expensive in both directions: an absent cursor is not `null`, so the loop would reopen
|
|
50
|
+
* the source at the top and export the whole table a second time into part 0 onward.
|
|
51
|
+
*/
|
|
52
|
+
function asCheckpoint(value: unknown, step: string): Checkpoint {
|
|
53
|
+
const cursor = fieldOf(value, 'cursor');
|
|
54
|
+
const rows = fieldOf(value, 'rows');
|
|
55
|
+
const bytes = fieldOf(value, 'bytes');
|
|
56
|
+
assert(
|
|
57
|
+
(cursor === null || typeof cursor === 'string') &&
|
|
58
|
+
typeof rows === 'number' &&
|
|
59
|
+
typeof bytes === 'number',
|
|
60
|
+
`step "${step}" replayed ${JSON.stringify(value)}, which is not an export checkpoint`,
|
|
61
|
+
`x jobs show <jobId> --json prints the run's steps — a run id whose "${step}" was written by something other than this export has to be retired, not resumed`,
|
|
62
|
+
);
|
|
63
|
+
return { cursor, rows, bytes };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function exportPass<Row, I>(
|
|
67
|
+
plan: ExportPlan<Row, I>,
|
|
68
|
+
args: JobRunArgs<I>,
|
|
69
|
+
): Promise<ExportReport> {
|
|
70
|
+
const { definition, size, maxPartBytes, pace } = plan;
|
|
71
|
+
const { step, runId, input, ctx } = args;
|
|
72
|
+
const name = definition.name;
|
|
73
|
+
const prefix = definition.prefix(input);
|
|
74
|
+
assert(
|
|
75
|
+
prefix.length > 0 && !prefix.startsWith('/') && !prefix.includes('..'),
|
|
76
|
+
`export "${name}" prefix() answered "${prefix}", which is not a key prefix`,
|
|
77
|
+
`return a relative, non-empty prefix from prefix() on exportRows("${name}") — "exports/<orgId>/<exportId>" is the shape, and a leading slash or a ".." reaches keys this export does not own`,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
let cursor: string | null = null;
|
|
81
|
+
let rows = 0;
|
|
82
|
+
let bytes = 0;
|
|
83
|
+
let parts = 0;
|
|
84
|
+
let live: Iteration<Row> | undefined;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The iteration positioned at `cursor`, opened on the first page this attempt actually runs, so a
|
|
88
|
+
* resumed pass sends no statement for a page it already wrote. `batches.cursor` IS where the next
|
|
89
|
+
* statement starts, so comparing it with the checkpoint's is the whole staleness test.
|
|
90
|
+
*/
|
|
91
|
+
const iterate = async (): Promise<Iteration<Row>> => {
|
|
92
|
+
if (live !== undefined && live.batches.cursor === cursor) return live;
|
|
93
|
+
await live?.batches.close();
|
|
94
|
+
const opened = definition.source({ input, ctx }).after(cursor).inBatches(size);
|
|
95
|
+
live = { batches: opened, pull: opened[Symbol.asyncIterator]() };
|
|
96
|
+
return live;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
for (let index = 0; ; index += 1) {
|
|
101
|
+
const stepName = `${STEP_PREFIX}${index}`;
|
|
102
|
+
const checkpoint = asCheckpoint(
|
|
103
|
+
await step.run(stepName, async (signal): Promise<Checkpoint> => {
|
|
104
|
+
// INSIDE the body, which is the whole of it: a completed step is served from storage
|
|
105
|
+
// without its body running, so an attempt resuming at page 500 replays 500 checkpoints
|
|
106
|
+
// and pays none of their pauses.
|
|
107
|
+
await pace?.wait({ signal, step: stepName });
|
|
108
|
+
const iteration = await iterate();
|
|
109
|
+
const next = await iteration.pull.next();
|
|
110
|
+
if (next.done === true) return { cursor: null, rows: 0, bytes: 0 };
|
|
111
|
+
const body = encodeExportPage({
|
|
112
|
+
subject: name,
|
|
113
|
+
format: definition.format,
|
|
114
|
+
columns: definition.columns,
|
|
115
|
+
records: next.value.map((record) => definition.row(record)),
|
|
116
|
+
// Part 0 only: the header belongs in the file once, and every line in both formats
|
|
117
|
+
// ends in a newline, so the parts concatenate into one valid file.
|
|
118
|
+
header: index === 0,
|
|
119
|
+
});
|
|
120
|
+
if (body.byteLength > maxPartBytes) {
|
|
121
|
+
throw new ExportPartTooLargeError({
|
|
122
|
+
export: name,
|
|
123
|
+
part: index,
|
|
124
|
+
bytes: body.byteLength,
|
|
125
|
+
maxBytes: maxPartBytes,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
// Asked before the write and not only before the read: past the deadline this attempt no
|
|
129
|
+
// longer owns the run, and `steps.ts` would refuse the checkpoint anyway — so a `put`
|
|
130
|
+
// here is an object written by a worker whose successor is already re-running the page.
|
|
131
|
+
if (signal.aborted) throw new JobAbortedError({ job: name, step: stepName });
|
|
132
|
+
await definition.sink.put(exportPartKey(prefix, index, definition.format), body);
|
|
133
|
+
return {
|
|
134
|
+
cursor: iteration.batches.cursor,
|
|
135
|
+
rows: next.value.length,
|
|
136
|
+
bytes: body.byteLength,
|
|
137
|
+
};
|
|
138
|
+
}),
|
|
139
|
+
stepName,
|
|
140
|
+
);
|
|
141
|
+
rows += checkpoint.rows;
|
|
142
|
+
bytes += checkpoint.bytes;
|
|
143
|
+
cursor = checkpoint.cursor;
|
|
144
|
+
// `inBatches()` never yields an empty page, so `rows` is what tells a written part from the
|
|
145
|
+
// one step an exhausted source costs.
|
|
146
|
+
if (checkpoint.rows > 0) parts += 1;
|
|
147
|
+
if (cursor === null) break;
|
|
148
|
+
}
|
|
149
|
+
} finally {
|
|
150
|
+
// Whatever the iteration holds belongs to this attempt, and an attempt that failed, was
|
|
151
|
+
// cancelled or finished is done with it either way.
|
|
152
|
+
await live?.batches.close();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const manifestKey = exportManifestKey(prefix);
|
|
156
|
+
// A step of its own, so a retry after the manifest landed does not write a second one — and so
|
|
157
|
+
// that "the artifact is closed" is a checkpoint like every other fact this pass records.
|
|
158
|
+
await step.run(MANIFEST_STEP, async (): Promise<null> => {
|
|
159
|
+
const manifest: ExportManifest = {
|
|
160
|
+
export: name,
|
|
161
|
+
runId,
|
|
162
|
+
prefix,
|
|
163
|
+
format: definition.format,
|
|
164
|
+
columns: [...definition.columns],
|
|
165
|
+
parts,
|
|
166
|
+
rows,
|
|
167
|
+
bytes,
|
|
168
|
+
// `toISOString()` is UTC by definition and carries its zone in the string — the one date in
|
|
169
|
+
// this framework that needs no `timeZone` argument, because it names no locale's calendar.
|
|
170
|
+
completedAt: new Date(plan.nowMs()).toISOString(),
|
|
171
|
+
};
|
|
172
|
+
await definition.sink.put(manifestKey, new TextEncoder().encode(JSON.stringify(manifest)));
|
|
173
|
+
return null;
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
logger.info('jobs.export.completed', { export: name, prefix, parts, rows, bytes });
|
|
177
|
+
return { name, prefix, manifestKey, parts, rows, bytes };
|
|
178
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Where an export's bytes land, and what the artifact says about itself when it is finished.
|
|
2
|
+
//
|
|
3
|
+
// A SEAM and not a `@ultimat3/storage` import, for the reason `PurgeTarget` is one: this package
|
|
4
|
+
// holds no storage dependency, and taking one so a queue could name a disk would put the object
|
|
5
|
+
// store on tier 3's import graph. A `StorageDriver` satisfies this by having the method it already
|
|
6
|
+
// has — `put(key, body, options?)` with a `Uint8Array` as a legal `StorageBody`
|
|
7
|
+
// (`packages/storage/src/driver.ts:117`) — so `disk('exports')` IS an `ExportSink`.
|
|
8
|
+
|
|
9
|
+
import { assert } from '@ultimat3/core';
|
|
10
|
+
import type { ExportFormat } from './export-format';
|
|
11
|
+
import { EXPORT_EXTENSION } from './export-format';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* One object, written whole.
|
|
15
|
+
*
|
|
16
|
+
* ONE OBJECT PER PAGE is the design, not a simplification. `put()` buffers by construction — its
|
|
17
|
+
* own header says the server-side path "is for objects that FIT IN MEMORY" — so an export that
|
|
18
|
+
* wrote one object would hold the entire dataset, which is the exact failure this factory exists
|
|
19
|
+
* to prevent. A page is bounded, so a page-sized object is bounded, and the artifact is the parts
|
|
20
|
+
* concatenated: both formats end every line in a newline so that concatenation is a valid file.
|
|
21
|
+
*
|
|
22
|
+
* It is also what makes at-least-once SAFE here. The key is derived from the page INDEX, so a
|
|
23
|
+
* replayed page rewrites the same key with the same bytes — an overwrite, never an append. No
|
|
24
|
+
* idempotency argument is needed about the app's rows, because a duplicate part is impossible to
|
|
25
|
+
* express.
|
|
26
|
+
*/
|
|
27
|
+
export interface ExportSink {
|
|
28
|
+
/**
|
|
29
|
+
* `Promise<unknown>`, never `Promise<void>`. `StorageDriver.put` answers
|
|
30
|
+
* `Promise<StorageObject>` (`packages/storage/src/driver.ts:117`), and a `Promise<void>` return
|
|
31
|
+
* makes it **not assignable** — so this file's own header, `export.ts` and the README all claimed
|
|
32
|
+
* `disk('exports')` IS an `ExportSink` while the type refused the assignment. Measured:
|
|
33
|
+
* `TS2322: Type 'Promise<StorageObject>' is not assignable to type 'Promise<void>'`.
|
|
34
|
+
*
|
|
35
|
+
* The pass discards the answer either way; `unknown` is what says so without forbidding a driver
|
|
36
|
+
* from returning one. `scripts/export-sink.test.ts` compiles the assignment, because three
|
|
37
|
+
* comments asserting it and nothing checking it is what let this ship.
|
|
38
|
+
*/
|
|
39
|
+
put(key: string, body: Uint8Array): Promise<unknown>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Zero-padded so a lexical listing of the parts is their order. */
|
|
43
|
+
export const EXPORT_PART_DIGITS = 5;
|
|
44
|
+
|
|
45
|
+
export const exportPartKey = (prefix: string, index: number, format: ExportFormat): string =>
|
|
46
|
+
`${prefix}/part-${String(index).padStart(EXPORT_PART_DIGITS, '0')}.${EXPORT_EXTENSION[format]}`;
|
|
47
|
+
|
|
48
|
+
export const exportManifestKey = (prefix: string): string => `${prefix}/manifest.json`;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* What the artifact says about itself. A COUNT of parts and never a list of them: the keys are
|
|
52
|
+
* derived from the index, so `exportPartKey(prefix, i, format)` rebuilds every one — and a list
|
|
53
|
+
* would be the one thing in this whole pass that grows with the size of the export.
|
|
54
|
+
*/
|
|
55
|
+
export interface ExportManifest {
|
|
56
|
+
readonly export: string;
|
|
57
|
+
readonly runId: string;
|
|
58
|
+
readonly prefix: string;
|
|
59
|
+
readonly format: ExportFormat;
|
|
60
|
+
readonly columns: readonly string[];
|
|
61
|
+
readonly parts: number;
|
|
62
|
+
readonly rows: number;
|
|
63
|
+
readonly bytes: number;
|
|
64
|
+
/** ISO 8601, UTC. The one instant an export has, and it is not a date anybody formats. */
|
|
65
|
+
readonly completedAt: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface MemoryExportSink extends ExportSink {
|
|
69
|
+
/** Key -> the bytes last written under it. A rewritten part replaces, exactly as S3 would. */
|
|
70
|
+
objects(): ReadonlyMap<string, Uint8Array>;
|
|
71
|
+
/** Every `put` in order, key and length — a rewrite appears twice, which is what proves replay. */
|
|
72
|
+
writes(): readonly { readonly key: string; readonly bytes: number }[];
|
|
73
|
+
reset(): void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The dev and test sink, and honest about being one: one heap, nothing survives a restart. It is
|
|
78
|
+
* deliberately UNBOUNDED, unlike `memoryWebhookLedger`'s ring — a sink that silently dropped parts
|
|
79
|
+
* would make a test of "the artifact is complete" pass over an artifact that is not.
|
|
80
|
+
*/
|
|
81
|
+
export function memoryExportSink(
|
|
82
|
+
options: { readonly onPut?: (key: string, body: Uint8Array) => void } = {},
|
|
83
|
+
): MemoryExportSink {
|
|
84
|
+
const objects = new Map<string, Uint8Array>();
|
|
85
|
+
const writes: { key: string; bytes: number }[] = [];
|
|
86
|
+
return {
|
|
87
|
+
put(key: string, body: Uint8Array): Promise<void> {
|
|
88
|
+
assert(
|
|
89
|
+
key.length > 0,
|
|
90
|
+
'an export sink was handed an empty key',
|
|
91
|
+
'return a non-empty prefix from prefix() on the exportRows() declaration',
|
|
92
|
+
);
|
|
93
|
+
options.onPut?.(key, body);
|
|
94
|
+
objects.set(key, body);
|
|
95
|
+
writes.push({ key, bytes: body.byteLength });
|
|
96
|
+
return Promise.resolve();
|
|
97
|
+
},
|
|
98
|
+
objects: (): ReadonlyMap<string, Uint8Array> => new Map(objects),
|
|
99
|
+
writes: (): readonly { readonly key: string; readonly bytes: number }[] => [...writes],
|
|
100
|
+
reset(): void {
|
|
101
|
+
objects.clear();
|
|
102
|
+
writes.length = 0;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
package/src/export.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// `exportRows()` — a paged read streamed to object storage with a resumable cursor, declared as a
|
|
2
|
+
// `job` and NOT as a ninth primitive. An export is durable background work with an input schema, a
|
|
3
|
+
// retry policy, an idempotency key and a queue, which is the definition of a `job` — so this file
|
|
4
|
+
// is a FACTORY over `job()`, exactly as `backfill()`, `purge()` and `webhook()` are and as `llm()`
|
|
5
|
+
// is over `action()`. That is what gives an export `.enqueue()`, the worker's cancellation, the
|
|
6
|
+
// dead-letter path, `x jobs show` and a manifest row without a line here.
|
|
7
|
+
//
|
|
8
|
+
// WHY IT IS THE FRAMEWORK'S: the memory bound. Accumulating a result set before writing it is the
|
|
9
|
+
// failure this exists to prevent, and it is the failure every hand-rolled exporter starts with —
|
|
10
|
+
// `const all = await repo.all(); await disk.put(key, csv(all))` works on the developer's 200 rows
|
|
11
|
+
// and OOM-kills the pod on the customer's two million. What ships is the paging, the checkpoint,
|
|
12
|
+
// the one-object-per-page artifact and the csv the reviewer's spreadsheet will not execute. What
|
|
13
|
+
// never ships is which columns leave the building, or who may ask for them.
|
|
14
|
+
//
|
|
15
|
+
// The declaration lives here and the pass lives in `export-pass.ts` — the same split `backfill.ts`
|
|
16
|
+
// and `backfill-pass.ts` already have.
|
|
17
|
+
|
|
18
|
+
import type { Clock, Ctx } from '@ultimat3/core';
|
|
19
|
+
import { assert, systemClock } from '@ultimat3/core';
|
|
20
|
+
import type { ReadBuilder } from '@ultimat3/entity';
|
|
21
|
+
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
22
|
+
import type { Pacer } from './backfill-rate';
|
|
23
|
+
import { createPacer } from './backfill-rate';
|
|
24
|
+
import type { DurationInput } from './clock';
|
|
25
|
+
import { nowMs } from './clock';
|
|
26
|
+
import type { ExportFormat, ExportRecord } from './export-format';
|
|
27
|
+
import { EXPORT_FORMATS } from './export-format';
|
|
28
|
+
import { exportPass } from './export-pass';
|
|
29
|
+
import type { ExportSink } from './export-sink';
|
|
30
|
+
import type { JobHandle } from './job';
|
|
31
|
+
import { job } from './job';
|
|
32
|
+
import type { RetryPolicy } from './retry';
|
|
33
|
+
import { DEFAULT_RETRY } from './retry';
|
|
34
|
+
import type { JobTenant } from './tenant';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Rows per statement, per durable step and per part. The same reasoning `DEFAULT_BACKFILL_BATCH`
|
|
38
|
+
* carries: one statement's worth of work a worker can finish inside a lease. Smaller than a
|
|
39
|
+
* backfill's because every page here is also an OBJECT — a page is encoded whole before it is
|
|
40
|
+
* written, so the page size is the heap bound as well as the statement size.
|
|
41
|
+
*/
|
|
42
|
+
export const DEFAULT_EXPORT_BATCH = 500;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The largest a single part may encode to. A heap bound, not a file-size preference: a page of 500
|
|
46
|
+
* rows that are each 2 KB is a megabyte held at once, and a row that is a megabyte of JSON turns
|
|
47
|
+
* the same page into half a gigabyte. Past it the pass refuses (`X_EXPORT_PART_TOO_LARGE`) instead
|
|
48
|
+
* of finding out on a pod with a memory limit.
|
|
49
|
+
*/
|
|
50
|
+
export const DEFAULT_EXPORT_MAX_PART_BYTES = 8 * 1_024 * 1_024;
|
|
51
|
+
|
|
52
|
+
export interface ExportDefinition<Row, I> {
|
|
53
|
+
/**
|
|
54
|
+
* REQUIRED, unlike a job's. An export's name is a durable key — the queue row, the step trace and
|
|
55
|
+
* every part's own step all carry it — so it is never left to whichever export name a module used.
|
|
56
|
+
*/
|
|
57
|
+
readonly name: string;
|
|
58
|
+
/**
|
|
59
|
+
* The payload, declared by the APP. Unlike `backfill()`, which takes no parameters, an export is
|
|
60
|
+
* always an export OF something: an org, a date range, a saved filter. A pointer, never a record
|
|
61
|
+
* — what to read is resolved from the app's own tables inside `source`.
|
|
62
|
+
*/
|
|
63
|
+
readonly input: StandardSchemaV1<unknown, I>;
|
|
64
|
+
/**
|
|
65
|
+
* REQUIRED, exactly as on `job()`, and the security boundary of this whole feature: an export
|
|
66
|
+
* concentrates a tenant's rows into ONE downloadable object, so a pass that runs under the wrong
|
|
67
|
+
* org is the worst shape a cross-tenant read can take.
|
|
68
|
+
*
|
|
69
|
+
* `tenant: 'none'` STRIPS the org and every tenant-scoped read inside the pass then fails closed
|
|
70
|
+
* with `X_TENANCY_ACTOR_ORG_REQUIRED` — which is correct and is deliberately NOT relaxed.
|
|
71
|
+
* `backfill()` gets a cross-tenant scope opened for it (`backfill-scope.ts`) because its lazy
|
|
72
|
+
* chain leaves an author nothing to wrap; an export gets no such grant, and the difference is
|
|
73
|
+
* the direction of the data. A sweep that rewrites every tenant's rows is an operator action
|
|
74
|
+
* with an audit trail; an export that READS every tenant's rows writes them into one artifact
|
|
75
|
+
* somebody can download. Use `'none'` for a table that has no tenant column at all.
|
|
76
|
+
*/
|
|
77
|
+
readonly tenant: JobTenant<I>;
|
|
78
|
+
/**
|
|
79
|
+
* The rows to export, as a chain: `({ input }) => db.orders.where({ orgId: input.orgId })`. Read
|
|
80
|
+
* once per attempt and never enqueued, so what a run exports cannot drift from what was declared.
|
|
81
|
+
* `orderBy` is optional — the driver's own total order ends in the primary key, which is what
|
|
82
|
+
* lets every page resume from the last one's cursor.
|
|
83
|
+
*/
|
|
84
|
+
source(args: { readonly input: I; readonly ctx: Ctx }): ReadBuilder<Row>;
|
|
85
|
+
/** The artifact's key prefix: `<prefix>/part-00000.csv`, `<prefix>/manifest.json`. */
|
|
86
|
+
prefix(input: I): string;
|
|
87
|
+
readonly format: ExportFormat;
|
|
88
|
+
/**
|
|
89
|
+
* The columns, in order, and the app's alone. Both encoders project through this list, so a
|
|
90
|
+
* `row()` answering a key it does not carry is refused rather than dropped — which is what stops
|
|
91
|
+
* `row: (r) => ({ ...r })` shipping every column the entity gains from the next migration on.
|
|
92
|
+
*/
|
|
93
|
+
readonly columns: readonly string[];
|
|
94
|
+
/** One row -> its cells. Where a date gets its explicit zone and a Money its currency. */
|
|
95
|
+
row(record: Row): ExportRecord;
|
|
96
|
+
/** Where the parts land. A `StorageDriver` satisfies this by having the method it already has. */
|
|
97
|
+
readonly sink: ExportSink;
|
|
98
|
+
/** Rows per statement, per step and per part. Defaults to `DEFAULT_EXPORT_BATCH`. */
|
|
99
|
+
readonly batch?: number;
|
|
100
|
+
/** Defaults to `DEFAULT_EXPORT_MAX_PART_BYTES`. */
|
|
101
|
+
readonly maxPartBytes?: number;
|
|
102
|
+
/**
|
|
103
|
+
* Pages per second. NO DEFAULT, which is where an export differs from a backfill: that one
|
|
104
|
+
* throttles by default because it competes for WRITE capacity on the rows the app is still
|
|
105
|
+
* serving, and this is a bounded read somebody is usually waiting for. Declare it for an export
|
|
106
|
+
* big enough to matter to the pool; there is no way to spell "unthrottled" except by omitting it.
|
|
107
|
+
*/
|
|
108
|
+
readonly rate?: number;
|
|
109
|
+
/** The clock the manifest's `completedAt` and the pacer are read from. */
|
|
110
|
+
readonly clock?: Clock;
|
|
111
|
+
readonly queue?: string;
|
|
112
|
+
readonly retry?: RetryPolicy;
|
|
113
|
+
/** Per attempt, not per pass: a resumed attempt picks up at the last checkpoint. */
|
|
114
|
+
readonly timeout?: DurationInput;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** What one completed pass reports — bounded, so `x jobs show` can print it. */
|
|
118
|
+
export interface ExportReport {
|
|
119
|
+
readonly name: string;
|
|
120
|
+
readonly prefix: string;
|
|
121
|
+
readonly manifestKey: string;
|
|
122
|
+
readonly parts: number;
|
|
123
|
+
readonly rows: number;
|
|
124
|
+
readonly bytes: number;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Everything fixed at declaration, validated and built once. Read by `exportPass`. */
|
|
128
|
+
export interface ExportPlan<Row, I> {
|
|
129
|
+
readonly definition: ExportDefinition<Row, I>;
|
|
130
|
+
readonly size: number;
|
|
131
|
+
readonly maxPartBytes: number;
|
|
132
|
+
/** Absent unless a `rate` was declared — see `ExportDefinition.rate`. */
|
|
133
|
+
readonly pace: Pacer | undefined;
|
|
134
|
+
nowMs(): number;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function exportRows<Row, I>(definition: ExportDefinition<Row, I>): JobHandle<I> {
|
|
138
|
+
const size = definition.batch ?? DEFAULT_EXPORT_BATCH;
|
|
139
|
+
// Refused where it was written. `inBatches()` refuses the same number one statement in, which for
|
|
140
|
+
// an export means a dead-lettered job and a stack trace instead of a failing build.
|
|
141
|
+
assert(
|
|
142
|
+
Number.isSafeInteger(size) && size >= 1,
|
|
143
|
+
`export "${definition.name}" declares batch: ${String(size)} — a batch is a whole number of rows, at least one`,
|
|
144
|
+
`set batch: ${DEFAULT_EXPORT_BATCH} on exportRows("${definition.name}") — the rows one statement reads, one step checkpoints and one part holds`,
|
|
145
|
+
);
|
|
146
|
+
const maxPartBytes = definition.maxPartBytes ?? DEFAULT_EXPORT_MAX_PART_BYTES;
|
|
147
|
+
assert(
|
|
148
|
+
Number.isSafeInteger(maxPartBytes) && maxPartBytes >= 1,
|
|
149
|
+
`export "${definition.name}" declares maxPartBytes: ${String(maxPartBytes)}, which no part can fit in`,
|
|
150
|
+
`set maxPartBytes: ${DEFAULT_EXPORT_MAX_PART_BYTES} on exportRows("${definition.name}"), or leave it out — the number is the heap one page may occupy`,
|
|
151
|
+
);
|
|
152
|
+
assert(
|
|
153
|
+
(EXPORT_FORMATS as readonly string[]).includes(definition.format),
|
|
154
|
+
`export "${definition.name}" declares format: ${String(definition.format)}`,
|
|
155
|
+
`set format to one of ${EXPORT_FORMATS.join(' | ')} on exportRows("${definition.name}")`,
|
|
156
|
+
);
|
|
157
|
+
// A column list nothing can be projected through is an artifact with no data in it, and both
|
|
158
|
+
// encoders would happily write one — an empty csv row per source row, forever.
|
|
159
|
+
assert(
|
|
160
|
+
definition.columns.length > 0 && new Set(definition.columns).size === definition.columns.length,
|
|
161
|
+
`export "${definition.name}" declares ${definition.columns.length} column(s) and ${new Set(definition.columns).size} distinct one(s)`,
|
|
162
|
+
`declare at least one column on exportRows("${definition.name}") and no duplicates — the list is the csv header and the ndjson key order, and a repeat writes the same cell twice`,
|
|
163
|
+
);
|
|
164
|
+
const clock = definition.clock ?? systemClock;
|
|
165
|
+
// Built at declaration for the reason `backfill()` builds its own there: the interval belongs to
|
|
166
|
+
// the table and the pool, not to whichever attempt holds the run.
|
|
167
|
+
const pace =
|
|
168
|
+
definition.rate === undefined
|
|
169
|
+
? undefined
|
|
170
|
+
: createPacer({ rate: definition.rate, job: definition.name, clock });
|
|
171
|
+
|
|
172
|
+
return job<I>({
|
|
173
|
+
name: definition.name,
|
|
174
|
+
input: definition.input,
|
|
175
|
+
// ONE live pass per artifact, derived rather than declared: two runs writing the same prefix
|
|
176
|
+
// race over every part key, and the loser's bytes win half the objects. It is also why there is
|
|
177
|
+
// no `idempotencyKey` on this declaration — a second spelling of "which artifact is this" is a
|
|
178
|
+
// second answer to the question the key exists to settle.
|
|
179
|
+
idempotencyKey: (input) => `${definition.name}:${definition.prefix(input)}`,
|
|
180
|
+
tenant: definition.tenant,
|
|
181
|
+
retry: definition.retry ?? DEFAULT_RETRY,
|
|
182
|
+
...(definition.queue === undefined ? {} : { queue: definition.queue }),
|
|
183
|
+
...(definition.timeout === undefined ? {} : { timeout: definition.timeout }),
|
|
184
|
+
run: (args) =>
|
|
185
|
+
exportPass<Row, I>({ definition, size, maxPartBytes, pace, nowMs: () => nowMs(clock) }, args),
|
|
186
|
+
});
|
|
187
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,23 @@
|
|
|
7
7
|
// bypasses `defineApi`'s own result — the ambiguity axiom 1 exists to refuse.
|
|
8
8
|
import './register';
|
|
9
9
|
|
|
10
|
+
// The wire format, RE-EXPORTED from `@ultimat3/core` and never re-declared: it is one module at
|
|
11
|
+
// the tier both halves can reach, because this package signs a delivery, `@ultimat3/http` verifies
|
|
12
|
+
// one, and neither may import the other. Re-exported here so a `job` file needs one import rather
|
|
13
|
+
// than two — the same reason `t` is re-exported above.
|
|
14
|
+
export type { WebhookMacInput, WebhookSigningInput } from '@ultimat3/core';
|
|
15
|
+
export {
|
|
16
|
+
isCanonicalWebhookField,
|
|
17
|
+
WEBHOOK_FIELD_MAX,
|
|
18
|
+
WEBHOOK_ID_HEADER,
|
|
19
|
+
WEBHOOK_SIGNATURE_HEADER,
|
|
20
|
+
WEBHOOK_SIGNATURE_VERSION,
|
|
21
|
+
WEBHOOK_TOPIC_HEADER,
|
|
22
|
+
webhookHeaders,
|
|
23
|
+
webhookMac,
|
|
24
|
+
webhookSignature,
|
|
25
|
+
webhookSigningString,
|
|
26
|
+
} from '@ultimat3/core';
|
|
10
27
|
/** Re-exported so a `job`/`task` file needs one import, not two. Same object as schema's. */
|
|
11
28
|
export type { Infer } from '@ultimat3/schema';
|
|
12
29
|
export { t } from '@ultimat3/schema';
|
|
@@ -84,6 +101,7 @@ export type {
|
|
|
84
101
|
QueueStats,
|
|
85
102
|
} from './driver';
|
|
86
103
|
export {
|
|
104
|
+
assertClaimQueues,
|
|
87
105
|
DEFAULT_QUEUE,
|
|
88
106
|
DEFAULT_VISIBILITY_TIMEOUT_MS,
|
|
89
107
|
isJobState,
|
|
@@ -134,6 +152,7 @@ export type { JobErrorCode } from './errors';
|
|
|
134
152
|
export {
|
|
135
153
|
ActionJobUnbridgedError,
|
|
136
154
|
CancelUnsupportedError,
|
|
155
|
+
ClaimQueuesEmptyError,
|
|
137
156
|
ConcurrencyUnenforceableError,
|
|
138
157
|
DriverUnavailableError,
|
|
139
158
|
IdempotencyRequiredError,
|
|
@@ -159,6 +178,18 @@ export type { PgEventBusOptions } from './events-pg';
|
|
|
159
178
|
export { createPgEventBus } from './events-pg';
|
|
160
179
|
export type { ExecuteJobOptions, JobExecution, JobOutcome } from './execute';
|
|
161
180
|
export { executeJob } from './execute';
|
|
181
|
+
export type { ExportDefinition, ExportReport } from './export';
|
|
182
|
+
export { DEFAULT_EXPORT_BATCH, DEFAULT_EXPORT_MAX_PART_BYTES, exportRows } from './export';
|
|
183
|
+
export { ExportPartTooLargeError, ExportRowInvalidError } from './export-errors';
|
|
184
|
+
export type { EncodePageInput, ExportFormat, ExportRecord, ExportValue } from './export-format';
|
|
185
|
+
export { csvHeader, EXPORT_EXTENSION, EXPORT_FORMATS, encodeExportPage } from './export-format';
|
|
186
|
+
export type { ExportManifest, ExportSink, MemoryExportSink } from './export-sink';
|
|
187
|
+
export {
|
|
188
|
+
EXPORT_PART_DIGITS,
|
|
189
|
+
exportManifestKey,
|
|
190
|
+
exportPartKey,
|
|
191
|
+
memoryExportSink,
|
|
192
|
+
} from './export-sink';
|
|
162
193
|
export type {
|
|
163
194
|
DeadLetterEntry,
|
|
164
195
|
JobsManifest,
|
|
@@ -220,7 +251,6 @@ export {
|
|
|
220
251
|
export { DEFAULT_OUTBOX_CLAIM_LEASE_MS } from './outbox-lease';
|
|
221
252
|
export type { PgOutboxOptions } from './outbox-pg';
|
|
222
253
|
export { createPgOutboxStore } from './outbox-pg';
|
|
223
|
-
|
|
224
254
|
export type {
|
|
225
255
|
PurgeDefinition,
|
|
226
256
|
PurgeInput,
|
|
@@ -285,5 +315,26 @@ export { getTask, isTaskHandle, registeredTasks, resetTasks, task } from './task
|
|
|
285
315
|
* "whose tenant is this", which is the thing this declaration exists to make singular.
|
|
286
316
|
*/
|
|
287
317
|
export type { JobTenant } from './tenant';
|
|
318
|
+
export type {
|
|
319
|
+
WebhookDefinition,
|
|
320
|
+
WebhookDeliveryInput,
|
|
321
|
+
WebhookEndpoint,
|
|
322
|
+
WebhookEvent,
|
|
323
|
+
WebhookFetch,
|
|
324
|
+
WebhookReport,
|
|
325
|
+
} from './webhook';
|
|
326
|
+
export { DEFAULT_WEBHOOK_DISABLE_AFTER, WEBHOOK_CONTENT_TYPE, webhook } from './webhook';
|
|
327
|
+
export {
|
|
328
|
+
WebhookDeliveryFailedError,
|
|
329
|
+
WebhookDeliveryRejectedError,
|
|
330
|
+
WebhookDeliveryThrottledError,
|
|
331
|
+
WebhookEndpointDisabledError,
|
|
332
|
+
WebhookEndpointInvalidError,
|
|
333
|
+
WebhookEndpointUnknownError,
|
|
334
|
+
WebhookEventInvalidError,
|
|
335
|
+
WebhookEventUnknownError,
|
|
336
|
+
} from './webhook-errors';
|
|
337
|
+
export type { MemoryWebhookLedger, WebhookAttempt, WebhookLedger } from './webhook-ledger';
|
|
338
|
+
export { DEFAULT_MAX_WEBHOOK_ATTEMPTS, memoryWebhookLedger } from './webhook-ledger';
|
|
288
339
|
export type { Worker, WorkerOptions, WorkerStats } from './worker';
|
|
289
340
|
export { createWorker } from './worker';
|