Skip to content

when creating timeseries_data, never create unnecessary indexes#1644

Merged
fredclausen merged 61 commits intosdr-enthusiasts:mainfrom
wiedehopf:main
Mar 24, 2026
Merged

when creating timeseries_data, never create unnecessary indexes#1644
fredclausen merged 61 commits intosdr-enthusiasts:mainfrom
wiedehopf:main

Conversation

@wiedehopf
Copy link
Copy Markdown
Contributor

save space and time during migration process

…ffer

## Summary

Implements two complementary memory optimizations to reduce peak heap
usage and eliminate expensive repeated work on client connect.

## Changes

### Backend — Tiered timeseries cache (utils/timeseries.ts, services/timeseries-cache.ts)

- Added CacheTier type (warm | lazy | query-only) and tier field in
  PERIOD_CONFIG per period.
- Exported WARM_PERIODS constant (1hr, 6hr, 12hr) derived from config.
- Tier 1 (warm): 1hr, 6hr, 12hr — kept warm in RAM, refreshed on
  wall-clock schedule, pushed to all clients.
- Tier 2 (lazy, TTL): 24hr, 1wk, 30day — query on first request, cached
  with TTL equal to refresh interval; not pushed unsolicited.
- Tier 3 (query-only): 6mon, 1yr — always queried on demand; never
  stored in RAM on a timer. Eliminates the previously scheduled 1yr
  rebuild that caused ~420 MB periodic heap spikes.
- getOrQueryTimeSeries(period) routes requests to the correct tier.

### Backend — Message ring buffer (services/message-ring-buffer.ts)

- RingBuffer<T> implementation with fixed capacity and FIFO eviction.
- Module-level push/get API: pushMessage, pushAlert (alert dedupe by
  uid), getRecentMessages, getRecentAlerts.
- initMessageBuffers / warmMessageBuffers — seeds buffers at startup
  from DB (one read, no re-enrichment on connect).
- resetMessageBuffersForTesting — test isolation helper.

### Backend — Wiring (services/index.ts, socket/handlers.ts, server.ts)

- setupMessageQueue pushes enriched messages/alerts to ring buffers
  immediately after enrichment.
- handleConnect reads from ring buffers instead of querying DB and
  re-enriching on every connect.
- server.ts invokes initMessageBuffers + warmMessageBuffers at startup.

### Frontend (types/timeseries.ts, hooks/useSocketIO.ts, hooks/useRRDTimeSeriesData.ts)

- Exported WARM_PERIODS from types.
- useSocketIO: on connect emits rrd_timeseries only for WARM_PERIODS
  (3 requests instead of 8).
- useRRDTimeSeriesData: for non-warm periods, emits rrd_timeseries on
  cache miss (lazy on-demand instead of eager on connect).

## Test coverage

- Backend: 34 test files, 1033 tests passed (4 skipped)
- Frontend: 36 test files, 1401 tests passed
- New: message-ring-buffer unit + integration tests (891 lines)
- Updated: timeseries-cache, handlers, integration, timeseries utils,
  useRRDTimeSeriesData, useSocketIO tests

## Memory impact

- Removes periodic 1yr/6mon in-memory rebuild (was ~420 MB spike every 12 hr).
- Connect path: zero DB queries, zero re-enrichment per connect.
- Ring buffer overhead: bounded, small (defaults 250 msgs + 100 alerts).
…tches

Previously regenerateAllAlertMatches() called:

  db.select().from(messages).all()

which materialises the entire messages table into a JS array at once.
On a large database (500k+ rows) this causes a significant transient
heap spike at exactly the moment when this already-expensive operation
is under load.

Two fixes applied together:

1. CURSOR-PAGINATED READS (WHERE id > lastId ORDER BY id LIMIT 1000)
   Peak heap for the message array is now O(BATCH_SIZE) rather than
   O(total messages). The INTEGER PRIMARY KEY rowid index makes each
   page fetch O(log N) — no O(offset) cost on later pages as there
   would be with LIMIT/OFFSET.

2. SINGLE better-sqlite3 TRANSACTION
   The DELETE + resetAlertCounts + all INSERT/UPDATE writes are wrapped
   in one conn.transaction() call. Benefits:
   - Atomicity: a crash or error mid-run rolls back the DELETE so the
     database is never left in a partially-cleared state.
   - Throughput: bulk inserts in one transaction are ~100-1000x faster
     than autocommit because each autocommit write is a separate
     fsync-visible journal flush.

Additional cleanup:
- saveAlertMatch closure promoted out of the per-message loop (defined
  once, takes message as a parameter).
- console.error replaced with logger.error.

Tests added:
- regression: processes all messages when count exceeds BATCH_SIZE (1000)
- regression: messages on a page boundary (exactly BATCH_SIZE) are all processed
- regression: non-matching messages on later pages are not counted as matched
- regression: entire regeneration is atomic (trigger-forced rollback
  verifies the DELETE is rolled back when the transaction aborts)

beforeEach now also mocks getSqliteConnection (needed by conn.transaction()).

All 1037 backend tests pass.
Merge of upstream PR sdr-enthusiasts#1644 (wiedehopf).

WHAT CHANGED
------------
Migration 9 created two indexes on timeseries_stats at table-creation time:

  CREATE INDEX idx_timeseries_timestamp_resolution ON timeseries_stats (timestamp, resolution);
  CREATE INDEX idx_timeseries_resolution            ON timeseries_stats (resolution);

Both indexes were always dead weight:
- migration 11 dropped idx_timeseries_timestamp_resolution and recreated
  it as a UNIQUE index — work immediately undone by migration 12.
- migration 12 dropped both indexes unconditionally and rebuilt the table
  with timestamp as INTEGER PRIMARY KEY (the rowid alias), making both
  indexes redundant anyway.

Creating them in migration 9 only to discard them in 12 wastes time and
disk space proportional to the number of timeseries rows — significant on
a DB that has been running for months.

CHANGES
-------
- migration 9:  remove both CREATE INDEX calls.
- migration 11: remove the 'Step 2: Replace non-unique index with unique
  index' block — it created a UNIQUE index that migration 12 immediately
  dropped, achieving nothing.
- migration 12: bare DROP INDEX → DROP INDEX IF EXISTS.  Without this fix
  a fresh install (migration 9 never created the indexes) would hit
  migration 12 with no indexes to drop and throw.  IF EXISTS makes the
  drop a safe no-op when the index is absent.
- drizzle/0001_add_timeseries_stats.sql: remove matching CREATE INDEX lines
  (reference artifact, not executed at runtime).
- dev-docs/TIMESERIES_STRATEGY.md: remove index SQL from schema example.

REGRESSION TEST ADDED
---------------------
'regression: migration 12 DROP INDEX IF EXISTS is safe when indexes were
never created (fresh install path)' — seeds a migration-11 state database
WITHOUT the two indexes (the state produced by the updated migration 9)
and asserts that runMigrations() does not throw and produces the correct
final schema.
also prevent them from being created during earlier migrations
@wiedehopf wiedehopf force-pushed the main branch 6 times, most recently from 09963bf to f262d8b Compare March 16, 2026 16:18
fredclausen and others added 12 commits March 24, 2026 08:32
…hort viewports

The CSS media query hiding .page__header below 800px viewport height
causes h1 heading assertions to fail in Playwright's default 720px
viewport. Replace with always-visible content-area selectors.
…visible

Tests checking .page__stats and Mark All Read button need the page
header which is hidden at viewport heights below 800px. Set viewport
height to 900px at the start of those 3 tests.
…ct log levels

The Dockerfile default was MIN_LOG_LEVEL=3 (warn), which suppressed all
info-level output. Commit b286f1c worked around this by promoting
info messages to warn. Fix the root cause by setting the default to 4
(info) and reverting the log level changes back to their correct
severity.
The scale config had the numeric formatter and 'Count' title on x (the
category axis) instead of y (the value axis). This caused Chart.js to
display index numbers (0, 1, 2) instead of the label strings (Good
Messages, Errors, Total). Swap the scale configs and fix tooltip to
read parsed.y for vertical bars.
…age__stats

Same root cause as the regular e2e fixes: .page__header is hidden at
viewport heights below 800px, and Desktop Chrome defaults to 720px.

Also removes deprecated baseUrl from tsconfig.app.json — TypeScript 6
resolves paths relative to the tsconfig directory by default.
@fredclausen fredclausen merged commit 48f341f into sdr-enthusiasts:main Mar 24, 2026
13 of 15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants