UNPKG

signalk-parquet

Version:

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

237 lines (203 loc) 6.79 kB
import { Context, Path } from '@signalk/server-api'; import { DuckDBPool } from './duckdb-pool'; import * as path from 'path'; import * as fs from 'fs-extra'; import { debugLogger } from './debug-logger'; import { CACHE_TTL } from '../config/cache-defaults'; import { DirectoryScanner } from './directory-scanner'; import { HivePathBuilder } from './hive-path-builder'; /** * Schema information for an object-valued path */ export interface PathComponentSchema { components: Map<string, ComponentInfo>; // component name -> type info timestamp: number; } export interface ComponentInfo { name: string; // e.g., "latitude", "longitude", "altitude" columnName: string; // e.g., "value_latitude", "value_longitude" dataType: 'numeric' | 'string' | 'boolean' | 'unknown'; } /** * Cache for path component schemas * Key: `${context}:${path}` */ const schemaCache = new Map<string, PathComponentSchema>(); /** * Directory scanner for finding parquet files * Reused across multiple schema discovery operations */ const directoryScanner = new DirectoryScanner(); /** * Hive path builder for constructing Hive-style paths */ const hivePathBuilder = new HivePathBuilder(); /** * Get the component schema for an object-valued path across all parquet files * Returns the union of all value_* columns found in any file for this path * Uses Hive-partitioned directory structure: tier=raw/context=.../path=.../ */ export async function getPathComponentSchema( dataDir: string, context: Context, pathStr: Path ): Promise<PathComponentSchema | null> { const cacheKey = `${context}:${pathStr}`; const now = Date.now(); // Check cache first const cached = schemaCache.get(cacheKey); if (cached && now - cached.timestamp < CACHE_TTL.SCHEMA) { return cached; } try { // Build Hive-style path for this context and path // Default to 'raw' tier for schema discovery const sanitizedContext = hivePathBuilder.sanitizeContext(context); const sanitizedPath = hivePathBuilder.sanitizePath(pathStr); const pathDir = path.join( dataDir, 'tier=raw', `context=${sanitizedContext}`, `path=${sanitizedPath}` ); if (!(await fs.pathExists(pathDir))) { return null; } // Recursively find all .parquet files const parquetFiles = await findParquetFiles(pathDir); if (parquetFiles.length === 0) { return null; } // Query schemas from all files to get union of components const allComponents = new Map<string, ComponentInfo>(); // Get connection from pool const connection = await DuckDBPool.getConnection(); try { for (const filePath of parquetFiles) { try { // First check if this file has a 'value' column const valueColQuery = ` SELECT name FROM parquet_schema('${filePath.replace(/'/g, "''")}') WHERE name = 'value' `; const valueColResult = await connection.runAndReadAll(valueColQuery); const hasValueColumn = valueColResult.getRowObjects().length > 0; // If 'value' column exists, skip this file - it's a scalar path if (hasValueColumn) { continue; } // Query the parquet schema for data component columns // Exclude metadata columns like value_units, value_description, value_json const schemaQuery = ` SELECT name, type FROM parquet_schema('${filePath.replace(/'/g, "''")}') WHERE name LIKE 'value_%' AND name NOT IN ('value_json', 'value_units', 'value_description', 'value_age') `; const result = await connection.runAndReadAll(schemaQuery); const rows = result.getRowObjects() as Array<{ name: string; type: string; }>; rows.forEach(row => { const columnName = row.name; const columnType = row.type; const componentName = columnName.replace(/^value_/, ''); // Skip if we already have this component if (allComponents.has(componentName)) { return; } // Determine data type category const dataType = inferDataTypeCategory(columnType); allComponents.set(componentName, { name: componentName, columnName: columnName, dataType: dataType, }); }); } catch (error) { // Skip files with errors (corrupted, etc.) debugLogger.warn( `[Schema Cache] Error reading schema from ${filePath}:`, error ); } } } finally { connection.disconnectSync(); } if (allComponents.size === 0) { // No value_* columns found - this is a simple scalar path return null; } const schema: PathComponentSchema = { components: allComponents, timestamp: now, }; // Cache it schemaCache.set(cacheKey, schema); return schema; } catch (error) { debugLogger.error( `[Schema Cache] Error getting schema for ${pathStr}:`, error ); return null; } } /** * Clear the schema cache (useful for testing or when data structure changes) */ export function clearSchemaCache(): void { schemaCache.clear(); debugLogger.log('[Schema Cache] Schema cache cleared'); } /** * Infer data type category from DuckDB type string */ export function inferDataTypeCategory( duckdbType: string ): ComponentInfo['dataType'] { const typeUpper = duckdbType.toUpperCase(); // Numeric types if ( typeUpper.includes('INT') || typeUpper.includes('DOUBLE') || typeUpper.includes('FLOAT') || typeUpper.includes('DECIMAL') || typeUpper.includes('NUMERIC') || typeUpper.includes('REAL') || typeUpper.includes('BIGINT') || typeUpper.includes('SMALLINT') || typeUpper.includes('TINYINT') ) { return 'numeric'; } // String types if ( typeUpper.includes('VARCHAR') || typeUpper.includes('CHAR') || typeUpper.includes('TEXT') || typeUpper.includes('STRING') || typeUpper.includes('UTF8') || typeUpper.includes('BYTE_ARRAY') ) { return 'string'; } // Boolean if (typeUpper.includes('BOOL')) { return 'boolean'; } return 'unknown'; } /** * Recursively find all .parquet files in a directory * Uses DirectoryScanner for cached, efficient file discovery */ async function findParquetFiles(dir: string): Promise<string[]> { // Use DirectoryScanner with pattern matching for .parquet files const fileInfos = await directoryScanner.scanDirectory(dir, /\.parquet$/); // Convert FileInfo[] to string[] for compatibility return fileInfos.map(f => f.path); }