Skip to content

[DB-1606] Reflect license renewals without restarting the node - #5648

Open
timothycoleman wants to merge 5 commits into
masterfrom
timothycoleman/update-license-expiry
Open

[DB-1606] Reflect license renewals without restarting the node#5648
timothycoleman wants to merge 5 commits into
masterfrom
timothycoleman/update-license-expiry

Conversation

@timothycoleman

Copy link
Copy Markdown
Contributor

A node with a valid license now periodically revalidates with the licensing server while running, instead of only validating once at
startup. A renewed expiry date (or changed entitlements) is picked up without a restart.

The License page in the admin UI now updates live when the license changes, instead of only showing the license as it was when the page was first opened.

@linear-code

linear-code Bot commented Jun 22, 2026

Copy link
Copy Markdown

DB-1606

Base automatically changed from timothycoleman/single-node-license to master June 24, 2026 06:05
@timothycoleman
timothycoleman marked this pull request as ready for review June 24, 2026 06:07
@timothycoleman
timothycoleman requested a review from a team as a code owner June 24, 2026 06:07
Copilot AI review requested due to automatic review settings June 24, 2026 06:07
@qodo-code-review

Copy link
Copy Markdown
Contributor

PR Summary by Qodo

Revalidate licenses at runtime, live-refresh admin License page, and add single-node fallback
✨ Enhancement 🧪 Tests 🕐 40+ Minutes

Grey Divider

Description

• Periodically revalidate Keygen licenses during runtime to pick up renewals and entitlement
 changes.
• Grant an all-features fallback license for single-node deployments without a usable key.
• Live-refresh the admin License page by subscribing to license updates from the server.
Diagram

graph TD
A["ClusterVNode"] --> B["LicensingPlugin"] --> C["ILicenseService"] --> G["Admin UI License page"]
B --> D["KeygenLifecycleService"] --> E{{"Licensing server"}}
B --> F["SingleNodeFallbackProvider"] --> C
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Time-based revalidation (separate timer)
  • ➕ Decouples revalidation from heartbeat semantics (works even when no heartbeat is required).
  • ➕ More predictable revalidation cadence in wall-clock time.
  • ➖ Adds an additional periodic background loop alongside heartbeat.
  • ➖ Harder to keep load proportional to existing Keygen traffic patterns.
2. UI polling /license endpoint instead of subscribing
  • ➕ Avoids server-to-client reactive subscription threading and disposal concerns.
  • ➕ Works even if license observable completes/errors unexpectedly.
  • ➖ Adds constant HTTP load and increases UI staleness between polls.
  • ➖ More code/complexity to tune polling and handle backoff.
3. Server push via SignalR/SSE for license changes
  • ➕ Near-instant UI updates with fewer client requests.
  • ➕ Can support broader live telemetry patterns later.
  • ➖ Introduces new infrastructure and operational surface area (connections, scaling, auth).
  • ➖ Overkill if only the License page needs live updates.

Recommendation: The PR’s heartbeat-count-based revalidation is a good default: it leverages existing cadence, bounds Keygen load, and is easy to reason about. If deployments commonly have "no heartbeat required" responses yet still need renewal pickup, consider adding an optional time-based revalidation fallback; otherwise keep the current approach and rely on restart in that niche case.

Files changed (13) +271 / -32

Enhancement (6) +124 / -16
ClusterVNode.csPass single-node context into licensing plugin +1/-1

Pass single-node context into licensing plugin

• Updates node bootstrap wiring to construct LicensingPlugin with an isSingleNode flag. This enables single-node-specific licensing behavior (fallback license) without changing multi-node behavior.

src/KurrentDB.Core/ClusterVNode.cs

KeygenLifecycleService.csPeriodically revalidate licenses after N heartbeats +19/-5

Periodically revalidate licenses after N heartbeats

• Adds a heartbeatsPerRevalidation parameter and changes the heartbeat loop to run for a bounded number of beats before forcing revalidation. Preserves existing behavior when heartbeats fail, and documents the no-heartbeat case (no periodic revalidation).

src/KurrentDB.Licensing/Keygen/KeygenLifecycleService.cs

LicensingPlugin.csWrap provider for single-node fallback and enable periodic revalidation +8/-4

Wrap provider for single-node fallback and enable periodic revalidation

• Extends the plugin to accept isSingleNode and wraps the underlying provider with SingleNodeFallbackLicenseProvider when appropriate. Configures KeygenLifecycleService with a revalidation cadence (every 4 heartbeats) and surfaces NoLicenseKeyException as a clean 404 on /license.

src/KurrentDB.Licensing/LicensingPlugin.cs

NoLicenseKeyException.csIntroduce explicit exception for missing license key configuration +8/-0

Introduce explicit exception for missing license key configuration

• Adds a dedicated exception type to distinguish “no key configured” from validation failures. This supports both cleaner logging semantics and UI/API handling.

src/KurrentDB.Licensing/NoLicenseKeyException.cs

SingleNodeFallbackLicenseProvider.csGrant ALL entitlements on single-node when no usable key exists +54/-0

Grant ALL entitlements on single-node when no usable key exists

• Adds an ILicenseProvider wrapper that falls back to a long-lived ALL-entitlements license on single-node when the inner provider errors. Differentiates missing key vs invalid key for logging and embeds explanatory notes in the minted license.

src/KurrentDB.Licensing/SingleNodeFallbackLicenseProvider.cs

License.razorLive-update license page when server license changes +34/-6

Live-update license page when server license changes

• Subscribes to ILicenseService.Licenses (interactive-only) and refreshes the displayed license summary on updates, handling renderer disposal races. Removes the “fully licensed” success notice and clarifies invalid-license messaging.

src/KurrentDB/Components/Pages/License.razor

Bug fix (1) +1 / -1
License.csExtend minted JWT license lifetime to avoid future mid-run expiry issues +1/-1

Extend minted JWT license lifetime to avoid future mid-run expiry issues

• Updates self-signed license token expiry from 1 hour to 100 years. Prevents token expiry from becoming a latent failure mode if entitlements become dynamically re-evaluated at runtime.

src/KurrentDB.Plugins/Licensing/License.cs

Refactor (2) +10 / -11
KeygenLicenseProvider.csCentralize license minting via LicenseSummary helper +3/-11

Centralize license minting via LicenseSummary helper

• Refactors license creation paths to use LicenseSummary.CreateLicense instead of duplicating claim/export logic. Maintains existing semantics for inconclusive validation (grant ALL to avoid outages).

src/KurrentDB.Licensing/Keygen/KeygenLicenseProvider.cs

LicenseSummary.csAdd helper to mint licenses from a summary and entitlements +7/-0

Add helper to mint licenses from a summary and entitlements

• Adds LicenseSummary.CreateLicense to export summary claims and mint a self-signed license with provided entitlements. Simplifies callers and reduces duplication.

src/KurrentDB.Licensing/LicenseSummary.cs

Tests (4) +136 / -4
KeygenLifecycleServiceTests.csStabilize lifecycle tests and add renewed-expiry revalidation coverage +59/-3

Stabilize lifecycle tests and add renewed-expiry revalidation coverage

• Refactors test setup to parameterize heartbeats-per-revalidation for deterministic behavior. Adds a new test asserting that periodic revalidation picks up a renewed expiry after a heartbeat.

src/KurrentDB.Licensing.Tests/Keygen/KeygenLifecycleServiceTests.cs

KeygenSimulator.Replies.csAllow simulated validation responses to include expiry timestamps +3/-1

Allow simulated validation responses to include expiry timestamps

• Extends ReplyWith_ValidationResponse to accept an optional expiry value and include it in the mocked Keygen response payload. This supports tests validating renewed expiry propagation.

src/KurrentDB.Licensing.Tests/Keygen/KeygenSimulator.Replies.cs

LicensingPluginTests.csUpdate licensing plugin test helper for new constructor signature +2/-0

Update licensing plugin test helper for new constructor signature

• Adjusts test construction of LicensingPlugin to provide the new isSingleNode argument. Keeps test intent the same while aligning with updated wiring.

src/KurrentDB.Licensing.Tests/LicensingPluginTests.cs

SingleNodeFallbackLicenseProviderTests.csAdd coverage for single-node fallback licensing behavior +72/-0

Add coverage for single-node fallback licensing behavior

• Introduces tests verifying pass-through behavior for valid licenses and error propagation in multi-node. Adds single-node cases that grant ALL entitlements when no key is configured or validation fails.

src/KurrentDB.Licensing.Tests/SingleNodeFallbackLicenseProviderTests.cs

A node with a valid license now periodically revalidates with the
licensing server while running, instead of only validating once at
startup. A renewed expiry date (or changed entitlements) is picked up
without a restart.

The License page in the admin UI now updates live when the license
changes, instead of only showing the license as it was when the page
was first opened.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@timothycoleman
timothycoleman force-pushed the timothycoleman/update-license-expiry branch from 42b54e8 to 548cb50 Compare June 24, 2026 06:10

Copilot AI 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.

Pull request overview

This PR updates KurrentDB’s licensing flow so that a running node periodically revalidates with the licensing server (allowing renewed expiries/entitlements to be picked up without restart), and the Admin UI License page reflects license changes live.

Changes:

  • Add periodic revalidation in KeygenLifecycleService based on a configurable “heartbeats per revalidation” cadence.
  • Introduce a single-node fallback license provider (granting ALL entitlements when no usable license is available in single-node mode).
  • Make the License admin page subscribe to license changes so it updates while open.

Reviewed changes

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

Show a summary per file
File Description
src/KurrentDB/Components/Pages/License.razor Subscribes to license updates to refresh the page live; adds prerender vs interactive handling.
src/KurrentDB.Plugins/Licensing/License.cs Extends minted JWT expiration window to avoid short-lived self-signed licenses expiring.
src/KurrentDB.Licensing/SingleNodeFallbackLicenseProvider.cs Adds single-node “grant ALL” fallback when the inner provider errors.
src/KurrentDB.Licensing/NoLicenseKeyException.cs Introduces a dedicated exception type for “no license configured”.
src/KurrentDB.Licensing/LicensingPlugin.cs Wires periodic revalidation and the single-node fallback provider into DI/service setup.
src/KurrentDB.Licensing/LicenseSummary.cs Adds helper to mint a license token from a summary + entitlements.
src/KurrentDB.Licensing/Keygen/KeygenLifecycleService.cs Implements periodic revalidation by restarting after N successful heartbeats.
src/KurrentDB.Licensing/Keygen/KeygenLicenseProvider.cs Refactors to use LicenseSummary.CreateLicense.
src/KurrentDB.Licensing.Tests/SingleNodeFallbackLicenseProviderTests.cs Adds tests for pass-through vs fallback behavior in single- vs multi-node.
src/KurrentDB.Licensing.Tests/LicensingPluginTests.cs Updates construction for new isSingleNode parameter.
src/KurrentDB.Licensing.Tests/Keygen/KeygenSimulator.Replies.cs Allows test simulator to vary expiry in validation responses.
src/KurrentDB.Licensing.Tests/Keygen/KeygenLifecycleServiceTests.cs Adds coverage ensuring revalidation picks up renewed expiry.
src/KurrentDB.Core/ClusterVNode.cs Passes isSingleNode into LicensingPlugin.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/KurrentDB/Components/Pages/License.razor Outdated
Comment thread src/KurrentDB/Components/Pages/License.razor Outdated
Comment thread src/KurrentDB/Components/Pages/License.razor Outdated
Comment thread src/KurrentDB.Licensing/Keygen/KeygenLifecycleService.cs
@qodo-code-review

Copy link
Copy Markdown
Contributor

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (3) 📜 Skill insights (0)

Grey Divider


Action required

1. LicensingPlugin bool arg unnamed 📘 Rule violation ⚙ Maintainability
Description
LicensingPlugin is constructed with a positional boolean argument (isSingleNode), which reduces
call-site clarity and violates the named-boolean-argument convention. This makes it easier to
accidentally swap or misread boolean parameters as the signature evolves.
Code

src/KurrentDB.Core/ClusterVNode.cs[987]

+		modifiedOptions = modifiedOptions.WithPlugableComponent(new LicensingPlugin(isSingleNode, ex => {
Evidence
PR Compliance ID 7 requires named boolean arguments at call sites. The call `new
LicensingPlugin(isSingleNode, ex => { ... })` passes the boolean positionally rather than as
isSingleNode: ....

CLAUDE.md: Naming conventions: versioned class names use NounVersionSuffix; use accurate parameter names; use named boolean arguments at call sites; avoid magic numbers by using constants
src/KurrentDB.Core/ClusterVNode.cs[987-987]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A boolean argument is passed positionally to `LicensingPlugin`, which violates the repo convention requiring named boolean arguments at call sites.

## Issue Context
Rule requires booleans to be passed with names (e.g., `isSingleNode: isSingleNode`) to avoid ambiguity and future-parameter-order bugs.

## Fix Focus Areas
- src/KurrentDB.Core/ClusterVNode.cs[987-987]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. SingleNodeFallbackLicenseProvider bool unnamed 📘 Rule violation ⚙ Maintainability
Description
SingleNodeFallbackLicenseProvider is constructed with a positional boolean argument
(_isSingleNode), which violates the named-boolean-argument requirement. This decreases readability
and increases the risk of misuse if the parameter list changes.
Code

src/KurrentDB.Licensing/LicensingPlugin.cs[R93-94]

+		var licenseProvider = _licenseProvider ??
+			new SingleNodeFallbackLicenseProvider(new KeygenLicenseProvider(licenses), _isSingleNode);
Evidence
PR Compliance ID 7 requires named boolean arguments at call sites. `new
SingleNodeFallbackLicenseProvider(..., _isSingleNode)` passes the boolean positionally instead of
using isSingleNode: _isSingleNode.

CLAUDE.md: Naming conventions: versioned class names use NounVersionSuffix; use accurate parameter names; use named boolean arguments at call sites; avoid magic numbers by using constants
src/KurrentDB.Licensing/LicensingPlugin.cs[93-95]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A boolean argument is passed positionally to `SingleNodeFallbackLicenseProvider`, violating the convention to use named boolean arguments.

## Issue Context
Use named boolean arguments (e.g., `isSingleNode: _isSingleNode`) to make intent explicit and avoid parameter-order hazards.

## Fix Focus Areas
- src/KurrentDB.Licensing/LicensingPlugin.cs[93-95]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. heartbeatsPerRevalidation magic number 📘 Rule violation ⚙ Maintainability
Description
heartbeatsPerRevalidation: 4 is a magic number embedded at the call site. This violates the
guideline to use a named constant/config value so the meaning and tuning intent are explicit.
Code

src/KurrentDB.Licensing/LicensingPlugin.cs[R85-87]

				new Fingerprint(clientOptions.Licensing.IncludePortInFingerprint ? clientOptions.NodePort : null),
+				heartbeatsPerRevalidation: 4,
				revalidationDelay: TimeSpan.FromSeconds(10));
Evidence
PR Compliance ID 7 requires avoiding magic numbers by using constants. The new
KeygenLifecycleService construction hardcodes heartbeatsPerRevalidation: 4 without a named
constant/configuration value.

CLAUDE.md: Naming conventions: versioned class names use NounVersionSuffix; use accurate parameter names; use named boolean arguments at call sites; avoid magic numbers by using constants
src/KurrentDB.Licensing/LicensingPlugin.cs[85-87]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A numeric literal (`4`) is used for `heartbeatsPerRevalidation`, making the behavior/tuning intent unclear and violating the no-magic-numbers convention.

## Issue Context
Introduce a named constant (or configuration) that documents why `4` is chosen, and use that value at the call site.

## Fix Focus Areas
- src/KurrentDB.Licensing/LicensingPlugin.cs[85-87]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

4. No revalidation without heartbeat 🐞 Bug ≡ Correctness
Description
KeygenLifecycleService.HeartbeatAsNecessary() blocks indefinitely when
GetMachineResponse.RequiresHeartbeat is false, preventing the service from ever re-entering
Validate() and thus leaving renewed expiry/entitlements unapplied until a restart in that
configuration.
Code

src/KurrentDB.Licensing/Keygen/KeygenLifecycleService.cs[R183-188]

		if (!response.RequiresHeartbeat) {
+			// Without a heartbeat there is no keep-alive cadence to revalidate on, so we hold the
+			// current license until the node restarts.
			Log.Debug("No heartbeat required");
			await Task.Delay(Timeout.Infinite, cancellationToken);
		}
Evidence
When heartbeats aren’t required, the lifecycle loop never returns to validation because it awaits an
infinite delay. This can occur because the RequiresHeartbeat flag is directly derived from the
machine payload field RequireHeartbeat, which can be false.

src/KurrentDB.Licensing/Keygen/KeygenLifecycleService.cs[175-191]
src/KurrentDB.Licensing/Keygen/Models.cs[78-86]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`KeygenLifecycleService` currently only has a periodic revalidation cadence when Keygen requires heartbeats. If Keygen returns `RequireHeartbeat=false` for the machine, the service delays forever and will never revalidate again, so renewed expiry/changed entitlements won’t be picked up until node restart.

### Issue Context
- `GetMachineResponse.RequiresHeartbeat` is derived from the Keygen API field `MachineAttributes.RequireHeartbeat`; it can be `false` depending on policy/config.
- The PR goal is to reflect renewals live; this code path prevents that for non-heartbeat policies.

### Fix Focus Areas
- src/KurrentDB.Licensing/Keygen/KeygenLifecycleService.cs[175-191]

### Suggested change
In `HeartbeatAsNecessary`, when `RequiresHeartbeat` is false, **do not** block forever. Instead, wait for a periodic revalidation interval (could reuse `_revalidationDelay` or introduce a separate `noHeartbeatRevalidationInterval`) and then `return` so `MainLoop` re-enters `Validate()`.

Example direction:
- If `!response.RequiresHeartbeat`:
 - `Log.Debug("No heartbeat required; scheduling periodic revalidation")`
 - `await Task.Delay(<some interval>, cancellationToken)`
 - `return;`

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment on lines 85 to 87
new Fingerprint(clientOptions.Licensing.IncludePortInFingerprint ? clientOptions.NodePort : null),
heartbeatsPerRevalidation: 4,
revalidationDelay: TimeSpan.FromSeconds(10));

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.

Action required

3. heartbeatsperrevalidation magic number 📘 Rule violation ⚙ Maintainability

heartbeatsPerRevalidation: 4 is a magic number embedded at the call site. This violates the
guideline to use a named constant/config value so the meaning and tuning intent are explicit.
Agent Prompt
## Issue description
A numeric literal (`4`) is used for `heartbeatsPerRevalidation`, making the behavior/tuning intent unclear and violating the no-magic-numbers convention.

## Issue Context
Introduce a named constant (or configuration) that documents why `4` is chosen, and use that value at the call site.

## Fix Focus Areas
- src/KurrentDB.Licensing/LicensingPlugin.cs[85-87]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

timothycoleman and others added 4 commits July 9, 2026 08:51
Switch the License page to the modern nullable-reference-types style,
replacing the JetBrains [CanBeNull] annotations with `?`. The
_subscription field is left null during the prerender pass, matching
the Dispose null-check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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