Skip to content

feat: sweep expired notification channels from storage - #2222

Open
jeswr wants to merge 5 commits into
CommunitySolidServer:mainfrom
jeswr:feat/notification-channel-sweep-next-major
Open

jeswr wants to merge 5 commits into
CommunitySolidServer:mainfrom
jeswr:feat/notification-channel-sweep-next-major

Conversation

@jeswr

@jeswr jeswr commented Aug 22, 2026 •

Copy link
Copy Markdown
Contributor

📁 Related issues

Related to #2220.

✍️ Description

Expired notification channels whose topics go quiet can remain in storage indefinitely. Add a configurable background sweep that removes expired channel records and their topic-index entries.

The sweep runs every 60 minutes by default, with up to 15% jitter. Setting sweepInterval to 0 disables it. Sweeps cannot overlap within a storage instance, and each candidate is re-read and checked under its channel write lock before deletion so a concurrent renewal is preserved. Updates that recreate a removed channel also restore its topic index.

The timer is unreferenced. The bundled notification storage configuration registers a finalizer that stops future sweeps and waits for the active sweep before backend cleanup. Custom configurations that instantiate this storage independently can register the same finalizer for shutdown cleanup.

This branch is rebased onto main, using its existing logging module, v7 component context, and v7.3.0 release notes. NotificationChannelStorage and the stored data format are unchanged; the new constructor arguments are optional. Suggested classification: semver.minor.

The rebase also exposed an existing incompatibility between main's locked stream wrapper and asynchronous N3 stream iteration: the sweep could hang during enumeration and prevent shutdown. The focused fix delegates asynchronous iteration to the original stream while renewing the lock for each chunk. A regression test covers delayed N3 data and lock release, and the unchanged expiry integration test now completes shutdown.

Each sweep enumerates the backing key-value storage, so its cost depends on the selected backend. Operators can lengthen the interval or disable sweeping.

Validation

On Node 22.21.1:

  • Build and generated component metadata passed; TypeScript build rerun after the stream fix.
  • Full ESLint, Markdown lint, and test type checking passed.
  • Full unit suite: 358 suites, 2,422 tests passed, with 100% coverage.
  • WebSocket, webhook, streaming HTTP, expiry cleanup, and lock-lifecycle integration suites: 5 suites, 61 tests passed.
  • git diff --check passed.

npm run validate still fails parsing generated AuxiliaryLinkMetadataWriter metadata. The identical error was reproduced after building a clean archive of the target main commit (7a09132fc), so it is not introduced by this PR.

✅ PR check list

  • Maintainer to confirm the semver label; proposed semver.minor.
  • Maintainer to confirm main as the target for this additive feature.
  • Release notes updated in the v7.3.0 section.
  • Sweep configuration and shutdown behavior documented.

@jeswr jeswr left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is a configuration change so I expect there to be updates to the release notes.

@jeswr

jeswr commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the release-note feedback in a79eb6a by documenting the default sweep interval and jitter, the disable option, and the Finalizer requirement for custom configurations.

@jeswr

jeswr commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

Fixed the CI failures in f33ba77. The notification storage config used ParallelHandler and FinalizableHandler without importing the asynchronous-handlers JSON-LD context, causing all configuration-backed suites to fail before server startup. The failed integration repro and all notification-storage unit tests now pass locally.

@jeswr
jeswr marked this pull request as ready for review August 22, 2026 15:38
Copilot AI lite review requested due to automatic review settings August 22, 2026 15:38

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds configurable, jittered background cleanup for expired notification channels and topic-index entries.

Changes:

  • Adds periodic expiry sweeping with renewal revalidation.
  • Registers timer cleanup during finalization.
  • Adds configuration, tests, and release notes.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Summary
test/unit/server/notifications/KeyValueChannelStorage.test.ts Adds coverage for sweeping, cleanup, races, and finalization.
src/server/notifications/KeyValueChannelStorage.ts Implements sweeping and timer management. Two moderate issues remain: await in-flight sweeps during finalization and prevent overlapping sweeps.
RELEASE_NOTES.md Documents the new behavior and configuration.
config/http/notifications/base/storage.json Registers storage finalization.
Suppressed comments (1)

src/server/notifications/KeyValueChannelStorage.ts:160

  • Because update shares this identifier lock but accepts a missing oldChannel, an update that starts after this lock is acquired can run after the deletion and recreate only the channel record; update does not restore the topic index in that path. The renewed channel then disappears from getAll(topic) even though get(id) finds it. Please make the update/delete protocol preserve the index (or reject updates that race with deletion).
      await this.locker.withWriteLock(this.getLockKey(id), async(): Promise<void> => {
        const channel = await this.storage.get(encodeURIComponent(id));
        if (channel && this.isChannel(channel) && typeof channel.endAt === 'number' && channel.endAt < Date.now()) {

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/server/notifications/KeyValueChannelStorage.ts
Comment thread src/server/notifications/KeyValueChannelStorage.ts
@jeswr
jeswr marked this pull request as draft August 22, 2026 15:47
@jeswr

jeswr commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

Also addressed the update/delete race noted in the review summary: when an update acquires the channel lock after deletion, it now restores both the channel record and its topic-index entry. The regression test verifies the two records remain consistent.

@jeswr
jeswr marked this pull request as ready for review August 22, 2026 19:57

public async finalize(): Promise<void> {
if (this.timer) {
clearInterval(this.timer);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

From what I see, the reason this is a breaking change is because the interface of this class changes to also be a Finalizable. This is done because the timer is cleared at the end. But I don't think this is actually necessary. I was going to suggest to just call this.timer.unref, but you already do that, so there is no reason to worry about clearing the timer any more at the end. Similarly, there is no need to await the active sweep promise. The point of the finalize function is to clean stuff up that would cause issues if the server is restarted (e.g., cleaning the lock folder, which is something for which this is used). So if the finalize stuff is removed, this could then also target the main branch as that would make it no longer breaking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

There is a distinction here between letting the process exit and stopping background work. timer.unref() only prevents the timer from keeping Node.js alive; it does not cancel the interval. If other work keeps the process alive after app.stop(), the old storage would continue sweeping without clearInterval().

There is also the in-flight case: clearing the interval does not stop a sweep that is already enumerating or deleting data. CSS runs Finalizer before CleanupFinalizer; the latter removes filesystem locks or closes the Redis locker. Waiting for the active sweep prevents it from accessing those dependencies after cleanup. This is why I have retained finalization and the current target branch.

I have documented that distinction and added fake-timer regressions for stopping later sweeps and awaiting pending deletions. The sweep error path also lets shutdown cleanup continue when an active sweep fails, with the error logged by setSafeInterval.

Reference: Node.js timer documentation.

Implemented in 8320aacc9. Build, lint, type checks, 2,288 unit tests, and 805 integration tests passed. Three Docker-dependent integration suites were skipped.

@jeswr jeswr Sep 17, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for pointing out async that the goal here was to help make this a non-breaking change. I'll push changes to use unref.

Do you see value in having the finaliser design for the next mver as well (which I can open a separate PR for) - or shall we just drop the idea?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do you see value in having the finaliser design for the next mver as well (which I can open a separate PR for) - or shall we just drop the idea?

It's one of those things where it technically might be more correct to do it, but I wonder if it's worth the extra interface/configuration and other hassle. The finalize functions only triggers when the server is shutting down, which, unless you're running tests, is something that doesn't happen that often anyway (and it would have to happen specifically during such a run to even potentially cause an issue). And if it takes the long I see users being more likely to just force exit the process anyway. The main issue is potentially some inconsistencies in the lock folder, but that one gets cleared anyway on server start to prevent issues. So I would not look into this unless it turns out there actually are issues being caused by this.

Comment thread test/unit/server/notifications/KeyValueChannelStorage.test.ts Outdated
@jeswr

jeswr commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

@joachimvh I have addressed your comments. This is ready for re-review.

Periodically remove expired notification channels that would otherwise remain forever when their topics go quiet. Revalidate each candidate under its channel lock before deletion, keep active and indefinite channels, jitter and unref the timer, and clear it through the configured finalizer.
Keep finalization so sweeps stop and drain before backend cleanup, even
when other work keeps the process alive after the server stops. Let the
timer helper log sweep failures without blocking shutdown cleanup.

Use Jest fake timers to exercise scheduled sweeps and shutdown behavior,
and document why unreferencing the timer does not replace finalization.
Delegate asynchronous iteration to the original stream so N3 readable callbacks retain the correct stream identity. Renew the lock for each yielded chunk. This prevents notification sweeps over the streamed key-value backend from hanging during shutdown.
@jeswr
jeswr force-pushed the feat/notification-channel-sweep-next-major branch from 8320aac to 862fc7a Compare September 27, 2026 18:29
@jeswr
jeswr changed the base branch from versions/next-major to main September 27, 2026 18:29
@jeswr

jeswr commented Sep 28, 2026

Copy link
Copy Markdown
Contributor Author

@joachimvh I've taken a closer look at this and ended up just rebasing this PR to the main branch.

It is actually a minor change. The reason that the LLM I was working with had suggested next/major previously is because the PR template states:

Patch updates can target main, other changes should target the latest versions/* branch.

@joachimvh

Copy link
Copy Markdown
Member

@jeswr there might have been a misunderstanding. Because I see the PR is still making KeyValueChannelStorage a Finalizable, which is a breaking change. For example, anyone extending that class in their repository would get an error after this change that they are not implementing the required method. My point was that I would simply not make this class finalizable.

@jeswr

jeswr commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

For example, anyone extending that class in their repository would get an error after this change that they are not implementing the required method.

It's a class, not a type interface they need to implement - so they will inherit the implementation from KeyValueChannelStorage.

The only place I can see it possibly being a breaking change is if custom configurations break if they do not have the new FinalisationHander that we see added here. I can look into whether that would cause a break or not.

@joachimvh

Copy link
Copy Markdown
Member

so they will inherit the implementation from KeyValueChannelStorage.

True, I was thinking wrong here. There are some minor edge cases but this is probably fine.

This branch has not been deployed

No deployments
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.

3 participants