signalk-parquet
Version:
Vessel data Parquet file archive with automated value and geospatial triggers. History API compliant with cloud backups and queries.
1,034 lines (936 loc) • 33.5 kB
text/typescript
/**
* Aggregation Service
*
* Handles multi-tier data aggregation from raw data to progressively
* downsampled tiers for efficient long-term storage and querying.
*
* Tiers:
* - raw: Original data (~1s resolution)
* - 5s: 5-second aggregates
* - 60s: 1-minute aggregates
* - 1h: Hourly aggregates
*/
import * as fs from 'fs-extra';
import * as path from 'path';
import { glob } from 'glob';
import { ServerAPI } from '@signalk/server-api';
import { DuckDBPool } from '../utils/duckdb-pool';
import { HivePathBuilder, AggregationTier } from '../utils/hive-path-builder';
import { isAngularPath } from '../utils/angular-paths';
import { CLEANUP_YIELD_INTERVAL, POSITION_MAX_SPEED_MPS } from '../constants';
import { PathRetentionRule, RetentionRuleSet } from '../utils/retention-rules';
export interface AggregationConfig {
outputDirectory: string;
filenamePrefix: string;
// Per-tier retention in days. 0 means "keep forever". Tiers above raw
// are multiples of the raw value by convention (5s=2x, 60s=4x,
// 1h=12x), but they are passed in independently so the route layer
// can override.
retentionDays: {
raw: number;
'5s': number;
'60s': number;
'1h': number;
};
// Optional per-path overrides applied on top of the tier defaults.
// The override's `days` value is the raw retention; the same tier
// multipliers (2/4/12x) scale it to upper tiers. `skipAggregation`
// removes a path from the rollup pipeline entirely.
pathRetentionOverrides?: PathRetentionRule[];
}
// Tier multipliers used when scaling a raw-tier retention to upper
// tiers. The same convention applies to the global default and to
// per-path overrides; exported so callers in index.ts and the route
// layer build identical AggregationConfig.retentionDays without
// drifting from this file.
export const TIER_RETENTION_MULTIPLIER: Record<AggregationTier, number> = {
raw: 1,
'5s': 2,
'60s': 4,
'1h': 12,
};
/**
* Build the per-tier retention block from a raw-tier value. 0 stays
* 0 in every tier (= keep forever).
*/
export function buildPerTierRetention(
rawDays: number
): AggregationConfig['retentionDays'] {
return {
raw: rawDays * TIER_RETENTION_MULTIPLIER.raw,
'5s': rawDays * TIER_RETENTION_MULTIPLIER['5s'],
'60s': rawDays * TIER_RETENTION_MULTIPLIER['60s'],
'1h': rawDays * TIER_RETENTION_MULTIPLIER['1h'],
};
}
export interface AggregationProgress {
jobId: string;
status: 'running' | 'completed' | 'cancelled' | 'error';
tier: AggregationTier;
processed: number;
total: number;
currentFile?: string;
startTime: Date;
completedAt?: Date;
error?: string;
}
export interface BulkAggregationProgress {
jobId: string;
status: 'scanning' | 'running' | 'completed' | 'cancelled' | 'error';
phase: 'scan' | 'aggregation';
currentDate?: string;
datesProcessed: number;
datesTotal: number;
percent: number;
filesCreated: number;
recordsAggregated: number;
startTime: Date;
completedAt?: Date;
error?: string;
errors: string[];
}
export interface AggregationResult {
sourceTier: AggregationTier;
targetTier: AggregationTier;
filesProcessed: number;
recordsAggregated: number;
filesCreated: number;
duration: number;
errors: string[];
}
const bulkAggregationJobs = new Map<string, BulkAggregationProgress>();
const BULK_JOB_TTL_MS = 60 * 60 * 1000; // 1 hour
function scheduleBulkJobCleanup(jobId: string) {
setTimeout(() => {
const job = bulkAggregationJobs.get(jobId);
if (job && job.status !== 'running' && job.status !== 'scanning') {
bulkAggregationJobs.delete(jobId);
}
}, BULK_JOB_TTL_MS);
}
const TIER_INTERVALS: Record<AggregationTier, number> = {
raw: 1,
'5s': 5,
'60s': 60,
'1h': 3600,
};
const TIER_HIERARCHY: AggregationTier[] = ['raw', '5s', '60s', '1h'];
export class AggregationService {
private readonly config: AggregationConfig;
private readonly app: ServerAPI;
private readonly hivePathBuilder: HivePathBuilder;
private readonly retentionRules: RetentionRuleSet;
private currentJob: AggregationProgress | null = null;
private cancelRequested: boolean = false;
constructor(config: AggregationConfig, app: ServerAPI) {
this.config = config;
this.app = app;
this.hivePathBuilder = new HivePathBuilder();
this.retentionRules = new RetentionRuleSet(
config.pathRetentionOverrides || [],
(rule, err) => {
// A pattern that fails to compile is dropped; log so the
// operator sees the offending rule rather than silently losing
// it. Doesn't block plugin start.
this.app.error(
`[Retention] Dropping rule with invalid pattern '${rule.pattern}': ${err.message}`
);
}
);
}
/**
* Run aggregation for a specific date.
* Optional pathFilter restricts which signalk paths are aggregated (used by
* targeted migrations like position re-aggregation).
*/
async aggregateDate(
date: Date,
pathFilter?: (signalkPath: string) => boolean
): Promise<AggregationResult[]> {
const results: AggregationResult[] = [];
// Aggregate through the hierarchy: raw -> 5s -> 60s -> 1h
for (let i = 0; i < TIER_HIERARCHY.length - 1; i++) {
const sourceTier = TIER_HIERARCHY[i];
const targetTier = TIER_HIERARCHY[i + 1];
try {
const result = await this.aggregateTier(
sourceTier,
targetTier,
date,
pathFilter
);
results.push(result);
} catch (error) {
this.app.error(
`Aggregation ${sourceTier} -> ${targetTier} failed: ${(error as Error).message}`
);
results.push({
sourceTier,
targetTier,
filesProcessed: 0,
recordsAggregated: 0,
filesCreated: 0,
duration: 0,
errors: [(error as Error).message],
});
}
}
return results;
}
/**
* Aggregate from one tier to the next
*/
async aggregateTier(
sourceTier: AggregationTier,
targetTier: AggregationTier,
date: Date,
pathFilter?: (signalkPath: string) => boolean
): Promise<AggregationResult> {
const startTime = Date.now();
const errors: string[] = [];
let filesProcessed = 0;
let recordsAggregated = 0;
let filesCreated = 0;
const year = date.getUTCFullYear();
const dayOfYear = this.hivePathBuilder.getDayOfYear(date);
// Find all source files for this date
const sourcePattern = path.join(
this.config.outputDirectory,
`tier=${sourceTier}`,
'context=*',
'path=*',
`year=${year}`,
`day=${String(dayOfYear).padStart(3, '0')}`,
'*.parquet'
);
const allSourceFiles = await glob(sourcePattern);
// Exclude files in processed, quarantine, failed, repaired directories
const sourceFiles = allSourceFiles.filter(
f =>
!f.includes('/processed/') &&
!f.includes('/quarantine/') &&
!f.includes('/failed/') &&
!f.includes('/repaired/')
);
if (sourceFiles.length === 0) {
this.app.debug(
`No source files found for ${sourceTier} on ${date.toISOString().slice(0, 10)}`
);
return {
sourceTier,
targetTier,
filesProcessed: 0,
recordsAggregated: 0,
filesCreated: 0,
duration: Date.now() - startTime,
errors: [],
};
}
// Group files by context and path
const fileGroups = this.groupFilesByContextPath(sourceFiles);
// Process each group
for (const [key, files] of fileGroups) {
if (this.cancelRequested) break;
try {
const { context, signalkPath } = this.parseGroupKey(key);
if (pathFilter && !pathFilter(signalkPath)) continue;
// Honour per-path skipAggregation. These paths live only in
// tier=raw and are cleaned up directly via cleanupOldData.
if (this.retentionRules.shouldSkipAggregation(signalkPath)) {
this.app.debug(
`Skipping aggregation for ${signalkPath}: matched a retention rule with skipAggregation=true`
);
continue;
}
const result = await this.aggregateGroup(
files,
context,
signalkPath,
sourceTier,
targetTier,
date
);
filesProcessed += files.length;
recordsAggregated += result.recordsAggregated;
if (result.outputFile) {
filesCreated++;
}
} catch (error) {
const errorMsg = `Failed to aggregate ${key}: ${(error as Error).message}`;
this.app.error(errorMsg);
errors.push(errorMsg);
}
}
return {
sourceTier,
targetTier,
filesProcessed,
recordsAggregated,
filesCreated,
duration: Date.now() - startTime,
errors,
};
}
/**
* Aggregate a group of files for a specific context/path
*/
private async aggregateGroup(
files: string[],
context: string,
signalkPath: string,
sourceTier: AggregationTier,
targetTier: AggregationTier,
date: Date
): Promise<{ recordsAggregated: number; outputFile: string | null }> {
const intervalSeconds = TIER_INTERVALS[targetTier];
const isSourceRaw = sourceTier === 'raw';
const fileListStr = files.map(f => `'${f}'`).join(', ');
// Detect schema: scalar ('value' / 'value_avg'), position ('value_latitude'/'value_longitude'),
// or unsupported object-type (skip).
const connection = await DuckDBPool.getConnection();
let isPosition = false;
try {
const schemaQuery = `SELECT column_name FROM (DESCRIBE SELECT * FROM read_parquet([${fileListStr}], union_by_name=true))`;
const schemaResult = await connection.runAndReadAll(schemaQuery);
const columns = schemaResult
.getRowObjects()
.map((r: Record<string, unknown>) => r.column_name as string);
const hasLatLon =
columns.includes('value_latitude') &&
columns.includes('value_longitude');
const requiredColumn = isSourceRaw ? 'value' : 'bucket_time';
const hasScalarColumn = columns.includes(requiredColumn);
if (hasLatLon) {
isPosition = true;
} else if (!hasScalarColumn) {
this.app.debug(
`Skipping ${signalkPath}: no '${requiredColumn}' column (object-type data stays in raw tier)`
);
connection.disconnectSync();
return { recordsAggregated: 0, outputFile: null };
}
} catch (error) {
connection.disconnectSync();
throw error;
}
// Build output path
const outputDir = this.hivePathBuilder.buildPath(
this.config.outputDirectory,
targetTier,
context,
signalkPath,
date
);
await fs.ensureDir(outputDir);
const outputFile = path.join(
outputDir,
`${this.config.filenamePrefix}_${date.toISOString().slice(0, 10)}_aggregated.parquet`
);
// Use different query depending on source tier schema and whether the path is angular/position
// Raw tier has: received_timestamp, value (or value_latitude/value_longitude for position)
// Aggregated tiers have: bucket_time, value_avg/value_latitude, value_min, value_max, sample_count, first_timestamp, last_timestamp
const angular =
!isPosition && isAngularPath(signalkPath, this.app, context);
const query = isPosition
? this.buildPositionAggregationQuery(
fileListStr,
intervalSeconds,
isSourceRaw,
outputFile
)
: this.buildAggregationQuery(
fileListStr,
intervalSeconds,
isSourceRaw,
angular,
outputFile
);
try {
await connection.runAndReadAll(query);
// Get record count from output
const countQuery = `SELECT COUNT(*) as cnt FROM read_parquet('${outputFile}')`;
const countResult = await connection.runAndReadAll(countQuery);
const rows = countResult.getRowObjects();
const recordCount = rows[0]?.cnt || 0;
return {
recordsAggregated: Number(recordCount),
outputFile,
};
} finally {
connection.disconnectSync();
}
}
/**
* Build the aggregation SQL query, branching on angular vs scalar paths
*/
private buildAggregationQuery(
fileListStr: string,
intervalSeconds: number,
isSourceRaw: boolean,
isAngular: boolean,
outputFile: string
): string {
if (isAngular) {
return this.buildAngularAggregationQuery(
fileListStr,
intervalSeconds,
isSourceRaw,
outputFile
);
}
// Standard scalar aggregation
if (isSourceRaw) {
return `
COPY (
SELECT
time_bucket(INTERVAL '${intervalSeconds} seconds', received_timestamp::TIMESTAMP) as bucket_time,
context,
path,
AVG(CASE WHEN value IS NOT NULL AND TRY_CAST(value AS DOUBLE) IS NOT NULL THEN CAST(value AS DOUBLE) END) as value_avg,
MIN(CASE WHEN value IS NOT NULL AND TRY_CAST(value AS DOUBLE) IS NOT NULL THEN CAST(value AS DOUBLE) END) as value_min,
MAX(CASE WHEN value IS NOT NULL AND TRY_CAST(value AS DOUBLE) IS NOT NULL THEN CAST(value AS DOUBLE) END) as value_max,
COUNT(*) as sample_count,
MIN(received_timestamp) as first_timestamp,
MAX(received_timestamp) as last_timestamp
FROM read_parquet([${fileListStr}], union_by_name=true)
GROUP BY bucket_time, context, path
ORDER BY bucket_time
) TO '${outputFile}' (FORMAT PARQUET, COMPRESSION 'SNAPPY');
`;
}
return `
COPY (
SELECT
time_bucket(INTERVAL '${intervalSeconds} seconds', src_bucket_time::TIMESTAMP) as bucket_time,
context,
path,
SUM(value_avg * sample_count) / SUM(sample_count) as value_avg,
MIN(value_min) as value_min,
MAX(value_max) as value_max,
SUM(sample_count)::BIGINT as sample_count,
MIN(first_timestamp) as first_timestamp,
MAX(last_timestamp) as last_timestamp
FROM (
SELECT bucket_time as src_bucket_time, context, path, value_avg, value_min, value_max, sample_count, first_timestamp, last_timestamp
FROM read_parquet([${fileListStr}], union_by_name=true)
) src
GROUP BY time_bucket(INTERVAL '${intervalSeconds} seconds', src_bucket_time::TIMESTAMP), context, path
ORDER BY 1
) TO '${outputFile}' (FORMAT PARQUET, COMPRESSION 'SNAPPY');
`;
}
/**
* Build angular-specific aggregation query using vector decomposition:
* ATAN2(AVG(SIN(value)), AVG(COS(value)))
*/
private buildAngularAggregationQuery(
fileListStr: string,
intervalSeconds: number,
isSourceRaw: boolean,
outputFile: string
): string {
if (isSourceRaw) {
// Vector average from raw radian values
return `
COPY (
SELECT
time_bucket(INTERVAL '${intervalSeconds} seconds', received_timestamp::TIMESTAMP) as bucket_time,
context,
path,
ATAN2(
AVG(SIN(CAST(value AS DOUBLE))),
AVG(COS(CAST(value AS DOUBLE)))
) as value_avg,
NULL::DOUBLE as value_min,
NULL::DOUBLE as value_max,
COUNT(*) as sample_count,
AVG(SIN(CAST(value AS DOUBLE))) as value_sin_avg,
AVG(COS(CAST(value AS DOUBLE))) as value_cos_avg,
MIN(received_timestamp) as first_timestamp,
MAX(received_timestamp) as last_timestamp
FROM read_parquet([${fileListStr}], union_by_name=true)
WHERE value IS NOT NULL AND TRY_CAST(value AS DOUBLE) IS NOT NULL
GROUP BY bucket_time, context, path
ORDER BY bucket_time
) TO '${outputFile}' (FORMAT PARQUET, COMPRESSION 'SNAPPY');
`;
}
// Re-aggregate from already-aggregated data using stored sin/cos averages
// weighted by sample count for correct vector re-aggregation
return `
COPY (
SELECT
time_bucket(INTERVAL '${intervalSeconds} seconds', src_bucket_time::TIMESTAMP) as bucket_time,
context,
path,
ATAN2(
SUM(value_sin_avg * sample_count) / SUM(sample_count),
SUM(value_cos_avg * sample_count) / SUM(sample_count)
) as value_avg,
NULL::DOUBLE as value_min,
NULL::DOUBLE as value_max,
SUM(sample_count)::BIGINT as sample_count,
SUM(value_sin_avg * sample_count) / SUM(sample_count) as value_sin_avg,
SUM(value_cos_avg * sample_count) / SUM(sample_count) as value_cos_avg,
MIN(first_timestamp) as first_timestamp,
MAX(last_timestamp) as last_timestamp
FROM (
SELECT bucket_time as src_bucket_time, context, path, value_sin_avg, value_cos_avg, sample_count, first_timestamp, last_timestamp
FROM read_parquet([${fileListStr}], union_by_name=true)
) src
GROUP BY time_bucket(INTERVAL '${intervalSeconds} seconds', src_bucket_time::TIMESTAMP), context, path
ORDER BY 1
) TO '${outputFile}' (FORMAT PARQUET, COMPRESSION 'SNAPPY');
`;
}
/**
* Build position aggregation query.
*
* Selects one representative point per bucket by ranking candidates:
* 1. Fewest glitchy neighbors — a neighbor is glitchy when the implied
* speed between it and the candidate exceeds POSITION_MAX_SPEED_MPS.
* Both neighbors clean beats one; one beats none.
* 2. Timestamp at or after the bucket midpoint.
* 3. Closest to the bucket midpoint.
*
* A bucket always produces a row if any candidate exists; buckets made
* entirely of glitches still emit their least-bad candidate.
*/
private buildPositionAggregationQuery(
fileListStr: string,
intervalSeconds: number,
isSourceRaw: boolean,
outputFile: string
): string {
// Haversine distance in meters between two lat/lon pairs.
const haversine = (
lat1: string,
lon1: string,
lat2: string,
lon2: string
) => `
(2 * 6371000 * ASIN(SQRT(
POWER(SIN(RADIANS(${lat2} - ${lat1}) / 2), 2) +
COS(RADIANS(${lat1})) * COS(RADIANS(${lat2})) *
POWER(SIN(RADIANS(${lon2} - ${lon1}) / 2), 2)
)))
`;
// Source columns differ between raw and aggregated tiers.
// Raw: received_timestamp, value_latitude, value_longitude
// Aggregated: bucket_time (as timestamp), value_latitude, value_longitude, sample_count, first_timestamp, last_timestamp
const tsCol = isSourceRaw ? 'received_timestamp' : 'bucket_time';
const srcSampleCount = isSourceRaw ? '1::BIGINT' : 'sample_count';
const srcFirstTs = isSourceRaw ? 'received_timestamp' : 'first_timestamp';
const srcLastTs = isSourceRaw ? 'received_timestamp' : 'last_timestamp';
return `
COPY (
WITH src AS (
SELECT
${tsCol}::TIMESTAMP AS ts,
context,
path,
TRY_CAST(value_latitude AS DOUBLE) AS lat,
TRY_CAST(value_longitude AS DOUBLE) AS lon,
${srcSampleCount} AS src_sample_count,
${srcFirstTs}::TIMESTAMP AS src_first_ts,
${srcLastTs}::TIMESTAMP AS src_last_ts
FROM read_parquet([${fileListStr}], union_by_name=true)
WHERE TRY_CAST(value_latitude AS DOUBLE) BETWEEN -90 AND 90
AND TRY_CAST(value_longitude AS DOUBLE) BETWEEN -180 AND 180
),
bucketed AS (
SELECT
time_bucket(INTERVAL '${intervalSeconds} seconds', ts) AS bucket_time,
*
FROM src
),
with_neighbors AS (
SELECT
*,
LAG(lat) OVER w AS prev_lat,
LAG(lon) OVER w AS prev_lon,
LAG(ts) OVER w AS prev_ts,
LEAD(lat) OVER w AS next_lat,
LEAD(lon) OVER w AS next_lon,
LEAD(ts) OVER w AS next_ts
FROM bucketed
WINDOW w AS (PARTITION BY context, path, bucket_time ORDER BY ts)
),
scored AS (
SELECT
*,
(bucket_time + INTERVAL '${intervalSeconds * 500} milliseconds') AS bucket_mid,
CASE
WHEN prev_ts IS NULL THEN 1
WHEN ${haversine('prev_lat', 'prev_lon', 'lat', 'lon')} /
GREATEST(EXTRACT(EPOCH FROM (ts - prev_ts)), 0.001) <= ${POSITION_MAX_SPEED_MPS}
THEN 1
ELSE 0
END AS prev_ok,
CASE
WHEN next_ts IS NULL THEN 1
WHEN ${haversine('lat', 'lon', 'next_lat', 'next_lon')} /
GREATEST(EXTRACT(EPOCH FROM (next_ts - ts)), 0.001) <= ${POSITION_MAX_SPEED_MPS}
THEN 1
ELSE 0
END AS next_ok
FROM with_neighbors
),
ranked AS (
SELECT
*,
ROW_NUMBER() OVER (
PARTITION BY context, path, bucket_time
ORDER BY
(prev_ok + next_ok) DESC,
CASE WHEN ts >= bucket_mid THEN 0 ELSE 1 END,
ABS(EXTRACT(EPOCH FROM (ts - bucket_mid)))
) AS rn
FROM scored
),
bucket_stats AS (
SELECT
bucket_time,
context,
path,
SUM(src_sample_count)::BIGINT AS sample_count,
MIN(src_first_ts) AS first_timestamp,
MAX(src_last_ts) AS last_timestamp
FROM bucketed
GROUP BY bucket_time, context, path
)
SELECT
r.bucket_time,
r.context,
r.path,
r.lat AS value_latitude,
r.lon AS value_longitude,
NULL::DOUBLE AS value_min,
NULL::DOUBLE AS value_max,
s.sample_count,
s.first_timestamp,
s.last_timestamp
FROM ranked r
JOIN bucket_stats s
ON r.bucket_time = s.bucket_time
AND r.context = s.context
AND r.path = s.path
WHERE r.rn = 1
ORDER BY r.bucket_time
) TO '${outputFile}' (FORMAT PARQUET, COMPRESSION 'SNAPPY');
`;
}
/**
* Group files by context and path
*/
private groupFilesByContextPath(files: string[]): Map<string, string[]> {
const groups = new Map<string, string[]>();
for (const file of files) {
const parsed = this.hivePathBuilder.detectPathStyle(file);
if (parsed.isHive && parsed.context && parsed.signalkPath) {
const key = `${parsed.context}:${parsed.signalkPath}`;
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key)!.push(file);
}
}
return groups;
}
/**
* Parse a group key back to context and path
*/
private parseGroupKey(key: string): { context: string; signalkPath: string } {
const lastColon = key.lastIndexOf(':');
if (lastColon > 0) {
return {
context: key.substring(0, lastColon),
signalkPath: key.substring(lastColon + 1),
};
}
return { context: 'vessels.self', signalkPath: key };
}
/**
* Clean up old data based on retention settings.
*
* Per-tier retention defaults come from `config.retentionDays`. A
* value of 0 at any tier means "keep forever" — that tier is skipped
* entirely. Per-path overrides take precedence: when a file's path
* matches a retention rule, the rule's `days` (scaled by the tier
* multiplier) is used instead of the global tier default.
*
* Files outside the Hive layout (legacy flat storage) are not
* touched: there is no path/year/day to anchor a retention decision
* on.
*/
async cleanupOldData(): Promise<{
deletedFiles: number;
failedFiles: number;
freedBytes: number;
}> {
let deletedFiles = 0;
let failedFiles = 0;
let freedBytes = 0;
const now = new Date();
const hasPathRules = !this.retentionRules.isEmpty();
let processedSinceYield = 0;
for (const tier of TIER_HIERARCHY) {
if (this.cancelRequested) break;
const tierDefaultDays = this.config.retentionDays[tier];
// No tier default and no per-path overrides → nothing to do for
// this tier. With overrides present we still walk the tier so
// path-specific rules can act even when the global is infinite.
if (tierDefaultDays <= 0 && !hasPathRules) continue;
const tierMultiplier = TIER_RETENTION_MULTIPLIER[tier];
const pattern = path.join(
this.config.outputDirectory,
`tier=${tier}`,
'**',
'*.parquet'
);
const files = await glob(pattern);
for (const file of files) {
if (this.cancelRequested) break;
if (++processedSinceYield >= CLEANUP_YIELD_INTERVAL) {
processedSinceYield = 0;
await new Promise(resolve => setImmediate(resolve));
}
try {
const parsed = this.hivePathBuilder.detectPathStyle(file);
if (!parsed.isHive || !parsed.year || !parsed.dayOfYear) continue;
const effectiveDays = this.resolveEffectiveRetentionDays(
parsed.signalkPath,
tierDefaultDays,
tierMultiplier
);
// null means keep forever for this (path, tier) pair.
if (effectiveDays === null) continue;
// Compare at day granularity (midnight UTC). The partition's
// fileDate is midnight UTC; if cutoffDate kept the current
// time-of-day, a `days: 1` cleanup running mid-afternoon
// would already see "yesterday" as older than cutoff and
// delete it ~16 hours before its day was actually complete.
const cutoffDate = new Date(now);
cutoffDate.setUTCHours(0, 0, 0, 0);
cutoffDate.setUTCDate(cutoffDate.getUTCDate() - effectiveDays);
const fileDate = this.hivePathBuilder.dateFromDayOfYear(
parsed.year,
parsed.dayOfYear
);
if (fileDate < cutoffDate) {
const stats = await fs.stat(file);
await fs.remove(file);
freedBytes += stats.size;
deletedFiles++;
}
} catch (error) {
this.app.error(
`Failed to check/delete ${file}: ${(error as Error).message}`
);
failedFiles++;
}
}
}
this.app.debug(
`Cleanup: deleted ${deletedFiles} files, ${failedFiles} failed, freed ${(freedBytes / 1024 / 1024).toFixed(2)} MB`
);
return { deletedFiles, failedFiles, freedBytes };
}
/**
* Pick the retention to apply for one (path, tier) pair.
*
* - If a path-rule matches and `skipAggregation` is set: use rule.days
* for every tier (no multiplier). The path lives only in raw, so
* any stale rows in upper tiers should sweep at the same horizon
* as raw rather than linger 2/4/12x longer.
* - Else if a path-rule matches: use rule.days × tierMultiplier
* (rule.days is interpreted as raw-tier retention; upper tiers
* scale by the same convention as the global default).
* - Else: use the global tier default.
* - 0 (from either source) means infinite — return null so the caller
* skips deletion.
*/
private resolveEffectiveRetentionDays(
signalkPath: string | undefined,
tierDefaultDays: number,
tierMultiplier: number
): number | null {
if (signalkPath) {
const matched = this.retentionRules.match(signalkPath);
if (matched) {
if (matched.days <= 0) return null;
return matched.skipAggregation
? matched.days
: matched.days * tierMultiplier;
}
}
return tierDefaultDays > 0 ? tierDefaultDays : null;
}
/**
* Get current job progress
*/
getProgress(): AggregationProgress | null {
return this.currentJob;
}
/**
* Cancel current job
*/
cancel(): void {
this.cancelRequested = true;
}
/**
* Schedule daily aggregation (call from consolidation timer)
*/
async runDailyAggregation(): Promise<AggregationResult[]> {
const yesterday = new Date();
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
this.app.debug(
`Running daily aggregation for ${yesterday.toISOString().slice(0, 10)}`
);
const results = await this.aggregateDate(yesterday);
return results;
}
/**
* Discover all unique dates in tier=raw across all contexts and paths
*/
async discoverRawDates(startDate?: Date, endDate?: Date): Promise<Date[]> {
const rawDir = path.join(this.config.outputDirectory, 'tier=raw');
if (!(await fs.pathExists(rawDir))) return [];
const dayDirs = await glob(
path.join(rawDir, 'context=*', 'path=*', 'year=*', 'day=*')
);
const dateSet = new Set<string>();
for (const dir of dayDirs) {
const yearMatch = dir.match(/year=(\d{4})/);
const dayMatch = dir.match(/day=(\d{1,3})/);
if (yearMatch && dayMatch) {
const year = parseInt(yearMatch[1]);
const day = parseInt(dayMatch[1]);
const date = this.hivePathBuilder.dateFromDayOfYear(year, day);
const dateStr = date.toISOString().slice(0, 10);
if (startDate && date < startDate) continue;
if (endDate && date > endDate) continue;
dateSet.add(dateStr);
}
}
return Array.from(dateSet)
.sort()
.map(d => new Date(d + 'T00:00:00Z'));
}
/**
* Start bulk aggregation as a background job
*/
startBulkAggregation(startDate?: Date, endDate?: Date): string {
const jobId = `bulk_agg_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
const progress: BulkAggregationProgress = {
jobId,
status: 'scanning',
phase: 'scan',
datesProcessed: 0,
datesTotal: 0,
percent: 0,
filesCreated: 0,
recordsAggregated: 0,
startTime: new Date(),
errors: [],
};
bulkAggregationJobs.set(jobId, progress);
this.cancelRequested = false;
this.runBulkAggregation(jobId, startDate, endDate).catch(error => {
const job = bulkAggregationJobs.get(jobId);
if (job) {
job.status = 'error';
job.error = (error as Error).message;
job.completedAt = new Date();
}
});
return jobId;
}
/**
* Run bulk aggregation across all dates in tier=raw
*/
private async runBulkAggregation(
jobId: string,
startDate?: Date,
endDate?: Date
): Promise<void> {
const progress = bulkAggregationJobs.get(jobId);
if (!progress) return;
try {
// Phase 1: Discover dates
progress.phase = 'scan';
progress.status = 'scanning';
const dates = await this.discoverRawDates(startDate, endDate);
progress.datesTotal = dates.length;
if (dates.length === 0) {
progress.status = 'completed';
progress.completedAt = new Date();
scheduleBulkJobCleanup(jobId);
return;
}
this.app.debug(
`[BulkAggregation] Found ${dates.length} dates to aggregate (${dates[0].toISOString().slice(0, 10)} to ${dates[dates.length - 1].toISOString().slice(0, 10)})`
);
// Phase 2: Aggregate each date
progress.phase = 'aggregation';
progress.status = 'running';
for (let i = 0; i < dates.length; i++) {
if (this.cancelRequested) {
progress.status = 'cancelled';
progress.completedAt = new Date();
scheduleBulkJobCleanup(jobId);
return;
}
const date = dates[i];
const dateStr = date.toISOString().slice(0, 10);
progress.currentDate = dateStr;
progress.datesProcessed = i;
progress.percent = Math.round((i / dates.length) * 100);
try {
const results = await this.aggregateDate(date);
for (const r of results) {
progress.filesCreated += r.filesCreated;
progress.recordsAggregated += r.recordsAggregated;
if (r.errors.length > 0) {
progress.errors.push(...r.errors.map(e => `[${dateStr}] ${e}`));
}
}
} catch (error) {
const errorMsg = `[${dateStr}] ${(error as Error).message}`;
this.app.error(`[BulkAggregation] ${errorMsg}`);
progress.errors.push(errorMsg);
}
}
progress.datesProcessed = dates.length;
progress.percent = 100;
progress.status = 'completed';
progress.completedAt = new Date();
scheduleBulkJobCleanup(jobId);
this.app.debug(
`[BulkAggregation] Complete: ${dates.length} dates, ${progress.filesCreated} files created, ${progress.recordsAggregated} records aggregated`
);
} catch (error) {
progress.status = 'error';
progress.error = (error as Error).message;
progress.completedAt = new Date();
scheduleBulkJobCleanup(jobId);
}
}
/**
* Get bulk aggregation job progress
*/
getBulkProgress(jobId: string): BulkAggregationProgress | null {
return bulkAggregationJobs.get(jobId) || null;
}
/**
* Cancel a bulk aggregation job
*/
cancelBulk(jobId: string): boolean {
const job = bulkAggregationJobs.get(jobId);
if (job && (job.status === 'running' || job.status === 'scanning')) {
this.cancelRequested = true;
return true;
}
return false;
}
}