signalk-parquet
Version:
Vessel data Parquet file archive with automated value and geospatial triggers. History API compliant with cloud backups and queries.
217 lines (197 loc) • 7.34 kB
text/typescript
import { DuckDBInstance } from '@duckdb/node-api';
import * as path from 'path';
import * as fs from 'fs-extra';
/**
* Singleton DuckDB instance with connection pooling
* Loads spatial extension once on initialization
*
* This eliminates the performance overhead of creating new DuckDB instances
* for every request and repeatedly loading the spatial extension.
*
* @example
* ```typescript
* // During plugin startup
* await DuckDBPool.initialize();
*
* // In request handlers
* const connection = await DuckDBPool.getConnection();
* const result = await connection.runAndReadAll("SELECT * FROM ...");
* // ... process result
* // Note: DuckDB connections close automatically when no longer referenced
*
* // During plugin shutdown
* await DuckDBPool.shutdown();
* ```
*/
export interface S3Config {
accessKeyId: string;
secretAccessKey: string;
region: string;
endpoint?: string; // R2: '{accountId}.r2.cloudflarestorage.com', or host[:port] for self-hosted S3 (Garage, MinIO, etc.)
useSSL?: boolean; // Set to false for self-hosted endpoints served over plain HTTP
urlStyle?: 'path' | 'vhost'; // R2 and most self-hosted S3-compatible services require 'path'
}
export class DuckDBPool {
private static instance: DuckDBInstance | null = null;
private static initialized: boolean = false;
private static s3Initialized: boolean = false;
private static sqliteDbPath: string | null = null;
private static sqliteInitialized: boolean = false;
/**
* Initialize the DuckDB instance and load extensions
* Call this once during plugin startup
*
* @throws Error if initialization fails
*/
static async initialize(homeBaseDir?: string): Promise<void> {
if (this.instance) {
return; // Already initialized
}
// DuckDB defaults its extension/home directory to `$HOME/.duckdb`. On hosts
// where $HOME is read-only — e.g. the Signal K App Store CI sandbox, which
// fails activation with `IO Error: Failed to create directory
// "$HOME/.duckdb": Read-only file system` — the `INSTALL spatial`
// below then aborts. Point DuckDB at a writable dir under the plugin's own
// data directory instead; this also caches downloaded extensions across
// restarts. Falls back to DuckDB's default when no directory is provided.
const config: Record<string, string> = {};
if (homeBaseDir) {
const duckdbHome = path.join(homeBaseDir, '.duckdb');
await fs.ensureDir(duckdbHome);
config.home_directory = duckdbHome;
config.extension_directory = path.join(duckdbHome, 'extensions');
config.temp_directory = path.join(duckdbHome, 'tmp');
}
// Fully set up on a local variable and only publish to this.instance on
// success, so a failure here (e.g. INSTALL spatial with no network) leaves
// the pool uninitialized and a later initialize() can retry cleanly.
const instance = await DuckDBInstance.create(':memory:', config);
// Load spatial extension once for all future connections
const setupConn = await instance.connect();
try {
// Cap DuckDB memory to prevent OOM when combined with Node's heap
await setupConn.runAndReadAll("SET memory_limit = '512MB';");
await setupConn.runAndReadAll('INSTALL spatial;');
await setupConn.runAndReadAll('LOAD spatial;');
this.instance = instance;
this.initialized = true;
} finally {
setupConn.disconnectSync();
}
}
/**
* Get a connection from the pool
* The connection shares the same instance, so spatial extension is already loaded
*
* @returns A new connection (closes automatically when no longer referenced)
* @throws Error if pool is not initialized
*/
static async getConnection() {
if (!this.instance) {
throw new Error(
'DuckDBPool not initialized. Call DuckDBPool.initialize() first.'
);
}
return await this.instance.connect();
}
/**
* Store the SQLite buffer database path for federated queries.
* Call this after initialize() and after the SQLiteBuffer is created.
*
* The path is used only as a "buffer exists" signal and for diagnostics —
* DuckDB must NEVER open buffer.db itself (see buffer-staging.ts for why
* the old ATTACH approach crashed the server).
*
* @param dbPath Absolute path to the SQLite buffer.db file
*/
static initializeSQLiteBuffer(dbPath: string): void {
this.sqliteDbPath = dbPath;
this.sqliteInitialized = true;
}
/**
* Check if the SQLite buffer path has been configured
*/
static isSQLiteBufferInitialized(): boolean {
return this.sqliteInitialized && this.sqliteDbPath !== null;
}
/**
* Get the configured SQLite buffer path (or null)
*/
static getSQLiteBufferPath(): string | null {
return this.sqliteDbPath;
}
/**
* Cleanup on plugin shutdown
* Sets the instance to null to allow garbage collection
*/
static async shutdown(): Promise<void> {
if (this.instance) {
// DuckDB instances handle cleanup automatically
this.instance = null;
this.initialized = false;
this.s3Initialized = false;
this.sqliteDbPath = null;
this.sqliteInitialized = false;
}
}
/**
* Check if pool is ready
* @returns true if the pool has been initialized
*/
static isInitialized(): boolean {
return this.initialized;
}
/**
* Initialize S3 credentials for DuckDB
* This allows DuckDB to query S3 parquet files directly
*
* @param config S3 configuration with credentials and region
* @throws Error if pool is not initialized or S3 setup fails
*/
static async initializeS3(config: S3Config): Promise<void> {
if (!this.instance) {
throw new Error(
'DuckDBPool not initialized. Call DuckDBPool.initialize() first.'
);
}
if (this.s3Initialized) {
return; // Already initialized
}
const connection = await this.instance.connect();
try {
// Install and load httpfs extension for S3 support
await connection.runAndReadAll('INSTALL httpfs;');
await connection.runAndReadAll('LOAD httpfs;');
// Create S3 secret — R2 and self-hosted S3-compatible services (Garage, MinIO)
// typically need ENDPOINT and URL_STYLE 'path'
let endpointClause = config.endpoint
? `,\n ENDPOINT '${config.endpoint.replace(/'/g, "''")}'`
: '';
if (config.urlStyle) {
endpointClause += `,\n URL_STYLE '${config.urlStyle}'`;
}
if (config.endpoint && config.useSSL !== undefined) {
endpointClause += `,\n USE_SSL ${config.useSSL ? 'true' : 'false'}`;
}
const secretSql = `
CREATE OR REPLACE SECRET s3_credentials (
TYPE S3,
KEY_ID '${config.accessKeyId.replace(/'/g, "''")}',
SECRET '${config.secretAccessKey.replace(/'/g, "''")}',
REGION '${config.region.replace(/'/g, "''")}'${endpointClause}
)
`;
await connection.runAndReadAll(secretSql);
this.s3Initialized = true;
} finally {
connection.disconnectSync();
}
}
/**
* Check if S3 credentials have been initialized
* @returns true if S3 is ready for queries
*/
static isS3Initialized(): boolean {
return this.s3Initialized;
}
}