UNPKG

signalk-parquet

Version:

Vessel data Parquet file archive with automated value and geospatial triggers. History API compliant with cloud backups and queries.

159 lines (145 loc) 5.43 kB
/** * Stage SQLite buffer rows into a DuckDB TEMP table for federated queries. * * The buffer must never be opened by DuckDB's bundled SQLite (ATTACH ... TYPE SQLITE): * two SQLite libraries in one process cannot see each other's POSIX advisory locks, * so DuckDB's copy would treat the live database as unused, run WAL recovery, and * truncate the -shm file under node:sqlite's active mmap — crashing the server with * SIGBUS on the next large write transaction. Instead, rows are read through the * buffer's own node:sqlite connection and copied into a per-connection DuckDB temp * table, which the federated SQL unions with parquet exactly as before. */ import { DuckDBConnection } from '@duckdb/node-api'; import { pathToTableName } from './sqlite-buffer'; /** The minimal slice of SQLiteBuffer that staging needs. */ export interface BufferStagingSource { getTableSchema( signalkPath: string ): Array<{ name: string; type: string }> | undefined; getRowsForFederation( signalkPath: string, context: string, fromIso: string, toIso: string, afterId: number, limit: number ): Array<Record<string, unknown>>; } /** Rows copied per read/append cycle — bounds JS heap regardless of window size. */ const STAGING_BATCH_SIZE = 5000; /** Hard cap on staged rows per query, far above any real buffer window. */ const MAX_STAGED_ROWS = 1_000_000; /** Map a declared SQLite column type to the DuckDB column type for staging. */ function duckdbTypeFor(sqliteType: string): 'BIGINT' | 'DOUBLE' | 'VARCHAR' { const t = sqliteType.toUpperCase(); if (t.includes('INT')) return 'BIGINT'; if (t.includes('REAL') || t.includes('FLOA') || t.includes('DOUB')) { return 'DOUBLE'; } return 'VARCHAR'; } /** * Copy the buffer rows a query needs (one path, one context, time-windowed, * unexported only) into a TEMP table on the given DuckDB connection. * * Returns the qualified temp table name to use in FROM clauses, or null when * the path has no buffer table or no rows match (callers skip the UNION ALL, * as they did when ATTACH-era subqueries returned null). * * The temp table carries all buffer columns, so the existing subquery WHERE * clauses (context/time/exported/filters) remain valid against it. */ export async function stageBufferTable( connection: DuckDBConnection, buffer: BufferStagingSource, context: string, signalkPath: string, fromIso: string, toIso: string, warn?: (msg: string) => void ): Promise<string | null> { const schema = buffer.getTableSchema(signalkPath); if (!schema || schema.length === 0) { return null; } const tableName = `stage_${pathToTableName(signalkPath)}`; const qualifiedName = `temp.main.${tableName}`; const columnTypes = schema.map(col => duckdbTypeFor(col.type)); const columnDefs = schema .map((col, i) => `"${col.name}" ${columnTypes[i]}`) .join(', '); await connection.run( `CREATE OR REPLACE TEMP TABLE ${tableName} (${columnDefs})` ); const appender = await connection.createAppender(tableName, 'main', 'temp'); let totalRows = 0; let afterId = 0; try { for (;;) { const rows = buffer.getRowsForFederation( signalkPath, context, fromIso, toIso, afterId, STAGING_BATCH_SIZE ); if (rows.length === 0) break; for (const row of rows) { for (let i = 0; i < schema.length; i++) { const value = row[schema[i].name]; if (value === null || value === undefined) { appender.appendNull(); } else if (columnTypes[i] === 'BIGINT') { // SQLite columns are dynamically typed — tolerate stray non-integer content const num = typeof value === 'bigint' ? value : Number(value); if (typeof num === 'bigint') { appender.appendBigInt(num); } else if (Number.isFinite(num)) { appender.appendBigInt(BigInt(Math.trunc(num))); } else { appender.appendNull(); } } else if (columnTypes[i] === 'DOUBLE') { const num = Number(value); if (Number.isFinite(num)) { appender.appendDouble(num); } else { appender.appendNull(); } } else { appender.appendVarchar(String(value)); } } appender.endRow(); } totalRows += rows.length; afterId = Number(rows[rows.length - 1].id); if (totalRows >= MAX_STAGED_ROWS) { // Only fail on genuine overflow — landing exactly on the cap is fine const overflow = buffer.getRowsForFederation( signalkPath, context, fromIso, toIso, afterId, 1 ); if (overflow.length === 0) break; warn?.( `[buffer-staging] ${signalkPath}: staged row cap reached (${MAX_STAGED_ROWS}); ` + `buffer data after id ${afterId} would be omitted from this query` ); throw new Error( `[buffer-staging] ${signalkPath}: buffer rows exceed the staged row cap ` + `(${MAX_STAGED_ROWS}); refusing to answer with incomplete data` ); } if (rows.length < STAGING_BATCH_SIZE) break; } appender.flushSync(); } finally { appender.closeSync(); } return totalRows > 0 ? qualifiedName : null; }