Skip to content

Commit 81a222a

Browse files
authored
stream query cache refresh writes (#116)
1 parent f6fd8de commit 81a222a

6 files changed

Lines changed: 402 additions & 139 deletions

File tree

src/query/collections.js

Lines changed: 128 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import fs from 'node:fs'
33
import path from 'node:path'
44
import { QUERY_CACHE_SCHEMA_VERSION, isQueryDataset } from './schema.js'
55
import { readCacheCursor, stableFingerprint, writeCacheCursor } from './iceberg/cursor.js'
6-
import { readJsonlEntries } from './iceberg/jsonl.js'
6+
import { readJsonlEntryBatches } from './iceberg/jsonl.js'
77
import { collectionColumnsToSpecs } from './iceberg/schema.js'
88
import { 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

Comments
 (0)