Skip to content

fix: too many file descriptors with thousands of notifications#1887

Merged
elibosley merged 3 commits intomainfrom
codex/troubleshoot-emfile-error-on-server
Mar 5, 2026
Merged

fix: too many file descriptors with thousands of notifications#1887
elibosley merged 3 commits intomainfrom
codex/troubleshoot-emfile-error-on-server

Conversation

@elibosley
Copy link
Member

@elibosley elibosley commented Mar 5, 2026

Motivation

  • Reduce noisy/rapid writes from the Redux store into the NestJS ConfigService by debouncing syncs and skipping unchanged state writes.
  • Make the notifications path configurable via Nest ConfigService instead of relying on store getters so tests can inject a deterministic path.

Description

  • Introduce STORE_SYNC_DEBOUNCE_MS, syncTimer, lastSyncedState, and scheduleSync() in StoreSyncService to debounce updates and skip writes when serialized state is unchanged, and clear timers on onModuleDestroy().
  • Add api/src/unraid-api/config/store-sync.service.spec.ts with mocked store and ConfigService using Vitest fake timers to verify debouncing, deduplication, and cleanup behavior.
  • Change NotificationsService to accept ConfigService in the constructor, add getConfiguredPath() and use it in paths() and constructor initialization rather than getters.dynamix().
  • Update notifications.service.spec.ts to provide a mocked ConfigService to the testing module in both test suites and remove the previous dynamic getters mocking approach so the tests use the injected path.

Testing

  • Ran unit tests with Vitest including the new store-sync.service.spec.ts which verifies debounced writes, unchanged-state skipping, and destroy cleanup, and these tests passed.
  • Ran the NotificationsService integration-style tests after injecting a mocked ConfigService, and those tests passed.

Codex Task

Summary by CodeRabbit

  • New Features

    • Debounced store synchronization to reduce redundant configuration writes during rapid state changes.
  • Improvements

    • Notification path handling now reads from centralized configuration, making paths configurable and more robust.
    • Ensured pending syncs are cancelled on shutdown to avoid stray updates.
  • Tests

    • Added tests covering debounced sync behavior, no-op on unchanged state, and cleanup on destroy.
    • Updated notification tests to use the new configuration approach.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 5, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: a3f021ba-edbc-479a-94ae-ab1e7ca13527

📥 Commits

Reviewing files that changed from the base of the PR and between 77d2266 and ef41d26.

📒 Files selected for processing (1)
  • api/src/unraid-api/graph/resolvers/notifications/notifications.module.ts

Disabled knowledge base sources:

  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.


Walkthrough

Adds a debounced StoreSyncService that schedules serialized store writes to ConfigService and avoids redundant writes; refactors NotificationsService to read its path from ConfigService and updates tests and plugin instantiation accordingly.

Changes

Cohort / File(s) Summary
StoreSyncService
api/src/unraid-api/config/store-sync.service.ts, api/src/unraid-api/config/store-sync.service.spec.ts
Adds debounced sync (STORE_SYNC_DEBOUNCE_MS), timer-based scheduleSync(), lastSyncedState comparison, syncTimer property, onModuleDestroy cleanup, and Vitest tests covering debounce, no-op on unchanged state, and teardown.
NotificationsService Refactor
api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts, api/src/unraid-api/graph/resolvers/notifications/notifications.service.spec.ts, api/src/unraid-api/graph/resolvers/notifications/loadNotificationsFile.test.ts, api/src/unraid-api/graph/resolvers/notifications/notifications.module.ts
Injects ConfigService into NotificationsService, adds getConfiguredPath() to read store.dynamix.notify.path (defaults to /tmp/notifications), updates module to import ConfigModule, and adapts tests to provide/mock ConfigService.
Plugin service update
api/src/unraid-api/plugin/plugin.service.ts
Instantiates NotificationsService with a ConfigService when handling invalid plugins to match the new constructor signature.

Sequence Diagram

sequenceDiagram
    actor Store
    participant StoreSyncService
    participant DebounceTimer as Timer
    participant ConfigService

    Store->>StoreSyncService: notify state change
    StoreSyncService->>StoreSyncService: clear existing timer
    StoreSyncService->>DebounceTimer: schedule timer (STORE_SYNC_DEBOUNCE_MS)
    Store->>StoreSyncService: rapid state change
    StoreSyncService->>StoreSyncService: clear & reschedule timer
    DebounceTimer->>StoreSyncService: timer fires
    StoreSyncService->>StoreSyncService: serialize state & compare lastSyncedState
    alt state changed
        StoreSyncService->>ConfigService: set('store', serialized)
        ConfigService-->>StoreSyncService: ack
        StoreSyncService->>StoreSyncService: update lastSyncedState
    else state unchanged
        StoreSyncService-->>StoreSyncService: skip write
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I hop and I debounce away the race,

Saved states tucked in a quiet place,
Timers cleared, no duplicate write,
Config hums softly through the night,
A tiny rabbit claps—what tidy grace!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: reducing frequent store writes through debouncing and deduplication, which directly addresses the EMFILE (too many file descriptors) error when handling thousands of notifications.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch codex/troubleshoot-emfile-error-on-server

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

@elibosley elibosley changed the title Debounce store sync, avoid redundant writes, and migrate NotificationsService to use ConfigService fix: too many file descriptors with thousands of notifications Mar 5, 2026
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 15c3fee023

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +59 to +60
constructor(private readonly configService: ConfigService) {
this.path = this.getConfiguredPath();

Choose a reason for hiding this comment

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

P2 Badge Keep NotificationsService constructible outside Nest DI

Making ConfigService a required constructor dependency breaks existing non-DI instantiation paths: PluginService.importPlugins still calls new NotificationsService() when plugin loading fails, so this.configService is undefined and getConfiguredPath() throws before createNotification runs. In that failure path, the original plugin-validation error gets masked and the alert notification is never created, which regresses diagnostics for invalid plugins.

Useful? React with 👍 / 👎.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
api/src/unraid-api/graph/resolvers/notifications/loadNotificationsFile.test.ts (1)

29-43: ⚠️ Potential issue | 🟡 Minor

Remove the unused notify.path property from the mock; the display settings are still needed but untested.

The notify.path property in the mock at line 32-34 is dead code—NotificationsService now reads the notification path from ConfigService via the injected config object. However, the display settings are still required: they're used in formatDatetime() (line 864 of notifications.service.ts) to format timestamps according to user preferences.

The test caveat is real: although getters.dynamix() is called for display settings, no test actually verifies that formattedTimestamp is formatted correctly using these settings. The valid notification test omits the formattedTimestamp assertion, and only edge-case tests check it.

Simplify the mock to remove the unused notify.path property:

Suggested mock adjustment
vi.mock('@app/store/index.js', () => ({
    getters: {
        dynamix: vi.fn().mockReturnValue({
            display: {
                date: 'Y-m-d',
                time: 'H:i:s',
            },
        }),
    },
}));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@api/src/unraid-api/graph/resolvers/notifications/loadNotificationsFile.test.ts`
around lines 29 - 43, The mock in the test uses an unused notify.path property;
update the vi.mock for '@app/store/index.js' to remove notify.path while keeping
getters.dynamix returning the display settings used by formatDatetime
(date/time), since NotificationsService now reads the notification path from
ConfigService; ensure the mock only includes getters.dynamix with the display
object so tests still exercise formattedTimestamp behavior referenced by
NotificationsService/formatDatetime.
🧹 Nitpick comments (1)
api/src/unraid-api/config/store-sync.service.ts (1)

8-8: Consider making debounce configurable or documenting the choice.

The 1-second debounce is reasonable for reducing file descriptor pressure. Consider adding a brief comment explaining why 1000ms was chosen, or making it configurable via environment variable for different deployment scenarios.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@api/src/unraid-api/config/store-sync.service.ts` at line 8, The constant
STORE_SYNC_DEBOUNCE_MS (currently 1000) should be made configurable or
documented: either add a brief inline comment above STORE_SYNC_DEBOUNCE_MS
explaining the 1000ms choice (to reduce file descriptor pressure and coalesce
rapid updates), or replace the hardcoded value with an environment-backed
setting (e.g., read process.env.STORE_SYNC_DEBOUNCE_MS, parseInt with a safe
default of 1000) and use that value wherever STORE_SYNC_DEBOUNCE_MS is
referenced so deployments can tune debounce behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@api/src/unraid-api/plugin/plugin.service.ts`:
- Line 54: The static importPlugins method instantiates ConfigService directly
which bypasses NestJS DI so NotificationsService(new ConfigService()) will use
defaults (e.g., /tmp/notifications); fix by either (A) changing importPlugins
signature to accept a properly injected ConfigService and pass that into
NotificationsService, or (B) replace the direct new with a module-level
singleton/lazy getter that is initialized from the DI context at startup, or (C)
if using the default path is intentional, add a clear comment in importPlugins
next to NotificationsService(new ConfigService()) stating this is deliberate and
that the notification path will fall back to the default.

---

Outside diff comments:
In
`@api/src/unraid-api/graph/resolvers/notifications/loadNotificationsFile.test.ts`:
- Around line 29-43: The mock in the test uses an unused notify.path property;
update the vi.mock for '@app/store/index.js' to remove notify.path while keeping
getters.dynamix returning the display settings used by formatDatetime
(date/time), since NotificationsService now reads the notification path from
ConfigService; ensure the mock only includes getters.dynamix with the display
object so tests still exercise formattedTimestamp behavior referenced by
NotificationsService/formatDatetime.

---

Nitpick comments:
In `@api/src/unraid-api/config/store-sync.service.ts`:
- Line 8: The constant STORE_SYNC_DEBOUNCE_MS (currently 1000) should be made
configurable or documented: either add a brief inline comment above
STORE_SYNC_DEBOUNCE_MS explaining the 1000ms choice (to reduce file descriptor
pressure and coalesce rapid updates), or replace the hardcoded value with an
environment-backed setting (e.g., read process.env.STORE_SYNC_DEBOUNCE_MS,
parseInt with a safe default of 1000) and use that value wherever
STORE_SYNC_DEBOUNCE_MS is referenced so deployments can tune debounce behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 5f004d54-6d8b-4c40-811f-e5c5dc1da050

📥 Commits

Reviewing files that changed from the base of the PR and between 90bbe9a and 77d2266.

📒 Files selected for processing (6)
  • api/src/unraid-api/config/store-sync.service.spec.ts
  • api/src/unraid-api/config/store-sync.service.ts
  • api/src/unraid-api/graph/resolvers/notifications/loadNotificationsFile.test.ts
  • api/src/unraid-api/graph/resolvers/notifications/notifications.service.spec.ts
  • api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts
  • api/src/unraid-api/plugin/plugin.service.ts

} catch (error) {
PluginService.logger.error(`Plugin from ${pkgName} is invalid: %o`, error as object);
const notificationService = new NotificationsService();
const notificationService = new NotificationsService(new ConfigService());
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Potential issue: manually instantiated ConfigService lacks configuration context.

Creating new ConfigService() directly bypasses NestJS dependency injection, meaning this ConfigService instance won't have access to the actual application configuration. The notification will use the default path (/tmp/notifications) rather than any configured path.

Since this is in a static method (importPlugins), proper DI is challenging. Consider one of these approaches:

  1. Accept the default path behavior for error notifications (simplest, may be acceptable)
  2. Pass ConfigService into the static method from a properly injected context
  3. Use a module-level singleton or lazy initialization pattern

If the default path is acceptable for plugin error notifications, consider adding a comment explaining this intentional behavior.

💡 Optional: Add clarifying comment
-                const notificationService = new NotificationsService(new ConfigService());
+                // Note: Uses default config path since we're outside DI context during plugin load
+                const notificationService = new NotificationsService(new ConfigService());
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@api/src/unraid-api/plugin/plugin.service.ts` at line 54, The static
importPlugins method instantiates ConfigService directly which bypasses NestJS
DI so NotificationsService(new ConfigService()) will use defaults (e.g.,
/tmp/notifications); fix by either (A) changing importPlugins signature to
accept a properly injected ConfigService and pass that into
NotificationsService, or (B) replace the direct new with a module-level
singleton/lazy getter that is initialized from the DI context at startup, or (C)
if using the default path is intentional, add a clear comment in importPlugins
next to NotificationsService(new ConfigService()) stating this is deliberate and
that the notification path will fall back to the default.

@codecov
Copy link

codecov bot commented Mar 5, 2026

Codecov Report

❌ Patch coverage is 97.14286% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 48.64%. Comparing base (14a8fa8) to head (ef41d26).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
api/src/unraid-api/plugin/plugin.service.ts 50.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1887      +/-   ##
==========================================
+ Coverage   48.62%   48.64%   +0.01%     
==========================================
  Files        1013     1013              
  Lines       67606    67635      +29     
  Branches     6925     6940      +15     
==========================================
+ Hits        32872    32898      +26     
- Misses      34613    34616       +3     
  Partials      121      121              

☔ View full report in Codecov by Sentry.
📢 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.

@github-actions
Copy link
Contributor

github-actions bot commented Mar 5, 2026

This plugin has been deployed to Cloudflare R2 and is available for testing.
Download it at this URL:

https://preview.dl.unraid.net/unraid-api/tag/PR1887/dynamix.unraid.net.plg

@elibosley elibosley merged commit 7956987 into main Mar 5, 2026
12 of 13 checks passed
@elibosley elibosley deleted the codex/troubleshoot-emfile-error-on-server branch March 5, 2026 20:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant