@@ -3,7 +3,7 @@ import fs from 'node:fs'
33import path from 'node:path'
44import { QUERY_CACHE_SCHEMA_VERSION , isQueryDataset } from './schema.js'
55import { readCacheCursor , stableFingerprint , writeCacheCursor } from './iceberg/cursor.js'
6- import { readJsonlEntries } from './iceberg/jsonl.js'
6+ import { readJsonlEntryBatches } from './iceberg/jsonl.js'
77import { collectionColumnsToSpecs } from './iceberg/schema.js'
88import { appendRowsToTable , queryCacheTableExists , queryCacheTableUrl } from './iceberg/store.js'
99
@@ -450,31 +450,45 @@ async function materializeCollectionIncremental(partition, force) {
450450 let reset = force || ! existing || existing . cache_schema_version !== QUERY_CACHE_SCHEMA_VERSION
451451 if ( existing && partition . sourceSize < existing . byte_offset ) reset = true
452452
453- let read = readJsonlEntries (
454- partition . jsonlPath ,
455- reset ? 0 : existing ?. byte_offset ?? 0 ,
456- reset ? 0 : existing ?. line_number ?? 0
457- )
458453 let columns = existing ?. columns
459454 let timestampColumn = existing ?. timestamp_column
455+ let startByteOffset = reset ? 0 : existing ?. byte_offset ?? 0
456+ let startLineNumber = reset ? 0 : existing ?. line_number ?? 0
460457 if ( ! reset && existing ) {
461- const nextColumns = inferCollectionColumns ( read . entries . map ( ( entry ) => entry . raw ) , partition . collection . timestamp_column )
458+ const nextColumns = await inferCollectionColumnsFromJsonl (
459+ partition . jsonlPath ,
460+ partition . collection . timestamp_column ,
461+ startByteOffset ,
462+ startLineNumber
463+ )
462464 if ( ! columnsCompatible ( existing . columns , nextColumns ) ) {
463465 reset = true
464- read = readJsonlEntries ( partition . jsonlPath , 0 , 0 )
466+ startByteOffset = 0
467+ startLineNumber = 0
465468 }
466469 }
467470 if ( reset || ! columns ) {
468- columns = inferCollectionColumns ( read . entries . map ( ( entry ) => entry . raw ) , partition . collection . timestamp_column )
471+ columns = await inferCollectionColumnsFromJsonl ( partition . jsonlPath , partition . collection . timestamp_column , 0 , 0 )
469472 timestampColumn = resolveTimestampColumn ( columns , partition . collection . timestamp_column )
470473 }
474+ const materializedColumns = columns
471475
472476 const epoch = reset ? ( existing ?. source_epoch ?? - 1 ) + 1 : existing ?. source_epoch ?? 0
473477 const tablePath = reset ? path . join ( partition . cachePath , `epoch=${ epoch } ` ) : existing ?. table_path ?? partition . tablePath
474478 const tableUrl = queryCacheTableUrl ( tablePath )
475- const rows = read . entries . map ( ( entry ) => materializeRow ( entry , columns , partition . jsonlPath , sourceId , epoch ) )
476- if ( ! reset && read . nextByteOffset === existing ?. byte_offset && rows . length === 0 ) return { rows : 0 }
477- await appendRowsToTable ( tablePath , collectionColumnsToSpecs ( columns ) , rows )
479+ const columnSpecs = collectionColumnsToSpecs ( materializedColumns )
480+ let rowsWritten = 0
481+ const read = await readJsonlEntryBatches (
482+ partition . jsonlPath ,
483+ { startByteOffset, startLineNumber } ,
484+ async ( batch ) => {
485+ const rows = batch . entries . map ( ( entry ) => materializeRow ( entry , materializedColumns , partition . jsonlPath , sourceId , epoch ) )
486+ await appendRowsToTable ( tablePath , columnSpecs , rows )
487+ rowsWritten += rows . length
488+ }
489+ )
490+ if ( ! reset && read . nextByteOffset === existing ?. byte_offset && rowsWritten === 0 ) return { rows : 0 }
491+ if ( rowsWritten === 0 ) await appendRowsToTable ( tablePath , columnSpecs , [ ] )
478492 writeCacheCursor ( partition . cursorPath , {
479493 cache_schema_version : QUERY_CACHE_SCHEMA_VERSION ,
480494 kind : 'collection' ,
@@ -489,13 +503,13 @@ async function materializeCollectionIncremental(partition, force) {
489503 source_mtime_ms : read . fileMtimeMs ,
490504 byte_offset : read . nextByteOffset ,
491505 line_number : read . nextLineNumber ,
492- row_count : ( reset ? 0 : existing ?. row_count ?? 0 ) + rows . length ,
493- schema_fingerprint : stableFingerprint ( columns ) ,
506+ row_count : ( reset ? 0 : existing ?. row_count ?? 0 ) + rowsWritten ,
507+ schema_fingerprint : stableFingerprint ( materializedColumns ) ,
494508 refreshed_at : new Date ( ) . toISOString ( ) ,
495- columns,
509+ columns : materializedColumns ,
496510 ...( timestampColumn ? { timestamp_column : timestampColumn } : { } ) ,
497511 } )
498- return { rows : rows . length }
512+ return { rows : rowsWritten }
499513}
500514
501515/**
@@ -613,62 +627,122 @@ function walkDir(dir, onFile) {
613627}
614628
615629/**
616- * @param {Record< string, unknown>[] } rawRows
630+ * @param {string } filePath
617631 * @param {string | undefined } requestedTimestampColumn
618- * @returns {CollectionColumnMeta[] }
632+ * @param {number } startByteOffset
633+ * @param {number } startLineNumber
634+ * @returns {Promise<CollectionColumnMeta[]> }
635+ */
636+ async function inferCollectionColumnsFromJsonl ( filePath , requestedTimestampColumn , startByteOffset , startLineNumber ) {
637+ const inference = createCollectionColumnInference ( requestedTimestampColumn )
638+ await readJsonlEntryBatches (
639+ filePath ,
640+ { startByteOffset, startLineNumber } ,
641+ ( batch ) => {
642+ for ( const entry of batch . entries ) inference . observe ( entry . raw )
643+ }
644+ )
645+ return inference . columns ( )
646+ }
647+
648+ /**
649+ * @param {string | undefined } requestedTimestampColumn
650+ * @returns {{
651+ * observe: (raw: Record<string, unknown>) => void,
652+ * columns: () => CollectionColumnMeta[],
653+ * }}
619654 */
620- function inferCollectionColumns ( rawRows , requestedTimestampColumn ) {
655+ function createCollectionColumnInference ( requestedTimestampColumn ) {
621656 /** @type {CollectionColumnMeta[] } */
622- const columns = [ ...META_COLUMNS ]
623- /** @type {Map<string, { sourceField: string, values: unknown[], present: number, nullable: boolean }> } */
657+ const baseColumns = [ ...META_COLUMNS ]
658+ /** @type {Map<string, {
659+ * sourceField: string,
660+ * present: number,
661+ * nullable: boolean,
662+ * hasValue: boolean,
663+ * allBooleans: boolean,
664+ * allNumbers: boolean,
665+ * allStrings: boolean,
666+ * allTimestamps: boolean,
667+ * }>} */
624668 const stats = new Map ( )
625- /** @type {Set<string> } */
626- const usedNames = new Set ( columns . map ( ( column ) => column . name ) )
627-
628- for ( const raw of rawRows ) {
629- for ( const [ sourceField , value ] of Object . entries ( raw ) ) {
630- let stat = stats . get ( sourceField )
631- if ( ! stat ) {
632- stat = { sourceField, values : [ ] , present : 0 , nullable : false }
633- stats . set ( sourceField , stat )
634- }
635- stat . present ++
636- if ( value === undefined || value === null ) stat . nullable = true
637- else stat . values . push ( value )
638- }
639- }
669+ let rows = 0
640670
641- for ( const stat of stats . values ( ) ) {
642- const baseName = normalizeColumnName ( stat . sourceField )
643- const name = uniqueName ( baseName , usedNames )
644- usedNames . add ( name )
645- columns . push ( {
646- name,
647- source_field : stat . sourceField ,
648- type : inferColumnType ( stat . sourceField , name , stat . values , requestedTimestampColumn ) ,
649- nullable : stat . nullable || stat . present < rawRows . length ,
650- } )
671+ return {
672+ observe ( raw ) {
673+ rows ++
674+ for ( const [ sourceField , value ] of Object . entries ( raw ) ) {
675+ let stat = stats . get ( sourceField )
676+ if ( ! stat ) {
677+ stat = {
678+ sourceField,
679+ present : 0 ,
680+ nullable : false ,
681+ hasValue : false ,
682+ allBooleans : true ,
683+ allNumbers : true ,
684+ allStrings : true ,
685+ allTimestamps : true ,
686+ }
687+ stats . set ( sourceField , stat )
688+ }
689+ stat . present ++
690+ if ( value === undefined || value === null ) {
691+ stat . nullable = true
692+ continue
693+ }
694+ stat . hasValue = true
695+ if ( typeof value !== 'boolean' ) stat . allBooleans = false
696+ if ( typeof value !== 'number' || ! Number . isFinite ( value ) ) stat . allNumbers = false
697+ if ( typeof value !== 'string' ) stat . allStrings = false
698+ if ( ! isTimestampValue ( value ) ) stat . allTimestamps = false
699+ }
700+ } ,
701+ columns ( ) {
702+ /** @type {CollectionColumnMeta[] } */
703+ const columns = [ ...baseColumns ]
704+ /** @type {Set<string> } */
705+ const usedNames = new Set ( columns . map ( ( column ) => column . name ) )
706+
707+ for ( const stat of stats . values ( ) ) {
708+ const baseName = normalizeColumnName ( stat . sourceField )
709+ const name = uniqueName ( baseName , usedNames )
710+ usedNames . add ( name )
711+ columns . push ( {
712+ name,
713+ source_field : stat . sourceField ,
714+ type : inferColumnType ( stat . sourceField , name , stat , requestedTimestampColumn ) ,
715+ nullable : stat . nullable || stat . present < rows ,
716+ } )
717+ }
718+ return columns
719+ } ,
651720 }
652- return columns
653721}
654722
655723/**
656724 * @param {string } sourceField
657725 * @param {string } columnName
658- * @param {unknown[] } values
726+ * @param {{
727+ * hasValue: boolean,
728+ * allBooleans: boolean,
729+ * allNumbers: boolean,
730+ * allStrings: boolean,
731+ * allTimestamps: boolean,
732+ * }} stat
659733 * @param {string | undefined } requestedTimestampColumn
660734 * @returns {CollectionColumnMeta['type'] }
661735 */
662- function inferColumnType ( sourceField , columnName , values , requestedTimestampColumn ) {
663- if ( values . length === 0 ) return 'JSON'
736+ function inferColumnType ( sourceField , columnName , stat , requestedTimestampColumn ) {
737+ if ( ! stat . hasValue ) return 'JSON'
664738 const requested = requestedTimestampColumn && (
665739 requestedTimestampColumn === sourceField ||
666740 normalizeColumnName ( requestedTimestampColumn ) === columnName
667741 )
668- if ( ( requested || isTimestampCandidate ( sourceField ) || isTimestampCandidate ( columnName ) ) && values . every ( isTimestampValue ) ) return 'TIMESTAMP'
669- if ( values . every ( ( value ) => typeof value === 'boolean' ) ) return 'BOOLEAN'
670- if ( values . every ( ( value ) => typeof value === 'number' && Number . isFinite ( value ) ) ) return 'DOUBLE'
671- if ( values . every ( ( value ) => typeof value === 'string' ) ) return 'STRING'
742+ if ( ( requested || isTimestampCandidate ( sourceField ) || isTimestampCandidate ( columnName ) ) && stat . allTimestamps ) return 'TIMESTAMP'
743+ if ( stat . allBooleans ) return 'BOOLEAN'
744+ if ( stat . allNumbers ) return 'DOUBLE'
745+ if ( stat . allStrings ) return 'STRING'
672746 return 'JSON'
673747}
674748
0 commit comments