Repository navigation
Implement time-series data compaction with automated downsampling, retention policies, and continuous aggregates for cost-effective long-term historical storage #257
Description
Difficulty
10/10 — Expert. Estimated effort: 5–7 days for a senior engineer.
Context
The storage adapter (issue 10) persists every location update as a raw row in location_events. A fleet of 10,000 vehicles sending 1 update/second generates 864M rows/day (74B rows/90 days). At ~200 bytes/row, that's 170 GB/day raw — completely unsustainable for PostgreSQL without compaction.
The README.md mentions "persisting historical tracks" (line 37) but has no retention, downsampling, or compaction strategy. Querying raw data for a 30-day route history returns millions of points — unusable for visualization (browser can't render 1M points) and slow for analysis.
Production fleet tracking requires:
- Raw retention: 7 days (for replay, exactly-once, geofence audit).
- Downsampled retention: 1 minute resolution for 90 days (for route visualization).
- Hourly aggregates: 1 year (for analytics: distance, speed, idle time, geofence dwell).
- Continuous aggregates: Materialized views that auto-refresh, so dashboards query pre-computed data.
Problem statement
Design and implement a time-series compaction subsystem integrated with the storage adapter that:
-
Tiered retention policies: Configurable per room (or globally):
rawRetentionDays: 7— keep every point.downsample1mRetentionDays: 90— keep 1-minute downsampled (median/mean position per minute).downsample1hRetentionDays: 365— keep 1-hour aggregates (count, min/max/avg speed, distance, bounding box).aggregate1dRetentionDays: 2555(7 years) — daily rollups for compliance.
-
Automated downsampling jobs: Background workers (pg_cron or Node.js timers) that:
- 1-minute downsample: For each room, each minute bucket, compute:
median(lat),median(lon),avg(altitude),avg(accuracy),max(speed),count(*) as pointCount,min(timestamp) as bucketStart,max(timestamp) as bucketEnd. Store inlocation_events_1m(hypertable or partitioned table). - 1-hour aggregate: From 1m table, compute:
sum(pointCount),sum(distance)(haversine between consecutive median points),avg(speed),max(speed),min(speed),st_makelineof median points for route geometry. - Daily rollup: From 1h table, compute daily totals.
- 1-minute downsample: For each room, each minute bucket, compute:
-
Continuous aggregates (TimescaleDB native): If Postgres has TimescaleDB extension, use
CREATE MATERIALIZED VIEW ... WITH (timescaledb.continuous) REFRESH ...for automatic maintenance. If not, implement equivalent with pg_cron + manual refresh. -
Transparent query routing:
queryRoom(roomId, { from, to, limit, resolution })whereresolutioncan be"raw" | "1m" | "1h" | "1d" | "auto"."auto"selects the finest resolution that returns ≤limitpoints (default 1000). This allows dashboards to request "last 30 days" and get 1-hour aggregates automatically. -
Data lifecycle management:
- Raw data deletion:
DELETE FROM location_events WHERE timestamp < now() - rawRetentionDays(partitioned by day —DROP PARTITIONis instant). - Downsampled data deletion: same pattern for each tier.
- Compaction must not block writes — use
pg_sleepbetween batches, orDELETE ... WHERE ctid IN (SELECT ctid FROM ... LIMIT 10000)loops.
- Raw data deletion:
-
Storage adapter interface extension: New methods:
compact(rawRetentionDays, downsample1mRetentionDays, ...): Promise<CompactionResult>queryRoom(roomId, options: { from, to, limit, resolution }): Promise<LocationEvent[]>getCompactionStatus(): Promise<{ lastRun, nextRun, tiers: [{ name, rows, sizeBytes, oldest, newest }] }>
-
Metrics and observability:
storage_compaction_duration_seconds{phase="raw_delete|downsample_1m|aggregate_1h"}storage_tier_rows{tier="raw|1m|1h|1d"}storage_tier_size_bytes{tier="raw|1m|1h|1d"}storage_compaction_lag_minutes{tier="1m|1h|1d"}— how far behind the continuous aggregate is.
Current behavior
src/storage/postgres.js: singlelocation_eventstable, no partitioning, no downsampling, no retention.src/storage/adapter.js: interface hasqueryRoom,querySpatial,getLatest,writeBatch— no resolution parameter, no compaction.- No background workers, no pg_cron, no TimescaleDB usage.
docker-compose.yml: plain Postgres 16, no TimescaleDB.
Required behavior
src/storage/postgres.jsextended with:- Table partitioning:
location_eventspartitioned byDAY(timestamp)(native PG16 declarative partitioning). - Downsample tables:
location_events_1m,location_events_1h,location_events_1d— also partitioned. - Compaction job:
async runCompaction()called every 5 minutes (configurable). UsesLIMITbatches to avoid long locks. - Continuous aggregate setup: if
TIMESCALEDB_ENABLED=true, create materialized views with continuous refresh. Else, manual refresh in compaction job. queryRoomwithresolutionparameter: routes to appropriate table based on time range and resolution.
- Table partitioning:
src/storage/memory.js: in-memory implementation of downsampling (for tests) — simple array reduction.src/storage/adapter.js: extended interface with new methods.- Configuration:
STORAGE_RAW_RETENTION_DAYS,STORAGE_1M_RETENTION_DAYS,STORAGE_1H_RETENTION_DAYS,STORAGE_1D_RETENTION_DAYS,STORAGE_COMPACTION_INTERVAL_MS,STORAGE_COMPACTION_BATCH_SIZE. - Migration script:
src/storage/migration-compaction.sqlfor existing deployments.
Constraints
- Do not modify
server.js,room-manager.js,validator.js,auth.js,rate-limiter.js,conn-rate-limiter.js,logger.js,errors.js. - Do not modify existing test files. New test files required.
- Add
pg-cronas a Postgres extension (not npm dependency) — document indocker-compose.ymlto usetimescale/timescaledb:latest-pg16image. - If TimescaleDB not used, implement manual continuous aggregates with triggers or pg_cron — no new npm deps.
- Compaction must be idempotent and resumable — if killed mid-run, next run continues.
- Raw data deletion must use partition
DROP(instant) notDELETE(slow). Requires declarative partitioning. - Downsampling queries must be efficient: use
LATERALjoins or window functions to compute median without sorting entire partition. - Memory usage during compaction: ≤ 100MB (stream results, don't load entire partition).
- The
queryRoomresolution routing must be backwards compatible — defaultresolution: "auto"preserves current behavior for existing callers.
Acceptance criteria
-
location_eventstable is partitioned by day (verify with\d+ location_events) -
runCompaction()deletes raw partitions older thanrawRetentionDaysviaDROP PARTITION -
runCompaction()populateslocation_events_1mwith correct median positions per minute per room -
runCompaction()populateslocation_events_1hwith correct aggregates (distance, speed, count) -
queryRoom(roomId, { from: -30d, to: now, resolution: "auto" })returns ~720 points (1h resolution) not 2.5M raw points -
queryRoom(roomId, { from: -1h, to: now, resolution: "raw" })returns raw points -
queryRoom(roomId, { from: -30d, to: now, resolution: "1m" })returns 1-minute downsampled - Continuous aggregate (if TimescaleDB) refreshes automatically within 5 minutes of new data
- Metrics:
storage_tier_rowsshows correct counts for each tier - Compaction job completes in <60s for 100M raw rows (partition drop is instant; downsample is the cost)
-
npm run lintpasses - All existing tests pass (MemoryAdapter implements same interface)
- New test file:
tests/storage-compaction.test.jswith integration tests (requires TimescaleDB or pg_cron)
Out of scope
- TimescaleDB licensing/commercial features — use Apache 2.0 features only (continuous aggregates are free).
- Cross-room spatial aggregates (heatmaps) — single-room only for this issue.
- Real-time materialized view refresh (continuous aggregates have ~1min lag — acceptable).
- Data export / backup — separate concern.
- Compression (PostgreSQL
pglzorzstdon partitions) — optional optimization.
Hints and references
- PG16 declarative partitioning:
CREATE TABLE location_events ( ..., timestamp TIMESTAMPTZ NOT NULL ) PARTITION BY RANGE (timestamp); -- Create partitions for each day: CREATE TABLE location_events_2026_01_15 PARTITION OF location_events FOR VALUES FROM ('2026-01-15') TO ('2026-01-16'); -- Drop old partition: ALTER TABLE location_events DETACH PARTITION location_events_2026_01_01; DROP TABLE location_events_2026_01_01;
- Median in Postgres:
percentile_cont(0.5) WITHIN GROUP (ORDER BY latitude)— but this is slow on large partitions. Better:approximate_percentilefrompostgresql-hllor usepercentile_discwithGROUP BY minute_bucket. - 1-minute bucket:
date_trunc('minute', timestamp) as bucket. - Haversine distance in SQL:
6371000 * 2 * asin(sqrt( sin((lat2 - lat1)/2)^2 + cos(lat1)*cos(lat2)*sin((lon2 - lon1)/2)^2 ))
- For continuous aggregates (TimescaleDB):
CREATE MATERIALIZED VIEW location_events_1m WITH (timescaledb.continuous) AS SELECT room_id, date_trunc('minute', timestamp) as bucket, percentile_cont(0.5) WITHIN GROUP (ORDER BY latitude) as lat, percentile_cont(0.5) WITHIN GROUP (ORDER BY longitude) as lon, count(*) as point_count FROM location_events GROUP BY room_id, bucket; REFRESH MATERIALIZED VIEW location_events_1m; -- or continuous auto-refresh
- For manual refresh without TimescaleDB: pg_cron job every 5 min runs
REFRESH MATERIALIZED VIEW CONCURRENTLY location_events_1m;. queryRoomresolution routing logic:const range = to - from; if (resolution === "auto") { if (range < 3600000) resolution = "raw"; // < 1 hour else if (range < 86400000) resolution = "1m"; // < 1 day else if (range < 604800000) resolution = "1h"; // < 1 week else resolution = "1d"; } const table = `location_events_${resolution === "raw" ? "" : resolution}`;
Activity
daddygokings-art commentedon Aug 17, 2026
I have previously worked on similar features/issues and am confident I can deliver a quality solution. Kindly assign this issue to me.
grantfox-oss commentedon Aug 17, 2026
🦊 GrantFox — @daddygokings-art has been assigned to this issue as part of the Third Campaign campaign!
Next steps:
Closes #257)Good luck! Track your progress on GrantFox.
feat(storage): implement time-series compaction with downsampling and…
grantfox-oss commentedon Aug 18, 2026
🎉 This issue has been marked as completed on GrantFox as part of the Third Campaign campaign!
@daddygokings-art's PR #269 was approved and merged by @levibliz.
🏆 @daddygokings-art: You earned 35 FoxPoints for this contribution! Your current tier: Explorer (48 total points). Track your full progress on GrantFox.
👏 Great work, @daddygokings-art! Keep contributing to RiftCore00.
feat(storage): implement time-series compaction with downsampling and…