signalk-parquet
Version:
Vessel data Parquet file archive with automated value and geospatial triggers. History API compliant with cloud backups and queries.
552 lines (486 loc) • 15.8 kB
text/typescript
/**
* Parquet Export Service
*
* Handles periodic export of data from SQLite buffer to Parquet files.
* Provides crash recovery by checking for pending records on startup.
*/
import * as fs from 'fs-extra';
import * as path from 'path';
import { SQLiteBuffer } from '../utils/sqlite-buffer';
import { DataRecord, ParquetWriter } from '../types';
import { ServerAPI } from '@signalk/server-api';
import { HivePathBuilder } from '../utils/hive-path-builder';
export interface ExportServiceConfig {
outputDirectory: string;
filenamePrefix: string;
useHivePartitioning: boolean;
dailyExportHour: number;
s3Upload?: {
enabled: boolean;
};
}
export interface ExportResult {
batchId: string;
recordsExported: number;
filesCreated: string[];
duration: number;
errors: string[];
}
export class ParquetExportService {
private readonly sqliteBuffer: SQLiteBuffer;
private readonly parquetWriter: ParquetWriter;
private readonly config: ExportServiceConfig;
private readonly app: ServerAPI;
private readonly hivePathBuilder: HivePathBuilder;
private exportInterval: NodeJS.Timeout | null = null;
private isExporting: boolean = false;
private lastExportTime: Date | null = null;
private totalExported: number = 0;
private lastBatchExported: number = 0;
private lastExportTrigger: 'startup' | 'daily' | 'forced' | null = null;
constructor(
sqliteBuffer: SQLiteBuffer,
parquetWriter: ParquetWriter,
config: ExportServiceConfig,
app: ServerAPI
) {
this.sqliteBuffer = sqliteBuffer;
this.parquetWriter = parquetWriter;
this.config = config;
this.app = app;
this.hivePathBuilder = new HivePathBuilder();
}
/**
* Start the export service
*
* No startup export — catchup for completed days is handled by
* exportAllUnexported() called from index.ts after a 10s delay.
* Today's data stays in SQLite for the History API to read live.
*/
start(): void {
this.app.debug(
`ParquetExportService started (daily export mode, no startup blast)`
);
}
/**
* Stop the export service
*/
stop(): void {
if (this.exportInterval) {
clearInterval(this.exportInterval);
this.exportInterval = null;
}
this.app.debug('ParquetExportService stopped');
}
/**
* Force an immediate export of completed days (excludes today)
*/
async forceExport(): Promise<ExportResult> {
this.lastExportTrigger = 'forced';
return this.exportAllUnexported();
}
/**
* Build a flat-structure file path (legacy compatibility)
*/
private buildFlatFilePath(context: string, signalkPath: string): string {
// Clean context for filesystem
let contextPath: string;
if (context === 'vessels.self') {
contextPath = this.app.selfContext.replace(/\./g, '/').replace(/:/g, '_');
} else if (context.startsWith('vessels.')) {
const vesselId = context.replace('vessels.', '').replace(/:/g, '_');
contextPath = `vessels/${vesselId}`;
} else {
contextPath = context.replace(/:/g, '_').replace(/\./g, '/');
}
const dirPath = path.join(
this.config.outputDirectory,
contextPath,
signalkPath.replace(/\./g, '/')
);
const timestamp = new Date()
.toISOString()
.replace(/[:.]/g, '')
.slice(0, 15);
return path.join(
dirPath,
`${this.config.filenamePrefix}_${timestamp}.parquet`
);
}
/**
* Generate a unique batch ID
*/
private generateBatchId(): string {
const timestamp = new Date()
.toISOString()
.replace(/[:.]/g, '')
.slice(0, 15);
const random = Math.random().toString(36).substring(2, 8);
return `batch_${timestamp}_${random}`;
}
/**
* Get service status
*/
getStatus(): {
isRunning: boolean;
isExporting: boolean;
lastExportTime: Date | null;
lastBatchExported: number;
totalExported: number;
pendingRecords: number;
dailyExportHour: number;
lastExportTrigger: string | null;
mode: 'daily';
} {
return {
isRunning: true, // Always running in daily mode (scheduled from index.ts)
isExporting: this.isExporting,
lastExportTime: this.lastExportTime,
lastBatchExported: this.lastBatchExported,
totalExported: this.totalExported,
pendingRecords: this.sqliteBuffer.getPendingCount(),
dailyExportHour: this.config.dailyExportHour,
lastExportTrigger: this.lastExportTrigger,
mode: 'daily',
};
}
/**
* Get health check information
*/
getHealth(): {
healthy: boolean;
lastExportTime: Date | null;
pendingRecords: number;
bufferStats: ReturnType<SQLiteBuffer['getStats']>;
} {
const stats = this.sqliteBuffer.getStats();
const healthy = !this.isExporting;
return {
healthy,
lastExportTime: this.lastExportTime,
pendingRecords: stats.pendingRecords,
bufferStats: stats,
};
}
/**
* Export all unexported data from SQLite to Parquet (complete days only)
* Used at startup to catch up on any missed exports.
* Excludes today's data to avoid creating partial day files that would
* conflict with the daily export.
*/
async exportAllUnexported(): Promise<ExportResult> {
const startTime = Date.now();
const batchId = this.generateBatchId();
let totalRecordsExported = 0;
const allFilesCreated: string[] = [];
const allErrors: string[] = [];
// Get dates with unexported records (excludes today to avoid partial files)
const dates = this.sqliteBuffer.getDatesWithUnexportedRecords(true);
if (dates.length === 0) {
this.app.debug('[StartupExport] No unexported records found');
this.lastExportTime = new Date();
this.lastBatchExported = 0;
this.lastExportTrigger = 'startup';
return {
batchId,
recordsExported: 0,
filesCreated: [],
duration: Date.now() - startTime,
errors: [],
};
}
this.app.debug(
`[StartupExport] Found unexported records for ${dates.length} dates: ${dates.join(', ')}`
);
// Export each date
for (const dateStr of dates) {
const targetDate = new Date(dateStr + 'T00:00:00.000Z');
try {
const result = await this.exportDayToParquet(targetDate);
totalRecordsExported += result.recordsExported;
allFilesCreated.push(...result.filesCreated);
allErrors.push(...result.errors);
if (result.recordsExported > 0) {
this.app.debug(
`[StartupExport] Exported ${result.recordsExported} records for ${dateStr}`
);
}
} catch (error) {
const errorMsg = `[StartupExport] Failed to export ${dateStr}: ${(error as Error).message}`;
this.app.error(errorMsg);
allErrors.push(errorMsg);
}
}
this.lastExportTime = new Date();
this.lastBatchExported = totalRecordsExported;
this.totalExported += totalRecordsExported;
this.lastExportTrigger = 'startup';
this.app.debug(
`[StartupExport] Complete: ${totalRecordsExported} records to ${allFilesCreated.length} files in ${Date.now() - startTime}ms`
);
return {
batchId,
recordsExported: totalRecordsExported,
filesCreated: allFilesCreated,
duration: Date.now() - startTime,
errors: allErrors,
};
}
/**
* Export a full day's data to Parquet files (one file per context/path)
* This creates consolidated daily files directly, without needing a separate
* consolidation step.
*
* @param targetDate The date to export (UTC). Typically yesterday.
* @returns Export result with details about files created
*/
async exportDayToParquet(targetDate: Date): Promise<ExportResult> {
if (this.isExporting) {
this.app.debug('Export already in progress, skipping daily export');
return {
batchId: '',
recordsExported: 0,
filesCreated: [],
duration: 0,
errors: ['Export already in progress'],
};
}
this.isExporting = true;
const startTime = Date.now();
const batchId = this.generateBatchId();
const filesCreated: string[] = [];
const errors: string[] = [];
let recordsExported = 0;
const dateStr = targetDate.toISOString().slice(0, 10);
this.app.debug(`[DailyExport] Starting daily export for ${dateStr}`);
try {
// Get all distinct context/path combinations for this date
const pathsForDate = this.sqliteBuffer.getPathsForDate(targetDate);
if (pathsForDate.length === 0) {
this.app.debug(`[DailyExport] No data found for ${dateStr}`);
this.lastExportTime = new Date();
this.lastBatchExported = 0;
this.lastExportTrigger = 'daily';
return {
batchId,
recordsExported: 0,
filesCreated: [],
duration: Date.now() - startTime,
errors: [],
};
}
this.app.debug(
`[DailyExport] Found ${pathsForDate.length} paths with data for ${dateStr}`
);
// Export each context/path to its own file (batched to limit memory)
const BATCH_SIZE = 5000;
for (const { context, path: signalkPath } of pathsForDate) {
try {
const count = this.sqliteBuffer.getRecordCountForPathAndDate(
context,
signalkPath,
targetDate
);
if (count === 0) {
continue;
}
const firstBatch = this.sqliteBuffer.getRecordsForPathAndDateBatched(
context,
signalkPath,
targetDate,
BATCH_SIZE,
0
);
let offset = BATCH_SIZE;
const filePath = await this.exportDailyGroupBatched(
context,
signalkPath,
firstBatch,
() => {
const batch = this.sqliteBuffer.getRecordsForPathAndDateBatched(
context,
signalkPath,
targetDate,
BATCH_SIZE,
offset
);
offset += BATCH_SIZE;
return batch;
},
targetDate
);
if (filePath) {
filesCreated.push(filePath);
recordsExported += count;
// Mark records as exported by date range
this.sqliteBuffer.markDateExported(
context,
signalkPath,
targetDate,
batchId
);
this.app.debug(
`[DailyExport] Exported ${count} records for ${context}:${signalkPath}`
);
}
} catch (error) {
const errorMsg = `[DailyExport] Failed to export ${context}:${signalkPath}: ${(error as Error).message}`;
this.app.error(errorMsg);
errors.push(errorMsg);
}
}
// Cleanup old exported records
const cleaned = this.sqliteBuffer.cleanup();
if (cleaned > 0) {
this.app.debug(
`[DailyExport] Cleaned up ${cleaned} old exported records`
);
}
// Truncate WAL after heavy export+cleanup batch
try {
this.sqliteBuffer.checkpoint();
} catch {
// Non-critical — WAL will be checkpointed eventually
}
this.lastExportTime = new Date();
this.lastBatchExported = recordsExported;
this.totalExported += recordsExported;
this.lastExportTrigger = 'daily';
this.app.debug(
`[DailyExport] Complete: ${recordsExported} records to ${filesCreated.length} files in ${Date.now() - startTime}ms`
);
return {
batchId,
recordsExported,
filesCreated,
duration: Date.now() - startTime,
errors,
};
} finally {
this.isExporting = false;
}
}
/**
* Export a group of records as a timestamped file
* Uses consistent timestamped naming (same as startup exports) for simplicity
*/
private async exportDailyGroup(
context: string,
signalkPath: string,
records: DataRecord[],
targetDate: Date,
_batchId: string
): Promise<string | null> {
if (records.length === 0) return null;
// Build file path with timestamp (consistent with exportGroup)
let filePath: string;
if (this.config.useHivePartitioning) {
// Resolve vessels.self to actual vessel context
const resolvedContext =
context === 'vessels.self' ? this.app.selfContext : context;
// Use Hive-style partitioning
// Directory is based on targetDate (for correct day partition)
// Filename uses CURRENT time for uniqueness (avoid overwrites)
const dirPath = this.hivePathBuilder.buildPath(
this.config.outputDirectory,
'raw',
resolvedContext,
signalkPath,
targetDate
);
const timestampStr = new Date()
.toISOString()
.replace(/[:.]/g, '')
.slice(0, 15);
filePath = path.join(
dirPath,
`${this.config.filenamePrefix}_${timestampStr}.parquet`
);
} else {
// Use legacy flat structure with timestamp
filePath = this.buildFlatFilePath(context, signalkPath);
}
// Ensure directory exists
await fs.ensureDir(path.dirname(filePath));
// Write to temp file first for atomic operation
const tempFilePath = filePath + '.tmp';
try {
// Write records to temp file
await this.parquetWriter.writeRecords(tempFilePath, records);
// Validate the written file
const stats = await fs.stat(tempFilePath);
if (stats.size < 100) {
throw new Error('Written file is too small, likely corrupt');
}
// Atomic rename
await fs.rename(tempFilePath, filePath);
return filePath;
} catch (error) {
// Clean up temp file on failure
try {
await fs.remove(tempFilePath);
} catch {
// Ignore cleanup errors
}
throw error;
}
}
/**
* Export records in batches to avoid loading all rows into memory at once.
* firstBatch is used for schema detection; nextBatch callback pulls subsequent chunks.
*/
private async exportDailyGroupBatched(
context: string,
signalkPath: string,
firstBatch: DataRecord[],
nextBatch: () => DataRecord[],
targetDate: Date
): Promise<string | null> {
if (firstBatch.length === 0) return null;
let filePath: string;
if (this.config.useHivePartitioning) {
const resolvedContext =
context === 'vessels.self' ? this.app.selfContext : context;
const dirPath = this.hivePathBuilder.buildPath(
this.config.outputDirectory,
'raw',
resolvedContext,
signalkPath,
targetDate
);
const timestampStr = new Date()
.toISOString()
.replace(/[:.]/g, '')
.slice(0, 15);
filePath = path.join(
dirPath,
`${this.config.filenamePrefix}_${timestampStr}.parquet`
);
} else {
filePath = this.buildFlatFilePath(context, signalkPath);
}
await fs.ensureDir(path.dirname(filePath));
const tempFilePath = filePath + '.tmp';
try {
await this.parquetWriter.writeParquetBatched(
tempFilePath,
firstBatch,
nextBatch,
signalkPath
);
const stats = await fs.stat(tempFilePath);
if (stats.size < 100) {
throw new Error('Written file is too small, likely corrupt');
}
await fs.rename(tempFilePath, filePath);
return filePath;
} catch (error) {
try {
await fs.remove(tempFilePath);
} catch {
// Ignore cleanup errors
}
throw error;
}
}
}