Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,33 @@ public sealed class KeygenLifecycleServiceTests : IDisposable {

public KeygenLifecycleServiceTests() {
_licenses = Channel.CreateUnbounded<LicenseInfo>();
_keygen = new KeygenSimulator();
// High heartbeat count so that most tests exercise the steady-state heartbeat behaviour
// without triggering revalidation. Periodic revalidation is covered by its own test.
_sut = CreateSut(heartbeatsPerRevalidation: int.MaxValue);
}

KeygenLifecycleService CreateSut(int heartbeatsPerRevalidation) {
var options = new KeygenClientOptions {
Licensing = new() {
LicenseKey = "the-key",
},
ReadOnlyReplica = true,
Archiver = true,
};
_keygen = new KeygenSimulator();
_sut = new KeygenLifecycleService(
var sut = new KeygenLifecycleService(
new KeygenClient(
options,
new RestClient(
new RestClientOptions($"https://mock-key-gen") {
ConfigureMessageHandler = _ => _keygen,
})),
new Fingerprint(port: null),
heartbeatsPerRevalidation: heartbeatsPerRevalidation,
revalidationDelay: TimeSpan.FromMilliseconds(10));

_sut.Licenses.Subscribe(async x => await _licenses.Writer.WriteAsync(x));
sut.Licenses.Subscribe(async x => await _licenses.Writer.WriteAsync(x));
return sut;
}

public void Dispose() {
Expand Down Expand Up @@ -237,4 +245,52 @@ public async Task when_license_becomes_suspended() {

await _keygen.ShouldReceive_ValidationRequest();
}

[Fact]
public async Task revalidates_to_pick_up_a_renewed_expiry() {
var originalExpiry = new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero);
var renewedExpiry = new DateTimeOffset(2031, 1, 1, 0, 0, 0, TimeSpan.Zero);

// revalidate after a single heartbeat so the test doesn't depend on timing
using var sut = CreateSut(heartbeatsPerRevalidation: 1);
await sut.StartAsync(CancellationToken.None);

// initial validation reports the original expiry
await _keygen.ShouldReceive_ValidationRequest();
await _keygen.ReplyWith_ValidationResponse("VALID", expiry: originalExpiry);
await _keygen.ShouldReceive_EntitlementRequest();
await _keygen.ReplyWith_Entitlements("A_SPECIAL_ENTITLEMENT");

await AssertNextLicense(new LicenseInfo.Conclusive(
LicenseId: "the-license-id",
Name: "the name of the license",
Valid: true,
Trial: false,
Warning: false,
Detail: "valid",
Expiry: originalExpiry,
Entitlements: ["A_SPECIAL_ENTITLEMENT"]));

// after one heartbeat the service revalidates from the top
await _keygen.ShouldReceive_GetMachine();
await _keygen.ReplyWith_Machine();
await _keygen.ShouldReceive_Heartbeat();
await _keygen.ReplyWith_HeartbeatResponse();

// revalidation reports the renewed expiry
await _keygen.ShouldReceive_ValidationRequest();
await _keygen.ReplyWith_ValidationResponse("VALID", expiry: renewedExpiry);
await _keygen.ShouldReceive_EntitlementRequest();
await _keygen.ReplyWith_Entitlements("A_SPECIAL_ENTITLEMENT");

await AssertNextLicense(new LicenseInfo.Conclusive(
LicenseId: "the-license-id",
Name: "the name of the license",
Valid: true,
Trial: false,
Warning: false,
Detail: "valid",
Expiry: renewedExpiry,
Entitlements: ["A_SPECIAL_ENTITLEMENT"]));
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Kurrent, Inc and/or licensed to Kurrent, Inc under one or more agreements.
// Kurrent, Inc licenses this file to you under the Kurrent License v1 (see LICENSE.md).

using System;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
Expand All @@ -9,12 +10,13 @@
namespace KurrentDB.Licensing.Tests.Keygen;

partial class KeygenSimulator {
public async Task ReplyWith_ValidationResponse(string code) => await Send(
public async Task ReplyWith_ValidationResponse(string code, DateTimeOffset? expiry = null) => await Send(
HttpStatusCode.OK,
new Models.ValidateLicenseResponse {
Data = new() {
Attributes = new() {
Name = "the name of the license",
Expiry = expiry,
Metadata = new() {
{ "trial", "false" }
}
Expand Down
27 changes: 22 additions & 5 deletions src/KurrentDB.Licensing/Keygen/KeygenLifecycleService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,27 @@ public sealed class KeygenLifecycleService : IHostedService, IDisposable {

readonly KeygenClient _client;
readonly string _fingerprint;
// revalidate after this many heartbeats even if they are still working fine, so that changes to
// the license (e.g. a renewed expiry) are picked up. keeps the extra load proportional to, and
// controllable via, the heartbeat mechanism.
readonly int _heartbeatsPerRevalidation;
// if revalidating, wait this long first
readonly TimeSpan _revalidationDelay;
readonly CancellationTokenSource _heartbeatCancellation = new();
readonly ReplaySubject<LicenseInfo> _licenses = new(bufferSize: 1);

public KeygenLifecycleService(KeygenClient client, Fingerprint fingerprint, TimeSpan revalidationDelay) {
public KeygenLifecycleService(
KeygenClient client,
Fingerprint fingerprint,
int heartbeatsPerRevalidation,
TimeSpan revalidationDelay) {

if (heartbeatsPerRevalidation < 1)
throw new ArgumentOutOfRangeException(nameof(heartbeatsPerRevalidation), "Must be >= 1.");

_client = client;
_fingerprint = fingerprint.Get();
_heartbeatsPerRevalidation = heartbeatsPerRevalidation;
_revalidationDelay = revalidationDelay;
}
Comment thread
timothycoleman marked this conversation as resolved.

Expand Down Expand Up @@ -74,7 +88,7 @@ async Task MainLoop(CancellationToken cancellationToken) {

await HeartbeatAsNecessary(cancellationToken);

// heartbeat process has failed, start over.
// the heartbeat process has failed or it is time to revalidate; start over.
await Task.Delay(_revalidationDelay, cancellationToken);
break;
}
Expand Down Expand Up @@ -161,7 +175,7 @@ async Task<LicenseInfo> Deactivate() {
}
}

// completes when the heartbeat fails
// completes when the heartbeat fails or it is time to revalidate
async Task HeartbeatAsNecessary(CancellationToken cancellationToken) {
var restResponse = await _client.GetMachine(_fingerprint, cancellationToken);
if (!TryGetParsedResponse(restResponse, "License GetMachine", out var response, out var _)) {
Expand All @@ -170,22 +184,25 @@ async Task HeartbeatAsNecessary(CancellationToken cancellationToken) {
}

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);
}

await MaintainHeartbeat(response.HeartbeatInterval, cancellationToken);
}

// completes when the heartbeat fails
// completes when the heartbeat fails or it is time to revalidate
async Task MaintainHeartbeat(int interval, CancellationToken cancellationToken) {
Log.Debug("Starting heartbeat with interval {Interval} seconds", interval);

var delay = TimeSpan.FromSeconds(interval > 10
? interval - 10
: interval / 5.0);

while (true) {
// revalidate once we've sent this many heartbeats, even if they're all healthy
for (var beat = 0; beat < _heartbeatsPerRevalidation; beat++) {
await Task.Delay(delay, cancellationToken);

var restResponse = await _client.SendHeartbeat(_fingerprint, cancellationToken);
Expand Down
1 change: 1 addition & 0 deletions src/KurrentDB.Licensing/LicensingPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ public override void ConfigureServices(IServiceCollection services, IConfigurati
// todo: Interceptors = [new KeygenSignatureInterceptor()],
})),
new Fingerprint(clientOptions.Licensing.IncludePortInFingerprint ? clientOptions.NodePort : null),
heartbeatsPerRevalidation: 4,
revalidationDelay: TimeSpan.FromSeconds(10));
Comment on lines 85 to 87

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


licenses = lifecycleService.Licenses;
Expand Down
40 changes: 35 additions & 5 deletions src/KurrentDB/Components/Pages/License.razor
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@

@page "/ui/license"
@attribute [Authorize]
@implements IDisposable
@using EventStore.Plugins.Licensing
@using JetBrains.Annotations
@using KurrentDB.Licensing
@using Microsoft.AspNetCore.Authorization

Expand Down Expand Up @@ -69,17 +69,47 @@
</MudStack>

@code {
[Inject] ILicenseService LicenseService { get; set; }
#nullable enable
[Inject] ILicenseService LicenseService { get; set; } = null!;

[CanBeNull] Dictionary<string, object> _licenseSummary;
Dictionary<string, object?>? _licenseSummary;
IDisposable? _subscription;

protected override void OnInitialized() {
base.OnInitialized();
if (LicenseService.CurrentLicense == null) {

// Show the current license immediately (this is also the only work done during prerender).
var current = LicenseService.CurrentLicense;
_licenseSummary = current is null ? null : LicenseSummary.SelectForEndpoint(current);

// The live subscription needs the interactive circuit; skip it during the prerender pass.
if (!RendererInfo.IsInteractive) {
return;
}

_licenseSummary = LicenseSummary.SelectForEndpoint(LicenseService.CurrentLicense);
// Delivers the current license immediately and then subsequent changes.
_subscription = LicenseService.Licenses.Subscribe(
license => Update(license is null ? null : LicenseSummary.SelectForEndpoint(license)),
_ => Update(null));
}

// Updates arrive off the licensing service's background thread, so marshal to the renderer
// and guard the teardown race (the circuit can be disposed while an update is in flight).
void Update(Dictionary<string, object?>? summary) => _ = RefreshAsync(summary);

async Task RefreshAsync(Dictionary<string, object?>? summary) {
try {
await InvokeAsync(() => {
_licenseSummary = summary;
StateHasChanged();
});
} catch (ObjectDisposedException) {
// Component/renderer torn down while an update was in flight — ignore.
}
}

public void Dispose() {
_subscription?.Dispose();
}

string GetValue(string key) => _licenseSummary?[key]?.ToString() ?? string.Empty;
Expand Down
Loading