@eltjs/elt 0.0.0-stage → 0.1.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 +202 -0
- package/README.md +1153 -2
- package/dist/core/catalog.d.ts +7 -0
- package/dist/core/catalog.js +23 -0
- package/dist/core/change-signal.d.ts +9 -0
- package/dist/core/change-signal.js +4 -0
- package/dist/core/clock.d.ts +6 -0
- package/dist/core/clock.js +1 -0
- package/dist/core/connection.d.ts +20 -0
- package/dist/core/connection.js +31 -0
- package/dist/core/copy-configuration.d.ts +24 -0
- package/dist/core/copy-configuration.js +120 -0
- package/dist/core/copy.d.ts +27 -0
- package/dist/core/copy.js +56 -0
- package/dist/core/deduplication.d.ts +14 -0
- package/dist/core/deduplication.js +96 -0
- package/dist/core/derived-dataset.d.ts +19 -0
- package/dist/core/derived-dataset.js +10 -0
- package/dist/core/derived-refreshes.d.ts +15 -0
- package/dist/core/derived-refreshes.js +120 -0
- package/dist/core/destination.d.ts +22 -0
- package/dist/core/destination.js +17 -0
- package/dist/core/document-parser.d.ts +6 -0
- package/dist/core/document-parser.js +15 -0
- package/dist/core/document-text.d.ts +28 -0
- package/dist/core/document-text.js +72 -0
- package/dist/core/file-content.d.ts +5 -0
- package/dist/core/file-content.js +24 -0
- package/dist/core/file-extension.d.ts +1 -0
- package/dist/core/file-extension.js +9 -0
- package/dist/core/file-read.d.ts +20 -0
- package/dist/core/file-read.js +79 -0
- package/dist/core/file-storage.d.ts +8 -0
- package/dist/core/file-storage.js +4 -0
- package/dist/core/file-transfer.d.ts +12 -0
- package/dist/core/file-transfer.js +65 -0
- package/dist/core/formats/base64-format.d.ts +6 -0
- package/dist/core/formats/base64-format.js +13 -0
- package/dist/core/formats/calendar-date-format.d.ts +6 -0
- package/dist/core/formats/calendar-date-format.js +13 -0
- package/dist/core/formats/decimal-format.d.ts +9 -0
- package/dist/core/formats/decimal-format.js +41 -0
- package/dist/core/formats/declared-format.d.ts +12 -0
- package/dist/core/formats/declared-format.js +48 -0
- package/dist/core/formats/field-format.d.ts +6 -0
- package/dist/core/formats/field-format.js +10 -0
- package/dist/core/formats/int64-format.d.ts +6 -0
- package/dist/core/formats/int64-format.js +16 -0
- package/dist/core/formats/local-time-format.d.ts +6 -0
- package/dist/core/formats/local-time-format.js +10 -0
- package/dist/core/formats/local-timestamp-format.d.ts +6 -0
- package/dist/core/formats/local-timestamp-format.js +10 -0
- package/dist/core/formats/temporal-format.d.ts +7 -0
- package/dist/core/formats/temporal-format.js +23 -0
- package/dist/core/formats/timestamp-format.d.ts +7 -0
- package/dist/core/formats/timestamp-format.js +14 -0
- package/dist/core/identifiers.d.ts +10 -0
- package/dist/core/identifiers.js +51 -0
- package/dist/core/interleave.d.ts +1 -0
- package/dist/core/interleave.js +38 -0
- package/dist/core/partition.d.ts +16 -0
- package/dist/core/partition.js +60 -0
- package/dist/core/pass-channels.d.ts +46 -0
- package/dist/core/pass-channels.js +65 -0
- package/dist/core/pipeline.d.ts +37 -0
- package/dist/core/pipeline.js +546 -0
- package/dist/core/reader-relation.d.ts +6 -0
- package/dist/core/reader-relation.js +11 -0
- package/dist/core/record-validation.d.ts +58 -0
- package/dist/core/record-validation.js +128 -0
- package/dist/core/reload-mode.d.ts +9 -0
- package/dist/core/reload-mode.js +17 -0
- package/dist/core/replication.d.ts +8 -0
- package/dist/core/replication.js +478 -0
- package/dist/core/rule.d.ts +29 -0
- package/dist/core/rule.js +6 -0
- package/dist/core/signal-watch.d.ts +11 -0
- package/dist/core/signal-watch.js +110 -0
- package/dist/core/snapshot.d.ts +17 -0
- package/dist/core/snapshot.js +111 -0
- package/dist/core/source.d.ts +79 -0
- package/dist/core/source.js +227 -0
- package/dist/core/stream-change.d.ts +5 -0
- package/dist/core/stream-change.js +31 -0
- package/dist/core/stream.d.ts +26 -0
- package/dist/core/stream.js +86 -0
- package/dist/core/target-description.d.ts +15 -0
- package/dist/core/target-description.js +73 -0
- package/dist/core/target.d.ts +5 -0
- package/dist/core/target.js +14 -0
- package/dist/core/upkeep.d.ts +4 -0
- package/dist/core/upkeep.js +1 -0
- package/dist/core/writer.d.ts +41 -0
- package/dist/core/writer.js +17 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +36 -0
- package/dist/state/checkpoint-store.d.ts +29 -0
- package/dist/state/checkpoint-store.js +81 -0
- package/dist/state/sync-history-relations.d.ts +72 -0
- package/dist/state/sync-history-relations.js +74 -0
- package/dist/state/sync-history.d.ts +37 -0
- package/dist/state/sync-history.js +80 -0
- package/dist/storage/local-files.d.ts +15 -0
- package/dist/storage/local-files.js +121 -0
- package/package.json +48 -4
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
import { delay } from '@zukhruf/async';
|
|
2
|
+
import { errorMessage, } from '../state/sync-history.js';
|
|
3
|
+
import { Connection } from './connection.js';
|
|
4
|
+
import { describeFailures, } from './copy.js';
|
|
5
|
+
import { DerivedRefreshes } from './derived-refreshes.js';
|
|
6
|
+
import { interleave } from './interleave.js';
|
|
7
|
+
import { PassFailed, PassFinished, PassStarted, passChannels, } from './pass-channels.js';
|
|
8
|
+
import { replicate } from './replication.js';
|
|
9
|
+
import { brokenSignals, watchSignals } from './signal-watch.js';
|
|
10
|
+
// How long a stopping watch waits for a source to finish stopping, such as
|
|
11
|
+
// ending a helper process, before it leaves the source behind: a source that
|
|
12
|
+
// ignores its signal must not hang its host's shutdown, as a grace period
|
|
13
|
+
// bounds a process's SIGTERM before SIGKILL.
|
|
14
|
+
const stopGraceMs = 1000;
|
|
15
|
+
// When a watch loads a stream again, with no change, after its copy failed
|
|
16
|
+
// for a system reason, as Airbyte's platform retries a failed sync attempt:
|
|
17
|
+
// 10 s after the first failure and three times longer after each further one
|
|
18
|
+
// in a row, at most 30 minutes, until 5 in a row failed; the source's next
|
|
19
|
+
// change then starts over. Airbyte also stops at 10 failures in all, which
|
|
20
|
+
// binds only once attempts that made progress break up a run of failures; a
|
|
21
|
+
// watch does not count progress. As in Airbyte, a config failure is never
|
|
22
|
+
// retried: only the user can fix it, and the source's next change loads it.
|
|
23
|
+
// https://github.com/airbytehq/airbyte-platform/blob/f69abdf9567f1d496fdb7b69e2265e0104c5f2d3/airbyte-workers/src/main/kotlin/io/airbyte/workers/runtime/AirbyteWorkerRetryConfig.kt#L9-L13
|
|
24
|
+
// https://github.com/airbytehq/airbyte-platform/blob/f69abdf9567f1d496fdb7b69e2265e0104c5f2d3/airbyte-commons-temporal/src/main/kotlin/io/airbyte/commons/temporal/scheduling/retries/BackoffPolicy.kt#L32-L42
|
|
25
|
+
// https://github.com/airbytehq/airbyte-platform/blob/f69abdf9567f1d496fdb7b69e2265e0104c5f2d3/airbyte-commons-temporal/src/main/kotlin/io/airbyte/commons/temporal/scheduling/retries/RetryManager.kt#L94-L98
|
|
26
|
+
// https://github.com/airbytehq/airbyte-platform/blob/f69abdf9567f1d496fdb7b69e2265e0104c5f2d3/airbyte-workers/src/main/kotlin/io/airbyte/workers/temporal/scheduling/ConnectionManagerWorkflowImpl.kt#L534
|
|
27
|
+
const retryLimit = 5;
|
|
28
|
+
const retryDelayMs = (failures) => Math.min(10_000 * 3 ** (failures - 1), 30 * 60_000);
|
|
29
|
+
// A run that did not load completely, as its sync history recorded. Like
|
|
30
|
+
// Airbyte, every copy still ran; results holds each one's committed counts and
|
|
31
|
+
// failures, and cause is the first failure.
|
|
32
|
+
export class PipelineError extends AggregateError {
|
|
33
|
+
name = 'PipelineError';
|
|
34
|
+
results;
|
|
35
|
+
constructor(results) {
|
|
36
|
+
const incomplete = results.filter(({ failures }) => failures.length > 0);
|
|
37
|
+
const errors = incomplete.flatMap(({ failures }) => failures.map(({ error }) => error));
|
|
38
|
+
super(errors, `The following copies did not load completely: ${incomplete.map(describeFailures).join('; ')}`, { cause: errors[0] });
|
|
39
|
+
this.results = Object.freeze([...results]);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// A watch that ended, once every connection stopped or its signal aborted,
|
|
43
|
+
// with stopped connections: each error names a connection whose change
|
|
44
|
+
// signals broke the contract, and the sync history recorded why.
|
|
45
|
+
export class WatchError extends AggregateError {
|
|
46
|
+
name = 'WatchError';
|
|
47
|
+
}
|
|
48
|
+
// The orchestrator. Connections are independent: each pass reads one
|
|
49
|
+
// connection's source into its destination, and passes of different
|
|
50
|
+
// connections run side by side, so a slow source never holds back another.
|
|
51
|
+
export class Pipeline {
|
|
52
|
+
connections;
|
|
53
|
+
history;
|
|
54
|
+
// In declared order, which is the order they refresh in.
|
|
55
|
+
derived;
|
|
56
|
+
#clock;
|
|
57
|
+
constructor({ connections, history, derived, clock, }) {
|
|
58
|
+
if (!connections.every((connection) => connection instanceof Connection))
|
|
59
|
+
throw new TypeError('Pipeline connections must be Connection declarations');
|
|
60
|
+
this.connections = Object.freeze([...connections]);
|
|
61
|
+
this.history = history;
|
|
62
|
+
this.derived = Object.freeze([...(derived ?? [])]);
|
|
63
|
+
this.#clock = clock;
|
|
64
|
+
Object.freeze(this);
|
|
65
|
+
}
|
|
66
|
+
// Airbyte's Clear for these copies: empties each target and removes its checkpoint.
|
|
67
|
+
async clear(steps = this.connections.flatMap((connection) => connection.steps)) {
|
|
68
|
+
this.#assertDistinct();
|
|
69
|
+
for (const connection of this.connections)
|
|
70
|
+
connection.validate();
|
|
71
|
+
for (const copy of steps) {
|
|
72
|
+
const connection = this.connections.find(({ steps }) => steps.includes(copy));
|
|
73
|
+
if (connection === undefined)
|
|
74
|
+
throw new TypeError("Only this pipeline's copies can be cleared");
|
|
75
|
+
await copy.clear(connection.source, connection.destination, connection.checkpoints);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Once signal aborts, each pass stops at its next message and is recorded as
|
|
79
|
+
// cancelled, and the run rejects with the signal's reason, as fetch does. A
|
|
80
|
+
// stop that comes after the passes ended only stops the derived data
|
|
81
|
+
// catching up: the run ends as its passes did, and the next refresh goes on.
|
|
82
|
+
// While the passes read, each commit asks for a refresh in the background,
|
|
83
|
+
// so derived data follows a long pass. A background refresh's failure
|
|
84
|
+
// stops no pass, and rejects the run once the passes ended, as the failure
|
|
85
|
+
// of a micro-batch fails a Spark query; otherwise the run refreshes once
|
|
86
|
+
// more after the passes, and that refresh's failure rejects it.
|
|
87
|
+
async run(options) {
|
|
88
|
+
const signal = options?.signal;
|
|
89
|
+
await this.#declare();
|
|
90
|
+
const refreshes = new DerivedRefreshes(this.derived, this.connections, signal, () => { });
|
|
91
|
+
let passes;
|
|
92
|
+
try {
|
|
93
|
+
passes = await Promise.allSettled(this.connections.map((connection) => this.#pass(connection, connection.steps, signal, () => refreshes.request('commit'))));
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
await refreshes.settled();
|
|
97
|
+
}
|
|
98
|
+
// A pass rejects only with an error no history recorded, such as the
|
|
99
|
+
// history's own; the run rejects with it once every other pass ended.
|
|
100
|
+
const unrecorded = passes.find((pass) => pass.status === 'rejected');
|
|
101
|
+
if (unrecorded !== undefined)
|
|
102
|
+
throw unrecorded.reason;
|
|
103
|
+
const results = passes.flatMap((pass) => pass.status === 'fulfilled' ? pass.value.outcomes : []);
|
|
104
|
+
if (results.some(({ cancelled }) => cancelled))
|
|
105
|
+
throw signal?.reason;
|
|
106
|
+
// Derived data follows what the copies committed, also when some copies
|
|
107
|
+
// failed; a dataset's own failure rejects the run as itself, since no
|
|
108
|
+
// history recorded it.
|
|
109
|
+
if (refreshes.failure !== undefined)
|
|
110
|
+
throw refreshes.failure.error;
|
|
111
|
+
await refreshes.refresh();
|
|
112
|
+
if (results.some(({ failures }) => failures.length > 0))
|
|
113
|
+
throw new PipelineError(results);
|
|
114
|
+
return results.map(({ failures: _, cancelled: __, ...result }) => result);
|
|
115
|
+
}
|
|
116
|
+
// Each connection reads the change signals of its own source and runs a
|
|
117
|
+
// pass for what changed. A failed change signal fails only its streams, and
|
|
118
|
+
// its failure is recorded (ADR 0020); a connection whose change signals
|
|
119
|
+
// break the contract stops alone, and its failure is recorded; the rest keep
|
|
120
|
+
// watching. Once every connection stopped, or signal aborts, the recorded
|
|
121
|
+
// failures are thrown together as a WatchError. An error the sync history
|
|
122
|
+
// could not record, or a derived dataset's own failure, stops every
|
|
123
|
+
// connection and rejects as itself.
|
|
124
|
+
async *watch({ signal, }) {
|
|
125
|
+
await this.#declare();
|
|
126
|
+
if (signal.aborted)
|
|
127
|
+
return;
|
|
128
|
+
const controller = new AbortController();
|
|
129
|
+
const watching = AbortSignal.any([signal, controller.signal]);
|
|
130
|
+
const stopped = [];
|
|
131
|
+
const refreshes = new DerivedRefreshes(this.derived, this.connections, watching, (error) => controller.abort(error));
|
|
132
|
+
const passes = interleave(this.connections
|
|
133
|
+
.filter(({ steps }) => steps.length > 0)
|
|
134
|
+
.map((connection) => this.#watch(connection, watching, controller, stopped, () => refreshes.request('commit'))), Number.POSITIVE_INFINITY);
|
|
135
|
+
// Advanced by hand: yield* would forward a consumer's early return to the
|
|
136
|
+
// merged loops before the abort that lets idle ones finish.
|
|
137
|
+
try {
|
|
138
|
+
for (let next = await passes.next(); !next.done; next = await passes.next()) {
|
|
139
|
+
refreshes.request('pass');
|
|
140
|
+
yield next.value;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
controller.abort();
|
|
145
|
+
await passes.return(undefined);
|
|
146
|
+
await refreshes.settled();
|
|
147
|
+
}
|
|
148
|
+
if (refreshes.failure !== undefined)
|
|
149
|
+
throw refreshes.failure.error;
|
|
150
|
+
if (stopped.length > 0)
|
|
151
|
+
throw new WatchError(stopped, stopped.map(errorMessage).join('; '));
|
|
152
|
+
}
|
|
153
|
+
async *#watch(connection, signal,
|
|
154
|
+
// Stops every connection's watch.
|
|
155
|
+
everyone, stopped, committed) {
|
|
156
|
+
const controller = new AbortController();
|
|
157
|
+
const watching = AbortSignal.any([signal, controller.signal]);
|
|
158
|
+
const streams = new Map(connection.steps.map((copy) => [copy.from.name, copy.from]));
|
|
159
|
+
const pending = new Set();
|
|
160
|
+
// A check vouches for a stream only when nothing changed it, no pass is
|
|
161
|
+
// reading it, and its last copy loaded completely: a stream whose change
|
|
162
|
+
// failed to load stays current only as of before that change.
|
|
163
|
+
const loading = new Set();
|
|
164
|
+
const loaded = new Set();
|
|
165
|
+
// Each stream whose copy last failed for a system reason: how many times
|
|
166
|
+
// in a row, and when the watch loads it again.
|
|
167
|
+
const retries = new Map();
|
|
168
|
+
let wake = Promise.withResolvers();
|
|
169
|
+
// An abort wakes the loop below, whether or not the source heeds it.
|
|
170
|
+
watching.addEventListener('abort', () => wake.resolve(), { once: true });
|
|
171
|
+
let finished = false;
|
|
172
|
+
// Change signals that cannot be read as declared, which the history
|
|
173
|
+
// records once the connection stopped, and a check the history could not
|
|
174
|
+
// record, which stops the whole watch.
|
|
175
|
+
let failure;
|
|
176
|
+
let unrecorded;
|
|
177
|
+
// Change signals that break the contract stop their connection: watching
|
|
178
|
+
// them again would only break it again.
|
|
179
|
+
const broken = (message) => {
|
|
180
|
+
failure = { error: new TypeError(message) };
|
|
181
|
+
};
|
|
182
|
+
// Each stream whose change signal failed, from the check that saw it fail
|
|
183
|
+
// until a check reads it again: the history records it one time.
|
|
184
|
+
const failing = new Set();
|
|
185
|
+
// When a check last found each stream unchanged, so each check that the
|
|
186
|
+
// history records vouches for each stream as of its own last check.
|
|
187
|
+
const unchangedAt = new Map();
|
|
188
|
+
// Each stream that a check has told about. As an informer lists before it
|
|
189
|
+
// watches, the first pass waits until every stream had its first check:
|
|
190
|
+
// the first pass reads all streams as one task group, and the first value
|
|
191
|
+
// of each gauge is the baseline for the next checks (ADR 0020).
|
|
192
|
+
const told = new Set();
|
|
193
|
+
// Runs an upkeep when the watch starts and then each interval until the
|
|
194
|
+
// watch stops. A failure is recorded one time for each run of failures, as
|
|
195
|
+
// a failed pass of the streams that the watch keeps; after a config
|
|
196
|
+
// failure the upkeep runs no more in this watch, as a gauge does (ADR 0021).
|
|
197
|
+
const keep = async ({ interval, run }) => {
|
|
198
|
+
for (let failed = false; !watching.aborted;) {
|
|
199
|
+
try {
|
|
200
|
+
await run(watching);
|
|
201
|
+
failed = false;
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
if (watching.aborted)
|
|
205
|
+
return;
|
|
206
|
+
if (!failed) {
|
|
207
|
+
try {
|
|
208
|
+
await this.#recordFailure(connection, error, [...streams.keys()]);
|
|
209
|
+
}
|
|
210
|
+
catch (suppressed) {
|
|
211
|
+
unrecorded = { error: suppressed };
|
|
212
|
+
wake.resolve();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
failed = true;
|
|
217
|
+
if (connection.source.failureType(error) === 'config')
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
await delay(interval, { clock: this.#clock, signal: watching }).catch(() => undefined);
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
// The pipeline reads the source's change signals and decides what each
|
|
224
|
+
// outcome means (ADR 0020).
|
|
225
|
+
const receive = (async () => {
|
|
226
|
+
const keeping = [];
|
|
227
|
+
try {
|
|
228
|
+
// A gauge is the pipeline's from the moment the source gives it, so a
|
|
229
|
+
// source that fails partway through its signals leaves none open. A
|
|
230
|
+
// gauge that stops a helper process can take a moment to stop: the
|
|
231
|
+
// watch waits for it, within the grace of a stopping watch.
|
|
232
|
+
await using gauges = new AsyncDisposableStack();
|
|
233
|
+
const owned = new Set();
|
|
234
|
+
const signals = [];
|
|
235
|
+
try {
|
|
236
|
+
for (const signal of connection.source.changeSignals([...streams.values()], this.#clock)) {
|
|
237
|
+
signals.push(signal);
|
|
238
|
+
if (owned.has(signal.gauge))
|
|
239
|
+
continue;
|
|
240
|
+
owned.add(signal.gauge);
|
|
241
|
+
if (isDisposable(signal.gauge))
|
|
242
|
+
gauges.use(signal.gauge);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
failure = { error };
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const contract = brokenSignals(signals, [...streams.values()]);
|
|
250
|
+
if (contract !== undefined) {
|
|
251
|
+
broken(contract);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
keeping.push(...[...connection.source.upkeep(this.#clock)].map(keep));
|
|
255
|
+
try {
|
|
256
|
+
for await (const outcomes of watchSignals(signals, {
|
|
257
|
+
signal: watching,
|
|
258
|
+
clock: this.#clock,
|
|
259
|
+
failureType: (error) => connection.source.failureType(error),
|
|
260
|
+
})) {
|
|
261
|
+
const at = this.#now();
|
|
262
|
+
for (const [name, outcome] of outcomes) {
|
|
263
|
+
told.add(name);
|
|
264
|
+
if (outcome.state === 'failed') {
|
|
265
|
+
unchangedAt.delete(name);
|
|
266
|
+
if (failing.has(name))
|
|
267
|
+
continue;
|
|
268
|
+
failing.add(name);
|
|
269
|
+
try {
|
|
270
|
+
await this.#recordFailure(connection, outcome.error, [name]);
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
unrecorded = { error };
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
failing.delete(name);
|
|
279
|
+
if (outcome.state === 'changed') {
|
|
280
|
+
unchangedAt.delete(name);
|
|
281
|
+
pending.add(name);
|
|
282
|
+
}
|
|
283
|
+
else
|
|
284
|
+
unchangedAt.set(name, at);
|
|
285
|
+
}
|
|
286
|
+
if (pending.size > 0 && told.size === streams.size)
|
|
287
|
+
wake.resolve();
|
|
288
|
+
const current = [...streams.values()].flatMap((stream) => {
|
|
289
|
+
const checkedAt = unchangedAt.get(stream.name);
|
|
290
|
+
return checkedAt !== undefined &&
|
|
291
|
+
loaded.has(stream.name) &&
|
|
292
|
+
!pending.has(stream.name) &&
|
|
293
|
+
!loading.has(stream.name)
|
|
294
|
+
? [{ stream, at: checkedAt }]
|
|
295
|
+
: [];
|
|
296
|
+
});
|
|
297
|
+
if (current.length > 0)
|
|
298
|
+
try {
|
|
299
|
+
await this.history.checked(connection, at, () => checked(connection.source, current));
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
unrecorded = { error };
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
// A change watcher refuses an interval or a timeout that is not a
|
|
309
|
+
// positive number of milliseconds: a declaration that breaks the
|
|
310
|
+
// contract.
|
|
311
|
+
if (!watching.aborted)
|
|
312
|
+
failure = { error };
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
finally {
|
|
316
|
+
// The checks ended, so the connection stops watching: its upkeeps
|
|
317
|
+
// stop too, after its gauges were disposed, so an upkeep that is slow
|
|
318
|
+
// to stop keeps no gauge open.
|
|
319
|
+
controller.abort();
|
|
320
|
+
await Promise.all(keeping);
|
|
321
|
+
finished = true;
|
|
322
|
+
wake.resolve();
|
|
323
|
+
}
|
|
324
|
+
})();
|
|
325
|
+
try {
|
|
326
|
+
while (!watching.aborted &&
|
|
327
|
+
failure === undefined &&
|
|
328
|
+
unrecorded === undefined) {
|
|
329
|
+
// Retries end with the change signals, as their changes do.
|
|
330
|
+
if (!finished)
|
|
331
|
+
for (const [name, { at }] of retries)
|
|
332
|
+
if (at <= this.#clock.Date.now())
|
|
333
|
+
pending.add(name);
|
|
334
|
+
if (pending.size === 0) {
|
|
335
|
+
if (finished)
|
|
336
|
+
break;
|
|
337
|
+
// Each wait has its own wake. Each thing that wakes the loop also
|
|
338
|
+
// shows in the checks above, so a wake from before this wait has
|
|
339
|
+
// nothing to tell, and a retry timer that ends before the Date of
|
|
340
|
+
// its retry makes the loop wait again, not spin.
|
|
341
|
+
const woken = (wake = Promise.withResolvers());
|
|
342
|
+
const due = Math.min(...[...retries.values()].map(({ at }) => at));
|
|
343
|
+
const retrying = Number.isFinite(due)
|
|
344
|
+
? this.#clock.setTimeout(() => woken.resolve(), due - this.#clock.Date.now())
|
|
345
|
+
: undefined;
|
|
346
|
+
await woken.promise;
|
|
347
|
+
if (retrying !== undefined)
|
|
348
|
+
this.#clock.clearTimeout(retrying);
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
const steps = connection.steps.filter((copy) => pending.has(copy.from.name));
|
|
352
|
+
pending.clear();
|
|
353
|
+
for (const copy of steps)
|
|
354
|
+
loading.add(copy.from.name);
|
|
355
|
+
// Like a schedule after a failed sync, watching goes on: each pass
|
|
356
|
+
// reports what did not load, and a retry or the source's next change
|
|
357
|
+
// loads it again. A stopping watch stops the pass it is running, as
|
|
358
|
+
// run({ signal }) does.
|
|
359
|
+
const pass = await this.#pass(connection, steps, signal, committed);
|
|
360
|
+
for (const { copy, failures } of pass.outcomes) {
|
|
361
|
+
const { name } = copy.from;
|
|
362
|
+
loading.delete(name);
|
|
363
|
+
if (failures.length === 0)
|
|
364
|
+
loaded.add(name);
|
|
365
|
+
else
|
|
366
|
+
loaded.delete(name);
|
|
367
|
+
// A success, a failure only the user can fix, or the last failure
|
|
368
|
+
// the limit allows ends the run of retries.
|
|
369
|
+
const inARow = (retries.get(name)?.failures ?? 0) + 1;
|
|
370
|
+
if (failures.some(({ failureType }) => failureType === 'system') &&
|
|
371
|
+
inARow < retryLimit)
|
|
372
|
+
retries.set(name, {
|
|
373
|
+
failures: inARow,
|
|
374
|
+
at: this.#clock.Date.now() + retryDelayMs(inARow),
|
|
375
|
+
});
|
|
376
|
+
else
|
|
377
|
+
retries.delete(name);
|
|
378
|
+
}
|
|
379
|
+
yield pass;
|
|
380
|
+
}
|
|
381
|
+
if (unrecorded !== undefined)
|
|
382
|
+
throw unrecorded.error;
|
|
383
|
+
if (failure !== undefined) {
|
|
384
|
+
await this.#recordFailure(connection, failure.error, [
|
|
385
|
+
...streams.keys(),
|
|
386
|
+
]);
|
|
387
|
+
stopped.push(connectionError(connection, failure.error));
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
catch (error) {
|
|
391
|
+
// No history recorded this error, so no reader would see it: every
|
|
392
|
+
// connection stops at once, rather than once each next changes.
|
|
393
|
+
everyone.abort();
|
|
394
|
+
throw error;
|
|
395
|
+
}
|
|
396
|
+
finally {
|
|
397
|
+
controller.abort();
|
|
398
|
+
const graced = new AbortController();
|
|
399
|
+
await Promise.race([
|
|
400
|
+
receive,
|
|
401
|
+
delay(stopGraceMs, {
|
|
402
|
+
clock: this.#clock,
|
|
403
|
+
signal: graced.signal,
|
|
404
|
+
ref: false,
|
|
405
|
+
}).catch(() => undefined),
|
|
406
|
+
]);
|
|
407
|
+
graced.abort();
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
// One read per pass, never held between watch passes: a long read can
|
|
411
|
+
// block the upstream's own maintenance, such as SQLite WAL checkpoints.
|
|
412
|
+
// committed is told each time a copy of the pass made rows visible.
|
|
413
|
+
async #pass(connection, steps, signal, committed) {
|
|
414
|
+
const { source } = connection;
|
|
415
|
+
// Asked before the read, so a store an app stopped writing is current
|
|
416
|
+
// only as of then.
|
|
417
|
+
const asked = await Promise.all(steps.map(async (copy) => {
|
|
418
|
+
try {
|
|
419
|
+
return { copy, upstream: await source.currentAsOf(copy.from) };
|
|
420
|
+
}
|
|
421
|
+
catch (error) {
|
|
422
|
+
return { copy, unknown: { error } };
|
|
423
|
+
}
|
|
424
|
+
}));
|
|
425
|
+
const record = await this.history.begin(connection, steps.map((copy) => ({ copy, coverage: source.coverage(copy.from) })), this.#now());
|
|
426
|
+
// The pass runs in the stores that subscribers bind to its start, so a
|
|
427
|
+
// subscriber's async context follows the pass's work, as a span's
|
|
428
|
+
// context follows it (Node diagnostics_channel, Channel.runStores).
|
|
429
|
+
return passChannels.start.runStores(new PassStarted(connection), async () => {
|
|
430
|
+
const read = await replicate(source, connection.destination, connection.checkpoints, asked.flatMap((answer) => 'upstream' in answer ? [answer.copy] : []), connection, committed, signal, this.#clock);
|
|
431
|
+
// A copy whose source could not tell how current its upstream is was not
|
|
432
|
+
// read: what it would load could not be dated.
|
|
433
|
+
const outcomes = [
|
|
434
|
+
...read,
|
|
435
|
+
...asked.flatMap((answer) => 'unknown' in answer
|
|
436
|
+
? [
|
|
437
|
+
{
|
|
438
|
+
copy: answer.copy,
|
|
439
|
+
count: 0,
|
|
440
|
+
deleted: 0,
|
|
441
|
+
failures: [
|
|
442
|
+
{
|
|
443
|
+
partition: null,
|
|
444
|
+
error: answer.unknown.error,
|
|
445
|
+
failureType: source.failureType(answer.unknown.error),
|
|
446
|
+
},
|
|
447
|
+
],
|
|
448
|
+
cancelled: false,
|
|
449
|
+
},
|
|
450
|
+
]
|
|
451
|
+
: []),
|
|
452
|
+
];
|
|
453
|
+
await record.finish(outcomes, new Map(asked.flatMap((answer) => 'upstream' in answer && answer.upstream !== null
|
|
454
|
+
? [[answer.copy.from.name, answer.upstream]]
|
|
455
|
+
: [])), this.#now());
|
|
456
|
+
passChannels.finish.publish(new PassFinished(connection, outcomes));
|
|
457
|
+
return Object.freeze({ connection, outcomes: Object.freeze(outcomes) });
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
// Records a failure as a failed attempt of the streams that it stopped: why
|
|
461
|
+
// a connection stopped, or why the change signal of one stream failed. When
|
|
462
|
+
// the history cannot, the SuppressedError holds both errors, as a failed
|
|
463
|
+
// dispose does.
|
|
464
|
+
async #recordFailure(connection, failure, stopped) {
|
|
465
|
+
const failureType = connection.source.failureType(failure);
|
|
466
|
+
// The attempt declares each stream once, as one read covers it once: a
|
|
467
|
+
// connection that copies a stream twice fails its validation for that,
|
|
468
|
+
// and the history still records why.
|
|
469
|
+
const streams = connection.steps.filter((copy, index, steps) => stopped.includes(copy.from.name) &&
|
|
470
|
+
steps.findIndex(({ from }) => from.name === copy.from.name) === index);
|
|
471
|
+
try {
|
|
472
|
+
const record = await this.history.begin(connection, streams.map((copy) => ({
|
|
473
|
+
copy,
|
|
474
|
+
coverage: connection.source.coverage(copy.from),
|
|
475
|
+
})), this.#now());
|
|
476
|
+
await record.fail(failure, failureType, this.#now());
|
|
477
|
+
}
|
|
478
|
+
catch (error) {
|
|
479
|
+
throw new SuppressedError(error, failure, `Connection ${connection.name} stopped (${errorMessage(failure)}), and its sync history could not record why: ${errorMessage(error)}`);
|
|
480
|
+
}
|
|
481
|
+
passChannels.failure.publish(new PassFailed(connection, failure, failureType));
|
|
482
|
+
}
|
|
483
|
+
// Every declaration is checked before any connection reads, so a wiring
|
|
484
|
+
// mistake stops the whole pipeline; the connection it belongs to records it,
|
|
485
|
+
// in a history installed first.
|
|
486
|
+
async #declare() {
|
|
487
|
+
this.#assertDistinct();
|
|
488
|
+
for (const connection of this.connections)
|
|
489
|
+
this.history.validate(connection);
|
|
490
|
+
await this.history.install(this.connections, this.#now());
|
|
491
|
+
for (const connection of this.connections)
|
|
492
|
+
try {
|
|
493
|
+
connection.validate();
|
|
494
|
+
}
|
|
495
|
+
catch (error) {
|
|
496
|
+
await this.#recordFailure(connection, error, connection.steps.map(({ from }) => from.name));
|
|
497
|
+
throw error;
|
|
498
|
+
}
|
|
499
|
+
for (const dataset of this.derived)
|
|
500
|
+
dataset.validate(this.connections);
|
|
501
|
+
}
|
|
502
|
+
// The time of an event the history records, on the pipeline's clock.
|
|
503
|
+
#now() {
|
|
504
|
+
return new Date(this.#clock.Date.now());
|
|
505
|
+
}
|
|
506
|
+
#assertDistinct() {
|
|
507
|
+
const names = this.connections.map(({ name }) => name);
|
|
508
|
+
if (new Set(names).size !== names.length)
|
|
509
|
+
throw new TypeError('Pipeline connection names must be distinct');
|
|
510
|
+
const ids = this.connections
|
|
511
|
+
.flatMap(({ steps }) => steps.map(({ id }) => id))
|
|
512
|
+
.filter((id) => id !== undefined);
|
|
513
|
+
if (new Set(ids).size !== ids.length)
|
|
514
|
+
throw new TypeError('Pipeline copy IDs must be distinct');
|
|
515
|
+
const derived = this.derived.map(({ name }) => name);
|
|
516
|
+
if (new Set(derived).size !== derived.length)
|
|
517
|
+
throw new TypeError('Pipeline derived dataset names must be distinct');
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
function connectionError(connection, cause) {
|
|
521
|
+
return new Error(`Connection ${connection.name}: ${errorMessage(cause)}`, {
|
|
522
|
+
cause,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
// The streams that checks found unchanged, each current as of its check or as
|
|
526
|
+
// of earlier when its source says its upstream was current only then. A
|
|
527
|
+
// stream whose source cannot tell is left out: the check does not vouch for
|
|
528
|
+
// it.
|
|
529
|
+
async function checked(source, checks) {
|
|
530
|
+
const answers = await Promise.allSettled(checks.map(({ stream }) => source.currentAsOf(stream)));
|
|
531
|
+
return checks.flatMap(({ stream, at }, index) => {
|
|
532
|
+
const answer = answers[index];
|
|
533
|
+
if (answer?.status !== 'fulfilled')
|
|
534
|
+
return [];
|
|
535
|
+
const upstream = answer.value;
|
|
536
|
+
return [
|
|
537
|
+
{
|
|
538
|
+
stream: stream.name,
|
|
539
|
+
at: upstream !== null && upstream < at ? upstream : at,
|
|
540
|
+
},
|
|
541
|
+
];
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
function isDisposable(value) {
|
|
545
|
+
return Symbol.dispose in value || Symbol.asyncDispose in value;
|
|
546
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Where readers discover every documented relation and column.
|
|
2
|
+
export const readerCatalog = Object.freeze({
|
|
3
|
+
name: 'catalog',
|
|
4
|
+
description: 'One row per readable view, table and column, explaining meaning and use. Discover sync_status and extraction_coverage before judging freshness or completeness. Raw tables are not listed.',
|
|
5
|
+
columns: Object.freeze({
|
|
6
|
+
kind: 'view, table or column.',
|
|
7
|
+
name: 'Relation name, or relation.column.',
|
|
8
|
+
data_type: 'Column type; NULL for relations.',
|
|
9
|
+
description: 'Meaning, limitations and usage guidance; does not authorize refresh or other writes.',
|
|
10
|
+
}),
|
|
11
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export type ScalarSchema = {
|
|
2
|
+
readonly description?: string;
|
|
3
|
+
readonly format?: 'date-time' | 'date' | 'date-time-local' | 'time-local' | 'int64' | 'decimal';
|
|
4
|
+
readonly precision?: number;
|
|
5
|
+
readonly scale?: number;
|
|
6
|
+
readonly contentEncoding?: 'base64';
|
|
7
|
+
readonly minimum?: number;
|
|
8
|
+
readonly maximum?: number;
|
|
9
|
+
readonly minLength?: number;
|
|
10
|
+
readonly enum?: readonly (string | number)[];
|
|
11
|
+
};
|
|
12
|
+
export type ScalarItemSchema = ScalarSchema & {
|
|
13
|
+
readonly type: Exclude<ScalarType, 'null'>;
|
|
14
|
+
};
|
|
15
|
+
export type ObjectFieldSchema = ScalarSchema & {
|
|
16
|
+
readonly type: ScalarType | readonly ScalarType[];
|
|
17
|
+
};
|
|
18
|
+
export type ObjectItemSchema = {
|
|
19
|
+
readonly type: 'object';
|
|
20
|
+
readonly description?: string;
|
|
21
|
+
readonly properties: Readonly<Record<string, ObjectFieldSchema>>;
|
|
22
|
+
readonly required: readonly string[];
|
|
23
|
+
};
|
|
24
|
+
export type ItemSchema = ScalarItemSchema | ObjectItemSchema;
|
|
25
|
+
export type FieldSchema = ScalarSchema & {
|
|
26
|
+
readonly type: FieldType | readonly FieldType[];
|
|
27
|
+
readonly items?: ItemSchema;
|
|
28
|
+
};
|
|
29
|
+
type FieldType = ScalarType | 'array';
|
|
30
|
+
type ScalarType = 'string' | 'integer' | 'number' | 'boolean' | 'null';
|
|
31
|
+
type ScalarValue<T> = T extends 'string' ? string : T extends 'integer' | 'number' ? number : T extends 'boolean' ? boolean : T extends 'null' ? null : never;
|
|
32
|
+
type Types<F extends {
|
|
33
|
+
readonly type: unknown;
|
|
34
|
+
}> = F['type'] extends readonly (infer T)[] ? T : F['type'];
|
|
35
|
+
type ItemValue<I extends ItemSchema> = I extends ObjectItemSchema ? {
|
|
36
|
+
-readonly [K in keyof I['properties']]: ScalarValue<Types<I['properties'][K]>>;
|
|
37
|
+
} : ScalarValue<I['type']>;
|
|
38
|
+
type FieldValue<F extends FieldSchema> = 'array' extends Types<F> ? F['items'] extends ItemSchema ? ItemValue<F['items']>[] | ScalarValue<Exclude<Types<F>, 'array'>> : never : ScalarValue<Types<F>>;
|
|
39
|
+
export type Properties = Readonly<Record<string, FieldSchema>>;
|
|
40
|
+
export type StreamSchema = {
|
|
41
|
+
readonly type: 'object';
|
|
42
|
+
readonly description?: string;
|
|
43
|
+
readonly properties?: Properties;
|
|
44
|
+
readonly required?: readonly string[];
|
|
45
|
+
};
|
|
46
|
+
export type SchemaRecord<P extends Properties> = string extends keyof P ? Record<string, unknown> : {
|
|
47
|
+
-readonly [K in keyof P]: FieldValue<P[K]>;
|
|
48
|
+
};
|
|
49
|
+
export type RecordDraft<P extends Properties> = {
|
|
50
|
+
readonly [K in keyof P]: unknown;
|
|
51
|
+
};
|
|
52
|
+
export declare function validateRecords<P extends Properties>(stream: {
|
|
53
|
+
readonly name: string;
|
|
54
|
+
readonly jsonSchema: {
|
|
55
|
+
readonly properties?: P;
|
|
56
|
+
};
|
|
57
|
+
}, records: unknown, source: string): SchemaRecord<P>[];
|
|
58
|
+
export {};
|