@useairfoil/connector-kit 0.6.0 → 0.7.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.txt +201 -0
- package/METRICS.md +38 -42
- package/README.md +111 -444
- package/dist/annotations-vzSzXtr-.d.mts +34 -0
- package/dist/arrow-flight-DDQ0ktx9.mjs +251 -0
- package/dist/arrow-flight-DDQ0ktx9.mjs.map +1 -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-C6_dZnhE.mjs +256 -0
- package/dist/connector-app-C6_dZnhE.mjs.map +1 -0
- package/dist/{constants-D113Ut1x.js → constants-v3UX-hf6.mjs} +5 -5
- package/dist/constants-v3UX-hf6.mjs.map +1 -0
- package/dist/demo/index.d.mts +2 -0
- package/dist/demo/index.mjs +2 -0
- package/dist/demo-DqTJ7TWI.mjs +204 -0
- package/dist/demo-DqTJ7TWI.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-Cj2PwlNm.mjs +30 -0
- package/dist/iceberg-Cj2PwlNm.mjs.map +1 -0
- package/dist/index-BTAbexBp.d.mts +55 -0
- package/dist/{index-DXa26Ry5.d.ts → index-BX8G7d41.d.mts} +1 -2
- package/dist/{index-VpHaZXZw.d.ts → index-Byr8NTdG.d.mts} +11 -13
- package/dist/index-CV6V6fyx.d.mts +79 -0
- package/dist/{index-C5Ma7V7t.d.ts → index-CZKx2Yce.d.mts} +72 -37
- package/dist/{index-DYpEMJf-.d.ts → index-CgvFomO6.d.mts} +6 -7
- package/dist/{index-bfjH7Yb0.d.ts → index-DeTwIUKl.d.mts} +9 -10
- package/dist/{index-DKoRwyFn.d.ts → index-DkVNO6ks.d.mts} +2 -5
- package/dist/{index-BKw7jA2a.d.ts → index-dHS59EZl.d.mts} +10 -8
- package/dist/{index-CR9HJDdr.d.ts → index-eFuoMZZ3.d.mts} +18 -20
- package/dist/{index-ZSuiZX5H.d.ts → index-fHIJJoRe.d.mts} +25 -30
- package/dist/index-uFa2wwYo.d.mts +27 -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-DoCUwm5N.mjs +275 -0
- package/dist/ingestion-DoCUwm5N.mjs.map +1 -0
- package/dist/ingestor/index.d.mts +3 -0
- package/dist/ingestor/index.mjs +4 -0
- package/dist/ingestor-DjCEzBzZ.mjs +26 -0
- package/dist/ingestor-DjCEzBzZ.mjs.map +1 -0
- package/dist/instrumented-Bq1WX2L5.mjs +29 -0
- package/dist/instrumented-Bq1WX2L5.mjs.map +1 -0
- package/dist/manifest/index.d.mts +2 -0
- package/dist/manifest/{index.js → index.mjs} +2 -4
- package/dist/{manifest-P7kBnch9.js → manifest-CABeIhAG.mjs} +14 -19
- package/dist/manifest-CABeIhAG.mjs.map +1 -0
- package/dist/{telemetry-j_nxwYfh.js → metrics-C901IIYZ.mjs} +172 -26
- package/dist/metrics-C901IIYZ.mjs.map +1 -0
- package/dist/{metrics-C4V1pm6v.d.ts → metrics-DoJ0WeKu.d.mts} +18 -13
- 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-Bn8Fivt1.mjs +100 -0
- package/dist/runtime-config-Bn8Fivt1.mjs.map +1 -0
- package/dist/server-D1TTN0_D.mjs +144 -0
- package/dist/server-D1TTN0_D.mjs.map +1 -0
- package/dist/service-BRoy_EId.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-BdYulQOY.js → state-store-DhSrKVEH.mjs} +15 -22
- package/dist/state-store-DhSrKVEH.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-BTTd7AUR.js → status-4h6Oxh-O.mjs} +14 -16
- package/dist/status-4h6Oxh-O.mjs.map +1 -0
- package/dist/table-CxM09xf7.mjs +307 -0
- package/dist/table-CxM09xf7.mjs.map +1 -0
- package/dist/types-NtSv6gwl.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/package.json +72 -58
- package/src/catalog/retry.ts +25 -0
- package/src/catalog/table.ts +237 -0
- package/src/connector-app/create-table.ts +47 -0
- package/src/connector-app/demo.ts +47 -0
- package/src/connector-app/index.ts +3 -0
- package/src/connector-app/setup.ts +69 -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 +172 -0
- package/src/iceberg/annotations.ts +49 -0
- package/src/iceberg/error.ts +9 -0
- package/src/iceberg/index.ts +3 -0
- package/src/iceberg/table.ts +461 -0
- package/src/index.ts +3 -1
- package/src/ingestion/engine.ts +240 -60
- package/src/ingestor/arrow-flight.ts +141 -0
- package/src/ingestor/arrow.ts +248 -0
- package/src/ingestor/console.ts +13 -0
- package/src/ingestor/index.ts +13 -0
- package/src/ingestor/instrumented.ts +61 -0
- package/src/ingestor/service.ts +30 -0
- package/src/manifest/config.ts +3 -3
- package/src/metrics.ts +66 -20
- package/src/runtime-config/config.ts +11 -8
- package/src/runtime-config/constants.ts +3 -2
- package/src/runtime-config/index.ts +2 -7
- package/src/runtime-config/layer.ts +1 -1
- package/src/runtime-config/table-bindings.ts +50 -136
- 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 +20 -28
- 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 -12
- package/dist/connector-app-BljHd0Pe.js +0 -64
- package/dist/connector-app-BljHd0Pe.js.map +0 -1
- package/dist/constants-D113Ut1x.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-BA8ASHKr.d.ts +0 -31
- package/dist/index.d.ts +0 -14
- package/dist/index.js +0 -60
- package/dist/index.js.map +0 -1
- package/dist/ingestion/index.d.ts +0 -6
- package/dist/ingestion/index.js +0 -11
- package/dist/ingestion-QBGKrFr9.js +0 -200
- package/dist/ingestion-QBGKrFr9.js.map +0 -1
- package/dist/manifest/index.d.ts +0 -2
- package/dist/manifest-P7kBnch9.js.map +0 -1
- package/dist/metrics-inkCAxKq.js +0 -136
- package/dist/metrics-inkCAxKq.js.map +0 -1
- package/dist/metrics.d.ts +0 -4
- package/dist/metrics.js +0 -5
- package/dist/publisher/index.d.ts +0 -6
- package/dist/publisher/index.js +0 -8
- package/dist/publisher-BdWJOfer.js +0 -138
- package/dist/publisher-BdWJOfer.js.map +0 -1
- package/dist/runtime-config/index.d.ts +0 -4
- package/dist/runtime-config/index.js +0 -5
- package/dist/runtime-config-IbyCVpei.js +0 -60
- package/dist/runtime-config-IbyCVpei.js.map +0 -1
- package/dist/server-D-eLX3yN.js +0 -183
- package/dist/server-D-eLX3yN.js.map +0 -1
- package/dist/service-CUjGhe-7.d.ts +0 -32
- package/dist/service-DiP-XETo.js +0 -8
- package/dist/service-DiP-XETo.js.map +0 -1
- package/dist/state-store/index.d.ts +0 -3
- package/dist/state-store/index.js +0 -5
- package/dist/state-store-BdYulQOY.js.map +0 -1
- package/dist/status-BTTd7AUR.js.map +0 -1
- package/dist/table-bindings-Dp1DhTeS.js +0 -106
- package/dist/table-bindings-Dp1DhTeS.js.map +0 -1
- package/dist/telemetry-j_nxwYfh.js.map +0 -1
- package/dist/types-jVD5Ys43.d.ts +0 -174
- package/dist/webhook/index.d.ts +0 -7
- package/dist/webhook/index.js +0 -9
- package/dist/webhook-MhFcLW-o.js +0 -13
- package/dist/webhook-MhFcLW-o.js.map +0 -1
- package/src/publisher/console.ts +0 -18
- package/src/publisher/index.ts +0 -7
- package/src/publisher/instrumented.ts +0 -75
- package/src/publisher/service.ts +0 -37
- package/src/publisher/wings.ts +0 -204
package/src/index.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
export * from "./core";
|
|
2
2
|
export * as Auth from "./auth";
|
|
3
3
|
export * as ConnectorApp from "./connector-app";
|
|
4
|
+
export * as Demo from "./demo";
|
|
5
|
+
export * as Iceberg from "./iceberg";
|
|
4
6
|
export * as Ingestion from "./ingestion";
|
|
7
|
+
export * as Ingestor from "./ingestor";
|
|
5
8
|
export * as Manifest from "./manifest";
|
|
6
9
|
export * as Metrics from "./metrics";
|
|
7
|
-
export * as Publisher from "./publisher";
|
|
8
10
|
export * as RuntimeConfig from "./runtime-config";
|
|
9
11
|
export * as StateStore from "./state-store";
|
|
10
12
|
export * as Status from "./status";
|
package/src/ingestion/engine.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { DateTime, Effect, Layer, Queue } from "effect";
|
|
2
|
-
import { HttpRouter, type HttpServer, HttpServerResponse } from "effect/
|
|
3
|
-
import * as Observability from "effect/
|
|
1
|
+
import { Array as Arr, DateTime, Effect, Layer, Order, Queue } from "effect";
|
|
2
|
+
import { HttpRouter, type HttpServer, HttpServerResponse } from "effect/http";
|
|
3
|
+
import * as Observability from "effect/observability";
|
|
4
4
|
|
|
5
5
|
import type {
|
|
6
|
+
ChangesFeed,
|
|
6
7
|
ConnectorDefinition,
|
|
7
8
|
Cursor,
|
|
8
9
|
ResourceDefinition,
|
|
@@ -12,11 +13,11 @@ import type {
|
|
|
12
13
|
import type { StateOperation, StateSource } from "../state-store/schema";
|
|
13
14
|
|
|
14
15
|
import { ConnectorError } from "../errors";
|
|
16
|
+
import { ingestBatch } from "../ingestor/instrumented";
|
|
17
|
+
import { Ingestor } from "../ingestor/service";
|
|
15
18
|
import * as Metrics from "../metrics";
|
|
16
|
-
import { publishBatch } from "../publisher/instrumented";
|
|
17
|
-
import { Publisher } from "../publisher/service";
|
|
18
19
|
import { StateStore } from "../state-store";
|
|
19
|
-
import { deriveSyncState, normalizeCursor } from "../state-store/state";
|
|
20
|
+
import { deriveSyncState, hasChanges, normalizeCursor } from "../state-store/state";
|
|
20
21
|
import { statusRoute } from "../status";
|
|
21
22
|
import { Attr, SpanName, annotateError } from "../telemetry";
|
|
22
23
|
import { router, WebhookQueue, type QueuedWebhookBatch } from "../webhook/server";
|
|
@@ -44,47 +45,70 @@ type RunWebhookOptions = RunOptions & {
|
|
|
44
45
|
};
|
|
45
46
|
|
|
46
47
|
/**
|
|
47
|
-
* Runs all connector ingestion loops. Callers provide the
|
|
48
|
+
* Runs all connector ingestion loops. Callers provide the Ingestor and
|
|
48
49
|
* StateStore layers; webhook mode additionally requires an HttpServer layer.
|
|
49
50
|
*/
|
|
50
51
|
export function run<const Resources extends ReadonlyArray<ResourceDefinition>>(
|
|
51
52
|
connector: ConnectorDefinition<Resources>,
|
|
52
53
|
options?: RunNoWebhookOptions,
|
|
53
|
-
): Effect.Effect<void, ConnectorError, StateStore |
|
|
54
|
+
): Effect.Effect<void, ConnectorError, StateStore | Ingestor>;
|
|
54
55
|
export function run<const Resources extends ReadonlyArray<ResourceDefinition>>(
|
|
55
56
|
connector: ConnectorDefinition<Resources>,
|
|
56
57
|
options: RunWebhookOptions,
|
|
57
|
-
): Effect.Effect<void, ConnectorError, StateStore |
|
|
58
|
-
export function run(
|
|
59
|
-
const queueLayer = makeWebhookQueueLayer(connector.name);
|
|
60
|
-
const runtimeLayer = options?.webhook
|
|
61
|
-
? Layer.mergeAll(
|
|
62
|
-
queueLayer,
|
|
63
|
-
makeWebhookServerLayer(connector, options.webhook).pipe(Layer.provide(queueLayer)),
|
|
64
|
-
)
|
|
65
|
-
: Layer.empty;
|
|
66
|
-
|
|
58
|
+
): Effect.Effect<void, ConnectorError, StateStore | Ingestor | HttpServer.HttpServer>;
|
|
59
|
+
export function run(definition: ConnectorDefinition, options?: RunOptions) {
|
|
67
60
|
return Effect.gen(function* () {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
yield*
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
61
|
+
// Check the whole feed, even the resources that don't run.
|
|
62
|
+
if (definition.changes) yield* validateFeed(definition, definition.changes);
|
|
63
|
+
const ingestor = yield* Ingestor;
|
|
64
|
+
const connector = selectResources(definition, ingestor.resourceNames);
|
|
65
|
+
const queueLayer = makeWebhookQueueLayer(connector.name);
|
|
66
|
+
const runtimeLayer = options?.webhook
|
|
67
|
+
? Layer.mergeAll(
|
|
68
|
+
queueLayer,
|
|
69
|
+
makeWebhookServerLayer(connector, options.webhook).pipe(Layer.provide(queueLayer)),
|
|
70
|
+
)
|
|
71
|
+
: Layer.empty;
|
|
76
72
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
73
|
+
return yield* Effect.gen(function* () {
|
|
74
|
+
const initialCutoff =
|
|
75
|
+
options?.initialCutoff ?? (yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)));
|
|
76
|
+
yield* Effect.logInfo("Connector started").pipe(
|
|
77
|
+
Effect.annotateLogs({
|
|
78
|
+
[Attr.connectorName]: connector.name,
|
|
79
|
+
resources: connector.resources.length,
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
81
82
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
83
|
+
const sourceRun = runIngestion(connector, initialCutoff);
|
|
84
|
+
const webhookRun = options?.webhook
|
|
85
|
+
? runWebhookRuntime(connector, options.webhook.routes)
|
|
86
|
+
: Effect.void;
|
|
87
|
+
|
|
88
|
+
return yield* Effect.all([sourceRun, webhookRun], { concurrency: "unbounded" }).pipe(
|
|
89
|
+
Effect.asVoid,
|
|
90
|
+
);
|
|
91
|
+
}).pipe(Effect.provide(runtimeLayer));
|
|
92
|
+
});
|
|
86
93
|
}
|
|
87
94
|
|
|
95
|
+
const selectResources = (
|
|
96
|
+
connector: ConnectorDefinition,
|
|
97
|
+
names: ReadonlyArray<string> | undefined,
|
|
98
|
+
): ConnectorDefinition => {
|
|
99
|
+
if (names === undefined) return connector;
|
|
100
|
+
const isSelected = (resource: ResourceDefinition) => names.includes(resource.name);
|
|
101
|
+
const { changes, ...rest } = connector;
|
|
102
|
+
const feedResources = changes?.resources.filter(isSelected) ?? [];
|
|
103
|
+
return {
|
|
104
|
+
...rest,
|
|
105
|
+
resources: connector.resources.filter(isSelected),
|
|
106
|
+
...(changes && feedResources.length > 0
|
|
107
|
+
? { changes: { ...changes, resources: feedResources } }
|
|
108
|
+
: {}),
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
|
|
88
112
|
const makeWebhookQueueLayer = (connector: string): Layer.Layer<WebhookQueue> =>
|
|
89
113
|
Layer.effect(WebhookQueue)(
|
|
90
114
|
Effect.gen(function* () {
|
|
@@ -111,7 +135,7 @@ const runWebhookQueueConsumer = (connector: ConnectorDefinition) =>
|
|
|
111
135
|
const batch = yield* Queue.take(queue.queue);
|
|
112
136
|
const depth = yield* Queue.size(queue.queue);
|
|
113
137
|
yield* Metrics.setWebhookQueueDepth(connector.name, depth);
|
|
114
|
-
yield*
|
|
138
|
+
yield* ingestBatch({
|
|
115
139
|
connector: connector.name,
|
|
116
140
|
resource: batch.resource,
|
|
117
141
|
source: "webhook",
|
|
@@ -134,7 +158,10 @@ const makeWebhookServerLayer = (
|
|
|
134
158
|
const metricsPath = options.metricsPath === undefined ? "/metrics" : options.metricsPath;
|
|
135
159
|
const statusPath = options.statusPath === undefined ? "/status" : options.statusPath;
|
|
136
160
|
const app = Layer.mergeAll(
|
|
137
|
-
router(options.routes, {
|
|
161
|
+
router(options.routes, {
|
|
162
|
+
connectorName: connector.name,
|
|
163
|
+
resourceNames: connector.resources.map((resource) => resource.name),
|
|
164
|
+
}),
|
|
138
165
|
HttpRouter.add("GET", healthPath, Effect.succeed(HttpServerResponse.text("ok"))),
|
|
139
166
|
...(metricsPath === false
|
|
140
167
|
? []
|
|
@@ -150,14 +177,57 @@ const makeWebhookServerLayer = (
|
|
|
150
177
|
const runIngestion = (
|
|
151
178
|
connector: ConnectorDefinition,
|
|
152
179
|
initialCutoff: Cursor.Value,
|
|
153
|
-
): Effect.Effect<void, ConnectorError, StateStore |
|
|
154
|
-
Effect.
|
|
155
|
-
connector.
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
180
|
+
): Effect.Effect<void, ConnectorError, StateStore | Ingestor> =>
|
|
181
|
+
Effect.gen(function* () {
|
|
182
|
+
const feed = connector.changes;
|
|
183
|
+
yield* Effect.all(
|
|
184
|
+
[
|
|
185
|
+
Effect.forEach(
|
|
186
|
+
connector.resources,
|
|
187
|
+
(resource) => runResourceSources(connector, resource, initialCutoff),
|
|
188
|
+
{ concurrency: "unbounded" },
|
|
189
|
+
),
|
|
190
|
+
feed ? runChangesFeed(connector, feed, initialCutoff) : Effect.void,
|
|
191
|
+
],
|
|
192
|
+
{ concurrency: "unbounded" },
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const bySavedAt = Order.mapInput(
|
|
197
|
+
Order.String,
|
|
198
|
+
(state: NonNullable<ResourceState["changes"]>) => state.lastSuccessAt ?? "",
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
const validateFeed = (connector: ConnectorDefinition, feed: ChangesFeed) =>
|
|
202
|
+
Effect.forEach(feed.resources, (member, index) => {
|
|
203
|
+
if (feed.resources.findIndex((item) => item.name === member.name) !== index) {
|
|
204
|
+
return Effect.fail(
|
|
205
|
+
new ConnectorError({ message: `Resource ${member.name} is in the changes feed twice` }),
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
const resource = connector.resources.find((item) => item.name === member.name);
|
|
209
|
+
if (!resource) {
|
|
210
|
+
return Effect.fail(
|
|
211
|
+
new ConnectorError({
|
|
212
|
+
message: `Changes feed resource ${member.name} is not a resource of connector ${connector.name}`,
|
|
213
|
+
}),
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
if (resource.changes) {
|
|
217
|
+
return Effect.fail(
|
|
218
|
+
new ConnectorError({
|
|
219
|
+
message: `Resource ${member.name} has its own changes and is also in the changes feed`,
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return Effect.void;
|
|
224
|
+
}).pipe(
|
|
225
|
+
Effect.andThen(
|
|
226
|
+
feed.resources.length === 0
|
|
227
|
+
? Effect.fail(new ConnectorError({ message: "The changes feed has no resources" }))
|
|
228
|
+
: Effect.void,
|
|
229
|
+
),
|
|
230
|
+
);
|
|
161
231
|
|
|
162
232
|
const initializeResourceState = (
|
|
163
233
|
existing: ResourceState | undefined,
|
|
@@ -207,9 +277,9 @@ const runResourceSources = (
|
|
|
207
277
|
return initializeRuntimeStatus(connector, resource, initialCutoff).pipe(
|
|
208
278
|
Effect.andThen(Effect.all(runs, { concurrency: "unbounded" })),
|
|
209
279
|
Effect.asVoid,
|
|
210
|
-
Effect.catch(() =>
|
|
280
|
+
Effect.catch((error) =>
|
|
211
281
|
Metrics.setSyncState({ connector: connector.name, resource: resource.name }, "error").pipe(
|
|
212
|
-
Effect.andThen(Effect.logError("Connector resource initialization failed")),
|
|
282
|
+
Effect.andThen(Effect.logError("Connector resource initialization failed", error)),
|
|
213
283
|
Effect.annotateLogs({
|
|
214
284
|
[Attr.connectorName]: connector.name,
|
|
215
285
|
resource: resource.name,
|
|
@@ -250,8 +320,8 @@ const isolateSourceFailure = <A, R>(
|
|
|
250
320
|
): Effect.Effect<A, never, R> =>
|
|
251
321
|
// Only typed source errors are isolated. Defects and interruption still stop the runtime.
|
|
252
322
|
effect.pipe(
|
|
253
|
-
Effect.catch(() =>
|
|
254
|
-
Effect.logError("Connector source parked").pipe(
|
|
323
|
+
Effect.catch((error) =>
|
|
324
|
+
Effect.logError("Connector source parked", error).pipe(
|
|
255
325
|
Effect.annotateLogs({
|
|
256
326
|
[Attr.connectorName]: connector.name,
|
|
257
327
|
resource: resource.name,
|
|
@@ -270,8 +340,9 @@ const refreshRuntimeStatus = Effect.fnUntraced(function* (
|
|
|
270
340
|
const state = yield* getInitializedState(resource.name, initialCutoff);
|
|
271
341
|
yield* Metrics.setSyncState(
|
|
272
342
|
{ connector: connector.name, resource: resource.name },
|
|
273
|
-
deriveSyncState(resource, state),
|
|
343
|
+
deriveSyncState(connector, resource, state),
|
|
274
344
|
);
|
|
345
|
+
|
|
275
346
|
return state;
|
|
276
347
|
});
|
|
277
348
|
|
|
@@ -283,15 +354,16 @@ const initializeRuntimeStatus = Effect.fnUntraced(function* (
|
|
|
283
354
|
const state = yield* getInitializedState(resource.name, initialCutoff);
|
|
284
355
|
yield* Metrics.setSyncState(
|
|
285
356
|
{ connector: connector.name, resource: resource.name },
|
|
286
|
-
deriveSyncState(resource, state),
|
|
357
|
+
deriveSyncState(connector, resource, state),
|
|
287
358
|
);
|
|
359
|
+
|
|
288
360
|
if (resource.backfill) {
|
|
289
361
|
yield* Metrics.setLastSuccessTimestamp(
|
|
290
362
|
{ connector: connector.name, resource: resource.name, source: "backfill" },
|
|
291
363
|
state.backfill?.lastSuccessAt,
|
|
292
364
|
);
|
|
293
365
|
}
|
|
294
|
-
if (resource
|
|
366
|
+
if (hasChanges(connector, resource)) {
|
|
295
367
|
yield* Metrics.setLastSuccessTimestamp(
|
|
296
368
|
{ connector: connector.name, resource: resource.name, source: "changes" },
|
|
297
369
|
state.changes?.lastSuccessAt,
|
|
@@ -328,15 +400,15 @@ const runBackfill = Effect.fnUntraced(function* (
|
|
|
328
400
|
})
|
|
329
401
|
.pipe(recordResourceError(connector, resource, "backfill", "fetch"));
|
|
330
402
|
|
|
331
|
-
yield*
|
|
403
|
+
yield* ingestBatch({
|
|
332
404
|
connector: connector.name,
|
|
333
405
|
resource: resource.name,
|
|
334
406
|
source: "backfill",
|
|
335
407
|
batch: {
|
|
336
408
|
cursor: page.nextPageCursor ?? backfill.cutoff,
|
|
337
|
-
|
|
409
|
+
rows: page.rows,
|
|
338
410
|
},
|
|
339
|
-
}).pipe(recordResourceError(connector, resource, "backfill", "
|
|
411
|
+
}).pipe(recordResourceError(connector, resource, "backfill", "ingest"));
|
|
340
412
|
|
|
341
413
|
const lastSuccessAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso));
|
|
342
414
|
state = {
|
|
@@ -349,7 +421,7 @@ const runBackfill = Effect.fnUntraced(function* (
|
|
|
349
421
|
},
|
|
350
422
|
};
|
|
351
423
|
|
|
352
|
-
// Checkpoint only after
|
|
424
|
+
// Checkpoint only after ingestion succeeds so a crash cannot skip an
|
|
353
425
|
// unacknowledged page; replay therefore remains at-least-once.
|
|
354
426
|
yield* Effect.withSpan(
|
|
355
427
|
store
|
|
@@ -397,18 +469,18 @@ const runChanges = Effect.fnUntraced(function* (
|
|
|
397
469
|
.fetch({ cursor })
|
|
398
470
|
.pipe(recordResourceError(connector, resource, "changes", "fetch"));
|
|
399
471
|
|
|
400
|
-
yield*
|
|
472
|
+
yield* ingestBatch({
|
|
401
473
|
connector: connector.name,
|
|
402
474
|
resource: resource.name,
|
|
403
475
|
source: "changes",
|
|
404
476
|
batch: {
|
|
405
477
|
cursor: page.cursor,
|
|
406
|
-
|
|
478
|
+
rows: page.rows,
|
|
407
479
|
},
|
|
408
|
-
}).pipe(recordResourceError(connector, resource, "changes", "
|
|
480
|
+
}).pipe(recordResourceError(connector, resource, "changes", "ingest"));
|
|
409
481
|
|
|
410
482
|
const lastSuccessAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso));
|
|
411
|
-
// Advance the durable cursor only after
|
|
483
|
+
// Advance the durable cursor only after the engine acknowledges the page.
|
|
412
484
|
yield* Effect.withSpan(
|
|
413
485
|
store
|
|
414
486
|
.setChangesState(resource.name, {
|
|
@@ -433,6 +505,114 @@ const runChanges = Effect.fnUntraced(function* (
|
|
|
433
505
|
yield* refreshRuntimeStatus(connector, resource, initialCutoff);
|
|
434
506
|
}
|
|
435
507
|
|
|
436
|
-
|
|
508
|
+
if (!page.hasMore) {
|
|
509
|
+
yield* Effect.sleep(resource.changes.interval ?? "1 minute");
|
|
510
|
+
}
|
|
437
511
|
}
|
|
438
512
|
});
|
|
513
|
+
|
|
514
|
+
// Every member resource stores the feed cursor. All rows are ingested before any
|
|
515
|
+
// cursor is written. If a crash stops the writes partway, the next run starts
|
|
516
|
+
// from the oldest cursor.
|
|
517
|
+
const runChangesFeed = (
|
|
518
|
+
connector: ConnectorDefinition,
|
|
519
|
+
feed: ChangesFeed,
|
|
520
|
+
initialCutoff: Cursor.Value,
|
|
521
|
+
): Effect.Effect<void, never, StateStore | Ingestor> =>
|
|
522
|
+
Effect.gen(function* () {
|
|
523
|
+
const store = yield* StateStore;
|
|
524
|
+
const members = feed.resources;
|
|
525
|
+
const selected = new Set(members.map((resource) => resource.name));
|
|
526
|
+
// Any failure stops the whole feed, so it is an error for every resource in it.
|
|
527
|
+
const recordFeedError =
|
|
528
|
+
(operation: StateOperation) =>
|
|
529
|
+
<A, R>(effect: Effect.Effect<A, ConnectorError, R>) =>
|
|
530
|
+
effect.pipe(
|
|
531
|
+
Effect.tapError(() =>
|
|
532
|
+
Effect.forEach(
|
|
533
|
+
members,
|
|
534
|
+
(resource) =>
|
|
535
|
+
Metrics.setSyncState(
|
|
536
|
+
{ connector: connector.name, resource: resource.name },
|
|
537
|
+
"error",
|
|
538
|
+
).pipe(Effect.andThen(store.setResourceError(resource.name, "changes", operation))),
|
|
539
|
+
{ discard: true },
|
|
540
|
+
),
|
|
541
|
+
),
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
while (true) {
|
|
545
|
+
const states = yield* Effect.forEach(members, (resource) =>
|
|
546
|
+
store.getResourceState(resource.name),
|
|
547
|
+
);
|
|
548
|
+
// A resource that was off has an older cursor. Resume from the oldest, so it
|
|
549
|
+
// gets the changes it missed. The others read some changes again.
|
|
550
|
+
const saved = states.flatMap((state) => (state?.changes ? [state.changes] : []));
|
|
551
|
+
const cursor = Arr.isReadonlyArrayNonEmpty(saved)
|
|
552
|
+
? Arr.min(saved, bySavedAt).cursor
|
|
553
|
+
: initialCutoff;
|
|
554
|
+
const result = yield* feed.fetch({ cursor, selected }).pipe(recordFeedError("fetch"));
|
|
555
|
+
|
|
556
|
+
yield* Effect.forEach(
|
|
557
|
+
members,
|
|
558
|
+
(resource) =>
|
|
559
|
+
ingestBatch({
|
|
560
|
+
connector: connector.name,
|
|
561
|
+
resource: resource.name,
|
|
562
|
+
source: "changes",
|
|
563
|
+
batch: {
|
|
564
|
+
cursor: result.cursor,
|
|
565
|
+
rows: result.rows[resource.name] ?? [],
|
|
566
|
+
},
|
|
567
|
+
}),
|
|
568
|
+
{ discard: true },
|
|
569
|
+
).pipe(recordFeedError("ingest"));
|
|
570
|
+
|
|
571
|
+
const lastSuccessAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso));
|
|
572
|
+
yield* Effect.forEach(
|
|
573
|
+
members,
|
|
574
|
+
(resource) =>
|
|
575
|
+
Effect.withSpan(
|
|
576
|
+
store
|
|
577
|
+
.setChangesState(resource.name, {
|
|
578
|
+
cursor: normalizeCursor(result.cursor),
|
|
579
|
+
lastSuccessAt,
|
|
580
|
+
})
|
|
581
|
+
.pipe(
|
|
582
|
+
Effect.andThen(
|
|
583
|
+
Metrics.setLastSuccessTimestamp(
|
|
584
|
+
{ connector: connector.name, resource: resource.name, source: "changes" },
|
|
585
|
+
lastSuccessAt,
|
|
586
|
+
),
|
|
587
|
+
),
|
|
588
|
+
Effect.andThen(store.clearResourceError(resource.name, "changes")),
|
|
589
|
+
Effect.tapError((error) => annotateError("state_set", error)),
|
|
590
|
+
),
|
|
591
|
+
SpanName.stateSet,
|
|
592
|
+
{ attributes: { [Attr.stateKey]: resource.name } },
|
|
593
|
+
),
|
|
594
|
+
{ discard: true },
|
|
595
|
+
).pipe(recordFeedError("checkpoint"));
|
|
596
|
+
|
|
597
|
+
yield* Effect.forEach(members, (resource, index) =>
|
|
598
|
+
states[index]?.lastError?.source === "changes"
|
|
599
|
+
? refreshRuntimeStatus(connector, resource, initialCutoff)
|
|
600
|
+
: Effect.void,
|
|
601
|
+
);
|
|
602
|
+
|
|
603
|
+
if (!result.hasMore) {
|
|
604
|
+
yield* Effect.sleep(feed.interval ?? "1 minute");
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}).pipe(
|
|
608
|
+
// Only typed errors park the feed. Defects and interruption still stop the runtime.
|
|
609
|
+
Effect.catch((error) =>
|
|
610
|
+
Effect.logError("Connector changes feed parked", error).pipe(
|
|
611
|
+
Effect.annotateLogs({
|
|
612
|
+
[Attr.connectorName]: connector.name,
|
|
613
|
+
resources: feed.resources.map((resource) => resource.name).join(","),
|
|
614
|
+
}),
|
|
615
|
+
Effect.andThen(Effect.never),
|
|
616
|
+
),
|
|
617
|
+
),
|
|
618
|
+
);
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { RecordBatch, TypeMap } from "apache-arrow";
|
|
2
|
+
import type { TableIdentifier } from "iceberg-js";
|
|
3
|
+
|
|
4
|
+
import { CatalogManager } from "@useairfoil/api";
|
|
5
|
+
import { Config, Effect, Layer } from "effect";
|
|
6
|
+
import { FetchHttpClient } from "effect/http";
|
|
7
|
+
|
|
8
|
+
import type { TableSource } from "../core/types";
|
|
9
|
+
|
|
10
|
+
import { ConnectorError } from "../errors";
|
|
11
|
+
import { compileTable } from "../iceberg/table";
|
|
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 ArrowFlightIngestorConfig<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
|
+
/** Compiles local schemas and opens one Arrow Flight stream per bound resource. */
|
|
37
|
+
export const layerArrowFlight = <const Source extends TableSource>(
|
|
38
|
+
config: ArrowFlightIngestorConfig<Source>,
|
|
39
|
+
): Layer.Layer<Ingestor, ConnectorError, CatalogManager.CatalogManager> =>
|
|
40
|
+
Layer.effect(Ingestor)(
|
|
41
|
+
Effect.gen(function* () {
|
|
42
|
+
const manager = yield* CatalogManager.CatalogManager;
|
|
43
|
+
const bindings: Partial<Record<string, RuntimeConfig.TableBinding>> = config.tables;
|
|
44
|
+
|
|
45
|
+
const prepared: Array<PreparedTable> = [];
|
|
46
|
+
for (const resource of config.connector.resources) {
|
|
47
|
+
const binding = bindings[resource.name];
|
|
48
|
+
if (!binding) continue;
|
|
49
|
+
|
|
50
|
+
const { namespace, name } = binding;
|
|
51
|
+
const identifier = { namespace, name };
|
|
52
|
+
const { schema } = yield* compileTable(resource.rowSchema).pipe(
|
|
53
|
+
Effect.mapError((cause) =>
|
|
54
|
+
connectorError(`Invalid schema for ${resource.name}: ${cause.message}`, cause),
|
|
55
|
+
),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const encode = yield* makeRowEncoder(schema, resource.version);
|
|
59
|
+
prepared.push({ resource: resource.name, identifier, encode });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const entries = new Map<string, IngestorEntry>();
|
|
63
|
+
for (const { resource, identifier, encode } of prepared) {
|
|
64
|
+
const ingestor = yield* manager
|
|
65
|
+
.ingestor({
|
|
66
|
+
catalog: config.catalog,
|
|
67
|
+
namespace: identifier.namespace,
|
|
68
|
+
table: identifier.name,
|
|
69
|
+
})
|
|
70
|
+
.pipe(
|
|
71
|
+
Effect.mapError((cause) =>
|
|
72
|
+
connectorError(`Failed to open ingestor for ${resource}`, cause),
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
entries.set(resource, {
|
|
77
|
+
encode,
|
|
78
|
+
push: (batch) =>
|
|
79
|
+
ingestor
|
|
80
|
+
.push(batch)
|
|
81
|
+
.pipe(
|
|
82
|
+
Effect.mapError((cause) =>
|
|
83
|
+
connectorError(`Failed to ingest rows for ${resource}`, cause),
|
|
84
|
+
),
|
|
85
|
+
),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const service: IngestorService = {
|
|
90
|
+
resourceNames: prepared.map(({ resource }) => resource),
|
|
91
|
+
ingest: ({ resource, batch }) => {
|
|
92
|
+
const entry = entries.get(resource);
|
|
93
|
+
|
|
94
|
+
if (!entry) return Effect.fail(connectorError(`Unknown resource ${resource}`));
|
|
95
|
+
if (batch.rows.length === 0) return Effect.succeed({ rows: 0, bytes: 0 });
|
|
96
|
+
|
|
97
|
+
return entry
|
|
98
|
+
.encode(batch.rows)
|
|
99
|
+
.pipe(
|
|
100
|
+
Effect.flatMap((encoded) =>
|
|
101
|
+
entry
|
|
102
|
+
.push(encoded)
|
|
103
|
+
.pipe(Effect.as({ rows: batch.rows.length, bytes: encoded.data.byteLength })),
|
|
104
|
+
),
|
|
105
|
+
);
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
return Ingestor.of(service);
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
/** Configures Arrow Flight ingestion from the hosted URL, catalog and table bindings. */
|
|
114
|
+
export const layerArrowFlightConfig = (connector: TableSource) =>
|
|
115
|
+
Layer.unwrap(
|
|
116
|
+
Effect.gen(function* () {
|
|
117
|
+
const { catalog, uri, flightUri } = yield* Config.all({
|
|
118
|
+
catalog: RuntimeConfig.catalog,
|
|
119
|
+
uri: RuntimeConfig.engineUri,
|
|
120
|
+
flightUri: RuntimeConfig.flightUri,
|
|
121
|
+
}).pipe(
|
|
122
|
+
Effect.mapError((cause) => connectorError("Invalid Arrow Flight configuration", cause)),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
if (uri.protocol !== "http:" && uri.protocol !== "https:") {
|
|
126
|
+
return yield* Effect.fail(connectorError("Invalid Arrow Flight URL"));
|
|
127
|
+
}
|
|
128
|
+
if (flightUri.protocol !== "http:" && flightUri.protocol !== "https:") {
|
|
129
|
+
return yield* Effect.fail(connectorError("Invalid Arrow Flight URL"));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const tables = yield* RuntimeConfig.loadTableBindings(connector).pipe(
|
|
133
|
+
Effect.mapError((cause) => connectorError(cause.message, cause)),
|
|
134
|
+
);
|
|
135
|
+
const manager = CatalogManager.layer({ engineUri: uri.href, flightUri: flightUri.href }).pipe(
|
|
136
|
+
Layer.provide(FetchHttpClient.layer),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
return layerArrowFlight({ connector, catalog, tables }).pipe(Layer.provide(manager));
|
|
140
|
+
}),
|
|
141
|
+
);
|