@ultimat3/jobs 29.3.2 → 30.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 +68 -89
- package/README.md +304 -10
- package/package.json +18 -6
- package/src/backfill-definition.ts +184 -0
- package/src/backfill-errors.ts +18 -2
- package/src/backfill-pass.ts +43 -40
- package/src/backfill-pending.ts +40 -17
- package/src/backfill-reader.ts +94 -0
- package/src/backfill-registry.ts +5 -0
- package/src/backfill-scope.ts +1 -2
- package/src/backfill-source.ts +113 -0
- package/src/backfill.ts +54 -135
- package/src/consumer-errors.ts +42 -0
- package/src/consumer-guard.ts +19 -0
- package/src/consumer-loop.ts +171 -0
- package/src/consumer-registry.ts +103 -0
- package/src/consumer-types.ts +81 -0
- package/src/consumer.ts +147 -0
- package/src/driver-memory-operator.ts +25 -2
- package/src/driver-memory.ts +35 -1
- package/src/driver-pg-claim-sql.ts +128 -0
- package/src/driver-pg-ddl.ts +11 -2
- package/src/driver-pg-demand-sql.ts +27 -0
- package/src/driver-pg-enqueue-many.ts +153 -0
- package/src/driver-pg-event-wake-sql.ts +51 -0
- package/src/driver-pg-operator-sql.ts +34 -6
- package/src/driver-pg-operator.ts +36 -2
- package/src/driver-pg-outbox-sql.ts +34 -0
- package/src/driver-pg-sql.ts +24 -91
- package/src/driver-pg.ts +17 -1
- package/src/driver-redis-refusal.ts +25 -0
- package/src/driver-redis-scripts.ts +79 -30
- package/src/driver-redis.ts +70 -42
- package/src/driver.ts +38 -1
- package/src/enqueue-record.ts +137 -0
- package/src/error-codes.ts +233 -0
- package/src/errors-concurrency.ts +2 -2
- package/src/errors-declaration.ts +15 -2
- package/src/errors-operator.ts +1 -1
- package/src/errors-requeue.ts +1 -1
- package/src/errors-saga.ts +87 -0
- package/src/errors-steps.ts +35 -0
- package/src/errors-tenant.ts +1 -1
- package/src/errors.ts +25 -165
- package/src/events-pg.ts +20 -8
- package/src/events.ts +14 -3
- package/src/execute-ctx.ts +51 -0
- package/src/execute-unwind.ts +191 -0
- package/src/execute.ts +50 -51
- package/src/export-errors.ts +1 -1
- package/src/failure-row.ts +43 -0
- package/src/import-errors.ts +102 -0
- package/src/import-format.ts +232 -0
- package/src/import-pass.ts +255 -0
- package/src/import-source.ts +49 -0
- package/src/import.ts +175 -0
- package/src/index.ts +163 -7
- package/src/inspect-operator.ts +24 -3
- package/src/inspect.ts +17 -4
- package/src/introspection.ts +24 -1
- package/src/job-enqueue-many.ts +108 -0
- package/src/job-types.ts +229 -0
- package/src/job.ts +19 -208
- package/src/metrics.ts +10 -0
- package/src/outbox-pg.ts +35 -0
- package/src/outbox-relay.ts +58 -19
- package/src/outbox.ts +65 -73
- package/src/purge-entities.ts +92 -0
- package/src/purge.ts +10 -2
- package/src/queue-wake.ts +1 -1
- package/src/refresh-view.ts +76 -0
- package/src/retry-classification.ts +27 -8
- package/src/retry.ts +26 -0
- package/src/rollup-errors.ts +27 -0
- package/src/rollup.ts +190 -0
- package/src/run-deadline.ts +11 -7
- package/src/scheduler.ts +2 -1
- package/src/settled.ts +56 -15
- package/src/step-resolve.ts +37 -0
- package/src/steps-api.ts +111 -0
- package/src/steps-core.ts +26 -0
- package/src/steps-output.ts +142 -0
- package/src/steps-store.ts +68 -0
- package/src/steps-suspension.ts +51 -0
- package/src/steps-timeout.ts +10 -5
- package/src/steps-unwind.ts +94 -0
- package/src/steps-wait.ts +163 -0
- package/src/steps.ts +174 -210
- package/src/subject-erase-pass.ts +134 -0
- package/src/subject-errors.ts +48 -0
- package/src/subject-export-pass.ts +228 -0
- package/src/subject-jobs.ts +121 -0
- package/src/subject-plan.ts +287 -0
- package/src/subject-select.ts +106 -0
- package/src/subject.ts +119 -0
- package/src/tenant.ts +69 -1
- package/src/type-pins.ts +90 -0
- package/src/webhook-errors.ts +1 -1
- package/src/webhook-receiver-errors.ts +59 -0
- package/src/webhook-receiver-route.ts +127 -0
- package/src/webhook-receiver-types.ts +97 -0
- package/src/webhook-receiver.ts +132 -0
- package/src/webhook-target.ts +20 -43
- package/src/webhook.ts +7 -4
- package/src/worker-queue-demand.ts +57 -0
- package/src/worker-queue-depth.ts +15 -3
- package/src/worker-registry.ts +4 -1
- package/src/worker-types.ts +2 -0
- package/src/worker.ts +2 -2
package/src/import.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// `importRows()` — a file in object storage read a page at a time into the app's tables, declared
|
|
2
|
+
// as a `job` and NOT as a ninth primitive: the twin of `exportRows()`, a FACTORY over `job()` as
|
|
3
|
+
// `backfill()`, `purge()` and `webhook()` are. That is what gives an import `.enqueue()`, the
|
|
4
|
+
// worker's cancellation, the dead-letter path, `x jobs show` and a manifest row.
|
|
5
|
+
//
|
|
6
|
+
// WHY IT IS THE FRAMEWORK'S: the memory bound and the resume. `parse(await disk.get(key))` holds
|
|
7
|
+
// the whole upload, and a retried job re-applies every row from zero. What ships is the quote-aware
|
|
8
|
+
// record boundary, the byte-offset checkpoint and the per-row error report; which columns mean
|
|
9
|
+
// what, and who may import, never do. The pass lives in `import-pass.ts`.
|
|
10
|
+
|
|
11
|
+
import type { Clock, Ctx } from '@ultimat3/core';
|
|
12
|
+
import { assert, systemClock } from '@ultimat3/core';
|
|
13
|
+
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
14
|
+
import type { Pacer } from './backfill-rate';
|
|
15
|
+
import { backfillPacer } from './backfill-rate';
|
|
16
|
+
import type { DurationInput } from './clock';
|
|
17
|
+
import type { ExportFormat } from './export-format';
|
|
18
|
+
import { EXPORT_FORMATS } from './export-format';
|
|
19
|
+
import type { ExportSink } from './export-sink';
|
|
20
|
+
import { importPass } from './import-pass';
|
|
21
|
+
import type { ImportSource } from './import-source';
|
|
22
|
+
import type { JobHandle } from './job';
|
|
23
|
+
import { job } from './job';
|
|
24
|
+
import type { RetryPolicy } from './retry';
|
|
25
|
+
import { DEFAULT_RETRY } from './retry';
|
|
26
|
+
import type { JobTenant } from './tenant';
|
|
27
|
+
|
|
28
|
+
/** Records per `handle` call: one statement's worth. A page hands over its whole read in these. */
|
|
29
|
+
export const DEFAULT_IMPORT_BATCH = 500;
|
|
30
|
+
/** Bytes one page reads. The heap bound: a window is held, never the file. */
|
|
31
|
+
export const DEFAULT_IMPORT_PAGE_BYTES = 1_024 * 1_024;
|
|
32
|
+
/** The longest one record may be. Must stay below `pageBytes`, so every window holds a record. */
|
|
33
|
+
export const DEFAULT_IMPORT_MAX_RECORD_BYTES = 64 * 1_024;
|
|
34
|
+
/** Refused rows past which the import ends failed. `0` stops at the first bad page. */
|
|
35
|
+
export const DEFAULT_IMPORT_MAX_ROW_ERRORS = 1_000;
|
|
36
|
+
|
|
37
|
+
export interface ImportDefinition<Rec, I> {
|
|
38
|
+
/** REQUIRED, as on `exportRows()`: the queue row, the step trace and every page carry it. */
|
|
39
|
+
readonly name: string;
|
|
40
|
+
/** The payload: a pointer to the file (`{ orgId, uploadKey }`), never its rows. */
|
|
41
|
+
readonly input: StandardSchemaV1<unknown, I>;
|
|
42
|
+
/**
|
|
43
|
+
* REQUIRED, exactly as on `job()`. `handle` runs under it, so a scoped write in a `'none'` import
|
|
44
|
+
* fails closed with `X_TENANCY_ACTOR_ORG_REQUIRED` — deliberately not relaxed: an import writes
|
|
45
|
+
* one file's rows, and the file names no tenant the app did not put in `input`.
|
|
46
|
+
*/
|
|
47
|
+
readonly tenant: JobTenant<NoInfer<I>>;
|
|
48
|
+
/** Where the file is — a THUNK, read per page: `source: () => disk('imports')`. */
|
|
49
|
+
readonly source: () => ImportSource;
|
|
50
|
+
/**
|
|
51
|
+
* The file's object key, from the input. `I` and `Rec` are inferred from `input` and `schema`
|
|
52
|
+
* ALONE (`NoInfer` everywhere else), so a helper typed for half the input cannot narrow it.
|
|
53
|
+
*/
|
|
54
|
+
key(input: NoInfer<I>): string;
|
|
55
|
+
readonly format: ExportFormat;
|
|
56
|
+
/** The declared columns. A csv header must name exactly these; ndjson keys must be among them. */
|
|
57
|
+
readonly columns: readonly string[];
|
|
58
|
+
/**
|
|
59
|
+
* Each record, keyed by column, through this. A csv record is text, coerced as an HTTP form is
|
|
60
|
+
* (`coerceInput`): an unquoted empty cell is `null`, a quoted `""` is `''`. No formula guard is
|
|
61
|
+
* undone: an import cannot know a leading `'` was the exporter's.
|
|
62
|
+
*/
|
|
63
|
+
readonly schema: StandardSchemaV1<unknown, Rec>;
|
|
64
|
+
/**
|
|
65
|
+
* One page of accepted rows. AT LEAST ONCE per page: a page that dies after `handle` and before
|
|
66
|
+
* its checkpoint runs again. `upsertAll` is the idempotent spelling, as for `backfill()`.
|
|
67
|
+
*/
|
|
68
|
+
handle(args: {
|
|
69
|
+
readonly rows: readonly NoInfer<Rec>[];
|
|
70
|
+
readonly ctx: Ctx;
|
|
71
|
+
readonly input: NoInfer<I>;
|
|
72
|
+
}): unknown;
|
|
73
|
+
/** Where refused rows are reported: `<prefix>/<runId>/errors-00000.ndjson`, one part per page. */
|
|
74
|
+
readonly errors: { readonly sink: () => ExportSink; prefix(input: NoInfer<I>): string };
|
|
75
|
+
/** Records per `handle` call, never per read. Defaults to `DEFAULT_IMPORT_BATCH`. */
|
|
76
|
+
readonly batch?: number;
|
|
77
|
+
/** Defaults to `DEFAULT_IMPORT_PAGE_BYTES`. */
|
|
78
|
+
readonly pageBytes?: number;
|
|
79
|
+
/** Defaults to `DEFAULT_IMPORT_MAX_RECORD_BYTES`; below `pageBytes`. */
|
|
80
|
+
readonly maxRecordBytes?: number;
|
|
81
|
+
/** Defaults to `DEFAULT_IMPORT_MAX_ROW_ERRORS`. */
|
|
82
|
+
readonly maxRowErrors?: number;
|
|
83
|
+
/** Pages per second. No default, as an export's: omit it for unthrottled. */
|
|
84
|
+
readonly rate?: number;
|
|
85
|
+
readonly clock?: Clock;
|
|
86
|
+
readonly queue?: string;
|
|
87
|
+
readonly retry?: RetryPolicy;
|
|
88
|
+
/** Per attempt: a resumed attempt picks up at the last checkpointed byte offset. */
|
|
89
|
+
readonly timeout?: DurationInput;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** What one completed pass reports — bounded, so `x jobs show` can print it. */
|
|
93
|
+
export interface ImportReport {
|
|
94
|
+
readonly name: string;
|
|
95
|
+
readonly key: string;
|
|
96
|
+
/** Where this run's refused rows are: `<errors.prefix>/<runId>`, the parts under it. */
|
|
97
|
+
readonly errorPrefix: string;
|
|
98
|
+
readonly rows: number;
|
|
99
|
+
readonly rejected: number;
|
|
100
|
+
/** Error parts written. */
|
|
101
|
+
readonly parts: number;
|
|
102
|
+
readonly bytes: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Everything fixed at declaration, validated once. Read by `importPass`. */
|
|
106
|
+
export interface ImportPlan<Rec, I> {
|
|
107
|
+
readonly definition: ImportDefinition<Rec, I>;
|
|
108
|
+
readonly batch: number;
|
|
109
|
+
readonly pageBytes: number;
|
|
110
|
+
readonly maxRecordBytes: number;
|
|
111
|
+
readonly maxRowErrors: number;
|
|
112
|
+
readonly pace: Pacer | undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function importRows<Rec, I>(definition: ImportDefinition<Rec, I>): JobHandle<I> {
|
|
116
|
+
const name = definition.name;
|
|
117
|
+
const batch = definition.batch ?? DEFAULT_IMPORT_BATCH;
|
|
118
|
+
const pageBytes = definition.pageBytes ?? DEFAULT_IMPORT_PAGE_BYTES;
|
|
119
|
+
const maxRecordBytes = definition.maxRecordBytes ?? DEFAULT_IMPORT_MAX_RECORD_BYTES;
|
|
120
|
+
const maxRowErrors = definition.maxRowErrors ?? DEFAULT_IMPORT_MAX_ROW_ERRORS;
|
|
121
|
+
assert(
|
|
122
|
+
Number.isSafeInteger(batch) && batch >= 1,
|
|
123
|
+
`import "${name}" declares batch: ${String(batch)} — a batch is a whole number of records, at least one`,
|
|
124
|
+
`set batch: ${DEFAULT_IMPORT_BATCH} on importRows("${name}") — the records one page hands handle()`,
|
|
125
|
+
);
|
|
126
|
+
assert(
|
|
127
|
+
Number.isSafeInteger(maxRecordBytes) &&
|
|
128
|
+
Number.isSafeInteger(pageBytes) &&
|
|
129
|
+
maxRecordBytes >= 1 &&
|
|
130
|
+
maxRecordBytes < pageBytes,
|
|
131
|
+
`import "${name}" declares pageBytes: ${String(pageBytes)} and maxRecordBytes: ${String(maxRecordBytes)} — a page must be longer than the longest record, or a record can fill a window with no end in it`,
|
|
132
|
+
`set pageBytes: ${DEFAULT_IMPORT_PAGE_BYTES} and maxRecordBytes: ${DEFAULT_IMPORT_MAX_RECORD_BYTES} on importRows("${name}"), or leave both out`,
|
|
133
|
+
);
|
|
134
|
+
assert(
|
|
135
|
+
Number.isSafeInteger(maxRowErrors) && maxRowErrors >= 0,
|
|
136
|
+
`import "${name}" declares maxRowErrors: ${String(maxRowErrors)}`,
|
|
137
|
+
`set maxRowErrors: ${DEFAULT_IMPORT_MAX_ROW_ERRORS} on importRows("${name}") — a whole number, 0 to stop at the first bad page`,
|
|
138
|
+
);
|
|
139
|
+
assert(
|
|
140
|
+
(EXPORT_FORMATS as readonly string[]).includes(definition.format),
|
|
141
|
+
`import "${name}" declares format: ${String(definition.format)}`,
|
|
142
|
+
`set format to one of ${EXPORT_FORMATS.join(' | ')} on importRows("${name}")`,
|
|
143
|
+
);
|
|
144
|
+
assert(
|
|
145
|
+
definition.columns.length > 0 && new Set(definition.columns).size === definition.columns.length,
|
|
146
|
+
`import "${name}" declares ${definition.columns.length} column(s) and ${new Set(definition.columns).size} distinct one(s)`,
|
|
147
|
+
`declare at least one column on importRows("${name}") and no duplicates — the list is what a csv header must name`,
|
|
148
|
+
);
|
|
149
|
+
const clock = definition.clock ?? systemClock;
|
|
150
|
+
const pace =
|
|
151
|
+
definition.rate === undefined
|
|
152
|
+
? undefined
|
|
153
|
+
: backfillPacer({ rate: definition.rate, job: name, clock });
|
|
154
|
+
const plan: ImportPlan<Rec, I> = {
|
|
155
|
+
definition,
|
|
156
|
+
batch,
|
|
157
|
+
pageBytes,
|
|
158
|
+
maxRecordBytes,
|
|
159
|
+
maxRowErrors,
|
|
160
|
+
pace,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
return job<I>({
|
|
164
|
+
name,
|
|
165
|
+
input: definition.input,
|
|
166
|
+
// ONE live pass per file, derived rather than declared, as an export's per prefix: two passes
|
|
167
|
+
// over one file would hand every row to `handle` twice and race over every error part.
|
|
168
|
+
idempotencyKey: (input) => `${name}:${definition.key(input)}`,
|
|
169
|
+
tenant: definition.tenant,
|
|
170
|
+
retry: definition.retry ?? DEFAULT_RETRY,
|
|
171
|
+
...(definition.queue === undefined ? {} : { queue: definition.queue }),
|
|
172
|
+
...(definition.timeout === undefined ? {} : { timeout: definition.timeout }),
|
|
173
|
+
run: (args) => importPass<Rec, I>(plan, args),
|
|
174
|
+
});
|
|
175
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -18,7 +18,11 @@ export type {
|
|
|
18
18
|
BackfillBatch,
|
|
19
19
|
BackfillDefinition,
|
|
20
20
|
BackfillInput,
|
|
21
|
+
BackfillPage,
|
|
22
|
+
BackfillPages,
|
|
21
23
|
BackfillReport,
|
|
24
|
+
ScopedBackfillDefinition,
|
|
25
|
+
ScopedBackfillInput,
|
|
22
26
|
} from './backfill';
|
|
23
27
|
export { backfill, DEFAULT_BACKFILL_BATCH } from './backfill';
|
|
24
28
|
export {
|
|
@@ -27,6 +31,7 @@ export {
|
|
|
27
31
|
BackfillMigrationPendingError,
|
|
28
32
|
BackfillPendingError,
|
|
29
33
|
BackfillRunningError,
|
|
34
|
+
BackfillSourceInvalidError,
|
|
30
35
|
BackfillStalledError,
|
|
31
36
|
BackfillUnknownError,
|
|
32
37
|
} from './backfill-errors';
|
|
@@ -75,6 +80,29 @@ export type { AnnounceExhaustedOptions, ExhaustedCounts } from './claim-exhauste
|
|
|
75
80
|
export { announceExhausted } from './claim-exhausted';
|
|
76
81
|
export type { JobConcurrency, KeyedConcurrency, WhenBusy } from './concurrency';
|
|
77
82
|
export { MAX_CONCURRENCY_KEY_LENGTH, WHEN_BUSY } from './concurrency';
|
|
83
|
+
export { consumer } from './consumer';
|
|
84
|
+
export { ConsumerInvalidError, ConsumerSelfEnqueueError } from './consumer-errors';
|
|
85
|
+
export {
|
|
86
|
+
CONSUMER_LINGER_MS,
|
|
87
|
+
CONSUMER_PROGRESS_NOTE,
|
|
88
|
+
DEFAULT_CONSUMER_BATCH,
|
|
89
|
+
} from './consumer-loop';
|
|
90
|
+
// `stampConsumer` is deliberately absent, for the reason `stampBackfill` is.
|
|
91
|
+
export type { ConsumerDeclaration, ConsumerRow } from './consumer-registry';
|
|
92
|
+
export {
|
|
93
|
+
consumerDeclarationOf,
|
|
94
|
+
consumerRow,
|
|
95
|
+
isConsumer,
|
|
96
|
+
registeredConsumers,
|
|
97
|
+
} from './consumer-registry';
|
|
98
|
+
export type {
|
|
99
|
+
ConsumerBatch,
|
|
100
|
+
ConsumerCursor,
|
|
101
|
+
ConsumerDefinition,
|
|
102
|
+
ConsumerHandle,
|
|
103
|
+
ConsumerInput,
|
|
104
|
+
ConsumerReport,
|
|
105
|
+
} from './consumer-types';
|
|
78
106
|
export type { JobConcurrencyDescriptor, JobDescriptor } from './describe';
|
|
79
107
|
export type {
|
|
80
108
|
AckOptions,
|
|
@@ -89,6 +117,7 @@ export type {
|
|
|
89
117
|
JobRecord,
|
|
90
118
|
JobState,
|
|
91
119
|
NackOptions,
|
|
120
|
+
QueueDemand,
|
|
92
121
|
QueueStats,
|
|
93
122
|
SettleBy,
|
|
94
123
|
} from './driver';
|
|
@@ -116,18 +145,18 @@ export { postgresJobDriver, postgresLeader } from './driver-pg';
|
|
|
116
145
|
// too — the array-parameter proofs are `driver-pg-array.live.test.ts` (25.0.0).
|
|
117
146
|
export { SQL_JOBS_TABLE } from './driver-pg-sql';
|
|
118
147
|
export { signalEnqueued, signalStaged } from './enqueue-signal';
|
|
119
|
-
export type { JobErrorCode } from './
|
|
148
|
+
export type { JobErrorCode } from './error-codes';
|
|
149
|
+
export { JOB_ERROR_CODES, JOB_ERROR_TITLES } from './error-codes';
|
|
120
150
|
export {
|
|
121
151
|
ActionJobUnbridgedError,
|
|
122
152
|
CancelUnsupportedError,
|
|
123
153
|
ClaimQueuesEmptyError,
|
|
124
154
|
DriverUnavailableError,
|
|
125
155
|
IdempotencyRequiredError,
|
|
126
|
-
JOB_ERROR_CODES,
|
|
127
|
-
JOB_ERROR_TITLES,
|
|
128
156
|
JobAbortedError,
|
|
129
157
|
JobDrainedError,
|
|
130
158
|
JobDuplicateError,
|
|
159
|
+
JobEnqueueBatchTooLargeError,
|
|
131
160
|
JobMaxAttemptsError,
|
|
132
161
|
JobNameTakenError,
|
|
133
162
|
JobNotCancellableError,
|
|
@@ -153,6 +182,16 @@ export {
|
|
|
153
182
|
JobPageInvalidError,
|
|
154
183
|
} from './errors-operator';
|
|
155
184
|
export { JobNotFoundError, JobNotRequeueableError } from './errors-requeue';
|
|
185
|
+
export {
|
|
186
|
+
COMPENSATE_PREFIX,
|
|
187
|
+
JobCompensationFailedError,
|
|
188
|
+
StepCompensateUndeclaredError,
|
|
189
|
+
StepNameReservedError,
|
|
190
|
+
StepNotResolvableError,
|
|
191
|
+
StepOutcomeUnknownError,
|
|
192
|
+
UNWINDING_STEP,
|
|
193
|
+
} from './errors-saga';
|
|
194
|
+
export { JobEventInvalidError, StepOutputUnserializableError } from './errors-steps';
|
|
156
195
|
export { JobTenantMismatchError } from './errors-tenant';
|
|
157
196
|
export type { EventBus, JobEvent, MemoryEventBusOptions, PublishOptions } from './events';
|
|
158
197
|
export {
|
|
@@ -180,7 +219,28 @@ export {
|
|
|
180
219
|
exportPartKey,
|
|
181
220
|
memoryExportSink,
|
|
182
221
|
} from './export-sink';
|
|
222
|
+
export type { CallerFailure } from './failure-row';
|
|
223
|
+
export { callerFailure } from './failure-row';
|
|
183
224
|
export { IDLE_POLL_CEILING_MS } from './idle-backoff';
|
|
225
|
+
export type { ImportDefinition, ImportReport } from './import';
|
|
226
|
+
export {
|
|
227
|
+
DEFAULT_IMPORT_BATCH,
|
|
228
|
+
DEFAULT_IMPORT_MAX_RECORD_BYTES,
|
|
229
|
+
DEFAULT_IMPORT_MAX_ROW_ERRORS,
|
|
230
|
+
DEFAULT_IMPORT_PAGE_BYTES,
|
|
231
|
+
importRows,
|
|
232
|
+
} from './import';
|
|
233
|
+
export {
|
|
234
|
+
ImportHeaderMismatchError,
|
|
235
|
+
ImportRecordTooLargeError,
|
|
236
|
+
ImportTooManyErrorsError,
|
|
237
|
+
} from './import-errors';
|
|
238
|
+
export type { ImportRejection } from './import-format';
|
|
239
|
+
export { IMPORT_REJECTIONS } from './import-format';
|
|
240
|
+
export type { ImportRowError } from './import-pass';
|
|
241
|
+
export { importErrorKey } from './import-pass';
|
|
242
|
+
export type { ImportObject, ImportSource, MemoryImportSource } from './import-source';
|
|
243
|
+
export { memoryImportSource } from './import-source';
|
|
184
244
|
export type {
|
|
185
245
|
DeadLetterEntry,
|
|
186
246
|
JobsManifest,
|
|
@@ -197,7 +257,13 @@ export {
|
|
|
197
257
|
inspectQueues,
|
|
198
258
|
retryFromStep,
|
|
199
259
|
} from './inspect';
|
|
200
|
-
export {
|
|
260
|
+
export {
|
|
261
|
+
pauseQueue,
|
|
262
|
+
promoteJob,
|
|
263
|
+
removeJob,
|
|
264
|
+
resolveJobStep,
|
|
265
|
+
resumeQueue,
|
|
266
|
+
} from './inspect-operator';
|
|
201
267
|
export type {
|
|
202
268
|
BulkFilter,
|
|
203
269
|
BulkResult,
|
|
@@ -209,6 +275,7 @@ export type {
|
|
|
209
275
|
JobIntrospection,
|
|
210
276
|
JobProgress,
|
|
211
277
|
PausedName,
|
|
278
|
+
StepResolution,
|
|
212
279
|
TaskFire,
|
|
213
280
|
WorkerAnnouncement,
|
|
214
281
|
WorkerRecord,
|
|
@@ -235,6 +302,8 @@ export type {
|
|
|
235
302
|
JobRunArgs,
|
|
236
303
|
} from './job';
|
|
237
304
|
export { describeJobs, getJob, isJobHandle, job, registeredJobs, resetJobs } from './job';
|
|
305
|
+
export type { EnqueueManyOptions } from './job-enqueue-many';
|
|
306
|
+
export { MAX_ENQUEUE_BATCH } from './job-enqueue-many';
|
|
238
307
|
export type { JobsRetentionOptions } from './jobs-retention';
|
|
239
308
|
export { jobsPurgeTarget } from './jobs-retention';
|
|
240
309
|
export type { HeldLease, LeaseStore, MemoryLeaseStore, MemoryLeaseStoreOptions } from './leases';
|
|
@@ -251,8 +320,10 @@ export type {
|
|
|
251
320
|
export { concurrencyLimiter, NO_TENANT, tenantKeyFrom } from './limits';
|
|
252
321
|
export {
|
|
253
322
|
queueDeadJobs,
|
|
323
|
+
queueDemand,
|
|
254
324
|
queueOldestReady,
|
|
255
325
|
recordQueueDeadJobs,
|
|
326
|
+
recordQueueDemand,
|
|
256
327
|
recordQueueOldestReady,
|
|
257
328
|
} from './metrics';
|
|
258
329
|
export type {
|
|
@@ -265,7 +336,6 @@ export type {
|
|
|
265
336
|
OutboxStore,
|
|
266
337
|
} from './outbox';
|
|
267
338
|
export {
|
|
268
|
-
enqueueInTx,
|
|
269
339
|
jobsFacade,
|
|
270
340
|
memoryOutboxStore,
|
|
271
341
|
outboxJobsFacade,
|
|
@@ -291,13 +361,40 @@ export type {
|
|
|
291
361
|
} from './purge';
|
|
292
362
|
export { DEFAULT_PURGE_CRON, purge } from './purge';
|
|
293
363
|
export type { PurgeBatching } from './purge-batches';
|
|
364
|
+
export type { PurgeEntities } from './purge-entities';
|
|
365
|
+
export {
|
|
366
|
+
resetPurgeCoverage,
|
|
367
|
+
retainedEntities,
|
|
368
|
+
retainTarget,
|
|
369
|
+
unsweptEntities,
|
|
370
|
+
} from './purge-entities';
|
|
294
371
|
export type { PgListener, QueueWake, QueueWakeOptions } from './queue-wake';
|
|
295
372
|
export { startQueueWake } from './queue-wake';
|
|
373
|
+
export type { RefreshViewDefinition, RefreshViewReport } from './refresh-view';
|
|
374
|
+
export { refreshView } from './refresh-view';
|
|
296
375
|
export type { IntervalScheduler } from './renewal-timer';
|
|
297
376
|
export type { BackoffStrategy, Random, RetryDecision, RetryPolicy } from './retry';
|
|
298
|
-
export {
|
|
377
|
+
export {
|
|
378
|
+
backoffDelayMs,
|
|
379
|
+
DEFAULT_RETRY,
|
|
380
|
+
enqueuedAttempts,
|
|
381
|
+
isFinalAttempt,
|
|
382
|
+
isUnwindAttempt,
|
|
383
|
+
nextRetry,
|
|
384
|
+
retrySchedule,
|
|
385
|
+
UNWIND_MARGIN,
|
|
386
|
+
} from './retry';
|
|
299
387
|
export type { JobRetryDecision, JobStopReason } from './retry-classification';
|
|
300
388
|
export { failureForRow, nextRetryForError } from './retry-classification';
|
|
389
|
+
export type {
|
|
390
|
+
RollupBucket,
|
|
391
|
+
RollupDefinition,
|
|
392
|
+
RollupInput,
|
|
393
|
+
RollupReport,
|
|
394
|
+
RollupTarget,
|
|
395
|
+
} from './rollup';
|
|
396
|
+
export { rollup } from './rollup';
|
|
397
|
+
export { RefreshViewInvalidError, RollupInvalidError } from './rollup-errors';
|
|
301
398
|
export type {
|
|
302
399
|
CronResolver,
|
|
303
400
|
DispatchedOccurrence,
|
|
@@ -323,27 +420,65 @@ export {
|
|
|
323
420
|
export type { ScheduledFire, SchedulerState } from './scheduler-state';
|
|
324
421
|
export { fireThroughDriver, memorySchedulerState } from './scheduler-state';
|
|
325
422
|
export type { JobCompleted, JobFailed, JobSettled } from './settled';
|
|
326
|
-
export { ON_SETTLED_ATTEMPTS } from './settled';
|
|
423
|
+
export { ON_SETTLED_ATTEMPTS, ON_SETTLED_TIMEOUT_MS } from './settled';
|
|
327
424
|
export type {
|
|
425
|
+
CompensateContext,
|
|
328
426
|
EventLookup,
|
|
329
427
|
StepApi,
|
|
330
428
|
StepFence,
|
|
331
429
|
StepRecord,
|
|
332
430
|
StepRunner,
|
|
333
431
|
StepRunnerOptions,
|
|
432
|
+
StepRunOptions,
|
|
334
433
|
StepStatus,
|
|
335
434
|
StepStore,
|
|
336
435
|
WaitForEventOptions,
|
|
337
436
|
} from './steps';
|
|
338
437
|
export {
|
|
438
|
+
DEFAULT_EVENT_POLL_MS,
|
|
339
439
|
isStepStatus,
|
|
340
440
|
isStepSuspension,
|
|
341
441
|
MAX_TRACE_NAMES,
|
|
342
442
|
STEP_STATUSES,
|
|
343
443
|
StepSuspension,
|
|
344
444
|
stepRunner,
|
|
445
|
+
WAKING_EVENT_POLL_MS,
|
|
345
446
|
} from './steps';
|
|
346
447
|
export { memoryStepStore } from './steps-memory';
|
|
448
|
+
export type { StepOutput } from './steps-output';
|
|
449
|
+
export type {
|
|
450
|
+
RetainedTarget,
|
|
451
|
+
Subject,
|
|
452
|
+
SubjectInit,
|
|
453
|
+
SubjectRef,
|
|
454
|
+
SubjectTarget,
|
|
455
|
+
SubjectTargetEntry,
|
|
456
|
+
SubjectTargetPage,
|
|
457
|
+
SubjectTreatment,
|
|
458
|
+
} from './subject';
|
|
459
|
+
export { DEFAULT_SUBJECT_BATCH, defineSubject, registeredSubjects, resetSubjects } from './subject';
|
|
460
|
+
export type { SubjectEraseReport, SubjectTally } from './subject-erase-pass';
|
|
461
|
+
export {
|
|
462
|
+
SubjectCycleError,
|
|
463
|
+
SubjectTableUndeclaredError,
|
|
464
|
+
SubjectTreatmentInvalidError,
|
|
465
|
+
} from './subject-errors';
|
|
466
|
+
export type { SubjectExportReport, SubjectIndexEntry } from './subject-export-pass';
|
|
467
|
+
export type { SubjectExportDefinition, SubjectJobDefinition } from './subject-jobs';
|
|
468
|
+
export {
|
|
469
|
+
plannedSubject,
|
|
470
|
+
subjectErase,
|
|
471
|
+
subjectEraseTables,
|
|
472
|
+
subjectExport,
|
|
473
|
+
} from './subject-jobs';
|
|
474
|
+
export type {
|
|
475
|
+
SubjectPlan,
|
|
476
|
+
SubjectPlanInput,
|
|
477
|
+
SubjectStep,
|
|
478
|
+
SubjectUnlink,
|
|
479
|
+
SubjectVia,
|
|
480
|
+
} from './subject-plan';
|
|
481
|
+
export { planSubject } from './subject-plan';
|
|
347
482
|
export type {
|
|
348
483
|
CatchUpPolicy,
|
|
349
484
|
TaskDefinition,
|
|
@@ -384,5 +519,26 @@ export {
|
|
|
384
519
|
} from './webhook-errors';
|
|
385
520
|
export type { MemoryWebhookLedger, WebhookAttempt, WebhookLedger } from './webhook-ledger';
|
|
386
521
|
export { DEFAULT_MAX_WEBHOOK_ATTEMPTS, memoryWebhookLedger } from './webhook-ledger';
|
|
522
|
+
export { webhookReceiver, webhookReceiverRoutes } from './webhook-receiver';
|
|
523
|
+
export {
|
|
524
|
+
WEBHOOK_EVENT_ID_PATTERN,
|
|
525
|
+
WEBHOOK_RECEIVER_RETRY_AFTER_SECONDS,
|
|
526
|
+
WebhookEventIdInvalidError,
|
|
527
|
+
WebhookReceiverInvalidError,
|
|
528
|
+
WebhookReceiverUnavailableError,
|
|
529
|
+
} from './webhook-receiver-errors';
|
|
530
|
+
export type {
|
|
531
|
+
ReceivedEvent,
|
|
532
|
+
ReceivedEventFacts,
|
|
533
|
+
WebhookAnswer,
|
|
534
|
+
WebhookHandleArgs,
|
|
535
|
+
WebhookInbound,
|
|
536
|
+
WebhookReceiver,
|
|
537
|
+
WebhookReceiverDefinition,
|
|
538
|
+
WebhookReceiverInput,
|
|
539
|
+
WebhookReceiverTenant,
|
|
540
|
+
} from './webhook-receiver-types';
|
|
387
541
|
export type { Worker, WorkerOptions, WorkerStats } from './worker';
|
|
388
542
|
export { jobWorker } from './worker';
|
|
543
|
+
export type { CutoffTimes } from './worker-drain-cutoff';
|
|
544
|
+
export { cutoffTimes, DRAIN_CANCEL_MARGIN_MS } from './worker-drain-cutoff';
|
package/src/inspect-operator.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
// The operator verbs `x jobs` and a dashboard bind to: remove, promote, pause, resume
|
|
2
|
-
// refuses LOUDLY rather than answering "nothing happened" — an operator
|
|
1
|
+
// The operator verbs `x jobs` and a dashboard bind to: remove, promote, pause, resume, resolve a
|
|
2
|
+
// step. Each one refuses LOUDLY rather than answering "nothing happened" — an operator has to
|
|
3
3
|
// know whether the job is gone or was never theirs to remove. Apart from `inspect.ts`, which is
|
|
4
4
|
// the read side.
|
|
5
5
|
|
|
6
6
|
import { NotImplementedError } from '@ultimat3/core';
|
|
7
7
|
import type { JobDriver, JobRecord } from './driver';
|
|
8
8
|
import { JobNotPromotableError } from './errors-operator';
|
|
9
|
-
import type { JobIntrospection, PausedName } from './introspection';
|
|
9
|
+
import type { JobIntrospection, PausedName, StepResolution } from './introspection';
|
|
10
|
+
import type { StepRecord } from './steps-store';
|
|
10
11
|
|
|
11
12
|
function operatorOf(driver: JobDriver): JobIntrospection {
|
|
12
13
|
if (driver.introspect === undefined) {
|
|
@@ -47,3 +48,23 @@ export async function resumeQueue(
|
|
|
47
48
|
await operator.resumeQueue(queue);
|
|
48
49
|
return operator.pausedQueues();
|
|
49
50
|
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A person's answer to `X_STEP_OUTCOME_UNKNOWN`: the resolved record. Then `x jobs retry <id>`.
|
|
54
|
+
* A driver with no `resolveStep` is refused as one with no operator surface at all.
|
|
55
|
+
*/
|
|
56
|
+
export function resolveJobStep(
|
|
57
|
+
driver: JobDriver,
|
|
58
|
+
jobId: string,
|
|
59
|
+
step: string,
|
|
60
|
+
resolution: StepResolution,
|
|
61
|
+
): Promise<StepRecord> {
|
|
62
|
+
const operator = operatorOf(driver);
|
|
63
|
+
if (operator.resolveStep === undefined) {
|
|
64
|
+
throw new NotImplementedError({
|
|
65
|
+
cause: `the "${driver.name}" jobs driver keeps no step records an operator can resolve: its introspect has no resolveStep`,
|
|
66
|
+
fix: 'setJobDriver(postgresJobDriver()) # at boot: the pg and memory drivers implement resolveStep — then x jobs resolve-step <id> <step> --ran --output <json>',
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return operator.resolveStep(jobId, step, resolution);
|
|
70
|
+
}
|
package/src/inspect.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// object so `x jobs ... --json` and the MCP tool share one shape — an agent debugging a stuck
|
|
3
3
|
// queue reads exactly what the dashboard renders.
|
|
4
4
|
|
|
5
|
-
import { NotImplementedError } from '@ultimat3/core';
|
|
5
|
+
import { NotImplementedError, REDACTED } from '@ultimat3/core';
|
|
6
|
+
import { maskSensitive } from '@ultimat3/schema';
|
|
6
7
|
import type { BackfillProgress } from './backfill-inspect';
|
|
7
8
|
import { backfillForRun } from './backfill-inspect';
|
|
8
9
|
import type { WhenBusy } from './concurrency';
|
|
@@ -78,8 +79,10 @@ export interface JobTrace {
|
|
|
78
79
|
/** The thrown value's stack for `lastError`, bounded. `null` when the failure carried none. */
|
|
79
80
|
readonly stack: string | null;
|
|
80
81
|
/**
|
|
81
|
-
* The stored payload, with every
|
|
82
|
-
*
|
|
82
|
+
* The stored payload, with every `.sensitive()` field of the job's schema and every key the app
|
|
83
|
+
* declared secret (`redactKeys`) replaced — `[redacted]` whole for a job whose declaration is
|
|
84
|
+
* gone. App data all the same: a surface that shows it gates it like any other read of a
|
|
85
|
+
* tenant's rows.
|
|
83
86
|
*/
|
|
84
87
|
readonly input: unknown;
|
|
85
88
|
/** What the body last reported through `progress()`; `null` until it reports. */
|
|
@@ -129,6 +132,16 @@ function concurrencyKeyOf(handle: AnyJobHandle | undefined, record: JobRecord):
|
|
|
129
132
|
}
|
|
130
133
|
}
|
|
131
134
|
|
|
135
|
+
/**
|
|
136
|
+
* The stored payload as an operator may read it: the job's own `.sensitive()` fields masked by its
|
|
137
|
+
* schema, THEN `redactKeys`. A job this process has not registered has no schema to say which keys
|
|
138
|
+
* are sensitive, so its input is hidden whole — never shown on the hope that none were.
|
|
139
|
+
*/
|
|
140
|
+
function operatorInput(handle: AnyJobHandle | undefined, record: JobRecord): unknown {
|
|
141
|
+
if (handle === undefined) return REDACTED;
|
|
142
|
+
return redactInput(maskSensitive(handle.input, record.input, REDACTED));
|
|
143
|
+
}
|
|
144
|
+
|
|
132
145
|
function toStepTrace(record: StepRecord): StepTrace {
|
|
133
146
|
return {
|
|
134
147
|
name: record.name,
|
|
@@ -160,7 +173,7 @@ export async function inspectJob(driver: JobDriver, jobId: string): Promise<JobT
|
|
|
160
173
|
runAt: new Date(record.runAt).toISOString(),
|
|
161
174
|
lastError: record.lastError ?? null,
|
|
162
175
|
stack: record.lastErrorStack ?? null,
|
|
163
|
-
input:
|
|
176
|
+
input: operatorInput(handle, record),
|
|
164
177
|
progress: record.progress ?? null,
|
|
165
178
|
tenantId: record.tenantId ?? null,
|
|
166
179
|
traceparent: record.traceparent ?? null,
|
package/src/introspection.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { finiteCount } from '@ultimat3/core';
|
|
8
8
|
import type { ClaimIdentity, JobRecord, JobState } from './driver';
|
|
9
9
|
import { JobPageInvalidError } from './errors-operator';
|
|
10
|
+
import type { StepRecord } from './steps-store';
|
|
10
11
|
|
|
11
12
|
/** Rows one `list()` call may answer. A page, never a table: a dashboard walks with `after`. */
|
|
12
13
|
export const MAX_JOB_PAGE = 200;
|
|
@@ -99,6 +100,14 @@ export function pageLimit(subject: string, limit: number | undefined): number {
|
|
|
99
100
|
return asked;
|
|
100
101
|
}
|
|
101
102
|
|
|
103
|
+
/** What a person found out about a `once` step: it ran (and answered `output`), or it did not. */
|
|
104
|
+
export type StepResolution =
|
|
105
|
+
| { readonly ran: true; readonly output: unknown }
|
|
106
|
+
| { readonly ran: false };
|
|
107
|
+
|
|
108
|
+
/** The step statuses an operator may resolve: a `once` step that began and never completed. */
|
|
109
|
+
export const RESOLVABLE_STEP_STATUSES: ReadonlySet<string> = new Set(['started', 'failed']);
|
|
110
|
+
|
|
102
111
|
/** Which rows a bulk call addresses. `state` is REQUIRED: "every job" is never one call. */
|
|
103
112
|
export interface BulkFilter {
|
|
104
113
|
readonly state: JobState;
|
|
@@ -144,6 +153,12 @@ export interface WorkerAnnouncement {
|
|
|
144
153
|
* what it holds. Absent: serving.
|
|
145
154
|
*/
|
|
146
155
|
readonly draining?: boolean;
|
|
156
|
+
/**
|
|
157
|
+
* The build this process runs: `BUILD_ID`, the id `x-ultimate-build` answers with. Optional so a
|
|
158
|
+
* custom driver still compiles, and absent on a row a worker from before 30.0.0 wrote. What
|
|
159
|
+
* `x deploy verify` tells a stalled worker roll by: the old pods still claim the queue.
|
|
160
|
+
*/
|
|
161
|
+
readonly build?: string;
|
|
147
162
|
}
|
|
148
163
|
|
|
149
164
|
export interface WorkerRecord extends WorkerAnnouncement {
|
|
@@ -151,7 +166,7 @@ export interface WorkerRecord extends WorkerAnnouncement {
|
|
|
151
166
|
readonly heartbeatAt: number;
|
|
152
167
|
/**
|
|
153
168
|
* Epoch ms of the FIRST announcement that said `draining` — kept by every later one. Absent while
|
|
154
|
-
* the worker serves. What `
|
|
169
|
+
* the worker serves. What `SQL_SCALER_DEMAND` excludes a worker's held jobs by.
|
|
155
170
|
*/
|
|
156
171
|
readonly drainingAt?: number;
|
|
157
172
|
}
|
|
@@ -241,6 +256,14 @@ export interface JobIntrospection {
|
|
|
241
256
|
* `runAt` is due now. `filter.state` must be `ready` (a retry backing off) or `delayed`.
|
|
242
257
|
*/
|
|
243
258
|
promoteMany(filter: BulkFilter): Promise<BulkResult>;
|
|
259
|
+
/**
|
|
260
|
+
* Answer a `once` step whose outcome is unknown (`X_STEP_OUTCOME_UNKNOWN`): `ran` writes it
|
|
261
|
+
* `completed` with `output`, so a replay returns it; not `ran` writes `cleared`, so the next run
|
|
262
|
+
* calls `fn` again. ONE write, fenced to a job no worker holds and a step that `started` or
|
|
263
|
+
* `failed`; anything else is `X_STEP_NOT_RESOLVABLE`, an unknown id `X_JOB_NOT_FOUND`. Optional,
|
|
264
|
+
* as `cancel` is: a driver may keep no step records an operator can reach.
|
|
265
|
+
*/
|
|
266
|
+
resolveStep?(jobId: string, step: string, resolution: StepResolution): Promise<StepRecord>;
|
|
244
267
|
/** A paused queue is never claimed, by any worker; enqueues still land. Idempotent. */
|
|
245
268
|
pauseQueue(queue: string): Promise<void>;
|
|
246
269
|
resumeQueue(queue: string): Promise<void>;
|