release: Robotico.Nexus.Client 1.0.1 catalog/ops extras
ci/woodpecker/push/woodpecker Pipeline failed Details

Add ListSeries, GetCardinality, and ApplyRetention on NexusTimeSession
(ISP extras off ITimeSession) with DTOs and fake-host tests for coverage.
This commit is contained in:
Robotico Agent 2026-07-25 08:45:34 +02:00
parent 80f78dceeb
commit 871bf728a7
8 changed files with 256 additions and 2 deletions

View File

@ -1,5 +1,14 @@
# Changelog
## Unreleased
## 1.0.1
- `NexusTimeSession` catalog/ops extras (ISP: not on `ITimeSession`): `ListSeries`,
`GetCardinality`, `ApplyRetention` over Wire.V1 RPCs with DTOs
`TimeSeriesCatalogEntry`, `TimeCardinalitySnapshot`, `TimeMetricCardinality`.
- Suite-layout ProjectReference resolution for `Mishima.Nexus.Wire.V1` (alongside sibling monorepo path).
## 1.0.0
- Initial `NexusClient`: connects via `nexus+graph|time|document|relational://` URIs (explicit port

View File

@ -11,7 +11,7 @@
</PropertyGroup>
<PropertyGroup>
<Version>1.0.0</Version>
<Version>1.0.1</Version>
<Product>Robotico.Nexus.Client</Product>
<PackageId>Robotico.Nexus.Client</PackageId>
<Title>Robotico.Nexus.Client</Title>
@ -39,6 +39,11 @@
<PropertyGroup>
<RoboticoGraphHubProjectPath>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\robotico-graph-csharp\src\Robotico.Graph\Robotico.Graph.csproj'))</RoboticoGraphHubProjectPath>
<RoboticoTimeHubProjectPath>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\robotico-time-csharp\src\Robotico.Time\Robotico.Time.csproj'))</RoboticoTimeHubProjectPath>
<MishimaNexusWireV1ProjectPath>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..\mishima-suite\mishima-nexus\src\Mishima.Nexus.Wire.V1\Mishima.Nexus.Wire.V1.csproj'))</MishimaNexusWireV1ProjectPath>
<!-- Suite layout: robotico-patterns lives under mishima-suite/ -->
<_MishimaNexusWireV1Suite>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..\mishima-nexus\src\Mishima.Nexus.Wire.V1\Mishima.Nexus.Wire.V1.csproj'))</_MishimaNexusWireV1Suite>
<!-- Sibling layout: robotico-patterns next to mishima-suite/ -->
<_MishimaNexusWireV1Sibling>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..\mishima-suite\mishima-nexus\src\Mishima.Nexus.Wire.V1\Mishima.Nexus.Wire.V1.csproj'))</_MishimaNexusWireV1Sibling>
<MishimaNexusWireV1ProjectPath Condition="Exists('$(_MishimaNexusWireV1Suite)')">$(_MishimaNexusWireV1Suite)</MishimaNexusWireV1ProjectPath>
<MishimaNexusWireV1ProjectPath Condition="'$(MishimaNexusWireV1ProjectPath)' == '' and Exists('$(_MishimaNexusWireV1Sibling)')">$(_MishimaNexusWireV1Sibling)</MishimaNexusWireV1ProjectPath>
</PropertyGroup>
</Project>

View File

@ -322,6 +322,108 @@ public sealed class NexusTimeSession : ITimeSession
});
}
/// <summary>
/// Lists registered series for explorers (Nexus wire extra; not on <see cref="ITimeSession"/>).
/// </summary>
public Robotico.Result.Result<IReadOnlyList<TimeSeriesCatalogEntry>> ListSeries(int limit = 1000)
{
Robotico.Result.Result disposed = EnsureNotDisposed();
if (disposed.IsError(out IError? disposedError))
{
return Robotico.Result.Result.Error<IReadOnlyList<TimeSeriesCatalogEntry>>(disposedError!);
}
int normalizedLimit = limit < 1 ? 1000 : limit;
return InvokeGeneric(() =>
{
TimeListSeriesResponse response = _client.ListSeries(
new TimeListSeriesRequest { Limit = normalizedLimit },
Metadata());
TimeSeriesCatalogEntry[] entries = new TimeSeriesCatalogEntry[response.Series.Count];
for (int i = 0; i < response.Series.Count; i++)
{
TimeSeriesCatalogEntryWire wire = response.Series[i];
Dictionary<string, string> labels = new(StringComparer.Ordinal);
foreach (TimeLabelPair pair in wire.Labels)
{
labels[pair.Key] = pair.Value;
}
entries[i] = new TimeSeriesCatalogEntry(
wire.SeriesId,
wire.MetricName,
labels,
wire.SampleCount);
}
return NexusWireStatus.ToResult(response.Status, (IReadOnlyList<TimeSeriesCatalogEntry>)entries);
});
}
/// <summary>
/// Cardinality inspection (Nexus wire extra; not on <see cref="ITimeSession"/>).
/// </summary>
public Robotico.Result.Result<TimeCardinalitySnapshot> GetCardinality(int topN = 20)
{
Robotico.Result.Result disposed = EnsureNotDisposed();
if (disposed.IsError(out IError? disposedError))
{
return Robotico.Result.Result.Error<TimeCardinalitySnapshot>(disposedError!);
}
int normalizedTopN = topN < 1 ? 20 : topN;
return InvokeGeneric(() =>
{
TimeGetCardinalityResponse response = _client.GetCardinality(
new TimeGetCardinalityRequest { TopN = normalizedTopN },
Metadata());
TimeMetricCardinality[] top =
[
.. response.TopMetricsBySeriesCount.Select(static m =>
new TimeMetricCardinality(m.MetricName, m.SeriesCount)),
];
TimeCardinalitySnapshot snap = new(
response.ActiveSeriesCount,
response.TotalSampleCount,
top);
return NexusWireStatus.ToResult(response.Status, snap);
});
}
/// <summary>
/// Applies retention cutoff (Nexus wire extra; not on <see cref="ITimeSession"/>).
/// Drops samples/events with timestamp strictly before
/// <paramref name="retainAfterExclusiveUnixNanos"/>.
/// </summary>
/// <returns>Removed sample/event count reported by the engine.</returns>
public Robotico.Result.Result<long> ApplyRetention(long retainAfterExclusiveUnixNanos)
{
Robotico.Result.Result disposed = EnsureNotDisposed();
if (disposed.IsError(out IError? disposedError))
{
return Robotico.Result.Result.Error<long>(disposedError!);
}
if (retainAfterExclusiveUnixNanos <= 0)
{
return Robotico.Result.Result.Error<long>(
TimeErrors.Error(
"retainAfterExclusiveUnixNanos must be positive.",
TimeErrors.InvalidArgument));
}
return InvokeGeneric(() =>
{
TimeApplyRetentionResponse response = _client.ApplyRetention(
new TimeApplyRetentionRequest
{
RetainAfterExclusiveUnixNanos = retainAfterExclusiveUnixNanos,
},
Metadata());
return NexusWireStatus.ToResult(response.Status, response.RemovedCount);
});
}
/// <inheritdoc />
public void Dispose() => _disposed = true;

View File

@ -0,0 +1,13 @@
namespace Robotico.Nexus.Client.Time;
/// <summary>
/// Cardinality snapshot from <see cref="NexusTimeSession.GetCardinality"/>.
/// Not on <c>ITimeSession</c> (ISP): ops inspection surface for explorers.
/// </summary>
/// <param name="ActiveSeriesCount">Active series in the logical database.</param>
/// <param name="TotalSampleCount">Total samples across all series.</param>
/// <param name="TopMetricsBySeriesCount">Top metrics by series fan-out.</param>
public sealed record TimeCardinalitySnapshot(
long ActiveSeriesCount,
long TotalSampleCount,
IReadOnlyList<TimeMetricCardinality> TopMetricsBySeriesCount);

View File

@ -0,0 +1,6 @@
namespace Robotico.Nexus.Client.Time;
/// <summary>Per-metric series count for cardinality inspection.</summary>
/// <param name="MetricName">Metric name.</param>
/// <param name="SeriesCount">Number of series with this metric name.</param>
public sealed record TimeMetricCardinality(string MetricName, long SeriesCount);

View File

@ -0,0 +1,15 @@
namespace Robotico.Nexus.Client.Time;
/// <summary>
/// One registered series from <see cref="NexusTimeSession.ListSeries"/>.
/// Not on <c>ITimeSession</c> (ISP): catalog ops are Nexus wire extras for explorers/ops.
/// </summary>
/// <param name="SeriesId">Engine-assigned series id.</param>
/// <param name="MetricName">Metric name (Prometheus-style).</param>
/// <param name="Labels">Series labels (sorted as returned by the engine).</param>
/// <param name="SampleCount">Current sample count for the series.</param>
public sealed record TimeSeriesCatalogEntry(
ulong SeriesId,
string MetricName,
IReadOnlyDictionary<string, string> Labels,
int SampleCount);

View File

@ -7,11 +7,13 @@ namespace Robotico.Nexus.Client.Tests.TestSupport;
public sealed class FakeTimeEngineService : NexusTimeEngineService.NexusTimeEngineServiceBase
{
private readonly Dictionary<ulong, List<TimeSampleWire>> _samplesBySeries = [];
private readonly Dictionary<ulong, (string MetricName, IReadOnlyList<TimeLabelPair> Labels)> _seriesMeta = [];
private readonly Dictionary<ulong, List<TimeWideEventWire>> _eventsByTable = [];
private readonly Dictionary<ulong, List<TimeEventFieldWire>> _fieldsByTable = [];
private ulong _nextSeriesId = 1;
private ulong _nextTableId = 1;
private ulong _nextEventId = 1;
private long _retentionRemoved;
/// <inheritdoc />
public override Task<TimePingResponse> Ping(TimePingRequest request, ServerCallContext context)
@ -40,9 +42,76 @@ public sealed class FakeTimeEngineService : NexusTimeEngineService.NexusTimeEngi
{
ulong id = _nextSeriesId++;
_samplesBySeries[id] = [];
_seriesMeta[id] = (request.MetricName, [.. request.Labels]);
return Task.FromResult(new TimeRegisterSeriesResponse { Status = NexusStatuses.Ok, SeriesId = id });
}
/// <inheritdoc />
public override Task<TimeListSeriesResponse> ListSeries(TimeListSeriesRequest request, ServerCallContext context)
{
TimeListSeriesResponse response = new() { Status = NexusStatuses.Ok };
int limit = request.Limit is 0 ? int.MaxValue : request.Limit;
foreach ((ulong id, (string metric, IReadOnlyList<TimeLabelPair> labels)) in _seriesMeta.Take(limit))
{
TimeSeriesCatalogEntryWire entry = new()
{
SeriesId = id,
MetricName = metric,
SampleCount = _samplesBySeries.TryGetValue(id, out List<TimeSampleWire>? samples) ? samples.Count : 0,
};
entry.Labels.AddRange(labels);
response.Series.Add(entry);
}
return Task.FromResult(response);
}
/// <inheritdoc />
public override Task<TimeGetCardinalityResponse> GetCardinality(TimeGetCardinalityRequest request, ServerCallContext context)
{
Dictionary<string, int> byMetric = new(StringComparer.Ordinal);
foreach ((string metric, _) in _seriesMeta.Values)
{
byMetric[metric] = byMetric.GetValueOrDefault(metric) + 1;
}
int topN = request.TopN is 0 ? 20 : request.TopN;
TimeGetCardinalityResponse response = new()
{
Status = NexusStatuses.Ok,
ActiveSeriesCount = _seriesMeta.Count,
TotalSampleCount = _samplesBySeries.Values.Sum(list => list.Count),
};
foreach ((string metric, int count) in byMetric.OrderByDescending(static p => p.Value).ThenBy(static p => p.Key, StringComparer.Ordinal).Take(topN))
{
response.TopMetricsBySeriesCount.Add(new TimeMetricCardinalityWire { MetricName = metric, SeriesCount = count });
}
return Task.FromResult(response);
}
/// <inheritdoc />
public override Task<TimeApplyRetentionResponse> ApplyRetention(TimeApplyRetentionRequest request, ServerCallContext context)
{
long removed = 0;
foreach (List<TimeSampleWire> samples in _samplesBySeries.Values)
{
int before = samples.Count;
samples.RemoveAll(s => s.TimestampUnixNanos < request.RetainAfterExclusiveUnixNanos);
removed += before - samples.Count;
}
foreach (List<TimeWideEventWire> events in _eventsByTable.Values)
{
int before = events.Count;
events.RemoveAll(e => e.TimestampUnixNanos < request.RetainAfterExclusiveUnixNanos);
removed += before - events.Count;
}
_retentionRemoved = removed;
return Task.FromResult(new TimeApplyRetentionResponse { Status = NexusStatuses.Ok, RemovedCount = removed });
}
/// <inheritdoc />
public override Task<TimeAppendSampleResponse> AppendSample(TimeAppendSampleRequest request, ServerCallContext context)
{

View File

@ -1,4 +1,5 @@
using Robotico.Nexus.Client.Tests.TestSupport;
using Robotico.Nexus.Client.Time;
using Robotico.Result.Errors;
using Robotico.Time;
using Xunit;
@ -230,9 +231,36 @@ public class NexusTimeSessionTests : IAsyncLifetime
Assert.Single(hits!);
}
[Fact]
public void ListSeries_GetCardinality_ApplyRetention_CatalogOps()
{
NexusTimeSession nexus = Assert.IsType<NexusTimeSession>(_session);
ulong s1 = RegisterSeries("cpu.load");
_session.AppendSample(s1, 100, 1.0);
_session.AppendSample(s1, 200, 2.0);
ulong s2 = RegisterSeries("cpu.load");
RegisterSeries("mem.used");
Assert.True(nexus.ListSeries(10).IsSuccess(out IReadOnlyList<TimeSeriesCatalogEntry>? listed, out _));
Assert.Equal(3, listed!.Count);
Assert.Contains(listed, e => e is { SeriesId: var id, MetricName: "cpu.load", SampleCount: 2 } && id == s1);
Assert.True(nexus.GetCardinality(10).IsSuccess(out TimeCardinalitySnapshot? card, out _));
Assert.Equal(3, card!.ActiveSeriesCount);
Assert.Equal(2, card.TotalSampleCount);
Assert.Contains(card.TopMetricsBySeriesCount, m => m is { MetricName: "cpu.load", SeriesCount: 2 });
Assert.True(nexus.ApplyRetention(150).IsSuccess(out long removed, out _));
Assert.True(removed >= 1);
Assert.True(nexus.ApplyRetention(0).IsError(out IError? retErr));
Assert.Equal(TimeErrors.InvalidArgument, retErr!.Code);
}
[Fact]
public void AllOperations_Fail_AfterDispose()
{
NexusTimeSession nexus = Assert.IsType<NexusTimeSession>(_session);
_session.Dispose();
Assert.True(_session.Doctor().IsError(out IError? e1));
@ -267,6 +295,13 @@ public class NexusTimeSessionTests : IAsyncLifetime
Assert.True(_session.SearchFullText(1, 0, "x", 10).IsError(out IError? e11));
Assert.Equal(NexusErrors.Disposed, e11!.Code);
Assert.True(nexus.ListSeries().IsError(out IError? e12));
Assert.Equal(NexusErrors.Disposed, e12!.Code);
Assert.True(nexus.GetCardinality().IsError(out IError? e13));
Assert.Equal(NexusErrors.Disposed, e13!.Code);
Assert.True(nexus.ApplyRetention(1).IsError(out IError? e14));
Assert.Equal(NexusErrors.Disposed, e14!.Code);
}
private ulong RegisterSeries(string metricName)