@loaders.gl/scan 5.0.0-alpha.3

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.
Files changed (52) hide show
  1. package/LICENSE +52 -0
  2. package/README.md +219 -0
  3. package/dist/delta.cjs +24246 -0
  4. package/dist/delta.cjs.map +6 -0
  5. package/dist/delta.d.ts +11 -0
  6. package/dist/delta.d.ts.map +1 -0
  7. package/dist/delta.js +12 -0
  8. package/dist/delta.js.map +1 -0
  9. package/dist/dist.min.js +18475 -0
  10. package/dist/federated-table-scan-source.d.ts +116 -0
  11. package/dist/federated-table-scan-source.d.ts.map +1 -0
  12. package/dist/federated-table-scan-source.js +691 -0
  13. package/dist/federated-table-scan-source.js.map +1 -0
  14. package/dist/hudi.cjs +24065 -0
  15. package/dist/hudi.cjs.map +6 -0
  16. package/dist/hudi.d.ts +54 -0
  17. package/dist/hudi.d.ts.map +1 -0
  18. package/dist/hudi.js +124 -0
  19. package/dist/hudi.js.map +1 -0
  20. package/dist/iceberg.cjs +25862 -0
  21. package/dist/iceberg.cjs.map +6 -0
  22. package/dist/iceberg.d.ts +12 -0
  23. package/dist/iceberg.d.ts.map +1 -0
  24. package/dist/iceberg.js +12 -0
  25. package/dist/iceberg.js.map +1 -0
  26. package/dist/index.cjs +3485 -0
  27. package/dist/index.cjs.map +6 -0
  28. package/dist/index.d.ts +15 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/index.js +14 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/scan-engine.d.ts +44 -0
  33. package/dist/scan-engine.d.ts.map +1 -0
  34. package/dist/scan-engine.js +49 -0
  35. package/dist/scan-engine.js.map +1 -0
  36. package/dist/scan-query.d.ts +24 -0
  37. package/dist/scan-query.d.ts.map +1 -0
  38. package/dist/scan-query.js +5 -0
  39. package/dist/scan-query.js.map +1 -0
  40. package/dist/vector-table-scan-source.d.ts +90 -0
  41. package/dist/vector-table-scan-source.d.ts.map +1 -0
  42. package/dist/vector-table-scan-source.js +215 -0
  43. package/dist/vector-table-scan-source.js.map +1 -0
  44. package/package.json +66 -0
  45. package/src/delta.ts +18 -0
  46. package/src/federated-table-scan-source.ts +991 -0
  47. package/src/hudi.ts +200 -0
  48. package/src/iceberg.ts +36 -0
  49. package/src/index.ts +97 -0
  50. package/src/scan-engine.ts +96 -0
  51. package/src/scan-query.ts +27 -0
  52. package/src/vector-table-scan-source.ts +294 -0
@@ -0,0 +1,991 @@
1
+ // loaders.gl
2
+ // SPDX-License-Identifier: MIT
3
+ // Copyright (c) vis.gl contributors
4
+
5
+ import * as arrow from 'apache-arrow';
6
+ import {
7
+ createScanQueryMetadata,
8
+ emitScanExecutionTelemetry,
9
+ explainTableQuery,
10
+ getColumnarPredicateColumns,
11
+ planTableQuery,
12
+ type DataSourceManager,
13
+ type ManageableDataSource,
14
+ type ScanQueryMetadata,
15
+ type ScanQueryMetadataOptions,
16
+ type ScanExecutionTelemetry,
17
+ type ScanExecutionTelemetryStatus,
18
+ type ScanSourceExecutionTelemetry,
19
+ type TableQueryCapabilities,
20
+ type TableQueryExplain,
21
+ type TableScanReadOptions,
22
+ type TableScanSource
23
+ } from '@loaders.gl/loader-utils';
24
+ import type {
25
+ ArrowTable,
26
+ ArrowTableBatch,
27
+ DataType,
28
+ Field,
29
+ Schema,
30
+ TableBatch
31
+ } from '@loaders.gl/schema';
32
+ import {convertArrowToSchema, convertBatch, convertSchemaToArrow} from '@loaders.gl/schema-utils';
33
+ import {queryArrowTable, type SQLPredicate} from '@loaders.gl/sql';
34
+
35
+ /** Schema reconciliation modes supported by ordered append federation. */
36
+ export type FederatedTableSchemaPolicy = 'strict' | 'union';
37
+
38
+ /** One named table source participating in an ordered federated append. */
39
+ export type FederatedTableSourceEntry = Readonly<{
40
+ /** Stable id resolved through the shared {@link DataSourceManager}. */
41
+ dataSourceId: string;
42
+ /** Source-local predicate, projection, and limit applied before schema reconciliation. */
43
+ query?: Omit<TableScanReadOptions<SQLPredicate>, 'signal' | 'onTelemetry'>;
44
+ /** Source-column to federated-column renames applied before compatibility checks. */
45
+ columnMapping?: Readonly<Record<string, string>>;
46
+ }>;
47
+
48
+ /** Options for an ordered federated table source. */
49
+ export type FederatedTableScanSourceOptions = Readonly<{
50
+ /** Named sources appended in caller-specified order. */
51
+ sources: readonly FederatedTableSourceEntry[];
52
+ /** Strict requires matching columns; union null-fills missing columns. Defaults to strict. */
53
+ schemaPolicy?: FederatedTableSchemaPolicy;
54
+ /** Explicit federated schema used to validate, order, and safely normalize physical columns. */
55
+ outputSchema?: Schema;
56
+ /** Optional display name reported through scan metadata. */
57
+ name?: string;
58
+ /** Optional description reported through scan metadata. */
59
+ description?: string;
60
+ }>;
61
+
62
+ /** Provenance attached to every federated Arrow batch. */
63
+ export type FederatedTableBatchProvenance = Readonly<{
64
+ /** Manager id of the source that emitted this batch. */
65
+ sourceId: string;
66
+ /** Zero-based source position in append order. */
67
+ sourceIndex: number;
68
+ /** Zero-based data-batch position within the source. */
69
+ sourceBatchIndex: number;
70
+ /** Original batch metadata supplied by the physical source. */
71
+ sourceMetadata?: unknown;
72
+ }>;
73
+
74
+ /** Arrow batch emitted by an ordered federated scan. */
75
+ export type FederatedTableBatch = ArrowTableBatch<FederatedTableBatchProvenance> &
76
+ Readonly<{
77
+ /** Manager id of the source that emitted this batch. */
78
+ sourceId: string;
79
+ /** Zero-based source position in append order. */
80
+ sourceIndex: number;
81
+ /** Zero-based data-batch position within the source. */
82
+ sourceBatchIndex: number;
83
+ }>;
84
+
85
+ /** Per-source diagnostics included in a federated explanation. */
86
+ export type FederatedTableSourceExplain = Readonly<{
87
+ /** Manager id resolved for this source. */
88
+ sourceId: string;
89
+ /** Zero-based source position in append order. */
90
+ sourceIndex: number;
91
+ /** Physical source type reported by metadata discovery. */
92
+ sourceType: string;
93
+ /** Source columns visible after the source-local projection. */
94
+ sourceColumns: readonly string[];
95
+ /** Federated column names produced after explicit mappings. */
96
+ outputColumns: readonly string[];
97
+ /** Explicit source-column to federated-column mappings. */
98
+ columnMapping: Readonly<Record<string, string>>;
99
+ /** Output types that require an explicit lossless normalization for this source. */
100
+ normalizedTypes: Readonly<Record<string, DataType>>;
101
+ }>;
102
+
103
+ /** Serializable logical explanation for an ordered federated table scan. */
104
+ export type FederatedTableScanExplain = TableQueryExplain<SQLPredicate> &
105
+ Readonly<{
106
+ /** Schema policy used to reconcile child sources. */
107
+ schemaPolicy: FederatedTableSchemaPolicy;
108
+ /** Sources in physical append order. */
109
+ sources: readonly FederatedTableSourceExplain[];
110
+ }>;
111
+
112
+ /** Correctness capabilities of the federated append executor. */
113
+ export const FEDERATED_TABLE_QUERY_CAPABILITIES: TableQueryCapabilities = Object.freeze({
114
+ predicate: 'residual',
115
+ projection: 'pushdown+residual',
116
+ limit: 'pushdown+residual',
117
+ streaming: true,
118
+ cancellation: true
119
+ });
120
+
121
+ type ManagedTableScanSource = ManageableDataSource & TableScanSource<TableBatch, SQLPredicate>;
122
+
123
+ type MappedSourceField = Readonly<{
124
+ sourceName: string;
125
+ outputName: string;
126
+ field: Field;
127
+ }>;
128
+
129
+ type ResolvedFederatedSource = Readonly<{
130
+ entry: FederatedTableSourceEntry;
131
+ source: ManagedTableScanSource;
132
+ metadata: ScanQueryMetadata;
133
+ sourceIndex: number;
134
+ fields: readonly MappedSourceField[];
135
+ sourceNameByOutputName: ReadonlyMap<string, string>;
136
+ }>;
137
+
138
+ type FederatedTablePlan = Readonly<{
139
+ schema: Schema;
140
+ sources: readonly ResolvedFederatedSource[];
141
+ }>;
142
+
143
+ type MutableScanExecutionTelemetry = {
144
+ status: ScanExecutionTelemetryStatus;
145
+ sourcesPlanned: number;
146
+ sourcesRead: number;
147
+ batchesRead: number;
148
+ rowsRead: number;
149
+ rowsTested: number;
150
+ rowsRetained: number;
151
+ rowsReturned: number;
152
+ bytesFetched: number;
153
+ filesOpened: number;
154
+ tasksOpened: number;
155
+ rowsPruned: number;
156
+ durationMilliseconds: number;
157
+ earlyTerminationReason?: ScanExecutionTelemetry['earlyTerminationReason'];
158
+ sources: ScanSourceExecutionTelemetry[];
159
+ error?: unknown;
160
+ };
161
+
162
+ let nextFederatedSourceId = 0;
163
+
164
+ /**
165
+ * Resolves managed table sources and appends their results as one ordered Arrow stream.
166
+ *
167
+ * `DataSourceManager` remains the single registry and lifecycle owner. This adapter subscribes for
168
+ * the duration of metadata discovery or iteration, so deferred and non-persistent sources use the
169
+ * same replacement, retention, and cleanup system as the rest of loaders.gl.
170
+ */
171
+ export class FederatedTableScanSource
172
+ implements TableScanSource<FederatedTableBatch, SQLPredicate>
173
+ {
174
+ /** Shared manager used as the authoritative named-source registry. */
175
+ readonly dataSourceManager: DataSourceManager;
176
+ /** Immutable sources in physical append order. */
177
+ readonly sources: readonly FederatedTableSourceEntry[];
178
+ /** Schema reconciliation policy. */
179
+ readonly schemaPolicy: FederatedTableSchemaPolicy;
180
+ /** Optional caller-declared normalized output schema. */
181
+ readonly outputSchema?: Schema;
182
+ /** Optional display name reported through scan metadata. */
183
+ readonly name?: string;
184
+ /** Human-readable description reported through scan metadata. */
185
+ readonly description: string;
186
+
187
+ /** Stable id used to isolate this adapter's manager subscriptions. */
188
+ private readonly instanceId = nextFederatedSourceId++;
189
+ /** Monotonic id used to isolate overlapping operations on this adapter. */
190
+ private nextOperationId = 0;
191
+
192
+ /** Creates an ordered federated source backed by one shared DataSourceManager. */
193
+ constructor(dataSourceManager: DataSourceManager, options: FederatedTableScanSourceOptions) {
194
+ if (!options.sources.length) {
195
+ throw new Error('Federated table scans require at least one source.');
196
+ }
197
+ if (
198
+ options.schemaPolicy &&
199
+ options.schemaPolicy !== 'strict' &&
200
+ options.schemaPolicy !== 'union'
201
+ ) {
202
+ throw new Error(`Unsupported federated schema policy: ${options.schemaPolicy}`);
203
+ }
204
+ this.dataSourceManager = dataSourceManager;
205
+ this.sources = Object.freeze(options.sources.map(cloneSourceEntry));
206
+ this.schemaPolicy = options.schemaPolicy || 'strict';
207
+ this.outputSchema = options.outputSchema ? cloneSchema(options.outputSchema) : undefined;
208
+ this.name = options.name;
209
+ this.description = options.description || `${this.sources.length} ordered table sources`;
210
+ }
211
+
212
+ /** Discovers and reconciles every child schema without reading federated result batches. */
213
+ async getQueryMetadata(options: ScanQueryMetadataOptions = {}): Promise<ScanQueryMetadata> {
214
+ const consumerId = this.createConsumerId('metadata');
215
+ try {
216
+ const plan = await this.resolvePlan(consumerId, options.signal);
217
+ return createScanQueryMetadata({
218
+ sourceType: 'federated-table',
219
+ queryType: 'table',
220
+ execution: {status: 'supported', method: 'read'},
221
+ name: this.name,
222
+ description: this.description,
223
+ schema: plan.schema,
224
+ capabilities: {table: FEDERATED_TABLE_QUERY_CAPABILITIES},
225
+ statistics: getFederatedStatistics(plan.sources)
226
+ });
227
+ } finally {
228
+ this.dataSourceManager.unsubscribe({consumerId});
229
+ }
230
+ }
231
+
232
+ /** Explains source resolution, schema reconciliation, and the global table query. */
233
+ async explain(
234
+ options: TableScanReadOptions<SQLPredicate> = {}
235
+ ): Promise<FederatedTableScanExplain> {
236
+ const consumerId = this.createConsumerId('explain');
237
+ try {
238
+ const plan = await this.resolvePlan(consumerId, options.signal);
239
+ const explanation = explainTableQuery(
240
+ plan.schema.fields.map(field => field.name),
241
+ options,
242
+ FEDERATED_TABLE_QUERY_CAPABILITIES
243
+ );
244
+ return Object.freeze({
245
+ ...explanation,
246
+ schemaPolicy: this.schemaPolicy,
247
+ sources: Object.freeze(
248
+ plan.sources.map(source => createSourceExplanation(source, plan.schema))
249
+ )
250
+ });
251
+ } finally {
252
+ this.dataSourceManager.unsubscribe({consumerId});
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Emits source and batch ordered Arrow results with one global limit and batch provenance.
258
+ *
259
+ * Returning early from the iterator closes the active child iterator and releases every manager
260
+ * subscription. Later sources are never read once the global limit has been satisfied.
261
+ */
262
+ async *read(
263
+ options: TableScanReadOptions<SQLPredicate> = {}
264
+ ): AsyncIterableIterator<FederatedTableBatch> {
265
+ const consumerId = this.createConsumerId('read');
266
+ const startedAt = Date.now();
267
+ const telemetry: MutableScanExecutionTelemetry = {
268
+ status: 'early-terminated',
269
+ sourcesPlanned: this.sources.length,
270
+ sourcesRead: 0,
271
+ batchesRead: 0,
272
+ rowsRead: 0,
273
+ rowsTested: 0,
274
+ rowsRetained: 0,
275
+ rowsReturned: 0,
276
+ bytesFetched: 0,
277
+ filesOpened: 0,
278
+ tasksOpened: 0,
279
+ rowsPruned: 0,
280
+ durationMilliseconds: 0,
281
+ sources: []
282
+ };
283
+ try {
284
+ const plan = await this.resolvePlan(consumerId, options.signal);
285
+ const scanStep = planTableQuery(
286
+ plan.schema.fields.map(field => field.name),
287
+ options
288
+ )[0] as Readonly<{columns: readonly string[]}>;
289
+ let remaining = options.limit ?? Number.POSITIVE_INFINITY;
290
+ if (remaining <= 0) {
291
+ telemetry.earlyTerminationReason = 'limit';
292
+ return;
293
+ }
294
+
295
+ for (const resolvedSource of plan.sources) {
296
+ throwIfAborted(options.signal);
297
+ if (remaining <= 0) {
298
+ telemetry.earlyTerminationReason = 'limit';
299
+ return;
300
+ }
301
+ const sourceStartedAt = Date.now();
302
+ let childTelemetry: ScanExecutionTelemetry | undefined;
303
+ let sourceCompleted = false;
304
+ let sourceError: unknown;
305
+ let sourceBatchesDecoded = 0;
306
+ let sourceRowsRead = 0;
307
+ let sourceRowsTested = 0;
308
+ let sourceRowsRetained = 0;
309
+ let sourceRowsReturned = 0;
310
+ const sourceReadOptions = createSourceReadOptions(
311
+ resolvedSource,
312
+ scanStep.columns,
313
+ remaining,
314
+ options,
315
+ value => {
316
+ childTelemetry = value;
317
+ }
318
+ );
319
+ const sourceResidualPredicate = getSourceResidualPredicate(resolvedSource);
320
+ let sourceRemaining = resolvedSource.entry.query?.limit ?? Number.POSITIVE_INFINITY;
321
+ telemetry.sourcesRead++;
322
+ let sourceBatchIndex = 0;
323
+ try {
324
+ for await (const batch of resolvedSource.source.read(sourceReadOptions)) {
325
+ throwIfAborted(options.signal);
326
+ if (remaining <= 0) {
327
+ telemetry.earlyTerminationReason = 'limit';
328
+ return;
329
+ }
330
+ if (sourceRemaining <= 0) break;
331
+ if (batch.batchType !== 'data') continue;
332
+ telemetry.batchesRead++;
333
+ telemetry.rowsRead += batch.length;
334
+ sourceBatchesDecoded++;
335
+ sourceRowsRead += batch.length;
336
+ if (batch.length <= 0) continue;
337
+ const physicalTable = convertBatch(
338
+ batch.schema ? batch : {...batch, schema: resolvedSource.metadata.schema},
339
+ 'arrow-table'
340
+ );
341
+ const sourceResult = queryArrowTable(physicalTable, {
342
+ predicate: sourceResidualPredicate,
343
+ limit: Number.isFinite(sourceRemaining) ? sourceRemaining : undefined,
344
+ signal: options.signal
345
+ });
346
+ if (sourceResidualPredicate) {
347
+ sourceRowsTested += physicalTable.data.numRows;
348
+ sourceRowsRetained += sourceResult.data.numRows;
349
+ }
350
+ sourceRemaining -= sourceResult.data.numRows;
351
+ const currentSourceBatchIndex = sourceBatchIndex++;
352
+ if (!sourceResult.data.numRows) continue;
353
+ const canonicalTable = createCanonicalArrowTable(
354
+ plan.schema,
355
+ resolvedSource,
356
+ sourceResult
357
+ );
358
+ const result = queryArrowTable(canonicalTable, {
359
+ predicate: options.predicate,
360
+ columns: options.columns,
361
+ limit: Number.isFinite(remaining) ? remaining : undefined,
362
+ signal: options.signal
363
+ });
364
+ if (options.predicate) {
365
+ telemetry.rowsTested += canonicalTable.data.numRows;
366
+ telemetry.rowsRetained += result.data.numRows;
367
+ }
368
+ const resultLength = result.data.numRows;
369
+ if (!resultLength) continue;
370
+ const provenance: FederatedTableBatchProvenance = Object.freeze({
371
+ sourceId: resolvedSource.entry.dataSourceId,
372
+ sourceIndex: resolvedSource.sourceIndex,
373
+ sourceBatchIndex: currentSourceBatchIndex,
374
+ sourceMetadata: batch.metadata
375
+ });
376
+ telemetry.rowsReturned += resultLength;
377
+ sourceRowsReturned += resultLength;
378
+ yield {
379
+ batchType: 'data',
380
+ shape: 'arrow-table',
381
+ schema: convertArrowToSchema(result.data.schema),
382
+ data: result.data,
383
+ length: resultLength,
384
+ metadata: provenance,
385
+ sourceId: provenance.sourceId,
386
+ sourceIndex: provenance.sourceIndex,
387
+ sourceBatchIndex: provenance.sourceBatchIndex
388
+ };
389
+ remaining -= resultLength;
390
+ }
391
+ sourceCompleted = true;
392
+ } catch (error) {
393
+ sourceError = error;
394
+ throw error;
395
+ } finally {
396
+ const sourceTelemetry = createSourceTelemetry({
397
+ resolvedSource,
398
+ childTelemetry,
399
+ sourceStartedAt,
400
+ sourceCompleted,
401
+ sourceError,
402
+ signal: options.signal,
403
+ batchesDecoded: sourceBatchesDecoded,
404
+ rowsRead: sourceRowsRead,
405
+ rowsTested: sourceRowsTested,
406
+ rowsRetained: sourceRowsRetained,
407
+ rowsReturned: sourceRowsReturned
408
+ });
409
+ telemetry.sources.push(sourceTelemetry);
410
+ telemetry.bytesFetched += sourceTelemetry.bytesFetched || 0;
411
+ telemetry.filesOpened += sourceTelemetry.filesOpened || 0;
412
+ telemetry.tasksOpened += sourceTelemetry.tasksOpened || 0;
413
+ telemetry.rowsPruned += sourceTelemetry.rowsPruned || 0;
414
+ }
415
+ }
416
+ telemetry.status = 'completed';
417
+ } catch (error) {
418
+ telemetry.status = options.signal?.aborted ? 'cancelled' : 'failed';
419
+ telemetry.error = error;
420
+ throw error;
421
+ } finally {
422
+ this.dataSourceManager.unsubscribe({consumerId});
423
+ telemetry.durationMilliseconds = Date.now() - startedAt;
424
+ if (telemetry.status === 'early-terminated' && !telemetry.earlyTerminationReason) {
425
+ telemetry.earlyTerminationReason = 'consumer-return';
426
+ }
427
+ emitScanExecutionTelemetry(
428
+ options.onTelemetry,
429
+ Object.freeze({
430
+ ...telemetry,
431
+ batchesDecoded: telemetry.batchesRead,
432
+ sources: Object.freeze([...telemetry.sources])
433
+ }) satisfies ScanExecutionTelemetry
434
+ );
435
+ }
436
+ }
437
+
438
+ /** Resolves managed sources, discovers their schemas, and creates one compatibility plan. */
439
+ private async resolvePlan(consumerId: string, signal?: AbortSignal): Promise<FederatedTablePlan> {
440
+ const sources: ResolvedFederatedSource[] = [];
441
+ for (let sourceIndex = 0; sourceIndex < this.sources.length; sourceIndex++) {
442
+ throwIfAborted(signal);
443
+ const entry = this.sources[sourceIndex];
444
+ const source = await resolveManagedSource(
445
+ this.dataSourceManager,
446
+ entry.dataSourceId,
447
+ consumerId,
448
+ String(sourceIndex),
449
+ signal
450
+ );
451
+ if (typeof source.getQueryMetadata !== 'function' || typeof source.read !== 'function') {
452
+ throw new Error(`Managed source does not implement TableScanSource: ${entry.dataSourceId}`);
453
+ }
454
+ const metadata = await source.getQueryMetadata({signal});
455
+ if (metadata.execution.status !== 'supported' || metadata.execution.method !== 'read') {
456
+ throw new Error(`Federated table source does not support read(): ${entry.dataSourceId}`);
457
+ }
458
+ if (metadata.queryType !== 'table') {
459
+ throw new Error(`Federated source is not a table source: ${entry.dataSourceId}`);
460
+ }
461
+ const fields = resolveMappedFields(entry, metadata);
462
+ sources.push({
463
+ entry,
464
+ source,
465
+ metadata,
466
+ sourceIndex,
467
+ fields,
468
+ sourceNameByOutputName: new Map(fields.map(field => [field.outputName, field.sourceName]))
469
+ });
470
+ }
471
+ return Object.freeze({
472
+ schema: reconcileSchemas(sources, this.schemaPolicy, this.outputSchema),
473
+ sources: Object.freeze(sources)
474
+ });
475
+ }
476
+
477
+ /** Returns a collision-free manager consumer id for one operation. */
478
+ private createConsumerId(operation: string): string {
479
+ return `federated-table:${this.instanceId}:${operation}:${this.nextOperationId++}`;
480
+ }
481
+ }
482
+
483
+ /** Clones the public entry so later caller mutations cannot change a running plan. */
484
+ function cloneSourceEntry(entry: FederatedTableSourceEntry): FederatedTableSourceEntry {
485
+ if (!entry.dataSourceId) {
486
+ throw new Error('Federated table source ids must be non-empty.');
487
+ }
488
+ return Object.freeze({
489
+ dataSourceId: entry.dataSourceId,
490
+ query: entry.query
491
+ ? Object.freeze({
492
+ ...entry.query,
493
+ columns: entry.query.columns ? Object.freeze([...entry.query.columns]) : undefined
494
+ })
495
+ : undefined,
496
+ columnMapping: entry.columnMapping ? Object.freeze({...entry.columnMapping}) : undefined
497
+ });
498
+ }
499
+
500
+ /** Selects source-local fields and applies explicit output names. */
501
+ function resolveMappedFields(
502
+ entry: FederatedTableSourceEntry,
503
+ metadata: ScanQueryMetadata
504
+ ): readonly MappedSourceField[] {
505
+ const sourceFields = new Map(metadata.schema.fields.map(field => [field.name, field]));
506
+ for (const sourceName of Object.keys(entry.columnMapping || {})) {
507
+ if (!sourceFields.has(sourceName)) {
508
+ throw new Error(
509
+ `Federated column mapping source not found in ${entry.dataSourceId}: ${sourceName}`
510
+ );
511
+ }
512
+ }
513
+ const selectedNames = entry.query?.columns || metadata.schema.fields.map(field => field.name);
514
+ if (!selectedNames.length) {
515
+ throw new Error(`Federated source projection must not be empty: ${entry.dataSourceId}`);
516
+ }
517
+ const outputNames = new Set<string>();
518
+ return Object.freeze(
519
+ selectedNames.map(sourceName => {
520
+ const field = sourceFields.get(sourceName);
521
+ if (!field) {
522
+ throw new Error(
523
+ `Federated source column not found in ${entry.dataSourceId}: ${sourceName}`
524
+ );
525
+ }
526
+ const outputName = entry.columnMapping?.[sourceName] ?? sourceName;
527
+ if (!outputName) {
528
+ throw new Error(`Federated output column names must be non-empty: ${sourceName}`);
529
+ }
530
+ if (outputNames.has(outputName)) {
531
+ throw new Error(
532
+ `Federated column mapping creates duplicate output column in ${entry.dataSourceId}: ${outputName}`
533
+ );
534
+ }
535
+ outputNames.add(outputName);
536
+ return Object.freeze({sourceName, outputName, field});
537
+ })
538
+ );
539
+ }
540
+
541
+ /** Reconciles mapped fields using strict equality or first-seen union ordering. */
542
+ function reconcileSchemas(
543
+ sources: readonly ResolvedFederatedSource[],
544
+ schemaPolicy: FederatedTableSchemaPolicy,
545
+ outputSchema?: Schema
546
+ ): Schema {
547
+ const outputFields = new Map<string, Field & {presentCount: number}>();
548
+ const firstSourceNames = sources[0].fields.map(field => field.outputName);
549
+ for (const source of sources) {
550
+ const sourceNames = new Set(source.fields.map(field => field.outputName));
551
+ if (schemaPolicy === 'strict') {
552
+ const missing = firstSourceNames.filter(name => !sourceNames.has(name));
553
+ const extra = source.fields
554
+ .map(field => field.outputName)
555
+ .filter(name => !firstSourceNames.includes(name));
556
+ if (missing.length || extra.length) {
557
+ throw new Error(
558
+ `Federated strict schema mismatch for ${source.entry.dataSourceId}: missing [${missing.join(', ')}], extra [${extra.join(', ')}]`
559
+ );
560
+ }
561
+ }
562
+ for (const mappedField of source.fields) {
563
+ const current = outputFields.get(mappedField.outputName);
564
+ if (current) {
565
+ if (!outputSchema && !areDataTypesEqual(current.type, mappedField.field.type)) {
566
+ throw new Error(
567
+ `Federated column type mismatch for ${mappedField.outputName}: ${formatDataType(current.type)} versus ${formatDataType(mappedField.field.type)} in ${source.entry.dataSourceId}`
568
+ );
569
+ }
570
+ current.nullable = Boolean(current.nullable || mappedField.field.nullable);
571
+ current.presentCount++;
572
+ } else {
573
+ outputFields.set(mappedField.outputName, {
574
+ ...mappedField.field,
575
+ name: mappedField.outputName,
576
+ metadata: mappedField.field.metadata ? {...mappedField.field.metadata} : undefined,
577
+ presentCount: 1
578
+ });
579
+ }
580
+ }
581
+ }
582
+ const fields = [...outputFields.values()].map(({presentCount, ...field}) => ({
583
+ ...field,
584
+ nullable: Boolean(field.nullable || presentCount < sources.length)
585
+ }));
586
+ const inferredSchema = {fields, metadata: {}};
587
+ return outputSchema
588
+ ? validateAndCloneOutputSchema(outputSchema, sources, inferredSchema, schemaPolicy)
589
+ : inferredSchema;
590
+ }
591
+
592
+ /** Validates an explicit output contract and permits only deterministic lossless casts. */
593
+ function validateAndCloneOutputSchema(
594
+ outputSchema: Schema,
595
+ sources: readonly ResolvedFederatedSource[],
596
+ inferredSchema: Schema,
597
+ schemaPolicy: FederatedTableSchemaPolicy
598
+ ): Schema {
599
+ const outputFields = new Map<string, Field>();
600
+ for (const field of outputSchema.fields) {
601
+ if (!field.name || outputFields.has(field.name)) {
602
+ throw new Error(
603
+ `Federated output schema contains an invalid or duplicate field: ${field.name}`
604
+ );
605
+ }
606
+ outputFields.set(field.name, field);
607
+ }
608
+ const inferredNames = new Set(inferredSchema.fields.map(field => field.name));
609
+ const missingFromContract = [...inferredNames].filter(name => !outputFields.has(name));
610
+ const absentFromSources = outputSchema.fields
611
+ .map(field => field.name)
612
+ .filter(name => !inferredNames.has(name));
613
+ if (missingFromContract.length || (schemaPolicy === 'strict' && absentFromSources.length)) {
614
+ throw new Error(
615
+ `Federated output schema mismatch: missing [${missingFromContract.join(', ')}], absent [${absentFromSources.join(', ')}]`
616
+ );
617
+ }
618
+ for (const outputField of outputSchema.fields) {
619
+ const presentFields = sources.flatMap(source =>
620
+ source.fields
621
+ .filter(field => field.outputName === outputField.name)
622
+ .map(field => ({sourceId: source.entry.dataSourceId, field: field.field}))
623
+ );
624
+ if (!presentFields.length) {
625
+ if (!outputField.nullable) {
626
+ throw new Error(
627
+ `Federated output field is absent from every source and must be nullable: ${outputField.name}`
628
+ );
629
+ }
630
+ continue;
631
+ }
632
+ for (const presentField of presentFields) {
633
+ if (presentField.field.nullable && !outputField.nullable) {
634
+ throw new Error(
635
+ `Federated output field cannot remove nullability for ${outputField.name} in ${presentField.sourceId}`
636
+ );
637
+ }
638
+ if (!isLosslessDataTypeCast(presentField.field.type, outputField.type)) {
639
+ throw new Error(
640
+ `Unsupported federated normalization for ${outputField.name}: ${formatDataType(presentField.field.type)} to ${formatDataType(outputField.type)} in ${presentField.sourceId}`
641
+ );
642
+ }
643
+ }
644
+ if (presentFields.length < sources.length && !outputField.nullable) {
645
+ throw new Error(
646
+ `Federated union field must be nullable when absent from a source: ${outputField.name}`
647
+ );
648
+ }
649
+ }
650
+ return cloneSchema(outputSchema);
651
+ }
652
+
653
+ /** Creates the source-local query required by the global canonical scan step. */
654
+ function createSourceReadOptions(
655
+ source: ResolvedFederatedSource,
656
+ requiredOutputColumns: readonly string[],
657
+ remaining: number,
658
+ options: TableScanReadOptions<SQLPredicate>,
659
+ onTelemetry: (telemetry: ScanExecutionTelemetry) => void
660
+ ): TableScanReadOptions<SQLPredicate> {
661
+ const sourceResidualPredicate = getSourceResidualPredicate(source);
662
+ const requiredSourceColumns = requiredOutputColumns
663
+ .map(outputName => source.sourceNameByOutputName.get(outputName))
664
+ .filter((sourceName): sourceName is string => Boolean(sourceName));
665
+ if (sourceResidualPredicate) {
666
+ requiredSourceColumns.push(...getColumnarPredicateColumns(sourceResidualPredicate));
667
+ }
668
+ if (!requiredSourceColumns.length) {
669
+ requiredSourceColumns.push(source.fields[0].sourceName);
670
+ }
671
+ const sourceLimit = source.entry.query?.limit;
672
+ const globalLimitCanBePushed = options.predicate === undefined && Number.isFinite(remaining);
673
+ const limit = globalLimitCanBePushed
674
+ ? Math.min(sourceLimit ?? Number.POSITIVE_INFINITY, remaining)
675
+ : sourceLimit;
676
+ return {
677
+ ...source.entry.query,
678
+ columns: Object.freeze([...new Set(requiredSourceColumns)]),
679
+ predicate: sourceResidualPredicate ? undefined : source.entry.query?.predicate,
680
+ limit: sourceResidualPredicate ? undefined : Number.isFinite(limit) ? limit : undefined,
681
+ signal: options.signal,
682
+ onTelemetry
683
+ };
684
+ }
685
+
686
+ /** Returns the source-local predicate when the child requires residual execution. */
687
+ function getSourceResidualPredicate(source: ResolvedFederatedSource): SQLPredicate | undefined {
688
+ return source.metadata.capabilities.table?.predicate === 'unsupported'
689
+ ? source.entry.query?.predicate
690
+ : undefined;
691
+ }
692
+
693
+ /** Converts one physical batch to the reconciled Arrow schema, renaming and null-filling fields. */
694
+ function createCanonicalArrowTable(
695
+ schema: Schema,
696
+ source: ResolvedFederatedSource,
697
+ physicalTable: ArrowTable
698
+ ): ArrowTable {
699
+ const arrowSchema = convertSchemaToArrow(schema);
700
+ const columns: Record<string, arrow.Vector> = {};
701
+ for (const field of arrowSchema.fields) {
702
+ const sourceName = source.sourceNameByOutputName.get(field.name);
703
+ const vector = sourceName ? physicalTable.data.getChild(sourceName) : null;
704
+ const mappedField = source.fields.find(sourceField => sourceField.outputName === field.name);
705
+ columns[field.name] = vector
706
+ ? mappedField &&
707
+ !areDataTypesEqual(
708
+ mappedField.field.type,
709
+ schema.fields.find(schemaField => schemaField.name === field.name)!.type
710
+ )
711
+ ? arrow.vectorFromArray(Array.from(vector), field.type)
712
+ : vector
713
+ : arrow.vectorFromArray(
714
+ Array.from({length: physicalTable.data.numRows}, () => null),
715
+ field.type
716
+ );
717
+ }
718
+ return {
719
+ shape: 'arrow-table',
720
+ schema,
721
+ data: new arrow.Table(arrowSchema, columns)
722
+ };
723
+ }
724
+
725
+ /** Creates one portable per-source telemetry record and preserves source-specific counters. */
726
+ function createSourceTelemetry(
727
+ options: Readonly<{
728
+ resolvedSource: ResolvedFederatedSource;
729
+ childTelemetry?: ScanExecutionTelemetry;
730
+ sourceStartedAt: number;
731
+ sourceCompleted: boolean;
732
+ sourceError?: unknown;
733
+ signal?: AbortSignal;
734
+ batchesDecoded: number;
735
+ rowsRead: number;
736
+ rowsTested: number;
737
+ rowsRetained: number;
738
+ rowsReturned: number;
739
+ }>
740
+ ): ScanSourceExecutionTelemetry {
741
+ const {resolvedSource, childTelemetry} = options;
742
+ const status: ScanExecutionTelemetryStatus =
743
+ childTelemetry?.status ||
744
+ (options.signal?.aborted
745
+ ? 'cancelled'
746
+ : options.sourceError
747
+ ? 'failed'
748
+ : options.sourceCompleted
749
+ ? 'completed'
750
+ : 'early-terminated');
751
+ const details = childTelemetry
752
+ ? Object.freeze(
753
+ Object.fromEntries(
754
+ Object.entries(childTelemetry).filter(
755
+ ([key]) =>
756
+ ![
757
+ 'status',
758
+ 'sourcesPlanned',
759
+ 'sourcesRead',
760
+ 'batchesRead',
761
+ 'batchesDecoded',
762
+ 'rowsRead',
763
+ 'rowsTested',
764
+ 'rowsRetained',
765
+ 'rowsReturned',
766
+ 'bytesRead',
767
+ 'bytesFetched',
768
+ 'filesOpened',
769
+ 'tasksOpened',
770
+ 'rowsPruned',
771
+ 'durationMilliseconds',
772
+ 'sources',
773
+ 'details',
774
+ 'error'
775
+ ].includes(key)
776
+ )
777
+ )
778
+ )
779
+ : undefined;
780
+ return Object.freeze({
781
+ sourceId: resolvedSource.entry.dataSourceId,
782
+ sourceType: resolvedSource.metadata.sourceType,
783
+ sourceIndex: resolvedSource.sourceIndex,
784
+ status,
785
+ filesOpened: childTelemetry?.filesOpened,
786
+ tasksOpened: childTelemetry?.tasksOpened,
787
+ bytesFetched: childTelemetry?.bytesFetched ?? childTelemetry?.bytesRead,
788
+ batchesDecoded: childTelemetry?.batchesDecoded ?? options.batchesDecoded,
789
+ rowsRead: childTelemetry?.rowsRead ?? options.rowsRead,
790
+ rowsTested: childTelemetry?.rowsTested || options.rowsTested || undefined,
791
+ rowsRetained: childTelemetry?.rowsRetained || options.rowsRetained || undefined,
792
+ rowsReturned: options.rowsReturned,
793
+ rowsPruned: childTelemetry?.rowsPruned,
794
+ durationMilliseconds: Date.now() - options.sourceStartedAt,
795
+ details:
796
+ childTelemetry?.details ?? (details && Object.keys(details).length ? details : undefined),
797
+ error: childTelemetry?.error ?? options.sourceError
798
+ });
799
+ }
800
+
801
+ /** Returns exact aggregate row count only when child-local filters and limits cannot change it. */
802
+ function getFederatedStatistics(
803
+ sources: readonly ResolvedFederatedSource[]
804
+ ): {rowCount?: number | bigint} | undefined {
805
+ if (
806
+ sources.some(
807
+ source =>
808
+ source.entry.query?.predicate ||
809
+ source.entry.query?.limit !== undefined ||
810
+ source.metadata.statistics?.rowCount === undefined
811
+ )
812
+ ) {
813
+ return undefined;
814
+ }
815
+ const counts = sources.map(source => source.metadata.statistics!.rowCount!);
816
+ return {
817
+ rowCount: counts.some(count => typeof count === 'bigint')
818
+ ? counts.reduce<bigint>((sum, count) => sum + BigInt(count), 0n)
819
+ : counts.reduce<number>((sum, count) => sum + Number(count), 0)
820
+ };
821
+ }
822
+
823
+ /** Creates serializable per-source explanation details. */
824
+ function createSourceExplanation(
825
+ source: ResolvedFederatedSource,
826
+ outputSchema: Schema
827
+ ): FederatedTableSourceExplain {
828
+ const normalizedTypes = Object.fromEntries(
829
+ source.fields
830
+ .filter(field => {
831
+ const outputField = outputSchema.fields.find(
832
+ schemaField => schemaField.name === field.outputName
833
+ );
834
+ return outputField && !areDataTypesEqual(outputField.type, field.field.type);
835
+ })
836
+ .map(field => [
837
+ field.outputName,
838
+ outputSchema.fields.find(schemaField => schemaField.name === field.outputName)!.type
839
+ ])
840
+ );
841
+ return Object.freeze({
842
+ sourceId: source.entry.dataSourceId,
843
+ sourceIndex: source.sourceIndex,
844
+ sourceType: source.metadata.sourceType,
845
+ sourceColumns: Object.freeze(source.fields.map(field => field.sourceName)),
846
+ outputColumns: Object.freeze(source.fields.map(field => field.outputName)),
847
+ columnMapping: Object.freeze({...source.entry.columnMapping}),
848
+ normalizedTypes: Object.freeze(normalizedTypes)
849
+ });
850
+ }
851
+
852
+ /** Returns true when Arrow can normalize values without narrowing or changing their domain. */
853
+ function isLosslessDataTypeCast(sourceType: DataType, outputType: DataType): boolean {
854
+ if (areDataTypesEqual(sourceType, outputType) || sourceType === 'null') return true;
855
+ if (typeof sourceType === 'object' && sourceType.type === 'dictionary') {
856
+ return isLosslessDataTypeCast(sourceType.dictionary, outputType);
857
+ }
858
+ if (sourceType === 'utf8-view' && outputType === 'utf8') return true;
859
+ if (sourceType === 'binary-view' && outputType === 'binary') return true;
860
+ const sourceInteger = getIntegerType(sourceType);
861
+ const outputInteger = getIntegerType(outputType);
862
+ if (sourceInteger && outputInteger) {
863
+ return (
864
+ (sourceInteger.signed === outputInteger.signed && sourceInteger.bits <= outputInteger.bits) ||
865
+ (!sourceInteger.signed && outputInteger.signed && sourceInteger.bits < outputInteger.bits)
866
+ );
867
+ }
868
+ const sourceFloatBits = getFloatBits(sourceType);
869
+ const outputFloatBits = getFloatBits(outputType);
870
+ if (sourceFloatBits && outputFloatBits) return sourceFloatBits <= outputFloatBits;
871
+ return Boolean(sourceInteger && sourceInteger.bits <= 32 && outputFloatBits === 64);
872
+ }
873
+
874
+ /** Returns the signedness and width for portable integer aliases. */
875
+ function getIntegerType(dataType: DataType): {signed: boolean; bits: number} | null {
876
+ const types: Partial<Record<string, {signed: boolean; bits: number}>> = {
877
+ int: {signed: true, bits: 32},
878
+ int8: {signed: true, bits: 8},
879
+ int16: {signed: true, bits: 16},
880
+ int32: {signed: true, bits: 32},
881
+ int64: {signed: true, bits: 64},
882
+ uint8: {signed: false, bits: 8},
883
+ uint16: {signed: false, bits: 16},
884
+ uint32: {signed: false, bits: 32},
885
+ uint64: {signed: false, bits: 64}
886
+ };
887
+ return typeof dataType === 'string' ? types[dataType] || null : null;
888
+ }
889
+
890
+ /** Returns the width for portable floating-point aliases. */
891
+ function getFloatBits(dataType: DataType): number | null {
892
+ const types: Partial<Record<string, number>> = {float: 32, float16: 16, float32: 32, float64: 64};
893
+ return typeof dataType === 'string' ? types[dataType] || null : null;
894
+ }
895
+
896
+ /** Clones a portable schema through the canonical Arrow serializer. */
897
+ function cloneSchema(schema: Schema): Schema {
898
+ return convertArrowToSchema(convertSchemaToArrow(schema));
899
+ }
900
+
901
+ /** Compares portable data types, including nested types and typed union identifiers. */
902
+ function areDataTypesEqual(left: DataType, right: DataType): boolean {
903
+ return formatDataType(left) === formatDataType(right);
904
+ }
905
+
906
+ /** Serializes a portable data type for diagnostics and deterministic equality checks. */
907
+ function formatDataType(dataType: DataType): string {
908
+ return typeof dataType === 'string' ? dataType : JSON.stringify(canonicalizeValue(dataType));
909
+ }
910
+
911
+ /** Canonicalizes object keys while preserving array and typed-array element order. */
912
+ function canonicalizeValue(value: unknown): unknown {
913
+ if (ArrayBuffer.isView(value)) {
914
+ return Array.from(value as unknown as ArrayLike<number>);
915
+ }
916
+ if (Array.isArray(value)) {
917
+ return value.map(canonicalizeValue);
918
+ }
919
+ if (value && typeof value === 'object') {
920
+ return Object.fromEntries(
921
+ Object.entries(value)
922
+ .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
923
+ .map(([key, child]) => [key, canonicalizeValue(child)])
924
+ );
925
+ }
926
+ return value;
927
+ }
928
+
929
+ /** Resolves a current, asynchronous, or deferred source through one manager subscription. */
930
+ function resolveManagedSource(
931
+ dataSourceManager: DataSourceManager,
932
+ dataSourceId: string,
933
+ consumerId: string,
934
+ requestId: string,
935
+ signal?: AbortSignal
936
+ ): Promise<ManagedTableScanSource> {
937
+ let resolveDeferredSource!: (source: ManagedTableScanSource) => void;
938
+ let rejectDeferredSource!: (error: unknown) => void;
939
+ const deferredSource = new Promise<ManagedTableScanSource>((resolve, reject) => {
940
+ resolveDeferredSource = resolve;
941
+ rejectDeferredSource = reject;
942
+ });
943
+ let sourceGeneration = 0;
944
+ const acceptSource = (
945
+ source: ManagedTableScanSource | Promise<ManagedTableScanSource> | null
946
+ ): void => {
947
+ const generation = ++sourceGeneration;
948
+ if (source) {
949
+ void Promise.resolve(source).then(
950
+ result => {
951
+ if (generation === sourceGeneration) resolveDeferredSource(result);
952
+ },
953
+ error => {
954
+ if (generation === sourceGeneration) rejectDeferredSource(error);
955
+ }
956
+ );
957
+ }
958
+ };
959
+ const managedSource = dataSourceManager.subscribe<ManagedTableScanSource>({
960
+ dataSourceId,
961
+ consumerId,
962
+ requestId,
963
+ onChange: acceptSource
964
+ });
965
+ if (managedSource === undefined) {
966
+ throw new Error(`Federated table source is not registered: ${dataSourceId}`);
967
+ }
968
+ acceptSource(managedSource || null);
969
+ return waitForPromise(deferredSource, signal);
970
+ }
971
+
972
+ /** Waits for a managed asynchronous source while preserving operation-local cancellation. */
973
+ async function waitForPromise<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
974
+ if (!signal) return await promise;
975
+ throwIfAborted(signal);
976
+ return await new Promise<T>((resolve, reject) => {
977
+ const abort = () => reject(getAbortReason(signal));
978
+ signal.addEventListener('abort', abort, {once: true});
979
+ void promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
980
+ });
981
+ }
982
+
983
+ /** Throws the caller's abort reason between resolution, planning, and batch operations. */
984
+ function throwIfAborted(signal?: AbortSignal): void {
985
+ if (signal?.aborted) throw getAbortReason(signal);
986
+ }
987
+
988
+ /** Returns a portable cancellation reason when AbortSignal.reason is unavailable. */
989
+ function getAbortReason(signal: AbortSignal): unknown {
990
+ return signal.reason || new DOMException('Request aborted', 'AbortError');
991
+ }