@loaders.gl/scan 5.0.0-alpha.10
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 +52 -0
- package/README.md +219 -0
- package/dist/delta.cjs +25568 -0
- package/dist/delta.cjs.map +6 -0
- package/dist/delta.d.ts +11 -0
- package/dist/delta.d.ts.map +1 -0
- package/dist/delta.js +12 -0
- package/dist/delta.js.map +1 -0
- package/dist/dist.min.js +18535 -0
- package/dist/federated-table-scan-source.d.ts +116 -0
- package/dist/federated-table-scan-source.d.ts.map +1 -0
- package/dist/federated-table-scan-source.js +691 -0
- package/dist/federated-table-scan-source.js.map +1 -0
- package/dist/hudi.cjs +25405 -0
- package/dist/hudi.cjs.map +6 -0
- package/dist/hudi.d.ts +58 -0
- package/dist/hudi.d.ts.map +1 -0
- package/dist/hudi.js +147 -0
- package/dist/hudi.js.map +1 -0
- package/dist/iceberg.cjs +27662 -0
- package/dist/iceberg.cjs.map +6 -0
- package/dist/iceberg.d.ts +12 -0
- package/dist/iceberg.d.ts.map +1 -0
- package/dist/iceberg.js +12 -0
- package/dist/iceberg.js.map +1 -0
- package/dist/index.cjs +3540 -0
- package/dist/index.cjs.map +6 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/scan-engine.d.ts +44 -0
- package/dist/scan-engine.d.ts.map +1 -0
- package/dist/scan-engine.js +51 -0
- package/dist/scan-engine.js.map +1 -0
- package/dist/scan-query.d.ts +24 -0
- package/dist/scan-query.d.ts.map +1 -0
- package/dist/scan-query.js +5 -0
- package/dist/scan-query.js.map +1 -0
- package/dist/vector-table-scan-source.d.ts +92 -0
- package/dist/vector-table-scan-source.d.ts.map +1 -0
- package/dist/vector-table-scan-source.js +220 -0
- package/dist/vector-table-scan-source.js.map +1 -0
- package/package.json +66 -0
- package/src/delta.ts +18 -0
- package/src/federated-table-scan-source.ts +991 -0
- package/src/hudi.ts +235 -0
- package/src/iceberg.ts +36 -0
- package/src/index.ts +97 -0
- package/src/scan-engine.ts +100 -0
- package/src/scan-query.ts +27 -0
- package/src/vector-table-scan-source.ts +300 -0
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
// loaders.gl
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
// Copyright (c) vis.gl contributors
|
|
4
|
+
import * as arrow from 'apache-arrow';
|
|
5
|
+
import { createScanQueryMetadata, emitScanExecutionTelemetry, explainTableQuery, getColumnarPredicateColumns, planTableQuery } from '@loaders.gl/loader-utils';
|
|
6
|
+
import { convertArrowToSchema, convertBatch, convertSchemaToArrow } from '@loaders.gl/schema-utils';
|
|
7
|
+
import { queryArrowTable } from '@loaders.gl/sql';
|
|
8
|
+
/** Correctness capabilities of the federated append executor. */
|
|
9
|
+
export const FEDERATED_TABLE_QUERY_CAPABILITIES = Object.freeze({
|
|
10
|
+
predicate: 'residual',
|
|
11
|
+
projection: 'pushdown+residual',
|
|
12
|
+
limit: 'pushdown+residual',
|
|
13
|
+
streaming: true,
|
|
14
|
+
cancellation: true
|
|
15
|
+
});
|
|
16
|
+
let nextFederatedSourceId = 0;
|
|
17
|
+
/**
|
|
18
|
+
* Resolves managed table sources and appends their results as one ordered Arrow stream.
|
|
19
|
+
*
|
|
20
|
+
* `DataSourceManager` remains the single registry and lifecycle owner. This adapter subscribes for
|
|
21
|
+
* the duration of metadata discovery or iteration, so deferred and non-persistent sources use the
|
|
22
|
+
* same replacement, retention, and cleanup system as the rest of loaders.gl.
|
|
23
|
+
*/
|
|
24
|
+
export class FederatedTableScanSource {
|
|
25
|
+
/** Shared manager used as the authoritative named-source registry. */
|
|
26
|
+
dataSourceManager;
|
|
27
|
+
/** Immutable sources in physical append order. */
|
|
28
|
+
sources;
|
|
29
|
+
/** Schema reconciliation policy. */
|
|
30
|
+
schemaPolicy;
|
|
31
|
+
/** Optional caller-declared normalized output schema. */
|
|
32
|
+
outputSchema;
|
|
33
|
+
/** Optional display name reported through scan metadata. */
|
|
34
|
+
name;
|
|
35
|
+
/** Human-readable description reported through scan metadata. */
|
|
36
|
+
description;
|
|
37
|
+
/** Stable id used to isolate this adapter's manager subscriptions. */
|
|
38
|
+
instanceId = nextFederatedSourceId++;
|
|
39
|
+
/** Monotonic id used to isolate overlapping operations on this adapter. */
|
|
40
|
+
nextOperationId = 0;
|
|
41
|
+
/** Creates an ordered federated source backed by one shared DataSourceManager. */
|
|
42
|
+
constructor(dataSourceManager, options) {
|
|
43
|
+
if (!options.sources.length) {
|
|
44
|
+
throw new Error('Federated table scans require at least one source.');
|
|
45
|
+
}
|
|
46
|
+
if (options.schemaPolicy &&
|
|
47
|
+
options.schemaPolicy !== 'strict' &&
|
|
48
|
+
options.schemaPolicy !== 'union') {
|
|
49
|
+
throw new Error(`Unsupported federated schema policy: ${options.schemaPolicy}`);
|
|
50
|
+
}
|
|
51
|
+
this.dataSourceManager = dataSourceManager;
|
|
52
|
+
this.sources = Object.freeze(options.sources.map(cloneSourceEntry));
|
|
53
|
+
this.schemaPolicy = options.schemaPolicy || 'strict';
|
|
54
|
+
this.outputSchema = options.outputSchema ? cloneSchema(options.outputSchema) : undefined;
|
|
55
|
+
this.name = options.name;
|
|
56
|
+
this.description = options.description || `${this.sources.length} ordered table sources`;
|
|
57
|
+
}
|
|
58
|
+
/** Discovers and reconciles every child schema without reading federated result batches. */
|
|
59
|
+
async getQueryMetadata(options = {}) {
|
|
60
|
+
const consumerId = this.createConsumerId('metadata');
|
|
61
|
+
try {
|
|
62
|
+
const plan = await this.resolvePlan(consumerId, options.signal);
|
|
63
|
+
return createScanQueryMetadata({
|
|
64
|
+
sourceType: 'federated-table',
|
|
65
|
+
queryType: 'table',
|
|
66
|
+
execution: { status: 'supported', method: 'read' },
|
|
67
|
+
name: this.name,
|
|
68
|
+
description: this.description,
|
|
69
|
+
schema: plan.schema,
|
|
70
|
+
capabilities: { table: FEDERATED_TABLE_QUERY_CAPABILITIES },
|
|
71
|
+
statistics: getFederatedStatistics(plan.sources)
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
this.dataSourceManager.unsubscribe({ consumerId });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** Explains source resolution, schema reconciliation, and the global table query. */
|
|
79
|
+
async explain(options = {}) {
|
|
80
|
+
const consumerId = this.createConsumerId('explain');
|
|
81
|
+
try {
|
|
82
|
+
const plan = await this.resolvePlan(consumerId, options.signal);
|
|
83
|
+
const explanation = explainTableQuery(plan.schema.fields.map(field => field.name), options, FEDERATED_TABLE_QUERY_CAPABILITIES);
|
|
84
|
+
return Object.freeze({
|
|
85
|
+
...explanation,
|
|
86
|
+
schemaPolicy: this.schemaPolicy,
|
|
87
|
+
sources: Object.freeze(plan.sources.map(source => createSourceExplanation(source, plan.schema)))
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
this.dataSourceManager.unsubscribe({ consumerId });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Emits source and batch ordered Arrow results with one global limit and batch provenance.
|
|
96
|
+
*
|
|
97
|
+
* Returning early from the iterator closes the active child iterator and releases every manager
|
|
98
|
+
* subscription. Later sources are never read once the global limit has been satisfied.
|
|
99
|
+
*/
|
|
100
|
+
async *read(options = {}) {
|
|
101
|
+
const consumerId = this.createConsumerId('read');
|
|
102
|
+
const startedAt = Date.now();
|
|
103
|
+
const telemetry = {
|
|
104
|
+
status: 'early-terminated',
|
|
105
|
+
sourcesPlanned: this.sources.length,
|
|
106
|
+
sourcesRead: 0,
|
|
107
|
+
batchesRead: 0,
|
|
108
|
+
rowsRead: 0,
|
|
109
|
+
rowsTested: 0,
|
|
110
|
+
rowsRetained: 0,
|
|
111
|
+
rowsReturned: 0,
|
|
112
|
+
bytesFetched: 0,
|
|
113
|
+
filesOpened: 0,
|
|
114
|
+
tasksOpened: 0,
|
|
115
|
+
rowsPruned: 0,
|
|
116
|
+
durationMilliseconds: 0,
|
|
117
|
+
sources: []
|
|
118
|
+
};
|
|
119
|
+
try {
|
|
120
|
+
const plan = await this.resolvePlan(consumerId, options.signal);
|
|
121
|
+
const scanStep = planTableQuery(plan.schema.fields.map(field => field.name), options)[0];
|
|
122
|
+
let remaining = options.limit ?? Number.POSITIVE_INFINITY;
|
|
123
|
+
if (remaining <= 0) {
|
|
124
|
+
telemetry.earlyTerminationReason = 'limit';
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
for (const resolvedSource of plan.sources) {
|
|
128
|
+
throwIfAborted(options.signal);
|
|
129
|
+
if (remaining <= 0) {
|
|
130
|
+
telemetry.earlyTerminationReason = 'limit';
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const sourceStartedAt = Date.now();
|
|
134
|
+
let childTelemetry;
|
|
135
|
+
let sourceCompleted = false;
|
|
136
|
+
let sourceError;
|
|
137
|
+
let sourceBatchesDecoded = 0;
|
|
138
|
+
let sourceRowsRead = 0;
|
|
139
|
+
let sourceRowsTested = 0;
|
|
140
|
+
let sourceRowsRetained = 0;
|
|
141
|
+
let sourceRowsReturned = 0;
|
|
142
|
+
const sourceReadOptions = createSourceReadOptions(resolvedSource, scanStep.columns, remaining, options, value => {
|
|
143
|
+
childTelemetry = value;
|
|
144
|
+
});
|
|
145
|
+
const sourceResidualPredicate = getSourceResidualPredicate(resolvedSource);
|
|
146
|
+
let sourceRemaining = resolvedSource.entry.query?.limit ?? Number.POSITIVE_INFINITY;
|
|
147
|
+
telemetry.sourcesRead++;
|
|
148
|
+
let sourceBatchIndex = 0;
|
|
149
|
+
try {
|
|
150
|
+
for await (const batch of resolvedSource.source.read(sourceReadOptions)) {
|
|
151
|
+
throwIfAborted(options.signal);
|
|
152
|
+
if (remaining <= 0) {
|
|
153
|
+
telemetry.earlyTerminationReason = 'limit';
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (sourceRemaining <= 0)
|
|
157
|
+
break;
|
|
158
|
+
if (batch.batchType !== 'data')
|
|
159
|
+
continue;
|
|
160
|
+
telemetry.batchesRead++;
|
|
161
|
+
telemetry.rowsRead += batch.length;
|
|
162
|
+
sourceBatchesDecoded++;
|
|
163
|
+
sourceRowsRead += batch.length;
|
|
164
|
+
if (batch.length <= 0)
|
|
165
|
+
continue;
|
|
166
|
+
const physicalTable = convertBatch(batch.schema ? batch : { ...batch, schema: resolvedSource.metadata.schema }, 'arrow-table');
|
|
167
|
+
const sourceResult = queryArrowTable(physicalTable, {
|
|
168
|
+
predicate: sourceResidualPredicate,
|
|
169
|
+
limit: Number.isFinite(sourceRemaining) ? sourceRemaining : undefined,
|
|
170
|
+
signal: options.signal
|
|
171
|
+
});
|
|
172
|
+
if (sourceResidualPredicate) {
|
|
173
|
+
sourceRowsTested += physicalTable.data.numRows;
|
|
174
|
+
sourceRowsRetained += sourceResult.data.numRows;
|
|
175
|
+
}
|
|
176
|
+
sourceRemaining -= sourceResult.data.numRows;
|
|
177
|
+
const currentSourceBatchIndex = sourceBatchIndex++;
|
|
178
|
+
if (!sourceResult.data.numRows)
|
|
179
|
+
continue;
|
|
180
|
+
const canonicalTable = createCanonicalArrowTable(plan.schema, resolvedSource, sourceResult);
|
|
181
|
+
const result = queryArrowTable(canonicalTable, {
|
|
182
|
+
predicate: options.predicate,
|
|
183
|
+
columns: options.columns,
|
|
184
|
+
limit: Number.isFinite(remaining) ? remaining : undefined,
|
|
185
|
+
signal: options.signal
|
|
186
|
+
});
|
|
187
|
+
if (options.predicate) {
|
|
188
|
+
telemetry.rowsTested += canonicalTable.data.numRows;
|
|
189
|
+
telemetry.rowsRetained += result.data.numRows;
|
|
190
|
+
}
|
|
191
|
+
const resultLength = result.data.numRows;
|
|
192
|
+
if (!resultLength)
|
|
193
|
+
continue;
|
|
194
|
+
const provenance = Object.freeze({
|
|
195
|
+
sourceId: resolvedSource.entry.dataSourceId,
|
|
196
|
+
sourceIndex: resolvedSource.sourceIndex,
|
|
197
|
+
sourceBatchIndex: currentSourceBatchIndex,
|
|
198
|
+
sourceMetadata: batch.metadata
|
|
199
|
+
});
|
|
200
|
+
telemetry.rowsReturned += resultLength;
|
|
201
|
+
sourceRowsReturned += resultLength;
|
|
202
|
+
yield {
|
|
203
|
+
batchType: 'data',
|
|
204
|
+
shape: 'arrow-table',
|
|
205
|
+
schema: convertArrowToSchema(result.data.schema),
|
|
206
|
+
data: result.data,
|
|
207
|
+
length: resultLength,
|
|
208
|
+
metadata: provenance,
|
|
209
|
+
sourceId: provenance.sourceId,
|
|
210
|
+
sourceIndex: provenance.sourceIndex,
|
|
211
|
+
sourceBatchIndex: provenance.sourceBatchIndex
|
|
212
|
+
};
|
|
213
|
+
remaining -= resultLength;
|
|
214
|
+
}
|
|
215
|
+
sourceCompleted = true;
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
sourceError = error;
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
const sourceTelemetry = createSourceTelemetry({
|
|
223
|
+
resolvedSource,
|
|
224
|
+
childTelemetry,
|
|
225
|
+
sourceStartedAt,
|
|
226
|
+
sourceCompleted,
|
|
227
|
+
sourceError,
|
|
228
|
+
signal: options.signal,
|
|
229
|
+
batchesDecoded: sourceBatchesDecoded,
|
|
230
|
+
rowsRead: sourceRowsRead,
|
|
231
|
+
rowsTested: sourceRowsTested,
|
|
232
|
+
rowsRetained: sourceRowsRetained,
|
|
233
|
+
rowsReturned: sourceRowsReturned
|
|
234
|
+
});
|
|
235
|
+
telemetry.sources.push(sourceTelemetry);
|
|
236
|
+
telemetry.bytesFetched += sourceTelemetry.bytesFetched || 0;
|
|
237
|
+
telemetry.filesOpened += sourceTelemetry.filesOpened || 0;
|
|
238
|
+
telemetry.tasksOpened += sourceTelemetry.tasksOpened || 0;
|
|
239
|
+
telemetry.rowsPruned += sourceTelemetry.rowsPruned || 0;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
telemetry.status = 'completed';
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
telemetry.status = options.signal?.aborted ? 'cancelled' : 'failed';
|
|
246
|
+
telemetry.error = error;
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
this.dataSourceManager.unsubscribe({ consumerId });
|
|
251
|
+
telemetry.durationMilliseconds = Date.now() - startedAt;
|
|
252
|
+
if (telemetry.status === 'early-terminated' && !telemetry.earlyTerminationReason) {
|
|
253
|
+
telemetry.earlyTerminationReason = 'consumer-return';
|
|
254
|
+
}
|
|
255
|
+
emitScanExecutionTelemetry(options.onTelemetry, Object.freeze({
|
|
256
|
+
...telemetry,
|
|
257
|
+
batchesDecoded: telemetry.batchesRead,
|
|
258
|
+
sources: Object.freeze([...telemetry.sources])
|
|
259
|
+
}));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/** Resolves managed sources, discovers their schemas, and creates one compatibility plan. */
|
|
263
|
+
async resolvePlan(consumerId, signal) {
|
|
264
|
+
const sources = [];
|
|
265
|
+
for (let sourceIndex = 0; sourceIndex < this.sources.length; sourceIndex++) {
|
|
266
|
+
throwIfAborted(signal);
|
|
267
|
+
const entry = this.sources[sourceIndex];
|
|
268
|
+
const source = await resolveManagedSource(this.dataSourceManager, entry.dataSourceId, consumerId, String(sourceIndex), signal);
|
|
269
|
+
if (typeof source.getQueryMetadata !== 'function' || typeof source.read !== 'function') {
|
|
270
|
+
throw new Error(`Managed source does not implement TableScanSource: ${entry.dataSourceId}`);
|
|
271
|
+
}
|
|
272
|
+
const metadata = await source.getQueryMetadata({ signal });
|
|
273
|
+
if (metadata.execution.status !== 'supported' || metadata.execution.method !== 'read') {
|
|
274
|
+
throw new Error(`Federated table source does not support read(): ${entry.dataSourceId}`);
|
|
275
|
+
}
|
|
276
|
+
if (metadata.queryType !== 'table') {
|
|
277
|
+
throw new Error(`Federated source is not a table source: ${entry.dataSourceId}`);
|
|
278
|
+
}
|
|
279
|
+
const fields = resolveMappedFields(entry, metadata);
|
|
280
|
+
sources.push({
|
|
281
|
+
entry,
|
|
282
|
+
source,
|
|
283
|
+
metadata,
|
|
284
|
+
sourceIndex,
|
|
285
|
+
fields,
|
|
286
|
+
sourceNameByOutputName: new Map(fields.map(field => [field.outputName, field.sourceName]))
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
return Object.freeze({
|
|
290
|
+
schema: reconcileSchemas(sources, this.schemaPolicy, this.outputSchema),
|
|
291
|
+
sources: Object.freeze(sources)
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
/** Returns a collision-free manager consumer id for one operation. */
|
|
295
|
+
createConsumerId(operation) {
|
|
296
|
+
return `federated-table:${this.instanceId}:${operation}:${this.nextOperationId++}`;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
/** Clones the public entry so later caller mutations cannot change a running plan. */
|
|
300
|
+
function cloneSourceEntry(entry) {
|
|
301
|
+
if (!entry.dataSourceId) {
|
|
302
|
+
throw new Error('Federated table source ids must be non-empty.');
|
|
303
|
+
}
|
|
304
|
+
return Object.freeze({
|
|
305
|
+
dataSourceId: entry.dataSourceId,
|
|
306
|
+
query: entry.query
|
|
307
|
+
? Object.freeze({
|
|
308
|
+
...entry.query,
|
|
309
|
+
columns: entry.query.columns ? Object.freeze([...entry.query.columns]) : undefined
|
|
310
|
+
})
|
|
311
|
+
: undefined,
|
|
312
|
+
columnMapping: entry.columnMapping ? Object.freeze({ ...entry.columnMapping }) : undefined
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
/** Selects source-local fields and applies explicit output names. */
|
|
316
|
+
function resolveMappedFields(entry, metadata) {
|
|
317
|
+
const sourceFields = new Map(metadata.schema.fields.map(field => [field.name, field]));
|
|
318
|
+
for (const sourceName of Object.keys(entry.columnMapping || {})) {
|
|
319
|
+
if (!sourceFields.has(sourceName)) {
|
|
320
|
+
throw new Error(`Federated column mapping source not found in ${entry.dataSourceId}: ${sourceName}`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
const selectedNames = entry.query?.columns || metadata.schema.fields.map(field => field.name);
|
|
324
|
+
if (!selectedNames.length) {
|
|
325
|
+
throw new Error(`Federated source projection must not be empty: ${entry.dataSourceId}`);
|
|
326
|
+
}
|
|
327
|
+
const outputNames = new Set();
|
|
328
|
+
return Object.freeze(selectedNames.map(sourceName => {
|
|
329
|
+
const field = sourceFields.get(sourceName);
|
|
330
|
+
if (!field) {
|
|
331
|
+
throw new Error(`Federated source column not found in ${entry.dataSourceId}: ${sourceName}`);
|
|
332
|
+
}
|
|
333
|
+
const outputName = entry.columnMapping?.[sourceName] ?? sourceName;
|
|
334
|
+
if (!outputName) {
|
|
335
|
+
throw new Error(`Federated output column names must be non-empty: ${sourceName}`);
|
|
336
|
+
}
|
|
337
|
+
if (outputNames.has(outputName)) {
|
|
338
|
+
throw new Error(`Federated column mapping creates duplicate output column in ${entry.dataSourceId}: ${outputName}`);
|
|
339
|
+
}
|
|
340
|
+
outputNames.add(outputName);
|
|
341
|
+
return Object.freeze({ sourceName, outputName, field });
|
|
342
|
+
}));
|
|
343
|
+
}
|
|
344
|
+
/** Reconciles mapped fields using strict equality or first-seen union ordering. */
|
|
345
|
+
function reconcileSchemas(sources, schemaPolicy, outputSchema) {
|
|
346
|
+
const outputFields = new Map();
|
|
347
|
+
const firstSourceNames = sources[0].fields.map(field => field.outputName);
|
|
348
|
+
for (const source of sources) {
|
|
349
|
+
const sourceNames = new Set(source.fields.map(field => field.outputName));
|
|
350
|
+
if (schemaPolicy === 'strict') {
|
|
351
|
+
const missing = firstSourceNames.filter(name => !sourceNames.has(name));
|
|
352
|
+
const extra = source.fields
|
|
353
|
+
.map(field => field.outputName)
|
|
354
|
+
.filter(name => !firstSourceNames.includes(name));
|
|
355
|
+
if (missing.length || extra.length) {
|
|
356
|
+
throw new Error(`Federated strict schema mismatch for ${source.entry.dataSourceId}: missing [${missing.join(', ')}], extra [${extra.join(', ')}]`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
for (const mappedField of source.fields) {
|
|
360
|
+
const current = outputFields.get(mappedField.outputName);
|
|
361
|
+
if (current) {
|
|
362
|
+
if (!outputSchema && !areDataTypesEqual(current.type, mappedField.field.type)) {
|
|
363
|
+
throw new Error(`Federated column type mismatch for ${mappedField.outputName}: ${formatDataType(current.type)} versus ${formatDataType(mappedField.field.type)} in ${source.entry.dataSourceId}`);
|
|
364
|
+
}
|
|
365
|
+
current.nullable = Boolean(current.nullable || mappedField.field.nullable);
|
|
366
|
+
current.presentCount++;
|
|
367
|
+
}
|
|
368
|
+
else {
|
|
369
|
+
outputFields.set(mappedField.outputName, {
|
|
370
|
+
...mappedField.field,
|
|
371
|
+
name: mappedField.outputName,
|
|
372
|
+
metadata: mappedField.field.metadata ? { ...mappedField.field.metadata } : undefined,
|
|
373
|
+
presentCount: 1
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
const fields = [...outputFields.values()].map(({ presentCount, ...field }) => ({
|
|
379
|
+
...field,
|
|
380
|
+
nullable: Boolean(field.nullable || presentCount < sources.length)
|
|
381
|
+
}));
|
|
382
|
+
const inferredSchema = { fields, metadata: {} };
|
|
383
|
+
return outputSchema
|
|
384
|
+
? validateAndCloneOutputSchema(outputSchema, sources, inferredSchema, schemaPolicy)
|
|
385
|
+
: inferredSchema;
|
|
386
|
+
}
|
|
387
|
+
/** Validates an explicit output contract and permits only deterministic lossless casts. */
|
|
388
|
+
function validateAndCloneOutputSchema(outputSchema, sources, inferredSchema, schemaPolicy) {
|
|
389
|
+
const outputFields = new Map();
|
|
390
|
+
for (const field of outputSchema.fields) {
|
|
391
|
+
if (!field.name || outputFields.has(field.name)) {
|
|
392
|
+
throw new Error(`Federated output schema contains an invalid or duplicate field: ${field.name}`);
|
|
393
|
+
}
|
|
394
|
+
outputFields.set(field.name, field);
|
|
395
|
+
}
|
|
396
|
+
const inferredNames = new Set(inferredSchema.fields.map(field => field.name));
|
|
397
|
+
const missingFromContract = [...inferredNames].filter(name => !outputFields.has(name));
|
|
398
|
+
const absentFromSources = outputSchema.fields
|
|
399
|
+
.map(field => field.name)
|
|
400
|
+
.filter(name => !inferredNames.has(name));
|
|
401
|
+
if (missingFromContract.length || (schemaPolicy === 'strict' && absentFromSources.length)) {
|
|
402
|
+
throw new Error(`Federated output schema mismatch: missing [${missingFromContract.join(', ')}], absent [${absentFromSources.join(', ')}]`);
|
|
403
|
+
}
|
|
404
|
+
for (const outputField of outputSchema.fields) {
|
|
405
|
+
const presentFields = sources.flatMap(source => source.fields
|
|
406
|
+
.filter(field => field.outputName === outputField.name)
|
|
407
|
+
.map(field => ({ sourceId: source.entry.dataSourceId, field: field.field })));
|
|
408
|
+
if (!presentFields.length) {
|
|
409
|
+
if (!outputField.nullable) {
|
|
410
|
+
throw new Error(`Federated output field is absent from every source and must be nullable: ${outputField.name}`);
|
|
411
|
+
}
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
for (const presentField of presentFields) {
|
|
415
|
+
if (presentField.field.nullable && !outputField.nullable) {
|
|
416
|
+
throw new Error(`Federated output field cannot remove nullability for ${outputField.name} in ${presentField.sourceId}`);
|
|
417
|
+
}
|
|
418
|
+
if (!isLosslessDataTypeCast(presentField.field.type, outputField.type)) {
|
|
419
|
+
throw new Error(`Unsupported federated normalization for ${outputField.name}: ${formatDataType(presentField.field.type)} to ${formatDataType(outputField.type)} in ${presentField.sourceId}`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (presentFields.length < sources.length && !outputField.nullable) {
|
|
423
|
+
throw new Error(`Federated union field must be nullable when absent from a source: ${outputField.name}`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return cloneSchema(outputSchema);
|
|
427
|
+
}
|
|
428
|
+
/** Creates the source-local query required by the global canonical scan step. */
|
|
429
|
+
function createSourceReadOptions(source, requiredOutputColumns, remaining, options, onTelemetry) {
|
|
430
|
+
const sourceResidualPredicate = getSourceResidualPredicate(source);
|
|
431
|
+
const requiredSourceColumns = requiredOutputColumns
|
|
432
|
+
.map(outputName => source.sourceNameByOutputName.get(outputName))
|
|
433
|
+
.filter((sourceName) => Boolean(sourceName));
|
|
434
|
+
if (sourceResidualPredicate) {
|
|
435
|
+
requiredSourceColumns.push(...getColumnarPredicateColumns(sourceResidualPredicate));
|
|
436
|
+
}
|
|
437
|
+
if (!requiredSourceColumns.length) {
|
|
438
|
+
requiredSourceColumns.push(source.fields[0].sourceName);
|
|
439
|
+
}
|
|
440
|
+
const sourceLimit = source.entry.query?.limit;
|
|
441
|
+
const globalLimitCanBePushed = options.predicate === undefined && Number.isFinite(remaining);
|
|
442
|
+
const limit = globalLimitCanBePushed
|
|
443
|
+
? Math.min(sourceLimit ?? Number.POSITIVE_INFINITY, remaining)
|
|
444
|
+
: sourceLimit;
|
|
445
|
+
return {
|
|
446
|
+
...source.entry.query,
|
|
447
|
+
columns: Object.freeze([...new Set(requiredSourceColumns)]),
|
|
448
|
+
predicate: sourceResidualPredicate ? undefined : source.entry.query?.predicate,
|
|
449
|
+
limit: sourceResidualPredicate ? undefined : Number.isFinite(limit) ? limit : undefined,
|
|
450
|
+
signal: options.signal,
|
|
451
|
+
onTelemetry
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
/** Returns the source-local predicate when the child requires residual execution. */
|
|
455
|
+
function getSourceResidualPredicate(source) {
|
|
456
|
+
return source.metadata.capabilities.table?.predicate === 'unsupported'
|
|
457
|
+
? source.entry.query?.predicate
|
|
458
|
+
: undefined;
|
|
459
|
+
}
|
|
460
|
+
/** Converts one physical batch to the reconciled Arrow schema, renaming and null-filling fields. */
|
|
461
|
+
function createCanonicalArrowTable(schema, source, physicalTable) {
|
|
462
|
+
const arrowSchema = convertSchemaToArrow(schema);
|
|
463
|
+
const columns = {};
|
|
464
|
+
for (const field of arrowSchema.fields) {
|
|
465
|
+
const sourceName = source.sourceNameByOutputName.get(field.name);
|
|
466
|
+
const vector = sourceName ? physicalTable.data.getChild(sourceName) : null;
|
|
467
|
+
const mappedField = source.fields.find(sourceField => sourceField.outputName === field.name);
|
|
468
|
+
columns[field.name] = vector
|
|
469
|
+
? mappedField &&
|
|
470
|
+
!areDataTypesEqual(mappedField.field.type, schema.fields.find(schemaField => schemaField.name === field.name).type)
|
|
471
|
+
? arrow.vectorFromArray(Array.from(vector), field.type)
|
|
472
|
+
: vector
|
|
473
|
+
: arrow.vectorFromArray(Array.from({ length: physicalTable.data.numRows }, () => null), field.type);
|
|
474
|
+
}
|
|
475
|
+
return {
|
|
476
|
+
shape: 'arrow-table',
|
|
477
|
+
schema,
|
|
478
|
+
data: new arrow.Table(arrowSchema, columns)
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
/** Creates one portable per-source telemetry record and preserves source-specific counters. */
|
|
482
|
+
function createSourceTelemetry(options) {
|
|
483
|
+
const { resolvedSource, childTelemetry } = options;
|
|
484
|
+
const status = childTelemetry?.status ||
|
|
485
|
+
(options.signal?.aborted
|
|
486
|
+
? 'cancelled'
|
|
487
|
+
: options.sourceError
|
|
488
|
+
? 'failed'
|
|
489
|
+
: options.sourceCompleted
|
|
490
|
+
? 'completed'
|
|
491
|
+
: 'early-terminated');
|
|
492
|
+
const details = childTelemetry
|
|
493
|
+
? Object.freeze(Object.fromEntries(Object.entries(childTelemetry).filter(([key]) => ![
|
|
494
|
+
'status',
|
|
495
|
+
'sourcesPlanned',
|
|
496
|
+
'sourcesRead',
|
|
497
|
+
'batchesRead',
|
|
498
|
+
'batchesDecoded',
|
|
499
|
+
'rowsRead',
|
|
500
|
+
'rowsTested',
|
|
501
|
+
'rowsRetained',
|
|
502
|
+
'rowsReturned',
|
|
503
|
+
'bytesRead',
|
|
504
|
+
'bytesFetched',
|
|
505
|
+
'filesOpened',
|
|
506
|
+
'tasksOpened',
|
|
507
|
+
'rowsPruned',
|
|
508
|
+
'durationMilliseconds',
|
|
509
|
+
'sources',
|
|
510
|
+
'details',
|
|
511
|
+
'error'
|
|
512
|
+
].includes(key))))
|
|
513
|
+
: undefined;
|
|
514
|
+
return Object.freeze({
|
|
515
|
+
sourceId: resolvedSource.entry.dataSourceId,
|
|
516
|
+
sourceType: resolvedSource.metadata.sourceType,
|
|
517
|
+
sourceIndex: resolvedSource.sourceIndex,
|
|
518
|
+
status,
|
|
519
|
+
filesOpened: childTelemetry?.filesOpened,
|
|
520
|
+
tasksOpened: childTelemetry?.tasksOpened,
|
|
521
|
+
bytesFetched: childTelemetry?.bytesFetched ?? childTelemetry?.bytesRead,
|
|
522
|
+
batchesDecoded: childTelemetry?.batchesDecoded ?? options.batchesDecoded,
|
|
523
|
+
rowsRead: childTelemetry?.rowsRead ?? options.rowsRead,
|
|
524
|
+
rowsTested: childTelemetry?.rowsTested || options.rowsTested || undefined,
|
|
525
|
+
rowsRetained: childTelemetry?.rowsRetained || options.rowsRetained || undefined,
|
|
526
|
+
rowsReturned: options.rowsReturned,
|
|
527
|
+
rowsPruned: childTelemetry?.rowsPruned,
|
|
528
|
+
durationMilliseconds: Date.now() - options.sourceStartedAt,
|
|
529
|
+
details: childTelemetry?.details ?? (details && Object.keys(details).length ? details : undefined),
|
|
530
|
+
error: childTelemetry?.error ?? options.sourceError
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
/** Returns exact aggregate row count only when child-local filters and limits cannot change it. */
|
|
534
|
+
function getFederatedStatistics(sources) {
|
|
535
|
+
if (sources.some(source => source.entry.query?.predicate ||
|
|
536
|
+
source.entry.query?.limit !== undefined ||
|
|
537
|
+
source.metadata.statistics?.rowCount === undefined)) {
|
|
538
|
+
return undefined;
|
|
539
|
+
}
|
|
540
|
+
const counts = sources.map(source => source.metadata.statistics.rowCount);
|
|
541
|
+
return {
|
|
542
|
+
rowCount: counts.some(count => typeof count === 'bigint')
|
|
543
|
+
? counts.reduce((sum, count) => sum + BigInt(count), 0n)
|
|
544
|
+
: counts.reduce((sum, count) => sum + Number(count), 0)
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
/** Creates serializable per-source explanation details. */
|
|
548
|
+
function createSourceExplanation(source, outputSchema) {
|
|
549
|
+
const normalizedTypes = Object.fromEntries(source.fields
|
|
550
|
+
.filter(field => {
|
|
551
|
+
const outputField = outputSchema.fields.find(schemaField => schemaField.name === field.outputName);
|
|
552
|
+
return outputField && !areDataTypesEqual(outputField.type, field.field.type);
|
|
553
|
+
})
|
|
554
|
+
.map(field => [
|
|
555
|
+
field.outputName,
|
|
556
|
+
outputSchema.fields.find(schemaField => schemaField.name === field.outputName).type
|
|
557
|
+
]));
|
|
558
|
+
return Object.freeze({
|
|
559
|
+
sourceId: source.entry.dataSourceId,
|
|
560
|
+
sourceIndex: source.sourceIndex,
|
|
561
|
+
sourceType: source.metadata.sourceType,
|
|
562
|
+
sourceColumns: Object.freeze(source.fields.map(field => field.sourceName)),
|
|
563
|
+
outputColumns: Object.freeze(source.fields.map(field => field.outputName)),
|
|
564
|
+
columnMapping: Object.freeze({ ...source.entry.columnMapping }),
|
|
565
|
+
normalizedTypes: Object.freeze(normalizedTypes)
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
/** Returns true when Arrow can normalize values without narrowing or changing their domain. */
|
|
569
|
+
function isLosslessDataTypeCast(sourceType, outputType) {
|
|
570
|
+
if (areDataTypesEqual(sourceType, outputType) || sourceType === 'null')
|
|
571
|
+
return true;
|
|
572
|
+
if (typeof sourceType === 'object' && sourceType.type === 'dictionary') {
|
|
573
|
+
return isLosslessDataTypeCast(sourceType.dictionary, outputType);
|
|
574
|
+
}
|
|
575
|
+
if (sourceType === 'utf8-view' && outputType === 'utf8')
|
|
576
|
+
return true;
|
|
577
|
+
if (sourceType === 'binary-view' && outputType === 'binary')
|
|
578
|
+
return true;
|
|
579
|
+
const sourceInteger = getIntegerType(sourceType);
|
|
580
|
+
const outputInteger = getIntegerType(outputType);
|
|
581
|
+
if (sourceInteger && outputInteger) {
|
|
582
|
+
return ((sourceInteger.signed === outputInteger.signed && sourceInteger.bits <= outputInteger.bits) ||
|
|
583
|
+
(!sourceInteger.signed && outputInteger.signed && sourceInteger.bits < outputInteger.bits));
|
|
584
|
+
}
|
|
585
|
+
const sourceFloatBits = getFloatBits(sourceType);
|
|
586
|
+
const outputFloatBits = getFloatBits(outputType);
|
|
587
|
+
if (sourceFloatBits && outputFloatBits)
|
|
588
|
+
return sourceFloatBits <= outputFloatBits;
|
|
589
|
+
return Boolean(sourceInteger && sourceInteger.bits <= 32 && outputFloatBits === 64);
|
|
590
|
+
}
|
|
591
|
+
/** Returns the signedness and width for portable integer aliases. */
|
|
592
|
+
function getIntegerType(dataType) {
|
|
593
|
+
const types = {
|
|
594
|
+
int: { signed: true, bits: 32 },
|
|
595
|
+
int8: { signed: true, bits: 8 },
|
|
596
|
+
int16: { signed: true, bits: 16 },
|
|
597
|
+
int32: { signed: true, bits: 32 },
|
|
598
|
+
int64: { signed: true, bits: 64 },
|
|
599
|
+
uint8: { signed: false, bits: 8 },
|
|
600
|
+
uint16: { signed: false, bits: 16 },
|
|
601
|
+
uint32: { signed: false, bits: 32 },
|
|
602
|
+
uint64: { signed: false, bits: 64 }
|
|
603
|
+
};
|
|
604
|
+
return typeof dataType === 'string' ? types[dataType] || null : null;
|
|
605
|
+
}
|
|
606
|
+
/** Returns the width for portable floating-point aliases. */
|
|
607
|
+
function getFloatBits(dataType) {
|
|
608
|
+
const types = { float: 32, float16: 16, float32: 32, float64: 64 };
|
|
609
|
+
return typeof dataType === 'string' ? types[dataType] || null : null;
|
|
610
|
+
}
|
|
611
|
+
/** Clones a portable schema through the canonical Arrow serializer. */
|
|
612
|
+
function cloneSchema(schema) {
|
|
613
|
+
return convertArrowToSchema(convertSchemaToArrow(schema));
|
|
614
|
+
}
|
|
615
|
+
/** Compares portable data types, including nested types and typed union identifiers. */
|
|
616
|
+
function areDataTypesEqual(left, right) {
|
|
617
|
+
return formatDataType(left) === formatDataType(right);
|
|
618
|
+
}
|
|
619
|
+
/** Serializes a portable data type for diagnostics and deterministic equality checks. */
|
|
620
|
+
function formatDataType(dataType) {
|
|
621
|
+
return typeof dataType === 'string' ? dataType : JSON.stringify(canonicalizeValue(dataType));
|
|
622
|
+
}
|
|
623
|
+
/** Canonicalizes object keys while preserving array and typed-array element order. */
|
|
624
|
+
function canonicalizeValue(value) {
|
|
625
|
+
if (ArrayBuffer.isView(value)) {
|
|
626
|
+
return Array.from(value);
|
|
627
|
+
}
|
|
628
|
+
if (Array.isArray(value)) {
|
|
629
|
+
return value.map(canonicalizeValue);
|
|
630
|
+
}
|
|
631
|
+
if (value && typeof value === 'object') {
|
|
632
|
+
return Object.fromEntries(Object.entries(value)
|
|
633
|
+
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
|
|
634
|
+
.map(([key, child]) => [key, canonicalizeValue(child)]));
|
|
635
|
+
}
|
|
636
|
+
return value;
|
|
637
|
+
}
|
|
638
|
+
/** Resolves a current, asynchronous, or deferred source through one manager subscription. */
|
|
639
|
+
function resolveManagedSource(dataSourceManager, dataSourceId, consumerId, requestId, signal) {
|
|
640
|
+
let resolveDeferredSource;
|
|
641
|
+
let rejectDeferredSource;
|
|
642
|
+
const deferredSource = new Promise((resolve, reject) => {
|
|
643
|
+
resolveDeferredSource = resolve;
|
|
644
|
+
rejectDeferredSource = reject;
|
|
645
|
+
});
|
|
646
|
+
let sourceGeneration = 0;
|
|
647
|
+
const acceptSource = (source) => {
|
|
648
|
+
const generation = ++sourceGeneration;
|
|
649
|
+
if (source) {
|
|
650
|
+
void Promise.resolve(source).then(result => {
|
|
651
|
+
if (generation === sourceGeneration)
|
|
652
|
+
resolveDeferredSource(result);
|
|
653
|
+
}, error => {
|
|
654
|
+
if (generation === sourceGeneration)
|
|
655
|
+
rejectDeferredSource(error);
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
const managedSource = dataSourceManager.subscribe({
|
|
660
|
+
dataSourceId,
|
|
661
|
+
consumerId,
|
|
662
|
+
requestId,
|
|
663
|
+
onChange: acceptSource
|
|
664
|
+
});
|
|
665
|
+
if (managedSource === undefined) {
|
|
666
|
+
throw new Error(`Federated table source is not registered: ${dataSourceId}`);
|
|
667
|
+
}
|
|
668
|
+
acceptSource(managedSource || null);
|
|
669
|
+
return waitForPromise(deferredSource, signal);
|
|
670
|
+
}
|
|
671
|
+
/** Waits for a managed asynchronous source while preserving operation-local cancellation. */
|
|
672
|
+
async function waitForPromise(promise, signal) {
|
|
673
|
+
if (!signal)
|
|
674
|
+
return await promise;
|
|
675
|
+
throwIfAborted(signal);
|
|
676
|
+
return await new Promise((resolve, reject) => {
|
|
677
|
+
const abort = () => reject(getAbortReason(signal));
|
|
678
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
679
|
+
void promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
/** Throws the caller's abort reason between resolution, planning, and batch operations. */
|
|
683
|
+
function throwIfAborted(signal) {
|
|
684
|
+
if (signal?.aborted)
|
|
685
|
+
throw getAbortReason(signal);
|
|
686
|
+
}
|
|
687
|
+
/** Returns a portable cancellation reason when AbortSignal.reason is unavailable. */
|
|
688
|
+
function getAbortReason(signal) {
|
|
689
|
+
return signal.reason || new DOMException('Request aborted', 'AbortError');
|
|
690
|
+
}
|
|
691
|
+
//# sourceMappingURL=federated-table-scan-source.js.map
|