@useairfoil/connector-kit 0.6.1 → 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 +27 -30
- package/README.md +33 -51
- package/dist/{annotations-BE74YmAO.d.mts → annotations-vzSzXtr-.d.mts} +5 -10
- package/dist/{wings-I56hGtNv.mjs → arrow-flight-DDQ0ktx9.mjs} +25 -99
- package/dist/arrow-flight-DDQ0ktx9.mjs.map +1 -0
- package/dist/connector-app/index.d.mts +2 -2
- package/dist/connector-app/index.mjs +2 -2
- package/dist/connector-app-C6_dZnhE.mjs +256 -0
- package/dist/connector-app-C6_dZnhE.mjs.map +1 -0
- package/dist/{constants-D-GQzpHZ.mjs → constants-v3UX-hf6.mjs} +3 -2
- package/dist/constants-v3UX-hf6.mjs.map +1 -0
- package/dist/demo/index.d.mts +1 -1
- package/dist/demo/index.mjs +1 -1
- package/dist/{demo-B30Vlkk2.mjs → demo-DqTJ7TWI.mjs} +32 -19
- package/dist/demo-DqTJ7TWI.mjs.map +1 -0
- package/dist/iceberg/index.d.mts +3 -3
- package/dist/iceberg/index.mjs +3 -3
- package/dist/iceberg-Cj2PwlNm.mjs +30 -0
- package/dist/iceberg-Cj2PwlNm.mjs.map +1 -0
- package/dist/{index-DJhDp4Xc.d.mts → index-BTAbexBp.d.mts} +13 -16
- package/dist/{index-BUHSGm9Q.d.mts → index-Byr8NTdG.d.mts} +2 -2
- package/dist/{index-Dk7urvpF.d.mts → index-CV6V6fyx.d.mts} +9 -6
- package/dist/{index-NnCutIqM.d.mts → index-CZKx2Yce.d.mts} +29 -19
- package/dist/{index-BfXpgxk9.d.mts → index-DeTwIUKl.d.mts} +3 -3
- package/dist/{index-CRVAwJ5-.d.mts → index-dHS59EZl.d.mts} +4 -4
- package/dist/{index-ygpml2kC.d.mts → index-fHIJJoRe.d.mts} +17 -10
- package/dist/index-uFa2wwYo.d.mts +27 -0
- package/dist/index.d.mts +11 -11
- package/dist/index.mjs +10 -10
- package/dist/ingestion/index.d.mts +1 -1
- package/dist/ingestion/index.mjs +1 -1
- package/dist/{ingestion-q0WwqUSt.mjs → ingestion-DoCUwm5N.mjs} +6 -6
- package/dist/{ingestion-q0WwqUSt.mjs.map → ingestion-DoCUwm5N.mjs.map} +1 -1
- package/dist/ingestor/index.d.mts +3 -3
- package/dist/ingestor/index.mjs +3 -3
- package/dist/{ingestor-D4L-HPkg.mjs → ingestor-DjCEzBzZ.mjs} +6 -6
- package/dist/ingestor-DjCEzBzZ.mjs.map +1 -0
- package/dist/{instrumented-Bhp7fyvh.mjs → instrumented-Bq1WX2L5.mjs} +2 -2
- package/dist/{instrumented-Bhp7fyvh.mjs.map → instrumented-Bq1WX2L5.mjs.map} +1 -1
- package/dist/manifest/index.mjs +1 -1
- package/dist/{manifest-BGKxmKLy.mjs → manifest-CABeIhAG.mjs} +2 -2
- package/dist/{manifest-BGKxmKLy.mjs.map → manifest-CABeIhAG.mjs.map} +1 -1
- package/dist/{metrics-BaEX__MN.mjs → metrics-C901IIYZ.mjs} +6 -11
- package/dist/metrics-C901IIYZ.mjs.map +1 -0
- package/dist/{metrics-CVXFL7YT.d.mts → metrics-DoJ0WeKu.d.mts} +2 -2
- package/dist/metrics.d.mts +1 -1
- package/dist/metrics.mjs +1 -1
- package/dist/runtime-config/index.d.mts +2 -2
- package/dist/runtime-config/index.mjs +3 -3
- package/dist/{runtime-config-Oa55gAew.mjs → runtime-config-Bn8Fivt1.mjs} +11 -8
- package/dist/runtime-config-Bn8Fivt1.mjs.map +1 -0
- package/dist/{server-CMnxz3h0.mjs → server-D1TTN0_D.mjs} +3 -3
- package/dist/{server-CMnxz3h0.mjs.map → server-D1TTN0_D.mjs.map} +1 -1
- package/dist/{service-DLkhuxYl.d.mts → service-BRoy_EId.d.mts} +2 -2
- package/dist/state-store/index.mjs +1 -1
- package/dist/{state-store-uFvHY7Ax.mjs → state-store-DhSrKVEH.mjs} +2 -2
- package/dist/{state-store-uFvHY7Ax.mjs.map → state-store-DhSrKVEH.mjs.map} +1 -1
- package/dist/status/index.d.mts +1 -1
- package/dist/status/index.mjs +1 -1
- package/dist/{status-CKb9IhQ9.mjs → status-4h6Oxh-O.mjs} +2 -2
- package/dist/{status-CKb9IhQ9.mjs.map → status-4h6Oxh-O.mjs.map} +1 -1
- package/dist/{table-V0tgUC5i.mjs → table-CxM09xf7.mjs} +22 -85
- package/dist/table-CxM09xf7.mjs.map +1 -0
- package/dist/{types-xZtSmrGA.d.mts → types-NtSv6gwl.d.mts} +3 -3
- package/dist/webhook/index.d.mts +2 -2
- package/dist/webhook/index.mjs +1 -1
- package/package.json +19 -18
- package/src/catalog/retry.ts +25 -0
- package/src/catalog/table.ts +73 -37
- package/src/connector-app/create-table.ts +8 -8
- package/src/connector-app/demo.ts +24 -19
- package/src/connector-app/index.ts +1 -0
- package/src/connector-app/setup.ts +69 -0
- package/src/core/types.ts +1 -1
- package/src/demo/definition.ts +1 -1
- package/src/demo/generate.ts +1 -1
- package/src/demo/run.ts +60 -30
- package/src/iceberg/annotations.ts +3 -16
- package/src/iceberg/index.ts +2 -2
- package/src/iceberg/table.ts +28 -78
- package/src/ingestion/engine.ts +1 -1
- package/src/ingestor/{wings.ts → arrow-flight.ts} +25 -27
- package/src/ingestor/console.ts +1 -1
- package/src/ingestor/index.ts +5 -1
- package/src/runtime-config/config.ts +8 -3
- package/src/runtime-config/constants.ts +2 -1
- package/src/runtime-config/index.ts +1 -1
- package/src/runtime-config/table-bindings.ts +6 -4
- package/src/telemetry.ts +11 -19
- package/dist/connector-app-C-L4QqG6.mjs +0 -123
- package/dist/connector-app-C-L4QqG6.mjs.map +0 -1
- package/dist/constants-D-GQzpHZ.mjs.map +0 -1
- package/dist/demo-B30Vlkk2.mjs.map +0 -1
- package/dist/iceberg-D6L8I6vA.mjs +0 -39
- package/dist/iceberg-D6L8I6vA.mjs.map +0 -1
- package/dist/index-CUPP2A_m.d.mts +0 -27
- package/dist/ingestor-D4L-HPkg.mjs.map +0 -1
- package/dist/metrics-BaEX__MN.mjs.map +0 -1
- package/dist/runtime-config-Oa55gAew.mjs.map +0 -1
- package/dist/table-V0tgUC5i.mjs.map +0 -1
- package/dist/wings-I56hGtNv.mjs.map +0 -1
package/src/catalog/table.ts
CHANGED
|
@@ -6,30 +6,34 @@ import type {
|
|
|
6
6
|
TableSchema,
|
|
7
7
|
} from "iceberg-js";
|
|
8
8
|
|
|
9
|
-
import
|
|
9
|
+
import { CatalogManager } from "@useairfoil/api";
|
|
10
10
|
import { Effect, type Schema } from "effect";
|
|
11
11
|
import { HttpClient } from "effect/http";
|
|
12
12
|
import { getCurrentSchema } from "iceberg-js";
|
|
13
13
|
|
|
14
14
|
import { ConnectorError } from "../errors";
|
|
15
15
|
import { IcebergSchemaError } from "../iceberg/error";
|
|
16
|
-
import { compileTable,
|
|
16
|
+
import { compileTable, makeCreateRequest } from "../iceberg/table";
|
|
17
|
+
import { retryCatalogRequest, type TableRetryOptions } from "./retry";
|
|
17
18
|
|
|
18
19
|
type Catalog = Effect.Success<
|
|
19
|
-
ReturnType<
|
|
20
|
+
ReturnType<CatalogManager.CatalogManagerService["getIcebergCatalog"]>
|
|
20
21
|
>;
|
|
21
22
|
|
|
22
23
|
type TableOptions = {
|
|
23
|
-
readonly
|
|
24
|
+
readonly retry?: TableRetryOptions;
|
|
25
|
+
readonly catalog: Pick<Catalog, "loadTable" | "createTable" | "createNamespaceIfNotExists">;
|
|
24
26
|
readonly identifier: TableIdentifier;
|
|
25
27
|
readonly location?: string | undefined;
|
|
28
|
+
readonly properties?: Readonly<Record<string, string>> | undefined;
|
|
26
29
|
};
|
|
27
30
|
|
|
28
|
-
type
|
|
29
|
-
readonly
|
|
31
|
+
type EngineTableOptions = {
|
|
32
|
+
readonly engineUri: string;
|
|
30
33
|
readonly catalogId: string;
|
|
31
34
|
readonly identifier: TableIdentifier;
|
|
32
35
|
readonly location?: string | undefined;
|
|
36
|
+
readonly properties?: Readonly<Record<string, string>> | undefined;
|
|
33
37
|
};
|
|
34
38
|
|
|
35
39
|
const tableError = (cause: unknown): ConnectorError => {
|
|
@@ -58,21 +62,12 @@ const compareTypes = (
|
|
|
58
62
|
if (actual.type !== "struct") return path;
|
|
59
63
|
return compareFields(expected.fields, actual.fields, path);
|
|
60
64
|
case "list":
|
|
61
|
-
if (
|
|
62
|
-
actual.type !== "list" ||
|
|
63
|
-
expected["element-id"] !== actual["element-id"] ||
|
|
64
|
-
expected["element-required"] !== actual["element-required"]
|
|
65
|
-
) {
|
|
65
|
+
if (actual.type !== "list" || expected["element-required"] !== actual["element-required"]) {
|
|
66
66
|
return path;
|
|
67
67
|
}
|
|
68
68
|
return compareTypes(expected.element, actual.element, `${path}.element`);
|
|
69
69
|
case "map":
|
|
70
|
-
if (
|
|
71
|
-
actual.type !== "map" ||
|
|
72
|
-
expected["key-id"] !== actual["key-id"] ||
|
|
73
|
-
expected["value-id"] !== actual["value-id"] ||
|
|
74
|
-
expected["value-required"] !== actual["value-required"]
|
|
75
|
-
) {
|
|
70
|
+
if (actual.type !== "map" || expected["value-required"] !== actual["value-required"]) {
|
|
76
71
|
return path;
|
|
77
72
|
}
|
|
78
73
|
return (
|
|
@@ -93,7 +88,7 @@ const compareFields = (
|
|
|
93
88
|
const fieldPath = `${path}.${field.name}`;
|
|
94
89
|
const other = actual.find((candidate) => candidate.name === field.name);
|
|
95
90
|
|
|
96
|
-
if (!other || field.
|
|
91
|
+
if (!other || field.required !== other.required) return fieldPath;
|
|
97
92
|
|
|
98
93
|
const mismatch = compareTypes(field.type, other.type, fieldPath);
|
|
99
94
|
if (mismatch) return mismatch;
|
|
@@ -107,6 +102,21 @@ const verifyTable = (
|
|
|
107
102
|
Effect.try({
|
|
108
103
|
try: () => {
|
|
109
104
|
if (!Array.isArray(metadata.schemas)) throw new Error("Invalid table metadata");
|
|
105
|
+
if (metadata["format-version"] !== 3) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
"Storage-engine tables must use Iceberg format v3. Migrate the table before ingesting.",
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
const partitionSpec = metadata["partition-specs"].find(
|
|
111
|
+
(spec) => spec["spec-id"] === metadata["default-spec-id"],
|
|
112
|
+
);
|
|
113
|
+
if (!partitionSpec) throw new Error("Table has no default partition spec");
|
|
114
|
+
if (partitionSpec.fields.length > 0) {
|
|
115
|
+
throw new Error("Storage-engine tables must be unpartitioned.");
|
|
116
|
+
}
|
|
117
|
+
if (metadata.properties?.["encryption.key-id"] !== undefined) {
|
|
118
|
+
throw new Error("Storage-engine tables must be unencrypted.");
|
|
119
|
+
}
|
|
110
120
|
const actual = getCurrentSchema(metadata);
|
|
111
121
|
if (!actual) throw new Error("Table has no current schema");
|
|
112
122
|
|
|
@@ -117,11 +127,17 @@ const verifyTable = (
|
|
|
117
127
|
);
|
|
118
128
|
}
|
|
119
129
|
|
|
120
|
-
const
|
|
121
|
-
|
|
130
|
+
const identifierNames = (schema: TableSchema) =>
|
|
131
|
+
new Set(
|
|
132
|
+
schema.fields
|
|
133
|
+
.filter((field) => schema["identifier-field-ids"]?.includes(field.id))
|
|
134
|
+
.map((field) => field.name),
|
|
135
|
+
);
|
|
136
|
+
const expectedNames = identifierNames(expected);
|
|
137
|
+
const actualNames = identifierNames(actual);
|
|
122
138
|
if (
|
|
123
|
-
|
|
124
|
-
[...
|
|
139
|
+
expectedNames.size !== actualNames.size ||
|
|
140
|
+
[...expectedNames].some((name) => !actualNames.has(name))
|
|
125
141
|
) {
|
|
126
142
|
throw new Error(
|
|
127
143
|
"Table identifier fields do not match. Recreate the table or revert the schema change.",
|
|
@@ -137,35 +153,40 @@ const verifyTable = (
|
|
|
137
153
|
}),
|
|
138
154
|
});
|
|
139
155
|
|
|
140
|
-
/** Creates a table
|
|
156
|
+
/** Creates a table through the REST catalog, then checks the loaded schema. */
|
|
141
157
|
export const createTable = (
|
|
142
158
|
schema: Schema.Top,
|
|
143
159
|
options: TableOptions,
|
|
144
160
|
): Effect.Effect<TableSchema, ConnectorError> =>
|
|
145
161
|
Effect.gen(function* () {
|
|
146
162
|
const table = yield* compileTable(schema);
|
|
147
|
-
yield* options.catalog.
|
|
148
|
-
options.identifier,
|
|
149
|
-
|
|
163
|
+
yield* options.catalog.createTable(
|
|
164
|
+
{ namespace: options.identifier.namespace },
|
|
165
|
+
makeCreateRequest(table, {
|
|
166
|
+
name: options.identifier.name,
|
|
167
|
+
location: options.location,
|
|
168
|
+
properties: options.properties,
|
|
169
|
+
}),
|
|
150
170
|
);
|
|
151
171
|
|
|
152
172
|
const metadata = yield* options.catalog.loadTable(options.identifier);
|
|
153
173
|
return yield* verifyTable(metadata, table.schema);
|
|
154
174
|
}).pipe(Effect.mapError(tableError));
|
|
155
175
|
|
|
156
|
-
/** Creates a table through
|
|
157
|
-
export const
|
|
176
|
+
/** Creates a missing table through an engine catalog and verifies its schema. */
|
|
177
|
+
export const ensureTableFromEngine = (
|
|
158
178
|
schema: Schema.Top,
|
|
159
|
-
options:
|
|
179
|
+
options: EngineTableOptions,
|
|
160
180
|
): Effect.Effect<TableSchema, ConnectorError, HttpClient.HttpClient> =>
|
|
161
181
|
Effect.scoped(
|
|
162
182
|
Effect.gen(function* () {
|
|
163
|
-
const manager = yield*
|
|
183
|
+
const manager = yield* CatalogManager.make({ engineUri: options.engineUri });
|
|
164
184
|
const catalog = yield* manager.getIcebergCatalog(options.catalogId);
|
|
165
|
-
return yield*
|
|
185
|
+
return yield* ensureTable(schema, {
|
|
166
186
|
catalog,
|
|
167
187
|
identifier: options.identifier,
|
|
168
188
|
location: options.location,
|
|
189
|
+
properties: options.properties,
|
|
169
190
|
});
|
|
170
191
|
}),
|
|
171
192
|
).pipe(Effect.mapError(tableError));
|
|
@@ -177,14 +198,29 @@ export const ensureTable = (
|
|
|
177
198
|
): Effect.Effect<TableSchema, ConnectorError> =>
|
|
178
199
|
Effect.gen(function* () {
|
|
179
200
|
const table = yield* compileTable(schema);
|
|
180
|
-
const
|
|
201
|
+
const request = <A>(
|
|
202
|
+
effect: Effect.Effect<A, Effect.Error<ReturnType<Catalog["loadConfig"]>>>,
|
|
203
|
+
) => retryCatalogRequest(effect, options.retry);
|
|
204
|
+
const metadata = yield* request(options.catalog.loadTable(options.identifier)).pipe(
|
|
181
205
|
Effect.catchIf(
|
|
182
206
|
(error) => error.status === 404,
|
|
183
207
|
() =>
|
|
184
|
-
|
|
185
|
-
.
|
|
186
|
-
|
|
187
|
-
|
|
208
|
+
request(
|
|
209
|
+
options.catalog.createNamespaceIfNotExists({ namespace: options.identifier.namespace }),
|
|
210
|
+
)
|
|
211
|
+
.pipe(
|
|
212
|
+
Effect.andThen(
|
|
213
|
+
request(
|
|
214
|
+
options.catalog.createTable(
|
|
215
|
+
{ namespace: options.identifier.namespace },
|
|
216
|
+
makeCreateRequest(table, {
|
|
217
|
+
name: options.identifier.name,
|
|
218
|
+
location: options.location,
|
|
219
|
+
properties: options.properties,
|
|
220
|
+
}),
|
|
221
|
+
),
|
|
222
|
+
),
|
|
223
|
+
),
|
|
188
224
|
)
|
|
189
225
|
.pipe(
|
|
190
226
|
// Another instance may have created the table. Reload and check it.
|
|
@@ -192,7 +228,7 @@ export const ensureTable = (
|
|
|
192
228
|
(error) => error.status === 409 || error.status === 412,
|
|
193
229
|
() => Effect.void,
|
|
194
230
|
),
|
|
195
|
-
Effect.flatMap(() => options.catalog.loadTable(options.identifier)),
|
|
231
|
+
Effect.flatMap(() => request(options.catalog.loadTable(options.identifier))),
|
|
196
232
|
),
|
|
197
233
|
),
|
|
198
234
|
);
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { Effect, Option, type Schema } from "effect";
|
|
2
2
|
import { Argument, CliError, Command, Flag } from "effect/cli";
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { ensureTableFromEngine } from "../catalog/table";
|
|
5
|
+
import { engineUri } from "../runtime-config/config";
|
|
6
6
|
|
|
7
|
-
/** Creates the table command
|
|
7
|
+
/** Creates the table command; existing tables must match the connector's row schemas. */
|
|
8
8
|
export const makeCreateTableCommand = (schemas: Readonly<Record<string, Schema.Top>>) =>
|
|
9
9
|
Command.make(
|
|
10
10
|
"create-table",
|
|
@@ -35,13 +35,13 @@ export const makeCreateTableCommand = (schemas: Readonly<Record<string, Schema.T
|
|
|
35
35
|
});
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
const uri = yield*
|
|
39
|
-
yield*
|
|
40
|
-
|
|
38
|
+
const uri = yield* engineUri;
|
|
39
|
+
yield* ensureTableFromEngine(schema, {
|
|
40
|
+
engineUri: uri.href,
|
|
41
41
|
catalogId: catalog,
|
|
42
42
|
identifier: { namespace, name },
|
|
43
43
|
location: Option.getOrUndefined(location),
|
|
44
44
|
});
|
|
45
|
-
yield* Effect.log(`
|
|
45
|
+
yield* Effect.log(`Prepared ${table}`);
|
|
46
46
|
}),
|
|
47
|
-
).pipe(Command.withDescription("Create an Iceberg table for a connector resource"));
|
|
47
|
+
).pipe(Command.withDescription("Create or verify an Iceberg table for a connector resource"));
|
|
@@ -1,41 +1,46 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { DateTime, Effect, Option, Schema } from "effect";
|
|
2
2
|
import { Command, Flag } from "effect/cli";
|
|
3
|
+
import humanFormat from "human-format";
|
|
3
4
|
|
|
4
5
|
import { run, type DemoDefinition } from "../demo";
|
|
5
|
-
import {
|
|
6
|
+
import { layerArrowFlightConfig } from "../ingestor/arrow-flight";
|
|
6
7
|
|
|
7
|
-
/** Reads sizes such as `
|
|
8
|
-
export const parseSize = (input: string): Option.Option<number> =>
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
return ByteSize.fromString(input).pipe(
|
|
12
|
-
Option.flatMap(ByteSize.toNumber),
|
|
13
|
-
Option.filter((bytes) => bytes > 0),
|
|
8
|
+
/** Reads byte sizes such as `512kB`, `128MB` or `1.5GB`. */
|
|
9
|
+
export const parseSize = (input: string): Option.Option<number> =>
|
|
10
|
+
Option.liftThrowable(() => humanFormat.parse.bytes(input))().pipe(
|
|
11
|
+
Option.filter((bytes) => Number.isSafeInteger(bytes) && bytes > 0),
|
|
14
12
|
);
|
|
15
|
-
};
|
|
16
13
|
|
|
17
14
|
/**
|
|
18
|
-
* Creates the command that writes
|
|
19
|
-
* It reads
|
|
15
|
+
* Creates the command that writes demo data over Arrow Flight, then exits.
|
|
16
|
+
* It reads hosted config and table bindings, so provide `RuntimeConfig.layerHosted()`.
|
|
20
17
|
*/
|
|
21
18
|
export const makeDemoCommand = (demo: DemoDefinition) =>
|
|
22
19
|
Command.make(
|
|
23
20
|
"demo",
|
|
24
21
|
{
|
|
25
22
|
size: Flag.String("size").pipe(
|
|
26
|
-
Flag.filterMap(parseSize, () => "a size like
|
|
23
|
+
Flag.filterMap(parseSize, () => "a size like 512kB, 128MB or 1.5GB"),
|
|
24
|
+
),
|
|
25
|
+
seed: Flag.Int("seed").pipe(Flag.optional),
|
|
26
|
+
batchSize: Flag.Int("batch-size").pipe(
|
|
27
|
+
Flag.withSchema(Schema.Int.check(Schema.isGreaterThan(0))),
|
|
28
|
+
Flag.withDescription("Maximum rows per ingestion batch (default: 500)"),
|
|
29
|
+
Flag.optional,
|
|
27
30
|
),
|
|
28
|
-
seed: Flag.Int("seed").pipe(Flag.withDefault(1)),
|
|
29
31
|
},
|
|
30
|
-
({ size, seed }) =>
|
|
32
|
+
({ size, seed, batchSize }) =>
|
|
31
33
|
Effect.gen(function* () {
|
|
32
34
|
const now = yield* DateTime.nowAsDate;
|
|
33
35
|
|
|
34
|
-
yield* run(demo, {
|
|
36
|
+
yield* run(demo, {
|
|
37
|
+
bytes: size,
|
|
38
|
+
seed: Option.getOrUndefined(seed),
|
|
39
|
+
batchSize: Option.getOrUndefined(batchSize),
|
|
40
|
+
now,
|
|
41
|
+
}).pipe(
|
|
35
42
|
Effect.provide(
|
|
36
|
-
|
|
37
|
-
resources: [...demo.setup.tables, ...demo.group.tables],
|
|
38
|
-
}),
|
|
43
|
+
layerArrowFlightConfig({ resources: [...demo.setup.tables, ...demo.group.tables] }),
|
|
39
44
|
),
|
|
40
45
|
);
|
|
41
46
|
}),
|
|
@@ -2,3 +2,4 @@ export { check, type CheckOptions, type CheckResult, type ResourceCheckResult }
|
|
|
2
2
|
export { makeCreateTableCommand } from "./create-table";
|
|
3
3
|
export { makeDemoCommand, parseSize } from "./demo";
|
|
4
4
|
export { start, type App, type StartOptions } from "./start";
|
|
5
|
+
export { makeSetupCommand } from "./setup";
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { CatalogManager } from "@useairfoil/api";
|
|
2
|
+
import { Duration, Effect, Option, Schema } from "effect";
|
|
3
|
+
import { Command, Flag } from "effect/cli";
|
|
4
|
+
|
|
5
|
+
import { retryCatalogRequest } from "../catalog/retry";
|
|
6
|
+
import { ensureTable } from "../catalog/table";
|
|
7
|
+
import * as RuntimeConfig from "../runtime-config";
|
|
8
|
+
|
|
9
|
+
/** Creates or verifies all bound tables without loading provider credentials. */
|
|
10
|
+
export const makeSetupCommand = (schemas: Readonly<Record<string, Schema.Top>>) =>
|
|
11
|
+
Command.make(
|
|
12
|
+
"setup",
|
|
13
|
+
{
|
|
14
|
+
checkpointIntervalSec: Flag.Int("checkpoint-interval-sec").pipe(
|
|
15
|
+
Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 900 }))),
|
|
16
|
+
Flag.withDescription(
|
|
17
|
+
"Checkpoint interval for new tables in seconds (0 disables the interval delay)",
|
|
18
|
+
),
|
|
19
|
+
Flag.optional,
|
|
20
|
+
),
|
|
21
|
+
retryInterval: Flag.String("retry-interval").pipe(
|
|
22
|
+
Flag.withDefault("5 seconds"),
|
|
23
|
+
Flag.withSchema(Schema.DurationFromString),
|
|
24
|
+
Flag.filter(
|
|
25
|
+
(duration) => Duration.isPositive(duration) && Duration.isFinite(duration),
|
|
26
|
+
() => "a positive duration, such as 5 seconds",
|
|
27
|
+
),
|
|
28
|
+
Flag.withDescription("Delay between transient catalog retries"),
|
|
29
|
+
),
|
|
30
|
+
retries: Flag.Int("retries").pipe(
|
|
31
|
+
Flag.withDefault(10),
|
|
32
|
+
Flag.filter(
|
|
33
|
+
(value) => value >= 0,
|
|
34
|
+
() => "a non-negative number of retries",
|
|
35
|
+
),
|
|
36
|
+
Flag.withDescription("Maximum retries per catalog request after the initial attempt"),
|
|
37
|
+
),
|
|
38
|
+
},
|
|
39
|
+
({ retryInterval, retries, checkpointIntervalSec }) =>
|
|
40
|
+
Effect.gen(function* () {
|
|
41
|
+
const source = {
|
|
42
|
+
resources: Object.entries(schemas).map(([name, rowSchema]) => ({ name, rowSchema })),
|
|
43
|
+
};
|
|
44
|
+
const tables = yield* RuntimeConfig.loadTableBindings(source);
|
|
45
|
+
const uri = yield* RuntimeConfig.engineUri;
|
|
46
|
+
const catalogId = yield* RuntimeConfig.catalog;
|
|
47
|
+
const manager = yield* CatalogManager.make({ engineUri: uri.href });
|
|
48
|
+
const retry = { interval: retryInterval, times: retries };
|
|
49
|
+
const properties = Option.match(checkpointIntervalSec, {
|
|
50
|
+
onNone: () => undefined,
|
|
51
|
+
onSome: (seconds) => ({ "airfoil.checkpoint.interval-sec": String(seconds) }),
|
|
52
|
+
});
|
|
53
|
+
const catalog = yield* retryCatalogRequest(manager.getIcebergCatalog(catalogId), retry);
|
|
54
|
+
|
|
55
|
+
// Sequential setup avoids bursting the catalog's table-creation quota.
|
|
56
|
+
for (const resource of source.resources) {
|
|
57
|
+
const binding = tables[resource.name];
|
|
58
|
+
if (!binding) continue;
|
|
59
|
+
yield* ensureTable(resource.rowSchema, {
|
|
60
|
+
catalog,
|
|
61
|
+
identifier: binding,
|
|
62
|
+
location: binding.location,
|
|
63
|
+
retry,
|
|
64
|
+
properties,
|
|
65
|
+
});
|
|
66
|
+
yield* Effect.logInfo(`Prepared ${[...binding.namespace, binding.name].join(".")}`);
|
|
67
|
+
}
|
|
68
|
+
}),
|
|
69
|
+
).pipe(Command.withDescription("Create or verify the Iceberg tables in AIRFOIL_TABLE_BINDINGS"));
|
package/src/core/types.ts
CHANGED
|
@@ -96,7 +96,7 @@ export type ResourceDelete<S extends ResourceSchema, Version extends ResourceFie
|
|
|
96
96
|
readonly _deleted: true;
|
|
97
97
|
};
|
|
98
98
|
|
|
99
|
-
/** A full row, or a delete.
|
|
99
|
+
/** A full row, or a delete. The engine replaces the whole row. */
|
|
100
100
|
export type ResourceUpdate<S extends ResourceSchema, Version extends ResourceField<S>> =
|
|
101
101
|
| (ResourceRow<S> & { readonly _deleted?: boolean })
|
|
102
102
|
| ResourceDelete<S, Version>;
|
package/src/demo/definition.ts
CHANGED
|
@@ -48,7 +48,7 @@ export type DemoDefinition<
|
|
|
48
48
|
readonly tables: Group;
|
|
49
49
|
readonly rows: (index: number, context: DemoContext) => DemoRows<Group>;
|
|
50
50
|
/**
|
|
51
|
-
* Groups written per chunk,
|
|
51
|
+
* Groups written per chunk, 1000 by default. A run can go over the size by
|
|
52
52
|
* one chunk, so lower it when a group is big.
|
|
53
53
|
*/
|
|
54
54
|
readonly groupsPerChunk?: number;
|
package/src/demo/generate.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { Duration, Effect, Schema, type SchemaRepresentation } from "effect";
|
|
|
5
5
|
import { ConnectorError } from "../errors";
|
|
6
6
|
import { checkHasRepresentation } from "../iceberg/table";
|
|
7
7
|
|
|
8
|
-
/** What one run shares: the
|
|
8
|
+
/** What one run shares: the Faker and the time the run started. */
|
|
9
9
|
export type DemoContext = {
|
|
10
10
|
readonly faker: Faker;
|
|
11
11
|
readonly now: Date;
|
package/src/demo/run.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Faker, base, en } from "@faker-js/faker";
|
|
2
2
|
import { Array as Arr, Effect, Option, Schema, Stream } from "effect";
|
|
3
|
+
import humanFormat from "human-format";
|
|
3
4
|
|
|
4
5
|
import type { DemoDefinition, DemoTable } from "./definition";
|
|
5
6
|
|
|
@@ -11,9 +12,12 @@ import { compileRow, type DemoContext } from "./generate";
|
|
|
11
12
|
export type RunOptions = {
|
|
12
13
|
/** Stop once this many Arrow bytes are written. */
|
|
13
14
|
readonly bytes: number;
|
|
14
|
-
|
|
15
|
+
/** Omit for random data on each run. */
|
|
16
|
+
readonly seed?: number;
|
|
15
17
|
/** The time data is made around. Same seed, time and tables give the same rows. */
|
|
16
18
|
readonly now: Date;
|
|
19
|
+
/** Maximum rows per ingestion batch. Defaults to 500. Must be a positive integer. */
|
|
20
|
+
readonly batchSize?: number;
|
|
17
21
|
/** Groups per chunk. Overrides the demo's. */
|
|
18
22
|
readonly groupsPerChunk?: number;
|
|
19
23
|
};
|
|
@@ -24,10 +28,6 @@ export type DemoTotals = IngestResult & {
|
|
|
24
28
|
|
|
25
29
|
const empty: DemoTotals = { rows: 0, bytes: 0, tables: {} };
|
|
26
30
|
|
|
27
|
-
// Wings rejects Flight messages over 4 MiB. 500 rows keeps a batch of the
|
|
28
|
-
// widest demo rows well under that.
|
|
29
|
-
const batchRows = 500;
|
|
30
|
-
|
|
31
31
|
const sum = (results: ReadonlyArray<IngestResult>): IngestResult => ({
|
|
32
32
|
rows: results.reduce((total, result) => total + result.rows, 0),
|
|
33
33
|
bytes: results.reduce((total, result) => total + result.bytes, 0),
|
|
@@ -56,11 +56,17 @@ const add = (totals: DemoTotals, written: ReadonlyArray<readonly [string, Ingest
|
|
|
56
56
|
*/
|
|
57
57
|
export const run = Effect.fnUntraced(function* (demo: DemoDefinition, options: RunOptions) {
|
|
58
58
|
const ingestor = yield* Ingestor;
|
|
59
|
+
// Wide rows may need a lower batch size to fit the engine's limits.
|
|
60
|
+
const batchSize = yield* Schema.decodeUnknownEffect(Schema.Int.check(Schema.isGreaterThan(0)))(
|
|
61
|
+
options.batchSize ?? 500,
|
|
62
|
+
).pipe(
|
|
63
|
+
Effect.mapError((cause) => new ConnectorError({ message: "Invalid demo batch size", cause })),
|
|
64
|
+
);
|
|
59
65
|
|
|
60
66
|
const bound = (table: DemoTable) =>
|
|
61
67
|
ingestor.resourceNames === undefined || ingestor.resourceNames.includes(table.name);
|
|
62
68
|
|
|
63
|
-
// A fresh Faker per run
|
|
69
|
+
// A fresh Faker per run keeps explicit seeds repeatable.
|
|
64
70
|
const faker = new Faker({ locale: [en, base] });
|
|
65
71
|
faker.seed(options.seed);
|
|
66
72
|
const context: DemoContext = { faker, now: options.now };
|
|
@@ -80,27 +86,37 @@ export const run = Effect.fnUntraced(function* (demo: DemoDefinition, options: R
|
|
|
80
86
|
tables: Effect.Success<ReturnType<typeof prepare>>,
|
|
81
87
|
rowsOf: (name: string) => ReadonlyArray<object>,
|
|
82
88
|
) =>
|
|
83
|
-
Effect.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
Effect.forEach(Arr.chunksOf(rows, batchRows), (batch) =>
|
|
93
|
-
ingestBatch({
|
|
94
|
-
connector: demo.name,
|
|
95
|
-
resource: table.name,
|
|
96
|
-
source: "demo",
|
|
97
|
-
batch: { rows: batch },
|
|
98
|
-
}),
|
|
89
|
+
Effect.gen(function* () {
|
|
90
|
+
// Fill rows in table order before concurrent writes, keeping Faker deterministic.
|
|
91
|
+
const prepared = yield* Effect.forEach(tables, ({ table, fill, decode }) =>
|
|
92
|
+
Effect.forEach(rowsOf(table.name), (row) =>
|
|
93
|
+
decode(fill(context, row)).pipe(
|
|
94
|
+
Effect.mapError(
|
|
95
|
+
(cause) =>
|
|
96
|
+
new ConnectorError({ message: `Invalid demo row for ${table.name}`, cause }),
|
|
97
|
+
),
|
|
99
98
|
),
|
|
100
|
-
),
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
99
|
+
).pipe(Effect.map((rows) => ({ table, rows }))),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// Share durable flushes with bounded parallel writes.
|
|
103
|
+
return yield* Effect.forEach(
|
|
104
|
+
prepared,
|
|
105
|
+
({ table, rows }) =>
|
|
106
|
+
Effect.forEach(
|
|
107
|
+
Arr.chunksOf(rows, batchSize),
|
|
108
|
+
(rows) =>
|
|
109
|
+
ingestBatch({
|
|
110
|
+
connector: demo.name,
|
|
111
|
+
resource: table.name,
|
|
112
|
+
source: "demo",
|
|
113
|
+
batch: { rows },
|
|
114
|
+
}),
|
|
115
|
+
{ concurrency: 8 },
|
|
116
|
+
).pipe(Effect.map((results) => [table.name, sum(results)] as const)),
|
|
117
|
+
{ concurrency: 4 },
|
|
118
|
+
);
|
|
119
|
+
});
|
|
104
120
|
|
|
105
121
|
const setupTables = yield* prepare(demo.setup.tables);
|
|
106
122
|
const groupTables = yield* prepare(demo.group.tables);
|
|
@@ -112,7 +128,7 @@ export const run = Effect.fnUntraced(function* (demo: DemoDefinition, options: R
|
|
|
112
128
|
afterSetup.bytes >= options.bytes || groupTables.length === 0
|
|
113
129
|
? afterSetup
|
|
114
130
|
: yield* Stream.range(0, (demo.group.count ?? Infinity) - 1).pipe(
|
|
115
|
-
Stream.grouped(options.groupsPerChunk ?? demo.group.groupsPerChunk ??
|
|
131
|
+
Stream.grouped(options.groupsPerChunk ?? demo.group.groupsPerChunk ?? 1000),
|
|
116
132
|
Stream.mapEffect((indexes) => {
|
|
117
133
|
const groups = indexes.map((index) => demo.group.rows(index, context));
|
|
118
134
|
return write(groupTables, (name) => groups.flatMap((group) => group[name] ?? []));
|
|
@@ -121,7 +137,7 @@ export const run = Effect.fnUntraced(function* (demo: DemoDefinition, options: R
|
|
|
121
137
|
Stream.takeUntil((current) => current.bytes >= options.bytes),
|
|
122
138
|
Stream.tap((current) =>
|
|
123
139
|
Effect.logInfo("demo progress").pipe(
|
|
124
|
-
Effect.annotateLogs({ rows: current.rows,
|
|
140
|
+
Effect.annotateLogs({ rows: current.rows, size: humanFormat.bytes(current.bytes) }),
|
|
125
141
|
),
|
|
126
142
|
),
|
|
127
143
|
Stream.runLast,
|
|
@@ -131,11 +147,25 @@ export const run = Effect.fnUntraced(function* (demo: DemoDefinition, options: R
|
|
|
131
147
|
if (totals.bytes < options.bytes) {
|
|
132
148
|
yield* Effect.logWarning(
|
|
133
149
|
"The selected tables ran out of rows, so the run wrote less than the size asked for",
|
|
134
|
-
).pipe(
|
|
150
|
+
).pipe(
|
|
151
|
+
Effect.annotateLogs({
|
|
152
|
+
size: humanFormat.bytes(totals.bytes),
|
|
153
|
+
target: humanFormat.bytes(options.bytes),
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
135
156
|
}
|
|
136
157
|
|
|
137
158
|
yield* Effect.logInfo("demo done").pipe(
|
|
138
|
-
Effect.annotateLogs({
|
|
159
|
+
Effect.annotateLogs({
|
|
160
|
+
rows: totals.rows,
|
|
161
|
+
size: humanFormat.bytes(totals.bytes),
|
|
162
|
+
tables: Object.fromEntries(
|
|
163
|
+
Object.entries(totals.tables).map(([name, result]) => [
|
|
164
|
+
name,
|
|
165
|
+
{ rows: result.rows, size: humanFormat.bytes(result.bytes) },
|
|
166
|
+
]),
|
|
167
|
+
),
|
|
168
|
+
}),
|
|
139
169
|
);
|
|
140
170
|
|
|
141
171
|
return totals;
|
|
@@ -3,24 +3,11 @@ import { Schema } from "effect";
|
|
|
3
3
|
declare module "effect/Schema" {
|
|
4
4
|
namespace Annotations {
|
|
5
5
|
interface Annotations {
|
|
6
|
-
readonly fieldId?: number | undefined;
|
|
7
6
|
readonly icebergIdentifierFields?: ReadonlyArray<string> | undefined;
|
|
8
7
|
}
|
|
9
8
|
}
|
|
10
9
|
}
|
|
11
10
|
|
|
12
|
-
/** Sets a field's Iceberg ID, and any other annotations. */
|
|
13
|
-
export const field =
|
|
14
|
-
<A = unknown>(fieldId: number, annotations: Schema.Annotations.Key<A> = {}) =>
|
|
15
|
-
<S extends Schema.Schema<A>>(schema: S): S["Rebuild"] =>
|
|
16
|
-
schema.pipe(Schema.annotateKey({ ...annotations, fieldId }));
|
|
17
|
-
|
|
18
|
-
/** Adds an offset to a base ID for reusable schemas. */
|
|
19
|
-
export const ids =
|
|
20
|
-
(baseId: number) =>
|
|
21
|
-
(offset: number): number =>
|
|
22
|
-
baseId + offset;
|
|
23
|
-
|
|
24
11
|
type KnownField<S extends Schema.Top> = keyof S["Type"] & string;
|
|
25
12
|
|
|
26
13
|
/** A row field that cannot be null or undefined. */
|
|
@@ -40,7 +27,7 @@ export interface Identified<Key extends string> {
|
|
|
40
27
|
}
|
|
41
28
|
|
|
42
29
|
/**
|
|
43
|
-
* Marks the fields that identify a row.
|
|
30
|
+
* Marks the fields that identify a row. The engine upserts rows by them.
|
|
44
31
|
*
|
|
45
32
|
* Put it last. A later `.annotate()` keeps the runtime annotation, but the type
|
|
46
33
|
* loses the key, so `Resource.entity` stops compiling.
|
|
@@ -48,8 +35,8 @@ export interface Identified<Key extends string> {
|
|
|
48
35
|
* @example
|
|
49
36
|
* ```ts
|
|
50
37
|
* const Post = Schema.Struct({
|
|
51
|
-
* id: Schema.String
|
|
52
|
-
* updatedAt: Schema.String
|
|
38
|
+
* id: Schema.String,
|
|
39
|
+
* updatedAt: Schema.String,
|
|
53
40
|
* }).pipe(Iceberg.identifier("id"));
|
|
54
41
|
* ```
|
|
55
42
|
*/
|
package/src/iceberg/index.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { identifier } from "./annotations";
|
|
2
2
|
export { IcebergSchemaError } from "./error";
|
|
3
|
-
export {
|
|
3
|
+
export { makeCreateTableRequest, type CreateTableOptions } from "./table";
|