@eltjs/elt 0.1.3 → 0.3.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/README.md CHANGED
@@ -718,7 +718,7 @@ When a change signal fails:
718
718
 
719
719
  - A failed change signal fails only its streams. The other streams of the connection continue to load. A failed stream is not current as of the check.
720
720
  - The sync history records the failure one time, when the change signal starts to fail, as a failed pass of its streams with the failure type of the error.
721
- - After a `system` failure, such as a connection that its upstream dropped, the next check reads the gauge again, as Prometheus scrapes a failed target again at its next interval. A check that reads a new value loads the stream again.
721
+ - After a `system` failure, such as a connection that its upstream dropped, the next check reads the gauge again, as Prometheus scrapes a failed target again at its next interval. The first check that reads the gauge loads its streams again, also when the value did not change. Thus the sync history has a pass after the failed one, and a stream that failed for one check does not show as failed until its next change.
722
722
  - After a `config` failure, the watch reads that gauge no more, and its streams stay failed, because only the user can fix it. A new watch reads the gauge again.
723
723
  - An upkeep that fails is recorded in the same way, as a failed pass of the streams of the connection. After a `system` failure it runs again at its next interval. After a `config` failure it runs no more in this watch.
724
724
  - Change signals that break the contract stop their connection: a change signal names a stream that the connection did not select, a selected stream is in no change signal, the change signals of one stream have two intervals or two timeouts, or an interval or a timeout is not a positive number of milliseconds. A `changeSignals()` that throws also stops its connection. The sync history records it as a failed attempt. The other connections continue.
@@ -826,7 +826,7 @@ The destination contract:
826
826
 
827
827
  - Declarations are frozen, and you can use them again.
828
828
  - `Destination.createWriter(configuration, target)` selects a strategy of the storage with no I/O.
829
- - `Destination.load()` opens the one hold of the pass on the storage.
829
+ - `Destination.load({ clock, connection })` opens the one hold of the pass on the storage. `connection` is the name of the connection whose pass opens it, so a destination that records its loads can name each one.
830
830
  - `load.prepare(configuration, target, { writer, reloading })` gives each stream its `Stage`. A `Stage` has `apply`, `commit` and `discard`. It also has `fresh`, which is true when the target holds none of the earlier rows of the copy, so the copy reads from no checkpoint; `reloading`, which is true while commits go to a hidden target; and `complete`, which puts that hidden target in the place of the target after the stream ends with no failure.
831
831
  - Each destination uses `reloadMode` to decide what to do with the target: load into it, make it, evolve a stored target that the stream does not fit now ([shape changes](#shape-changes)), open a reload for an overwrite whose stored target does not fit now, or continue the reload that the last run of the copy left open. A `RESET` of the full stream also opens a reload.
832
832
  - Destinations own connections, files and publication. `Copy` and `Pipeline` have no SQL or file system code of a load.
@@ -1058,7 +1058,7 @@ Two rules exist:
1058
1058
  ## When a derived dataset fails
1059
1059
 
1060
1060
  - **A rule failure** is the output of its input: the status `failed` and the reason. The load and the run continue, and the next refresh tries the input again. See [ADR 0004](./docs/adr/0004-a-rule-failure-stays-on-its-output.md).
1061
- - **A dataset failure**, for example SQL that SQLite refuses, rejects the run as itself, or it stops the watch.
1061
+ - **A dataset failure**, for example SQL that SQLite refuses, rejects the run, or it stops the watch, with a `DerivedDatasetError`. Its `dataset` names the dataset, and its `cause` is the error of the store. The passes that a stopped watch ended are `cancelled` in the sync history, and their records do not name the failure.
1062
1062
  - **A copy that failed** keeps its last committed rows. The derived data of those rows stays as it was.
1063
1063
 
1064
1064
  ## The same concepts in other systems
@@ -1,4 +1,6 @@
1
1
  import type { CheckpointStore } from '../state/checkpoint-store.ts';
2
+ import type { PipelineClock } from './clock.ts';
3
+ import type { Connection } from './connection.ts';
2
4
  import { CopyConfiguration } from './copy-configuration.ts';
3
5
  import type { Destination } from './destination.ts';
4
6
  import type { Source } from './source.ts';
@@ -23,5 +25,5 @@ export declare class Copy<Target extends DestinationTarget> {
23
25
  get from(): Stream;
24
26
  writer(source: Source): string;
25
27
  validate(source: Source, destination: Destination<Target>, checkpoints?: CheckpointStore): void;
26
- clear(source: Source, destination: Destination<Target>, checkpoints?: CheckpointStore): Promise<void>;
28
+ clear({ name, source, destination, checkpoints }: Connection<Target>, clock: PipelineClock): Promise<void>;
27
29
  }
package/dist/core/copy.js CHANGED
@@ -44,10 +44,11 @@ export class Copy {
44
44
  }
45
45
  // Empties the target and removes this copy's checkpoint together, so the
46
46
  // next run reloads from scratch.
47
- async clear(source, destination, checkpoints) {
47
+ async clear({ name, source, destination, checkpoints }, clock) {
48
48
  this.validate(source, destination, checkpoints);
49
- const files = new FileTransfer(this.configuration.fileReads, destination.location(this.to), this.writer(source));
50
- const drop = () => destination.clear(this.configuration, this.to, this.writer(source), (values) => files.reconcile(values));
49
+ const writer = this.writer(source);
50
+ const files = new FileTransfer(this.configuration.fileReads, destination.location(this.to), writer);
51
+ const drop = () => destination.clear(this.configuration, this.to, { writer, clock, connection: name }, (values) => files.reconcile(values));
51
52
  if (this.id !== undefined && checkpoints !== undefined)
52
53
  await checkpoints.clear(this.id, drop);
53
54
  else
@@ -1,3 +1,4 @@
1
+ import type { PipelineClock } from './clock.ts';
1
2
  import type { Connection } from './connection.ts';
2
3
  import type { Target as DestinationTarget } from './target.ts';
3
4
  export type RefreshTrigger = {
@@ -7,11 +8,17 @@ export type RefreshTrigger = {
7
8
  readonly kind: 'pass';
8
9
  readonly tried: ReadonlySet<string>;
9
10
  };
11
+ export declare class DerivedDatasetError extends Error {
12
+ name: string;
13
+ readonly dataset: string;
14
+ constructor(dataset: string, cause: unknown);
15
+ }
10
16
  export declare abstract class DerivedDataset<Target extends DestinationTarget> {
11
17
  abstract readonly name: string;
12
18
  validate(_connections: readonly Connection<Target>[]): void;
13
19
  abstract refresh(options: {
14
20
  readonly connections: readonly Connection<Target>[];
21
+ readonly clock: PipelineClock;
15
22
  readonly earlier: readonly DerivedDataset<Target>[];
16
23
  readonly signal?: AbortSignal;
17
24
  readonly trigger: RefreshTrigger;
@@ -1,3 +1,17 @@
1
+ import { errorMessage } from '../state/sync-history.js';
2
+ // A dataset's own failure, such as SQL its store refuses, named for whoever
3
+ // reads it in a log: the cause alone, such as "no such column: x", does not say
4
+ // which dataset of the pipeline failed.
5
+ export class DerivedDatasetError extends Error {
6
+ name = 'DerivedDatasetError';
7
+ dataset;
8
+ constructor(dataset, cause) {
9
+ super(`Derived dataset ${dataset} failed: ${errorMessage(cause)}`, {
10
+ cause,
11
+ });
12
+ this.dataset = dataset;
13
+ }
14
+ }
1
15
  // Data a pipeline keeps in step with what its copies load, and can always
2
16
  // rebuild from it (Kleppmann's derived data; Estuary's derivations), such as a
3
17
  // view over loaded tables or the text of the files a copy stored. Each kind
@@ -1,10 +1,11 @@
1
+ import type { PipelineClock } from './clock.ts';
1
2
  import type { Connection } from './connection.ts';
2
- import type { DerivedDataset, RefreshTrigger } from './derived-dataset.ts';
3
+ import { type DerivedDataset, type RefreshTrigger } from './derived-dataset.ts';
3
4
  import type { Target as DestinationTarget } from './target.ts';
4
5
  type Kind = RefreshTrigger['kind'];
5
6
  export declare class DerivedRefreshes<Target extends DestinationTarget> {
6
7
  #private;
7
- constructor(datasets: readonly DerivedDataset<Target>[], connections: readonly Connection<Target>[], signal: AbortSignal | undefined, stop: (error: unknown) => void);
8
+ constructor(datasets: readonly DerivedDataset<Target>[], connections: readonly Connection<Target>[], clock: PipelineClock, signal: AbortSignal | undefined, stop: () => void);
8
9
  get failure(): {
9
10
  readonly error: unknown;
10
11
  } | undefined;
@@ -1,4 +1,5 @@
1
1
  import { Latch } from '@zukhruf/async';
2
+ import { DerivedDatasetError, } from './derived-dataset.js';
2
3
  // No refresh runs.
3
4
  class Idle {
4
5
  }
@@ -39,6 +40,7 @@ class Failed {
39
40
  export class DerivedRefreshes {
40
41
  #datasets;
41
42
  #connections;
43
+ #clock;
42
44
  #signal;
43
45
  #stop;
44
46
  #state = new Idle();
@@ -46,9 +48,10 @@ export class DerivedRefreshes {
46
48
  // refresh; the memory of the passes that run, which ends with them.
47
49
  #tried = new Map();
48
50
  #health = new Healthy();
49
- constructor(datasets, connections, signal, stop) {
51
+ constructor(datasets, connections, clock, signal, stop) {
50
52
  this.#datasets = datasets;
51
53
  this.#connections = connections;
54
+ this.#clock = clock;
52
55
  this.#signal = signal;
53
56
  this.#stop = stop;
54
57
  }
@@ -84,12 +87,21 @@ export class DerivedRefreshes {
84
87
  for (const [index, dataset] of this.#datasets.entries()) {
85
88
  const tried = this.#tried.get(dataset) ?? new Set();
86
89
  this.#tried.set(dataset, tried);
87
- await dataset.refresh({
88
- connections: this.#connections,
89
- earlier: this.#datasets.slice(0, index),
90
- signal: this.#signal,
91
- trigger: { kind, tried },
92
- });
90
+ try {
91
+ await dataset.refresh({
92
+ connections: this.#connections,
93
+ clock: this.#clock,
94
+ earlier: this.#datasets.slice(0, index),
95
+ signal: this.#signal,
96
+ trigger: { kind, tried },
97
+ });
98
+ }
99
+ catch (error) {
100
+ // A stop is not the dataset's failure.
101
+ if (this.#signal?.aborted === true)
102
+ throw error;
103
+ throw new DerivedDatasetError(dataset.name, error);
104
+ }
93
105
  }
94
106
  // The passes it follows ended, and their memory with them.
95
107
  if (kind === 'pass')
@@ -109,7 +121,7 @@ export class DerivedRefreshes {
109
121
  catch (error) {
110
122
  if (this.#signal?.aborted !== true) {
111
123
  this.#health = new Failed(error);
112
- this.#stop(error);
124
+ this.#stop();
113
125
  }
114
126
  }
115
127
  finally {
@@ -1,7 +1,7 @@
1
1
  import type { PipelineClock } from './clock.ts';
2
2
  import type { CopyConfiguration } from './copy-configuration.ts';
3
3
  import type { Target as DestinationTarget } from './target.ts';
4
- import type { FieldValues, Stage, Writer } from './writer.ts';
4
+ import type { Clearing, FieldValues, Stage, Writer } from './writer.ts';
5
5
  export type DestinationSyncMode = 'append' | 'overwrite' | 'append_dedup' | 'overwrite_dedup';
6
6
  export type Load<Target extends DestinationTarget> = AsyncDisposable & {
7
7
  prepare(configuration: CopyConfiguration, target: Target, binding: {
@@ -17,6 +17,7 @@ export declare abstract class Destination<Target extends DestinationTarget> {
17
17
  abstract createWriter(configuration: CopyConfiguration, target: Target): Writer;
18
18
  abstract load(options: {
19
19
  clock: PipelineClock;
20
+ connection: string;
20
21
  }): Promise<Load<Target>>;
21
- clear(configuration: CopyConfiguration, target: Target, writer: string, committed?: (values: FieldValues) => Promise<void>): Promise<void>;
22
+ clear(configuration: CopyConfiguration, target: Target, clearing: Clearing, committed?: (values: FieldValues) => Promise<void>): Promise<void>;
22
23
  }
@@ -11,7 +11,7 @@ export class Destination {
11
11
  if (!this.supportedDestinationSyncModes.includes(configuration.destinationSyncMode))
12
12
  throw new TypeError(`Destination does not support ${configuration.destinationSyncMode}`);
13
13
  }
14
- async clear(configuration, target, writer, committed) {
15
- await this.createWriter(configuration, target).clear(writer, committed);
14
+ async clear(configuration, target, clearing, committed) {
15
+ await this.createWriter(configuration, target).clear(clearing, committed);
16
16
  }
17
17
  }
@@ -72,7 +72,7 @@ export class Pipeline {
72
72
  const connection = this.connections.find(({ steps }) => steps.includes(copy));
73
73
  if (connection === undefined)
74
74
  throw new TypeError("Only this pipeline's copies can be cleared");
75
- await copy.clear(connection.source, connection.destination, connection.checkpoints);
75
+ await copy.clear(connection, this.#clock);
76
76
  }
77
77
  }
78
78
  // Once signal aborts, each pass stops at its next message and is recorded as
@@ -87,7 +87,7 @@ export class Pipeline {
87
87
  async run(options) {
88
88
  const signal = options?.signal;
89
89
  await this.#declare();
90
- const refreshes = new DerivedRefreshes(this.derived, this.connections, signal, () => { });
90
+ const refreshes = new DerivedRefreshes(this.derived, this.connections, this.#clock, signal, () => { });
91
91
  let passes;
92
92
  try {
93
93
  passes = await Promise.allSettled(this.connections.map((connection) => this.#pass(connection, connection.steps, signal, () => refreshes.request('commit'))));
@@ -128,7 +128,10 @@ export class Pipeline {
128
128
  const controller = new AbortController();
129
129
  const watching = AbortSignal.any([signal, controller.signal]);
130
130
  const stopped = [];
131
- const refreshes = new DerivedRefreshes(this.derived, this.connections, watching, (error) => controller.abort(error));
131
+ const refreshes = new DerivedRefreshes(this.derived, this.connections, this.#clock, watching,
132
+ // With no reason: the passes it stops were not refused by this failure,
133
+ // and no sync history records a dataset's failure (ADR 0004).
134
+ () => controller.abort());
132
135
  const passes = interleave(this.connections
133
136
  .filter(({ steps }) => steps.length > 0)
134
137
  .map((connection) => this.#watch(connection, watching, controller, stopped, () => refreshes.request('commit'))), Number.POSITIVE_INFINITY);
@@ -275,8 +278,12 @@ export class Pipeline {
275
278
  }
276
279
  continue;
277
280
  }
278
- failing.delete(name);
279
- if (outcome.state === 'changed') {
281
+ // A stream whose change signal failed loads again when a check
282
+ // reads the signal again, also with no new value: the history
283
+ // shows the latest attempt of a stream, and only a pass follows
284
+ // the failed one (ADR 0020).
285
+ const recovered = failing.delete(name);
286
+ if (outcome.state === 'changed' || recovered) {
280
287
  unchangedAt.delete(name);
281
288
  pending.add(name);
282
289
  }
@@ -99,9 +99,9 @@ export async function replicate(source, destination, checkpoints, copies, connec
99
99
  });
100
100
  try {
101
101
  if (checkpoints === undefined || bindings.size === 0)
102
- await transfer(source, destination, null, replications, signal, clock);
102
+ await transfer(source, destination, connection, null, replications, signal, clock);
103
103
  else
104
- await checkpoints.run(bindings, (run) => transfer(source, destination, run, replications, signal, clock));
104
+ await checkpoints.run(bindings, (run) => transfer(source, destination, connection, run, replications, signal, clock));
105
105
  }
106
106
  catch (error) {
107
107
  for (const replication of replications)
@@ -115,7 +115,7 @@ export async function replicate(source, destination, checkpoints, copies, connec
115
115
  replication.settle();
116
116
  return replications.map((replication) => replication.outcome());
117
117
  }
118
- async function transfer(source, destination, run, replications, signal, clock) {
118
+ async function transfer(source, destination, connection, run, replications, signal, clock) {
119
119
  const byStream = new Map(replications.map((replication) => [replication.stream.name, replication]));
120
120
  const incremental = (replication) => replication.copy.configuration.syncMode === 'incremental';
121
121
  const checkpoint = (replication) => {
@@ -153,7 +153,10 @@ async function transfer(source, destination, run, replications, signal, clock) {
153
153
  if (reading.length === 0)
154
154
  return;
155
155
  signal?.throwIfAborted();
156
- await using load = await destination.load({ clock });
156
+ await using load = await destination.load({
157
+ clock,
158
+ connection: connection.name,
159
+ });
157
160
  // Every target is prepared before anything is read; a copy whose target is
158
161
  // refused reads nothing.
159
162
  const prepared = [];
@@ -1,3 +1,4 @@
1
+ import type { PipelineClock } from './clock.ts';
1
2
  import type { Partition } from './partition.ts';
2
3
  import type { FailureType, KeyValue } from './source.ts';
3
4
  import type { Stream } from './stream.ts';
@@ -37,5 +38,10 @@ export type Stage = AsyncDisposable & {
37
38
  export declare abstract class Writer {
38
39
  readonly stream: Stream;
39
40
  constructor(stream: Stream);
40
- abstract clear(writer: string, committed?: (values: FieldValues) => Promise<void>): Promise<void>;
41
+ abstract clear(clearing: Clearing, committed?: (values: FieldValues) => Promise<void>): Promise<void>;
41
42
  }
43
+ export type Clearing = {
44
+ readonly writer: string;
45
+ readonly clock: PipelineClock;
46
+ readonly connection: string;
47
+ };
package/dist/index.d.ts CHANGED
@@ -5,7 +5,7 @@ export { Copy, type CopyOutcome, type CopyResult } from './core/copy.ts';
5
5
  export { CopyConfiguration, type DedupPolicy, } from './core/copy-configuration.ts';
6
6
  export type { Deduplication } from './core/deduplication.ts';
7
7
  export { Destination, type DestinationSyncMode, type Load, } from './core/destination.ts';
8
- export { DerivedDataset, type RefreshTrigger } from './core/derived-dataset.ts';
8
+ export { DerivedDataset, DerivedDatasetError, type RefreshTrigger, } from './core/derived-dataset.ts';
9
9
  export { DocumentParser } from './core/document-parser.ts';
10
10
  export { DocumentText } from './core/document-text.ts';
11
11
  export { type LoadedRow, Rule, type RuleColumn, type RuleResult, } from './core/rule.ts';
@@ -33,7 +33,7 @@ export { type Upkeep } from './core/upkeep.ts';
33
33
  export { Stream, type SyncMode } from './core/stream.ts';
34
34
  export { Target } from './core/target.ts';
35
35
  export { type DescribedColumn, describeTarget, type TargetDescription, } from './core/target-description.ts';
36
- export { type FieldValues, type LoadFailure, type Stage, TargetOwnedError, type WriteCount, type WriteOperation, Writer, } from './core/writer.ts';
36
+ export { type Clearing, type FieldValues, type LoadFailure, type Stage, TargetOwnedError, type WriteCount, type WriteOperation, Writer, } from './core/writer.ts';
37
37
  export { type CheckpointBinding, type CheckpointRun, type CheckpointSession, CheckpointStore, type StoredCheckpoint, } from './state/checkpoint-store.ts';
38
38
  export { copyStatus, type DeclaredCopy, errorMessage, passError, passFailureType, passStatus, type RecordedPass, recordedFailure, type StreamCheck, SyncHistory, type SyncStatus, } from './state/sync-history.ts';
39
39
  export { syncHistoryRelations } from './state/sync-history-relations.ts';
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ export { Connection } from './core/connection.js';
3
3
  export { Copy } from './core/copy.js';
4
4
  export { CopyConfiguration, } from './core/copy-configuration.js';
5
5
  export { Destination, } from './core/destination.js';
6
- export { DerivedDataset } from './core/derived-dataset.js';
6
+ export { DerivedDataset, DerivedDatasetError, } from './core/derived-dataset.js';
7
7
  export { DocumentParser } from './core/document-parser.js';
8
8
  export { DocumentText } from './core/document-text.js';
9
9
  export { Rule, } from './core/rule.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eltjs/elt",
3
- "version": "0.1.3",
3
+ "version": "0.3.0",
4
4
  "description": "A pipeline that copies the data of apps and services into stores that SQL can read: sources, streams, checkpoints, watches and derived datasets.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {