UNPKG

signalk-parquet

Version:

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

214 lines 9.01 kB
/** * Compaction Service * * Merges per-day parquet files within a (tier, context, path, year) group * into a single per-year parquet file, then deletes the source day-files. * * Why: the live and import write paths emit one parquet per (path, UTC day). * Over multiple years a typical vessel ends up with many thousands of small * files in tier=raw, each carrying parquet's per-file overhead (schema, * page index, footer). DuckDB queries spanning years pay that overhead per * file, and the filesystem suffers metadata bloat. * * Scope: this is purely a layout transformation. Schema is preserved * exactly (`SELECT *` with `union_by_name=true`), records are sorted by * `signalk_timestamp`, and writes go through a temp-file + atomic-rename * pattern so a partial run never corrupts the partition. * * After compaction the year directory contains one file like * * tier=raw/context=.../path=.../year=2024/year_compact_2024_<TS>.parquet * * and the `day=DDD/` subdirectories under it are removed. DuckDB's * hive-partitioning glob still finds the file; `day` becomes NULL for * compacted years, which matters only if a downstream consumer filters by * `day=…` directly. The History API filters by timestamp range, so * partition pruning at the year level still works and per-file min/max * stats handle the rest. * * Mirrors the same job-tracking / cancellable-job pattern as * MigrationService, AggregationService and GpxImportService. */ import { ServerAPI } from '@signalk/server-api'; import { AggregationTier } from '../utils/hive-path-builder'; export interface CompactionConfig { baseDirectory: string; tier: AggregationTier; beforeYear: number; pathFilter?: string; } export interface CompactionPlanGroup { tier: string; context: string; path: string; year: number; yearDir: string; sourcePaths: string[]; sourceFiles: number; sourceBytes: number; } export interface CompactionPlan { totalGroups: number; totalSourceFiles: number; totalSourceBytes: number; groups: CompactionPlanGroup[]; } export interface CompactionProgress { jobId: string; status: 'scanning' | 'running' | 'completed' | 'cancelled' | 'error'; phase: 'scan' | 'compact'; processed: number; total: number; percent: number; currentGroup?: string; startTime: Date; completedAt?: Date; error?: string; groupsCompacted: number; groupsSkipped: number; filesRemoved: number; bytesBefore: number; bytesAfter: number; errors: string[]; } export declare class CompactionConflictError extends Error { constructor(message: string); } /** * Mark every running/scanning compaction job for cancellation. Called * from plugin.stop() so a SignalK shutdown does not leave a job spinning * past the next group boundary. Does not await: a single in-flight * DuckDB COPY is uninterruptible, but the per-group loop will exit on * the next iteration check. */ export declare function signalShutdownAllCompactionJobs(): number; /** * Signal cancellation to every active job and wait for them to reach a * terminal state, bounded by `timeoutMs`. Use this from plugin.stop() * so DuckDBPool.shutdown() doesn't run while a COPY is still in flight * — that race turns a clean stop into a failed compaction at best, or * a partial temp file at worst. * * Returns counts so the caller can log what happened. `remaining > 0` * means a job was still running at timeout; the plugin proceeds with * shutdown anyway, since blocking forever is worse than leaking a job. */ export declare function quiesceAllCompactionJobs(timeoutMs?: number): Promise<{ signalled: number; quiesced: number; remaining: number; }>; /** * Remove stranded `*.tmp` files left behind by a SignalK crash mid-COPY. * Safe to call at every plugin start: only files matching the compaction * temp pattern under the data directory are removed. */ export declare function cleanupStrandedCompactionTempFiles(app: ServerAPI, baseDirectory: string): Promise<{ removed: number; }>; /** * Recover from stale `.compaction-trash-*` directories left by a crash * between move-to-trash and rename-to-published. Two cases: * * - Yearly file present in the parent: the publish completed before * the crash; trash holds duplicate sources that the post-publish * cleanup never got to remove. Delete the trash dir. * - Yearly file absent: the crash happened mid-move (or after move * but before publish). Restore the trashed files back to their * mirrored locations under the year-dir, then delete the empty * trash dir. * * This runs early in plugin start, before any new data writes can land * in day-dirs, so a restore can't clobber anything. */ export declare function recoverStrandedCompactionTrash(app: ServerAPI, baseDirectory: string): Promise<{ restored: number; cleaned: number; failed: number; }>; export declare class CompactionService { private readonly app; private readonly hivePathBuilder; constructor(app: ServerAPI); /** * Walk the Hive layout for a given tier and return what would be * compacted. Groups with only one file are not included (already * compact). Non-destructive — safe to call repeatedly. */ scan(config: CompactionConfig): Promise<CompactionPlan>; compact(config: CompactionConfig): Promise<string>; private run; /** * Walk the Hive layout tier=<T>/context=.../path=.../year=... and * return one group per year-directory that has more than one parquet * file under it. * * Scale note: result size is O(#contexts × #paths × #years). On a * typical SignalK install (one vessel, ~hundreds of paths, single-digit * years) this fits comfortably in memory. */ private findCompactableGroups; /** * Parse the trailing four segments of a Hive year directory into the * tier/context/path/year tuple. Returns null on shape mismatch. * * Example input: "/data/tier=raw/context=vessels__urn-mrn-…/path=navigation__position/year=2024" * Example output: { context: "vessels.urn:mrn:…", path: "navigation.position", year: 2024 } */ private parseYearDir; /** * Merge all parquet files under one (tier, context, path, year) group * into a single output file, then remove the sources. * * Publish sequence (atomic from a reader's perspective): * 1. DuckDB COPY into `<output>.tmp`, size-checked. * 2. Source files are *moved* (rename within yearDir) into a trash * directory: `<yearDir>/.compaction-trash-<jobId>/`. Mirrors the * original day=DDD/ structure so rollback is just a reverse * rename. * 3. The temp file is renamed to its final `year_compact_*.parquet` * name. This is the publish point. * 4. The trash directory is recursively removed. * * Why moves first, then publish: between steps 3 and 4 in a * "publish-then-delete" scheme, queries see both the new yearly file * and every still-present source — duplicate rows on the wire. The * trash-first scheme makes the year-dir's queryable contents flip * atomically: before the publish rename a query sees the originals * (less any briefly-renamed files mid-step-2), after it the query * sees only the yearly file. * * Failure handling: * - Step 1/2 failure (pre-publish): reverse any moves that did * succeed, delete temp, delete trash dir. Sources end back where * they started; no data lost. The group surfaces an error. * - Step 3 failure: same rollback as above. * - Step 4 failure (post-publish): data is committed; the yearly * file holds everything. Failure to clean trash is logged and * surfaced via `residualSources` (now meaning "files left in * trash that the operator may want to remove"). Startup sweep * handles these on next plugin start. */ private compactGroup; /** * Rename with one retry on EBUSY. On Windows a reader (DuckDB query, * antivirus, file explorer preview) can briefly hold the destination * path; a single backoff-and-retry covers the common case without * making us wait indefinitely. */ private renameWithRetry; /** * Remove a file or directory with exponential backoff on transient * errors. Windows readers (antivirus, search indexer, explorer * preview) can hold a brief handle; one delete attempt isn't enough. * After all attempts the last error is rethrown so the caller can * record it (e.g. as a residual). Permanent errors (ENOENT, etc.) * are not retried. */ private removeWithRetry; private removeEmptyDayDirs; getProgress(jobId: string): CompactionProgress | null; cancel(jobId: string): boolean; getJobIds(): string[]; } //# sourceMappingURL=compaction-service.d.ts.map