Lock-free, allocation-conscious time-series primitives written in modern C# for building fast counters, aggregations, and rolling analytics.
Package | NuGet |
---|---|
Core library |
- Data pipelines demand concurrency. We built the core on lock-free
ConcurrentDictionary
/ConcurrentQueue
structures and custom atomic helpers, so write-heavy workloads scale across cores without blocking. - Numeric algorithms shouldn’t duplicate code. Everything is generic over
INumber<T>
, soint
,decimal
, or your own numeric type can use the same summer/accumulator implementations. - Hundreds of signals, one API. Grouped accumulators and summers make it trivial to manage keyed windows (think “per customer”, “per endpoint”, “per shard”) with automatic clean-up.
- Production-ready plumbing. Orleans converters, Release automation, Coveralls reporting, and central package management are all wired up out of the box.
- Feature Highlights
- Quickstart
- Architecture Notes
- Development Workflow
- Release Automation
- Extensibility
- Contributing
- License
- Lock-free core – writes hit
ConcurrentDictionary
+ConcurrentQueue
, range metadata updated via custom atomics. - Generic summers –
NumberTimeSeriesSummer<T>
& friends operate on anyINumber<T>
implementation. - Mass fan-in ready – grouped accumulators/summers handle hundreds of keys without
lock
. - Orleans-native – converters bridge to Orleans surrogates so grains can persist accumulators out of the box.
- Delivery pipeline – GitHub Actions release workflow bundles builds, tests, packs, tagging, and publishing.
- Central package versions – single source of NuGet truth via
Directory.Packages.props
.
dotnet add package ManagedCode.TimeSeries
using ManagedCode.TimeSeries.Accumulators;
var requests = new IntTimeSeriesAccumulator(TimeSpan.FromSeconds(5), maxSamplesCount: 60);
Parallel.For(0, 10_000, i =>
{
requests.AddNewData(i);
});
Console.WriteLine($"Samples stored: {requests.Samples.Count}");
Console.WriteLine($"Events processed: {requests.DataCount}");
using ManagedCode.TimeSeries.Summers;
var latency = new NumberTimeSeriesSummer<decimal>(TimeSpan.FromMilliseconds(500));
latency.AddNewData(DateTimeOffset.UtcNow, 12.4m);
latency.AddNewData(DateTimeOffset.UtcNow.AddMilliseconds(250), 9.6m);
Console.WriteLine($"AVG: {latency.Average():F2} ms");
Console.WriteLine($"P50/P100: {latency.Min()} / {latency.Max()}");
using ManagedCode.TimeSeries.Accumulators;
var perEndpoint = new IntGroupTimeSeriesAccumulator(
sampleInterval: TimeSpan.FromSeconds(1),
maxSamplesCount: 300,
deleteOverdueSamples: true);
Parallel.ForEach(requests, req =>
{
perEndpoint.AddNewData(req.Path, req.Timestamp, 1);
});
foreach (var (endpoint, accumulator) in perEndpoint.Snapshot())
{
Console.WriteLine($"{endpoint}: {accumulator.DataCount} hits");
}
// In your Orleans silo:
builder.Services.AddSerializer(builder =>
{
builder.AddConverter<IntTimeSeriesAccumulatorConverter<int>>();
builder.AddConverter<IntTimeSeriesSummerConverter<int>>();
// …add others as needed
});
- Lock-free core:
BaseTimeSeries
stores samples in aConcurrentDictionary
and updates range metadata throughAtomicDateTimeOffset
. Per-key data is aConcurrentQueue<T>
(accumulators) or directINumber<T>
(summers). - Deterministic reads: consumers get an ordered read-only projection of the concurrent map, so existing iteration/test semantics stay intact while writers remain lock-free.
- Group managers:
BaseGroupTimeSeriesAccumulator
andBaseGroupNumberTimeSeriesSummer
useConcurrentDictionary<string, ...>
plus lightweight background timers for overdue clean-up—nolock
statements anywhere on the hot path. - Orleans bridge: converters project between the concurrent structures and Orleans’ plain dictionaries/queues, keeping serialized payloads simple while the live types stay lock-free.
Scenario | Hook |
---|---|
Custom numeric type | Implement INumber<T> and plug into NumberTimeSeriesSummer<T> |
Alternative aggregation strategy | Extend Strategy enum & override Update in a derived summer |
Domain-specific accumulator | Derive from TimeSeriesAccumulator<T, TSelf> (future rename of BaseTimeSeriesAccumulator ) and expose tailored helpers |
Serialization | Add dedicated Orleans converters / System.Text.Json converters using the pattern in ManagedCode.TimeSeries.Orleans |
Heads up: the
Base*
prefixes hang around for historical reasons. We plan to rename the concrete-ready generics toTimeSeriesAccumulator<T,...>
/TimeSeriesSummer<T,...>
in a future release with deprecation shims.
- Solution:
ManagedCode.TimeSeries.slnx
dotnet restore ManagedCode.TimeSeries.slnx dotnet build ManagedCode.TimeSeries.slnx --configuration Release dotnet test ManagedCode.TimeSeries.Tests/ManagedCode.TimeSeries.Tests.csproj --configuration Release
- Packages: update versions only in
Directory.Packages.props
. - Coverage:
dotnet test ... -p:CollectCoverage=true -p:CoverletOutputFormat=lcov
. - Benchmarks:
dotnet run --project ManagedCode.TimeSeries.Benchmark --configuration Release
.
- Workflow:
.github/workflows/release.yml
- Trigger: push to
main
or manualworkflow_dispatch
. - Steps: restore → build → test → pack →
dotnet nuget push
(skip duplicates) → create/tag release. - Configure secrets:
NUGET_API_KEY
: NuGet publish token.- Default
${{ secrets.GITHUB_TOKEN }}
is used for tagging and releases.
- Trigger: push to
- Restore/build/test using the commands above.
- Keep new APIs covered with tests (see existing samples in
ManagedCode.TimeSeries.Tests
). - Align with the lock-free architecture—avoid introducing
lock
on hot paths. - Document new features in this README.
MIT © ManagedCode SAS.