Skip to content

Purge orphaned DuckDB states automatically once at startup - #2651

Merged
Pierre-Gilles merged 7 commits into
GladysAssistant:masterfrom
Terdious:feat/purge-orphaned-duckdb-states
Jul 13, 2026
Merged

Purge orphaned DuckDB states automatically once at startup#2651
Pierre-Gilles merged 7 commits into
GladysAssistant:masterfrom
Terdious:feat/purge-orphaned-duckdb-states

Conversation

@Terdious

@Terdious Terdious commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Pull Request check-list

To ensure your Pull Request can be accepted as fast as possible, make sure to review and check all of these items:

  • If your changes affect the code, did you write the tests? (monthly-sliced purge over real DuckDB states, run-once flag semantics, empty-table path, startup trigger — new file at 100% lines)
  • Are server tests passing with coverage? (full npm run coverage green locally)
  • Did Cypress E2E tests pass? (background job, no UI)
  • Is the linter passing? (npm run eslint on front and server)
  • Did you run prettier?
  • If you are adding a new feature/service, did you run the integration comparator? (npm run compare-translations — the job type label is added to en/fr/de)
  • Did you test this pull request in real life? (validated on a 448M-state installation and by a second tester who purged 45.4M orphaned states — details in the comments)
  • If your changes modify the API (REST or Node.js), did you modify the API documentation? (no API change — the earlier manual route/button of this PR was removed after discussion)
  • If you are adding a new features/services which needs explanation, did you modify the user documentation? (automatic maintenance, visible in the jobs page)
  • Did you add fake requests data for the demo mode? (no request)

Description of change

Follow-up of #2650, reworked after the discussion on the dev channel: instead of a manual button, the cleanup of orphaned DuckDB states (states whose device_feature_id no longer exists — leftovers of devices/features deleted while the per-feature purge counted the wrong database) is a one-shot background job started automatically at startup.

  • Run-once via a system variable (DUCKDB_ORPHANED_STATES_PURGED, same pattern as the DuckDB migration), set only after a complete run: if Gladys restarts mid-purge, the job simply restarts at the next boot — deletes are idempotent, it only redoes the remaining work. No manual action ever needed.
  • As slow and gentle as possible: no upfront count — counting orphans over 448M states held the read connection for 15-20 minutes during testing, blocking every other read (charts, activity). Instead the history is walked in weekly created_at slices, and after each slice the purge sleeps 5x the time the slice took (duty cycle, capped at 60s): no DuckDB connection is ever held more than ~1-2 seconds, the average CPU/disk load stays around 1/6th, and Gladys stays responsive — field-tested on a 448M-state installation where fixed short pauses saturated the CPU for 17 minutes. Per-slice logs are greppable with the purge-orphaned-duckdb-states prefix. The parameters are cast to UUID explicitly to avoid a per-row VARCHAR cast.
  • Live progress: DuckDB returns the number of deleted rows per statement, so the job accumulates the purged count on the fly and reports the progress as a percentage of the time range walked. Fully visible in the "background jobs" page (nicely, with Jobs: attach structured data to background jobs, shown in the jobs page #2652).

Measured on a 448M-state installation: the previous count-first approach took ~30 minutes with long connection holds; the sliced walk holds each connection a few seconds at most.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added an idempotent one-time background cleanup task to purge orphaned DuckDB device state records, with execution progress reporting.
    • The task runs automatically during device initialization and safely no-ops after it completes once.
    • Added localized job labels for the new background task in English, German, and French.
  • Bug Fixes
    • Prevents orphaned DuckDB state entries from accumulating over time while preserving valid device states.
  • Tests
    • Added coverage for successful purge, already-completed runs, and empty-database scenarios.

Deleting a device or disabling a feature history did not always clean the
DuckDB states (the per-feature purge counted SQLite states since the DuckDB
migration), so installations can carry states which no longer belong to any
existing device feature. The Activity endpoint already filters them out
defensively, but they consume disk space forever.

Add a "purge orphaned DuckDB states" action in the DuckDB migration
settings card: it deletes every state whose device_feature_id no longer
exists, as a background job.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds an idempotent, weekly batched purge for orphaned DuckDB device states. The purge is registered as a background job and device event, runs during initialization, records completion in a system variable, includes tests, and adds German, English, and French job labels.

Changes

DuckDB orphan-state purge

Layer / File(s) Summary
Purge contract and storage logic
server/utils/constants.js, server/lib/device/device.purgeOrphanedDuckDbStates.js, server/test/lib/device/device.purgeOrphanedDuckDbStates.test.js
Defines purge identifiers, detects orphaned feature states, deletes them in weekly slices, tracks progress and completion, and tests normal, repeated, and empty-table cases.
Job, event, and initialization wiring
server/lib/device/index.js, server/lib/device/device.init.js, server/test/lib/device/device.init.test.js
Registers the purge job and event listener, exposes the DeviceManager method, invokes it during initialization, and verifies the call.
Background job translations
front/src/config/i18n/de.json, front/src/config/i18n/en.json, front/src/config/i18n/fr.json
Adds localized labels for the new background job type.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant DeviceInitialization
  participant DeviceManager
  participant DeviceFeature
  participant DuckDB
  participant JobProgress
  DeviceInitialization->>DeviceManager: Invoke orphaned-state purge
  DeviceManager->>DeviceFeature: Fetch existing feature IDs
  DeviceManager->>DuckDB: Query date range and delete orphaned rows weekly
  DeviceManager->>JobProgress: Update slice progress
  DeviceManager->>DeviceManager: Record purge completion flag
Loading

Poem

I’m a rabbit with rows to clear,
DuckDB crumbs disappear from here.
Batch by week, then hop along,
A tiny flag says “purge is done.”
Translations bloom in three tongues bright.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: an automatic one-time startup purge of orphaned DuckDB states.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jul 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.99%. Comparing base (21c15d4) to head (a10e111).

Additional details and impacted files
@@           Coverage Diff           @@
##           master    #2651   +/-   ##
=======================================
  Coverage   98.98%   98.99%           
=======================================
  Files        1056     1057    +1     
  Lines       21069    21116   +47     
=======================================
+ Hits        20856    20903   +47     
  Misses        213      213           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@server/test/lib/device/device.purgeOrphanedDuckDbStates.test.js`:
- Around line 16-64: Add a test in the device.purgeOrphanedDuckDbStates suite
covering the no-device-features case: clear all DeviceFeature records while
retaining seeded DuckDB states, invoke Device.purgeOrphanedDuckDbStates(), and
verify the result reports every state for deletion and that no rows remain in
t_device_feature_state. Ensure setup/cleanup remains isolated from the existing
tests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a973e7cb-89c2-427a-8e9d-f5dc336c09a0

📥 Commits

Reviewing files that changed from the base of the PR and between 51c7264 and de5929e.

📒 Files selected for processing (11)
  • front/src/config/i18n/de.json
  • front/src/config/i18n/en.json
  • front/src/config/i18n/fr.json
  • front/src/routes/settings/settings-system/SettingsSystemDuckDbMigration.jsx
  • server/api/controllers/device.controller.js
  • server/api/routes.js
  • server/lib/device/device.purgeOrphanedDuckDbStates.js
  • server/lib/device/index.js
  • server/test/controllers/device/device.controller.test.js
  • server/test/lib/device/device.purgeOrphanedDuckDbStates.test.js
  • server/utils/constants.js

Comment thread server/test/lib/device/device.purgeOrphanedDuckDbStates.test.js
Rework after discussion: instead of a manual button, the cleanup is a
one-shot background job started automatically at boot, gated by a system
variable (same pattern as the DuckDB migration). The variable is only set
after a complete run, so if Gladys restarts mid-purge the job restarts at
the next boot — deletes are idempotent.

Deliberately slow, as requested: no upfront count (counting orphans over
hundreds of millions of states held the read connection for 15-20 minutes
during testing), the history is walked in monthly slices with a pause
between each, so no DuckDB connection is ever held for long. DuckDB
returns the number of deleted rows per statement, so the purged count is
accumulated on the fly and reported by the job.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Terdious Terdious changed the title [WIP] Add a button to purge orphaned DuckDB states [WIP] Purge orphaned DuckDB states automatically once at startup Jul 11, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
server/test/lib/device/device.purgeOrphanedDuckDbStates.test.js (1)

30-82: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Still missing test for the "no device features exist" edge case.

The branch where featureIds.length === 0 (producing an empty orphanedClause that deletes all DuckDB states) remains untested. This was flagged in a previous review and has not been addressed. As per coding guidelines: "Assume 100% patch coverage for server changes; test every added branch, error path, helper, and modified line."

🧪 Suggested test using the existing buildDevice helper
   it('should set the flag without purging anything on an empty table', async () => {
     // ... existing test ...
   });
+  it('should purge all states when no device features exist', async () => {
+    // Remove all DeviceFeature records so featureIds is empty
+    await db.DeviceFeature.destroy({ where: {}, truncate: true });
+    const { device, variable } = buildDevice(null);
+    const res = await device.purgeOrphanedDuckDbStates();
+    expect(res).to.deep.equal({
+      numberOfOrphanedDuckDbStatesToDelete: 5, // 2 existing + 3 orphaned = all
+    });
+    const allStates = await db.duckDbReadConnectionAllAsync('SELECT * FROM t_device_feature_state');
+    expect(allStates).to.have.lengthOf(0);
+    assert.calledWith(variable.setValue, SYSTEM_VARIABLE_NAMES.DUCKDB_ORPHANED_STATES_PURGED, 'true');
+  });

Note: ensure DeviceFeature records are restored after this test to avoid polluting subsequent suites.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/test/lib/device/device.purgeOrphanedDuckDbStates.test.js` around lines
30 - 82, Add a test for the featureIds.length === 0 branch in
device.purgeOrphanedDuckDbStates, using buildDevice and a database state with no
DeviceFeature records, and verify all DuckDB states are purged and the purge
flag is set. Restore the DeviceFeature records in cleanup so this test does not
affect subsequent tests.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@server/test/lib/device/device.purgeOrphanedDuckDbStates.test.js`:
- Around line 30-82: Add a test for the featureIds.length === 0 branch in
device.purgeOrphanedDuckDbStates, using buildDevice and a database state with no
DeviceFeature records, and verify all DuckDB states are purged and the purge
flag is set. Restore the DeviceFeature records in cleanup so this test does not
affect subsequent tests.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e4a3ef8f-6cb1-4a4b-88d2-c7d3b3af0342

📥 Commits

Reviewing files that changed from the base of the PR and between de5929e and ced2c58.

📒 Files selected for processing (8)
  • front/src/config/i18n/de.json
  • front/src/config/i18n/en.json
  • front/src/config/i18n/fr.json
  • server/lib/device/device.init.js
  • server/lib/device/device.purgeOrphanedDuckDbStates.js
  • server/test/lib/device/device.init.test.js
  • server/test/lib/device/device.purgeOrphanedDuckDbStates.test.js
  • server/utils/constants.js
💤 Files with no reviewable changes (3)
  • front/src/config/i18n/en.json
  • front/src/config/i18n/de.json
  • front/src/config/i18n/fr.json

Field test on a 448M-state installation: monthly slices with a fixed 100ms
pause kept the CPU/disk saturated for 17 minutes — Gladys stayed up but
everything was slow, and each slice held the DuckDB write connection for
seconds, delaying live state processing (and scene triggers) accordingly.

- Weekly slices instead of monthly: the write connection is never held
  more than ~1-2s.
- Adaptive pause: after each slice, sleep 5x the time the slice took
  (capped at 60s), so the purge only ever uses ~1/6th of the resources.
  The one-shot purge takes a few times longer, which does not matter.
- Per-slice logs with a greppable prefix (purge-orphaned-duckdb-states)
  showing dates, deleted count, duration and pause.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@server/lib/device/device.purgeOrphanedDuckDbStates.js`:
- Around line 53-68: Update the feature-snapshot and delete flow in the purge
implementation to capture a purge cutoff before snapshotting feature IDs, then
constrain every slice’s DELETE—including the final unbounded slice—to states
created no later than that cutoff. Preserve the existing slice bounds and
completion behavior, and add a regression test that creates a feature and state
after the snapshot and verifies the state survives the purge.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fac09c4c-e9db-4dd5-b4b7-7806eb9d48bb

📥 Commits

Reviewing files that changed from the base of the PR and between ced2c58 and b26ed31.

📒 Files selected for processing (3)
  • server/lib/device/device.purgeOrphanedDuckDbStates.js
  • server/lib/device/index.js
  • server/test/lib/device/device.purgeOrphanedDuckDbStates.test.js
🚧 Files skipped from review as they are similar to previous changes (2)
  • server/lib/device/index.js
  • server/test/lib/device/device.purgeOrphanedDuckDbStates.test.js

Comment thread server/lib/device/device.purgeOrphanedDuckDbStates.js
Terdious and others added 2 commits July 12, 2026 09:05
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mass deletes accumulate delete-tracking memory and WAL until the next
checkpoint: flush explicitly at the end so the memory and the disk space
are released right away.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Terdious Terdious changed the title [WIP] Purge orphaned DuckDB states automatically once at startup Purge orphaned DuckDB states automatically once at startup Jul 12, 2026
@Terdious

Copy link
Copy Markdown
Contributor Author

Field-test results (two installations):

448M-state installation (i5 NUC-class server, shared with another Gladys + Home Assistant):

  • First iteration (fixed 100ms pauses, monthly slices): 17 minutes with CPU saturated at ~95% — everything was slow. This is what motivated the duty cycle.
  • Final version (weekly slices, sleep 5x slice duration): CPU in short sawtooth bursts, Activity view answered in ~125ms and energy dashboards in ~2s during the purge, scene latency unaffected. Cold-cache walk ~1h49, warm-cache ~29min.
  • Crash-recovery validated in real conditions: container stopped mid-purge, new image deployed — the purge restarted automatically at the next boot, and a final restart after completion logs Orphaned DuckDB states already purged. Not purging. with no job created.
  • Memory note: during the purge the node RSS can exceed the DuckDB memory_limit (delete tracking + allocator retention, observed ~9GiB for a 4.3GiB limit); the final CHECKPOINT flushes the WAL, and the retained allocator memory is reclaimed at the next restart. Worth knowing for very large databases, not blocking.

Second tester (laptop, Fedora, ~228M states): purged 45.4M orphaned states (~20% of the database!) in ~29 minutes, "no slowdown, maybe +2s on the Activity view" — deleted features and a whole device were correctly picked up by the per-feature purge (#2650) with counts displayed per job (#2652).

The orphan volume found in the wild confirms the point of running this cleanup automatically for everyone.

Terdious and others added 2 commits July 13, 2026 17:38
The feature list is snapshotted when the purge starts, but the purge runs
for a long time by design: states of a feature created while it runs
matched the stale NOT IN list in the (previously unbounded) last slice and
were deleted. Reported by CodeRabbit.

Bound every slice — including the last one — by the purge start date, and
clamp the walked range to it too so even future-dated states (skewed
device clocks) can never be evaluated against the stale snapshot. States
orphaned after the cutoff are handled by the per-feature purge from now
on, so nothing is left behind.

Also add the missing test for the no-feature-left branch (empty NOT IN
clause: every state is orphaned).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Pierre-Gilles
Pierre-Gilles merged commit d43c7b3 into GladysAssistant:master Jul 13, 2026
8 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