@useairfoil/connector-kit 0.5.3 → 0.6.1
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/METRICS.md +20 -19
- package/README.md +127 -430
- package/dist/annotations-BE74YmAO.d.mts +39 -0
- package/dist/auth/index.d.mts +2 -0
- package/dist/{auth-DvU_CZ7C.js → auth/index.mjs} +6 -8
- package/dist/auth/index.mjs.map +1 -0
- package/dist/connector-app/index.d.mts +2 -0
- package/dist/connector-app/index.mjs +2 -0
- package/dist/connector-app-C-L4QqG6.mjs +123 -0
- package/dist/connector-app-C-L4QqG6.mjs.map +1 -0
- package/dist/{constants-BRhIX_rz.js → constants-D-GQzpHZ.mjs} +10 -6
- package/dist/constants-D-GQzpHZ.mjs.map +1 -0
- package/dist/demo/index.d.mts +2 -0
- package/dist/demo/index.mjs +2 -0
- package/dist/demo-B30Vlkk2.mjs +191 -0
- package/dist/demo-B30Vlkk2.mjs.map +1 -0
- package/dist/errors/index.d.mts +2 -0
- package/dist/errors/index.mjs +2 -0
- package/dist/{errors-DHWylqRZ.js → errors-pT0YVJU5.mjs} +2 -3
- package/dist/errors-pT0YVJU5.mjs.map +1 -0
- package/dist/iceberg/index.d.mts +3 -0
- package/dist/iceberg/index.mjs +3 -0
- package/dist/iceberg-D6L8I6vA.mjs +39 -0
- package/dist/iceberg-D6L8I6vA.mjs.map +1 -0
- package/dist/{index-Cq2IzY7T.d.ts → index-BUHSGm9Q.d.mts} +11 -13
- package/dist/{index-CTHVudvQ.d.ts → index-BX8G7d41.d.mts} +1 -2
- package/dist/{index-J42AZaXU.d.ts → index-BfXpgxk9.d.mts} +9 -10
- package/dist/{index-DKWIh6M1.d.ts → index-CRVAwJ5-.d.mts} +10 -8
- package/dist/index-CUPP2A_m.d.mts +27 -0
- package/dist/{index-Coh_SkBs.d.ts → index-CgvFomO6.d.mts} +6 -7
- package/dist/index-DJhDp4Xc.d.mts +58 -0
- package/dist/index-Dk7urvpF.d.mts +76 -0
- package/dist/{index-D54ySAKn.d.ts → index-DkVNO6ks.d.mts} +2 -5
- package/dist/{index-DtJX_tic.d.ts → index-NnCutIqM.d.mts} +77 -38
- package/dist/{index-CBT-JX-T.d.ts → index-eFuoMZZ3.d.mts} +18 -20
- package/dist/index-ygpml2kC.d.mts +73 -0
- package/dist/index.d.mts +16 -0
- package/dist/index.mjs +45 -0
- package/dist/index.mjs.map +1 -0
- package/dist/ingestion/index.d.mts +2 -0
- package/dist/ingestion/index.mjs +2 -0
- package/dist/ingestion-q0WwqUSt.mjs +275 -0
- package/dist/ingestion-q0WwqUSt.mjs.map +1 -0
- package/dist/ingestor/index.d.mts +3 -0
- package/dist/ingestor/index.mjs +4 -0
- package/dist/ingestor-D4L-HPkg.mjs +26 -0
- package/dist/ingestor-D4L-HPkg.mjs.map +1 -0
- package/dist/instrumented-Bhp7fyvh.mjs +29 -0
- package/dist/instrumented-Bhp7fyvh.mjs.map +1 -0
- package/dist/manifest/index.d.mts +2 -0
- package/dist/manifest/{index.js → index.mjs} +2 -4
- package/dist/{manifest-ByE6YPG0.js → manifest-BGKxmKLy.mjs} +14 -19
- package/dist/manifest-BGKxmKLy.mjs.map +1 -0
- package/dist/metrics-BaEX__MN.mjs +352 -0
- package/dist/metrics-BaEX__MN.mjs.map +1 -0
- package/dist/metrics-CVXFL7YT.d.mts +64 -0
- package/dist/metrics.d.mts +2 -0
- package/dist/metrics.mjs +2 -0
- package/dist/{chunk-Bp6m_JJh.js → rolldown-runtime-D7D4PA-g.mjs} +4 -4
- package/dist/runtime-config/index.d.mts +2 -0
- package/dist/runtime-config/index.mjs +3 -0
- package/dist/runtime-config-Oa55gAew.mjs +97 -0
- package/dist/runtime-config-Oa55gAew.mjs.map +1 -0
- package/dist/{server-CXTQ7odl.js → server-CMnxz3h0.mjs} +21 -60
- package/dist/server-CMnxz3h0.mjs.map +1 -0
- package/dist/service-DLkhuxYl.d.mts +27 -0
- package/dist/service-jWxxfEht.mjs +7 -0
- package/dist/service-jWxxfEht.mjs.map +1 -0
- package/dist/state-store/index.d.mts +2 -0
- package/dist/state-store/index.mjs +2 -0
- package/dist/{state-store-yl6LiTQ0.js → state-store-uFvHY7Ax.mjs} +15 -22
- package/dist/state-store-uFvHY7Ax.mjs.map +1 -0
- package/dist/status/{index.d.ts → index.d.mts} +1 -4
- package/dist/status/{index.js → index.mjs} +2 -6
- package/dist/{status-B-oZBc33.js → status-CKb9IhQ9.mjs} +14 -16
- package/dist/status-CKb9IhQ9.mjs.map +1 -0
- package/dist/table-V0tgUC5i.mjs +370 -0
- package/dist/table-V0tgUC5i.mjs.map +1 -0
- package/dist/types-xZtSmrGA.d.mts +220 -0
- package/dist/webhook/index.d.mts +3 -0
- package/dist/webhook/index.mjs +12 -0
- package/dist/webhook/index.mjs.map +1 -0
- package/dist/wings-I56hGtNv.mjs +325 -0
- package/dist/wings-I56hGtNv.mjs.map +1 -0
- package/package.json +64 -51
- package/src/catalog/table.ts +201 -0
- package/src/connector-app/create-table.ts +47 -0
- package/src/connector-app/demo.ts +42 -0
- package/src/connector-app/index.ts +2 -0
- package/src/connector-app/start.ts +1 -1
- package/src/core/builder.ts +15 -23
- package/src/core/index.ts +11 -1
- package/src/core/types.ts +114 -39
- package/src/demo/definition.ts +68 -0
- package/src/demo/generate.ts +167 -0
- package/src/demo/index.ts +3 -0
- package/src/demo/run.ts +142 -0
- package/src/iceberg/annotations.ts +62 -0
- package/src/iceberg/error.ts +9 -0
- package/src/iceberg/index.ts +3 -0
- package/src/iceberg/table.ts +511 -0
- package/src/index.ts +3 -1
- package/src/ingestion/engine.ts +240 -60
- package/src/ingestor/arrow.ts +248 -0
- package/src/ingestor/console.ts +13 -0
- package/src/ingestor/index.ts +9 -0
- package/src/ingestor/instrumented.ts +61 -0
- package/src/ingestor/service.ts +30 -0
- package/src/ingestor/wings.ts +143 -0
- package/src/manifest/config.ts +3 -3
- package/src/metrics.ts +125 -33
- package/src/runtime-config/config.ts +14 -0
- package/src/runtime-config/constants.ts +5 -2
- package/src/runtime-config/error.ts +4 -1
- package/src/runtime-config/index.ts +2 -0
- package/src/runtime-config/layer.ts +1 -1
- package/src/runtime-config/table-bindings.ts +92 -0
- package/src/state-store/layer-key-value-store.ts +8 -3
- package/src/state-store/layer-memory.ts +1 -1
- package/src/state-store/layer-sql.ts +4 -4
- package/src/state-store/schema.ts +2 -2
- package/src/state-store/state.ts +14 -2
- package/src/status/route.ts +2 -2
- package/src/telemetry.ts +25 -10
- package/src/webhook/server.ts +39 -40
- package/dist/auth/index.d.ts +0 -2
- package/dist/auth/index.js +0 -3
- package/dist/auth-DvU_CZ7C.js.map +0 -1
- package/dist/connector-app/index.d.ts +0 -14
- package/dist/connector-app/index.js +0 -11
- package/dist/connector-app-d7Z-SZLQ.js +0 -64
- package/dist/connector-app-d7Z-SZLQ.js.map +0 -1
- package/dist/constants-BRhIX_rz.js.map +0 -1
- package/dist/errors/index.d.ts +0 -2
- package/dist/errors/index.js +0 -3
- package/dist/errors-DHWylqRZ.js.map +0 -1
- package/dist/index-Byr0Lf0C.d.ts +0 -25
- package/dist/index-o1-Z3n7V.d.ts +0 -55
- package/dist/index.d.ts +0 -14
- package/dist/index.js +0 -58
- package/dist/index.js.map +0 -1
- package/dist/ingestion/index.d.ts +0 -6
- package/dist/ingestion/index.js +0 -10
- package/dist/ingestion-BQo4cbGw.js +0 -200
- package/dist/ingestion-BQo4cbGw.js.map +0 -1
- package/dist/manifest/index.d.ts +0 -2
- package/dist/manifest-ByE6YPG0.js.map +0 -1
- package/dist/metrics-CVAk1ALR.d.ts +0 -53
- package/dist/metrics-CWPy2Aqc.js +0 -96
- package/dist/metrics-CWPy2Aqc.js.map +0 -1
- package/dist/metrics.d.ts +0 -4
- package/dist/metrics.js +0 -3
- package/dist/publisher/index.d.ts +0 -5
- package/dist/publisher/index.js +0 -6
- package/dist/publisher-BYT3RLVO.js +0 -131
- package/dist/publisher-BYT3RLVO.js.map +0 -1
- package/dist/runtime-config/index.d.ts +0 -2
- package/dist/runtime-config/index.js +0 -4
- package/dist/runtime-config-BpB-d_bF.js +0 -51
- package/dist/runtime-config-BpB-d_bF.js.map +0 -1
- package/dist/server-CXTQ7odl.js.map +0 -1
- package/dist/service-DuFiQwid.js +0 -191
- package/dist/service-DuFiQwid.js.map +0 -1
- package/dist/service-IE5qaf9x.d.ts +0 -32
- package/dist/state-store/index.d.ts +0 -3
- package/dist/state-store/index.js +0 -5
- package/dist/state-store-yl6LiTQ0.js.map +0 -1
- package/dist/status-B-oZBc33.js.map +0 -1
- package/dist/types-B35J5YPu.d.ts +0 -174
- package/dist/webhook/index.d.ts +0 -6
- package/dist/webhook/index.js +0 -8
- package/dist/webhook-DI-Afxkg.js +0 -13
- package/dist/webhook-DI-Afxkg.js.map +0 -1
- package/src/publisher/console.ts +0 -18
- package/src/publisher/index.ts +0 -7
- package/src/publisher/instrumented.ts +0 -68
- package/src/publisher/service.ts +0 -37
- package/src/publisher/wings.ts +0 -204
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Effect, Metric } from "effect";
|
|
2
|
+
|
|
3
|
+
import type { ResourceBatch } from "../core/types";
|
|
4
|
+
|
|
5
|
+
import * as Metrics from "../metrics";
|
|
6
|
+
import { Attr, EventAttr, EventName, SpanName, addCurrentSpanEvent } from "../telemetry";
|
|
7
|
+
import { Ingestor, type IngestSource } from "./service";
|
|
8
|
+
|
|
9
|
+
export const ingestBatch = Effect.fnUntraced(function* (options: {
|
|
10
|
+
readonly connector?: string;
|
|
11
|
+
readonly resource: string;
|
|
12
|
+
readonly source: IngestSource;
|
|
13
|
+
readonly batch: ResourceBatch;
|
|
14
|
+
}) {
|
|
15
|
+
const metricAttributes = Metrics.withResourceAttributes({
|
|
16
|
+
connector: options.connector ?? "unknown",
|
|
17
|
+
resource: options.resource,
|
|
18
|
+
source: options.source,
|
|
19
|
+
});
|
|
20
|
+
const batchOutcome = (outcome: "success" | "error") =>
|
|
21
|
+
Metric.withAttributes(Metrics.batches, {
|
|
22
|
+
...metricAttributes,
|
|
23
|
+
[Attr.batchOutcome]: outcome,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const ingestor = yield* Ingestor;
|
|
27
|
+
|
|
28
|
+
const ingest = ingestor.ingest(options).pipe(
|
|
29
|
+
Effect.tap(() => Effect.annotateCurrentSpan({ [Attr.ingestionSuccess]: true })),
|
|
30
|
+
Effect.tapError(() =>
|
|
31
|
+
Effect.annotateCurrentSpan({
|
|
32
|
+
[Attr.ingestionSuccess]: false,
|
|
33
|
+
[Attr.errorPhase]: "ingest",
|
|
34
|
+
}),
|
|
35
|
+
),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
// onError keeps the failure, so the engine still sees it and skips the checkpoint.
|
|
39
|
+
const result = yield* Effect.withSpan(ingest, SpanName.ingest, { kind: "producer" }).pipe(
|
|
40
|
+
Effect.trackDuration(Metric.withAttributes(Metrics.ingestDuration, metricAttributes)),
|
|
41
|
+
Effect.onError(() => Metric.update(batchOutcome("error"), 1)),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
yield* Metric.update(batchOutcome("success"), 1);
|
|
45
|
+
yield* Metric.update(
|
|
46
|
+
Metric.withAttributes(Metrics.rowsIngested, metricAttributes),
|
|
47
|
+
options.batch.rows.length,
|
|
48
|
+
);
|
|
49
|
+
yield* Metric.update(
|
|
50
|
+
Metric.withAttributes(Metrics.batchSize, metricAttributes),
|
|
51
|
+
options.batch.rows.length,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
if (options.batch.cursor !== undefined) {
|
|
55
|
+
yield* addCurrentSpanEvent(EventName.batchCheckpoint, {
|
|
56
|
+
[EventAttr.batchCursor]: options.batch.cursor,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return result;
|
|
61
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Context, type Effect } from "effect";
|
|
2
|
+
|
|
3
|
+
import type { ResourceBatch } from "../core/types";
|
|
4
|
+
import type { ConnectorError } from "../errors";
|
|
5
|
+
|
|
6
|
+
export type IngestSource = "backfill" | "changes" | "webhook" | "demo";
|
|
7
|
+
|
|
8
|
+
/** A resource batch and the source that produced it. */
|
|
9
|
+
export type IngestOptions = {
|
|
10
|
+
readonly resource: string;
|
|
11
|
+
readonly source: IngestSource;
|
|
12
|
+
readonly batch: ResourceBatch;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Rows written, and their size as uncompressed Arrow buffers. */
|
|
16
|
+
export type IngestResult = {
|
|
17
|
+
readonly rows: number;
|
|
18
|
+
readonly bytes: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Handles batches from connector sources. */
|
|
22
|
+
export interface IngestorService {
|
|
23
|
+
readonly ingest: (options: IngestOptions) => Effect.Effect<IngestResult, ConnectorError>;
|
|
24
|
+
/** Resources it has tables for. Only these run. Without it, every resource runs. */
|
|
25
|
+
readonly resourceNames?: ReadonlyArray<string>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class Ingestor extends Context.Service<Ingestor, IngestorService>()(
|
|
29
|
+
"@useairfoil/connector-kit/Ingestor",
|
|
30
|
+
) {}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { RecordBatch, TypeMap } from "apache-arrow";
|
|
2
|
+
import type { TableIdentifier } from "iceberg-js";
|
|
3
|
+
|
|
4
|
+
import * as Wings from "@useairfoil/wings";
|
|
5
|
+
import { Config, Effect, Layer } from "effect";
|
|
6
|
+
import { FetchHttpClient } from "effect/http";
|
|
7
|
+
|
|
8
|
+
import type { TableSource } from "../core/types";
|
|
9
|
+
|
|
10
|
+
import { ensureTable } from "../catalog/table";
|
|
11
|
+
import { ConnectorError } from "../errors";
|
|
12
|
+
import * as RuntimeConfig from "../runtime-config";
|
|
13
|
+
import { makeRowEncoder, type RowEncoder } from "./arrow";
|
|
14
|
+
import { Ingestor, type IngestorService } from "./service";
|
|
15
|
+
|
|
16
|
+
/** One catalog and an Iceberg table for each resource that runs. */
|
|
17
|
+
export type WingsIngestorConfig<Source extends TableSource = TableSource> = {
|
|
18
|
+
readonly connector: Source;
|
|
19
|
+
readonly catalog: string;
|
|
20
|
+
readonly tables: RuntimeConfig.ResolvedTableBindings<Source>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type IngestorEntry = {
|
|
24
|
+
readonly encode: RowEncoder;
|
|
25
|
+
readonly push: (batch: RecordBatch<TypeMap>) => Effect.Effect<void, ConnectorError>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type PreparedTable = {
|
|
29
|
+
readonly resource: string;
|
|
30
|
+
readonly identifier: TableIdentifier;
|
|
31
|
+
readonly encode: RowEncoder;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const connectorError = (message: string, cause?: unknown) => new ConnectorError({ message, cause });
|
|
35
|
+
|
|
36
|
+
/** Loads each table schema and opens one Wings stream per bound resource. */
|
|
37
|
+
export const layerWings = <const Source extends TableSource>(
|
|
38
|
+
config: WingsIngestorConfig<Source>,
|
|
39
|
+
): Layer.Layer<Ingestor, ConnectorError, Wings.CatalogManager.CatalogManager> =>
|
|
40
|
+
Layer.effect(Ingestor)(
|
|
41
|
+
Effect.gen(function* () {
|
|
42
|
+
const manager = yield* Wings.CatalogManager.CatalogManager;
|
|
43
|
+
const catalog = yield* manager
|
|
44
|
+
.getIcebergCatalog(config.catalog)
|
|
45
|
+
.pipe(Effect.mapError((cause) => connectorError("Failed to load Wings catalog", cause)));
|
|
46
|
+
|
|
47
|
+
const bindings: Partial<Record<string, RuntimeConfig.TableBinding>> = config.tables;
|
|
48
|
+
|
|
49
|
+
const prepared: Array<PreparedTable> = [];
|
|
50
|
+
for (const resource of config.connector.resources) {
|
|
51
|
+
const binding = bindings[resource.name];
|
|
52
|
+
if (!binding) continue;
|
|
53
|
+
|
|
54
|
+
const { namespace, name, location } = binding;
|
|
55
|
+
const identifier = { namespace, name };
|
|
56
|
+
const schema = yield* ensureTable(resource.rowSchema, {
|
|
57
|
+
catalog,
|
|
58
|
+
identifier,
|
|
59
|
+
location,
|
|
60
|
+
}).pipe(
|
|
61
|
+
Effect.mapError((cause) =>
|
|
62
|
+
connectorError(`Failed to prepare table for ${resource.name}: ${cause.message}`, cause),
|
|
63
|
+
),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const encode = yield* makeRowEncoder(schema, resource.version);
|
|
67
|
+
prepared.push({ resource: resource.name, identifier, encode });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const entries = new Map<string, IngestorEntry>();
|
|
71
|
+
for (const { resource, identifier, encode } of prepared) {
|
|
72
|
+
const ingestor = yield* manager
|
|
73
|
+
.ingestor({
|
|
74
|
+
catalog: config.catalog,
|
|
75
|
+
namespace: identifier.namespace,
|
|
76
|
+
table: identifier.name,
|
|
77
|
+
})
|
|
78
|
+
.pipe(
|
|
79
|
+
Effect.mapError((cause) =>
|
|
80
|
+
connectorError(`Failed to open ingestor for ${resource}`, cause),
|
|
81
|
+
),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
entries.set(resource, {
|
|
85
|
+
encode,
|
|
86
|
+
push: (batch) =>
|
|
87
|
+
ingestor
|
|
88
|
+
.push(batch)
|
|
89
|
+
.pipe(
|
|
90
|
+
Effect.mapError((cause) =>
|
|
91
|
+
connectorError(`Failed to ingest rows for ${resource}`, cause),
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const service: IngestorService = {
|
|
98
|
+
resourceNames: prepared.map(({ resource }) => resource),
|
|
99
|
+
ingest: ({ resource, batch }) => {
|
|
100
|
+
const entry = entries.get(resource);
|
|
101
|
+
|
|
102
|
+
if (!entry) return Effect.fail(connectorError(`Unknown resource ${resource}`));
|
|
103
|
+
if (batch.rows.length === 0) return Effect.succeed({ rows: 0, bytes: 0 });
|
|
104
|
+
|
|
105
|
+
return entry
|
|
106
|
+
.encode(batch.rows)
|
|
107
|
+
.pipe(
|
|
108
|
+
Effect.flatMap((encoded) =>
|
|
109
|
+
entry
|
|
110
|
+
.push(encoded)
|
|
111
|
+
.pipe(Effect.as({ rows: batch.rows.length, bytes: encoded.data.byteLength })),
|
|
112
|
+
),
|
|
113
|
+
);
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
return Ingestor.of(service);
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
/** Reads the Wings URL, catalog and table bindings from hosted config. */
|
|
122
|
+
export const layerWingsConfig = (connector: TableSource) =>
|
|
123
|
+
Layer.unwrap(
|
|
124
|
+
Effect.gen(function* () {
|
|
125
|
+
const { catalog, uri } = yield* Config.all({
|
|
126
|
+
catalog: RuntimeConfig.catalog,
|
|
127
|
+
uri: RuntimeConfig.wingsUri,
|
|
128
|
+
}).pipe(Effect.mapError((cause) => connectorError("Invalid Wings configuration", cause)));
|
|
129
|
+
|
|
130
|
+
if (uri.protocol !== "http:" && uri.protocol !== "https:") {
|
|
131
|
+
return yield* Effect.fail(connectorError("Invalid Wings URL"));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const tables = yield* RuntimeConfig.loadTableBindings(connector).pipe(
|
|
135
|
+
Effect.mapError((cause) => connectorError(cause.message, cause)),
|
|
136
|
+
);
|
|
137
|
+
const manager = Wings.CatalogManager.layer({ baseUrl: uri.href }).pipe(
|
|
138
|
+
Layer.provide(FetchHttpClient.layer),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
return layerWings({ connector, catalog, tables }).pipe(Layer.provide(manager));
|
|
142
|
+
}),
|
|
143
|
+
);
|
package/src/manifest/config.ts
CHANGED
|
@@ -111,7 +111,7 @@ const withDefault = <A extends string | number | boolean>(
|
|
|
111
111
|
|
|
112
112
|
/** Defines a required non-empty string, optionally with a default. */
|
|
113
113
|
export const string = (options: FieldOptions<string>): ConfigField<string> => ({
|
|
114
|
-
config: withDefault(Config.
|
|
114
|
+
config: withDefault(Config.NonEmptyString(options.runtimeKey), options.default),
|
|
115
115
|
spec: {
|
|
116
116
|
runtimeKey: options.runtimeKey,
|
|
117
117
|
type: "string",
|
|
@@ -145,7 +145,7 @@ export const number = (options: NumberFieldOptions): ConfigField<number> => {
|
|
|
145
145
|
|
|
146
146
|
/** Defines a boolean, optionally with a default. */
|
|
147
147
|
export const boolean = (options: FieldOptions<boolean>): ConfigField<boolean> => ({
|
|
148
|
-
config: withDefault(Config.
|
|
148
|
+
config: withDefault(Config.Boolean(options.runtimeKey), options.default),
|
|
149
149
|
spec: {
|
|
150
150
|
runtimeKey: options.runtimeKey,
|
|
151
151
|
type: "boolean",
|
|
@@ -160,7 +160,7 @@ export const boolean = (options: FieldOptions<boolean>): ConfigField<boolean> =>
|
|
|
160
160
|
export const select = <const Values extends readonly [string, ...Array<string>]>(
|
|
161
161
|
options: FieldOptions<Values[number]> & { readonly values: Values },
|
|
162
162
|
): ConfigField<Values[number]> => ({
|
|
163
|
-
config: withDefault(Config.
|
|
163
|
+
config: withDefault(Config.Literals(options.values, options.runtimeKey), options.default),
|
|
164
164
|
spec: {
|
|
165
165
|
runtimeKey: options.runtimeKey,
|
|
166
166
|
type: "select",
|
package/src/metrics.ts
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
Cause,
|
|
3
|
+
DateTime,
|
|
4
|
+
type Duration,
|
|
5
|
+
Effect,
|
|
6
|
+
Metric,
|
|
7
|
+
Option,
|
|
8
|
+
Predicate,
|
|
9
|
+
Schedule,
|
|
10
|
+
} from "effect";
|
|
11
|
+
import { HttpClientError, HttpClientResponse } from "effect/http";
|
|
2
12
|
|
|
3
13
|
import type { SyncState } from "./core/types";
|
|
4
14
|
|
|
5
|
-
|
|
6
|
-
|
|
15
|
+
import { Attr } from "./telemetry";
|
|
16
|
+
|
|
17
|
+
export type BatchSource = "backfill" | "changes" | "webhook" | "demo";
|
|
18
|
+
export type BatchOutcome = "success" | "error";
|
|
7
19
|
export type ApiRetryReason = "transport" | "timeout" | "rate_limit" | "server_error";
|
|
8
20
|
export type WebhookOutcome =
|
|
9
21
|
| "ok"
|
|
@@ -41,61 +53,83 @@ export type ApiRetryMetricAttrs = {
|
|
|
41
53
|
readonly reason: ApiRetryReason;
|
|
42
54
|
};
|
|
43
55
|
|
|
44
|
-
export const
|
|
45
|
-
description: "Total
|
|
56
|
+
export const batches = Metric.counter("airfoil.connector.batches", {
|
|
57
|
+
description: "Total resource batches ingested by connector sources",
|
|
46
58
|
incremental: true,
|
|
59
|
+
attributes: { unit: "{batch}" },
|
|
47
60
|
});
|
|
48
61
|
|
|
49
|
-
export const
|
|
50
|
-
description: "Total
|
|
62
|
+
export const rowsIngested = Metric.counter("airfoil.connector.rows.ingested", {
|
|
63
|
+
description: "Total resource rows successfully ingested",
|
|
51
64
|
incremental: true,
|
|
65
|
+
attributes: { unit: "{row}" },
|
|
52
66
|
});
|
|
53
67
|
|
|
54
|
-
export const
|
|
55
|
-
description: "
|
|
56
|
-
incremental: true,
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
export const batchSize = Metric.histogram("airfoil_connector_batch_size", {
|
|
60
|
-
description: "Distribution of resource mutation batch sizes",
|
|
68
|
+
export const batchSize = Metric.histogram("airfoil.connector.batch.size", {
|
|
69
|
+
description: "Distribution of resource row batch sizes",
|
|
61
70
|
boundaries: [1, 5, 10, 25, 50, 100, 250, 500, 1000],
|
|
71
|
+
attributes: { unit: "{row}" },
|
|
62
72
|
});
|
|
63
73
|
|
|
64
|
-
export const
|
|
65
|
-
description: "
|
|
74
|
+
export const ingestDuration = Metric.timer("airfoil.connector.ingest.duration", {
|
|
75
|
+
description: "Ingestor call duration in milliseconds",
|
|
66
76
|
boundaries: [1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000],
|
|
77
|
+
attributes: { unit: "ms" },
|
|
67
78
|
});
|
|
68
79
|
|
|
69
|
-
export const
|
|
80
|
+
export const webhookRequests = Metric.counter("airfoil.connector.webhook.requests", {
|
|
70
81
|
description: "Total webhook HTTP requests handled by connector routes",
|
|
71
82
|
incremental: true,
|
|
83
|
+
attributes: { unit: "{request}" },
|
|
72
84
|
});
|
|
73
85
|
|
|
74
|
-
export const webhookQueueDepth = Metric.gauge("
|
|
75
|
-
description: "Current queued webhook batches waiting for
|
|
86
|
+
export const webhookQueueDepth = Metric.gauge("airfoil.connector.webhook.queue.depth", {
|
|
87
|
+
description: "Current queued webhook batches waiting for ingestion",
|
|
88
|
+
attributes: { unit: "{batch}" },
|
|
76
89
|
});
|
|
77
90
|
|
|
78
|
-
export const syncState = Metric.gauge("
|
|
91
|
+
export const syncState = Metric.gauge("airfoil.connector.sync.state", {
|
|
79
92
|
description: "Connector resource sync state as a Prometheus state-set gauge",
|
|
93
|
+
attributes: { unit: "1" },
|
|
80
94
|
});
|
|
81
95
|
|
|
82
|
-
export const lastSuccessTimestamp = Metric.gauge(
|
|
83
|
-
"
|
|
84
|
-
{
|
|
85
|
-
|
|
86
|
-
},
|
|
87
|
-
);
|
|
96
|
+
export const lastSuccessTimestamp = Metric.gauge("airfoil.connector.last_success.timestamp", {
|
|
97
|
+
description: "Unix timestamp of the last successful durable source checkpoint",
|
|
98
|
+
attributes: { unit: "s" },
|
|
99
|
+
});
|
|
88
100
|
|
|
89
|
-
export const
|
|
101
|
+
export const apiRetries = Metric.counter("airfoil.connector.api.retries", {
|
|
90
102
|
description: "Total provider API retries performed by the connector",
|
|
91
103
|
incremental: true,
|
|
104
|
+
attributes: { unit: "{retry}" },
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const resourceAttributes = (attrs: ResourceMetricAttrs) => ({
|
|
108
|
+
[Attr.connectorName]: attrs.connector,
|
|
109
|
+
[Attr.resourceName]: attrs.resource,
|
|
110
|
+
[Attr.resourceSource]: attrs.source,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const syncAttributes = (attrs: SyncStateAttrs) => ({
|
|
114
|
+
[Attr.connectorName]: attrs.connector,
|
|
115
|
+
[Attr.resourceName]: attrs.resource,
|
|
92
116
|
});
|
|
93
117
|
|
|
94
118
|
export const recordWebhookRequest = (attrs: WebhookMetricAttrs) =>
|
|
95
|
-
Metric.update(
|
|
119
|
+
Metric.update(
|
|
120
|
+
Metric.withAttributes(webhookRequests, {
|
|
121
|
+
[Attr.connectorName]: attrs.connector,
|
|
122
|
+
[Attr.webhookPath]: attrs.path,
|
|
123
|
+
[Attr.webhookOutcome]: attrs.outcome,
|
|
124
|
+
}),
|
|
125
|
+
1,
|
|
126
|
+
);
|
|
96
127
|
|
|
97
128
|
export const setWebhookQueueDepth = (connector: string, depth: number) =>
|
|
98
|
-
Metric.update(
|
|
129
|
+
Metric.update(
|
|
130
|
+
Metric.withAttributes(webhookQueueDepth, { [Attr.connectorName]: connector }),
|
|
131
|
+
depth,
|
|
132
|
+
);
|
|
99
133
|
|
|
100
134
|
export const setLastSuccessTimestamp = (attrs: LastSuccessMetricAttrs, lastSuccessAt?: string) => {
|
|
101
135
|
const timestamp =
|
|
@@ -105,11 +139,66 @@ export const setLastSuccessTimestamp = (attrs: LastSuccessMetricAttrs, lastSucce
|
|
|
105
139
|
Option.map((dateTime) => DateTime.toEpochMillis(dateTime) / 1000),
|
|
106
140
|
Option.getOrElse(() => 0),
|
|
107
141
|
);
|
|
108
|
-
return Metric.update(
|
|
142
|
+
return Metric.update(
|
|
143
|
+
Metric.withAttributes(lastSuccessTimestamp, {
|
|
144
|
+
[Attr.connectorName]: attrs.connector,
|
|
145
|
+
[Attr.resourceName]: attrs.resource,
|
|
146
|
+
[Attr.resourceSource]: attrs.source,
|
|
147
|
+
}),
|
|
148
|
+
timestamp,
|
|
149
|
+
);
|
|
109
150
|
};
|
|
110
151
|
|
|
111
152
|
export const recordApiRetry = (attrs: ApiRetryMetricAttrs) =>
|
|
112
|
-
Metric.update(
|
|
153
|
+
Metric.update(
|
|
154
|
+
Metric.withAttributes(apiRetries, {
|
|
155
|
+
[Attr.connectorName]: attrs.connector,
|
|
156
|
+
[Attr.apiRetryReason]: attrs.reason,
|
|
157
|
+
}),
|
|
158
|
+
1,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
// Same statuses as `HttpClient.retryTransient`.
|
|
162
|
+
const transientStatuses = new Set([408, 429, 500, 502, 503, 504]);
|
|
163
|
+
|
|
164
|
+
// A connector only retries a `403` when the provider uses it for rate limits.
|
|
165
|
+
const reasonForStatus = (status: number): ApiRetryReason =>
|
|
166
|
+
status === 408 ? "timeout" : status === 429 || status === 403 ? "rate_limit" : "server_error";
|
|
167
|
+
|
|
168
|
+
// Effect exports the response type ID, not a guard.
|
|
169
|
+
const isResponse = (value: unknown): value is HttpClientResponse.HttpClientResponse =>
|
|
170
|
+
Predicate.hasProperty(value, HttpClientResponse.TypeId);
|
|
171
|
+
|
|
172
|
+
/** Why `HttpClient.retryTransient` retries. Undefined for a response it keeps. */
|
|
173
|
+
const retryReason = (value: unknown): ApiRetryReason | undefined => {
|
|
174
|
+
if (isResponse(value)) {
|
|
175
|
+
return transientStatuses.has(value.status) ? reasonForStatus(value.status) : undefined;
|
|
176
|
+
}
|
|
177
|
+
if (HttpClientError.isHttpClientError(value) && value.reason._tag === "StatusCodeError") {
|
|
178
|
+
return reasonForStatus(value.reason.response.status);
|
|
179
|
+
}
|
|
180
|
+
return Cause.isTimeoutError(value) ? "timeout" : "transport";
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* A jittered exponential schedule for `HttpClient.retryTransient` that records
|
|
185
|
+
* each retry with its reason.
|
|
186
|
+
*/
|
|
187
|
+
export const retrySchedule = (options: {
|
|
188
|
+
readonly connector: string;
|
|
189
|
+
readonly baseDelay: Duration.Input;
|
|
190
|
+
readonly times: number;
|
|
191
|
+
}) =>
|
|
192
|
+
Schedule.exponential(options.baseDelay).pipe(
|
|
193
|
+
Schedule.jittered,
|
|
194
|
+
Schedule.upTo({ times: options.times }),
|
|
195
|
+
Schedule.tap(({ input }) => {
|
|
196
|
+
const reason = retryReason(input);
|
|
197
|
+
return reason === undefined
|
|
198
|
+
? Effect.void
|
|
199
|
+
: recordApiRetry({ connector: options.connector, reason });
|
|
200
|
+
}),
|
|
201
|
+
);
|
|
113
202
|
|
|
114
203
|
const syncStates = ["pending", "backfilling", "live", "error"] as const;
|
|
115
204
|
|
|
@@ -119,10 +208,13 @@ export const setSyncState = (attrs: SyncStateAttrs, current: SyncState) =>
|
|
|
119
208
|
(state) =>
|
|
120
209
|
Metric.update(
|
|
121
210
|
Metric.withAttributes(syncState, {
|
|
122
|
-
...attrs,
|
|
123
|
-
state,
|
|
211
|
+
...syncAttributes(attrs),
|
|
212
|
+
[Attr.syncState]: state,
|
|
124
213
|
}),
|
|
125
214
|
state === current ? 1 : 0,
|
|
126
215
|
),
|
|
127
216
|
{ discard: true },
|
|
128
217
|
);
|
|
218
|
+
|
|
219
|
+
/** @internal Converts the ergonomic SDK input to canonical OTel attributes. */
|
|
220
|
+
export const withResourceAttributes = resourceAttributes;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Config } from "effect";
|
|
2
|
+
|
|
3
|
+
import { PlatformRuntimeDefault, PlatformRuntimeKey } from "./constants";
|
|
4
|
+
|
|
5
|
+
/** Shared HTTP port used by hosted and sandbox connector entrypoints. */
|
|
6
|
+
export const httpPort = Config.Port(PlatformRuntimeKey.httpPort).pipe(
|
|
7
|
+
Config.withDefault(PlatformRuntimeDefault.httpPort),
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
/** Wings URL, including the protocol. */
|
|
11
|
+
export const wingsUri = Config.URL(PlatformRuntimeKey.wingsUri);
|
|
12
|
+
|
|
13
|
+
/** Wings catalog ID. */
|
|
14
|
+
export const catalog = Config.NonEmptyString(PlatformRuntimeKey.catalog);
|
|
@@ -3,11 +3,13 @@ import { HashSet } from "effect";
|
|
|
3
3
|
/** Shared runtime keys owned by Airfoil rather than an individual connector. */
|
|
4
4
|
export const PlatformRuntimeKey = {
|
|
5
5
|
configPath: "AIRFOIL_CONFIG_PATH",
|
|
6
|
+
httpPort: "AIRFOIL_HTTP_PORT",
|
|
7
|
+
tableBindings: "AIRFOIL_TABLE_BINDINGS",
|
|
6
8
|
connectorInstanceId: "AIRFOIL_CONNECTOR_INSTANCE_ID",
|
|
7
9
|
stateTable: "AIRFOIL_STATE_TABLE",
|
|
8
10
|
postgresConnectionString: "POSTGRES_CONNECTION_STRING",
|
|
9
|
-
|
|
10
|
-
|
|
11
|
+
wingsUri: "WINGS_URI",
|
|
12
|
+
catalog: "AIRFOIL_CATALOG",
|
|
11
13
|
otelEnabled: "OTEL_ENABLED",
|
|
12
14
|
otelExporterOtlpEndpoint: "OTEL_EXPORTER_OTLP_ENDPOINT",
|
|
13
15
|
otelExporterOtlpHeaders: "OTEL_EXPORTER_OTLP_HEADERS",
|
|
@@ -18,6 +20,7 @@ export const PlatformRuntimeKey = {
|
|
|
18
20
|
|
|
19
21
|
/** Defaults for optional platform-owned runtime configuration. */
|
|
20
22
|
export const PlatformRuntimeDefault = {
|
|
23
|
+
httpPort: 8080,
|
|
21
24
|
stateTable: "_airfoil_connectors_state",
|
|
22
25
|
} as const;
|
|
23
26
|
|
|
@@ -3,7 +3,10 @@ import { Data } from "effect";
|
|
|
3
3
|
export type RuntimeConfigErrorCode =
|
|
4
4
|
| "CONFIG_PATH_MISSING"
|
|
5
5
|
| "CONFIG_FILE_UNREADABLE"
|
|
6
|
-
| "CONFIG_JSON_INVALID"
|
|
6
|
+
| "CONFIG_JSON_INVALID"
|
|
7
|
+
| "TABLE_BINDINGS_MISSING"
|
|
8
|
+
| "TABLE_BINDINGS_INVALID"
|
|
9
|
+
| "TABLE_BINDINGS_MISMATCH";
|
|
7
10
|
|
|
8
11
|
/** Expected hosted-config failure with a stable code and diagnostic cause. */
|
|
9
12
|
export class RuntimeConfigError extends Data.TaggedError("RuntimeConfigError")<{
|
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { RuntimeConfigError, type RuntimeConfigErrorCode } from "./error";
|
|
2
|
+
export { catalog, httpPort, wingsUri } from "./config";
|
|
2
3
|
export { PlatformRuntimeDefault, PlatformRuntimeKey } from "./constants";
|
|
3
4
|
export { layerHosted } from "./layer";
|
|
5
|
+
export { type ResolvedTableBindings, type TableBinding, loadTableBindings } from "./table-bindings";
|
|
@@ -8,7 +8,7 @@ const providerFromFile: Effect.Effect<
|
|
|
8
8
|
RuntimeConfigError,
|
|
9
9
|
FileSystem.FileSystem
|
|
10
10
|
> = Effect.gen(function* () {
|
|
11
|
-
const configPath = yield* Config.
|
|
11
|
+
const configPath = yield* Config.NonEmptyString(PlatformRuntimeKey.configPath).pipe(
|
|
12
12
|
Effect.mapError(
|
|
13
13
|
(error) =>
|
|
14
14
|
new RuntimeConfigError({
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { TableIdentifier } from "iceberg-js";
|
|
2
|
+
|
|
3
|
+
import { Config, Effect, Schema } from "effect";
|
|
4
|
+
|
|
5
|
+
import type { TableSource } from "../core/types";
|
|
6
|
+
|
|
7
|
+
import { PlatformRuntimeKey } from "./constants";
|
|
8
|
+
import { RuntimeConfigError } from "./error";
|
|
9
|
+
|
|
10
|
+
/** An Iceberg table and its storage location for each connector resource. */
|
|
11
|
+
export type TableBinding = TableIdentifier & { readonly location?: string | undefined };
|
|
12
|
+
|
|
13
|
+
export type ResolvedTableBindings<Source extends TableSource> = Readonly<
|
|
14
|
+
Partial<Record<Source["resources"][number]["name"], TableBinding>>
|
|
15
|
+
>;
|
|
16
|
+
|
|
17
|
+
const TableIdentifierSchema = Schema.Struct({
|
|
18
|
+
namespace: Schema.NonEmptyArray(Schema.NonEmptyString),
|
|
19
|
+
name: Schema.NonEmptyString,
|
|
20
|
+
location: Schema.optional(Schema.NonEmptyString),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const RawBindingsSchema = Schema.fromJsonString(
|
|
24
|
+
Schema.Record(Schema.String, TableIdentifierSchema),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const asError = (cause: unknown): Error =>
|
|
28
|
+
cause instanceof Error ? cause : new Error(String(cause));
|
|
29
|
+
|
|
30
|
+
const fail = (code: "TABLE_BINDINGS_INVALID" | "TABLE_BINDINGS_MISMATCH", message: string) =>
|
|
31
|
+
new RuntimeConfigError({ code, message, cause: new Error(message) });
|
|
32
|
+
|
|
33
|
+
/** Reads hosted table bindings and checks they name resources of the connector. */
|
|
34
|
+
export const loadTableBindings = <const Source extends TableSource>(
|
|
35
|
+
connector: Source,
|
|
36
|
+
): Effect.Effect<ResolvedTableBindings<Source>, RuntimeConfigError> =>
|
|
37
|
+
Effect.gen(function* () {
|
|
38
|
+
const raw = yield* Config.NonEmptyString(PlatformRuntimeKey.tableBindings).pipe(
|
|
39
|
+
Effect.mapError(
|
|
40
|
+
(cause) =>
|
|
41
|
+
new RuntimeConfigError({
|
|
42
|
+
code: "TABLE_BINDINGS_MISSING",
|
|
43
|
+
message: `${PlatformRuntimeKey.tableBindings} is required in hosted mode`,
|
|
44
|
+
cause: asError(cause),
|
|
45
|
+
}),
|
|
46
|
+
),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const decoded = yield* Schema.decodeUnknownEffect(RawBindingsSchema)(raw, {
|
|
50
|
+
onExcessProperty: "error",
|
|
51
|
+
}).pipe(
|
|
52
|
+
Effect.mapError(
|
|
53
|
+
(cause) =>
|
|
54
|
+
new RuntimeConfigError({
|
|
55
|
+
code: "TABLE_BINDINGS_INVALID",
|
|
56
|
+
message: "Hosted table bindings are invalid",
|
|
57
|
+
cause: asError(cause),
|
|
58
|
+
}),
|
|
59
|
+
),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const actual = Object.keys(decoded);
|
|
63
|
+
if (actual.length === 0) {
|
|
64
|
+
return yield* Effect.fail(
|
|
65
|
+
fail("TABLE_BINDINGS_INVALID", "Hosted table bindings name no resources"),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Bindings come from the platform, resources from the connector. An unknown name
|
|
70
|
+
// means one of them is out of date, and we could write to the wrong table.
|
|
71
|
+
const expected = new Set(connector.resources.map((resource) => resource.name));
|
|
72
|
+
const unknown = actual.filter((name) => !expected.has(name));
|
|
73
|
+
if (unknown.length > 0) {
|
|
74
|
+
return yield* Effect.fail(
|
|
75
|
+
fail(
|
|
76
|
+
"TABLE_BINDINGS_MISMATCH",
|
|
77
|
+
`Hosted table bindings name resources the connector doesn't have (${unknown.length})`,
|
|
78
|
+
),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return Object.fromEntries(
|
|
83
|
+
Object.entries(decoded).map(([resource, identifier]) => [
|
|
84
|
+
resource,
|
|
85
|
+
{
|
|
86
|
+
namespace: Array.from(identifier.namespace),
|
|
87
|
+
name: identifier.name,
|
|
88
|
+
...(identifier.location === undefined ? {} : { location: identifier.location }),
|
|
89
|
+
},
|
|
90
|
+
]),
|
|
91
|
+
) as ResolvedTableBindings<Source>;
|
|
92
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DateTime, Effect, Layer, Option, Schema } from "effect";
|
|
2
|
-
import { KeyValueStore } from "effect/
|
|
2
|
+
import { KeyValueStore } from "effect/persistence";
|
|
3
3
|
|
|
4
4
|
import { ConnectorError } from "../errors";
|
|
5
5
|
import { resourceComponentKey, resourceErrorKey } from "./keys";
|
|
@@ -33,8 +33,13 @@ const resourceErrorContext = (
|
|
|
33
33
|
switch (operation) {
|
|
34
34
|
case "fetch":
|
|
35
35
|
return { source, operation, code: "fetch_failed", message: `${sourceName} fetch failed` };
|
|
36
|
-
case "
|
|
37
|
-
return {
|
|
36
|
+
case "ingest":
|
|
37
|
+
return {
|
|
38
|
+
source,
|
|
39
|
+
operation,
|
|
40
|
+
code: "ingest_failed",
|
|
41
|
+
message: `${sourceName} ingestion failed`,
|
|
42
|
+
};
|
|
38
43
|
case "checkpoint":
|
|
39
44
|
return {
|
|
40
45
|
source,
|