UNPKG

signalk-parquet

Version:

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

1,024 lines (905 loc) 34 kB
import * as fs from 'fs-extra'; import * as path from 'path'; import { DataRecord, ParquetWriterOptions, FileFormat } from './types'; import { ServerAPI } from '@signalk/server-api'; import { SchemaService } from './schema-service'; import { DirectoryScanner } from './utils/directory-scanner'; // Try to import ParquetJS, fall back if not available // eslint-disable-next-line @typescript-eslint/no-explicit-any let parquet: any; try { // eslint-disable-next-line @typescript-eslint/no-require-imports parquet = require('@dsnp/parquetjs'); } catch (error) { parquet = null; } export class ParquetWriter { private format: FileFormat; private app?: ServerAPI; private schemaService?: SchemaService; private directoryScanner: DirectoryScanner; constructor(options: ParquetWriterOptions = { format: 'json' }) { this.format = options.format || 'json'; this.app = options.app; // Initialize schema service if app is available if (this.app) { this.schemaService = new SchemaService(this.app); } // Initialize directory scanner with 5-minute cache this.directoryScanner = new DirectoryScanner(5 * 60 * 1000); } getSchemaService(): SchemaService | undefined { return this.schemaService; } async writeRecords(filepath: string, records: DataRecord[]): Promise<string> { try { const directory = path.dirname(filepath); await fs.ensureDir(directory); let result: string; switch (this.format) { case 'json': result = await this.writeJSON(filepath, records); break; case 'csv': result = await this.writeCSV(filepath, records); break; case 'parquet': result = await this.writeParquet(filepath, records); break; default: throw new Error(`Unsupported format: ${this.format}`); } // Invalidate directory cache since we just wrote a file // Get the base data directory (go up until we find the root data dir) const baseDir = this.findBaseDataDir(directory); this.directoryScanner.invalidateCache(baseDir); return result; } catch (error) { throw new Error(`Failed to write records: ${(error as Error).message}`); } } /** * Find the base data directory from a nested path * This helps invalidate the right cache entry */ private findBaseDataDir(filePath: string): string { // Walk up the directory tree to find a reasonable cache boundary // Typically 2-3 levels up from the leaf file const current = path.dirname(filePath); const parts = current.split(path.sep); // Go up to the path level (usually 2-3 directories up) // This provides a good balance between cache granularity and performance if (parts.length > 3) { return parts.slice(0, -2).join(path.sep); } return current; } async writeJSON(filepath: string, records: DataRecord[]): Promise<string> { const jsonPath = filepath.replace(/\.(parquet|csv)$/, '.json'); await fs.writeJson(jsonPath, records, { spaces: 2 }); return jsonPath; } async writeCSV(filepath: string, records: DataRecord[]): Promise<string> { if (records.length === 0) return filepath; const csvPath = filepath.replace(/\.(parquet|json)$/, '.csv'); // Get all unique keys from all records const allKeys = new Set<string>(); records.forEach(record => { Object.keys(record).forEach(key => allKeys.add(key)); }); const headers = Array.from(allKeys).sort(); const csvRows = [headers.join(',')]; records.forEach(record => { const row = headers.map(header => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const value = (record as any)[header]; if (value === null || value === undefined) return ''; if ( typeof value === 'string' && (value.includes(',') || value.includes('"')) ) { return `"${value.replace(/"/g, '""')}"`; } return String(value); }); csvRows.push(row.join(',')); }); await fs.writeFile(csvPath, csvRows.join('\n')); return csvPath; } async writeParquet(filepath: string, records: DataRecord[]): Promise<string> { try { if (records.length === 0) { this.app?.debug('No records to write to Parquet file'); return filepath; } // Check if ParquetJS is available if (!parquet) { this.app?.debug('ParquetJS not available, falling back to JSON'); return await this.writeJSON(filepath, records); } // Extract path from records for intelligent schema detection const currentPath = records.length > 0 ? records[0].path : undefined; // Extract output directory from filepath (go up to find the base data directory) // const outputDirectory = this.extractOutputDirectory(filepath); // Extract filename prefix from filepath (everything before the date part) // const filename = path.basename(filepath, '.parquet'); // const match = filename.match(/^(.+)_\d{4}-\d{2}-\d{2}/); // const filenamePrefix = match ? match[1] : 'signalk_data'; // Check if parquet library is available if (!parquet) { throw new Error('ParquetJS not available'); } // Use intelligent schema detection for optimal data types const schema = await this.createParquetSchema(records, currentPath); // Create Parquet writer const writer = await parquet.ParquetWriter.openFile(schema, filepath); // Write records to Parquet file for (let i = 0; i < records.length; i++) { const record = records[i]; // eslint-disable-next-line @typescript-eslint/no-explicit-any const cleanRecord: { [key: string]: any } = {}; // Prepare record for typed Parquet schema const preparedRecord = this.prepareRecordForParquet(record, schema); Object.assign(cleanRecord, preparedRecord); await writer.appendRow(cleanRecord); } // Close the writer await writer.close(); // Validate the written file const isValid = await this.validateParquetFile(filepath); if (!isValid) { // Move invalid file to quarantine and log const quarantineDir = path.join(path.dirname(filepath), 'quarantine'); await fs.ensureDir(quarantineDir); const quarantineFile = path.join( quarantineDir, path.basename(filepath) ); await fs.move(filepath, quarantineFile, { overwrite: true }); await this.logQuarantine( quarantineFile, 'write', 'File failed validation after write' ); throw new Error( `Parquet file failed validation after write, moved to quarantine: ${quarantineFile}` ); } return filepath; } catch (error) { this.app?.debug(`❌ Parquet writing failed: ${(error as Error).message}`); this.app?.debug(`Error stack: ${(error as Error).stack}`); // Save to failed directory to maintain schema consistency const failedDir = path.join(path.dirname(filepath), 'failed'); await fs.ensureDir(failedDir); const failedPath = path.join( failedDir, path.basename(filepath).replace('.parquet', '_FAILED.json') ); this.app?.debug( `💾 Saving failed Parquet data as JSON to: ${failedPath}` ); this.app?.debug( '⚠️ This data will need manual conversion to maintain DuckDB schema consistency' ); await this.writeJSON(failedPath, records); // Throw error to alert system that Parquet writing is broken throw new Error( `Parquet writing failed for ${filepath}. Data saved to ${failedPath} for recovery.` ); } } /** * Write Parquet file in batches to avoid loading all records into memory. * Uses firstBatch for schema detection, then pulls subsequent batches via callback. */ async writeParquetBatched( filepath: string, firstBatch: DataRecord[], nextBatch: () => DataRecord[], currentPath?: string ): Promise<string> { try { if (firstBatch.length === 0) { this.app?.debug('No records to write to Parquet file'); return filepath; } if (!parquet) { throw new Error('ParquetJS not available'); } const schema = await this.createParquetSchema( firstBatch, currentPath || firstBatch[0].path ); const writer = await parquet.ParquetWriter.openFile(schema, filepath); // Write first batch for (const record of firstBatch) { const preparedRecord = this.prepareRecordForParquet(record, schema); await writer.appendRow({ ...preparedRecord }); } // Pull and write subsequent batches let batch = nextBatch(); while (batch.length > 0) { for (const record of batch) { const preparedRecord = this.prepareRecordForParquet(record, schema); await writer.appendRow({ ...preparedRecord }); } batch = nextBatch(); } await writer.close(); const isValid = await this.validateParquetFile(filepath); if (!isValid) { const quarantineDir = path.join(path.dirname(filepath), 'quarantine'); await fs.ensureDir(quarantineDir); const quarantineFile = path.join( quarantineDir, path.basename(filepath) ); await fs.move(filepath, quarantineFile, { overwrite: true }); await this.logQuarantine( quarantineFile, 'write', 'File failed validation after write' ); throw new Error( `Parquet file failed validation after write, moved to quarantine: ${quarantineFile}` ); } return filepath; } catch (error) { this.app?.debug( `Parquet batched writing failed: ${(error as Error).message}` ); throw error; } } // Create Parquet schema based on sample records // Now uses consolidated SchemaService // eslint-disable-next-line @typescript-eslint/no-explicit-any async createParquetSchema( records: DataRecord[], currentPath?: string ): Promise<any> { if (!this.schemaService) { throw new Error('SchemaService not available'); } const result = await this.schemaService.detectOptimalSchema( records, currentPath ); return result.schema; } // Guideline 3: Get type for empty columns using SignalK metadata and other files private async getTypeForEmptyColumn( colName: string, currentPath?: string, outputDirectory?: string, metadataCache?: Map<string, any>, _filenamePrefix?: string ): Promise<string> { this.app?.debug( ` 🔍 Empty column fallback for: ${colName} (path: ${currentPath || 'unknown'})` ); // For non-value columns, default to UTF8 if (colName !== 'value') { this.app?.debug(` ↪️ Non-value column, defaulting to UTF8`); return 'UTF8'; } // Try SignalK metadata first if (currentPath && this.app && metadataCache) { this.app?.debug( ` 🔎 Checking SignalK metadata for path: ${currentPath}` ); if (!metadataCache.has(currentPath)) { try { this.app?.debug( ` 🔍 Trying metadata lookup for path: "${currentPath}"` ); // Use app's getMetadata method let metadata = null; try { metadata = this.app.getMetadata(currentPath); this.app?.debug( ` 📡 Metadata result: ${metadata ? JSON.stringify(metadata) : 'null'}` ); } catch (error) { this.app?.debug( ` ❌ Metadata lookup error: ${(error as Error).message}` ); } metadataCache.set(currentPath, metadata); this.app?.debug( ` 📡 Retrieved metadata: ${metadata ? JSON.stringify(metadata) : 'null'}` ); } catch (error) { this.app?.debug( ` ❌ Metadata API call failed: ${(error as Error).message}` ); metadataCache.set(currentPath, null); } } else { this.app?.debug(` 💾 Using cached metadata for ${currentPath}`); } const metadata = metadataCache.get(currentPath); if (metadata && metadata.units) { // If metadata suggests numeric units (m/s, degrees, etc.), assume numeric const numericUnits = [ 'm/s', 'm', 'deg', 'rad', 'Pa', 'K', 'Hz', 'V', 'A', 'W', ]; const matchedUnit = numericUnits.find(unit => metadata.units.includes(unit) ); if (matchedUnit) { this.app?.debug( ` ✅ Metadata indicates numeric unit '${matchedUnit}', using DOUBLE` ); return 'DOUBLE'; } else { this.app?.debug( ` ↪️ Metadata has units '${metadata.units}' but not recognized as numeric` ); } } else { this.app?.debug( ` ↪️ No useful metadata found (metadata: ${!!metadata}, units: ${metadata?.units})` ); } } // Fallback to other consolidated files for the same path // Disabled to prevent errors from corrupted parquet files // if (currentPath && outputDirectory) { // this.app?.debug(` 🔎 Searching other files for path: ${currentPath}`); // const typeFromOtherFiles = this.getTypeFromOtherFiles(currentPath, outputDirectory, undefined, filenamePrefix); // if (typeFromOtherFiles) { // this.app?.debug(` ✅ Found type ${typeFromOtherFiles} from other files`); // return typeFromOtherFiles; // } else { // this.app?.debug(` ↪️ No type information found in other files`); // } // } // Final fallback to UTF8 this.app?.debug(` ✅ Final fallback to UTF8`); return 'UTF8'; } // Guideline 4: Get type for exploded value_ fields by parsing actual values private async getTypeForExplodedField( colName: string, currentPath?: string, outputDirectory?: string, values?: any[], metadataCache?: Map<string, any>, filenamePrefix?: string ): Promise<string> { this.app?.debug( ` 🧩 Exploded field analysis for: ${colName} (path: ${currentPath || 'unknown'})` ); // Always keep value_json as VARCHAR if (colName === 'value_json') { this.app?.debug(` ✅ ${colName}: UTF8 (JSON field always string)`); return 'UTF8'; } // If we have values, parse them to detect actual data types if (values && values.length > 0) { this.app?.debug(` 🧮 Parsing ${values.length} values for ${colName}`); let parsedNumbers = 0; let parsedBooleans = 0; let actualStrings = 0; let unparseable = 0; const stringValues = values.filter(v => typeof v === 'string'); for (const str of stringValues) { const trimmed = str.trim(); if (trimmed === 'true' || trimmed === 'false') { parsedBooleans++; } else if (!isNaN(Number(trimmed)) && trimmed !== '') { parsedNumbers++; } else if (trimmed === '') { unparseable++; } else { actualStrings++; } } const hasNumbers = values.some(v => typeof v === 'number') || parsedNumbers > 0; const hasStrings = values.some( v => typeof v === 'string' && v.trim() !== '' && isNaN(Number(v.trim())) && v.trim() !== 'true' && v.trim() !== 'false' ) || actualStrings > 0; const hasBooleans = values.some(v => typeof v === 'boolean') || parsedBooleans > 0; this.app?.debug( ` 🧮 ${colName}: Parsed - numbers:${parsedNumbers}, booleans:${parsedBooleans}, strings:${actualStrings}, unparseable:${unparseable}` ); this.app?.debug( ` 🧮 ${colName}: Final - hasNumbers:${hasNumbers}, hasStrings:${hasStrings}, hasBooleans:${hasBooleans}` ); if (hasNumbers && !hasStrings && !hasBooleans) { // All numbers - check if integers or floats // Always use DOUBLE for numeric maritime data (never INT64/BIGINT) const finalType = 'DOUBLE'; this.app?.debug( ` ✅ ${colName}: ${finalType} (parsed numbers, always DOUBLE for maritime data)` ); return finalType; } else if (hasBooleans && !hasNumbers && !hasStrings) { this.app?.debug(` ✅ ${colName}: BOOLEAN (parsed booleans)`); return 'BOOLEAN'; } else if ( unparseable > 0 && !hasNumbers && !hasStrings && !hasBooleans ) { // Only unparseable (empty) values - use HTTP metadata if (currentPath && metadataCache) { this.app?.debug( ` 🔍 ${colName}: Only empty values, using HTTP metadata fallback` ); const fallbackType = await this.getTypeForEmptyColumn( colName, currentPath, outputDirectory, metadataCache, filenamePrefix ); this.app?.debug( ` ✅ ${colName}: ${fallbackType} (from HTTP metadata for empty values)` ); return fallbackType; } } else if (hasStrings || actualStrings > 0) { this.app?.debug(` ✅ ${colName}: UTF8 (parsed strings)`); return 'UTF8'; } } // Fallback to field name inference if no values or unclear parsing this.app?.debug( ` ↪️ No clear type from value parsing, using field name inference` ); return this.inferTypeFromFieldName(colName); } // Helper: Search other consolidated files for type information private getTypeFromOtherFiles( currentPath: string, outputDirectory: string, specificColumn?: string, filenamePrefix?: string ): string | null { const targetColumn = specificColumn || 'value'; this.app?.debug( ` 🔍 Searching files for column '${targetColumn}' in path '${currentPath}'` ); try { // eslint-disable-next-line @typescript-eslint/no-require-imports const glob = require('glob'); const prefix = filenamePrefix || 'signalk_data'; const pathPattern = path.join( outputDirectory, 'vessels', '*', currentPath.replace(/\./g, '/'), `${prefix}_*.parquet` ); this.app?.debug(` 📁 Search pattern: ${pathPattern}`); const allFiles = glob.sync(pathPattern); // Filter out consolidated files const files = allFiles.filter( (file: string) => !file.includes('_consolidated.parquet') ); this.app?.debug( ` 📄 Found ${files.length} regular files to check (excluding consolidated)` ); for (const filePath of files) { try { this.app?.debug(` 🔎 Checking file: ${path.basename(filePath)}`); if (!parquet) { this.app?.debug(` ❌ Parquet library not available`); continue; } // Skip corrupted parquet files to prevent crashes if ( path.basename(filePath).includes('corrupted') || path.basename(filePath).includes('quarantine') ) { this.app?.debug( ` ⚠️ Skipping quarantined file: ${path.basename(filePath)}` ); continue; } try { const reader = parquet.ParquetReader.openFile(filePath); const schema = reader.schema; if (schema && schema.schema && schema.schema[targetColumn]) { const columnType = schema.schema[targetColumn].type; this.app?.debug( ` ✅ Found type ${columnType} for column '${targetColumn}' in ${path.basename(filePath)}` ); if (typeof reader.close === 'function') reader.close(); return columnType; } else { this.app?.debug( ` ↪️ Column '${targetColumn}' not found in ${path.basename(filePath)}` ); } if (typeof reader.close === 'function') reader.close(); } catch (fileError) { this.app?.debug( ` ⚠️ Corrupted file, skipping: ${path.basename(filePath)} - ${(fileError as Error).message}` ); continue; } } catch (error) { this.app?.debug( ` ❌ Error reading file ${path.basename(filePath)}: ${(error as Error).message}` ); continue; } } } catch (error) { this.app?.debug( ` ❌ File search error: ${(error as Error).message}` ); } this.app?.debug(` ❌ No type information found in any files`); return null; } // Helper: Infer type from field name patterns private inferTypeFromFieldName(fieldName: string): string { this.app?.debug(` 🏷️ Inferring type from field name: ${fieldName}`); const field = fieldName.toLowerCase(); // Coordinate fields if ( field.includes('latitude') || field.includes('longitude') || field.includes('lat') || field.includes('lon') ) { this.app?.debug(` ✅ Coordinate field detected, using DOUBLE`); return 'DOUBLE'; } // Numeric measurements if ( field.includes('speed') || field.includes('distance') || field.includes('depth') || field.includes('temperature') || field.includes('pressure') || field.includes('angle') || field.includes('bearing') || field.includes('course') || field.includes('heading') ) { this.app?.debug( ` ✅ Numeric measurement field detected, using DOUBLE` ); return 'DOUBLE'; } // Time/duration fields if ( field.includes('time') || field.includes('duration') || field.includes('age') ) { const isTimestamp = field.includes('timestamp'); const resultType = isTimestamp ? 'UTF8' : 'DOUBLE'; this.app?.debug( ` ✅ Time field detected, using ${resultType} (timestamp: ${isTimestamp})` ); return resultType; } // Default to UTF8 for unknown patterns this.app?.debug(` ✅ Unknown pattern, defaulting to UTF8`); return 'UTF8'; } // Helper: Extract output directory from filepath private extractOutputDirectory(filepath: string): string { // filepath format: /path/to/outputDir/vessels/context/path/filename.parquet // We want to extract up to the outputDir part const parts = filepath.split(path.sep); const vesselIndex = parts.findIndex(part => part === 'vessels'); if (vesselIndex > 0) { // Return everything up to but not including 'vessels' return parts.slice(0, vesselIndex).join(path.sep); } // Fallback: assume current directory structure return path.dirname(path.dirname(path.dirname(filepath))); } // Prepare a record for typed Parquet writing prepareRecordForParquet( record: DataRecord, // eslint-disable-next-line @typescript-eslint/no-explicit-any schema: any // eslint-disable-next-line @typescript-eslint/no-explicit-any ): { [key: string]: any } { // eslint-disable-next-line @typescript-eslint/no-explicit-any const cleanRecord: { [key: string]: any } = {}; // Serialize object fields to JSON strings (deferred from delta processing) // This improves performance by avoiding JSON.stringify() on every delta message const recordWithSerializedFields = { ...record }; if ( recordWithSerializedFields.source && typeof recordWithSerializedFields.source === 'object' ) { recordWithSerializedFields.source = JSON.stringify( recordWithSerializedFields.source ); } if ( recordWithSerializedFields.value_json && typeof recordWithSerializedFields.value_json === 'object' ) { recordWithSerializedFields.value_json = JSON.stringify( recordWithSerializedFields.value_json ); } if ( recordWithSerializedFields.meta && typeof recordWithSerializedFields.meta === 'object' ) { recordWithSerializedFields.meta = JSON.stringify( recordWithSerializedFields.meta ); } const schemaFields = schema.schema; Object.keys(schemaFields).forEach(fieldName => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const value = (recordWithSerializedFields as any)[fieldName]; const fieldType = schemaFields[fieldName].type; if (value === null || value === undefined) { // Use undefined instead of null - parquet handles undefined (omit field) better than null cleanRecord[fieldName] = undefined; } else if (typeof value === 'bigint') { // Handle BigInt values by converting to appropriate type switch (fieldType) { case 'DOUBLE': case 'FLOAT': cleanRecord[fieldName] = Number(value); break; case 'INT64': case 'INT32': // Convert BigInt to number if it fits in safe integer range if ( value <= Number.MAX_SAFE_INTEGER && value >= Number.MIN_SAFE_INTEGER ) { cleanRecord[fieldName] = Number(value); } else { cleanRecord[fieldName] = value.toString(); } break; case 'UTF8': default: cleanRecord[fieldName] = value.toString(); break; } } else { switch (fieldType) { case 'DOUBLE': case 'FLOAT': cleanRecord[fieldName] = typeof value === 'number' ? value : parseFloat(String(value)); break; case 'INT64': case 'INT32': cleanRecord[fieldName] = typeof value === 'number' ? Math.round(value) : parseInt(String(value)); break; case 'BOOLEAN': cleanRecord[fieldName] = typeof value === 'boolean' ? value : Boolean(value); break; case 'UTF8': default: if (typeof value === 'object') { cleanRecord[fieldName] = JSON.stringify(value); } else { cleanRecord[fieldName] = String(value); } break; } } }); return cleanRecord; } // Validate parquet file for corruption private async validateParquetFile(filepath: string): Promise<boolean> { try { if (!parquet || !(await fs.pathExists(filepath))) { return false; } // Check file size (must be > 100 bytes as per existing logic) const stats = await fs.stat(filepath); const fileSize = stats.size; if (fileSize < 100) { this.app?.debug( `❌ Parquet file too small: ${filepath} (${fileSize} bytes)` ); return false; } // Try to open and read the parquet file try { const reader = await parquet.ParquetReader.openFile(filepath); const cursor = reader.getCursor(); // Try to read first record to verify file structure const firstRecord = await cursor.next(); await reader.close(); // Log file size for debugging (matches your stat command format) this.app?.debug( `✅ Valid parquet file: ${fileSize.toString().padStart(12, ' ')} ${filepath}` ); return firstRecord !== null; } catch (readError) { this.app?.debug( `❌ Parquet file read failed: ${filepath} - ${(readError as Error).message}` ); return false; } } catch (error) { this.app?.debug( `❌ Parquet validation error: ${filepath} - ${(error as Error).message}` ); return false; } } // Log quarantined files private async logQuarantine( filepath: string, operation: string, reason: string ): Promise<void> { try { const stats = await fs.stat(filepath); const logEntry = { timestamp: new Date().toISOString(), filepath, fileSize: stats.size, operation, reason, formattedSize: `${stats.size.toString().padStart(12, ' ')} ${filepath}`, }; const quarantineDir = path.dirname(filepath); const logFile = path.join(quarantineDir, 'quarantine.log'); // Append to log file const logLine = `${logEntry.timestamp} | ${logEntry.operation} | ${logEntry.fileSize} bytes | ${logEntry.reason} | ${filepath}\n`; await fs.appendFile(logFile, logLine); this.app?.debug(`📋 Quarantine logged: ${logEntry.formattedSize}`); } catch (error) { this.app?.debug( `Failed to log quarantine entry: ${(error as Error).message}` ); } } } /** * Move undersized (`< 100B`) parquet files left behind by a crash between * ParquetWriter.openFile() — which creates a 0-byte stub on disk — and the * first appendRow()/close() that would populate it. The per-write catch * block can't run if the process is killed in that window, so we sweep * on plugin start. Files are moved to a sibling `quarantine/` dir, matching * the in-flight quarantine layout; the History API already excludes those * paths from queries. * * The sweep is incremental: a stub is a newly *created* file, which bumps its * parent directory's mtime, so directories unchanged since the last sweep are * skipped. The last-sweep time is persisted in `<baseDirectory>/.last-empty- * sweep`; the first run (no watermark) scans everything once to catch any * pre-existing stub, then later runs cost O(directories changed since the * previous start) and still catch a stub of any age after long downtime. */ export async function quarantineEmptyParquetFiles( app: ServerAPI, baseDirectory: string ): Promise<{ quarantined: number; failed: number }> { if (!(await fs.pathExists(baseDirectory))) { return { quarantined: 0, failed: 0 }; } // Persisted last-sweep timestamp. Missing/unparseable (first run after this // change) leaves the watermark at 0, so everything is scanned once to catch // pre-existing stubs; subsequent runs only inspect directories changed since. const stateFile = path.join(baseDirectory, '.last-empty-sweep'); let watermark = 0; try { const parsed = Number((await fs.readFile(stateFile, 'utf8')).trim()); if (Number.isFinite(parsed)) watermark = parsed; } catch { // No prior sweep recorded — proceed with watermark = 0 (full scan once). } const sweepStart = Date.now(); const ignoredDir = (name: string): boolean => name === 'quarantine' || name === 'failed' || name === 'repaired' || name.startsWith('.compaction-trash-'); let quarantined = 0; let failed = 0; const walk = async (dir: string): Promise<void> => { let entries: import('fs').Dirent[]; try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; // unreadable directory — skip } // A new stub bumps the mtime of the directory it was created in, so a // directory unchanged since the last sweep has no new files to check. // Subdirectories are always descended: an ancestor's mtime does NOT change // when a file is added deep in the tree. let inspectFiles = true; if (watermark > 0) { try { inspectFiles = (await fs.stat(dir)).mtimeMs > watermark; } catch { inspectFiles = true; } } for (const entry of entries) { if (entry.isDirectory()) { if (!ignoredDir(entry.name)) { await walk(path.join(dir, entry.name)); } continue; } if (!inspectFiles || !entry.name.endsWith('.parquet')) continue; const file = path.join(dir, entry.name); try { const stats = await fs.stat(file); if (stats.size >= 100) continue; const quarantineDir = path.join(dir, 'quarantine'); await fs.ensureDir(quarantineDir); const quarantinePath = path.join(quarantineDir, entry.name); await fs.move(file, quarantinePath, { overwrite: true }); const logLine = `${new Date().toISOString()} | startup-sweep | ${stats.size} bytes | Undersized parquet (likely crash between openFile and close) | ${quarantinePath}\n`; await fs.appendFile( path.join(quarantineDir, 'quarantine.log'), logLine ); quarantined++; app.debug(`Quarantined undersized parquet (${stats.size}B): ${file}`); } catch (err) { failed++; app.error( `Failed to quarantine undersized parquet ${file}: ${(err as Error).message}` ); } } }; await walk(baseDirectory); // Record the next run's cutoff: the sweep's start (not end, so files written // while it ran are re-checked), minus a slack margin so a coarse-resolution // filesystem (1s mtime) or a same-second write can't round a just-created // directory below the watermark and skip it. The overlap only re-inspects // directories touched in the ~2s around this sweep. const WATERMARK_SLACK_MS = 2000; try { await fs.writeFile( stateFile, String(sweepStart - WATERMARK_SLACK_MS), 'utf8' ); } catch (err) { app.error( `Failed to persist empty-sweep watermark ${stateFile}: ${(err as Error).message}` ); } if (quarantined > 0) { app.debug( `Parquet startup sweep: quarantined ${quarantined} undersized file(s)` ); } return { quarantined, failed }; }