diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 748e033..ed21763 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,21 +3,21 @@ "isRoot": true, "tools": { "roslynator.dotnet.cli": { - "version": "0.12.0", + "version": "1.0.0", "commands": [ "roslynator" ], "rollForward": false }, "coverlet.console": { - "version": "8.0.0", + "version": "10.0.1", "commands": [ "coverlet" ], "rollForward": false }, "dotnet-reportgenerator-globaltool": { - "version": "5.5.3", + "version": "5.5.11", "commands": [ "reportgenerator" ], diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e68f95d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,5 @@ +root = true + +[*.cs] +# RPC identity/signature drift must be reviewed before clients and silos diverge. +dotnet_analyzer_diagnostic.category-Versioning.severity = error diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd67c51..b25ecdf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,16 +14,77 @@ permissions: contents: read jobs: + performance: + name: Isolated concurrency comparison + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + BASELINE_REF: '705b47da12e41fa22aebc3049819c9efd2b605f8' + TEST_PROJECT: 'ManagedCode.Orleans.RateLimiting.Tests/ManagedCode.Orleans.RateLimiting.Tests.csproj' + RATE_LIMIT_PERFORMANCE_ALGORITHM: 'concurrency' + RATE_LIMIT_PERFORMANCE_WARM_WORKLOAD: 'true' + steps: + - name: Checkout + uses: actions/checkout@v7.0.1 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v6.0.0 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Prepare identical baseline harness + run: | + baseline_dir="$RUNNER_TEMP/rate-baseline" + mkdir -p "$baseline_dir" + git archive "$BASELINE_REF" | tar -x -C "$baseline_dir" + cp -R ManagedCode.Orleans.RateLimiting.Tests/Performance "$baseline_dir/ManagedCode.Orleans.RateLimiting.Tests/Performance" + printf 'Baseline: %s; updated: %s. All samples run serially on this runner.\n' "$BASELINE_REF" "$GITHUB_SHA" >> "$GITHUB_STEP_SUMMARY" + + - name: Build baseline + working-directory: ${{ runner.temp }}/rate-baseline + run: dotnet build "$TEST_PROJECT" -c Release -p:RunPerformanceTests=true + + - name: Build updated + run: dotnet build "$TEST_PROJECT" -c Release -p:RunPerformanceTests=true + + - name: Compare warmed workloads in ABBA order + run: | + sample=0 + for version in baseline updated updated baseline; do + source_dir="$GITHUB_WORKSPACE" + if [ "$version" = baseline ]; then source_dir="$RUNNER_TEMP/rate-baseline"; fi + for caller_token in false true; do + export RATE_LIMIT_PERFORMANCE_CALLER_TOKEN="$caller_token" + export RATE_LIMIT_PERFORMANCE_OUTPUT="$GITHUB_WORKSPACE/artifacts/performance/$sample-$version-$caller_token.json" + dotnet test --project "$source_dir/$TEST_PROJECT" -c Release --no-build -p:RunPerformanceTests=true --treenode-filter '/*/*/PerformanceBenchmarkTests/*' + done + sample=$((sample + 1)) + done + + - name: Summarize paired measurements + run: python3 scripts/summarize-performance.py artifacts/performance >> "$GITHUB_STEP_SUMMARY" + + - name: Upload every measurement + if: always() + uses: actions/upload-artifact@v7.0.1 + with: + name: performance-comparison + path: artifacts/performance/** + retention-days: 30 + build-and-test: name: Build, Test, and Coverage runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v7.0.1 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v6.0.0 with: dotnet-version: ${{ env.DOTNET_VERSION }} @@ -39,21 +100,29 @@ jobs: - name: Analyze run: dotnet build ManagedCode.Orleans.RateLimiting.sln --configuration Release --no-restore -p:RunAnalyzers=true + - name: Validate package compatibility + run: dotnet pack ManagedCode.Orleans.RateLimiting.sln --configuration Release --no-build + - name: Verify formatting run: dotnet format ManagedCode.Orleans.RateLimiting.sln --verify-no-changes - name: Test run: dotnet test --solution ManagedCode.Orleans.RateLimiting.sln --configuration Release --no-build --verbosity normal --report-trx --results-directory ./artifacts/test-results + - name: Map deterministic coverage source paths + run: | + mkdir -p artifacts/coverage + printf '|%s/=/_/\n' "$GITHUB_WORKSPACE" > artifacts/coverage/SourceRootsMapping.txt + - name: Collect coverage - run: dotnet tool run coverlet ManagedCode.Orleans.RateLimiting.Tests/bin/Release/net10.0/ManagedCode.Orleans.RateLimiting.Tests.dll --target "dotnet" --targetargs "test --project ManagedCode.Orleans.RateLimiting.Tests/ManagedCode.Orleans.RateLimiting.Tests.csproj --configuration Release --no-build --no-restore" --format cobertura --output artifacts/coverage/coverage.cobertura.xml --exclude "[ManagedCode.Orleans.RateLimiting.Tests]*" --threshold 85 --threshold-type line --threshold-stat total + run: dotnet tool run coverlet ManagedCode.Orleans.RateLimiting.Tests/bin/Release/net10.0/ManagedCode.Orleans.RateLimiting.Tests.dll --target "dotnet" --targetargs "test --project ManagedCode.Orleans.RateLimiting.Tests/ManagedCode.Orleans.RateLimiting.Tests.csproj --configuration Release --no-build --no-restore" --source-mapping-file artifacts/coverage/SourceRootsMapping.txt --format cobertura --output artifacts/coverage/coverage.cobertura.xml --exclude "[ManagedCode.Orleans.RateLimiting.Tests]*" --threshold 85 --threshold-type line --threshold-stat total - name: Render coverage report run: dotnet tool run reportgenerator -reports:"artifacts/coverage/coverage.cobertura.xml" -targetdir:"artifacts/coverage-report" -reporttypes:"HtmlSummary;MarkdownSummaryGithub" - name: Upload test artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.1 with: name: test-results path: ./artifacts/test-results/** @@ -61,7 +130,7 @@ jobs: - name: Upload coverage artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.1 with: name: coverage-report path: | @@ -70,7 +139,7 @@ jobs: retention-days: 5 - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v7.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: ./artifacts/coverage/coverage.cobertura.xml diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 735fd6e..2897971 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -12,6 +12,7 @@ name: CodeQL on: + workflow_dispatch: push: branches: [ main ] pull_request: @@ -20,22 +21,25 @@ on: schedule: - cron: '35 11 * * 4' +permissions: + contents: read + jobs: analyze: name: Analyze runs-on: ubuntu-latest + permissions: + contents: read + security-events: write strategy: fail-fast: false matrix: - language: [ 'csharp' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] - # Learn more: - # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed + language: [ 'csharp', 'actions' ] steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v7.0.1 - name: Initialize CodeQL uses: github/codeql-action/init@v4 @@ -43,14 +47,17 @@ jobs: languages: ${{ matrix.language }} - name: Setup .NET - uses: actions/setup-dotnet@v4 + if: matrix.language == 'csharp' + uses: actions/setup-dotnet@v6.0.0 with: dotnet-version: '10.0.x' - name: Restore + if: matrix.language == 'csharp' run: dotnet restore ManagedCode.Orleans.RateLimiting.sln - name: Build + if: matrix.language == 'csharp' run: dotnet build ManagedCode.Orleans.RateLimiting.sln --configuration Release --no-restore - name: Perform CodeQL Analysis diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 332bfc8..2fb78e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,10 +21,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v7.0.1 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v6.0.0 with: dotnet-version: ${{ env.DOTNET_VERSION }} @@ -32,7 +32,7 @@ jobs: id: version run: | VERSION=$(grep -oPm1 "(?<=)[^<]+" Directory.Build.props) - echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "Version from Directory.Build.props: $VERSION" - name: Restore local tools @@ -51,7 +51,7 @@ jobs: run: dotnet pack ManagedCode.Orleans.RateLimiting.sln --configuration Release --no-build -p:IncludeSymbols=false -p:SymbolPackageFormat=snupkg --output ./artifacts - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.1 with: name: nuget-packages path: ./artifacts/*.nupkg @@ -69,21 +69,23 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v7.0.1 - name: Download artifacts - uses: actions/download-artifact@v5 + uses: actions/download-artifact@v8.0.1 with: name: nuget-packages path: ./artifacts - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v6.0.0 with: dotnet-version: ${{ env.DOTNET_VERSION }} - name: Publish to NuGet id: publish + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} continue-on-error: true run: | set +e @@ -93,7 +95,7 @@ jobs: for package in ./artifacts/*.nupkg; do echo "Publishing $package..." RESULT=$(dotnet nuget push "$package" \ - --api-key ${{ secrets.NUGET_API_KEY }} \ + --api-key "$NUGET_API_KEY" \ --source https://api.nuget.org/v3/index.json \ --skip-duplicate 2>&1) EXIT_CODE=$? @@ -112,10 +114,10 @@ jobs: done if [ "$PUBLISHED" = true ] || echo "$OUTPUT" | grep -q "Your package was pushed"; then - echo "published=true" >> $GITHUB_OUTPUT + echo "published=true" >> "$GITHUB_OUTPUT" echo "At least one package was successfully published" else - echo "published=false" >> $GITHUB_OUTPUT + echo "published=false" >> "$GITHUB_OUTPUT" echo "No new packages were published (all already exist)" fi @@ -127,21 +129,22 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v7.0.1 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - name: Download artifacts - uses: actions/download-artifact@v5 + uses: actions/download-artifact@v8.0.1 with: name: nuget-packages path: ./artifacts - name: Create and push tag id: create_tag + env: + VERSION: ${{ needs.publish-nuget.outputs.version }} run: | - VERSION="${{ needs.publish-nuget.outputs.version }}" TAG="v$VERSION" git config user.name "github-actions[bot]" @@ -149,77 +152,73 @@ jobs: if git rev-parse "$TAG" >/dev/null 2>&1; then echo "Tag $TAG already exists" - echo "tag_exists=true" >> $GITHUB_OUTPUT + echo "tag_exists=true" >> "$GITHUB_OUTPUT" else echo "Creating tag $TAG" git tag -a "$TAG" -m "Release $VERSION" git push origin "$TAG" - echo "tag_exists=false" >> $GITHUB_OUTPUT + echo "tag_exists=false" >> "$GITHUB_OUTPUT" fi - name: Get previous tag id: prev_tag + env: + VERSION: ${{ needs.publish-nuget.outputs.version }} run: | - CURRENT_TAG="v${{ needs.publish-nuget.outputs.version }}" + CURRENT_TAG="v$VERSION" PREVIOUS_TAG=$(git tag --sort=-version:refname | grep -A1 "^$CURRENT_TAG$" | tail -n1 || echo "") if [ "$PREVIOUS_TAG" = "$CURRENT_TAG" ] || [ -z "$PREVIOUS_TAG" ]; then PREVIOUS_TAG=$(git tag --sort=-version:refname | grep -v "^$CURRENT_TAG$" | head -n1 || echo "") fi - echo "previous_tag=$PREVIOUS_TAG" >> $GITHUB_OUTPUT + echo "previous_tag=$PREVIOUS_TAG" >> "$GITHUB_OUTPUT" echo "Current tag: $CURRENT_TAG" echo "Previous tag: $PREVIOUS_TAG" - name: Generate release notes id: release_notes + env: + VERSION: ${{ needs.publish-nuget.outputs.version }} + PREVIOUS_TAG: ${{ steps.prev_tag.outputs.previous_tag }} run: | - VERSION="${{ needs.publish-nuget.outputs.version }}" - CURRENT_TAG="v$VERSION" - PREVIOUS_TAG="${{ steps.prev_tag.outputs.previous_tag }}" - - echo "# Release $VERSION" > release_notes.md - echo "" >> release_notes.md - echo "Released on $(date +'%Y-%m-%d')" >> release_notes.md - echo "" >> release_notes.md - - if [ -n "$PREVIOUS_TAG" ]; then - echo "## 📋 Changes since $PREVIOUS_TAG" >> release_notes.md - echo "" >> release_notes.md - - echo "### ✨ Features" >> release_notes.md - git log --pretty=format:"- %s (%h)" $PREVIOUS_TAG..HEAD --grep="^feat" --grep="^feature" >> release_notes.md || true - echo "" >> release_notes.md - - echo "### 🐛 Bug Fixes" >> release_notes.md - git log --pretty=format:"- %s (%h)" $PREVIOUS_TAG..HEAD --grep="^fix" --grep="^bugfix" >> release_notes.md || true - echo "" >> release_notes.md - - echo "### 📚 Documentation" >> release_notes.md - git log --pretty=format:"- %s (%h)" $PREVIOUS_TAG..HEAD --grep="^docs" --grep="^doc" >> release_notes.md || true - echo "" >> release_notes.md - - echo "### 🔧 Other Changes" >> release_notes.md - git log --pretty=format:"- %s (%h)" $PREVIOUS_TAG..HEAD --invert-grep --grep="^feat" --grep="^feature" --grep="^fix" --grep="^bugfix" --grep="^docs" --grep="^doc" >> release_notes.md || true - echo "" >> release_notes.md - else - echo "## 🎉 Initial Release" >> release_notes.md - echo "" >> release_notes.md - echo "### Recent Changes" >> release_notes.md - git log --pretty=format:"- %s (%h)" --max-count=20 >> release_notes.md - echo "" >> release_notes.md - fi - - echo "" >> release_notes.md - echo "## 📦 NuGet Packages" >> release_notes.md - echo "" >> release_notes.md - for package in ./artifacts/*.nupkg; do - PACKAGE_NAME=$(basename "$package" .nupkg) - BASE_NAME=$(echo "$PACKAGE_NAME" | sed "s/\.$VERSION//") - echo "- [$BASE_NAME v$VERSION](https://www.nuget.org/packages/$BASE_NAME/$VERSION)" >> release_notes.md - done - - echo "" >> release_notes.md - echo "---" >> release_notes.md - echo "*This release was automatically created by GitHub Actions*" >> release_notes.md + { + echo "# Release $VERSION" + echo "" + echo "Released on $(date +'%Y-%m-%d')" + echo "" + if [ -n "$PREVIOUS_TAG" ]; then + echo "## 📋 Changes since $PREVIOUS_TAG" + echo "" + echo "### ✨ Features" + git log --pretty=format:"- %s (%h)" "$PREVIOUS_TAG..HEAD" --grep="^feat" --grep="^feature" || true + echo "" + echo "### 🐛 Bug Fixes" + git log --pretty=format:"- %s (%h)" "$PREVIOUS_TAG..HEAD" --grep="^fix" --grep="^bugfix" || true + echo "" + echo "### 📚 Documentation" + git log --pretty=format:"- %s (%h)" "$PREVIOUS_TAG..HEAD" --grep="^docs" --grep="^doc" || true + echo "" + echo "### 🔧 Other Changes" + git log --pretty=format:"- %s (%h)" "$PREVIOUS_TAG..HEAD" --invert-grep --grep="^feat" --grep="^feature" --grep="^fix" --grep="^bugfix" --grep="^docs" --grep="^doc" || true + echo "" + else + echo "## 🎉 Initial Release" + echo "" + echo "### Recent Changes" + git log --pretty=format:"- %s (%h)" --max-count=20 + echo "" + fi + echo "" + echo "## 📦 NuGet Packages" + echo "" + for package in ./artifacts/*.nupkg; do + PACKAGE_NAME=$(basename "$package" .nupkg) + BASE_NAME=${PACKAGE_NAME%."$VERSION"} + echo "- [$BASE_NAME v$VERSION](https://www.nuget.org/packages/$BASE_NAME/$VERSION)" + done + echo "" + echo "---" + echo "*This release was automatically created by GitHub Actions*" + } > release_notes.md - name: Create GitHub Release uses: softprops/action-gh-release@v2 diff --git a/AGENTS.md b/AGENTS.md index 07023f7..55e360c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,13 +69,18 @@ Skill-management rules: - `format`: `dotnet format ManagedCode.Orleans.RateLimiting.sln --verify-no-changes` - `test`: `dotnet test --solution ManagedCode.Orleans.RateLimiting.sln --configuration Release --no-build --verbosity normal` - `coverage`: `dotnet tool restore` then `dotnet tool run coverlet ManagedCode.Orleans.RateLimiting.Tests/bin/Release/net10.0/ManagedCode.Orleans.RateLimiting.Tests.dll --target "dotnet" --targetargs "test --project ManagedCode.Orleans.RateLimiting.Tests/ManagedCode.Orleans.RateLimiting.Tests.csproj --configuration Release --no-build --no-restore" --format cobertura --output artifacts/coverage/coverage.cobertura.xml --exclude "[ManagedCode.Orleans.RateLimiting.Tests]*" --threshold 85 --threshold-type line --threshold-stat total` +- CI coverage: deterministic builds map source paths to `/_/`; generate `artifacts/coverage/SourceRootsMapping.txt` with `|/=/_/` and pass `--source-mapping-file artifacts/coverage/SourceRootsMapping.txt` to the coverage command. Keep the 85% threshold and source inclusion unchanged. - `coverage-report`: `dotnet tool run reportgenerator -reports:"artifacts/coverage/coverage.cobertura.xml" -targetdir:"artifacts/coverage-report" -reporttypes:"HtmlSummary;MarkdownSummaryGithub"` - `pack`: `dotnet pack ManagedCode.Orleans.RateLimiting.sln --configuration Release --no-build` +- `performance`: manually dispatch `ci.yml` on the working branch for an isolated baseline/updated comparison; see `docs/PerformanceAndTimeouts.md`. Functional CI runs omit benchmarks. .NET-specific rules: - The target framework is configured centrally in `Directory.Build.props`. - Package versions are configured centrally in `Directory.Packages.props`. +- Packing validates public API compatibility against published package version `10.1.0` via `PackageValidationBaselineVersion`. Keep the baseline on the previous stable release and do not suppress compatibility failures without explicit review. CI runs pack as a compatibility gate before tests. +- Published Core and Server RPC identities are guarded by the Orleans contract analyzer and checked-in project-local `OrleansContracts.txt` manifests. Versioning diagnostics are errors in the root `.editorconfig`. +- Regenerate contracts with `dotnet format analyzers ManagedCode.Orleans.RateLimiting.sln --no-restore --severity info --diagnostics ORLEANS0016 ORLEANS0017 ORLEANS0018 ORLEANS0019 ORLEANS0020 ORLEANS0022 ORLEANS0023 ORLEANS0024`, then review every identity/signature diff and build. Preserve retired entries; never regenerate baselines automatically in CI. For a new opted-in project, create an empty manifest file before the first regeneration if the SDK fails to persist a newly added AdditionalFile. - Tests run through Microsoft.Testing.Platform with TUnit. - Assertions use Shouldly. - Do not add xUnit, NUnit, MSTest, or FluentAssertions to new code. @@ -123,6 +128,8 @@ Skill-management rules: - Tests should prove real Orleans, middleware, HTTP, or SignalR flows through public contracts. - Prefer TestServer, Orleans TestingHost, and real in-process dependencies over mocks. - Flaky tests are failures; fix the cause. +- Performance and timeout behavior are release concerns: measure hot-path changes with a repeatable baseline, report latency/throughput/allocations, and verify queue cleanup and permit ownership after timeouts. +- Investigate material acquisition throughput or p99 regressions instead of accepting them as a compatibility tradeoff; performance is the priority for this library. - Do not lower coverage, analyzer severity, or assertion strength to make a branch green. ## Code and Design diff --git a/Directory.Build.props b/Directory.Build.props index 66ffe37..bbb2b5a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -11,7 +11,7 @@ latest-recommended false $(NoWarn);CS1591;CA1707;CA1848;CA1859;CA1873 - 10.1.0 + 10.2.0 $(Version) @@ -33,6 +33,7 @@ true snupkg true + 10.1.0 $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb diff --git a/Directory.Packages.props b/Directory.Packages.props index 7f4c01d..e9b585c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,28 +4,29 @@ - + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - + + diff --git a/ManagedCode.Orleans.RateLimiting.Client/AGENTS.md b/ManagedCode.Orleans.RateLimiting.Client/AGENTS.md index 6e031e8..32824dd 100644 --- a/ManagedCode.Orleans.RateLimiting.Client/AGENTS.md +++ b/ManagedCode.Orleans.RateLimiting.Client/AGENTS.md @@ -14,6 +14,7 @@ Provides ASP.NET Core and SignalR integration that resolves rate-limiter metadat - `Middlewares/RateLimitingHubFilter.cs` — SignalR hub invocation limiting. - `Attributes/` — ASP.NET-facing limiter attributes. - `Extensions/` — registration and pipeline helpers. +- `Services/` — acquisition timeout provider backed by client messaging options. ## Boundaries diff --git a/ManagedCode.Orleans.RateLimiting.Client/Extensions/ClientBuilderExtensions.cs b/ManagedCode.Orleans.RateLimiting.Client/Extensions/ClientBuilderExtensions.cs index d4ce59f..304bb86 100644 --- a/ManagedCode.Orleans.RateLimiting.Client/Extensions/ClientBuilderExtensions.cs +++ b/ManagedCode.Orleans.RateLimiting.Client/Extensions/ClientBuilderExtensions.cs @@ -1,6 +1,3 @@ -using ManagedCode.Orleans.RateLimiting.Core.Extensions; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; using Orleans; using Orleans.Hosting; @@ -10,8 +7,7 @@ public static class ClientBuilderExtensions { public static IClientBuilder AddOrleansRateLimiting(this IClientBuilder clientBuilder) { - clientBuilder.Services.AddOrleansRateLimitingCore(); - clientBuilder.Services.TryAddSingleton(serviceProvider => serviceProvider.GetRequiredService()); + clientBuilder.Services.AddOrleansRateLimiting(); return clientBuilder; } } diff --git a/ManagedCode.Orleans.RateLimiting.Client/Extensions/HttpRequestExtensions.cs b/ManagedCode.Orleans.RateLimiting.Client/Extensions/HttpRequestExtensions.cs index 9a0c5ca..9e1586f 100644 --- a/ManagedCode.Orleans.RateLimiting.Client/Extensions/HttpRequestExtensions.cs +++ b/ManagedCode.Orleans.RateLimiting.Client/Extensions/HttpRequestExtensions.cs @@ -8,22 +8,26 @@ namespace ManagedCode.Orleans.RateLimiting.Client.Extensions; public static class HttpRequestExtensions { private const char CsvSeparator = ','; - private const string RealIpHeaderName = "X-Real-IP"; - private const string ForwardedForHeaderName = "X-Forwarded-For"; - private const string RemoteAddressHeaderName = "REMOTE_ADDR"; - - private static readonly string[] DefaultIpHeaders = - [ - RealIpHeaderName, - ForwardedForHeaderName, - RemoteAddressHeaderName - ]; + private const string UnknownIpAddress = "unknown-ip"; + /// + /// Returns the connection address after any trusted proxy middleware has run. + /// Raw request headers are never used by this overload. + /// public static string GetClientIpAddress(this HttpRequest request) { - return GetClientIpAddress(request, DefaultIpHeaders); + var address = request.HttpContext.Connection.RemoteIpAddress; + if (address is null) + return UnknownIpAddress; + + return (address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address).ToString(); } + /// + /// Reads explicitly selected headers. Only use this legacy overload after independently + /// validating the proxy and ensuring it overwrites these headers. Header names do not + /// establish trust. Prefer the parameterless overload with ForwardedHeadersMiddleware. + /// public static string GetClientIpAddress(this HttpRequest request, string[] headers) { string? ip = null; diff --git a/ManagedCode.Orleans.RateLimiting.Client/Extensions/ServiceCollectionExtensions.cs b/ManagedCode.Orleans.RateLimiting.Client/Extensions/ServiceCollectionExtensions.cs index 07548ec..c38dcb2 100644 --- a/ManagedCode.Orleans.RateLimiting.Client/Extensions/ServiceCollectionExtensions.cs +++ b/ManagedCode.Orleans.RateLimiting.Client/Extensions/ServiceCollectionExtensions.cs @@ -1,3 +1,5 @@ +using ManagedCode.Orleans.RateLimiting.Core.Services; +using ManagedCode.Orleans.RateLimiting.Client.Services; using System; using ManagedCode.Orleans.RateLimiting.Core.Options; using ManagedCode.Orleans.RateLimiting.Client.Middlewares; @@ -12,6 +14,7 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddOrleansRateLimiting(this IServiceCollection collection) { ManagedCode.Orleans.RateLimiting.Core.Extensions.ServiceCollectionExtensions.AddOrleansRateLimitingCore(collection); + collection.TryAddSingleton(); collection.TryAddSingleton(sp => sp.GetRequiredService()); return collection; } diff --git a/ManagedCode.Orleans.RateLimiting.Client/Middlewares/OrleansBaseRateLimitingMiddleware.cs b/ManagedCode.Orleans.RateLimiting.Client/Middlewares/OrleansBaseRateLimitingMiddleware.cs index 00c3b4c..33be095 100644 --- a/ManagedCode.Orleans.RateLimiting.Client/Middlewares/OrleansBaseRateLimitingMiddleware.cs +++ b/ManagedCode.Orleans.RateLimiting.Client/Middlewares/OrleansBaseRateLimitingMiddleware.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using ManagedCode.Orleans.RateLimiting.Client.Attributes; using ManagedCode.Orleans.RateLimiting.Core.Extensions; +using ManagedCode.Orleans.RateLimiting.Core.Exceptions; using ManagedCode.Orleans.RateLimiting.Core.Models; using ManagedCode.Orleans.RateLimiting.Core.Models.Holders; using ManagedCode.Orleans.RateLimiting.Core.Models.Orchestration; @@ -38,7 +39,7 @@ public async Task Invoke(HttpContext httpContext) AddLimiters(httpContext, holder); - var error = await holder.AcquireAsync(); + var error = await holder.AcquireAsync(httpContext.RequestAborted); if (error is null) { await _next(httpContext); @@ -80,10 +81,14 @@ protected static (T attribute, string? postfix)? TryGetAttribute(HttpContext protected ILimiterHolder? TryGetLimiterHolder(string key, string configurationName) { - var limiter = _client.GetRateLimiterByConfig(key, configurationName, _services.GetServices()); + var partitionKey = CreateKey(configurationName.ToUpperInvariant(), key); + var limiter = _client.GetRateLimiterByConfig(partitionKey, configurationName, _services.GetServices()); if (limiter is null) + { _logger.LogError(RateLimitMiddlewareConstants.ConfigurationNotFoundLogMessage, configurationName); + throw new RateLimitConfigurationNotFoundException(configurationName); + } return limiter; } diff --git a/ManagedCode.Orleans.RateLimiting.Client/Middlewares/OrleansRequestRateLimitingMiddleware.cs b/ManagedCode.Orleans.RateLimiting.Client/Middlewares/OrleansRequestRateLimitingMiddleware.cs index 08d0dde..97fd046 100644 --- a/ManagedCode.Orleans.RateLimiting.Client/Middlewares/OrleansRequestRateLimitingMiddleware.cs +++ b/ManagedCode.Orleans.RateLimiting.Client/Middlewares/OrleansRequestRateLimitingMiddleware.cs @@ -30,7 +30,7 @@ public OrleansRequestRateLimitingMiddleware(RequestDelegate next, IRateLimitRequ public async Task Invoke(HttpContext httpContext) { await using var holder = await _orchestrator.CreateLimiterGroupAsync(CreateContext(httpContext), httpContext.RequestAborted); - var error = await holder.AcquireAsync(); + var error = await holder.AcquireAsync(httpContext.RequestAborted); if (error is null) { diff --git a/ManagedCode.Orleans.RateLimiting.Client/Middlewares/RateLimitingHubFilter.cs b/ManagedCode.Orleans.RateLimiting.Client/Middlewares/RateLimitingHubFilter.cs index f6554d5..b0c7fd1 100644 --- a/ManagedCode.Orleans.RateLimiting.Client/Middlewares/RateLimitingHubFilter.cs +++ b/ManagedCode.Orleans.RateLimiting.Client/Middlewares/RateLimitingHubFilter.cs @@ -3,6 +3,7 @@ using System.Security.Claims; using System.Threading.Tasks; using ManagedCode.Orleans.RateLimiting.Client.Options; +using ManagedCode.Orleans.RateLimiting.Client.Extensions; using ManagedCode.Orleans.RateLimiting.Core.Exceptions; using ManagedCode.Orleans.RateLimiting.Core.Interfaces; using ManagedCode.Orleans.RateLimiting.Core.Models.Orchestration; @@ -30,8 +31,8 @@ public RateLimitingHubFilter( public async ValueTask InvokeMethodAsync(HubInvocationContext invocationContext, Func> next) { - await using var holder = await _orchestrator.CreateLimiterGroupAsync(CreateContext(invocationContext)); - var lease = await holder.AcquireAsync(); + await using var holder = await _orchestrator.CreateLimiterGroupAsync(CreateContext(invocationContext), invocationContext.Context.ConnectionAborted); + var lease = await holder.AcquireAsync(invocationContext.Context.ConnectionAborted); if (lease is not null) { _logger.LogInformation(RateLimitMiddlewareConstants.SignalRRateLimitedLogMessage, invocationContext.HubMethodName, lease.Reason); @@ -70,7 +71,7 @@ private RateLimitRequestContext CreateContext(HubInvocationContext invocationCon TenantId = user?.FindFirstValue(RateLimitMiddlewareConstants.TenantIdClaimType) ?? user?.FindFirstValue(RateLimitMiddlewareConstants.ShortTenantIdClaimType), Role = user?.FindFirstValue(ClaimTypes.Role), - IpAddress = httpContext?.Connection.RemoteIpAddress?.ToString(), + IpAddress = httpContext?.Request.GetClientIpAddress(), Resource = string.Concat( invocationContext.Hub.GetType().FullName, RateLimitMiddlewareConstants.ResourceSeparator, diff --git a/ManagedCode.Orleans.RateLimiting.Client/Services/ClientRateLimiterTimeoutProvider.cs b/ManagedCode.Orleans.RateLimiting.Client/Services/ClientRateLimiterTimeoutProvider.cs new file mode 100644 index 0000000..7e54773 --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Client/Services/ClientRateLimiterTimeoutProvider.cs @@ -0,0 +1,8 @@ +using ManagedCode.Orleans.RateLimiting.Core.Services; +using Microsoft.Extensions.Options; +using Orleans.Configuration; + +namespace ManagedCode.Orleans.RateLimiting.Client.Services; + +internal sealed class ClientRateLimiterTimeoutProvider(IOptions options) + : RateLimiterTimeoutProvider(() => options.Value.ResponseTimeout); diff --git a/ManagedCode.Orleans.RateLimiting.Core/AGENTS.md b/ManagedCode.Orleans.RateLimiting.Core/AGENTS.md index e352002..51f94a8 100644 --- a/ManagedCode.Orleans.RateLimiting.Core/AGENTS.md +++ b/ManagedCode.Orleans.RateLimiting.Core/AGENTS.md @@ -13,6 +13,7 @@ Defines shared Orleans rate-limiter contracts, attributes, option models, lease - `Models/` — rate-limit leases, config, metadata, and holder types. - `Surrogates/` — Orleans serialization surrogates and converters for rate limiter options/statistics. - `Extensions/` — shared service and grain-factory helpers. +- `Services/BoundedRateLimiterGrainReference.cs` and `RateLimiterTimeoutProvider.cs` — generated proxy dispatch and host-supplied acquisition budgets without a global call filter. ## Boundaries @@ -37,7 +38,10 @@ Defines shared Orleans rate-limiter contracts, attributes, option models, lease ## Protected Areas +- `OrleansContracts.txt` records the published RPC identities; regenerate with the root command, review every diff, and retain retired history. + - Grain interface signatures. +- The bounded proxy base type participates in generated invoker identities; treat its namespace/type changes as wire-contract changes. - Attribute constructor behaviour and key semantics. - Orleans surrogate/converter pairs. - Lease metadata names and retry-after semantics. diff --git a/ManagedCode.Orleans.RateLimiting.Core/Exceptions/RateLimitConfigurationNotFoundException.cs b/ManagedCode.Orleans.RateLimiting.Core/Exceptions/RateLimitConfigurationNotFoundException.cs index 6c3064b..b6df974 100644 --- a/ManagedCode.Orleans.RateLimiting.Core/Exceptions/RateLimitConfigurationNotFoundException.cs +++ b/ManagedCode.Orleans.RateLimiting.Core/Exceptions/RateLimitConfigurationNotFoundException.cs @@ -1,9 +1,13 @@ using System; +using Orleans; namespace ManagedCode.Orleans.RateLimiting.Core.Exceptions; +[GenerateSerializer] public sealed class RateLimitConfigurationNotFoundException : Exception { + private const int ConfigurationNameFieldId = 0; + public RateLimitConfigurationNotFoundException() { } @@ -14,5 +18,6 @@ public RateLimitConfigurationNotFoundException(string configurationName) ConfigurationName = configurationName; } + [Id(ConfigurationNameFieldId)] public string? ConfigurationName { get; } } diff --git a/ManagedCode.Orleans.RateLimiting.Core/Exceptions/RateLimitExceededException.cs b/ManagedCode.Orleans.RateLimiting.Core/Exceptions/RateLimitExceededException.cs index e73a47b..7002ed1 100644 --- a/ManagedCode.Orleans.RateLimiting.Core/Exceptions/RateLimitExceededException.cs +++ b/ManagedCode.Orleans.RateLimiting.Core/Exceptions/RateLimitExceededException.cs @@ -1,10 +1,15 @@ using System; +using Orleans; using ManagedCode.Orleans.RateLimiting.Core.Models; namespace ManagedCode.Orleans.RateLimiting.Core.Exceptions; +[GenerateSerializer] public class RateLimitExceededException : Exception { + private const int ReasonFieldId = 0; + private const int RetryAfterFieldId = 1; + public RateLimitExceededException() : base(RateLimitMetadataNames.RateLimitExceededReason) { Reason = RateLimitMetadataNames.RateLimitExceededReason; @@ -29,6 +34,8 @@ public RateLimitExceededException(string reason, TimeSpan retry) : base(reason) RetryAfter = retry; } + [Id(ReasonFieldId)] public string Reason { get; set; } + [Id(RetryAfterFieldId)] public TimeSpan RetryAfter { get; set; } } diff --git a/ManagedCode.Orleans.RateLimiting.Core/Exceptions/RateLimitPartitionKeyNotFoundException.cs b/ManagedCode.Orleans.RateLimiting.Core/Exceptions/RateLimitPartitionKeyNotFoundException.cs index e538ecb..acede14 100644 --- a/ManagedCode.Orleans.RateLimiting.Core/Exceptions/RateLimitPartitionKeyNotFoundException.cs +++ b/ManagedCode.Orleans.RateLimiting.Core/Exceptions/RateLimitPartitionKeyNotFoundException.cs @@ -1,10 +1,14 @@ using System; +using Orleans; using ManagedCode.Orleans.RateLimiting.Core.Models.Orchestration; namespace ManagedCode.Orleans.RateLimiting.Core.Exceptions; +[GenerateSerializer] public sealed class RateLimitPartitionKeyNotFoundException : Exception { + private const int KindFieldId = 0; + public RateLimitPartitionKeyNotFoundException() { } @@ -15,5 +19,6 @@ public RateLimitPartitionKeyNotFoundException(RateLimitPartitionKind kind) Kind = kind; } + [Id(KindFieldId)] public RateLimitPartitionKind? Kind { get; } } diff --git a/ManagedCode.Orleans.RateLimiting.Core/Interfaces/IBoundedRateLimiterGrain.cs b/ManagedCode.Orleans.RateLimiting.Core/Interfaces/IBoundedRateLimiterGrain.cs new file mode 100644 index 0000000..3986929 --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Core/Interfaces/IBoundedRateLimiterGrain.cs @@ -0,0 +1,30 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using ManagedCode.Orleans.RateLimiting.Core.Models; +using ManagedCode.Orleans.RateLimiting.Core.Services; +using Orleans; +using Orleans.Concurrency; + +namespace ManagedCode.Orleans.RateLimiting.Core.Interfaces; + +/// Server-enforced acquisition budgets without cancellation traffic on the fast path. +[GenerateMethodSerializers(typeof(BoundedRateLimiterGrainReference))] +public interface IBoundedRateLimiterGrain : IGrainWithStringKey +{ + [AlwaysInterleave] + Task AcquireWithDeadlineAsync(int permitCount, TimeSpan timeout); + + [AlwaysInterleave] + Task AcquireCancellableWithDeadlineAsync(int permitCount, TimeSpan timeout, CancellationToken cancellationToken); +} + +[GenerateMethodSerializers(typeof(BoundedRateLimiterGrainReference))] +public interface IBoundedRateLimiterGrain : IBoundedRateLimiterGrain +{ + [AlwaysInterleave] + Task AcquireAndCheckConfigurationWithDeadlineAsync(int permitCount, TOptions options, TimeSpan timeout); + + [AlwaysInterleave] + Task AcquireAndCheckConfigurationCancellableWithDeadlineAsync(int permitCount, TOptions options, TimeSpan timeout, CancellationToken cancellationToken); +} diff --git a/ManagedCode.Orleans.RateLimiting.Core/Interfaces/ICancellableRateLimiterGrain.cs b/ManagedCode.Orleans.RateLimiting.Core/Interfaces/ICancellableRateLimiterGrain.cs new file mode 100644 index 0000000..5559ec0 --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Core/Interfaces/ICancellableRateLimiterGrain.cs @@ -0,0 +1,20 @@ +using System.Threading; +using System.Threading.Tasks; +using ManagedCode.Orleans.RateLimiting.Core.Models; +using Orleans; +using Orleans.Concurrency; + +namespace ManagedCode.Orleans.RateLimiting.Core.Interfaces; + +/// Optional cancellation capability. Existing grain contracts retain their RPC identities. +public interface ICancellableRateLimiterGrain : IGrainWithStringKey +{ + [AlwaysInterleave] + Task AcquireAsync(int permitCount, CancellationToken cancellationToken); +} + +public interface ICancellableRateLimiterGrain : ICancellableRateLimiterGrain +{ + [AlwaysInterleave] + Task AcquireAndCheckConfigurationAsync(int permitCount, TOptions options, CancellationToken cancellationToken); +} diff --git a/ManagedCode.Orleans.RateLimiting.Core/ManagedCode.Orleans.RateLimiting.Core.csproj b/ManagedCode.Orleans.RateLimiting.Core/ManagedCode.Orleans.RateLimiting.Core.csproj index a18c2ee..a85dc0d 100644 --- a/ManagedCode.Orleans.RateLimiting.Core/ManagedCode.Orleans.RateLimiting.Core.csproj +++ b/ManagedCode.Orleans.RateLimiting.Core/ManagedCode.Orleans.RateLimiting.Core.csproj @@ -3,6 +3,7 @@ Library true + true diff --git a/ManagedCode.Orleans.RateLimiting.Core/Models/Holders/BaseRateLimiterHolder.Cancellation.cs b/ManagedCode.Orleans.RateLimiting.Core/Models/Holders/BaseRateLimiterHolder.Cancellation.cs new file mode 100644 index 0000000..69f347e --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Core/Models/Holders/BaseRateLimiterHolder.Cancellation.cs @@ -0,0 +1,68 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using ManagedCode.Orleans.RateLimiting.Core.Interfaces; +using Orleans; + +namespace ManagedCode.Orleans.RateLimiting.Core.Models.Holders; + +public abstract partial class BaseRateLimiterHolder + where TGrain : IRateLimiterGrainWithConfiguration + where TOption : class +{ + public Task AcquireAsync(int permitCount, CancellationToken cancellationToken) + { + return AcquireCancellableAsync(permitCount, null, cancellationToken); + } + + public Task AcquireAndConfigureAsync(int permitCount, CancellationToken cancellationToken) + { + return AcquireAndCheckConfigurationAsync(permitCount, _option, cancellationToken); + } + + public Task AcquireAndCheckConfigurationAsync(int permitCount, TOption? options, CancellationToken cancellationToken) + { + options = _option ?? options; + return AcquireCancellableAsync(permitCount, options, cancellationToken); + } + + private async Task AcquireCancellableAsync(int permitCount, TOption? options, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var metadata = options is null + ? await AcquireBoundedAsync(permitCount, cancellationToken) + : await AcquireConfiguredBoundedAsync(permitCount, options, cancellationToken); + var lease = new OrleansRateLimitLease(metadata, _grainFactory, _grain); + if (cancellationToken.IsCancellationRequested) + { + await lease.DisposeAsync(); + cancellationToken.ThrowIfCancellationRequested(); + } + return lease; + } + catch (TimeoutException) + { + cancellationToken.ThrowIfCancellationRequested(); + return new OrleansRateLimitLease(new RateLimitLeaseMetadata(_grain.GetGrainId()), _grainFactory); + } + } + + private Task AcquireBoundedAsync(int permitCount, CancellationToken cancellationToken) + { + IBoundedRateLimiterGrain grain = _boundedGrain; + return cancellationToken.CanBeCanceled + ? grain.AcquireCancellableWithDeadlineAsync(permitCount, Timeout.InfiniteTimeSpan, cancellationToken) + : grain.AcquireWithDeadlineAsync(permitCount, Timeout.InfiniteTimeSpan); + } + + private Task AcquireConfiguredBoundedAsync(int permitCount, TOption options, CancellationToken cancellationToken) + { + var grain = _boundedGrain; + return cancellationToken.CanBeCanceled + ? grain.AcquireAndCheckConfigurationCancellableWithDeadlineAsync(permitCount, options, Timeout.InfiniteTimeSpan, cancellationToken) + : grain.AcquireAndCheckConfigurationWithDeadlineAsync(permitCount, options, Timeout.InfiniteTimeSpan); + } + +} diff --git a/ManagedCode.Orleans.RateLimiting.Core/Models/Holders/BaseRateLimiterHolder.cs b/ManagedCode.Orleans.RateLimiting.Core/Models/Holders/BaseRateLimiterHolder.cs index c31fb76..407c1fe 100644 --- a/ManagedCode.Orleans.RateLimiting.Core/Models/Holders/BaseRateLimiterHolder.cs +++ b/ManagedCode.Orleans.RateLimiting.Core/Models/Holders/BaseRateLimiterHolder.cs @@ -6,44 +6,36 @@ namespace ManagedCode.Orleans.RateLimiting.Core.Models.Holders; -public abstract class BaseRateLimiterHolder : ILimiterHolderWithConfiguration +public abstract partial class BaseRateLimiterHolder : ILimiterHolderWithConfiguration, ICancellableLimiterHolder where TGrain : IRateLimiterGrainWithConfiguration where TOption : class { + private const int DefaultPermitCount = 1; private readonly TGrain _grain; + private readonly IBoundedRateLimiterGrain _boundedGrain; private readonly IGrainFactory _grainFactory; private readonly TOption? _option; internal BaseRateLimiterHolder(TGrain grain, IGrainFactory grainFactory) { _grain = grain; + _boundedGrain = grain.AsReference>(); _grainFactory = grainFactory; } internal BaseRateLimiterHolder(TGrain grain, IGrainFactory grainFactory, TOption option) { _grain = grain; + _boundedGrain = grain.AsReference>(); _grainFactory = grainFactory; _option = option; } - public async Task AcquireAsync(int permitCount = 1) - { - try - { - var metadata = await _grain.AcquireAsync(permitCount); - return new OrleansRateLimitLease(metadata, _grainFactory); - } - catch (TimeoutException) - { - return new OrleansRateLimitLease(new RateLimitLeaseMetadata(_grain.GetGrainId()), _grainFactory); - } - } + public Task AcquireAsync(int permitCount = 1) + => AcquireAsync(permitCount, System.Threading.CancellationToken.None); - public async Task AcquireAndConfigureAsync(int permitCount = 1) - { - return _option is null ? await AcquireAsync(permitCount) : await AcquireAndCheckConfigurationAsync(permitCount, _option); - } + public Task AcquireAndConfigureAsync(int permitCount = 1) + => AcquireAndConfigureAsync(permitCount, System.Threading.CancellationToken.None); public ValueTask GetStatisticsAsync() { @@ -60,44 +52,11 @@ public ValueTask DeleteStateAsync() return _grain.DeleteStateAsync(); } - public async Task AcquireAndCheckConfigurationAsync(TOption? options) - { - if (options is null && _option is null) - return await AcquireAsync(); - - if (_option is not null) - options = _option; - - try - { - ArgumentNullException.ThrowIfNull(options); - var metadata = await _grain.AcquireAndCheckConfigurationAsync(options); - return new OrleansRateLimitLease(metadata, _grainFactory); - } - catch (TimeoutException) - { - return new OrleansRateLimitLease(new RateLimitLeaseMetadata(_grain.GetGrainId()), _grainFactory); - } - } - - public async Task AcquireAndCheckConfigurationAsync(int permitCount, TOption? options) - { - if (_option is not null) - options = _option; - - if (options is null) - return await AcquireAsync(); + public Task AcquireAndCheckConfigurationAsync(TOption? options) + => AcquireAndCheckConfigurationAsync(DefaultPermitCount, options, System.Threading.CancellationToken.None); - try - { - var metadata = await _grain.AcquireAndCheckConfigurationAsync(permitCount, options); - return new OrleansRateLimitLease(metadata, _grainFactory); - } - catch (TimeoutException) - { - return new OrleansRateLimitLease(new RateLimitLeaseMetadata(_grain.GetGrainId()), _grainFactory); - } - } + public Task AcquireAndCheckConfigurationAsync(int permitCount, TOption? options) + => AcquireAndCheckConfigurationAsync(permitCount, options, System.Threading.CancellationToken.None); public ValueTask Configure(TOption options) { diff --git a/ManagedCode.Orleans.RateLimiting.Core/Models/Holders/GroupLimiterHolder.cs b/ManagedCode.Orleans.RateLimiting.Core/Models/Holders/GroupLimiterHolder.cs index b2a93bc..f866bef 100644 --- a/ManagedCode.Orleans.RateLimiting.Core/Models/Holders/GroupLimiterHolder.cs +++ b/ManagedCode.Orleans.RateLimiting.Core/Models/Holders/GroupLimiterHolder.cs @@ -1,79 +1,126 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; namespace ManagedCode.Orleans.RateLimiting.Core.Models.Holders; public class GroupLimiterHolder : IAsyncDisposable { + private const int DefaultPermitCount = 1; private const string AlreadyAcquiredMessage = "A limiter group can only be acquired once before it is disposed."; private readonly List _holders = []; + private readonly object _lifecycleSync = new(); + private Task? _acquisition; + private Task? _disposal; private bool _acquired; private bool _disposed; - public int Count => _holders.Count; + public int Count + { + get { lock (_lifecycleSync) return _holders.Count; } + } public async ValueTask DisposeAsync() { - if (_disposed) - return; - - _disposed = true; - await ReleaseAcquiredAsync(); + Task disposal; + lock (_lifecycleSync) + { + _disposed = true; + disposal = _disposal ??= DisposeCoreAsync(_acquisition); + } + await disposal; GC.SuppressFinalize(this); } public bool AddLimiter(ILimiterHolder? holder) { - ObjectDisposedException.ThrowIf(_disposed, this); - - if (holder is not null) + lock (_lifecycleSync) { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_acquired) + throw new InvalidOperationException(AlreadyAcquiredMessage); + if (holder is null) + return false; + _holders.Add(new LimiterEntry(holder)); return true; } - - return false; } - public async Task AcquireAsync() + public Task AcquireAsync() => AcquireAsync(CancellationToken.None); + + public async Task AcquireAsync(CancellationToken cancellationToken) { - ObjectDisposedException.ThrowIf(_disposed, this); + Task acquisition; + lock (_lifecycleSync) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_acquired) + throw new InvalidOperationException(AlreadyAcquiredMessage); - if (_acquired) - throw new InvalidOperationException(AlreadyAcquiredMessage); + // Reserve the group before the first asynchronous grain response can arrive. + _acquired = true; + acquisition = _acquisition = AcquireCoreAsync(cancellationToken); + } + return await acquisition; + } - for (var index = 0; index < _holders.Count; index++) + private async Task AcquireCoreAsync(CancellationToken cancellationToken) + { + try { - var entry = _holders[index]; - var lease = await entry.Holder.AcquireAndConfigureAsync(); - if (lease.IsAcquired) + for (var index = 0; index < _holders.Count; index++) { + var entry = _holders[index]; + cancellationToken.ThrowIfCancellationRequested(); + var lease = cancellationToken.CanBeCanceled && entry.Holder is ICancellableLimiterHolder cancellable + ? await cancellable.AcquireAndConfigureAsync(DefaultPermitCount, cancellationToken) + : await entry.Holder.AcquireAndConfigureAsync(); + // Retain ownership before checking cancellation, including legacy holders. _holders[index] = entry with { Lease = lease }; - _acquired = true; - } - else - { - await ReleaseAcquiredAsync(); - return lease; + cancellationToken.ThrowIfCancellationRequested(); + if (!lease.IsAcquired) + { + await ReleaseAcquiredAsync(); + return lease; + } } + cancellationToken.ThrowIfCancellationRequested(); + return null; } + catch + { + await ReleaseAcquiredAsync(); + throw; + } + } - return null; + private async Task DisposeCoreAsync(Task? acquisition) + { + try + { + if (acquisition is not null) + await acquisition; + } + catch + { + // AcquireAsync delivers the original failure; disposal must still release leases. + } + await ReleaseAcquiredAsync(); } private async Task ReleaseAcquiredAsync() { var leases = _holders.Select(entry => entry.Lease).Where(lease => lease is not null).ToArray(); - for (var index = 0; index < _holders.Count; index++) _holders[index] = _holders[index] with { Lease = null }; - _acquired = false; - await Task.WhenAll(leases.Select(lease => lease!.DisposeAsync().AsTask())); + lock (_lifecycleSync) + _acquired = false; } private readonly record struct LimiterEntry(ILimiterHolder Holder, OrleansRateLimitLease? Lease = null); diff --git a/ManagedCode.Orleans.RateLimiting.Core/Models/Holders/ICancellableLimiterHolder.cs b/ManagedCode.Orleans.RateLimiting.Core/Models/Holders/ICancellableLimiterHolder.cs new file mode 100644 index 0000000..e872bec --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Core/Models/Holders/ICancellableLimiterHolder.cs @@ -0,0 +1,11 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace ManagedCode.Orleans.RateLimiting.Core.Models.Holders; + +/// Optional capability for cancelling queued acquisitions without changing existing holder implementations. +public interface ICancellableLimiterHolder : ILimiterHolder +{ + Task AcquireAsync(int permitCount, CancellationToken cancellationToken); + Task AcquireAndConfigureAsync(int permitCount, CancellationToken cancellationToken); +} diff --git a/ManagedCode.Orleans.RateLimiting.Core/Models/OrleansRateLimitLease.cs b/ManagedCode.Orleans.RateLimiting.Core/Models/OrleansRateLimitLease.cs index d0371d1..981d231 100644 --- a/ManagedCode.Orleans.RateLimiting.Core/Models/OrleansRateLimitLease.cs +++ b/ManagedCode.Orleans.RateLimiting.Core/Models/OrleansRateLimitLease.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.Frozen; using System.Globalization; using System.Diagnostics; using System.Linq; @@ -16,19 +17,29 @@ namespace ManagedCode.Orleans.RateLimiting.Core.Models; public class OrleansRateLimitLease : IAsyncDisposable { private readonly GrainId _grainId; + private readonly IRateLimiterGrain? _releaseGrain; private readonly Guid _guid; - private readonly Dictionary _metadata; + private readonly bool _isReleaseOptional; + private readonly IReadOnlyDictionary _metadata; private bool _disposed; public OrleansRateLimitLease(RateLimitLeaseMetadata metadata, IGrainFactory grainFactory) { _guid = metadata.LeaseId; + _isReleaseOptional = metadata.IsReleaseOptional; _grainId = metadata.GrainId; IsAcquired = metadata.IsAcquired; - _metadata = metadata.Metadata.ToDictionary(k => k.Key, v => v.Value?.ToString()); + _metadata = metadata.Metadata is [] ? FrozenDictionary.Empty : metadata.Metadata.ToDictionary(k => k.Key, v => v.Value?.ToString()); GrainFactory = grainFactory; } + internal OrleansRateLimitLease(RateLimitLeaseMetadata metadata, IGrainFactory grainFactory, IRateLimiterGrain releaseGrain) + : this(metadata, grainFactory) + { + if (!metadata.IsReleaseOptional && releaseGrain.GetGrainId() == metadata.GrainId) + _releaseGrain = releaseGrain; + } + public IGrainFactory GrainFactory { get; init; } public string Reason => @@ -41,7 +52,7 @@ public OrleansRateLimitLease(RateLimitLeaseMetadata metadata, IGrainFactory grai public bool IsAcquired { get; init; } - public IEnumerable MetadataNames => _metadata.Select(s => s.Key); + public IEnumerable MetadataNames => _metadata.Keys; public async ValueTask DisposeAsync() { @@ -50,7 +61,7 @@ public async ValueTask DisposeAsync() _disposed = true; - if (_guid == Guid.Empty) + if (_guid == Guid.Empty || _isReleaseOptional) { GC.SuppressFinalize(this); return; @@ -58,7 +69,8 @@ public async ValueTask DisposeAsync() try { - await GrainFactory.GetGrain(_grainId).AsReference().ReleaseLease(_guid); + var grain = _releaseGrain ?? GrainFactory.GetGrain(_grainId).AsReference(); + await grain.ReleaseLease(_guid); } catch { diff --git a/ManagedCode.Orleans.RateLimiting.Core/Models/RateLimitLeaseMetadata.cs b/ManagedCode.Orleans.RateLimiting.Core/Models/RateLimitLeaseMetadata.cs index 1a4df8e..a28cdae 100644 --- a/ManagedCode.Orleans.RateLimiting.Core/Models/RateLimitLeaseMetadata.cs +++ b/ManagedCode.Orleans.RateLimiting.Core/Models/RateLimitLeaseMetadata.cs @@ -10,6 +10,8 @@ namespace ManagedCode.Orleans.RateLimiting.Core.Models; [GenerateSerializer] public class RateLimitLeaseMetadata { + private const int ReleaseOptionalMemberId = 4; + public RateLimitLeaseMetadata(Guid leaseId, GrainId grainId, RateLimitLease lease) { LeaseId = lease.IsAcquired ? leaseId : Guid.Empty; @@ -35,6 +37,10 @@ public RateLimitLeaseMetadata(GrainId grainId) [Id(2)] public bool IsAcquired { get; set; } + /// False for older payloads; true only when the server has already released its native lease. + [Id(ReleaseOptionalMemberId)] + public bool IsReleaseOptional { get; set; } + [Id(3)] public KeyValuePair[] Metadata { get; set; } } diff --git a/ManagedCode.Orleans.RateLimiting.Core/OrleansContracts.txt b/ManagedCode.Orleans.RateLimiting.Core/OrleansContracts.txt new file mode 100644 index 0000000..f3ed66a --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Core/OrleansContracts.txt @@ -0,0 +1,49 @@ +# This file is generated by the Orleans contract analyzer. +# To regenerate, run this command from the repository root after replacing +# PATH_TO_PROJECT_OR_SOLUTION with the owning .csproj, .sln, or .slnx path: +# dotnet format PATH_TO_PROJECT_OR_SOLUTION analyzers --severity info --diagnostics ORLEANS0016 ORLEANS0017 ORLEANS0018 ORLEANS0019 ORLEANS0020 ORLEANS0022 ORLEANS0023 ORLEANS0024 +# Verify with: dotnet build PATH_TO_PROJECT_OR_SOLUTION +# The regeneration command edits this manifest only; it does not change source attributes. +# OrleansContracts format: 2 +# Method lines use: wire-identity: CLR-signature. +# The identity is the identifier Orleans uses at runtime, whether generated or declared in source. +# Review every diff: identity or signature changes can break wire compatibility during rolling upgrades. +# Details: https://aka.ms/orleans/OrleansContracts.txt + +interface [GrainInterfaceType("ManagedCode.Orleans.RateLimiting.Core.Interfaces.IBoundedRateLimiterGrain")] ManagedCode.Orleans.RateLimiting.Core.Interfaces.IBoundedRateLimiterGrain [Version(0)] + 1260B2F7: AcquireCancellableWithDeadlineAsync(int, System.TimeSpan, System.Threading.CancellationToken) -> Task + AA6FF5BF: AcquireWithDeadlineAsync(int, System.TimeSpan) -> Task + +interface [GrainInterfaceType("ManagedCode.Orleans.RateLimiting.Core.Interfaces.IBoundedRateLimiterGrain`1")] ManagedCode.Orleans.RateLimiting.Core.Interfaces.IBoundedRateLimiterGrain [Version(0)] + 6A75F086: AcquireAndCheckConfigurationCancellableWithDeadlineAsync(int, TOptions, System.TimeSpan, System.Threading.CancellationToken) -> Task + 420EFCC9: AcquireAndCheckConfigurationWithDeadlineAsync(int, TOptions, System.TimeSpan) -> Task + +interface [GrainInterfaceType("ManagedCode.Orleans.RateLimiting.Core.Interfaces.ICancellableRateLimiterGrain")] ManagedCode.Orleans.RateLimiting.Core.Interfaces.ICancellableRateLimiterGrain [Version(0)] + 8E3CC64D: AcquireAsync(int, System.Threading.CancellationToken) -> Task + +interface [GrainInterfaceType("ManagedCode.Orleans.RateLimiting.Core.Interfaces.ICancellableRateLimiterGrain`1")] ManagedCode.Orleans.RateLimiting.Core.Interfaces.ICancellableRateLimiterGrain [Version(0)] + 14C84CC6: AcquireAndCheckConfigurationAsync(int, TOptions, System.Threading.CancellationToken) -> Task + +interface [GrainInterfaceType("ManagedCode.Orleans.RateLimiting.Core.Interfaces.IConcurrencyLimiterGrain")] ManagedCode.Orleans.RateLimiting.Core.Interfaces.IConcurrencyLimiterGrain [Version(0)] + +interface [GrainInterfaceType("ManagedCode.Orleans.RateLimiting.Core.Interfaces.IFixedWindowRateLimiterGrain")] ManagedCode.Orleans.RateLimiting.Core.Interfaces.IFixedWindowRateLimiterGrain [Version(0)] + 55058BAB: TryReplenishAsync() -> ValueTask + +interface [GrainInterfaceType("ManagedCode.Orleans.RateLimiting.Core.Interfaces.IRateLimiterGrain")] ManagedCode.Orleans.RateLimiting.Core.Interfaces.IRateLimiterGrain [Version(0)] + 6DB5546F: AcquireAsync(int) -> Task + C84BE606: DeleteStateAsync() -> ValueTask + A8669675: GetStatisticsAsync() -> ValueTask + 4ABA0058: ReleaseLease(System.Guid) -> ValueTask + AE2CC5F3: ResetAsync() -> ValueTask + +interface [GrainInterfaceType("ManagedCode.Orleans.RateLimiting.Core.Interfaces.IRateLimiterGrainWithConfiguration`1")] ManagedCode.Orleans.RateLimiting.Core.Interfaces.IRateLimiterGrainWithConfiguration [Version(0)] + 28B831B6: AcquireAndCheckConfigurationAsync(TOption) -> Task + 9441A834: AcquireAndCheckConfigurationAsync(int, TOption) -> Task + 9A31BDD6: ConfigureAsync(TOption) -> ValueTask + 4817CFD7: GetConfiguration() -> ValueTask + +interface [GrainInterfaceType("ManagedCode.Orleans.RateLimiting.Core.Interfaces.ISlidingWindowRateLimiterGrain")] ManagedCode.Orleans.RateLimiting.Core.Interfaces.ISlidingWindowRateLimiterGrain [Version(0)] + 5BC5F648: TryReplenishAsync() -> ValueTask + +interface [GrainInterfaceType("ManagedCode.Orleans.RateLimiting.Core.Interfaces.ITokenBucketRateLimiterGrain")] ManagedCode.Orleans.RateLimiting.Core.Interfaces.ITokenBucketRateLimiterGrain [Version(0)] + C9A2372D: TryReplenishAsync() -> ValueTask diff --git a/ManagedCode.Orleans.RateLimiting.Core/Services/BoundedRateLimiterGrainReference.cs b/ManagedCode.Orleans.RateLimiting.Core/Services/BoundedRateLimiterGrainReference.cs new file mode 100644 index 0000000..ceedce4 --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Core/Services/BoundedRateLimiterGrainReference.cs @@ -0,0 +1,25 @@ +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Orleans; +using Orleans.Runtime; + +namespace ManagedCode.Orleans.RateLimiting.Core.Services; + +/// Supplies acquisition budgets before dispatch without a global call-filter pipeline. +[DefaultInvokableBaseType(typeof(Task<>), typeof(TaskRequest<>))] +public class BoundedRateLimiterGrainReference : GrainReference +{ + private readonly RateLimiterTimeoutProvider? _timeouts; + + protected BoundedRateLimiterGrainReference(GrainReferenceShared shared, IdSpan key) : base(shared, key) + => _timeouts = shared.ServiceProvider.GetService(); + + protected new ValueTask InvokeAsync(IRequest request) + { + _timeouts?.Apply(request); + return base.InvokeAsync(request); + } + + // Orleans validates both dispatch overloads even though bounded RPCs all return metadata. + protected new ValueTask InvokeAsync(IRequest request) => base.InvokeAsync(request); +} diff --git a/ManagedCode.Orleans.RateLimiting.Core/Services/RateLimiterTimeoutProvider.cs b/ManagedCode.Orleans.RateLimiting.Core/Services/RateLimiterTimeoutProvider.cs new file mode 100644 index 0000000..4c9a906 --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Core/Services/RateLimiterTimeoutProvider.cs @@ -0,0 +1,27 @@ +using System; +using System.Threading; +using ManagedCode.Orleans.RateLimiting.Core.Interfaces; +using Orleans.Serialization.Invocation; + +namespace ManagedCode.Orleans.RateLimiting.Core.Services; + +/// Passes acquisition budgets to the silo without per-request timers or linked tokens. +public abstract class RateLimiterTimeoutProvider(Func responseTimeout) +{ + private const int AcquireTimeoutArgument = 1; + private const int ConfigureTimeoutArgument = 2; + private const int BudgetNumerator = 4; + private const int BudgetDenominator = 5; + + internal void Apply(IInvokable request) + { + var timeout = request.GetDefaultResponseTimeout() ?? responseTimeout(); + var budget = timeout > TimeSpan.Zero ? TimeSpan.FromTicks(timeout.Ticks / BudgetDenominator * BudgetNumerator) : timeout; + var index = request.GetMethodName() is nameof(IBoundedRateLimiterGrain.AcquireWithDeadlineAsync) or nameof(IBoundedRateLimiterGrain.AcquireCancellableWithDeadlineAsync) + ? AcquireTimeoutArgument : ConfigureTimeoutArgument; + var requested = (TimeSpan)request.GetArgument(index)!; + if (requested != Timeout.InfiniteTimeSpan && (budget < TimeSpan.Zero || requested < budget)) + budget = requested; + request.SetArgument(index, budget); + } +} diff --git a/ManagedCode.Orleans.RateLimiting.Server/AGENTS.md b/ManagedCode.Orleans.RateLimiting.Server/AGENTS.md index a7d86b2..9dd7d67 100644 --- a/ManagedCode.Orleans.RateLimiting.Server/AGENTS.md +++ b/ManagedCode.Orleans.RateLimiting.Server/AGENTS.md @@ -9,7 +9,8 @@ Implements Orleans grains, grain call filters, and silo registration helpers for ## Entry Points - `Grains/` — concrete limiter grain implementations. -- `GrainCallFilter/` — incoming call filters that enforce limiter attributes on grain calls. +- `GrainCallFilter/` — incoming attribute enforcement. +- `Services/` — acquisition timeout provider backed by silo messaging options. - `Extensions/` — silo builder registration helpers for limiter types. - `Properties/AssemblyInfo.cs` — Orleans application-part metadata. @@ -36,7 +37,10 @@ Implements Orleans grains, grain call filters, and silo registration helpers for ## Protected Areas +- `OrleansContracts.txt` records the published RPC identities; regenerate with the root command, review every diff, and retain retired history. + - Grain state and limiter lifecycle. - Incoming grain call filter ordering and key selection. - Silo builder extension defaults. - Rate limiter option mapping to .NET runtime limiter implementations. +- Bounded acquisition fast paths, native queue cleanup, and explicit concurrency-lease release. diff --git a/ManagedCode.Orleans.RateLimiting.Server/Diagnostics/RateLimiterMetrics.cs b/ManagedCode.Orleans.RateLimiting.Server/Diagnostics/RateLimiterMetrics.cs new file mode 100644 index 0000000..467ad61 --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Server/Diagnostics/RateLimiterMetrics.cs @@ -0,0 +1,62 @@ +using System; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Threading.RateLimiting; + +namespace ManagedCode.Orleans.RateLimiting.Server.Diagnostics; + +internal static class RateLimiterMetrics +{ + internal const string MeterName = "ManagedCode.Orleans.RateLimiting"; + internal const string Acquired = "acquired"; + internal const string Rejected = "rejected"; + internal const string Cancelled = "cancelled"; + internal const string Failed = "failed"; + internal const string Written = "written"; + private const string AlgorithmTag = "algorithm"; + private const string OutcomeTag = "outcome"; + private const string ActivePermitsName = "rate_limiting.concurrency.active_permits"; + private const string Seconds = "s"; + private const string AcquisitionsName = "rate_limiting.acquisitions"; + private const string AcquisitionDurationName = "rate_limiting.acquire.duration"; + private const string StateWritesName = "rate_limiting.state.writes"; + private const string StateWriteDurationName = "rate_limiting.state.write.duration"; + private const string ConcurrencyAlgorithm = "concurrency"; + private const string FixedWindowAlgorithm = "fixed_window"; + private const string SlidingWindowAlgorithm = "sliding_window"; + private const string TokenBucketAlgorithm = "token_bucket"; + private const string CustomAlgorithm = "custom"; + private const int SingleEvent = 1; + private static readonly Meter Meter = new(MeterName); + private static readonly UpDownCounter ActivePermits = Meter.CreateUpDownCounter(ActivePermitsName); + private static readonly Counter Acquisitions = Meter.CreateCounter(AcquisitionsName); + private static readonly Histogram AcquisitionDuration = Meter.CreateHistogram(AcquisitionDurationName, Seconds); + private static readonly Counter StateWrites = Meter.CreateCounter(StateWritesName); + private static readonly Histogram StateWriteDuration = Meter.CreateHistogram(StateWriteDurationName, Seconds); + + internal static void ChangeActivePermits(int permits) => ActivePermits.Add(permits); + + internal static void RecordAcquisition(string outcome, long started) + { + var tags = CreateTags(outcome); + Acquisitions.Add(SingleEvent, tags); + AcquisitionDuration.Record(Stopwatch.GetElapsedTime(started).TotalSeconds, tags); + } + + internal static void RecordStateWrite(string outcome, long started) + { + var tags = CreateTags(outcome); + StateWrites.Add(SingleEvent, tags); + StateWriteDuration.Record(Stopwatch.GetElapsedTime(started).TotalSeconds, tags); + } + + private static TagList CreateTags(string outcome) + { + // Fixed vocabulary even for consumer subclasses: no grain keys, identities or exception text. + var algorithm = typeof(TLimiter) == typeof(ConcurrencyLimiter) ? ConcurrencyAlgorithm + : typeof(TLimiter) == typeof(FixedWindowRateLimiter) ? FixedWindowAlgorithm + : typeof(TLimiter) == typeof(SlidingWindowRateLimiter) ? SlidingWindowAlgorithm + : typeof(TLimiter) == typeof(TokenBucketRateLimiter) ? TokenBucketAlgorithm : CustomAlgorithm; + return new TagList { { AlgorithmTag, algorithm }, { OutcomeTag, outcome } }; + } +} diff --git a/ManagedCode.Orleans.RateLimiting.Server/Extensions/SiloBuilderExtensions.cs b/ManagedCode.Orleans.RateLimiting.Server/Extensions/SiloBuilderExtensions.cs index 4c37627..b2dc0a8 100644 --- a/ManagedCode.Orleans.RateLimiting.Server/Extensions/SiloBuilderExtensions.cs +++ b/ManagedCode.Orleans.RateLimiting.Server/Extensions/SiloBuilderExtensions.cs @@ -1,7 +1,10 @@ +using ManagedCode.Orleans.RateLimiting.Core.Services; using ManagedCode.Orleans.RateLimiting.Core.Extensions; using ManagedCode.Orleans.RateLimiting.Server.Options; using Microsoft.Extensions.DependencyInjection; using Orleans.Hosting; +using Microsoft.Extensions.DependencyInjection.Extensions; +using ManagedCode.Orleans.RateLimiting.Server.Services; namespace ManagedCode.Orleans.RateLimiting.Server.Extensions; @@ -10,6 +13,9 @@ public static class SiloBuilderExtensions public static IServiceCollection AddOrleansRateLimiting(this ISiloBuilder siloBuilder) { siloBuilder.Services.AddOrleansRateLimitingCore(); + // A co-hosted client uses the silo runtime. Supply its response timeout once per call. + siloBuilder.Services.RemoveAll(); + siloBuilder.Services.AddSingleton(); siloBuilder.Services.AddOptions(); return siloBuilder.Services; } diff --git a/ManagedCode.Orleans.RateLimiting.Server/GrainCallFilter/BaseRateLimitingIncomingFilter.cs b/ManagedCode.Orleans.RateLimiting.Server/GrainCallFilter/BaseRateLimitingIncomingFilter.cs index cb5e7ad..c4670fc 100644 --- a/ManagedCode.Orleans.RateLimiting.Server/GrainCallFilter/BaseRateLimitingIncomingFilter.cs +++ b/ManagedCode.Orleans.RateLimiting.Server/GrainCallFilter/BaseRateLimitingIncomingFilter.cs @@ -12,6 +12,8 @@ namespace ManagedCode.Orleans.RateLimiting.Server.GrainCallFilter; public abstract class BaseRateLimitingIncomingFilter : IIncomingGrainCallFilter where TAttribute : Attribute where TOptions : class { + private const string InvalidKeyMessage = "A rate limiter attribute must specify a valid key type and a non-empty explicit key."; + protected BaseRateLimitingIncomingFilter(IGrainFactory grainFactory, IEnumerable rateLimiterConfigs) { GrainFactory = grainFactory; @@ -47,6 +49,9 @@ public async Task Invoke(IIncomingGrainCallContext context) return CreateRateLimiter(context, attribute); } + if (context.InterfaceMethod is { } interfaceMethod && Attribute.IsDefined(interfaceMethod, typeof(TAttribute))) + return CreateRateLimiter(context, Attribute.GetCustomAttribute(interfaceMethod, typeof(TAttribute))); + if (context.ImplementationMethod.DeclaringType != null && Attribute.IsDefined(context.ImplementationMethod.DeclaringType, typeof(TAttribute))) { var attribute = Attribute.GetCustomAttribute(context.ImplementationMethod.DeclaringType, typeof(TAttribute)); @@ -63,27 +68,24 @@ public async Task Invoke(IIncomingGrainCallContext context) var limiterAttribute = (ILimiterPolicy)attribute; - var limiter = limiterAttribute.KeyType switch + var key = limiterAttribute.KeyType switch { - KeyType.Key when !string.IsNullOrWhiteSpace(limiterAttribute.Key) => GetLimiter(limiterAttribute.Key), - KeyType.GrainType => GetLimiter(context.ImplementationMethod.DeclaringType?.FullName ?? context.TargetContext.GrainId.ToString()), - KeyType.GrainId => GetLimiter(context.TargetContext.GrainId.ToString()), - _ => null + KeyType.Key when !string.IsNullOrWhiteSpace(limiterAttribute.Key) => limiterAttribute.Key, + KeyType.GrainType => context.ImplementationMethod.DeclaringType?.FullName ?? context.TargetContext.GrainId.ToString(), + KeyType.GrainId => context.TargetContext.GrainId.ToString(), + _ => throw new InvalidOperationException(InvalidKeyMessage) }; - if (limiter == null) - return null; - - if (!string.IsNullOrEmpty(limiterAttribute.ConfigurationName)) + if (limiterAttribute.ConfigurationName is not null) { var options = RateLimiterConfigs.FirstOrDefault(f => f.NameEquals(limiterAttribute.ConfigurationName) && f.OptionsTypeIs()); if (options is not null) - return (limiter, options.GetLimiterOptions()!); + return (GetLimiter(RateLimiterPolicyKeys.ForNamedPolicy(key, options.Name)), options.GetLimiterOptions()!); throw new RateLimitConfigurationNotFoundException(limiterAttribute.ConfigurationName); } - return (limiter, limiterAttribute.Options); + return (GetLimiter(RateLimiterPolicyKeys.ForInlinePolicy(key, limiterAttribute.Options)), limiterAttribute.Options); } protected abstract ILimiterHolderWithConfiguration GetLimiter(string key); diff --git a/ManagedCode.Orleans.RateLimiting.Server/GrainCallFilter/RateLimiterPolicyKeys.cs b/ManagedCode.Orleans.RateLimiting.Server/GrainCallFilter/RateLimiterPolicyKeys.cs new file mode 100644 index 0000000..bdef3d3 --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Server/GrainCallFilter/RateLimiterPolicyKeys.cs @@ -0,0 +1,32 @@ +using System.Threading.RateLimiting; +using ManagedCode.Orleans.RateLimiting.Core.Models.Orchestration; + +namespace ManagedCode.Orleans.RateLimiting.Server.GrainCallFilter; + +internal static class RateLimiterPolicyKeys +{ + private const string NamedPolicy = "named-policy"; + private const string InlinePolicy = "inline-policy"; + + public static string ForNamedPolicy(string key, string configurationName) => + RateLimitPartitionKeyFormatter.Join(NamedPolicy, configurationName.ToUpperInvariant(), key); + + public static string ForInlinePolicy(string key, object? options) + { + var settings = options switch + { + ConcurrencyLimiterOptions value => RateLimitPartitionKeyFormatter.Join( + value.PermitLimit, value.QueueLimit, value.QueueProcessingOrder), + FixedWindowRateLimiterOptions value => RateLimitPartitionKeyFormatter.Join( + value.PermitLimit, value.QueueLimit, value.QueueProcessingOrder, value.Window.Ticks, value.AutoReplenishment), + SlidingWindowRateLimiterOptions value => RateLimitPartitionKeyFormatter.Join( + value.PermitLimit, value.QueueLimit, value.QueueProcessingOrder, value.Window.Ticks, value.AutoReplenishment, value.SegmentsPerWindow), + TokenBucketRateLimiterOptions value => RateLimitPartitionKeyFormatter.Join( + value.TokenLimit, value.TokensPerPeriod, value.QueueLimit, value.QueueProcessingOrder, value.ReplenishmentPeriod.Ticks, value.AutoReplenishment), + _ => null + }; + + // Preserve key ownership for default options and externally implemented filter types. + return settings is null ? key : RateLimitPartitionKeyFormatter.Join(InlinePolicy, settings, key); + } +} diff --git a/ManagedCode.Orleans.RateLimiting.Server/Grains/AcquisitionDeadline.cs b/ManagedCode.Orleans.RateLimiting.Server/Grains/AcquisitionDeadline.cs new file mode 100644 index 0000000..d9e5cb1 --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Server/Grains/AcquisitionDeadline.cs @@ -0,0 +1,49 @@ +using System; +using System.Diagnostics; +using System.Threading; + +namespace ManagedCode.Orleans.RateLimiting.Server.Grains; + +internal readonly struct AcquisitionDeadline +{ + private const string TimeoutMessage = "The rate limiter acquisition deadline expired."; + private static readonly TimeSpan MaximumWait = TimeSpan.FromMilliseconds(int.MaxValue); + private readonly TimeSpan _timeout; + private readonly long _started; + internal bool IsEnabled { get; } + + internal AcquisitionDeadline(TimeSpan timeout) + { + if (timeout < TimeSpan.Zero && timeout != Timeout.InfiniteTimeSpan) + throw new ArgumentOutOfRangeException(nameof(timeout)); + _timeout = timeout; + _started = Stopwatch.GetTimestamp(); + IsEnabled = timeout != Timeout.InfiniteTimeSpan; + } + + internal TimeSpan Remaining + { + get + { + if (!IsEnabled) return Timeout.InfiniteTimeSpan; + var remaining = _timeout - Stopwatch.GetElapsedTime(_started); + return remaining <= TimeSpan.Zero ? TimeSpan.Zero : remaining < MaximumWait ? remaining : MaximumWait; + } + } + + internal bool IsExpired => IsEnabled && Remaining == TimeSpan.Zero; + + internal void ThrowIfExpired() + { + if (IsExpired) throw CreateException(); + } + + internal CancellationTokenSource CreateCancellationSource(CancellationToken callerToken) + { + var source = CancellationTokenSource.CreateLinkedTokenSource(callerToken); + source.CancelAfter(Remaining); + return source; + } + + internal static TimeoutException CreateException() => new(TimeoutMessage); +} diff --git a/ManagedCode.Orleans.RateLimiting.Server/Grains/ConcurrencyLimiterGrain.cs b/ManagedCode.Orleans.RateLimiting.Server/Grains/ConcurrencyLimiterGrain.cs index 3e749aa..02f5936 100644 --- a/ManagedCode.Orleans.RateLimiting.Server/Grains/ConcurrencyLimiterGrain.cs +++ b/ManagedCode.Orleans.RateLimiting.Server/Grains/ConcurrencyLimiterGrain.cs @@ -1,3 +1,5 @@ +using System; +using System.Threading; using System.Threading.RateLimiting; using System.Threading.Tasks; using ManagedCode.Orleans.RateLimiting.Core.Interfaces; @@ -11,7 +13,7 @@ namespace ManagedCode.Orleans.RateLimiting.Server.Grains; [GrainType(RateLimiterGrainTypeNames.ConcurrencyLimiter)] -public class ConcurrencyLimiterGrain : RateLimiterGrain, IConcurrencyLimiterGrain +public class ConcurrencyLimiterGrain : RateLimiterGrain, IConcurrencyLimiterGrain, ICancellableRateLimiterGrain, IBoundedRateLimiterGrain { public ConcurrencyLimiterGrain( ILogger logger, @@ -24,6 +26,8 @@ public ConcurrencyLimiterGrain( protected override bool TracksActiveLeaseState => true; + protected override bool IsQueueEnabled => Options.QueueLimit != default; + protected override int PermitLimit => Options.PermitLimit; public async Task AcquireAndCheckConfigurationAsync(ConcurrencyLimiterOptions options) @@ -36,6 +40,17 @@ public async Task AcquireAndCheckConfigurationAsync(int return await AcquireAndCheckConfigurationAsync(permitCount, options, CheckOptions); } + public Task AcquireAndCheckConfigurationAsync(int permitCount, ConcurrencyLimiterOptions options, CancellationToken cancellationToken) + { + return AcquireAndCheckConfigurationAsync(permitCount, options, CheckOptions, cancellationToken); + } + + public Task AcquireAndCheckConfigurationWithDeadlineAsync(int permitCount, ConcurrencyLimiterOptions options, TimeSpan timeout) + => AcquireAndCheckConfigurationWithDeadlineAsync(permitCount, options, CheckOptions, timeout, CancellationToken.None); + + public Task AcquireAndCheckConfigurationCancellableWithDeadlineAsync(int permitCount, ConcurrencyLimiterOptions options, TimeSpan timeout, CancellationToken cancellationToken) + => AcquireAndCheckConfigurationWithDeadlineAsync(permitCount, options, CheckOptions, timeout, cancellationToken); + protected override ConcurrencyLimiter CreateDefaultRateLimiter() { return new ConcurrencyLimiter(Options); diff --git a/ManagedCode.Orleans.RateLimiting.Server/Grains/FixedWindowRateLimiterGrain.cs b/ManagedCode.Orleans.RateLimiting.Server/Grains/FixedWindowRateLimiterGrain.cs index d824538..8456953 100644 --- a/ManagedCode.Orleans.RateLimiting.Server/Grains/FixedWindowRateLimiterGrain.cs +++ b/ManagedCode.Orleans.RateLimiting.Server/Grains/FixedWindowRateLimiterGrain.cs @@ -1,4 +1,5 @@ using System; +using System.Threading; using System.Threading.RateLimiting; using System.Threading.Tasks; using ManagedCode.Orleans.RateLimiting.Core.Interfaces; @@ -12,7 +13,7 @@ namespace ManagedCode.Orleans.RateLimiting.Server.Grains; [GrainType(RateLimiterGrainTypeNames.FixedWindowRateLimiter)] -public class FixedWindowRateLimiterGrain : RateLimiterGrain, IFixedWindowRateLimiterGrain +public class FixedWindowRateLimiterGrain : RateLimiterGrain, IFixedWindowRateLimiterGrain, ICancellableRateLimiterGrain, IBoundedRateLimiterGrain { public FixedWindowRateLimiterGrain( ILogger logger, @@ -23,6 +24,10 @@ public FixedWindowRateLimiterGrain( { } + protected override bool RequiresLeaseRelease => false; + + protected override bool IsQueueEnabled => Options.QueueLimit != default; + protected override int PermitLimit => Options.PermitLimit; public async ValueTask TryReplenishAsync() @@ -40,6 +45,17 @@ public async Task AcquireAndCheckConfigurationAsync(int return await AcquireAndCheckConfigurationAsync(permitCount, options, CheckOptions); } + public Task AcquireAndCheckConfigurationAsync(int permitCount, FixedWindowRateLimiterOptions options, CancellationToken cancellationToken) + { + return AcquireAndCheckConfigurationAsync(permitCount, options, CheckOptions, cancellationToken); + } + + public Task AcquireAndCheckConfigurationWithDeadlineAsync(int permitCount, FixedWindowRateLimiterOptions options, TimeSpan timeout) + => AcquireAndCheckConfigurationWithDeadlineAsync(permitCount, options, CheckOptions, timeout, CancellationToken.None); + + public Task AcquireAndCheckConfigurationCancellableWithDeadlineAsync(int permitCount, FixedWindowRateLimiterOptions options, TimeSpan timeout, CancellationToken cancellationToken) + => AcquireAndCheckConfigurationWithDeadlineAsync(permitCount, options, CheckOptions, timeout, cancellationToken); + protected override FixedWindowRateLimiter CreateDefaultRateLimiter() { return new FixedWindowRateLimiter(Options); diff --git a/ManagedCode.Orleans.RateLimiting.Server/Grains/RateLimiterGrain.Acquisition.cs b/ManagedCode.Orleans.RateLimiting.Server/Grains/RateLimiterGrain.Acquisition.cs new file mode 100644 index 0000000..490d74c --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Server/Grains/RateLimiterGrain.Acquisition.cs @@ -0,0 +1,188 @@ +using System; +using System.Diagnostics; +using ManagedCode.Orleans.RateLimiting.Server.Diagnostics; +using System.Threading; +using System.Threading.RateLimiting; +using System.Threading.Tasks; +using ManagedCode.Orleans.RateLimiting.Core.Models; +using Orleans; + +namespace ManagedCode.Orleans.RateLimiting.Server.Grains; + +public abstract partial class RateLimiterGrain + where TLimiter : RateLimiter + where TOptions : class +{ + protected async Task AcquireAndCheckConfigurationAsync(TOptions options, Func optionsChanged) + { + return await AcquireAndCheckConfigurationAsync(permitCount: 1, options, optionsChanged); + } + + protected Task AcquireAndCheckConfigurationAsync(int permitCount, TOptions options, Func optionsChanged) + => AcquireAndCheckConfigurationAsync(permitCount, options, optionsChanged, CancellationToken.None); + + protected Task AcquireAndCheckConfigurationAsync(int permitCount, TOptions options, Func optionsChanged, CancellationToken cancellationToken) + => AcquireConfiguredAsync(permitCount, options, optionsChanged, default, cancellationToken); + + private async Task AcquireConfiguredAsync(int permitCount, TOptions options, Func optionsChanged, + AcquisitionDeadline deadline, CancellationToken cancellationToken) + { + ArgumentOutOfRangeException.ThrowIfNegative(permitCount); + await EnterAcquireAsync(options, optionsChanged, permitCount, deadline, cancellationToken); + try + { + return await AcquireAndPersistAsync(permitCount, deadline, cancellationToken); + } + finally + { + ExitAcquire(); + } + } + + private async Task AcquireAndPersistAsync(int permitCount, AcquisitionDeadline deadline, CancellationToken cancellationToken) + { + var started = Stopwatch.GetTimestamp(); + var outcome = RateLimiterMetrics.Failed; + try + { + var metadata = await AcquireLeaseCoreAsync(permitCount, deadline, cancellationToken); + outcome = metadata.IsAcquired ? RateLimiterMetrics.Acquired : RateLimiterMetrics.Rejected; + return metadata; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + outcome = RateLimiterMetrics.Cancelled; + throw; + } + finally + { + RateLimiterMetrics.RecordAcquisition(outcome, started); + } + } + + private async Task AcquireLeaseCoreAsync(int permitCount, AcquisitionDeadline deadline, CancellationToken cancellationToken) + { + var leaseId = Guid.NewGuid(); + var lease = await AcquireNativeAsync(permitCount, deadline, cancellationToken); + if (cancellationToken.IsCancellationRequested || deadline.IsExpired) + { + lease.Dispose(); + cancellationToken.ThrowIfCancellationRequested(); + deadline.ThrowIfExpired(); + } + var metadata = new RateLimitLeaseMetadata(leaseId, this.GetGrainId(), lease) { IsReleaseOptional = !RequiresLeaseRelease }; + + if (!lease.IsAcquired) + return await PersistRejectedLeaseAsync(lease, metadata); + + await PersistAcquiredLeaseAsync(leaseId, lease, permitCount); + if (cancellationToken.IsCancellationRequested || deadline.IsExpired) + { + await ReleaseLease(leaseId); + cancellationToken.ThrowIfCancellationRequested(); + deadline.ThrowIfExpired(); + } + return metadata; + } + + private async Task PersistRejectedLeaseAsync(RateLimitLease lease, RateLimitLeaseMetadata metadata) + { + lease.Dispose(); + await MutateStateAsync(state => + { + state.TotalFailedLeases++; + CaptureRuntimeSnapshot(state); + }); + + return metadata; + } + + private async Task PersistAcquiredLeaseAsync(Guid leaseId, RateLimitLease lease, int permitCount) + { + await MutateStateAsync(state => + { + state.TotalSuccessfulLeases++; + AddActiveLeaseState(state, leaseId, permitCount); + CaptureRuntimeSnapshot(state); + }); + + if (RequiresLeaseRelease) + TrackLease(leaseId, lease, permitCount); + else + lease.Dispose(); + } + + private async Task EnterAcquireAsync(AcquisitionDeadline deadline, CancellationToken cancellationToken) + { + if (!await _configurationLock.WaitAsync(deadline.Remaining, cancellationToken)) + throw AcquisitionDeadline.CreateException(); + try + { + lock (_limiterLifetimeSync) + { + _activeAcquireCount++; + } + } + finally + { + _configurationLock.Release(); + } + } + + private async Task EnterAcquireAsync(TOptions options, Func optionsChanged, int permitCount, AcquisitionDeadline deadline, CancellationToken cancellationToken) + { + if (!await _configurationLock.WaitAsync(deadline.Remaining, cancellationToken)) + throw AcquisitionDeadline.CreateException(); + try + { + deadline.ThrowIfExpired(); + if (optionsChanged(options)) + { + await ConfigureWithinDeadlineAsync(options, permitCount, deadline, cancellationToken); + } + + lock (_limiterLifetimeSync) + { + _activeAcquireCount++; + } + } + finally + { + _configurationLock.Release(); + } + } + + private void ExitAcquire() + { + TaskCompletionSource? completed = null; + + lock (_limiterLifetimeSync) + { + _activeAcquireCount--; + if (_activeAcquireCount == NoActiveAcquires) + { + completed = _noActiveAcquires; + _noActiveAcquires = null; + } + } + + completed?.TrySetResult(); + } + + private Task WaitForActiveAcquiresAsync(CancellationToken cancellationToken = default) + { + Task waitTask; + + lock (_limiterLifetimeSync) + { + if (_activeAcquireCount == NoActiveAcquires) + return Task.CompletedTask; + + _noActiveAcquires ??= new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + waitTask = _noActiveAcquires.Task; + } + + return waitTask.WaitAsync(cancellationToken); + } + +} diff --git a/ManagedCode.Orleans.RateLimiting.Server/Grains/RateLimiterGrain.Configuration.cs b/ManagedCode.Orleans.RateLimiting.Server/Grains/RateLimiterGrain.Configuration.cs new file mode 100644 index 0000000..1254bec --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Server/Grains/RateLimiterGrain.Configuration.cs @@ -0,0 +1,51 @@ +using System; +using System.Threading; +using System.Threading.RateLimiting; +using System.Threading.Tasks; +using ManagedCode.Orleans.RateLimiting.Core.Models; +using Microsoft.Extensions.Logging; +using Orleans; + +namespace ManagedCode.Orleans.RateLimiting.Server.Grains; + +public abstract partial class RateLimiterGrain + where TLimiter : RateLimiter + where TOptions : class +{ + private async Task ConfigureLimiterAsync(TOptions options, int? permitCount = null, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var replacement = CreateValidatedReplacement(options, permitCount); + DisposeRateLimiter(); + _options = options; + RateLimiter = replacement; + await MutateStateAsync(state => ResetStateForConfiguration(state, options), flushImmediately: true, cancellationToken: cancellationToken); + _logger.LogInformation(RateLimiterLogMessages.ConfiguredLimiter, typeof(TLimiter).Name, this.GetPrimaryKeyString()); + } + + private TLimiter CreateValidatedReplacement(TOptions options, int? permitCount) + { + ArgumentNullException.ThrowIfNull(options); + var previousOptions = _options; + TLimiter? replacement = null; + try + { + // The existing subclass factory reads Options. No await exposes these + // candidate options to another Orleans turn before they are restored. + _options = options; + replacement = CreateDefaultRateLimiter(); + if (permitCount.HasValue) + ArgumentOutOfRangeException.ThrowIfGreaterThan(permitCount.Value, PermitLimit, nameof(permitCount)); + return replacement; + } + catch + { + replacement?.Dispose(); + throw; + } + finally + { + _options = previousOptions; + } + } +} diff --git a/ManagedCode.Orleans.RateLimiting.Server/Grains/RateLimiterGrain.Deadlines.cs b/ManagedCode.Orleans.RateLimiting.Server/Grains/RateLimiterGrain.Deadlines.cs new file mode 100644 index 0000000..6618df8 --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Server/Grains/RateLimiterGrain.Deadlines.cs @@ -0,0 +1,65 @@ +using System; +using System.Threading; +using System.Threading.RateLimiting; +using System.Threading.Tasks; +using ManagedCode.Orleans.RateLimiting.Core.Models; + +namespace ManagedCode.Orleans.RateLimiting.Server.Grains; + +public abstract partial class RateLimiterGrain + where TLimiter : RateLimiter + where TOptions : class +{ + protected virtual bool IsQueueEnabled => true; + + public Task AcquireWithDeadlineAsync(int permitCount, TimeSpan timeout) + => AcquireCancellableWithDeadlineAsync(permitCount, timeout, CancellationToken.None); + + public async Task AcquireCancellableWithDeadlineAsync(int permitCount, TimeSpan timeout, CancellationToken cancellationToken) + { + var deadline = new AcquisitionDeadline(timeout); + await EnterAcquireAsync(deadline, cancellationToken); + try { return await AcquireAndPersistAsync(permitCount, deadline, cancellationToken); } + finally { ExitAcquire(); } + } + + protected Task AcquireAndCheckConfigurationWithDeadlineAsync(int permitCount, TOptions options, + Func optionsChanged, TimeSpan timeout, CancellationToken cancellationToken) + => AcquireConfiguredAsync(permitCount, options, optionsChanged, new AcquisitionDeadline(timeout), cancellationToken); + + private ValueTask AcquireNativeAsync(int permitCount, AcquisitionDeadline deadline, CancellationToken cancellationToken) + { + if (!deadline.IsEnabled) return RateLimiter.AcquireAsync(permitCount, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + deadline.ThrowIfExpired(); + var lease = RateLimiter.AttemptAcquire(permitCount); + if (lease.IsAcquired || !IsQueueEnabled) return new(lease); + lease.Dispose(); + return AcquireQueuedNativeAsync(permitCount, deadline, cancellationToken); + } + + private async ValueTask AcquireQueuedNativeAsync(int permitCount, AcquisitionDeadline deadline, CancellationToken cancellationToken) + { + using var cancellation = deadline.CreateCancellationSource(cancellationToken); + try { return await RateLimiter.AcquireAsync(permitCount, cancellation.Token); } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && cancellation.IsCancellationRequested) + { + throw AcquisitionDeadline.CreateException(); + } + } + + private async Task ConfigureWithinDeadlineAsync(TOptions options, int permitCount, AcquisitionDeadline deadline, CancellationToken cancellationToken) + { + using var cancellation = deadline.IsEnabled ? deadline.CreateCancellationSource(cancellationToken) : null; + var token = cancellation?.Token ?? cancellationToken; + try + { + await WaitForActiveAcquiresAsync(token); + await ConfigureLimiterAsync(options, permitCount, token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && cancellation?.IsCancellationRequested == true) + { + throw AcquisitionDeadline.CreateException(); + } + } +} diff --git a/ManagedCode.Orleans.RateLimiting.Server/Grains/RateLimiterGrain.Leases.cs b/ManagedCode.Orleans.RateLimiting.Server/Grains/RateLimiterGrain.Leases.cs new file mode 100644 index 0000000..b74c4ed --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Server/Grains/RateLimiterGrain.Leases.cs @@ -0,0 +1,30 @@ +using System; +using System.Threading.RateLimiting; +using ManagedCode.Orleans.RateLimiting.Server.Diagnostics; + +namespace ManagedCode.Orleans.RateLimiting.Server.Grains; + +public abstract partial class RateLimiterGrain + where TLimiter : RateLimiter + where TOptions : class +{ + private void TrackLease(Guid leaseId, RateLimitLease lease, int permitCount) + { + if (!_rateLimitLeases.TryAdd(leaseId, new TrackedLease(lease, permitCount))) + { + lease.Dispose(); + return; + } + if (TracksActiveLeaseState) + RateLimiterMetrics.ChangeActivePermits(permitCount); + } + + private void DisposeTrackedLease(TrackedLease lease) + { + lease.Lease.Dispose(); + if (TracksActiveLeaseState) + RateLimiterMetrics.ChangeActivePermits(-lease.PermitCount); + } + + private readonly record struct TrackedLease(RateLimitLease Lease, int PermitCount); +} diff --git a/ManagedCode.Orleans.RateLimiting.Server/Grains/RateLimiterGrain.Persistence.cs b/ManagedCode.Orleans.RateLimiting.Server/Grains/RateLimiterGrain.Persistence.cs index 1331596..8fbca21 100644 --- a/ManagedCode.Orleans.RateLimiting.Server/Grains/RateLimiterGrain.Persistence.cs +++ b/ManagedCode.Orleans.RateLimiting.Server/Grains/RateLimiterGrain.Persistence.cs @@ -1,4 +1,6 @@ using System; +using System.Diagnostics; +using ManagedCode.Orleans.RateLimiting.Server.Diagnostics; using System.Threading; using System.Threading.RateLimiting; using System.Threading.Tasks; @@ -27,7 +29,7 @@ private void CaptureRuntimeSnapshot(RateLimiterGrainState state) var runtimeStatistics = RateLimiter.GetStatistics(); state.HasSnapshot = true; state.CurrentAvailablePermits = ToAvailablePermitCount(runtimeStatistics?.CurrentAvailablePermits); - state.UpdatedAtUtc = DateTimeOffset.UtcNow; + state.UpdatedAtUtc = Clock.GetUtcNow(); } private async Task ClearStoredStateAsync() @@ -35,10 +37,10 @@ private async Task ClearStoredStateAsync() await _stateLock.WaitAsync(); try { + await _state.ClearStateAsync(); + _state.State = new RateLimiterGrainState(); _stateDirty = false; _stateDeleted = true; - _state.State = new RateLimiterGrainState(); - await _state.ClearStateAsync(); } finally { @@ -58,6 +60,10 @@ private async Task FlushStateTimerAsync(CancellationToken cancellationToken) { await FlushStateIfDirtyAsync(cancellationToken); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Expected when the timer or activation is stopping. Dirty state is retained. + } catch (Exception exception) { _logger.LogError(exception, RateLimiterLogMessages.StateFlushFailed, typeof(TLimiter).Name, this.GetPrimaryKeyString()); @@ -72,7 +78,7 @@ private async Task FlushStateIfDirtyAsync(CancellationToken cancellationToken) await _stateLock.WaitAsync(cancellationToken); try { - await FlushStateIfDirtyLockedAsync(); + await FlushStateIfDirtyLockedAsync(cancellationToken); } finally { @@ -80,13 +86,28 @@ private async Task FlushStateIfDirtyAsync(CancellationToken cancellationToken) } } - private async Task FlushStateIfDirtyLockedAsync() + private async Task FlushStateIfDirtyLockedAsync(CancellationToken cancellationToken) { if (!_stateDirty) return; - await _state.WriteStateAsync(); - _stateDirty = false; + var started = Stopwatch.GetTimestamp(); + var outcome = RateLimiterMetrics.Failed; + try + { + await _state.WriteStateAsync(cancellationToken); + _stateDirty = false; + outcome = RateLimiterMetrics.Written; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + outcome = RateLimiterMetrics.Cancelled; + throw; + } + finally + { + RateLimiterMetrics.RecordStateWrite(outcome, started); + } } private int GetRestoredAvailablePermits() @@ -95,7 +116,7 @@ private int GetRestoredAvailablePermits() if (!state.HasSnapshot) return PermitLimit; - return GetRestoredAvailablePermits(state.UpdatedAtUtc, state.CurrentAvailablePermits, DateTimeOffset.UtcNow); + return GetRestoredAvailablePermits(state.UpdatedAtUtc, state.CurrentAvailablePermits, Clock.GetUtcNow()); } private async Task RestoreRateLimiterAsync() @@ -117,7 +138,7 @@ private void RestoreActiveLeases() { var lease = RateLimiter.AttemptAcquire(activeLease.PermitCount); if (lease.IsAcquired) - _rateLimitLeases.TryAdd(activeLease.LeaseId, lease); + TrackLease(activeLease.LeaseId, lease, activeLease.PermitCount); else lease.Dispose(); } @@ -145,7 +166,7 @@ private void ResetQuotaState(RateLimiterGrainState state) state.HasSnapshot = true; state.TotalFailedLeases = NoAvailablePermits; state.TotalSuccessfulLeases = NoAvailablePermits; - state.UpdatedAtUtc = DateTimeOffset.UtcNow; + state.UpdatedAtUtc = Clock.GetUtcNow(); } private void ResetStateForConfiguration(RateLimiterGrainState state, TOptions options) @@ -160,9 +181,10 @@ private int ToAvailablePermitCount(long? availablePermits) return ClampAvailablePermits((int)(availablePermits ?? PermitLimit)); } - private async Task MutateStateAsync(Action> update, bool flushImmediately = false) + private async Task MutateStateAsync(Action> update, bool flushImmediately = false, bool cancelBeforeMutation = false, CancellationToken cancellationToken = default) { - await _stateLock.WaitAsync(); + // Runtime mutation and its dirty snapshot must remain atomic on cancellation. + await _stateLock.WaitAsync(cancelBeforeMutation ? cancellationToken : CancellationToken.None); try { update(_state.State); @@ -170,7 +192,7 @@ private async Task MutateStateAsync(Action> upda _stateDirty = true; if (flushImmediately) - await FlushStateIfDirtyLockedAsync(); + await FlushStateIfDirtyLockedAsync(cancellationToken); } finally { diff --git a/ManagedCode.Orleans.RateLimiting.Server/Grains/RateLimiterGrain.cs b/ManagedCode.Orleans.RateLimiting.Server/Grains/RateLimiterGrain.cs index f588f2f..4e0c3d7 100644 --- a/ManagedCode.Orleans.RateLimiting.Server/Grains/RateLimiterGrain.cs +++ b/ManagedCode.Orleans.RateLimiting.Server/Grains/RateLimiterGrain.cs @@ -4,15 +4,17 @@ using System.Threading.RateLimiting; using System.Threading.Tasks; using ManagedCode.Orleans.RateLimiting.Core.Models; +using ManagedCode.Orleans.RateLimiting.Core.Interfaces; using ManagedCode.Orleans.RateLimiting.Server.Options; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Orleans; using Orleans.Runtime; namespace ManagedCode.Orleans.RateLimiting.Server.Grains; -public abstract partial class RateLimiterGrain : Grain, IDisposable +public abstract partial class RateLimiterGrain : Grain, IDisposable, ICancellableRateLimiterGrain, IBoundedRateLimiterGrain where TLimiter : RateLimiter where TOptions : class { @@ -25,7 +27,7 @@ public abstract partial class RateLimiterGrain : Grain, IDis private readonly TOptions _defaultOptions; private readonly ILogger _logger; private readonly object _limiterLifetimeSync = new(); - private readonly ConcurrentDictionary _rateLimitLeases = new(); + private readonly ConcurrentDictionary _rateLimitLeases = new(); private readonly TimeSpan _stateFlushPeriod; private readonly IPersistentState> _state; private readonly SemaphoreSlim _stateLock = new(SingleSemaphoreSlot, SingleSemaphoreSlot); @@ -54,10 +56,14 @@ protected RateLimiterGrain( protected TOptions Options => _options; + protected virtual TimeProvider Clock => GrainContext?.ActivationServices.GetService() ?? TimeProvider.System; + protected TLimiter RateLimiter { get; private set; } protected virtual bool TracksActiveLeaseState => false; + protected virtual bool RequiresLeaseRelease => true; + protected abstract int PermitLimit { get; } protected abstract TLimiter CreateDefaultRateLimiter(); @@ -76,12 +82,14 @@ public override async Task OnActivateAsync(CancellationToken cancellationToken) await base.OnActivateAsync(cancellationToken); } - public async Task AcquireAsync(int permitCount = 1) + public Task AcquireAsync(int permitCount = 1) => AcquireAsync(permitCount, CancellationToken.None); + + public async Task AcquireAsync(int permitCount, CancellationToken cancellationToken) { - await EnterAcquireAsync(); + await EnterAcquireAsync(default, cancellationToken); try { - return await AcquireAndPersistAsync(permitCount); + return await AcquireAndPersistAsync(permitCount, default, cancellationToken); } finally { @@ -91,8 +99,8 @@ public async Task AcquireAsync(int permitCount = 1) public async ValueTask ReleaseLease(Guid leaseId) { - _rateLimitLeases.TryRemove(leaseId, out var lease); - lease?.Dispose(); + if (_rateLimitLeases.TryRemove(leaseId, out var lease)) + DisposeTrackedLease(lease); if (!TracksActiveLeaseState) return; @@ -154,9 +162,9 @@ public async ValueTask DeleteStateAsync() try { await WaitForActiveAcquiresAsync(); + await ClearStoredStateAsync(); DisposeRateLimiter(); _options = _defaultOptions; - await ClearStoredStateAsync(); RateLimiter = CreateDefaultRateLimiter(); } finally @@ -173,7 +181,7 @@ public override async Task OnDeactivateAsync(DeactivationReason reason, Cancella await WaitForActiveAcquiresAsync(cancellationToken); if (!_stateDeleted || _stateDirty) - await MutateStateAsync(CaptureRuntimeSnapshot, flushImmediately: true); + await MutateStateAsync(CaptureRuntimeSnapshot, flushImmediately: true, cancelBeforeMutation: true, cancellationToken: cancellationToken); } finally { @@ -210,71 +218,6 @@ protected virtual int GetRestoredAvailablePermits(DateTimeOffset savedAtUtc, int return ClampAvailablePermits(savedAvailablePermits); } - protected async Task AcquireAndCheckConfigurationAsync(TOptions options, Func optionsChanged) - { - return await AcquireAndCheckConfigurationAsync(permitCount: 1, options, optionsChanged); - } - - protected async Task AcquireAndCheckConfigurationAsync(int permitCount, TOptions options, Func optionsChanged) - { - await EnterAcquireAsync(options, optionsChanged); - try - { - return await AcquireAndPersistAsync(permitCount); - } - finally - { - ExitAcquire(); - } - } - - private async Task ConfigureLimiterAsync(TOptions options) - { - DisposeRateLimiter(); - _options = options; - RateLimiter = CreateDefaultRateLimiter(); - await MutateStateAsync(state => ResetStateForConfiguration(state, options), flushImmediately: true); - _logger.LogInformation(RateLimiterLogMessages.ConfiguredLimiter, typeof(TLimiter).Name, this.GetPrimaryKeyString()); - } - - private async Task AcquireAndPersistAsync(int permitCount) - { - var leaseId = Guid.NewGuid(); - var lease = await RateLimiter.AcquireAsync(permitCount); - var metadata = new RateLimitLeaseMetadata(leaseId, this.GetGrainId(), lease); - - if (!lease.IsAcquired) - return await PersistRejectedLeaseAsync(lease, metadata); - - await PersistAcquiredLeaseAsync(leaseId, lease, permitCount); - return metadata; - } - - private async Task PersistRejectedLeaseAsync(RateLimitLease lease, RateLimitLeaseMetadata metadata) - { - lease.Dispose(); - await MutateStateAsync(state => - { - state.TotalFailedLeases++; - CaptureRuntimeSnapshot(state); - }); - - return metadata; - } - - private async Task PersistAcquiredLeaseAsync(Guid leaseId, RateLimitLease lease, int permitCount) - { - await MutateStateAsync(state => - { - state.TotalSuccessfulLeases++; - AddActiveLeaseState(state, leaseId, permitCount); - CaptureRuntimeSnapshot(state); - }); - - if (!_rateLimitLeases.TryAdd(leaseId, lease)) - lease.Dispose(); - } - private void ApplyStoredConfiguration() { if (_state.State.HasConfiguration && _state.State.Options is not null) @@ -292,77 +235,6 @@ private RateLimiterStatistics CreatePersistedStatistics(RateLimiterStatistics? r }; } - private async Task EnterAcquireAsync() - { - await _configurationLock.WaitAsync(); - try - { - lock (_limiterLifetimeSync) - { - _activeAcquireCount++; - } - } - finally - { - _configurationLock.Release(); - } - } - - private async Task EnterAcquireAsync(TOptions options, Func optionsChanged) - { - await _configurationLock.WaitAsync(); - try - { - if (optionsChanged(options)) - { - await WaitForActiveAcquiresAsync(); - await ConfigureLimiterAsync(options); - } - - lock (_limiterLifetimeSync) - { - _activeAcquireCount++; - } - } - finally - { - _configurationLock.Release(); - } - } - - private void ExitAcquire() - { - TaskCompletionSource? completed = null; - - lock (_limiterLifetimeSync) - { - _activeAcquireCount--; - if (_activeAcquireCount == NoActiveAcquires) - { - completed = _noActiveAcquires; - _noActiveAcquires = null; - } - } - - completed?.TrySetResult(); - } - - private Task WaitForActiveAcquiresAsync(CancellationToken cancellationToken = default) - { - Task waitTask; - - lock (_limiterLifetimeSync) - { - if (_activeAcquireCount == NoActiveAcquires) - return Task.CompletedTask; - - _noActiveAcquires ??= new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - waitTask = _noActiveAcquires.Task; - } - - return waitTask.WaitAsync(cancellationToken); - } - private int ClampAvailablePermits(int availablePermits) { return Math.Clamp(availablePermits, NoAvailablePermits, PermitLimit); @@ -380,7 +252,7 @@ private void DisposeConfigurationLock() private void DisposeRateLimiter() { foreach (var lease in _rateLimitLeases.Values) - lease.Dispose(); + DisposeTrackedLease(lease); _rateLimitLeases.Clear(); RateLimiter.Dispose(); diff --git a/ManagedCode.Orleans.RateLimiting.Server/Grains/SlidingWindowRateLimiterGrain.cs b/ManagedCode.Orleans.RateLimiting.Server/Grains/SlidingWindowRateLimiterGrain.cs index a2ced29..a87ffe6 100644 --- a/ManagedCode.Orleans.RateLimiting.Server/Grains/SlidingWindowRateLimiterGrain.cs +++ b/ManagedCode.Orleans.RateLimiting.Server/Grains/SlidingWindowRateLimiterGrain.cs @@ -1,4 +1,5 @@ using System; +using System.Threading; using System.Threading.RateLimiting; using System.Threading.Tasks; using ManagedCode.Orleans.RateLimiting.Core.Interfaces; @@ -12,7 +13,7 @@ namespace ManagedCode.Orleans.RateLimiting.Server.Grains; [GrainType(RateLimiterGrainTypeNames.SlidingWindowRateLimiter)] -public class SlidingWindowRateLimiterGrain : RateLimiterGrain, ISlidingWindowRateLimiterGrain +public class SlidingWindowRateLimiterGrain : RateLimiterGrain, ISlidingWindowRateLimiterGrain, ICancellableRateLimiterGrain, IBoundedRateLimiterGrain { public SlidingWindowRateLimiterGrain( ILogger logger, @@ -23,6 +24,10 @@ public SlidingWindowRateLimiterGrain( { } + protected override bool RequiresLeaseRelease => false; + + protected override bool IsQueueEnabled => Options.QueueLimit != default; + protected override int PermitLimit => Options.PermitLimit; public async ValueTask TryReplenishAsync() @@ -40,6 +45,17 @@ public async Task AcquireAndCheckConfigurationAsync(int return await AcquireAndCheckConfigurationAsync(permitCount, options, CheckOptions); } + public Task AcquireAndCheckConfigurationAsync(int permitCount, SlidingWindowRateLimiterOptions options, CancellationToken cancellationToken) + { + return AcquireAndCheckConfigurationAsync(permitCount, options, CheckOptions, cancellationToken); + } + + public Task AcquireAndCheckConfigurationWithDeadlineAsync(int permitCount, SlidingWindowRateLimiterOptions options, TimeSpan timeout) + => AcquireAndCheckConfigurationWithDeadlineAsync(permitCount, options, CheckOptions, timeout, CancellationToken.None); + + public Task AcquireAndCheckConfigurationCancellableWithDeadlineAsync(int permitCount, SlidingWindowRateLimiterOptions options, TimeSpan timeout, CancellationToken cancellationToken) + => AcquireAndCheckConfigurationWithDeadlineAsync(permitCount, options, CheckOptions, timeout, cancellationToken); + protected override SlidingWindowRateLimiter CreateDefaultRateLimiter() { return new SlidingWindowRateLimiter(Options); diff --git a/ManagedCode.Orleans.RateLimiting.Server/Grains/TokenBucketRateLimiterGrain.cs b/ManagedCode.Orleans.RateLimiting.Server/Grains/TokenBucketRateLimiterGrain.cs index 91af52d..dc99909 100644 --- a/ManagedCode.Orleans.RateLimiting.Server/Grains/TokenBucketRateLimiterGrain.cs +++ b/ManagedCode.Orleans.RateLimiting.Server/Grains/TokenBucketRateLimiterGrain.cs @@ -1,4 +1,5 @@ using System; +using System.Threading; using System.Threading.RateLimiting; using System.Threading.Tasks; using ManagedCode.Orleans.RateLimiting.Core.Interfaces; @@ -12,8 +13,10 @@ namespace ManagedCode.Orleans.RateLimiting.Server.Grains; [GrainType(RateLimiterGrainTypeNames.TokenBucketRateLimiter)] -public class TokenBucketRateLimiterGrain : RateLimiterGrain, ITokenBucketRateLimiterGrain +public class TokenBucketRateLimiterGrain : RateLimiterGrain, ITokenBucketRateLimiterGrain, ICancellableRateLimiterGrain, IBoundedRateLimiterGrain { + private const int NoPermits = 0; + public TokenBucketRateLimiterGrain( ILogger logger, IOptions options, @@ -23,6 +26,10 @@ public TokenBucketRateLimiterGrain( { } + protected override bool RequiresLeaseRelease => false; + + protected override bool IsQueueEnabled => Options.QueueLimit != default; + protected override int PermitLimit => Options.TokenLimit; public async ValueTask TryReplenishAsync() @@ -40,6 +47,17 @@ public async Task AcquireAndCheckConfigurationAsync(int return await AcquireAndCheckConfigurationAsync(permitCount, options, CheckOptions); } + public Task AcquireAndCheckConfigurationAsync(int permitCount, TokenBucketRateLimiterOptions options, CancellationToken cancellationToken) + { + return AcquireAndCheckConfigurationAsync(permitCount, options, CheckOptions, cancellationToken); + } + + public Task AcquireAndCheckConfigurationWithDeadlineAsync(int permitCount, TokenBucketRateLimiterOptions options, TimeSpan timeout) + => AcquireAndCheckConfigurationWithDeadlineAsync(permitCount, options, CheckOptions, timeout, CancellationToken.None); + + public Task AcquireAndCheckConfigurationCancellableWithDeadlineAsync(int permitCount, TokenBucketRateLimiterOptions options, TimeSpan timeout, CancellationToken cancellationToken) + => AcquireAndCheckConfigurationWithDeadlineAsync(permitCount, options, CheckOptions, timeout, cancellationToken); + protected override TokenBucketRateLimiter CreateDefaultRateLimiter() { return new TokenBucketRateLimiter(Options); @@ -55,9 +73,18 @@ protected override int GetRestoredAvailablePermits(DateTimeOffset savedAtUtc, in if (!Options.AutoReplenishment || Options.ReplenishmentPeriod <= TimeSpan.Zero) return base.GetRestoredAvailablePermits(savedAtUtc, savedAvailablePermits, nowUtc); - var replenishedPeriods = (nowUtc - savedAtUtc).Ticks / Options.ReplenishmentPeriod.Ticks; - var replenishedPermits = replenishedPeriods * Options.TokensPerPeriod; - return (int)Math.Min(PermitLimit, savedAvailablePermits + replenishedPermits); + var available = Math.Clamp(savedAvailablePermits, NoPermits, PermitLimit); + var elapsed = nowUtc - savedAtUtc; + if (elapsed <= TimeSpan.Zero) + return available; + + var replenishedPeriods = elapsed.Ticks / Options.ReplenishmentPeriod.Ticks; + var missing = PermitLimit - available; + // Bound before multiplying: long outages and tiny periods must not overflow. + if (replenishedPeriods > missing / Options.TokensPerPeriod) + return PermitLimit; + + return available + (int)(replenishedPeriods * Options.TokensPerPeriod); } private bool CheckOptions(TokenBucketRateLimiterOptions options) diff --git a/ManagedCode.Orleans.RateLimiting.Server/ManagedCode.Orleans.RateLimiting.Server.csproj b/ManagedCode.Orleans.RateLimiting.Server/ManagedCode.Orleans.RateLimiting.Server.csproj index b250493..ac204c5 100644 --- a/ManagedCode.Orleans.RateLimiting.Server/ManagedCode.Orleans.RateLimiting.Server.csproj +++ b/ManagedCode.Orleans.RateLimiting.Server/ManagedCode.Orleans.RateLimiting.Server.csproj @@ -3,6 +3,7 @@ Library true + true diff --git a/ManagedCode.Orleans.RateLimiting.Server/OrleansContracts.txt b/ManagedCode.Orleans.RateLimiting.Server/OrleansContracts.txt new file mode 100644 index 0000000..7901a6c --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Server/OrleansContracts.txt @@ -0,0 +1,23 @@ +# This file is generated by the Orleans contract analyzer. +# To regenerate, run this command from the repository root after replacing +# PATH_TO_PROJECT_OR_SOLUTION with the owning .csproj, .sln, or .slnx path: +# dotnet format PATH_TO_PROJECT_OR_SOLUTION analyzers --severity info --diagnostics ORLEANS0016 ORLEANS0017 ORLEANS0018 ORLEANS0019 ORLEANS0020 ORLEANS0022 ORLEANS0023 ORLEANS0024 +# Verify with: dotnet build PATH_TO_PROJECT_OR_SOLUTION +# The regeneration command edits this manifest only; it does not change source attributes. +# OrleansContracts format: 2 +# Method lines use: wire-identity: CLR-signature. +# The identity is the identifier Orleans uses at runtime, whether generated or declared in source. +# Review every diff: identity or signature changes can break wire compatibility during rolling upgrades. +# Details: https://aka.ms/orleans/OrleansContracts.txt + +# ManagedCode.Orleans.RateLimiting.Server.Grains.ConcurrencyLimiterGrain +class [GrainType("ManagedCode.$ConcurrencyLimiterGrain")] ManagedCode.Orleans.RateLimiting.Server.Grains.ConcurrencyLimiterGrain + +# ManagedCode.Orleans.RateLimiting.Server.Grains.FixedWindowRateLimiterGrain +class [GrainType("ManagedCode.$FixedWindowRateLimiterGrain")] ManagedCode.Orleans.RateLimiting.Server.Grains.FixedWindowRateLimiterGrain + +# ManagedCode.Orleans.RateLimiting.Server.Grains.SlidingWindowRateLimiterGrain +class [GrainType("ManagedCode.$SlidingWindowRateLimiterGrain")] ManagedCode.Orleans.RateLimiting.Server.Grains.SlidingWindowRateLimiterGrain + +# ManagedCode.Orleans.RateLimiting.Server.Grains.TokenBucketRateLimiterGrain +class [GrainType("ManagedCode.$TokenBucketRateLimiterGrain")] ManagedCode.Orleans.RateLimiting.Server.Grains.TokenBucketRateLimiterGrain diff --git a/ManagedCode.Orleans.RateLimiting.Server/Services/SiloRateLimiterTimeoutProvider.cs b/ManagedCode.Orleans.RateLimiting.Server/Services/SiloRateLimiterTimeoutProvider.cs new file mode 100644 index 0000000..efc8397 --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Server/Services/SiloRateLimiterTimeoutProvider.cs @@ -0,0 +1,8 @@ +using ManagedCode.Orleans.RateLimiting.Core.Services; +using Microsoft.Extensions.Options; +using Orleans.Configuration; + +namespace ManagedCode.Orleans.RateLimiting.Server.Services; + +internal sealed class SiloRateLimiterTimeoutProvider(IOptions options) + : RateLimiterTimeoutProvider(() => options.Value.ResponseTimeout); diff --git a/ManagedCode.Orleans.RateLimiting.Tests/AGENTS.md b/ManagedCode.Orleans.RateLimiting.Tests/AGENTS.md index ce6d182..8077e83 100644 --- a/ManagedCode.Orleans.RateLimiting.Tests/AGENTS.md +++ b/ManagedCode.Orleans.RateLimiting.Tests/AGENTS.md @@ -13,6 +13,8 @@ Contains integration and behaviour tests for grain limiters, HTTP middleware, Si - `Cluster/Grains/` — test grain contracts and implementations. - `TestApp/` — ASP.NET Core test app, controller, and SignalR hub. - `*Tests.cs` — TUnit test classes grouped by limiter or integration surface. +- `Cluster/TimeoutTestCluster.cs` — short client/silo timeout fixture with real RPC counters. +- `Performance/` — opt-in benchmark fixture with verified gateway/placement; see `../docs/PerformanceAndTimeouts.md`. ## Boundaries diff --git a/ManagedCode.Orleans.RateLimiting.Tests/AttributePolicyIsolationSecurityTests.cs b/ManagedCode.Orleans.RateLimiting.Tests/AttributePolicyIsolationSecurityTests.cs new file mode 100644 index 0000000..1b61754 --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Tests/AttributePolicyIsolationSecurityTests.cs @@ -0,0 +1,59 @@ +using System.Net; +using System.Threading.RateLimiting; +using ManagedCode.Orleans.RateLimiting.Client.Attributes; +using ManagedCode.Orleans.RateLimiting.Client.Extensions; +using ManagedCode.Orleans.RateLimiting.Core.Extensions; +using ManagedCode.Orleans.RateLimiting.Tests.Cluster; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace ManagedCode.Orleans.RateLimiting.Tests; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +public class AttributePolicyIsolationSecurityTests(TestClusterApplication testApp) +{ + private const string Path = "/combined-security-policy"; + private const int IpPermitLimit = 1; + private const int AnonymousPermitLimit = 2; + private const int QueueLimit = 0; + private const int WindowMinutes = 10; + + [Test] + public async Task DifferentAttributeConfigurationsCannotResetEachOthersQuota() + { + var ipConfig = Guid.NewGuid().ToString(); + var anonymousConfig = Guid.NewGuid().ToString(); + using var host = new HostBuilder().ConfigureWebHost(web => web.UseTestServer().ConfigureServices(services => + { + services.AddRouting(); + services.AddSingleton(testApp.Cluster.Client); + services.AddOrleansRateLimiting(); + services.AddOrleansRateLimiterOptions(ipConfig, CreateOptions(IpPermitLimit)); + services.AddOrleansRateLimiterOptions(anonymousConfig, CreateOptions(AnonymousPermitLimit)); + }).Configure(app => + { + app.UseRouting(); + app.UseOrleansIpRateLimiting(); + app.UseOrleansUserRateLimiting(); + app.UseEndpoints(endpoints => endpoints.MapGet(Path, () => Results.Ok()).WithDisplayName(ipConfig) + .WithMetadata(new IpRateLimiterAttribute(ipConfig), new AnonymousIpRateLimiterAttribute(anonymousConfig))); + })).Start(); + using var client = host.GetTestClient(); + + using var first = await client.GetAsync(Path); + first.StatusCode.ShouldBe(HttpStatusCode.OK); + using var second = await client.GetAsync(Path); + second.StatusCode.ShouldBe(HttpStatusCode.TooManyRequests); + } + + private static FixedWindowRateLimiterOptions CreateOptions(int permitLimit) => new() + { + PermitLimit = permitLimit, + QueueLimit = QueueLimit, + Window = TimeSpan.FromMinutes(WindowMinutes) + }; +} diff --git a/ManagedCode.Orleans.RateLimiting.Tests/BoundedRpcSelectionTests.cs b/ManagedCode.Orleans.RateLimiting.Tests/BoundedRpcSelectionTests.cs new file mode 100644 index 0000000..282b636 --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Tests/BoundedRpcSelectionTests.cs @@ -0,0 +1,35 @@ +using System.Threading.RateLimiting; +using ManagedCode.Orleans.RateLimiting.Core.Extensions; +using ManagedCode.Orleans.RateLimiting.Tests.Cluster; + +namespace ManagedCode.Orleans.RateLimiting.Tests; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +public class BoundedRpcSelectionTests(TimeoutTestCluster app) +{ + private const int PermitCount = 1; + + [Test] + [Arguments(false, false)] + [Arguments(false, true)] + [Arguments(true, false)] + [Arguments(true, true)] + public async Task OnlyCancellableCallersUseDistributedCancellation(bool cancellable, bool configure) + { + var options = new ConcurrencyLimiterOptions { PermitLimit = PermitCount }; + var holder = app.Cluster.Client.GetConcurrencyLimiter(Guid.NewGuid().ToString(), options); + await holder.Configure(options); + using var cancellation = new CancellationTokenSource(); + var token = cancellable ? cancellation.Token : CancellationToken.None; + app.RpcCounter.Reset(); + await using var lease = configure + ? await holder.AcquireAndConfigureAsync(PermitCount, token) + : await holder.AcquireAsync(PermitCount, token); + lease.IsAcquired.ShouldBeTrue(); + app.RpcCounter.BoundedCalls.ShouldBe(cancellable ? default : PermitCount); + app.RpcCounter.CancellableCalls.ShouldBe(cancellable ? PermitCount : default); + await lease.DisposeAsync(); + (await holder.GetStatisticsAsync())!.CurrentAvailablePermits.ShouldBe(PermitCount); + await holder.DeleteStateAsync(); + } +} diff --git a/ManagedCode.Orleans.RateLimiting.Tests/CancellationTests.cs b/ManagedCode.Orleans.RateLimiting.Tests/CancellationTests.cs new file mode 100644 index 0000000..f6f9f5d --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Tests/CancellationTests.cs @@ -0,0 +1,117 @@ +using System.Threading.RateLimiting; +using ManagedCode.Orleans.RateLimiting.Core.Extensions; +using ManagedCode.Orleans.RateLimiting.Core.Models.Holders; +using ManagedCode.Orleans.RateLimiting.Tests.Cluster; + +namespace ManagedCode.Orleans.RateLimiting.Tests; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +public class CancellationTests(TestClusterApplication testApp) +{ + private const int PermitCount = 1; + private const int EmptyQueue = 0; + private const int QueueLimit = 1; + private const int Segments = 2; + private const int TimeoutSeconds = 10; + private const int PollMilliseconds = 10; + private const int WindowMinutes = 10; + private const string Concurrency = "concurrency"; + private const string Fixed = "fixed"; + private const string Sliding = "sliding"; + private const string Token = "token"; + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(TimeoutSeconds); + private static readonly TimeSpan Window = TimeSpan.FromMinutes(WindowMinutes); + + [Test] + [Arguments(Concurrency)] + [Arguments(Fixed)] + [Arguments(Sliding)] + [Arguments(Token)] + public async Task QueuedAcquisitionCanBeCancelledAcrossOrleans(string algorithm) + { + var holder = CreateHolder(algorithm); + await using var held = await holder.AcquireAndConfigureAsync(); + held.IsAcquired.ShouldBeTrue(); + using var cancellation = new CancellationTokenSource(); + var pending = holder.AcquireAndConfigureAsync(PermitCount, cancellation.Token); + await WaitForQueueAsync(holder, PermitCount); + await cancellation.CancelAsync(); + await Should.ThrowAsync(() => pending.WaitAsync(Timeout)); + await WaitForQueueAsync(holder, EmptyQueue); + await held.DisposeAsync(); + if (algorithm == Concurrency) + { + await using var next = await holder.AcquireAsync(PermitCount, CancellationToken.None); + next.IsAcquired.ShouldBeTrue(); + } + } + + [Test] + [Arguments(Concurrency)] + [Arguments(Fixed)] + [Arguments(Sliding)] + [Arguments(Token)] + public async Task PreCancelledAcquisitionDoesNotConsumeQuota(string algorithm) + { + var holder = CreateHolder(algorithm); + using var cancellation = new CancellationTokenSource(); + await cancellation.CancelAsync(); + await Should.ThrowAsync(() => holder.AcquireAndConfigureAsync(PermitCount, cancellation.Token)); + await using var next = await holder.AcquireAndConfigureAsync(); + next.IsAcquired.ShouldBeTrue(); + } + + [Test] + public async Task GroupCancellationReleasesEarlierConcurrencyLeases() + { + var first = CreateHolder(Concurrency); + var second = CreateHolder(Concurrency); + await using var held = await second.AcquireAndConfigureAsync(); + await using var group = new GroupLimiterHolder(); + group.AddLimiter(first); + group.AddLimiter(second); + using var cancellation = new CancellationTokenSource(); + var pending = group.AcquireAsync(cancellation.Token); + await WaitForQueueAsync(second, PermitCount); + await cancellation.CancelAsync(); + await Should.ThrowAsync(() => pending.WaitAsync(Timeout)); + await WaitForQueueAsync(second, EmptyQueue); + (await first.GetStatisticsAsync())!.CurrentAvailablePermits.ShouldBe(PermitCount); + await held.DisposeAsync(); + (await group.AcquireAsync()).ShouldBeNull(); + } + + [Test] + public async Task PreCancelledEmptyGroupDoesNotAllowTheRequest() + { + await using var group = new GroupLimiterHolder(); + using var cancellation = new CancellationTokenSource(); + await cancellation.CancelAsync(); + await Should.ThrowAsync(() => group.AcquireAsync(cancellation.Token)); + } + + private ICancellableLimiterHolder CreateHolder(string algorithm) + { + var key = Guid.NewGuid().ToString(); + var client = testApp.Cluster.Client; + return algorithm switch + { + Concurrency => client.GetConcurrencyLimiter(key, new ConcurrencyLimiterOptions + { PermitLimit = PermitCount, QueueLimit = QueueLimit }), + Fixed => client.GetFixedWindowRateLimiter(key, new FixedWindowRateLimiterOptions + { PermitLimit = PermitCount, QueueLimit = QueueLimit, Window = Window, AutoReplenishment = false }), + Sliding => client.GetSlidingWindowRateLimiter(key, new SlidingWindowRateLimiterOptions + { PermitLimit = PermitCount, QueueLimit = QueueLimit, Window = Window, SegmentsPerWindow = Segments, AutoReplenishment = false }), + Token => client.GetTokenBucketRateLimiter(key, new TokenBucketRateLimiterOptions + { TokenLimit = PermitCount, TokensPerPeriod = PermitCount, QueueLimit = QueueLimit, ReplenishmentPeriod = Window, AutoReplenishment = false }), + _ => throw new ArgumentOutOfRangeException(nameof(algorithm)) + }; + } + + internal static async Task WaitForQueueAsync(ILimiterHolder holder, long expected) + { + using var deadline = new CancellationTokenSource(Timeout); + while ((await holder.GetStatisticsAsync())!.CurrentQueuedCount != expected) + await Task.Delay(PollMilliseconds, deadline.Token); + } +} diff --git a/ManagedCode.Orleans.RateLimiting.Tests/Cluster/FaultingGrainStorage.cs b/ManagedCode.Orleans.RateLimiting.Tests/Cluster/FaultingGrainStorage.cs new file mode 100644 index 0000000..bb6ea6d --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Tests/Cluster/FaultingGrainStorage.cs @@ -0,0 +1,31 @@ +using Orleans.Runtime; +using Orleans.Storage; + +namespace ManagedCode.Orleans.RateLimiting.Tests.Cluster; + +internal sealed class StorageFaultControl +{ + internal bool FailWrites; + internal int FailedWrites; + internal int SuccessfulWrites; +} + +internal sealed class FaultingGrainStorage(IGrainStorage inner, StorageFaultControl control) : IGrainStorage +{ + public Task ReadStateAsync(string stateName, GrainId grainId, IGrainState grainState) + => inner.ReadStateAsync(stateName, grainId, grainState); + + public Task ClearStateAsync(string stateName, GrainId grainId, IGrainState grainState) + => inner.ClearStateAsync(stateName, grainId, grainState); + + public async Task WriteStateAsync(string stateName, GrainId grainId, IGrainState grainState) + { + if (Volatile.Read(ref control.FailWrites)) + { + Interlocked.Increment(ref control.FailedWrites); + throw new IOException(FaultingPersistentState.FailureMessage); + } + await inner.WriteStateAsync(stateName, grainId, grainState); + Interlocked.Increment(ref control.SuccessfulWrites); + } +} diff --git a/ManagedCode.Orleans.RateLimiting.Tests/Cluster/FaultingPersistentState.cs b/ManagedCode.Orleans.RateLimiting.Tests/Cluster/FaultingPersistentState.cs new file mode 100644 index 0000000..55bd364 --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Tests/Cluster/FaultingPersistentState.cs @@ -0,0 +1,33 @@ +using ManagedCode.Orleans.RateLimiting.Server.Grains; +using Orleans.Runtime; + +namespace ManagedCode.Orleans.RateLimiting.Tests.Cluster; + +internal sealed class FaultingPersistentState : IPersistentState> where TOptions : class +{ + internal const string FailureMessage = "Injected storage failure"; + public RateLimiterGrainState State { get; set; } = new(); + public string Etag => string.Empty; + public bool RecordExists => true; + internal bool FailWrites { get; set; } + internal bool FailClears { get; set; } + internal bool BlockWrites { get; set; } + internal int Writes { get; private set; } + internal CancellationToken WriteToken { get; private set; } + internal TaskCompletionSource WriteStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task ReadStateAsync() => Task.CompletedTask; + public Task ClearStateAsync() => FailClears ? Task.FromException(new IOException(FailureMessage)) : Task.CompletedTask; + public Task WriteStateAsync() => WriteStateAsync(CancellationToken.None); + + public async Task WriteStateAsync(CancellationToken cancellationToken) + { + Writes++; + WriteToken = cancellationToken; + WriteStarted.TrySetResult(); + if (FailWrites) + throw new IOException(FailureMessage); + if (BlockWrites) + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } +} diff --git a/ManagedCode.Orleans.RateLimiting.Tests/Cluster/Grains/Interfaces/ITestSecurityGrain.cs b/ManagedCode.Orleans.RateLimiting.Tests/Cluster/Grains/Interfaces/ITestSecurityGrain.cs new file mode 100644 index 0000000..c2bf0b3 --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Tests/Cluster/Grains/Interfaces/ITestSecurityGrain.cs @@ -0,0 +1,44 @@ +using ManagedCode.Orleans.RateLimiting.Core.Attributes; + +namespace ManagedCode.Orleans.RateLimiting.Tests.Cluster.Grains.Interfaces; + +public interface ITestSecurityGrain : IGrainWithStringKey +{ + Task NamedStrictFixed(); + Task NamedLooseFixed(); + Task InlineStrictFixed(); + Task InlineStrictFixedAlias(); + Task InlineLooseFixed(); + Task MissingKeyFixed(); + Task EmptyConfigurationFixed(); + [FixedWindowRateLimiter(SecurityGrainOptions.Strict)] + Task InterfaceFixed(); + Task NamedStrictSliding(); + Task NamedLooseSliding(); + Task InlineStrictSliding(); + Task InlineLooseSliding(); + Task MissingKeySliding(); + Task EmptyConfigurationSliding(); + [SlidingWindowRateLimiter(SecurityGrainOptions.Strict)] + Task InterfaceSliding(); + Task NamedStrictToken(); + Task NamedLooseToken(); + Task InlineStrictToken(); + Task InlineLooseToken(); + Task MissingKeyToken(); + Task EmptyConfigurationToken(); + [TokenBucketRateLimiter(SecurityGrainOptions.Strict)] + Task InterfaceToken(); + Task NamedStrictConcurrency(); + Task NamedLooseConcurrency(); + Task InlineStrictConcurrency(); + Task InlineLooseConcurrency(); + Task MissingKeyConcurrency(); + Task EmptyConfigurationConcurrency(); + [ConcurrencyLimiter(SecurityGrainOptions.Strict, key: SecurityGrainOptions.InterfaceConcurrencyKey)] + Task InterfaceConcurrency(); + [ConcurrencyLimiter(SecurityGrainOptions.Strict, key: SecurityGrainOptions.InterfaceConcurrencyKey)] + Task InterfaceConcurrencyProbe(); + Task NestedNamedConcurrency(); + Task NestedInlineConcurrency(); +} diff --git a/ManagedCode.Orleans.RateLimiting.Tests/Cluster/Grains/TestSecurityGrain.cs b/ManagedCode.Orleans.RateLimiting.Tests/Cluster/Grains/TestSecurityGrain.cs new file mode 100644 index 0000000..465ad64 --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Tests/Cluster/Grains/TestSecurityGrain.cs @@ -0,0 +1,114 @@ +using ManagedCode.Orleans.RateLimiting.Core.Attributes; +using ManagedCode.Orleans.RateLimiting.Tests.Cluster.Grains.Interfaces; + +namespace ManagedCode.Orleans.RateLimiting.Tests.Cluster.Grains; + +public sealed class TestSecurityGrain : Grain, ITestSecurityGrain +{ + private int _calls; + + [FixedWindowRateLimiter(SecurityGrainOptions.Strict)] + public Task NamedStrictFixed() => CountCall(); + + [FixedWindowRateLimiter(SecurityGrainOptions.Loose)] + public Task NamedLooseFixed() => CountCall(); + + [FixedWindowRateLimiter(windowInSeconds: SecurityGrainOptions.WindowSeconds, permitLimit: SecurityGrainOptions.StrictPermits)] + public Task InlineStrictFixed() => CountCall(); + + [FixedWindowRateLimiter(windowInSeconds: SecurityGrainOptions.WindowSeconds, permitLimit: SecurityGrainOptions.StrictPermits)] + public Task InlineStrictFixedAlias() => CountCall(); + + [FixedWindowRateLimiter(windowInSeconds: SecurityGrainOptions.WindowSeconds, permitLimit: SecurityGrainOptions.LoosePermits)] + public Task InlineLooseFixed() => CountCall(); + + [FixedWindowRateLimiter(KeyType.Key)] + public Task MissingKeyFixed() => CountCall(); + + [FixedWindowRateLimiter(SecurityGrainOptions.EmptyConfiguration)] + public Task EmptyConfigurationFixed() => CountCall(); + + public Task InterfaceFixed() => CountCall(); + + [SlidingWindowRateLimiter(SecurityGrainOptions.Strict)] + public Task NamedStrictSliding() => CountCall(); + + [SlidingWindowRateLimiter(SecurityGrainOptions.Loose)] + public Task NamedLooseSliding() => CountCall(); + + [SlidingWindowRateLimiter(windowinSeconds: SecurityGrainOptions.WindowSeconds, permitLimit: SecurityGrainOptions.StrictPermits, segmentsPerWindow: SecurityGrainOptions.Segments)] + public Task InlineStrictSliding() => CountCall(); + + [SlidingWindowRateLimiter(windowinSeconds: SecurityGrainOptions.WindowSeconds, permitLimit: SecurityGrainOptions.LoosePermits, segmentsPerWindow: SecurityGrainOptions.Segments)] + public Task InlineLooseSliding() => CountCall(); + + [SlidingWindowRateLimiter(KeyType.Key)] + public Task MissingKeySliding() => CountCall(); + + [SlidingWindowRateLimiter(SecurityGrainOptions.EmptyConfiguration)] + public Task EmptyConfigurationSliding() => CountCall(); + + public Task InterfaceSliding() => CountCall(); + + [TokenBucketRateLimiter(SecurityGrainOptions.Strict)] + public Task NamedStrictToken() => CountCall(); + + [TokenBucketRateLimiter(SecurityGrainOptions.Loose)] + public Task NamedLooseToken() => CountCall(); + + [TokenBucketRateLimiter(replenishmentPeriodInSeconds: SecurityGrainOptions.WindowSeconds, tokenLimit: SecurityGrainOptions.StrictPermits, tokensPerPeriod: SecurityGrainOptions.StrictPermits)] + public Task InlineStrictToken() => CountCall(); + + [TokenBucketRateLimiter(replenishmentPeriodInSeconds: SecurityGrainOptions.WindowSeconds, tokenLimit: SecurityGrainOptions.LoosePermits, tokensPerPeriod: SecurityGrainOptions.LoosePermits)] + public Task InlineLooseToken() => CountCall(); + + [TokenBucketRateLimiter(KeyType.Key)] + public Task MissingKeyToken() => CountCall(); + + [TokenBucketRateLimiter(SecurityGrainOptions.EmptyConfiguration)] + public Task EmptyConfigurationToken() => CountCall(); + + public Task InterfaceToken() => CountCall(); + + [ConcurrencyLimiter(SecurityGrainOptions.Strict, key: SecurityGrainOptions.ConcurrencyKey)] + public Task NamedStrictConcurrency() => CountCall(); + + [ConcurrencyLimiter(SecurityGrainOptions.Loose, key: SecurityGrainOptions.ConcurrencyKey)] + public Task NamedLooseConcurrency() => CountCall(); + + [ConcurrencyLimiter(key: SecurityGrainOptions.InlineConcurrencyKey, permitLimit: SecurityGrainOptions.StrictPermits)] + public Task InlineStrictConcurrency() => CountCall(); + + [ConcurrencyLimiter(key: SecurityGrainOptions.InlineConcurrencyKey, permitLimit: SecurityGrainOptions.LoosePermits)] + public Task InlineLooseConcurrency() => CountCall(); + + [ConcurrencyLimiter(KeyType.Key)] + public Task MissingKeyConcurrency() => CountCall(); + + [ConcurrencyLimiter(SecurityGrainOptions.EmptyConfiguration)] + public Task EmptyConfigurationConcurrency() => CountCall(); + + public Task InterfaceConcurrency() => Peer().InterfaceConcurrencyProbe(); + + public Task InterfaceConcurrencyProbe() => CountCall(); + + [ConcurrencyLimiter(SecurityGrainOptions.Strict, key: SecurityGrainOptions.ConcurrencyKey)] + public async Task NestedNamedConcurrency() + { + var peer = Peer(); + await peer.NamedLooseConcurrency(); + return await peer.NamedStrictConcurrency(); + } + + [ConcurrencyLimiter(key: SecurityGrainOptions.InlineConcurrencyKey, permitLimit: SecurityGrainOptions.StrictPermits)] + public async Task NestedInlineConcurrency() + { + var peer = Peer(); + await peer.InlineLooseConcurrency(); + return await peer.InlineStrictConcurrency(); + } + + private ITestSecurityGrain Peer() => GrainFactory.GetGrain(Guid.NewGuid().ToString()); + + private Task CountCall() => Task.FromResult(++_calls); +} diff --git a/ManagedCode.Orleans.RateLimiting.Tests/Cluster/Grains/TimeoutProbeGrain.cs b/ManagedCode.Orleans.RateLimiting.Tests/Cluster/Grains/TimeoutProbeGrain.cs new file mode 100644 index 0000000..d7ec005 --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Tests/Cluster/Grains/TimeoutProbeGrain.cs @@ -0,0 +1,17 @@ +using ManagedCode.Orleans.RateLimiting.Core.Extensions; + +namespace ManagedCode.Orleans.RateLimiting.Tests.Cluster.Grains; + +public interface ITimeoutProbeGrain : IGrainWithStringKey +{ + Task AcquireAsync(string limiterKey); +} + +public class TimeoutProbeGrain : Grain, ITimeoutProbeGrain +{ + public async Task AcquireAsync(string limiterKey) + { + await using var lease = await GrainFactory.GetConcurrencyLimiter(limiterKey).AcquireAsync(); + return lease.IsAcquired; + } +} diff --git a/ManagedCode.Orleans.RateLimiting.Tests/Cluster/LeaseRpcCounter.cs b/ManagedCode.Orleans.RateLimiting.Tests/Cluster/LeaseRpcCounter.cs new file mode 100644 index 0000000..50e2175 --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Tests/Cluster/LeaseRpcCounter.cs @@ -0,0 +1,32 @@ +using ManagedCode.Orleans.RateLimiting.Core.Interfaces; + +namespace ManagedCode.Orleans.RateLimiting.Tests.Cluster; + +public sealed class LeaseRpcCounter : IOutgoingGrainCallFilter +{ + private int _releases; + private int _bounded; + private int _cancellable; + public int BoundedCalls => Volatile.Read(ref _bounded); + public int CancellableCalls => Volatile.Read(ref _cancellable); + public int ReleaseCalls => Volatile.Read(ref _releases); + public void Reset() + { + Interlocked.Exchange(ref _releases, default); + Interlocked.Exchange(ref _bounded, default); + Interlocked.Exchange(ref _cancellable, default); + } + + public async Task Invoke(IOutgoingGrainCallContext context) + { + if (context.MethodName == nameof(IRateLimiterGrain.ReleaseLease)) + Interlocked.Increment(ref _releases); + if (context.MethodName is nameof(IBoundedRateLimiterGrain.AcquireWithDeadlineAsync) + or nameof(IBoundedRateLimiterGrain.AcquireAndCheckConfigurationWithDeadlineAsync)) + Interlocked.Increment(ref _bounded); + if (context.MethodName is nameof(IBoundedRateLimiterGrain.AcquireCancellableWithDeadlineAsync) + or nameof(IBoundedRateLimiterGrain.AcquireAndCheckConfigurationCancellableWithDeadlineAsync)) + Interlocked.Increment(ref _cancellable); + await context.Invoke(); + } +} diff --git a/ManagedCode.Orleans.RateLimiting.Tests/Cluster/MetricCapture.cs b/ManagedCode.Orleans.RateLimiting.Tests/Cluster/MetricCapture.cs new file mode 100644 index 0000000..b365fdb --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Tests/Cluster/MetricCapture.cs @@ -0,0 +1,34 @@ +using System.Collections.Concurrent; +using System.Diagnostics.Metrics; + +namespace ManagedCode.Orleans.RateLimiting.Tests.Cluster; + +internal sealed class MetricCapture : IDisposable +{ + internal const string MeterName = "ManagedCode.Orleans.RateLimiting"; + internal const string AcquisitionCounter = "rate_limiting.acquisitions"; + internal const string ActivePermits = "rate_limiting.concurrency.active_permits"; + internal const string WriteCounter = "rate_limiting.state.writes"; + internal const string AlgorithmTag = "algorithm"; + internal const string OutcomeTag = "outcome"; + private readonly MeterListener _listener = new(); + internal ConcurrentQueue Measurements { get; } = new(); + + internal MetricCapture() + { + _listener.InstrumentPublished = (instrument, listener) => + { + if (instrument.Meter.Name == MeterName) + listener.EnableMeasurementEvents(instrument); + }; + _listener.SetMeasurementEventCallback((instrument, value, tags, _) => Capture(instrument, value, tags)); + _listener.SetMeasurementEventCallback((instrument, value, tags, _) => Capture(instrument, value, tags)); + _listener.Start(); + } + + private void Capture(Instrument instrument, double value, ReadOnlySpan> tags) + => Measurements.Enqueue(new Measurement(instrument.Name, value, tags.ToArray().ToDictionary(tag => tag.Key, tag => tag.Value))); + + public void Dispose() => _listener.Dispose(); + internal sealed record Measurement(string Name, double Value, Dictionary Tags); +} diff --git a/ManagedCode.Orleans.RateLimiting.Tests/Cluster/SecurityGrainOptions.cs b/ManagedCode.Orleans.RateLimiting.Tests/Cluster/SecurityGrainOptions.cs new file mode 100644 index 0000000..590ae0b --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Tests/Cluster/SecurityGrainOptions.cs @@ -0,0 +1,55 @@ +using System.Threading.RateLimiting; +using ManagedCode.Orleans.RateLimiting.Core.Extensions; +using Microsoft.Extensions.DependencyInjection; + +namespace ManagedCode.Orleans.RateLimiting.Tests.Cluster; + +internal static class SecurityGrainOptions +{ + public const string Strict = "grain-security-strict"; + public const string Loose = "grain-security-loose"; + public const string ConcurrencyKey = "grain-security-concurrency"; + public const string InlineConcurrencyKey = "grain-security-inline-concurrency"; + public const string InterfaceConcurrencyKey = "grain-security-interface-concurrency"; + public const string EmptyConfiguration = ""; + public const int StrictPermits = 1; + public const int LoosePermits = 2; + public const int QueueLimit = 0; + public const int WindowSeconds = 600; + public const int Segments = 2; + + public static void Configure(IServiceCollection services) + { + Add(services, Strict, StrictPermits); + Add(services, Loose, LoosePermits); + } + + private static void Add(IServiceCollection services, string name, int permits) + { + services.AddOrleansRateLimiterOptions(name, new ConcurrencyLimiterOptions + { + PermitLimit = permits, + QueueLimit = QueueLimit + }); + services.AddOrleansRateLimiterOptions(name, new FixedWindowRateLimiterOptions + { + PermitLimit = permits, + QueueLimit = QueueLimit, + Window = TimeSpan.FromSeconds(WindowSeconds) + }); + services.AddOrleansRateLimiterOptions(name, new SlidingWindowRateLimiterOptions + { + PermitLimit = permits, + QueueLimit = QueueLimit, + Window = TimeSpan.FromSeconds(WindowSeconds), + SegmentsPerWindow = Segments + }); + services.AddOrleansRateLimiterOptions(name, new TokenBucketRateLimiterOptions + { + TokenLimit = permits, + TokensPerPeriod = permits, + QueueLimit = QueueLimit, + ReplenishmentPeriod = TimeSpan.FromSeconds(WindowSeconds) + }); + } +} diff --git a/ManagedCode.Orleans.RateLimiting.Tests/Cluster/TestClusterApplication.cs b/ManagedCode.Orleans.RateLimiting.Tests/Cluster/TestClusterApplication.cs index 8312002..d2c5b79 100644 --- a/ManagedCode.Orleans.RateLimiting.Tests/Cluster/TestClusterApplication.cs +++ b/ManagedCode.Orleans.RateLimiting.Tests/Cluster/TestClusterApplication.cs @@ -5,26 +5,29 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Orleans.TestingHost; +using TUnit.Core.Interfaces; +using ManagedCode.Orleans.RateLimiting.Client.Extensions; namespace ManagedCode.Orleans.RateLimiting.Tests.Cluster; -public class TestClusterApplication : IDisposable, IAsyncDisposable +public class TestClusterApplication : IAsyncInitializer, IAsyncDisposable { - private readonly IHost _host; + private const string TestEnvironment = "Development"; + private IHost _host = null!; private bool _disposed; - public TestClusterApplication() + public async Task InitializeAsync() { - var builder = new TestClusterBuilder(); - builder.AddSiloBuilderConfigurator(); - builder.AddClientBuilderConfigurator(); + var builder = new InProcessTestClusterBuilder(); + builder.ConfigureSilo((_, silo) => new TestSiloConfigurations().Configure(silo)); + builder.ConfigureClient(client => client.AddOrleansRateLimiting()); Cluster = builder.Build(); - Cluster.Deploy(); + await Cluster.DeployAsync(); _host = new HostBuilder() .ConfigureWebHost(webBuilder => { - webBuilder.UseEnvironment("Development"); + webBuilder.UseEnvironment(TestEnvironment); webBuilder.UseTestServer(); webBuilder.ConfigureServices(services => { @@ -33,10 +36,11 @@ public TestClusterApplication() }); webBuilder.Configure(HttpHostProgram.Configure); }) - .Start(); + .Build(); + await _host.StartAsync(); } - public TestCluster Cluster { get; } + public InProcessTestCluster Cluster { get; private set; } = null!; public TestServer Server => _host.GetTestServer(); @@ -49,17 +53,6 @@ public HubConnection CreateSignalRClient(string hubUrl, Action o.HttpMessageHandlerFactory = _ => Server.CreateHandler()).Build(); } - public void Dispose() - { - if (_disposed) - return; - - _disposed = true; - _host.Dispose(); - Cluster.Dispose(); - GC.SuppressFinalize(this); - } - public async ValueTask DisposeAsync() { if (_disposed) diff --git a/ManagedCode.Orleans.RateLimiting.Tests/Cluster/TestSiloConfigurations.cs b/ManagedCode.Orleans.RateLimiting.Tests/Cluster/TestSiloConfigurations.cs index 35c59b8..aa9f6d2 100644 --- a/ManagedCode.Orleans.RateLimiting.Tests/Cluster/TestSiloConfigurations.cs +++ b/ManagedCode.Orleans.RateLimiting.Tests/Cluster/TestSiloConfigurations.cs @@ -16,6 +16,7 @@ public void Configure(ISiloBuilder siloBuilder) { siloBuilder.AddMemoryGrainStorage(RateLimiterStorageNames.StorageProviderName); siloBuilder.AddOrleansRateLimiting(); + SecurityGrainOptions.Configure(siloBuilder.Services); siloBuilder.Services.Configure(options => { options.StateFlushPeriod = TimeSpan.FromMilliseconds(StateFlushPeriodMilliseconds); diff --git a/ManagedCode.Orleans.RateLimiting.Tests/Cluster/TimeoutTestCluster.cs b/ManagedCode.Orleans.RateLimiting.Tests/Cluster/TimeoutTestCluster.cs new file mode 100644 index 0000000..1f3ee21 --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Tests/Cluster/TimeoutTestCluster.cs @@ -0,0 +1,41 @@ +using Microsoft.Extensions.DependencyInjection; +using ManagedCode.Orleans.RateLimiting.Client.Extensions; +using Orleans.Configuration; +using Orleans.TestingHost; +using TUnit.Core.Interfaces; + +namespace ManagedCode.Orleans.RateLimiting.Tests.Cluster; + +public sealed class TimeoutTestCluster : IAsyncInitializer, IAsyncDisposable +{ + private const short SiloCount = 1; + private const int TimeoutSeconds = 1; + private const int SiloTimeoutMilliseconds = 500; + public static readonly TimeSpan ResponseTimeout = TimeSpan.FromSeconds(TimeoutSeconds); + public LeaseRpcCounter RpcCounter { get; } = new(); + public InProcessTestCluster Cluster { get; private set; } = null!; + + public async Task InitializeAsync() + { + var builder = new InProcessTestClusterBuilder(SiloCount); + builder.ConfigureSilo((_, silo) => + { + new TestSiloConfigurations().Configure(silo); + silo.Services.Configure(options => + { + options.ResponseTimeout = TimeSpan.FromMilliseconds(SiloTimeoutMilliseconds); + options.CancelRequestOnTimeout = false; + }); + }); + builder.ConfigureClient(client => client.AddOrleansRateLimiting().Services.Configure(options => + { + options.ResponseTimeout = ResponseTimeout; + options.CancelRequestOnTimeout = false; + })); + builder.ConfigureClient(client => client.Services.AddSingleton(RpcCounter)); + Cluster = builder.Build(); + await Cluster.DeployAsync(); + } + + public ValueTask DisposeAsync() => Cluster.DisposeAsync(); +} diff --git a/ManagedCode.Orleans.RateLimiting.Tests/ConfigurationSecurityTests.cs b/ManagedCode.Orleans.RateLimiting.Tests/ConfigurationSecurityTests.cs new file mode 100644 index 0000000..8ecd355 --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Tests/ConfigurationSecurityTests.cs @@ -0,0 +1,111 @@ +using System.Threading.RateLimiting; +using ManagedCode.Orleans.RateLimiting.Core.Extensions; +using ManagedCode.Orleans.RateLimiting.Core.Interfaces; +using ManagedCode.Orleans.RateLimiting.Core.Models.Holders; +using ManagedCode.Orleans.RateLimiting.Tests.Cluster; + +namespace ManagedCode.Orleans.RateLimiting.Tests; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +public class ConfigurationSecurityTests(TestClusterApplication testApp) +{ + private const int PermitLimit = 2; + private const int InvalidLimit = 0; + private const int ChangedLimit = 3; + private const int NegativePermitCount = -1; + private const int ExcessivePermitCount = 4; + private const int QueueLimit = 0; + private const int Segments = 2; + private const int WindowMinutes = 10; + + [Test] + [Arguments(Failure.Configure)] + [Arguments(Failure.AcquireWithInvalidOptions)] + [Arguments(Failure.AcquireWithNegativeCount)] + [Arguments(Failure.AcquireWithExcessiveCount)] + public Task ConcurrencyRetainsQuotaAfterInvalidUpdate(Failure failure) => VerifyAsync( + testApp.Cluster.Client.GetConcurrencyLimiter(NewKey()), + new ConcurrencyLimiterOptions { PermitLimit = PermitLimit, QueueLimit = QueueLimit }, + new ConcurrencyLimiterOptions { PermitLimit = InvalidLimit, QueueLimit = QueueLimit }, + new ConcurrencyLimiterOptions { PermitLimit = ChangedLimit, QueueLimit = QueueLimit }, + options => options.PermitLimit, failure, returnsPermits: true); + + [Test] + [Arguments(Failure.Configure)] + [Arguments(Failure.AcquireWithInvalidOptions)] + [Arguments(Failure.AcquireWithNegativeCount)] + [Arguments(Failure.AcquireWithExcessiveCount)] + public Task FixedWindowRetainsQuotaAfterInvalidUpdate(Failure failure) => VerifyAsync( + testApp.Cluster.Client.GetFixedWindowRateLimiter(NewKey()), + Fixed(PermitLimit), Fixed(InvalidLimit), Fixed(ChangedLimit), options => options.PermitLimit, failure); + + [Test] + [Arguments(Failure.Configure)] + [Arguments(Failure.AcquireWithInvalidOptions)] + [Arguments(Failure.AcquireWithNegativeCount)] + [Arguments(Failure.AcquireWithExcessiveCount)] + public Task SlidingWindowRetainsQuotaAfterInvalidUpdate(Failure failure) => VerifyAsync( + testApp.Cluster.Client.GetSlidingWindowRateLimiter(NewKey()), + Sliding(PermitLimit), Sliding(InvalidLimit), Sliding(ChangedLimit), options => options.PermitLimit, failure); + + [Test] + [Arguments(Failure.Configure)] + [Arguments(Failure.AcquireWithInvalidOptions)] + [Arguments(Failure.AcquireWithNegativeCount)] + [Arguments(Failure.AcquireWithExcessiveCount)] + public Task TokenBucketRetainsQuotaAfterInvalidUpdate(Failure failure) => VerifyAsync( + testApp.Cluster.Client.GetTokenBucketRateLimiter(NewKey()), + Token(PermitLimit), Token(InvalidLimit), Token(ChangedLimit), options => options.TokenLimit, failure); + + private static async Task VerifyAsync(BaseRateLimiterHolder holder, + TOptions valid, TOptions invalid, TOptions changed, Func limit, Failure failure, + bool returnsPermits = false) where TOptions : class where TGrain : IRateLimiterGrainWithConfiguration + { + await holder.Configure(valid); + await using var active = await holder.AcquireAsync(PermitLimit); + active.IsAcquired.ShouldBeTrue(); + + if (failure == Failure.Configure) + await Should.ThrowAsync(() => holder.Configure(invalid).AsTask()); + else if (failure == Failure.AcquireWithInvalidOptions) + await Should.ThrowAsync(() => holder.AcquireAndCheckConfigurationAsync(invalid)); + else + await Should.ThrowAsync(() => holder.AcquireAndCheckConfigurationAsync( + failure == Failure.AcquireWithNegativeCount ? NegativePermitCount : ExcessivePermitCount, changed)); + + limit(await holder.GetConfiguration()).ShouldBe(PermitLimit); + (await holder.GetStatisticsAsync())!.CurrentAvailablePermits.ShouldBe(InvalidLimit); + await using var rejected = await holder.AcquireAsync(); + rejected.IsAcquired.ShouldBeFalse(); + await active.DisposeAsync(); + if (returnsPermits) + (await holder.GetStatisticsAsync())!.CurrentAvailablePermits.ShouldBe(PermitLimit); + } + + private static string NewKey() => Guid.NewGuid().ToString(); + + private static FixedWindowRateLimiterOptions Fixed(int limit) => new() + { + PermitLimit = limit, + QueueLimit = QueueLimit, + Window = TimeSpan.FromMinutes(WindowMinutes) + }; + + private static SlidingWindowRateLimiterOptions Sliding(int limit) => new() + { + PermitLimit = limit, + QueueLimit = QueueLimit, + Window = TimeSpan.FromMinutes(WindowMinutes), + SegmentsPerWindow = Segments + }; + + private static TokenBucketRateLimiterOptions Token(int limit) => new() + { + TokenLimit = limit, + TokensPerPeriod = PermitLimit, + QueueLimit = QueueLimit, + ReplenishmentPeriod = TimeSpan.FromMinutes(WindowMinutes) + }; + + public enum Failure { Configure, AcquireWithInvalidOptions, AcquireWithNegativeCount, AcquireWithExcessiveCount } +} diff --git a/ManagedCode.Orleans.RateLimiting.Tests/ControlledClockTests.cs b/ManagedCode.Orleans.RateLimiting.Tests/ControlledClockTests.cs new file mode 100644 index 0000000..68acc55 --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Tests/ControlledClockTests.cs @@ -0,0 +1,86 @@ +using System.Threading.RateLimiting; +using ManagedCode.Orleans.RateLimiting.Core.Extensions; +using ManagedCode.Orleans.RateLimiting.Server.Grains; +using ManagedCode.Orleans.RateLimiting.Server.Options; +using ManagedCode.Orleans.RateLimiting.Tests.Cluster; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using OptionsFactory = Microsoft.Extensions.Options.Options; +using Microsoft.Extensions.Time.Testing; +using Orleans.TestingHost; +using Orleans.Storage; +using ManagedCode.Orleans.RateLimiting.Server; + +namespace ManagedCode.Orleans.RateLimiting.Tests; + +public class ControlledClockTests +{ + private const string FailedOutcome = "failed"; + private const short SiloCount = 1; + private const int PermitCount = 1; + private const int AdvanceMilliseconds = 100; + private const int PollMilliseconds = 10; + private const int TimeoutSeconds = 10; + private static readonly TimeSpan Advance = TimeSpan.FromMilliseconds(AdvanceMilliseconds); + + [Test] + public async Task SnapshotTimestampsFollowTheInjectedClock() + { + var clock = new FakeTimeProvider(DateTimeOffset.UnixEpoch); + var state = new FaultingPersistentState(); + using var grain = new ClockGrain(state, clock); + await grain.ResetAsync(); + state.State.UpdatedAtUtc.ShouldBe(clock.GetUtcNow()); + clock.Advance(Advance); + await grain.ResetAsync(); + state.State.UpdatedAtUtc.ShouldBe(clock.GetUtcNow()); + } + + [Test] + public async Task FailedTimerWriteRetainsDirtyStateAndRetriesOnControlledClock() + { + var clock = new FakeTimeProvider(DateTimeOffset.UnixEpoch); + var faults = new StorageFaultControl(); + var builder = new InProcessTestClusterBuilder(SiloCount); + builder.ConfigureSilo((_, silo) => + { + new TestSiloConfigurations().Configure(silo); + silo.Services.AddSingleton(clock); + silo.Services.UseTimeProviderForBackgroundAreas(TimeProvider.System); + var storage = silo.Services.Last(descriptor => descriptor.ServiceType == typeof(IGrainStorage) + && descriptor.IsKeyedService && Equals(descriptor.ServiceKey, RateLimiterStorageNames.StorageProviderName)); + silo.Services.Remove(storage); + silo.Services.AddKeyedSingleton(RateLimiterStorageNames.StorageProviderName, + (services, key) => new FaultingGrainStorage((IGrainStorage)storage.KeyedImplementationFactory!(services, key), faults)); + }); + await using var cluster = builder.Build(); + await cluster.DeployAsync(); + using var capture = new MetricCapture(); + var holder = cluster.Client.GetConcurrencyLimiter(Guid.NewGuid().ToString(), + new ConcurrencyLimiterOptions { PermitLimit = PermitCount }); + await using var lease = await holder.AcquireAndConfigureAsync(); + var writes = Volatile.Read(ref faults.SuccessfulWrites); + Volatile.Write(ref faults.FailWrites, true); + clock.Advance(Advance); + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(TimeoutSeconds)); + while (Volatile.Read(ref faults.FailedWrites) == default(int)) + await Task.Delay(PollMilliseconds, deadline.Token); + Volatile.Write(ref faults.FailWrites, false); + while (Volatile.Read(ref faults.SuccessfulWrites) <= writes) + { + clock.Advance(Advance); + await Task.Delay(PollMilliseconds, deadline.Token); + } + capture.Measurements.ShouldContain(item => item.Name == MetricCapture.WriteCounter + && Equals(item.Tags[MetricCapture.OutcomeTag], FailedOutcome)); + (await holder.GetStatisticsAsync())!.CurrentAvailablePermits.ShouldBe(default(int)); + } + + private sealed class ClockGrain(FaultingPersistentState state, TimeProvider clock) + : ConcurrencyLimiterGrain(NullLogger.Instance, + OptionsFactory.Create(new ConcurrencyLimiterOptions { PermitLimit = PermitCount }), + OptionsFactory.Create(new RateLimiterPersistenceOptions { StateFlushPeriod = TimeSpan.Zero }), state) + { + protected override TimeProvider Clock => clock; + } +} diff --git a/ManagedCode.Orleans.RateLimiting.Tests/DeadlineQueueTests.cs b/ManagedCode.Orleans.RateLimiting.Tests/DeadlineQueueTests.cs new file mode 100644 index 0000000..1240a0c --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Tests/DeadlineQueueTests.cs @@ -0,0 +1,88 @@ +using System.Threading.RateLimiting; +using ManagedCode.Orleans.RateLimiting.Core.Extensions; +using ManagedCode.Orleans.RateLimiting.Core.Interfaces; +using ManagedCode.Orleans.RateLimiting.Core.Models.Holders; +using ManagedCode.Orleans.RateLimiting.Tests.Cluster; + +namespace ManagedCode.Orleans.RateLimiting.Tests; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +public class DeadlineQueueTests(TimeoutTestCluster app) +{ + private const int PermitCount = 1; + private const int ChangedLimit = 2; + private const int QueueLimit = 16; + private const int PollMilliseconds = 10; + private const int TestDeadlineSeconds = 5; + private static readonly TimeSpan TestDeadline = TimeSpan.FromSeconds(TestDeadlineSeconds); + + [Test] + public async Task ExpiredConfigurationWaitDoesNotReplaceQuotaOrLeaveQueueWaiters() + { + var key = Guid.NewGuid().ToString(); + var holder = app.Cluster.Client.GetConcurrencyLimiter(key, Options(PermitCount)); + await using var held = await holder.AcquireAndConfigureAsync(); + using var cancellation = new CancellationTokenSource(); + var grain = app.Cluster.Client.GetGrain(key).AsReference(); + var waiting = grain.AcquireAsync(PermitCount, cancellation.Token); + try + { + await WaitForQueueAsync(holder, PermitCount); + var replacement = app.Cluster.Client.GetConcurrencyLimiter(key, Options(ChangedLimit)); + await using var rejected = await replacement.AcquireAndConfigureAsync().WaitAsync(TestDeadline); + rejected.IsAcquired.ShouldBeFalse(); + (await holder.GetConfiguration()).PermitLimit.ShouldBe(PermitCount); + } + finally { await cancellation.CancelAsync(); } + await Should.ThrowAsync(() => waiting); + await WaitForQueueAsync(holder, default); + await held.DisposeAsync(); + (await holder.GetStatisticsAsync())!.CurrentAvailablePermits.ShouldBe(PermitCount); + await holder.DeleteStateAsync(); + } + + [Test] + public async Task QueuedSuccessPreservesCountersAndReturnsItsPermit() + { + var holder = app.Cluster.Client.GetConcurrencyLimiter(Guid.NewGuid().ToString(), Options(PermitCount)); + await using var held = await holder.AcquireAndConfigureAsync(); + var waiting = holder.AcquireAsync(); + await WaitForQueueAsync(holder, PermitCount); + await held.DisposeAsync(); + await using var granted = await waiting.WaitAsync(TestDeadline); + granted.IsAcquired.ShouldBeTrue(); + var statistics = (await holder.GetStatisticsAsync())!; + statistics.TotalSuccessfulLeases.ShouldBe(ChangedLimit); + statistics.TotalFailedLeases.ShouldBe(default); + await granted.DisposeAsync(); + (await holder.GetStatisticsAsync())!.CurrentAvailablePermits.ShouldBe(PermitCount); + await holder.DeleteStateAsync(); + } + + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task ExpiredExplicitBudgetCannotAcquireOrReconfigure(bool configure) + { + var key = Guid.NewGuid().ToString(); + var holder = app.Cluster.Client.GetConcurrencyLimiter(key, Options(PermitCount)); + await holder.Configure(Options(PermitCount)); + var grain = app.Cluster.Client.GetGrain(key).AsReference>(); + if (configure) + await Should.ThrowAsync(() => grain.AcquireAndCheckConfigurationWithDeadlineAsync(PermitCount, Options(ChangedLimit), TimeSpan.Zero)); + else + await Should.ThrowAsync(() => grain.AcquireWithDeadlineAsync(PermitCount, TimeSpan.Zero)); + (await holder.GetConfiguration()).PermitLimit.ShouldBe(PermitCount); + (await holder.GetStatisticsAsync())!.CurrentAvailablePermits.ShouldBe(PermitCount); + await holder.DeleteStateAsync(); + } + + private static ConcurrencyLimiterOptions Options(int permits) => new() { PermitLimit = permits, QueueLimit = QueueLimit }; + + private static async Task WaitForQueueAsync(ILimiterHolder holder, long count) + { + using var timeout = new CancellationTokenSource(TestDeadline); + while ((await holder.GetStatisticsAsync())!.CurrentQueuedCount != count) + await Task.Delay(PollMilliseconds, timeout.Token); + } +} diff --git a/ManagedCode.Orleans.RateLimiting.Tests/ExceptionSerializationSecurityTests.cs b/ManagedCode.Orleans.RateLimiting.Tests/ExceptionSerializationSecurityTests.cs new file mode 100644 index 0000000..726d9dd --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Tests/ExceptionSerializationSecurityTests.cs @@ -0,0 +1,50 @@ +using ManagedCode.Orleans.RateLimiting.Core.Exceptions; +using ManagedCode.Orleans.RateLimiting.Core.Models.Orchestration; +using Microsoft.Extensions.DependencyInjection; +using Orleans.Serialization; + +namespace ManagedCode.Orleans.RateLimiting.Tests; + +public class ExceptionSerializationSecurityTests +{ + private const string RejectionReason = "security-test-rejection"; + private const string ConfigurationName = "security-test-missing-policy"; + private const int RetrySeconds = 30; + + [Test] + public void RateLimitExceptionRoundTripsAsExceptionWithReasonAndRetryAfter() + { + var original = new RateLimitExceededException(RejectionReason, TimeSpan.FromSeconds(RetrySeconds)); + var result = RoundTrip(original).ShouldBeOfType(); + result.Message.ShouldBe(original.Message); + result.Reason.ShouldBe(original.Reason); + result.RetryAfter.ShouldBe(original.RetryAfter); + } + + [Test] + public void ConfigurationExceptionPreservesMissingName() + { + var original = new RateLimitConfigurationNotFoundException(ConfigurationName); + var result = RoundTrip(original).ShouldBeOfType(); + result.Message.ShouldBe(original.Message); + result.ConfigurationName.ShouldBe(ConfigurationName); + } + + [Test] + public void PartitionExceptionPreservesRequiredKind() + { + var original = new RateLimitPartitionKeyNotFoundException(RateLimitPartitionKind.Tenant); + var result = RoundTrip(original).ShouldBeOfType(); + result.Message.ShouldBe(original.Message); + result.Kind.ShouldBe(RateLimitPartitionKind.Tenant); + } + + private static Exception RoundTrip(Exception exception) + { + using var services = new ServiceCollection().AddSerializer().BuildServiceProvider(); + var serializer = services.GetRequiredService(); + var result = serializer.Deserialize(serializer.SerializeToArray(exception)); + result.ShouldNotBeNull(); + return result; + } +} diff --git a/ManagedCode.Orleans.RateLimiting.Tests/GrainFilterSecurityTests.cs b/ManagedCode.Orleans.RateLimiting.Tests/GrainFilterSecurityTests.cs new file mode 100644 index 0000000..80292fa --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Tests/GrainFilterSecurityTests.cs @@ -0,0 +1,93 @@ +using ManagedCode.Orleans.RateLimiting.Core.Exceptions; +using ManagedCode.Orleans.RateLimiting.Tests.Cluster; +using ManagedCode.Orleans.RateLimiting.Tests.Cluster.Grains.Interfaces; + +namespace ManagedCode.Orleans.RateLimiting.Tests; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +public class GrainFilterSecurityTests(TestClusterApplication testApp) +{ + [Test] + [Arguments(Limiter.Fixed, false)] + [Arguments(Limiter.Sliding, false)] + [Arguments(Limiter.Token, false)] + [Arguments(Limiter.Fixed, true)] + [Arguments(Limiter.Sliding, true)] + [Arguments(Limiter.Token, true)] + public async Task AlternatingPoliciesCannotResetQuota(Limiter kind, bool inline) + { + var (strict, loose) = Resolve(CreateGrain(), kind, inline); + await strict(); + await loose(); + await Should.ThrowAsync(() => strict()); + } + + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task AlternateConcurrencyPolicyCannotReleaseAnActivePermit(bool inline) + { + var grain = CreateGrain(); + await Should.ThrowAsync(() => inline + ? grain.NestedInlineConcurrency() : grain.NestedNamedConcurrency()); + } + + [Test] + public async Task MissingExplicitKeysFailClosedForEveryLimiter() + { + var grain = CreateGrain(); + Func>[] calls = [grain.MissingKeyFixed, grain.MissingKeySliding, grain.MissingKeyToken, grain.MissingKeyConcurrency]; + foreach (var call in calls) + await Should.ThrowAsync(() => call()); + } + + [Test] + public async Task EmptyNamedConfigurationsFailClosedForEveryLimiter() + { + var grain = CreateGrain(); + Func>[] calls = [grain.EmptyConfigurationFixed, grain.EmptyConfigurationSliding, grain.EmptyConfigurationToken, grain.EmptyConfigurationConcurrency]; + foreach (var call in calls) + await Should.ThrowAsync(() => call()); + } + + [Test] + public async Task InterfaceMethodQuotaAttributesAreEnforced() + { + var grain = CreateGrain(); + Func>[] calls = [grain.InterfaceFixed, grain.InterfaceSliding, grain.InterfaceToken]; + foreach (var call in calls) + { + await call(); + await Should.ThrowAsync(() => call()); + } + } + + [Test] + public async Task InterfaceMethodConcurrencyAttributeIsEnforced() + { + await Should.ThrowAsync(() => CreateGrain().InterfaceConcurrency()); + } + + [Test] + public async Task IdenticalInlinePoliciesStillShareTheirIntendedQuota() + { + var grain = CreateGrain(); + await grain.InlineStrictFixed(); + await Should.ThrowAsync(() => grain.InlineStrictFixedAlias()); + } + + private ITestSecurityGrain CreateGrain() => testApp.Cluster.Client.GetGrain(Guid.NewGuid().ToString()); + + private static (Func> Strict, Func> Loose) Resolve(ITestSecurityGrain grain, Limiter kind, bool inline) => (kind, inline) switch + { + (Limiter.Fixed, false) => (grain.NamedStrictFixed, grain.NamedLooseFixed), + (Limiter.Sliding, false) => (grain.NamedStrictSliding, grain.NamedLooseSliding), + (Limiter.Token, false) => (grain.NamedStrictToken, grain.NamedLooseToken), + (Limiter.Fixed, true) => (grain.InlineStrictFixed, grain.InlineLooseFixed), + (Limiter.Sliding, true) => (grain.InlineStrictSliding, grain.InlineLooseSliding), + (Limiter.Token, true) => (grain.InlineStrictToken, grain.InlineLooseToken), + _ => throw new ArgumentOutOfRangeException(nameof(kind)) + }; + + public enum Limiter { Fixed, Sliding, Token } +} diff --git a/ManagedCode.Orleans.RateLimiting.Tests/GroupLeaseSecurityTests.cs b/ManagedCode.Orleans.RateLimiting.Tests/GroupLeaseSecurityTests.cs new file mode 100644 index 0000000..2fb3de1 --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Tests/GroupLeaseSecurityTests.cs @@ -0,0 +1,187 @@ +using System.Threading.RateLimiting; +using ManagedCode.Orleans.RateLimiting.Core.Extensions; +using ManagedCode.Orleans.RateLimiting.Core.Models; +using ManagedCode.Orleans.RateLimiting.Core.Models.Holders; +using ManagedCode.Orleans.RateLimiting.Tests.Cluster; + +namespace ManagedCode.Orleans.RateLimiting.Tests; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +public class GroupLeaseSecurityTests(TestClusterApplication testApp) +{ + private const int PermitLimit = 2; + private const int InvalidPermitLimit = 0; + private const int QueueLimit = 0; + + [Test] + public async Task ExceptionDuringGroupAcquisitionImmediatelyReturnsEarlierConcurrencyPermits() + { + var limiter = CreateLimiter(); + var invalid = testApp.Cluster.Client.GetConcurrencyLimiter(Guid.NewGuid().ToString(), + new ConcurrencyLimiterOptions { PermitLimit = InvalidPermitLimit, QueueLimit = QueueLimit }); + await using var group = new GroupLimiterHolder(); + group.AddLimiter(limiter); + group.AddLimiter(invalid); + + await Should.ThrowAsync(() => group.AcquireAsync()); + (await limiter.GetStatisticsAsync())!.CurrentAvailablePermits.ShouldBe(PermitLimit); + } + + [Test] + public async Task OverlappingAcquisitionsCannotOverwriteAndLeakLeases() + { + var limiter = CreateLimiter(); + var gated = new GatedHolder(limiter); + await using var group = new GroupLimiterHolder(); + group.AddLimiter(gated); + var first = group.AcquireAsync(); + await gated.Acquired.Task; + var second = group.AcquireAsync(); + try + { + second.IsCompleted.ShouldBeTrue(); + await Should.ThrowAsync(() => second); + } + finally + { + gated.Continue.TrySetResult(); + await ObserveAsync(first); + await ObserveAsync(second); + await group.DisposeAsync(); + } + (await limiter.GetStatisticsAsync())!.CurrentAvailablePermits.ShouldBe(PermitLimit); + } + + [Test] + public async Task DisposeWaitsForInflightAcquisitionAndReturnsItsPermit() + { + var limiter = CreateLimiter(); + var gated = new GatedHolder(limiter); + await using var group = new GroupLimiterHolder(); + group.AddLimiter(gated); + var acquisition = group.AcquireAsync(); + await gated.Acquired.Task; + var disposal = group.DisposeAsync().AsTask(); + try + { + disposal.IsCompleted.ShouldBeFalse(); + } + finally + { + gated.Continue.TrySetResult(); + await acquisition; + await disposal; + } + (await limiter.GetStatisticsAsync())!.CurrentAvailablePermits.ShouldBe(PermitLimit); + } + + [Test] + public async Task GroupCannotBeMutatedDuringAcquisition() + { + var gated = new GatedHolder(CreateLimiter()); + await using var group = new GroupLimiterHolder(); + group.AddLimiter(gated); + var acquisition = group.AcquireAsync(); + await gated.Acquired.Task; + try + { + Should.Throw(() => group.AddLimiter(CreateLimiter())); + } + finally + { + gated.Continue.TrySetResult(); + await acquisition; + } + } + + [Test] + public async Task EveryDisposeCallerWaitsUntilThePendingLeaseIsReleased() + { + var limiter = CreateLimiter(); + var gated = new GatedHolder(limiter); + await using var group = new GroupLimiterHolder(); + group.AddLimiter(gated); + var acquisition = group.AcquireAsync(); + await gated.Acquired.Task; + var first = group.DisposeAsync().AsTask(); + var second = group.DisposeAsync().AsTask(); + try + { + second.IsCompleted.ShouldBeFalse(); + } + finally + { + gated.Continue.TrySetResult(); + await acquisition; + await Task.WhenAll(first, second); + } + (await limiter.GetStatisticsAsync())!.CurrentAvailablePermits.ShouldBe(PermitLimit); + } + + [Test] + public async Task RejectionRollsBackConcurrencyAndAllowsRetryAfterReset() + { + var limiter = CreateLimiter(); + var quota = testApp.Cluster.Client.GetFixedWindowRateLimiter(Guid.NewGuid().ToString(), new FixedWindowRateLimiterOptions + { + PermitLimit = SecurityGrainOptions.StrictPermits, + QueueLimit = QueueLimit, + Window = TimeSpan.FromSeconds(SecurityGrainOptions.WindowSeconds) + }); + await using var consumed = await quota.AcquireAndConfigureAsync(); + consumed.IsAcquired.ShouldBeTrue(); + await using var group = new GroupLimiterHolder(); + group.AddLimiter(limiter); + group.AddLimiter(quota); + + (await group.AcquireAsync()).ShouldNotBeNull(); + (await limiter.GetStatisticsAsync())!.CurrentAvailablePermits.ShouldBe(PermitLimit); + await quota.ResetAsync(); + (await group.AcquireAsync()).ShouldBeNull(); + await group.DisposeAsync(); + (await limiter.GetStatisticsAsync())!.CurrentAvailablePermits.ShouldBe(PermitLimit); + } + + [Test] + public async Task CancellationAfterLegacyHolderAcquiresReleasesTheLateLease() + { + var limiter = CreateLimiter(); + var gated = new GatedHolder(limiter); + await using var group = new GroupLimiterHolder(); + group.AddLimiter(gated); + using var cancellation = new CancellationTokenSource(); + var acquisition = group.AcquireAsync(cancellation.Token); + await gated.Acquired.Task; + await cancellation.CancelAsync(); + gated.Continue.TrySetResult(); + await Should.ThrowAsync(() => acquisition); + (await limiter.GetStatisticsAsync())!.CurrentAvailablePermits.ShouldBe(PermitLimit); + } + + private ConcurrencyLimiterHolder CreateLimiter() => testApp.Cluster.Client.GetConcurrencyLimiter(Guid.NewGuid().ToString(), + new ConcurrencyLimiterOptions { PermitLimit = PermitLimit, QueueLimit = QueueLimit }); + + private static async Task ObserveAsync(Task task) + { + try { await task; } + catch (InvalidOperationException) { } + } + + // Delays delivery of a real Orleans lease to deterministically expose lifecycle races. + private sealed class GatedHolder(ILimiterHolder inner) : ILimiterHolder + { + public TaskCompletionSource Acquired { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Continue { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public Task AcquireAsync(int permitCount = SecurityGrainOptions.StrictPermits) => inner.AcquireAsync(permitCount); + public async Task AcquireAndConfigureAsync(int permitCount = SecurityGrainOptions.StrictPermits) + { + var lease = await inner.AcquireAndConfigureAsync(permitCount); + Acquired.TrySetResult(); + await Continue.Task; + return lease; + } + public ValueTask GetStatisticsAsync() => inner.GetStatisticsAsync(); + public ValueTask ResetAsync() => inner.ResetAsync(); + public ValueTask DeleteStateAsync() => inner.DeleteStateAsync(); + } +} diff --git a/ManagedCode.Orleans.RateLimiting.Tests/HttpSecurityTests.cs b/ManagedCode.Orleans.RateLimiting.Tests/HttpSecurityTests.cs new file mode 100644 index 0000000..43908c0 --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Tests/HttpSecurityTests.cs @@ -0,0 +1,168 @@ +using System.Net; +using System.Security.Claims; +using System.Threading.RateLimiting; +using ManagedCode.Orleans.RateLimiting.Client.Attributes; +using ManagedCode.Orleans.RateLimiting.Client.Extensions; +using ManagedCode.Orleans.RateLimiting.Core.Exceptions; +using ManagedCode.Orleans.RateLimiting.Core.Extensions; +using ManagedCode.Orleans.RateLimiting.Tests.Cluster; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace ManagedCode.Orleans.RateLimiting.Tests; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +public class HttpSecurityTests(TestClusterApplication testApp) +{ + private const string ForwardedFor = "X-Forwarded-For"; + private const string RealIp = "X-Real-IP"; + private const string RemoteAddress = "REMOTE_ADDR"; + private const string PeerIp = "198.51.100.5"; + private const string FirstIp = "203.0.113.10"; + private const string SecondIp = "203.0.113.11"; + private const string ForwardedChain = "203.0.113.99, 203.0.113.10"; + private const string MappedPeerIp = "::ffff:198.51.100.5"; + private const string Path = "/protected"; + private const string MissingConfiguration = "missing-security-config"; + private const string AuthenticationType = "security-test"; + private const string UserId = "security-user"; + private const string Role = "security-role"; + private const string IpMode = "ip"; + private const string AnonymousMode = "anonymous"; + private const string AuthorizedMode = "authorized"; + private const string RoleMode = "role"; + private const string OrchestrationMode = "orchestration"; + private const int PermitLimit = 1; + private const int QueueLimit = 0; + private const int WindowMinutes = 10; + + [Test] + [Arguments(IpMode)] + [Arguments(AnonymousMode)] + [Arguments(AuthorizedMode)] + [Arguments(RoleMode)] + [Arguments(OrchestrationMode)] + public async Task RotatingUntrustedHeadersCannotBypassAnyHttpLimiter(string mode) + { + using var host = CreateHost(mode); + using var client = host.GetTestClient(); + (await SendAsync(client, ForwardedFor, FirstIp)).ShouldBe(HttpStatusCode.OK); + foreach (var header in new[] { ForwardedFor, RealIp, RemoteAddress }) + { + (await SendAsync(client, header, SecondIp)).ShouldBe(HttpStatusCode.TooManyRequests); + (await SendAsync(client, header, ForwardedChain)).ShouldBe(HttpStatusCode.TooManyRequests); + } + } + + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task OnlyKnownProxiesCanSelectForwardedClientPartitions(bool trustedProxy) + { + using var host = CreateHost(OrchestrationMode, forwardedHeaders: true, trustedProxy: trustedProxy); + using var client = host.GetTestClient(); + (await SendAsync(client, ForwardedFor, FirstIp)).ShouldBe(HttpStatusCode.OK); + (await SendAsync(client, ForwardedFor, ForwardedChain)).ShouldBe(HttpStatusCode.TooManyRequests); + (await SendAsync(client, ForwardedFor, SecondIp)).ShouldBe( + trustedProxy ? HttpStatusCode.OK : HttpStatusCode.TooManyRequests); + } + + [Test] + public async Task MissingConnectionAddressDoesNotDisableIpPolicy() + { + using var host = CreateHost(OrchestrationMode, missingAddress: true); + using var client = host.GetTestClient(); + (await SendAsync(client, ForwardedFor, FirstIp)).ShouldBe(HttpStatusCode.OK); + (await SendAsync(client, RealIp, SecondIp)).ShouldBe(HttpStatusCode.TooManyRequests); + } + + [Test] + public void MappedIpv4AndNativeIpv4UseTheSameKey() + { + var context = new DefaultHttpContext(); + context.Connection.RemoteIpAddress = IPAddress.Parse(MappedPeerIp); + context.Request.GetClientIpAddress().ShouldBe(PeerIp); + } + + [Test] + [Arguments(IpMode)] + [Arguments(AnonymousMode)] + [Arguments(AuthorizedMode)] + [Arguments(RoleMode)] + public async Task MissingAttributeConfigurationCannotRunProtectedEndpoint(string mode) + { + using var host = CreateHost(mode, missingConfiguration: true); + using var client = host.GetTestClient(); + await Should.ThrowAsync(() => client.GetAsync(Path)); + } + + private IHost CreateHost(string mode, bool forwardedHeaders = false, bool trustedProxy = false, + bool missingAddress = false, bool missingConfiguration = false) + { + var configName = Guid.NewGuid().ToString(); + return new HostBuilder().ConfigureWebHost(web => web.UseTestServer().ConfigureServices(services => + { + services.AddRouting(); + services.AddSingleton(testApp.Cluster.Client); + services.AddOrleansRateLimiterOptions(configName, new FixedWindowRateLimiterOptions + { + PermitLimit = PermitLimit, + QueueLimit = QueueLimit, + Window = TimeSpan.FromMinutes(WindowMinutes) + }); + services.AddOrleansRateLimiting(options => + { + if (mode == OrchestrationMode) + options.AddIpAddress(configName); + }); + }).Configure(app => + { + app.Use((context, next) => + { + context.Connection.RemoteIpAddress = missingAddress ? null : IPAddress.Parse(PeerIp); + if (mode is AuthorizedMode or RoleMode) + context.User = new ClaimsPrincipal(new ClaimsIdentity( + [new Claim(ClaimTypes.NameIdentifier, UserId), new Claim(ClaimTypes.Role, Role)], AuthenticationType)); + return next(context); + }); + if (forwardedHeaders) + app.UseForwardedHeaders(CreateForwardedOptions(trustedProxy)); + app.UseRouting(); + app.UseOrleansIpRateLimiting(); + app.UseOrleansUserRateLimiting(); + app.UseOrleansRequestRateLimiting(); + app.UseEndpoints(endpoints => endpoints.MapGet(Path, () => Results.Ok()).WithMetadata( + CreateAttribute(mode, missingConfiguration ? MissingConfiguration : configName)).WithDisplayName(configName)); + })).Start(); + } + + private static ForwardedHeadersOptions CreateForwardedOptions(bool trustedProxy) + { + var options = new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedFor }; + if (trustedProxy) + options.KnownProxies.Add(IPAddress.Parse(PeerIp)); + return options; + } + + private static object CreateAttribute(string mode, string configName) => mode switch + { + IpMode => new IpRateLimiterAttribute(configName), + AnonymousMode => new AnonymousIpRateLimiterAttribute(configName), + AuthorizedMode => new AuthorizedIpRateLimiterAttribute(configName), + RoleMode => new InRoleIpRateLimiterAttribute(configName, Role), + _ => new object() + }; + + private static async Task SendAsync(HttpClient client, string header, string value) + { + using var request = new HttpRequestMessage(HttpMethod.Get, Path); + request.Headers.Add(header, value); + using var response = await client.SendAsync(request); + return response.StatusCode; + } +} diff --git a/ManagedCode.Orleans.RateLimiting.Tests/LeaseMetadataCompatibilityTests.cs b/ManagedCode.Orleans.RateLimiting.Tests/LeaseMetadataCompatibilityTests.cs new file mode 100644 index 0000000..3fa502a --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Tests/LeaseMetadataCompatibilityTests.cs @@ -0,0 +1,23 @@ +using ManagedCode.Orleans.RateLimiting.Core.Models; +using Microsoft.Extensions.DependencyInjection; +using Orleans.Serialization; + +namespace ManagedCode.Orleans.RateLimiting.Tests; + +public class LeaseMetadataCompatibilityTests +{ + // Serialized by the unmodified 705b47d Core assembly, before field 4 existed. + private const string PreviousPayload = "IEAhMsVyuxo8rk6uUmeflS9SeyEgIGCKc/8pQTFyYXRlLWxpbWl0LWNvbXBhdGliaWxpdHng4CFg+BZJ50EbbGVhc2UtcGF5bG9hZODgAQMh4OA="; + private const string LeaseIdText = "bb72c532-3c1a-4eae-ae52-679f952f527b"; + + [Test] + public void PreviousLeasePayloadStillRequiresRelease() + { + using var services = new ServiceCollection().AddSerializer(builder => builder.AddAssembly(typeof(RateLimitLeaseMetadata).Assembly)).BuildServiceProvider(); + var metadata = services.GetRequiredService().Deserialize(Convert.FromBase64String(PreviousPayload)); + metadata.ShouldNotBeNull(); + metadata.IsAcquired.ShouldBeTrue(); + metadata.LeaseId.ShouldBe(Guid.Parse(LeaseIdText)); + metadata.IsReleaseOptional.ShouldBeFalse(); + } +} diff --git a/ManagedCode.Orleans.RateLimiting.Tests/LeaseReleaseOptimizationTests.cs b/ManagedCode.Orleans.RateLimiting.Tests/LeaseReleaseOptimizationTests.cs new file mode 100644 index 0000000..3681d00 --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Tests/LeaseReleaseOptimizationTests.cs @@ -0,0 +1,68 @@ +using System.Threading.RateLimiting; +using ManagedCode.Orleans.RateLimiting.Core.Interfaces; +using ManagedCode.Orleans.RateLimiting.Core.Models; +using ManagedCode.Orleans.RateLimiting.Tests.Cluster; + +namespace ManagedCode.Orleans.RateLimiting.Tests; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +public class LeaseReleaseOptimizationTests(TimeoutTestCluster app) +{ + private const int PermitCount = 1; + private const int Segments = 2; + private const int WindowMinutes = 10; + private const string Concurrency = "concurrency"; + private const string Fixed = "fixed"; + private const string Sliding = "sliding"; + private const string Token = "token"; + private static readonly TimeSpan Window = TimeSpan.FromMinutes(WindowMinutes); + + [Test] + [Arguments(Concurrency)] + [Arguments(Fixed)] + [Arguments(Sliding)] + [Arguments(Token)] + public async Task DisposalUsesOneRpcOnlyWhenPermitsMustBeReturned(string algorithm) + { + var grain = await CreateAsync(algorithm); + var metadata = await grain.AcquireAsync(PermitCount); + metadata.IsAcquired.ShouldBeTrue(); + metadata.LeaseId.ShouldNotBe(Guid.Empty); + metadata.IsReleaseOptional.ShouldBe(algorithm != Concurrency); + var lease = new OrleansRateLimitLease(metadata, app.Cluster.Client); + app.RpcCounter.Reset(); + await lease.DisposeAsync(); + await lease.DisposeAsync(); + app.RpcCounter.ReleaseCalls.ShouldBe(algorithm == Concurrency ? PermitCount : default); + (await grain.GetStatisticsAsync())!.CurrentAvailablePermits.ShouldBe(algorithm == Concurrency ? PermitCount : default); + // A previous client ignores the added flag and still sends ReleaseLease. It remains safe. + await grain.ReleaseLease(metadata.LeaseId); + (await grain.GetStatisticsAsync())!.CurrentAvailablePermits.ShouldBe(algorithm == Concurrency ? PermitCount : default); + await grain.DeleteStateAsync(); + } + + private async Task CreateAsync(string algorithm) + { + var key = Guid.NewGuid().ToString(); + switch (algorithm) + { + case Concurrency: + var concurrency = app.Cluster.Client.GetGrain(key); + await concurrency.ConfigureAsync(new ConcurrencyLimiterOptions { PermitLimit = PermitCount }); + return concurrency; + case Fixed: + var fixedWindow = app.Cluster.Client.GetGrain(key); + await fixedWindow.ConfigureAsync(new FixedWindowRateLimiterOptions { PermitLimit = PermitCount, Window = Window, AutoReplenishment = false }); + return fixedWindow; + case Sliding: + var sliding = app.Cluster.Client.GetGrain(key); + await sliding.ConfigureAsync(new SlidingWindowRateLimiterOptions { PermitLimit = PermitCount, Window = Window, SegmentsPerWindow = Segments, AutoReplenishment = false }); + return sliding; + case Token: + var token = app.Cluster.Client.GetGrain(key); + await token.ConfigureAsync(new TokenBucketRateLimiterOptions { TokenLimit = PermitCount, TokensPerPeriod = PermitCount, ReplenishmentPeriod = Window, AutoReplenishment = false }); + return token; + default: throw new ArgumentOutOfRangeException(nameof(algorithm)); + } + } +} diff --git a/ManagedCode.Orleans.RateLimiting.Tests/LegacyLeaseRpcCompatibilityTests.cs b/ManagedCode.Orleans.RateLimiting.Tests/LegacyLeaseRpcCompatibilityTests.cs new file mode 100644 index 0000000..e558b9b --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Tests/LegacyLeaseRpcCompatibilityTests.cs @@ -0,0 +1,53 @@ +using System.Threading.RateLimiting; +using ManagedCode.Orleans.RateLimiting.Core.Interfaces; +using ManagedCode.Orleans.RateLimiting.Tests.Cluster; + +namespace ManagedCode.Orleans.RateLimiting.Tests; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +public class LegacyLeaseRpcCompatibilityTests(TimeoutTestCluster app) +{ + private const int PermitLimit = 2; + private const int PermitCount = 1; + private const int QueueLimit = 0; + private const int Segments = 2; + private const int WindowMinutes = 10; + private static readonly TimeSpan Window = TimeSpan.FromMinutes(WindowMinutes); + + [Test] + public Task LegacyConcurrencyCallsPreserveConfigurationAndLeaseOwnership() + => VerifyAsync(new() + { PermitLimit = PermitLimit, QueueLimit = QueueLimit }, returnsPermits: true); + + [Test] + public Task LegacyFixedWindowCallsPreserveConfigurationAndQuota() + => VerifyAsync(new() + { PermitLimit = PermitLimit, QueueLimit = QueueLimit, Window = Window, AutoReplenishment = false }, returnsPermits: false); + + [Test] + public Task LegacySlidingWindowCallsPreserveConfigurationAndQuota() + => VerifyAsync(new() + { PermitLimit = PermitLimit, QueueLimit = QueueLimit, Window = Window, SegmentsPerWindow = Segments, AutoReplenishment = false }, returnsPermits: false); + + [Test] + public Task LegacyTokenBucketCallsPreserveConfigurationAndQuota() + => VerifyAsync(new() + { TokenLimit = PermitLimit, TokensPerPeriod = PermitLimit, QueueLimit = QueueLimit, ReplenishmentPeriod = Window, AutoReplenishment = false }, returnsPermits: false); + + private async Task VerifyAsync(TOptions options, bool returnsPermits) + where TGrain : IRateLimiterGrainWithConfiguration + where TOptions : class + { + var grain = app.Cluster.Client.GetGrain(Guid.NewGuid().ToString()); + var first = await grain.AcquireAndCheckConfigurationAsync(options); + var second = await grain.AcquireAndCheckConfigurationAsync(PermitCount, options); + first.IsAcquired.ShouldBeTrue(); + second.IsAcquired.ShouldBeTrue(); + first.LeaseId.ShouldNotBe(second.LeaseId); + (await grain.AcquireAndCheckConfigurationAsync(PermitCount, options)).IsAcquired.ShouldBeFalse(); + await grain.ReleaseLease(first.LeaseId); + await grain.ReleaseLease(second.LeaseId); + (await grain.GetStatisticsAsync())!.CurrentAvailablePermits.ShouldBe(returnsPermits ? PermitLimit : QueueLimit); + await grain.DeleteStateAsync(); + } +} diff --git a/ManagedCode.Orleans.RateLimiting.Tests/ManagedCode.Orleans.RateLimiting.Tests.csproj b/ManagedCode.Orleans.RateLimiting.Tests/ManagedCode.Orleans.RateLimiting.Tests.csproj index 9a10cfe..a9e0051 100644 --- a/ManagedCode.Orleans.RateLimiting.Tests/ManagedCode.Orleans.RateLimiting.Tests.csproj +++ b/ManagedCode.Orleans.RateLimiting.Tests/ManagedCode.Orleans.RateLimiting.Tests.csproj @@ -30,6 +30,7 @@ + @@ -45,4 +46,7 @@ + + + diff --git a/ManagedCode.Orleans.RateLimiting.Tests/ObservabilityTests.cs b/ManagedCode.Orleans.RateLimiting.Tests/ObservabilityTests.cs new file mode 100644 index 0000000..aa0766d --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Tests/ObservabilityTests.cs @@ -0,0 +1,59 @@ +using System.Threading.RateLimiting; +using ManagedCode.Orleans.RateLimiting.Core.Extensions; +using ManagedCode.Orleans.RateLimiting.Tests.Cluster; + +namespace ManagedCode.Orleans.RateLimiting.Tests; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +public class ObservabilityTests(TestClusterApplication testApp) +{ + private const int PermitCount = 1; + private const int QueueLimit = 1; + private const int InvalidPermitCount = -1; + private const int TagCount = 2; + private const int TimeoutSeconds = 10; + private const string Acquired = "acquired"; + private const string Rejected = "rejected"; + private const string Cancelled = "cancelled"; + private const string Failed = "failed"; + private const string Written = "written"; + private const string Algorithm = "concurrency"; + + [Test] + public async Task MetricsCoverResultsCancellationAndWritesWithoutPartitionIdentifiers() + { + using var capture = new MetricCapture(); + var key = Guid.NewGuid().ToString(); + var holder = testApp.Cluster.Client.GetConcurrencyLimiter(key, new ConcurrencyLimiterOptions + { PermitLimit = PermitCount, QueueLimit = QueueLimit }); + await using var held = await holder.AcquireAndConfigureAsync(); + using var cancellation = new CancellationTokenSource(); + var pending = holder.AcquireAsync(PermitCount, cancellation.Token); + await CancellationTests.WaitForQueueAsync(holder, PermitCount); + await using var rejected = await holder.AcquireAsync(); + rejected.IsAcquired.ShouldBeFalse(); + await cancellation.CancelAsync(); + await Should.ThrowAsync(() => pending.WaitAsync(TimeSpan.FromSeconds(TimeoutSeconds))); + await Should.ThrowAsync(() => holder.AcquireAsync(InvalidPermitCount)); + await held.DisposeAsync(); + var activeChanges = capture.Measurements.Where(item => item.Name == MetricCapture.ActivePermits).ToArray(); + activeChanges.ShouldContain(item => item.Value == PermitCount); + activeChanges.ShouldContain(item => item.Value == -PermitCount); + activeChanges.Sum(item => item.Value).ShouldBe(default(double)); + var measurements = capture.Measurements.Where(item => item.Name != MetricCapture.ActivePermits).ToArray(); + var outcomes = measurements.Where(item => item.Name == MetricCapture.AcquisitionCounter) + .Select(item => item.Tags[MetricCapture.OutcomeTag]).ToArray(); + foreach (var outcome in new[] { Acquired, Rejected, Cancelled, Failed }) + outcomes.ShouldContain(outcome); + measurements.ShouldContain(item => item.Name == MetricCapture.WriteCounter && Equals(item.Tags[MetricCapture.OutcomeTag], Written)); + foreach (var item in measurements) + { + item.Value.ShouldBeGreaterThanOrEqualTo(default(double)); + item.Tags.Count.ShouldBe(TagCount); + item.Tags.Keys.ShouldContain(MetricCapture.AlgorithmTag); + item.Tags.Keys.ShouldContain(MetricCapture.OutcomeTag); + item.Tags.Values.ShouldNotContain(key); + } + measurements.ShouldContain(item => Equals(item.Tags[MetricCapture.AlgorithmTag], Algorithm)); + } +} diff --git a/ManagedCode.Orleans.RateLimiting.Tests/Performance/PerformanceBenchmarkTests.cs b/ManagedCode.Orleans.RateLimiting.Tests/Performance/PerformanceBenchmarkTests.cs new file mode 100644 index 0000000..953a677 --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Tests/Performance/PerformanceBenchmarkTests.cs @@ -0,0 +1,77 @@ +using System.Runtime.InteropServices; +using System.Text.Json; +using ManagedCode.Orleans.RateLimiting.Tests.Cluster; + +namespace ManagedCode.Orleans.RateLimiting.Tests.Performance; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +public class PerformanceBenchmarkTests(PerformanceCluster app) +{ + private const string TopologyDescription = "Two silos; preferred gateway 0; verified grain placement on silo 0"; + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + [Test] + public async Task MeasureAcquisitionAndRelease() + { + var results = new List(); + foreach (var algorithm in new[] { PerformanceOptions.Concurrency, PerformanceOptions.Fixed, PerformanceOptions.Sliding, PerformanceOptions.Token }) + foreach (var scenario in new[] { PerformanceOptions.Sequential, PerformanceOptions.Hot, PerformanceOptions.Partitioned, PerformanceOptions.Abandoned }) + foreach (var cancellable in new[] { false, true }) + await MeasureAsync(results, algorithm, scenario, cancellable); + + results.ShouldNotBeEmpty(); + var output = Path.GetFullPath(Environment.GetEnvironmentVariable(PerformanceOptions.OutputVariable) ?? PerformanceOptions.DefaultOutput); + Directory.CreateDirectory(Path.GetDirectoryName(output)!); + var report = new + { + RuntimeInformation.FrameworkDescription, + RuntimeInformation.OSDescription, + RuntimeInformation.ProcessArchitecture, + Environment.ProcessorCount, + Timestamp = DateTimeOffset.UtcNow, + Topology = TopologyDescription, + WarmWorkload = ShouldWarmWorkload(), + MetadataPayload = PerformancePayload.Create(), + Results = results + }; + await File.WriteAllTextAsync(output, JsonSerializer.Serialize(report, JsonOptions)); + Console.WriteLine(output); + } + + private async Task MeasureAsync(List results, string algorithm, string scenario, bool cancellable) + { + var hasCallerToken = bool.TryParse(Environment.GetEnvironmentVariable(PerformanceOptions.CallerTokenVariable), out var enabled) && enabled; + if (hasCallerToken && !cancellable) return; + using var callerCancellation = hasCallerToken ? new CancellationTokenSource() : null; + var callerToken = callerCancellation?.Token ?? CancellationToken.None; + var selectedAlgorithm = Environment.GetEnvironmentVariable(PerformanceOptions.AlgorithmVariable); + if (selectedAlgorithm is not null && selectedAlgorithm != algorithm) + return; + if (scenario == PerformanceOptions.Abandoned && algorithm == PerformanceOptions.Concurrency) + return; + var workers = scenario is PerformanceOptions.Sequential or PerformanceOptions.Abandoned ? PerformanceOptions.Single : PerformanceOptions.Parallelism; + var partitionCount = scenario == PerformanceOptions.Partitioned ? workers : PerformanceOptions.Single; + var partitions = Enumerable.Range(default, partitionCount).Select(_ => PerformanceOptions.Create(app.Cluster.Client, algorithm)).ToArray(); + foreach (var partition in partitions) + await app.PlaceAsync(partition.Holder, partition.GrainId); + var holders = partitions.Select(partition => partition.Holder).ToArray(); + var warmup = selectedAlgorithm is not null && partitionCount == PerformanceOptions.Single + ? PerformanceOptions.SelectedAlgorithmWarmup : PerformanceOptions.Warmup; + foreach (var holder in holders) + for (var index = default(int); index < warmup; index++) + await PerformanceMeasurement.AcquireReleaseAsync(holder, cancellable, cancellationToken: callerToken); + if (ShouldWarmWorkload()) + await PerformanceMeasurement.RunAsync(algorithm, scenario, cancellable, default, holders, workers, callerToken); + for (var repetition = default(int); repetition < PerformanceOptions.Repetitions; repetition++) + { + foreach (var partition in partitions) app.AssertPlacement(partition.GrainId); + results.Add(await PerformanceMeasurement.RunAsync(algorithm, scenario, cancellable, repetition, holders, workers, callerToken)); + foreach (var partition in partitions) app.AssertPlacement(partition.GrainId); + } + foreach (var holder in holders) + await holder.DeleteStateAsync(); + } + + private static bool ShouldWarmWorkload() + => bool.TryParse(Environment.GetEnvironmentVariable(PerformanceOptions.WarmWorkloadVariable), out var enabled) && enabled; +} diff --git a/ManagedCode.Orleans.RateLimiting.Tests/Performance/PerformanceCluster.cs b/ManagedCode.Orleans.RateLimiting.Tests/Performance/PerformanceCluster.cs new file mode 100644 index 0000000..d0f4250 --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Tests/Performance/PerformanceCluster.cs @@ -0,0 +1,55 @@ +using Orleans.Messaging; +using ManagedCode.Orleans.RateLimiting.Client.Extensions; +using ManagedCode.Orleans.RateLimiting.Core.Models.Holders; +using ManagedCode.Orleans.RateLimiting.Tests.Cluster; +using Microsoft.Extensions.DependencyInjection; +using Orleans.Configuration; +using Orleans.Runtime; +using Orleans.Runtime.Placement; +using Orleans.TestingHost; +using TUnit.Core.Interfaces; + +namespace ManagedCode.Orleans.RateLimiting.Tests.Performance; + +public sealed class PerformanceCluster : IAsyncInitializer, IAsyncDisposable +{ + private const short SiloCount = 2; + private const int GatewayIndex = 0; + private const int GatewayCount = 1; + private const int PlacementIndex = 0; + public InProcessTestCluster Cluster { get; private set; } = null!; + + public async Task InitializeAsync() + { + var builder = new InProcessTestClusterBuilder(SiloCount); + builder.Options.GatewayPerSilo = false; + builder.ConfigureSilo((_, silo) => new TestSiloConfigurations().Configure(silo)); + builder.ConfigureClient(client => client.AddOrleansRateLimiting().Services.Configure(options => options.PreferredGatewayIndex = GatewayIndex)); + Cluster = builder.Build(); + await Cluster.DeployAsync(); + var gateways = await Cluster.Client.ServiceProvider.GetRequiredService().GetGateways(); + gateways.Count.ShouldBe(GatewayCount); + var expected = Cluster.Silos[PlacementIndex].SiloHost.Services.GetRequiredService().GatewayAddress!; + gateways.Single().Host.ShouldBe(expected.Endpoint.Address.ToString()); + gateways.Single().Port.ShouldBe(expected.Endpoint.Port); + } + + internal async Task PlaceAsync(ICancellableLimiterHolder holder, GrainId grainId) + { + var target = Cluster.Silos[PlacementIndex].SiloAddress; + RequestContext.Set(IPlacementDirector.PlacementHintKey, target); + try { await PerformanceMeasurement.AcquireReleaseAsync(holder, cancellable: false); } + finally { RequestContext.Remove(IPlacementDirector.PlacementHintKey); } + AssertPlacement(grainId); + } + + internal void AssertPlacement(GrainId grainId) + { + var target = Cluster.Silos[PlacementIndex].SiloAddress; + Cluster.TryGetGrainContext(grainId, out var context).ShouldBeTrue(); + context.ShouldNotBeNull(); + context.Address.SiloAddress.ShouldBe(target); + } + + public ValueTask DisposeAsync() => Cluster.DisposeAsync(); +} diff --git a/ManagedCode.Orleans.RateLimiting.Tests/Performance/PerformanceMeasurement.cs b/ManagedCode.Orleans.RateLimiting.Tests/Performance/PerformanceMeasurement.cs new file mode 100644 index 0000000..c912a0b --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Tests/Performance/PerformanceMeasurement.cs @@ -0,0 +1,65 @@ +using ManagedCode.Orleans.RateLimiting.Core.Models; +using System.Diagnostics; +using ManagedCode.Orleans.RateLimiting.Core.Models.Holders; + +namespace ManagedCode.Orleans.RateLimiting.Tests.Performance; + +internal sealed record PerformanceMeasurement(string Algorithm, string Scenario, bool Cancellable, int Repetition, + int Operations, double OperationsPerSecond, double P50Milliseconds, double P95Milliseconds, double P99Milliseconds, + double ProcessAllocatedBytesPerOperation, long ProcessRetainedBytesDelta, int Gen0Collections, int Gen1Collections, int Gen2Collections) +{ + public bool TokenCanBeCanceled { get; init; } + + private const double Median = 0.50; + private const double Tail95 = 0.95; + private const double Tail99 = 0.99; + private const int Gen0 = 0; + private const int Gen1 = 1; + private const int Gen2 = 2; + private const int LastIndexOffset = 1; + + internal static async Task RunAsync(string algorithm, string scenario, bool cancellable, + int repetition, ICancellableLimiterHolder[] holders, int workers, CancellationToken cancellationToken = default) + { + var times = new double[PerformanceOptions.Operations]; + var retainedBefore = GC.GetTotalMemory(forceFullCollection: true); + var allocatedBefore = GC.GetTotalAllocatedBytes(precise: true); + var collections = new[] { GC.CollectionCount(Gen0), GC.CollectionCount(Gen1), GC.CollectionCount(Gen2) }; + var started = Stopwatch.GetTimestamp(); + await Task.WhenAll(Enumerable.Range(default, workers).Select(Worker)); + var elapsed = Stopwatch.GetElapsedTime(started); + var allocated = GC.GetTotalAllocatedBytes(precise: true) - allocatedBefore; + var collected = new[] { GC.CollectionCount(Gen0) - collections[Gen0], GC.CollectionCount(Gen1) - collections[Gen1], GC.CollectionCount(Gen2) - collections[Gen2] }; + var retained = GC.GetTotalMemory(forceFullCollection: true) - retainedBefore; + Array.Sort(times); + return new(algorithm, scenario, cancellable, repetition, times.Length, times.Length / elapsed.TotalSeconds, + Percentile(times, Median), Percentile(times, Tail95), Percentile(times, Tail99), (double)allocated / times.Length, + retained, collected[Gen0], collected[Gen1], collected[Gen2]) + { TokenCanBeCanceled = cancellationToken.CanBeCanceled }; + + async Task Worker(int worker) + { + var holder = holders[worker % holders.Length]; + for (var index = worker; index < times.Length; index += workers) + { + var operationStarted = Stopwatch.GetTimestamp(); + await AcquireReleaseAsync(holder, cancellable, scenario != PerformanceOptions.Abandoned, cancellationToken); + times[index] = Stopwatch.GetElapsedTime(operationStarted).TotalMilliseconds; + } + } + } + + internal static async Task AcquireReleaseAsync(ICancellableLimiterHolder holder, bool cancellable, bool dispose = true, CancellationToken cancellationToken = default) + { + var lease = cancellable + ? await holder.AcquireAndConfigureAsync(PerformanceOptions.Single, cancellationToken) + : await AcquireWithoutTokenAsync(holder); + try { lease.IsAcquired.ShouldBeTrue(); } + finally { if (dispose) await lease.DisposeAsync(); } + } + + private static Task AcquireWithoutTokenAsync(ICancellableLimiterHolder holder) => holder.AcquireAndConfigureAsync(); + + private static double Percentile(double[] sorted, double percentile) + => sorted[(int)Math.Ceiling(sorted.Length * percentile) - LastIndexOffset]; +} diff --git a/ManagedCode.Orleans.RateLimiting.Tests/Performance/PerformanceOptions.cs b/ManagedCode.Orleans.RateLimiting.Tests/Performance/PerformanceOptions.cs new file mode 100644 index 0000000..5d21fe5 --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Tests/Performance/PerformanceOptions.cs @@ -0,0 +1,60 @@ +using ManagedCode.Orleans.RateLimiting.Core.Interfaces; +using Orleans.Runtime; +using System.Threading.RateLimiting; +using ManagedCode.Orleans.RateLimiting.Core.Extensions; +using ManagedCode.Orleans.RateLimiting.Core.Models.Holders; + +namespace ManagedCode.Orleans.RateLimiting.Tests.Performance; + +internal static class PerformanceOptions +{ + internal const int Operations = 32768; + internal const int Warmup = 4096; + internal const int SelectedAlgorithmWarmup = 65536; + internal const string CallerTokenVariable = "RATE_LIMIT_PERFORMANCE_CALLER_TOKEN"; + internal const string AlgorithmVariable = "RATE_LIMIT_PERFORMANCE_ALGORITHM"; + internal const string WarmWorkloadVariable = "RATE_LIMIT_PERFORMANCE_WARM_WORKLOAD"; + internal const int Repetitions = 3; + internal const int Parallelism = 16; + internal const int Single = 1; + internal const int Segments = 4; + internal const int Quota = 1000000; + internal const string Concurrency = "concurrency"; + internal const string Fixed = "fixed"; + internal const string Sliding = "sliding"; + internal const string Token = "token"; + internal const string Abandoned = "abandoned-time-leases"; + internal const string Sequential = "sequential"; + internal const string Hot = "hot-key-16"; + internal const string Partitioned = "partitioned-16"; + internal const string OutputVariable = "RATE_LIMIT_PERFORMANCE_OUTPUT"; + internal const string DefaultOutput = "artifacts/performance/result.json"; + private const int WindowHours = 1; + private static readonly TimeSpan Window = TimeSpan.FromHours(WindowHours); + + internal static (ICancellableLimiterHolder Holder, GrainId GrainId) Create(IGrainFactory client, string algorithm) + { + var key = Guid.NewGuid().ToString(); + ICancellableLimiterHolder holder = algorithm switch + { + Concurrency => client.GetConcurrencyLimiter(key, new ConcurrencyLimiterOptions + { PermitLimit = Quota, QueueLimit = Quota }), + Fixed => client.GetFixedWindowRateLimiter(key, new FixedWindowRateLimiterOptions + { PermitLimit = Quota, QueueLimit = Quota, Window = Window, AutoReplenishment = false }), + Sliding => client.GetSlidingWindowRateLimiter(key, new SlidingWindowRateLimiterOptions + { PermitLimit = Quota, QueueLimit = Quota, Window = Window, SegmentsPerWindow = Segments, AutoReplenishment = false }), + Token => client.GetTokenBucketRateLimiter(key, new TokenBucketRateLimiterOptions + { TokenLimit = Quota, QueueLimit = Quota, TokensPerPeriod = Quota, ReplenishmentPeriod = Window, AutoReplenishment = false }), + _ => throw new ArgumentOutOfRangeException(nameof(algorithm)) + }; + IRateLimiterGrain grain = algorithm switch + { + Concurrency => client.GetGrain(key), + Fixed => client.GetGrain(key), + Sliding => client.GetGrain(key), + Token => client.GetGrain(key), + _ => throw new ArgumentOutOfRangeException(nameof(algorithm)) + }; + return (holder, grain.GetGrainId()); + } +} diff --git a/ManagedCode.Orleans.RateLimiting.Tests/Performance/PerformancePayload.cs b/ManagedCode.Orleans.RateLimiting.Tests/Performance/PerformancePayload.cs new file mode 100644 index 0000000..d3ef88b --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Tests/Performance/PerformancePayload.cs @@ -0,0 +1,25 @@ +using System.Threading.RateLimiting; +using ManagedCode.Orleans.RateLimiting.Core.Models; +using Microsoft.Extensions.DependencyInjection; +using Orleans.Runtime; +using Orleans.Serialization; + +namespace ManagedCode.Orleans.RateLimiting.Tests.Performance; + +internal static class PerformancePayload +{ + private const string LeaseIdText = "bb72c532-3c1a-4eae-ae52-679f952f527b"; + private const string GrainTypeName = "rate-limit-compatibility"; + private const string GrainKey = "lease-payload"; + + internal static string Create() + { + var services = new ServiceCollection(); + services.AddSerializer(builder => builder.AddAssembly(typeof(RateLimitLeaseMetadata).Assembly)); + using var provider = services.BuildServiceProvider(); + using var limiter = new ConcurrencyLimiter(new ConcurrencyLimiterOptions { PermitLimit = PerformanceOptions.Single }); + using var lease = limiter.AttemptAcquire(PerformanceOptions.Single); + var metadata = new RateLimitLeaseMetadata(Guid.Parse(LeaseIdText), GrainId.Create(GrainTypeName, GrainKey), lease); + return Convert.ToBase64String(provider.GetRequiredService().SerializeToArray(metadata)); + } +} diff --git a/ManagedCode.Orleans.RateLimiting.Tests/PermitCountSecurityTests.cs b/ManagedCode.Orleans.RateLimiting.Tests/PermitCountSecurityTests.cs new file mode 100644 index 0000000..31ca6e9 --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Tests/PermitCountSecurityTests.cs @@ -0,0 +1,63 @@ +using System.Threading.RateLimiting; +using ManagedCode.Orleans.RateLimiting.Core.Extensions; +using ManagedCode.Orleans.RateLimiting.Core.Models.Holders; +using ManagedCode.Orleans.RateLimiting.Tests.Cluster; + +namespace ManagedCode.Orleans.RateLimiting.Tests; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +public class PermitCountSecurityTests(TestClusterApplication testApp) +{ + private const int PermitLimit = 3; + private const int RemainingPermits = 0; + private const int QueueLimit = 0; + private const int SegmentCount = 2; + private const int WindowMinutes = 10; + + [Test] + public async Task NullOptionsPreserveRequestedPermitCountForEveryLimiter() + { + var client = testApp.Cluster.Client; + var concurrency = client.GetConcurrencyLimiter(Guid.NewGuid().ToString()); + await concurrency.Configure(new ConcurrencyLimiterOptions { PermitLimit = PermitLimit, QueueLimit = QueueLimit }); + await VerifyAsync(concurrency); + + var fixedWindow = client.GetFixedWindowRateLimiter(Guid.NewGuid().ToString()); + await fixedWindow.Configure(new FixedWindowRateLimiterOptions + { + PermitLimit = PermitLimit, + QueueLimit = QueueLimit, + Window = TimeSpan.FromMinutes(WindowMinutes) + }); + await VerifyAsync(fixedWindow); + + var sliding = client.GetSlidingWindowRateLimiter(Guid.NewGuid().ToString()); + await sliding.Configure(new SlidingWindowRateLimiterOptions + { + PermitLimit = PermitLimit, + QueueLimit = QueueLimit, + Window = TimeSpan.FromMinutes(WindowMinutes), + SegmentsPerWindow = SegmentCount + }); + await VerifyAsync(sliding); + + var token = client.GetTokenBucketRateLimiter(Guid.NewGuid().ToString()); + await token.Configure(new TokenBucketRateLimiterOptions + { + TokenLimit = PermitLimit, + TokensPerPeriod = PermitLimit, + QueueLimit = QueueLimit, + ReplenishmentPeriod = TimeSpan.FromMinutes(WindowMinutes) + }); + await VerifyAsync(token); + } + + private static async Task VerifyAsync(ILimiterHolderWithConfiguration holder) where TOptions : class + { + await using var lease = await holder.AcquireAndCheckConfigurationAsync(PermitLimit, null); + lease.IsAcquired.ShouldBeTrue(); + (await holder.GetStatisticsAsync())!.CurrentAvailablePermits.ShouldBe(RemainingPermits); + await using var rejected = await holder.AcquireAsync(); + rejected.IsAcquired.ShouldBeFalse(); + } +} diff --git a/ManagedCode.Orleans.RateLimiting.Tests/PersistenceFailureTests.cs b/ManagedCode.Orleans.RateLimiting.Tests/PersistenceFailureTests.cs new file mode 100644 index 0000000..a62f636 --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Tests/PersistenceFailureTests.cs @@ -0,0 +1,67 @@ +using System.Threading.RateLimiting; +using ManagedCode.Orleans.RateLimiting.Server.Grains; +using ManagedCode.Orleans.RateLimiting.Server.Options; +using ManagedCode.Orleans.RateLimiting.Tests.Cluster; +using Microsoft.Extensions.Logging.Abstractions; +using OptionsFactory = Microsoft.Extensions.Options.Options; +using Orleans.Runtime; + +namespace ManagedCode.Orleans.RateLimiting.Tests; + +public class PersistenceFailureTests +{ + private const int TestPermitLimit = 1; + private const int NoPermits = 0; + private const int ExpectedWrites = 2; + private const int TimeoutSeconds = 10; + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(TimeoutSeconds); + + [Test] + public async Task FailedClearPreservesActiveRuntimeQuotaAndState() + { + var state = new FaultingPersistentState { FailClears = true }; + using var grain = new TestConcurrencyGrain(state); + using var lease = grain.TakePermit(); + var originalState = state.State; + await Should.ThrowAsync(() => grain.DeleteStateAsync().AsTask()); + state.State.ShouldBeSameAs(originalState); + (await grain.GetStatisticsAsync())!.CurrentAvailablePermits.ShouldBe(NoPermits); + lease.Dispose(); + (await grain.GetStatisticsAsync())!.CurrentAvailablePermits.ShouldBe(TestPermitLimit); + } + + [Test] + public async Task FailedWriteIsRetriedDuringFinalFlush() + { + var state = new FaultingPersistentState { FailWrites = true }; + using var grain = new TestConcurrencyGrain(state); + await Should.ThrowAsync(() => grain.ResetAsync().AsTask()); + state.FailWrites = false; + await grain.OnDeactivateAsync(default, CancellationToken.None); + state.Writes.ShouldBe(ExpectedWrites); + state.State.HasSnapshot.ShouldBeTrue(); + state.State.CurrentAvailablePermits.ShouldBe(TestPermitLimit); + } + + [Test] + public async Task DeactivationCancellationReachesPendingStorageWrite() + { + var state = new FaultingPersistentState { BlockWrites = true }; + using var grain = new TestConcurrencyGrain(state); + using var cancellation = new CancellationTokenSource(); + var pending = grain.OnDeactivateAsync(default, cancellation.Token); + await state.WriteStarted.Task.WaitAsync(Timeout); + state.WriteToken.ShouldBe(cancellation.Token); + await cancellation.CancelAsync(); + await Should.ThrowAsync(() => pending.WaitAsync(Timeout)); + state.State.HasSnapshot.ShouldBeTrue(); + } + + private sealed class TestConcurrencyGrain(FaultingPersistentState state) + : ConcurrencyLimiterGrain(NullLogger.Instance, + OptionsFactory.Create(new ConcurrencyLimiterOptions { PermitLimit = TestPermitLimit }), + OptionsFactory.Create(new RateLimiterPersistenceOptions { StateFlushPeriod = TimeSpan.Zero }), state) + { + public RateLimitLease TakePermit() => RateLimiter.AttemptAcquire(TestPermitLimit); + } +} diff --git a/ManagedCode.Orleans.RateLimiting.Tests/RpcTimeoutTests.cs b/ManagedCode.Orleans.RateLimiting.Tests/RpcTimeoutTests.cs new file mode 100644 index 0000000..58ef296 --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Tests/RpcTimeoutTests.cs @@ -0,0 +1,145 @@ +using ManagedCode.Orleans.RateLimiting.Tests.Cluster.Grains; +using System.Diagnostics; +using System.Threading.RateLimiting; +using ManagedCode.Orleans.RateLimiting.Core.Extensions; +using ManagedCode.Orleans.RateLimiting.Core.Models; +using ManagedCode.Orleans.RateLimiting.Core.Models.Holders; +using ManagedCode.Orleans.RateLimiting.Tests.Cluster; + +namespace ManagedCode.Orleans.RateLimiting.Tests; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +public class RpcTimeoutTests(TimeoutTestCluster app) +{ + private const int PermitCount = 1; + private const int QueueLimit = 16; + private const int NoQueuedRequests = 0; + private const int Segments = 2; + private const int WindowMinutes = 10; + private const int DeadlineSeconds = 5; + private const int PollMilliseconds = 10; + private const string StormReportFormat = "Timeout storm {0}, callerTokenOverload={1}, configure={2}: {3:F1} ms"; + private const string Concurrency = "concurrency"; + private const string Fixed = "fixed"; + private const string Sliding = "sliding"; + private const string Token = "token"; + private static readonly TimeSpan Deadline = TimeSpan.FromSeconds(DeadlineSeconds); + private static readonly TimeSpan Window = TimeSpan.FromMinutes(WindowMinutes); + + [Test] + public async Task SiloToSiloDeadlineAlsoRemovesQueuedAcquisition() + { + var key = Guid.NewGuid().ToString(); + var holder = app.Cluster.Client.GetConcurrencyLimiter(key, new ConcurrencyLimiterOptions { PermitLimit = PermitCount, QueueLimit = QueueLimit }); + await using var held = await holder.AcquireAndConfigureAsync(); + var probe = app.Cluster.Client.GetGrain(key); + var pending = probe.AcquireAsync(key); + await WaitForQueueAsync(holder, PermitCount); + (await pending.WaitAsync(Deadline)).ShouldBeFalse(); + await WaitForQueueAsync(holder, NoQueuedRequests); + await held.DisposeAsync(); + (await holder.GetStatisticsAsync())!.CurrentAvailablePermits.ShouldBe(PermitCount); + await holder.DeleteStateAsync(); + } + + [Test] + public async Task RpcTimeoutRemovesQueuedAcquisition() + { + var holder = CreateHolder(Concurrency); + await using var held = await holder.AcquireAndConfigureAsync(); + using var cancellation = new CancellationTokenSource(); + try + { + var pending = holder.AcquireAsync(PermitCount, cancellation.Token); + await WaitForQueueAsync(holder, PermitCount); + await using var rejected = await pending.WaitAsync(Deadline); + rejected.IsAcquired.ShouldBeFalse(); + await WaitForQueueAsync(holder, NoQueuedRequests); + } + finally + { + await cancellation.CancelAsync(); + } + } + + [Test] + [Arguments(Concurrency, false, false)] + [Arguments(Concurrency, true, false)] + [Arguments(Concurrency, false, true)] + [Arguments(Concurrency, true, true)] + [Arguments(Fixed, false, false)] + [Arguments(Fixed, true, false)] + [Arguments(Fixed, false, true)] + [Arguments(Fixed, true, true)] + [Arguments(Sliding, false, false)] + [Arguments(Sliding, true, false)] + [Arguments(Sliding, false, true)] + [Arguments(Sliding, true, true)] + [Arguments(Token, false, false)] + [Arguments(Token, true, false)] + [Arguments(Token, false, true)] + [Arguments(Token, true, true)] + public async Task TimeoutStormLeavesNoQueuedOrOrphanedPermits(string algorithm, bool cancellable, bool configure) + { + var holder = CreateHolder(algorithm); + await using var held = await holder.AcquireAndConfigureAsync(); + using var callerCancellation = new CancellationTokenSource(); + var started = Stopwatch.GetTimestamp(); + var pending = Enumerable.Range(NoQueuedRequests, QueueLimit).Select(_ => AcquireAsync(holder, cancellable, configure, callerCancellation.Token)).ToArray(); + await WaitForQueueAsync(holder, QueueLimit); + var results = await Task.WhenAll(pending).WaitAsync(Deadline); + foreach (var lease in results) + { + lease.IsAcquired.ShouldBeFalse(); + await lease.DisposeAsync(); + } + await WaitForQueueAsync(holder, NoQueuedRequests); + var elapsed = Stopwatch.GetElapsedTime(started); + elapsed.ShouldBeLessThan(Deadline); + Console.WriteLine(StormReportFormat, algorithm, cancellable, configure, elapsed.TotalMilliseconds); + await held.DisposeAsync(); + if (algorithm == Concurrency) + { + (await holder.GetStatisticsAsync())!.CurrentAvailablePermits.ShouldBe(PermitCount); + await using var next = await holder.AcquireAndConfigureAsync(); + next.IsAcquired.ShouldBeTrue(); + } + await holder.DeleteStateAsync(); + } + + private static Task AcquireAsync(ICancellableLimiterHolder holder, bool cancellable, bool configure, CancellationToken cancellationToken) + => (cancellable, configure) switch + { + (true, true) => holder.AcquireAndConfigureAsync(PermitCount, cancellationToken), + (true, false) => holder.AcquireAsync(PermitCount, cancellationToken), + (false, true) => AcquireLegacyAsync(holder, configure), + _ => AcquireLegacyAsync(holder, configure) + }; + + private static Task AcquireLegacyAsync(ILimiterHolder holder, bool configure) + => configure ? holder.AcquireAndConfigureAsync() : holder.AcquireAsync(); + + private ICancellableLimiterHolder CreateHolder(string algorithm) + { + var key = Guid.NewGuid().ToString(); + var client = app.Cluster.Client; + return algorithm switch + { + Concurrency => client.GetConcurrencyLimiter(key, new ConcurrencyLimiterOptions { PermitLimit = PermitCount, QueueLimit = QueueLimit }), + Fixed => client.GetFixedWindowRateLimiter(key, new FixedWindowRateLimiterOptions + { PermitLimit = PermitCount, QueueLimit = QueueLimit, Window = Window, AutoReplenishment = false }), + Sliding => client.GetSlidingWindowRateLimiter(key, new SlidingWindowRateLimiterOptions + { PermitLimit = PermitCount, QueueLimit = QueueLimit, Window = Window, SegmentsPerWindow = Segments, AutoReplenishment = false }), + Token => client.GetTokenBucketRateLimiter(key, new TokenBucketRateLimiterOptions + { TokenLimit = PermitCount, QueueLimit = QueueLimit, TokensPerPeriod = PermitCount, ReplenishmentPeriod = Window, AutoReplenishment = false }), + _ => throw new ArgumentOutOfRangeException(nameof(algorithm)) + }; + } + + private static async Task WaitForQueueAsync(ILimiterHolder holder, long expected) + { + using var deadline = new CancellationTokenSource(Deadline); + while ((await holder.GetStatisticsAsync())!.CurrentQueuedCount != expected) + await Task.Delay(PollMilliseconds, deadline.Token); + } +} diff --git a/ManagedCode.Orleans.RateLimiting.Tests/SignalRSecurityTests.cs b/ManagedCode.Orleans.RateLimiting.Tests/SignalRSecurityTests.cs new file mode 100644 index 0000000..655b992 --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Tests/SignalRSecurityTests.cs @@ -0,0 +1,86 @@ +using System.Net; +using System.Threading.RateLimiting; +using ManagedCode.Orleans.RateLimiting.Client.Extensions; +using ManagedCode.Orleans.RateLimiting.Core.Extensions; +using ManagedCode.Orleans.RateLimiting.Core.Models.Orchestration; +using ManagedCode.Orleans.RateLimiting.Tests.Cluster; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http.Connections; +using Microsoft.AspNetCore.SignalR; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace ManagedCode.Orleans.RateLimiting.Tests; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +public class SignalRSecurityTests(TestClusterApplication testApp) +{ + private const string HubPath = "/security-hub"; + private const string ForwardedFor = "X-Forwarded-For"; + private const string RealIp = "X-Real-IP"; + private const string FirstSpoofedIp = "203.0.113.10"; + private const string SecondSpoofedIp = "203.0.113.11"; + private const string PeerIp = "198.51.100.7"; + private const int PermitLimit = 1; + private const int QueueLimit = 0; + private const int WindowMinutes = 10; + + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task ReconnectingWithSpoofedHeadersDoesNotResetIpQuota(bool missingAddress) + { + using var host = CreateHost(missingAddress); + await using var first = CreateConnection(host.GetTestServer(), FirstSpoofedIp); + await first.StartAsync(); + (await first.InvokeAsync(nameof(SecurityHub.Protected))).ShouldBeTrue(); + await first.StopAsync(); + + await using var second = CreateConnection(host.GetTestServer(), SecondSpoofedIp); + await second.StartAsync(); + await Should.ThrowAsync(() => second.InvokeAsync(nameof(SecurityHub.Protected))); + } + + private IHost CreateHost(bool missingAddress) + { + var configurationName = Guid.NewGuid().ToString(); + return new HostBuilder().ConfigureWebHost(web => web.UseTestServer().ConfigureServices(services => + { + services.AddRouting(); + services.AddSingleton(testApp.Cluster.Client); + services.AddOrleansRateLimiterOptions(configurationName, new FixedWindowRateLimiterOptions + { + PermitLimit = PermitLimit, + QueueLimit = QueueLimit, + Window = TimeSpan.FromMinutes(WindowMinutes) + }); + services.AddSignalR().AddOrleansRateLimiting(configurationName, RateLimitPartitionKind.IpAddress); + }).Configure(app => + { + app.Use((context, next) => + { + context.Connection.RemoteIpAddress = missingAddress ? null : IPAddress.Parse(PeerIp); + return next(context); + }); + app.UseRouting(); + app.UseEndpoints(endpoints => endpoints.MapHub(HubPath)); + })).Start(); + } + + private static HubConnection CreateConnection(TestServer server, string spoofedIp) => new HubConnectionBuilder() + .WithUrl(new Uri(server.BaseAddress, HubPath), options => + { + options.Transports = HttpTransportType.LongPolling; + options.HttpMessageHandlerFactory = _ => server.CreateHandler(); + options.Headers[ForwardedFor] = spoofedIp; + options.Headers[RealIp] = spoofedIp; + }).Build(); + + public sealed class SecurityHub : Hub + { + public bool Protected() => !Context.ConnectionAborted.IsCancellationRequested; + } +} diff --git a/ManagedCode.Orleans.RateLimiting.Tests/TimeoutRegistrationTests.cs b/ManagedCode.Orleans.RateLimiting.Tests/TimeoutRegistrationTests.cs new file mode 100644 index 0000000..9c44798 --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Tests/TimeoutRegistrationTests.cs @@ -0,0 +1,42 @@ +using ManagedCode.Orleans.RateLimiting.Client.Extensions; +using ManagedCode.Orleans.RateLimiting.Core.Services; +using ManagedCode.Orleans.RateLimiting.Server.Extensions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace ManagedCode.Orleans.RateLimiting.Tests; + +public class TimeoutRegistrationTests +{ + private const int OneProvider = 1; + private const string SiloProviderName = "SiloRateLimiterTimeoutProvider"; + + [Test] + public void ServiceCollectionRegistrationInstallsOneDeadlineProviderWithoutFilters() + { + var services = new ServiceCollection(); + services.AddOrleansRateLimiting(); + services.AddOrleansRateLimiting(); + using var provider = services.BuildServiceProvider(); + provider.GetServices().Count().ShouldBe(OneProvider); + provider.GetServices().ShouldBeEmpty(); + } + + [Test] + [Arguments(true)] + [Arguments(false)] + public void CoHostedRegistrationUsesOneSiloDeadlineRegardlessOfOrder(bool clientFirst) + { + using var host = new HostBuilder().UseOrleans(silo => + { + if (clientFirst) silo.Services.AddOrleansRateLimiting(); + silo.AddOrleansRateLimiting(); + silo.AddOrleansRateLimiting(); + if (!clientFirst) silo.Services.AddOrleansRateLimiting(); + }).Build(); + var providers = host.Services.GetServices().ToArray(); + providers.Length.ShouldBe(OneProvider); + providers.Single().GetType().Name.ShouldBe(SiloProviderName); + host.Services.GetServices().ShouldBeEmpty(); + } +} diff --git a/ManagedCode.Orleans.RateLimiting.Tests/TokenBucketSnapshotBoundaryTests.cs b/ManagedCode.Orleans.RateLimiting.Tests/TokenBucketSnapshotBoundaryTests.cs new file mode 100644 index 0000000..5a37b5b --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Tests/TokenBucketSnapshotBoundaryTests.cs @@ -0,0 +1,56 @@ +using System.Threading.RateLimiting; +using ManagedCode.Orleans.RateLimiting.Server.Grains; +using ManagedCode.Orleans.RateLimiting.Server.Options; +using ManagedCode.Orleans.RateLimiting.Tests.Cluster; +using Microsoft.Extensions.Logging.Abstractions; +using OptionsFactory = Microsoft.Extensions.Options.Options; + +namespace ManagedCode.Orleans.RateLimiting.Tests; + +public class TokenBucketSnapshotBoundaryTests +{ + private const int Limit = 10; + private const int SavedPermits = 3; + private const int TokensPerPeriod = 2; + private const int SingleTick = 1; + private const int ElapsedTicks = 3; + private const int ExpectedPartial = 9; + private const int NoPermits = 0; + private static readonly DateTimeOffset SavedAt = DateTimeOffset.UnixEpoch; + + [Test] + public void LongOutageCannotOverflowRestoredPermits() + { + using var grain = CreateGrain(int.MaxValue); + grain.Restore(DateTimeOffset.MinValue, NoPermits, DateTimeOffset.MaxValue).ShouldBe(Limit); + } + + [Test] + public void BackwardsClockDoesNotSubtractPermits() + { + using var grain = CreateGrain(TokensPerPeriod); + grain.Restore(SavedAt, SavedPermits, SavedAt.AddTicks(-ElapsedTicks)).ShouldBe(SavedPermits); + } + + [Test] + public void PartialPeriodsRestoreExactlyTheElapsedQuota() + { + using var grain = CreateGrain(TokensPerPeriod); + grain.Restore(SavedAt, SavedPermits, SavedAt.AddTicks(ElapsedTicks)).ShouldBe(ExpectedPartial); + } + + private static TestTokenGrain CreateGrain(int tokens) => new(new TokenBucketRateLimiterOptions + { + AutoReplenishment = true, + TokenLimit = Limit, + TokensPerPeriod = tokens, + ReplenishmentPeriod = TimeSpan.FromTicks(SingleTick) + }); + + private sealed class TestTokenGrain(TokenBucketRateLimiterOptions options) + : TokenBucketRateLimiterGrain(NullLogger.Instance, + OptionsFactory.Create(options), OptionsFactory.Create(new RateLimiterPersistenceOptions()), new FaultingPersistentState()) + { + public int Restore(DateTimeOffset saved, int permits, DateTimeOffset now) => GetRestoredAvailablePermits(saved, permits, now); + } +} diff --git a/ManagedCode.Orleans.RateLimiting.Tests/TransportCancellationTests.cs b/ManagedCode.Orleans.RateLimiting.Tests/TransportCancellationTests.cs new file mode 100644 index 0000000..d3e7252 --- /dev/null +++ b/ManagedCode.Orleans.RateLimiting.Tests/TransportCancellationTests.cs @@ -0,0 +1,118 @@ +using System.Threading.RateLimiting; +using ManagedCode.Orleans.RateLimiting.Client.Middlewares; +using ManagedCode.Orleans.RateLimiting.Core.Extensions; +using ManagedCode.Orleans.RateLimiting.Core.Interfaces; +using ManagedCode.Orleans.RateLimiting.Core.Models.Holders; +using ManagedCode.Orleans.RateLimiting.Core.Models.Orchestration; +using ManagedCode.Orleans.RateLimiting.Tests.Cluster; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http.Connections; +using Microsoft.AspNetCore.SignalR; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace ManagedCode.Orleans.RateLimiting.Tests; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +public class TransportCancellationTests(TestClusterApplication testApp) +{ + private const string HttpPath = "/cancel-http"; + private const string HubPath = "/cancel-hub"; + private const int PermitCount = 1; + private const int QueueLimit = 1; + private const int EmptyQueue = 0; + private const int TimeoutSeconds = 10; + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(TimeoutSeconds); + + [Test] + public async Task HttpAbortCancelsQueuedGrainCallBeforeEndpointExecutes() + { + var holder = CreateHolder(); + await using var held = await holder.AcquireAndConfigureAsync(); + var calls = new InvocationCounter(); + using var host = CreateHost(holder, calls); + using var client = host.GetTestClient(); + using var cancellation = new CancellationTokenSource(); + var pending = client.GetAsync(HttpPath, cancellation.Token); + await CancellationTests.WaitForQueueAsync(holder, PermitCount); + await cancellation.CancelAsync(); + await Should.ThrowAsync(() => pending.WaitAsync(Timeout)); + await CancellationTests.WaitForQueueAsync(holder, EmptyQueue); + calls.Count.ShouldBe(EmptyQueue); + await held.DisposeAsync(); + (await client.GetAsync(HttpPath)).IsSuccessStatusCode.ShouldBeTrue(); + calls.Count.ShouldBe(PermitCount); + } + + [Test] + public async Task SignalRDisconnectCancelsQueuedGrainCallBeforeHubExecutes() + { + var holder = CreateHolder(); + await using var held = await holder.AcquireAndConfigureAsync(); + var calls = new InvocationCounter(); + using var host = CreateHost(holder, calls); + var server = host.GetTestServer(); + await using var connection = new HubConnectionBuilder().WithUrl(new Uri(server.BaseAddress, HubPath), options => + { + options.Transports = HttpTransportType.LongPolling; + options.HttpMessageHandlerFactory = _ => server.CreateHandler(); + }).Build(); + await connection.StartAsync(); + var pending = connection.InvokeAsync(nameof(CancellationHub.Protected)); + await CancellationTests.WaitForQueueAsync(holder, PermitCount); + await connection.StopAsync().WaitAsync(Timeout); + await Should.ThrowAsync(() => pending.WaitAsync(Timeout)); + await CancellationTests.WaitForQueueAsync(holder, EmptyQueue); + calls.Count.ShouldBe(EmptyQueue); + await held.DisposeAsync(); + (await holder.GetStatisticsAsync())!.CurrentAvailablePermits.ShouldBe(PermitCount); + } + + private ConcurrencyLimiterHolder CreateHolder() => testApp.Cluster.Client.GetConcurrencyLimiter(Guid.NewGuid().ToString(), + new ConcurrencyLimiterOptions { PermitLimit = PermitCount, QueueLimit = QueueLimit }); + + private static IHost CreateHost(ILimiterHolder holder, InvocationCounter calls) => new HostBuilder() + .ConfigureWebHost(web => web.UseTestServer().ConfigureServices(services => + { + services.AddRouting(); + services.AddSingleton(calls); + services.AddSingleton(new FixedOrchestrator(holder)); + services.AddSignalR(options => options.AddFilter()); + }).Configure(app => + { + app.UseRouting(); + app.UseWhen(context => context.Request.Path == HttpPath, + branch => branch.UseMiddleware()); + app.UseEndpoints(endpoints => + { + endpoints.MapGet(HttpPath, () => calls.Invoke()); + endpoints.MapHub(HubPath); + }); + })).Start(); + + private sealed class FixedOrchestrator(ILimiterHolder holder) : IRateLimitRequestOrchestrator + { + public ValueTask CreateLimiterGroupAsync(RateLimitRequestContext context, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var group = new GroupLimiterHolder(); + group.AddLimiter(holder); + return ValueTask.FromResult(group); + } + } + + public sealed class InvocationCounter + { + private int _count; + public int Count => Volatile.Read(ref _count); + public bool Invoke() { Interlocked.Increment(ref _count); return true; } + } + + public sealed class CancellationHub(InvocationCounter calls) : Hub + { + public bool Protected() => calls.Invoke(); + } +} diff --git a/README.md b/README.md index 4bd0f53..978a7b4 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,24 @@ public class TestFixedWindowRateLimiterGrain : Grain, ITestFixedWindowRateLimite } ``` +### Grain attribute policy safety (10.2.0) + +Grain filters honor attributes on implementation methods, then interface methods, +then the implementation class. Explicit key partitions require a non-empty key; +an empty or missing named configuration fails before protected work executes. +Different named policies and different inline option sets use separate limiter +identities, preventing one operation from resetting another operation's quota. +Identical inline options retain shared quota for the selected key. These changes +start new counters for named/inline policies; deploy clients and silos consistently. + +`GroupLimiterHolder` rejects overlapping acquisition and changes to its members +while acquisition/lease ownership is active. It rolls back earlier permits if a +later acquisition fails and waits for pending acquisition during disposal. Build +a group before acquiring it and continue to use `await using` around protected work. + +Invalid configuration updates and invalid permit counts leave the existing limiter, +quota, and active concurrency leases intact. Correct the rejected input before retrying. + ## ASP.NET Core Usage Register named limiter options and configure request orchestration rules. @@ -316,6 +334,67 @@ app.UseOrleansUserRateLimiting(); app.MapControllers(); ``` +### Cancellation and metrics (10.2.0) + +HTTP middleware propagates `RequestAborted`; SignalR propagates `ConnectionAborted`. +Queued requests are cancelled in Orleans and the native limiter, and partial groups +release concurrency leases. Direct holders also accept a token: + +```csharp +await using var lease = await limiter.AcquireAsync(permitCount: 1, cancellationToken); +``` + +Deploy updated silos before callers of the new cancellation APIs. Existing methods +and RPC identities remain available. Custom legacy holders remain compatible but +cannot interrupt an acquisition unless they implement `ICancellableLimiterHolder`. +Subscribe to meter `ManagedCode.Orleans.RateLimiting` for acquisition outcomes/durations, +active concurrency permits, and storage writes/durations. Metric tags contain no +partition identities. See [cancellation, metrics and persistence details](docs/CancellationAndObservability.md). + +### Client IPs and trusted proxies (10.2.0) + +HTTP middleware and SignalR use `HttpContext.Connection.RemoteIpAddress`. Raw +`X-Forwarded-For`, `X-Real-IP`, and `REMOTE_ADDR` headers do not select rate-limit +partitions. IPv4-mapped IPv6 addresses are normalized to IPv4; requests without a +connection address share the `unknown-ip` partition instead of skipping IP limits. + +Behind a reverse proxy, configure ASP.NET Core forwarded headers with your actual +trusted proxy addresses, and run it **before** authentication and rate limiting: + +```csharp +using System.Net; +using Microsoft.AspNetCore.HttpOverrides; + +const string trustedProxyAddress = "10.0.0.10"; // Replace with your proxy's address. +builder.Services.Configure(options => +{ + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor; + options.KnownProxies.Add(IPAddress.Parse(trustedProxyAddress)); +}); + +// After building the app, before the pipeline shown above: +app.UseForwardedHeaders(); +``` + +Keep a bounded `ForwardLimit` matching your proxy chain and restrict trusted +proxies/networks. Do not clear both trust lists or trust arbitrary clients. See +[Microsoft's proxy configuration guide](https://learn.microsoft.com/aspnet/core/host-and-deploy/proxy-load-balancer?view=aspnetcore-10.0). +The legacy `GetClientIpAddress(headers)` overload explicitly trusts the supplied +headers and is for callers that independently enforce this boundary; built-in +middleware never calls it. + +Attribute middleware now throws `RateLimitConfigurationNotFoundException` when a +referenced configuration is missing, before running the protected endpoint. +Attribute keys also include configuration identity so stacked policies cannot reset +each other. These key corrections start new counters during upgrade; deploy all +application nodes consistently. +Use stable authenticated identifiers for user/tenant/custom policies, mark required +partitions `required: true`, and combine them with an IP or shared limit where +anonymous requests must also be limited. Apply authentication before claim-based +rules. Orleans cluster access, configuration/reset/delete methods, and lease IDs +are trusted application capabilities; do not expose them directly to HTTP clients. +See [the 10.2.0 security review](docs/SecurityReview-10.2.0.md) for scope and verification. + Apply HTTP limiter attributes to controllers or actions. ```csharp @@ -372,3 +451,22 @@ Current local coverage after the .NET 10 migration and request-orchestration ref ## Contributing Issues and pull requests are welcome in the [GitHub repository](https://github.com/managedcode/Orleans.RateLimiting). + +### Acquisition deadlines and performance + +Register `AddOrleansRateLimiting()` on the client and silo. Version 10.2 holder acquisitions +use server-bounded RPCs; deploy updated silos before clients. The client supplies a wait +budget of 80% of the Orleans response timeout. The configured steady-state fast path +creates no deadline timer and uses distributed cancellation only for a cancellable caller +token. Queued/configuration waits enforce their remaining budget locally on the server. +The bounded proxy supplies this budget without a global outgoing filter, preserving +Orleans' direct dispatch path for release and unrelated grain calls. +Deadlines return rejected leases; caller cancellation throws `OperationCanceledException`. + +Fixed-window, sliding-window and token-bucket leases omit unnecessary release RPCs and +server retention. Concurrency disposal reuses the holder's grain reference to return permits. +Empty lease metadata shares immutable storage. Global Orleans timeout settings are unchanged; +network failures still do not provide distributed concurrency-lease expiry guarantees. + +See [performance and timeout validation](docs/PerformanceAndTimeouts.md) for measured +throughput, p99, allocations, real-token comparisons, and reproducible benchmark commands. diff --git a/docs/Architecture.md b/docs/Architecture.md index ee0a885..767d9cb 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -115,3 +115,72 @@ classDiagram - Limiter grains own durable quota state. They update in-memory state on acquire/release, flush dirty state to Orleans storage on a configurable timer, and force a final flush during deactivation, configuration, and reset. - `ResetAsync()` clears quota while keeping configuration. `DeleteStateAsync()` clears Orleans persistent state for limiter keys that are no longer needed and returns the activation to silo defaults. - Fixed-window, sliding-window, and token-bucket grains restore consumed quota from persisted snapshots. Concurrency grains additionally persist active lease ids and permit counts so leases acquired before deactivation can still be released after reactivation. + +## Client IP trust boundary + +```mermaid +flowchart LR + Request["Request with untrusted headers"] --> Proxy["ASP.NET Core ForwardedHeadersMiddleware"] + Trust["Known proxies/networks + bounded ForwardLimit"] --> Proxy + Proxy --> Connection["Connection.RemoteIpAddress"] + Connection --> Resolver["Normalize IPv4; shared fallback if absent"] + Resolver --> HTTP["HTTP rate-limit partition"] + Resolver --> Hub["SignalR rate-limit partition"] +``` + +Built-in integrations never resolve IPs from raw request headers. The host owns +proxy trust configuration and middleware ordering. Missing attribute configurations +fail before endpoint execution. See [security review and migration](SecurityReview-10.2.0.md). + +## Grain policy and group lifecycle boundaries + +- Grain attributes resolve in implementation-method, interface-method, then + implementation-class order. Invalid explicit keys and missing named configurations + fail before the grain implementation runs. +- Named policies include configuration identity in their keys. Built-in inline + policies include all limiter options, so different settings cannot reset each + other's runtime limiter; identical settings still share the selected partition. +- A limiter group reserves acquisition ownership before awaiting a grain. Partial + acquisition rolls back on rejection or exception. Disposal waits for any pending + acquisition before releasing leases, and concurrent disposal callers await the + same completion. Configure group membership before acquiring. +- Typed rate-limit exceptions have generated Orleans codecs and explicit field IDs. + The follow-up flow diagram and migration notes are in the [security review](SecurityReview-10.2.0.md#follow-up-review-grain-policies-and-lease-ownership). + +- Configuration replacement validates a candidate limiter and requested permit count + before disposing the current runtime limiter. Invalid input preserves its options, + quota, and active leases; the existing synchronous subclass factory remains supported. + +## Platform modernization + +Core and Server keep generated RPC identity manifests, checked by Orleans 10.3.1 +Versioning analyzers as build errors. No RPC identities change when enabling this +guard. Packing also compares public .NET APIs against published 10.1.0, and CI +runs this validation before tests. See the [current modernization review](ModernizationReview-2026-09.md) for +native cancellation, test-clock isolation, persistence, and observability priorities. + +## Cancellation and diagnostics + +HTTP request and SignalR connection cancellation flows through optional holder and +grain capabilities into native limiter queues. Original RPCs remain available. +Partial groups release owned concurrency leases even when cancellation arrives late. +Storage timer/shutdown writes receive cancellation tokens, failed writes remain dirty, +and failed clears preserve the runtime limiter. Snapshot time uses the activation's +`TimeProvider`. Metrics expose bounded algorithm/outcome tags and an aggregate active +concurrency-permit count. See [cancellation and observability](CancellationAndObservability.md) +for the sequence diagram, deployment order, metric names and test boundaries. + +## Acquisition budgets and lease release + +Client and Server registration bind hosting response-timeout options to Core's scalar +budget provider. Co-hosted applications install one provider using silo options. A generated +bounded proxy supplies the budget before dispatch. No global outgoing filter is registered, +so release and unrelated grain calls retain Orleans' direct dispatch path. +Holders cache their grain references and avoid native cancellation when the caller token +cannot be cancelled. The server creates timeout machinery for waiting/configuration paths; +configured immediate acquisitions do not allocate deadline timers. + +Known time-based limiters dispose native leases immediately and send additive metadata +allowing clients to omit the release RPC. Concurrency and custom base-grain implementations +retain explicit release ownership. Empty metadata shares immutable storage. See +[performance validation](PerformanceAndTimeouts.md) for the flow diagram, results and limits. diff --git a/docs/CancellationAndObservability.md b/docs/CancellationAndObservability.md new file mode 100644 index 0000000..f28b89f --- /dev/null +++ b/docs/CancellationAndObservability.md @@ -0,0 +1,112 @@ +# Cancellation, persistence and observability + +## Scope + +Implement the accepted modernization slices: additive cancellable grain/holder APIs, +HTTP and SignalR propagation, cancellable persistence writes, async TestingHost, +controlled grain clocks, bounded metrics, and arithmetic/storage failure regressions. +Preserve published API and RPC identities, quota semantics and storage-provider choice. +Verify focused integration tests, full suite, analyzers, formatting, coverage, package +compatibility and CI. Performance-only lease/RPC removal needs benchmark evidence and +an explicit metadata compatibility design; it is not bundled into this behavior change. + +## Cancellation ownership + +```mermaid +sequenceDiagram + participant HTTP as HTTP / SignalR + participant Group as Limiter group + participant Holder + participant Grain + participant Native as Native limiter + HTTP->>Group: AcquireAsync(request/connection token) + Group->>Holder: AcquireAndConfigureAsync(count, token) + Holder->>Grain: Optional cancellable RPC + Grain->>Native: AcquireAsync(count, token) + HTTP-->>Native: Cooperative cancellation + Native-->>Grain: Cancel queued acquisition + Grain-->>Group: OperationCanceledException + Group->>Grain: Release earlier concurrency leases without cancelled token +``` + +`ICancellableRateLimiterGrain` and its generic configuration counterpart are new +capabilities. Original interfaces and all original overloads remain unchanged. +`ICancellableLimiterHolder` is optional, so consumer implementations of `ILimiterHolder` +remain source-compatible. The group observes cancellation before/after each acquisition, +retains lease ownership before checking a late cancellation, and rolls back partial +acquisitions. Legacy holders cannot interrupt their own queue; the group waits for their +result, releases any late lease, then reports cancellation. Empty groups also observe it. + +Deploy updated silos before updated middleware/clients start calling the new interfaces. +Previous client binaries continue using supported old RPCs. All 10.2 holder acquisition +overloads now use bounded RPCs. Native token propagation is used only when the caller +token can be cancelled; other calls pass only the server wait budget. Upgrade +all silos serving limiter keys before upgrading clients: API compatibility does not make +an old silo implement a new capability. Register `AddOrleansRateLimiting` on the client +and silo to register the bounded proxy's timeout provider. No cluster-wide timeout defaults change. +See [performance and timeout validation](PerformanceAndTimeouts.md) for deadline semantics +and the additive lease-release optimization. Cancellation remains cooperative; +network partitions or a crash after server acquisition but before delivery are still +subject to the existing lease ownership and best-effort snapshot guarantees. + +Cancelling a native queued request removes it from the queue. Cleanup returns concurrency +permits. Successfully consumed time-window/token quota is not refunded by disposal. + +## Persistence and clocks + +Timer, configuration-acquisition and shutdown writes forward their tokens to Orleans +10.3 storage overloads. Providers using Orleans' compatibility fallback may ignore them. +The library retains its state lock: runtime mutations must still mark their snapshot dirty, +even if cancellation arrives during that transition. A failed or cancelled write does not +clear the dirty flag. A failed clear preserves the active runtime and in-memory state. + +Snapshots use the activation's registered `TimeProvider`, falling back to system UTC. +Tests pin Orleans background areas to real time while advancing the grain clock; native +`System.Threading.RateLimiting` replenishment timers still use their own clock. Token +restoration bounds elapsed replenishment before multiplication and clamps backwards-clock +snapshots. Aggregate sliding-window snapshots and batched crash durability are unchanged. + +## Metrics + +Subscribe with your telemetry backend's `AddMeter("ManagedCode.Orleans.RateLimiting")`. +No exporter/backend dependency is required by the library. + +| Instrument | Meaning | Tags | +| --- | --- | --- | +| `rate_limiting.acquisitions` | Native acquisition attempts, including errors/cancellation | `algorithm`, `outcome` | +| `rate_limiting.acquire.duration` | Acquisition including native queue wait and snapshot update, seconds | `algorithm`, `outcome` | +| `rate_limiting.concurrency.active_permits` | Up/down sum of permits held by live activations; restoration and disposal balance it | none | +| `rate_limiting.state.writes` | Snapshot write attempts | `algorithm`, `outcome` | +| `rate_limiting.state.write.duration` | Snapshot write duration, seconds | `algorithm`, `outcome` | + +Algorithms are a fixed vocabulary: `concurrency`, `fixed_window`, `sliding_window`, +`token_bucket`, `custom`. Outcomes are `acquired`, `rejected`, `cancelled`, `failed` +for acquisition and `written`, `cancelled`, `failed` for storage. There are no user, +IP, tenant, grain-key, configuration-name or exception-message tags. Acquisitions cancelled +before reaching the native limiter are not counted as native attempts. + +## Maintainability exception + +The existing generic `RateLimiterGrain` exceeds the type-size limit; +its synchronized runtime, configuration, lease and persistence ownership remain one unit +for this compatibility-preserving release. The implementation is split into focused +partial files below 400 lines, with new methods below 50 lines. Extract a state/lifetime +coordinator in a future dedicated refactor with the current race, queue and persistence +regressions as its acceptance suite. Splitting files alone does not resolve the type limit. + +## Verification of the initial cancellation slice + +The following records the initial 149-test slice. Current timeout/performance follow-up +results and the complete suite are recorded in [PerformanceAndTimeouts.md](PerformanceAndTimeouts.md). + +- 149/149 tests passed both normally and under Coverlet, with no skips; this slice + adds 22 tests to the previous 127-test baseline. The focused affected suite passes + 28 cases, including the existing group lifecycle regressions. +- Coverage: 93.29% total (Client 93.75%, Core 92.99%, Server 93.47%); the 85% gate is unchanged. +- Release/analyzer build: zero warnings/errors. Format verification, package API + compatibility against 10.1.0, all three packages, and actionlint pass. +- NuGet reports no known vulnerable direct/transitive packages across all projects. +- The RPC manifest diff only adds the two optional interfaces; no existing wire + identity is changed. Three controlled old-behavior regressions fail before the fixes. +- Controlled-clock coverage uses a real Orleans timer and memory storage decorated + with an injected failure; the native limiter clock is not virtualized. diff --git a/docs/ModernizationReview-2026-09.md b/docs/ModernizationReview-2026-09.md new file mode 100644 index 0000000..42bb9ef --- /dev/null +++ b/docs/ModernizationReview-2026-09.md @@ -0,0 +1,161 @@ +# .NET and Orleans modernization review + +Date: 2026-09-07. Baseline: `05bccaf` (127 tests, 93.34% coverage). + +## Scope and execution plan + +Compare this library's architecture and implementation with .NET 10 and Orleans +10.3.1 using upstream release notes, documentation, source, and installed packages. +Prioritize compatibility, cancellation, grain scheduling/persistence, testing, and +observability. Adopt the built-in RPC contract analyzer now without changing RPC +identities or method signatures; enable .NET package baseline validation against +published 10.1.0 and produce a ranked modernization plan for behavior changes. Do not introduce a new framework, storage provider, or breaking API as a +side effect of review. Use dotnet/orleans for API evidence and analyzer-config for +build policy. Verify missing-manifest failure, generate manifests with the official +code fix, prove that an intentional method-signature mutation fails the build, +restore it, then run build/analyze, format, and the existing tests/CI. + +## Decision + +Keep the Core / Client / Server boundary and one stateful grain per quota key. +The distributed coordinator still provides value beyond a process-local ASP.NET +Core limiter. .NET 10, C# 14, Orleans 10.3.1, central package management, generated +serializers, TUnit, and Microsoft.Testing.Platform are already in place. Upstream +GitHub's release API confirms Orleans 10.3.1, published 2026-08-28; older cached +release pages can show 10.2.x. This review targets the installed release, not preview +APIs or descriptions from a different version. + +## Prioritized improvements at the review baseline + +Cancellation, persistence writes/clocks, asynchronous TestingHost, bounded metrics, +and token snapshot boundaries are now implemented. See +[implementation, compatibility and operational details](CancellationAndObservability.md). +The table below preserves the original evidence and acceptance criteria; the +benchmark-dependent lease/RPC optimization remains a separate performance design. + +| Priority | Area and current evidence | Recommendation and acceptance criteria | +| --- | --- | --- | +| P1 | `Core/Interfaces/IRateLimiterGrain.cs`, `IRateLimiterGrainWithConfiguration.cs`, `Models/Holders/BaseRateLimiterHolder.cs`, and `GroupLimiterHolder.cs` have no cancellation path through acquisition. HTTP passes `RequestAborted` only while constructing the group; SignalR does not pass its connection cancellation to construction. | Add a compatible cancellable acquisition surface and flow the token through middleware, group, holder, grain locks, and `RateLimiter.AcquireAsync`. Retain existing RPC identities and old entry points. Test cancellation before dispatch, while queued, during partial group acquisition, and racing successful acquisition. Assert queue removal and restored concurrency capacity; cleanup must not use an already-cancelled request token. A caller-side `WaitAsync(token)` alone is insufficient because remote acquisition can still complete. | +| P1 — implemented | Orleans 10.3 adds an opt-in RPC contract compatibility analyzer. Previously the build could miss accidental changes to method or grain identities. | Enable it for Core and Server, check in generated `OrleansContracts.txt`, and promote Versioning diagnostics to errors in `.editorconfig`. Missing manifests and a controlled wire-ID mutation now fail compilation. Existing interfaces, attributes, wire IDs, and grain class IDs are unchanged. This guard does not prove behavioral or persisted-state compatibility. | +| P1 — implemented | `EnablePackageValidation` was enabled without a previous package baseline, so it did not compare public API across releases. | Set `PackageValidationBaselineVersion` to published 10.1.0 and run pack in PR CI. A controlled removal of `MetadataNames` fails with `CP0002`; restored Core, Client, and Server pass. The baseline is metadata-only `PackageDownload`, not a runtime dependency. | +| P2 | `RateLimiterGrain.Persistence.cs` uses parameterless storage methods and direct `DateTimeOffset.UtcNow`; shutdown and timer cancellation stop before the actual write. | Evaluate the new `IStorage` cancellation overloads for timer/shutdown writes. First add a cancellable/failing provider test, including retention of dirty state after failure. Keep the state lock: runtime serialization of storage calls does not make our multi-step snapshot/dirty-flag mutation atomic. Provider fallback implementations can still ignore cancellation. | +| P2 | `Tests/Cluster/TestClusterApplication.cs` synchronously deploys `TestCluster`; tests use wall-clock waits and activation collection. | Move fixture startup to asynchronous `InProcessTestClusterBuilder`/deployment in an isolated test-infrastructure change. Use Orleans' per-area time-provider helpers to control grain time without accelerating membership/background services. Add cancellation, restart, and write-failure tests. The native .NET limiters still own their clocks, so injecting a fake clock into Orleans alone does not make every limiter wait deterministic. Measure suite duration before claiming a speedup. | +| P2 | `RateLimiterGrain.cs` retains successful native leases for all algorithms and disposes them through a later RPC. Only concurrency leases return capacity on disposal. | Benchmark a release-free path for fixed/sliding/token algorithms, keeping concurrency ownership unchanged. Measure retained objects, RPCs/request, allocations, and latency before adopting it. Review LeaseId semantics and old-client disposal compatibility; do not silently reinterpret a public metadata field solely as a performance edit. | +| P2 | Logging exists, but the package does not publish its own acquisition/queue/flush metrics. Orleans 10.3 changed RPC activity tags. | Add a bounded-cardinality `System.Diagnostics.Metrics` meter for allowed/rejected/cancelled acquisitions, queue duration, active concurrency permits, and storage flush failures. Tag algorithm and outcome; never raw user, IP, tenant, or grain keys. Test counters with `MeterListener`; update consumer telemetry queries to current Orleans tags. Avoid bundling a dashboard or telemetry backend into the library. | +| P2 | Persisted quotas are aggregate snapshots with an update timestamp. Fixed/sliding restoration is conservative and abrupt crashes can lose unflushed changes. | Keep the documented best-effort rate-limit semantics. Add boundary tests for replenishment, clock movement, extreme arithmetic, and rolling upgrades with persisted state. Exact sliding-segment restoration or crash-durable billing requires a separate versioned state design and performance budget; new journaling/transaction APIs do not provide that automatically. | + +The implementation adds separate optional cancellation capabilities while retaining +old RPCs and holder interfaces. Updated silos must precede callers of the new RPCs. +Cluster-wide timeout defaults remain unchanged. The API baseline and generated RPC +manifest diff check compatibility; they do not prove arbitrary mixed-version deployment. + +## Version-specific evidence + +### Native cancellation: use the runtime contract, not a timeout assumption + +Orleans supports standard `CancellationToken` on grain calls. In the **10.3.1 +source**, `MessagingOptions.CancelRequestOnTimeout` defaults to `false`. The Learn +cancellation page still describes a default of `true` in its timeout section. +Prefer the tagged source for the installed release. Our holder catches +`TimeoutException` and returns rejection, but that does not by itself remove a +remote queued acquisition. Even explicit cancellation is cooperative and requires +observing the token and handling acquisition/response races. + +Sources: [cancellation API](https://learn.microsoft.com/en-us/dotnet/orleans/grains/cancellation-tokens), +[10.3.1 MessagingOptions](https://github.com/dotnet/orleans/blob/v10.3.1/src/Orleans.Core/Configuration/Options/MessagingOptions.cs). + +### Contract protection is a useful new capability to adopt immediately + +The 10.3.1 analyzer regenerates manifests project-wide, records effective RPC +identities, retains retired declarations, and detects drift. It is disabled unless +`EnableOrleansContractsAnalyzer` is set. Core and Server now opt in; Client has no +owned grain contracts, and the test-only contracts are intentionally outside the +published compatibility baseline. No analyzer dependency was added because the +existing Orleans packages already supply it. + +Source: [10.3.1 compatibility analyzer guide](https://github.com/dotnet/orleans/blob/v10.3.1/docs/site/src/content/docs/grains/grain-versioning/contract-compatibility-analyzer.md). + +### .NET package validation closes a different compatibility gap + +The SDK's baseline package validator is an existing capability, not a new .NET 10 +API. It complements RPC identity checks by comparing public/protected .NET members +with the published package. Adding an optional parameter can still be a binary +break, which is particularly relevant to future cancellation APIs. Keep original +methods and use a reviewed additive/versioned design. + +Source: [baseline package validator](https://learn.microsoft.com/en-us/dotnet/fundamentals/apicompat/package-validation/baseline-version-validator). + +### Persistence and testing gained APIs, but host policy still matters + +Orleans 10.3 adds cancellation-aware/concurrency-safe persistent-state operations. +`IStorage` retains parameterless APIs and adds token overloads with compatibility +fallbacks. TestingHost exposes `InProcessTestClusterBuilder` and +`UseTimeProviderForBackgroundAreas`; use the actual public type names rather than +inferring them from source filenames. `TimeProvider` itself predates .NET 10. + +Sources: [storage contract](https://github.com/dotnet/orleans/blob/v10.3.1/src/Orleans.Core.Abstractions/Core/IStorage.cs), +[test builder](https://github.com/dotnet/orleans/blob/v10.3.1/src/Orleans.TestingHost/InProcTestClusterBuilder.cs), +[clock isolation helper](https://github.com/dotnet/orleans/blob/v10.3.1/src/Orleans.TestingHost/TimeProviderTestingExtensions.cs). + +### Current architecture choices to retain + +- `IPersistentState` and generated serializers are the supported building blocks. + Keep storage-provider selection in the consuming host. Orleans 10.3's stricter + JSON type allow-list needs provider-level migration tests; do not turn on + `AllowAllTypes` to avoid those tests. +- `RegisterGrainTimer` with `KeepAlive = false` fits activation-local batched flushes. + Durable reminders/jobs would change lifecycle and storage cost. They are useful + for durable scheduled business work, not a mandatory replacement for this timer. +- `[AlwaysInterleave]` permits releases to progress while acquisitions queue. Do not + mark the entire grain reentrant or remove synchronization without proving the + configuration/acquisition/flush invariants. +- Orleans' current placement/directory improvements are already obtained through + the runtime update. Do not add custom placement or sharding before measuring a + hot shared key; naive sharding would multiply the effective quota. +- TUnit + Shouldly + Microsoft.Testing.Platform remains the repo's test stack. + Upstream Orleans using xUnit internally is not a reason to migrate this library. +- Built-in ASP.NET Core rate limiting is useful as a local admission layer. A + transparent distributed `RateLimiter` adapter needs an explicit design for its + synchronous fast path; never introduce sync-over-async Orleans calls to fit it. + +Sources: [Orleans 10.3 release notes](https://github.com/dotnet/orleans/releases/tag/v10.3.0), +[timer semantics](https://learn.microsoft.com/en-us/dotnet/orleans/grains/timers-and-reminders), +[.NET 10 library changes](https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-10/libraries). + +## Adoption sequence + +```mermaid +flowchart LR + Baseline["RPC + NuGet API baselines — implemented"] --> Cancel["Compatible cancellation API + queue/lease tests"] + Cancel --> Host["HTTP and SignalR cancellation propagation"] + Host --> Observe["Bounded metrics + workload benchmark"] + Testing["Async test cluster + controlled clocks/storage faults"] --> Cancel + Testing --> Persistence["Cancellation-aware persistence + failure tests"] + Observe --> Optimize["Measured lease/RPC optimizations"] +``` + +## Verification and boundaries + +The missing manifest produced `ORLEANS0020` as an error. The manifests were +regenerated by the upstream code fix, not hand-authored. An intentional temporary +RPC ID change produced `ORLEANS0018`; source was restored immediately afterward. +This proves the configured gate actually rejects wire-identity drift. Regeneration +changed only the manifests, with no source identity edits. + +CLI bootstrap detail: on this SDK, the initial format operation did not persist a +new AdditionalFile. Creating the empty destination first allowed the official code +fix to populate it. The checked-in manifests make subsequent regeneration normal. +Review generated diffs instead of regenerating baselines automatically in CI. + +This is a repository and upstream-API review. The ranked items above are proposed +follow-up slices, not claims that cancellation, metrics, exact durability, or a +new test host have already been implemented. Production hosting/provider behavior +and throughput have not been benchmarked in this review. + +Local verification: all 127 tests passed after enabling the Orleans guard. Build +and analyzer builds have zero warnings/errors, format verification passes, all +three packages pass the published 10.1.0 API baseline, and `actionlint` passes. +The two mutation controls failed for the intended diagnostic (`ORLEANS0018` and +`CP0002`) and all temporary source changes were restored. Production behavior was +not changed in this modernization pass; the prior full coverage result is 93.34%. +GitHub CI additionally runs the full suite and coverage for the final pushed SHA. diff --git a/docs/PerformanceAndTimeouts.md b/docs/PerformanceAndTimeouts.md new file mode 100644 index 0000000..a62a2e7 --- /dev/null +++ b/docs/PerformanceAndTimeouts.md @@ -0,0 +1,274 @@ +# Performance and timeout validation + +## Scope + +Baseline: `705b47d`. This work addresses acquisition/release throughput, p99 latency, +allocations, abandoned lease retention and bounded queue/configuration waits. Measured +performance takes priority over compatibility scaffolding. The immediate acquisition +path must not create client deadline timers or force distributed cancellation for a +caller whose token cannot be cancelled. No runtime dependency is added. + +## Server-enforced budgets and the fast path + +`AddOrleansRateLimiting` registers a timeout provider. The generated bounded grain proxy +uses it to supply a scalar `TimeSpan` budget before dispatch. It uses 80% of the explicit request response timeout or +hosting runtime response timeout, reserving headroom before Orleans expires the callback. +A caller-supplied shorter budget is preserved. Client/service-collection registration +is idempotent; co-hosted applications use one silo provider regardless of registration order. +The provider is resolved once when the reference is constructed. No global outgoing +filter, per-call service lookup, timer or linked cancellation source is introduced. + +The proxy uses Orleans' public `GenerateMethodSerializers` and `DefaultInvokableBaseType` +extension points. This preserves the runtime's direct dispatch path for calls without +filters, including release and unrelated grain calls. A global filter would force all +of them through an additional invoker and asynchronous pipeline; see the +[Orleans 10.3.1 dispatch implementation](https://github.com/dotnet/orleans/blob/v10.3.1/src/Orleans.Core/Runtime/GrainReferenceRuntime.cs). +The proxy's non-generic forwarding overload is required by source-generator validation; +bounded acquisition contracts themselves all return metadata through the generic overload. + +Holders cache their bounded grain reference. `CancellationToken.None` and overloads +without a token use RPCs without a cancellation argument. Cancellable callers use the +native Orleans token path, preserving HTTP abort and SignalR disconnect propagation. +The server first tries an immediate acquisition. If quota is available, the steady-state +path uses no deadline timer. A disabled native queue rejects immediately too. Timed +semaphore waits, queued acquisitions and configuration replacement establish timeout +machinery only on those slower paths. Queue success preserves the library's counters. + +A monotonic server clock tracks the remaining budget across configuration and acquisition. +A queue timeout removes its native waiter locally, without depending on a subsequent +client cancellation message. A cancelled configuration wait cannot reset quota. An expired +budget cannot acquire or replace configuration. Late acquired concurrency permits are +released before the server reports expiration. Holder deadlines return rejected leases; +caller cancellation remains `OperationCanceledException`. + +The relative budget starts when the server method runs. Activation/routing time is still +part of Orleans' separate client response timeout, so this is not a globally synchronized +absolute deadline. Atomic state mutation and providers which ignore cancellation cannot +be forcibly interrupted. Network partitions, lost replies and client crashes do not gain +a distributed concurrency-lease expiry guarantee. Concurrency permits still need explicit +release, and persistence retains its best-effort snapshot boundary. + +The original queue leak was reproduced using a one-second response timeout with +`CancelRequestOnTimeout=false`. Cancelling only after `TimeoutException` is too late for +Orleans' removed callback registration; see the +[10.3.1 callback implementation](https://github.com/dotnet/orleans/blob/v10.3.1/src/Orleans.Core/Runtime/CallbackData.cs#L140). +Global Orleans timeout/cancellation defaults and unrelated grain calls are unchanged. + +```mermaid +flowchart TD + Holder[Cached holder reference] --> Choice{Caller token cancellable?} + Choice -->|No| FastRPC[Bounded RPC without token protocol] + Choice -->|Yes| CancelRPC[Bounded RPC with caller token] + FastRPC --> Budget[Scalar server wait budget] + CancelRPC --> Budget + Budget --> Config{Configuration change or contention?} + Config -->|Yes| TimedConfig[Bounded configuration wait] + Config -->|No| Attempt[Immediate native attempt] + TimedConfig --> Attempt + Attempt -->|Granted| Lease[Return lease] + Attempt -->|Queue disabled| Reject[Rejected lease] + Attempt -->|Must wait| Queue[Local deadline and native queue] + Queue -->|Expired| Cleanup[Remove waiter and reject] + Queue -->|Granted| Lease +``` + +## Lease allocation and release + +Known fixed-window, sliding-window and token-bucket native leases do not refund quota +on disposal. Their grains dispose the native lease immediately and add metadata field 4, +`IsReleaseOptional=true`, retaining a non-empty lease id. Updated clients omit the release +RPC, and abandoned time-based leases no longer accumulate in a server dictionary. +Concurrency and custom base-grain implementations default to explicit release. + +Holders reuse their existing grain reference for concurrency release when the returned +owner id matches, avoiding a second grain-reference lookup/cast per operation. Publicly +constructed leases and different returned owner ids retain the factory-based fallback. +Empty metadata uses a shared immutable dictionary instead of allocating a dictionary for +every successful lease. + +All 10.2 holder acquisitions require the bounded capability; deploy updated silos before +updated clients. Existing RPC identities remain available, with direct quota/ownership +regressions. Old metadata without field 4 still requires release. Tests read an actual +baseline payload; a separate unmodified-baseline assembly probe also read the new field +successfully. Core-only holder construction needs Client/Server registration for an +automatically supplied budget. Direct legacy RPCs do not carry that budget. +Custom grain implementations used with built-in 10.2 holders must implement the generic +bounded capability as well; there is no runtime fallback to an unbounded legacy RPC. + +## Reproduce the measurements + +Performance source is opt-in. Ordinary test builds exclude `Performance/**/*.cs`. +Run without coverage instrumentation and serialize benchmark/build/test commands: + +```sh +dotnet build ManagedCode.Orleans.RateLimiting.sln -c Release -p:RunPerformanceTests=true +RATE_LIMIT_PERFORMANCE_OUTPUT=/tmp/rate-performance.json dotnet test \ + --project ManagedCode.Orleans.RateLimiting.Tests/ManagedCode.Orleans.RateLimiting.Tests.csproj \ + -c Release --no-build -p:RunPerformanceTests=true \ + --treenode-filter '/*/*/PerformanceBenchmarkTests/*' +``` + +Set `RATE_LIMIT_PERFORMANCE_ALGORITHM=concurrency` for an isolated concurrency comparison. +Also set `RATE_LIMIT_PERFORMANCE_CALLER_TOKEN=true` to measure a real cancellable token. +That lane reuses a caller-owned token source per case, measuring library/RPC costs without +including construction of a new caller token source in every operation. The token is not +cancelled during throughput measurement; functional tests exercise actual cancellation. +Set `RATE_LIMIT_PERFORMANCE_WARM_WORKLOAD=true` to additionally execute a complete +unmeasured repetition with the same worker count and disposal behavior before measurement. +The JSON `WarmWorkload` flag records this; reports predating that flag used only the +per-holder sequential warm-up described below. + +Rebuild without `RunPerformanceTests` before ordinary tests or coverage. Export `705b47d` +into a separate directory and copy only the identical `Performance` source and its +conditional compile item for baseline comparison. Restore/build/test consistently under +the canonical `/private/tmp/...` path on macOS. An archive has expected SourceLink warnings +because it lacks Git metadata; the real checkout must still build without warnings. + +The harness uses a dedicated two-silo in-process Orleans fixture, memory persistence +and a 50 ms snapshot flush period. Only silo 0 exposes a gateway, verified through the +actual gateway provider. Placement hints activate every measured grain on silo 0; the +hint is removed before warm-up. The harness asserts placement before and after every +repetition. Sixteen partitions therefore measure sixteen grains on the same silo. This +keeps the RPC route identical across revisions instead of comparing random gateway hops. Each full-matrix case warms 4,096 operations per holder and +measures three repetitions of 32,768 successful acquisition/disposal operations. Scenarios +are sequential, 16 workers on one key, and 16 workers on separate keys. The separate +abandoned time-lease scenario skips disposal. Isolated concurrency cases warm 65,536 +operations on a single key, retaining 4,096 per holder for the partitioned scenario. + +JSON `Cancellable` selects the overload; `TokenCanBeCanceled` distinguishes an actual +cancellable token from `CancellationToken.None`. Reports retain all repetitions, ops/s, +p50/p95/p99 latency, process allocated bytes per operation, retained bytes after full GC, +and GC collection counts. Allocation includes Orleans, the fixture and background storage. +Retained deltas include pooling/background activity, so negative deltas are possible. + +These are local measurements on a shared development machine, without confidence intervals +or a production network/storage provider. Timing varies; RPC counts, avoided per-request +objects, queue cleanup and quota assertions provide additional deterministic evidence. + +## Initial iteration measurements + +The reports in this section measure `b6efd7e`, the scalar-budget implementation before +moving budget injection out of the global filter into the bounded proxy. They document +the reason for the final proxy change. Use the latest isolated CI run for final-code results. + +Medians of three repetitions on .NET 10.0.11, macOS 26.6.2, arm64 (12 logical processors). +Baseline is `705b47d`; updated is this change. The full matrix uses the same verified +single-gateway route and placement for both revisions. These are observed differences, +not statistically established capacity guarantees. + +| Algorithm / 16 workers on one key | Overload | Baseline ops/s | Updated ops/s | Change | p99 baseline → updated (ms) | Allocated B/op baseline → updated | +| --- | --- | ---: | ---: | ---: | ---: | ---: | +| concurrency | No token | 58,367 | 60,579 | +3.8% | 1.202 → 0.918 | 6,583 → 6,535 | +| concurrency | Token.None | 57,873 | 59,655 | +3.1% | 1.263 → 1.043 | 9,199 → 6,315 | +| fixed | No token | 58,693 | 103,855 | +76.9% | 1.257 → 0.437 | 6,098 → 3,877 | +| fixed | Token.None | 59,844 | 105,938 | +77.0% | 1.206 → 0.372 | 9,278 → 3,757 | +| sliding | No token | 62,441 | 106,058 | +69.9% | 0.997 → 0.378 | 6,503 → 3,761 | +| sliding | Token.None | 58,425 | 106,600 | +82.5% | 1.153 → 0.366 | 9,266 → 3,797 | +| token | No token | 62,187 | 111,312 | +79.0% | 0.971 → 0.331 | 6,362 → 3,739 | +| token | Token.None | 62,216 | 109,350 | +75.8% | 1.008 → 0.287 | 9,280 → 3,835 | + +The separate real-caller-token concurrency lane measured hot-key throughput +59,913 → 62,137 ops/s (+3.7%), p99 1.112 → 0.908 ms and allocated bytes/operation +9,645 → 6,682. Sequential throughput was +0.9%; partitioned throughput was +9.8%. + +The isolated concurrency lane had an anomalously slow baseline hot-key median +(32,995/31,533 ops/s versus 58,367/57,873 in the full matrix). Its apparent 91–100% +improvement is **not** used as the expected performance gain. Its raw results are +retained so this run-to-run variation remains visible. + +The full matrix also contains regressions: sequential concurrency with `Token.None` +was -8.9% (the isolated lane was +11.8%); abandoned fixed/sliding leases without a token +were -17.7%/-7.8%. Abandoned leases intentionally skip disposal and omit the release-RPC +saving. These observations prevent a claim that every scenario improved. The abandoned +case also checks retained memory, while functional tests prove that time-based leases +no longer require release and still consume quota. + +Repeating the full matrix in reversed revision order retained the strong time-limiter +gains, but concurrency timing remained variable: hot-key throughput was -3.2%/+3.3% +for no-token/`Token.None`, sequential -22.5%/-20.9%, and partitioned -0.6%/-25.0%. +The first measured workload used only sequential warm-up, including before parallel +cases. These results prompted the additional workload-warmed ABBA validation below; +they are retained rather than discarded. + +The local workload-warmed ABBA lane still varied: no-token hot-key throughput was -9.0% +and sequential -29.5%, while `Token.None` hot-key was +3.7% and sequential +15.1%. +During this lane, the machine reported load averages 29.36/28.81/20.48 with 12 logical +CPUs; unrelated browser tests, Chromium and a Gateway process were active. Those user +processes were left running. These contended measurements cannot establish either a +reliable speedup or the absence of regressions. The isolated CI comparison below is the +release assessment lane; local timings are diagnostic observations. + +Warmed raw samples: [baseline A](performance/2026-09-07-warmed-0-baseline.json), +[updated B](performance/2026-09-07-warmed-1-updated.json), +[updated B repeat](performance/2026-09-07-warmed-2-updated.json), +[baseline A repeat](performance/2026-09-07-warmed-3-baseline.json). + +All repetitions, including unfavorable results: + +- [Full matrix baseline](performance/2026-09-07-baseline.json) and [updated](performance/2026-09-07-updated.json). +- [Isolated concurrency baseline](performance/2026-09-07-concurrency-baseline.json) and [updated](performance/2026-09-07-concurrency-updated.json). +- [Real caller token baseline](performance/2026-09-07-caller-token-baseline.json) and [updated](performance/2026-09-07-caller-token-updated.json). +- [Reversed full matrix baseline](performance/2026-09-07-repeat-baseline.json) and [updated](performance/2026-09-07-repeat-updated.json). + +## Isolated CI comparison + +The first [isolated run](https://github.com/managedcode/Orleans.RateLimiting/actions/runs/34155639259) +tested `b6efd7e` on a four-CPU Ubuntu 24.04 runner with .NET 10.0.11. No-token hot-key +throughput matched baseline (+0.3%, p99 1.316 → 1.279 ms), but the `CancellationToken.None` +overload still lost 8.4% throughput. Real cancellable-token hot-key throughput was -2.8%. +This remaining cost prompted replacement of the global filter with the bounded proxy. +The full first-run data is retained in `performance/2026-09-07-first-ci-*.json`. + +Manually dispatch the existing `CI` workflow on the feature branch. Its performance job +uses its own Ubuntu runner, separate from functional tests and coverage. The baseline +is pinned in `BASELINE_REF` to the pre-change commit; update that reviewed value when +establishing a future release baseline. + +Validation sequence: + +1. Export baseline production sources with `git archive` and copy only the current benchmark harness. +2. Build both revisions without coverage instrumentation; expected archive SourceLink warnings do not affect the benchmark. +3. Run concurrency baseline → updated → updated → baseline on that one runner. Each sample + exercises both `CancellationToken.None`/no-token overloads and a real cancellable caller token, + with exact-workload warm-up and verified gateway/placement. +4. Aggregate all six measured repetitions per case using `scripts/summarize-performance.py`. + Review throughput, p99 and allocation together; no samples are removed and no timing-based + pass threshold masks a regression. +5. Publish the comparison in the job summary and every raw JSON in the + `performance-comparison` artifact (30 days). Exact-run results accompany the PR checks. + +Run with `gh workflow run ci.yml --ref codex/security-rate-limit-10-2` for this release. +Linux runner numbers are compared within that runner, never directly to the macOS values above. + + +## Verification + +- 187/187 ordinary tests pass, without skips; this follow-up adds 38 to the 149-test baseline. + Coverage instrumentation also passes all 187 tests. +- Total line coverage is 93.78% (Client 93.77%, Core 94.24%, Server 93.25%); the 85% gate + is unchanged. Timeout providers have 100% coverage. Acquisition, deadline, holder + cancellation and lease files remain above 90%. +- The 16 timeout storms each queue 16 requests across all four algorithms, both overload + choices and configured/unconfigured calls. They complete in 800.9–810.0 ms against a + one-second client response timeout with `CancelRequestOnTimeout=false`. All queues + empty, and concurrency capacity remains available afterward. The separate silo-origin + case uses its 500 ms messaging timeout. +- Regressions cover queued success/counters, expired configuration replacement, an + explicitly expired budget, actual cancellable-RPC selection, release RPC counts, + older metadata and both legacy configuration-RPC overloads. +- Release/analyzer builds have zero warnings/errors. Ordinary and opt-in benchmark + formatting pass. Direct/transitive NuGet vulnerability audit and actionlint pass. +- All three 10.2.0 packages and symbols pack successfully, including public API validation + against published 10.1.0. Published RPC identities are retained. The new bounded proxy + and its invoker identities are introduced together in the unpublished 10.2 release; + intermediate branch builds are not a rolling-upgrade compatibility baseline. + +### Generated-proxy coverage exception + +`BoundedRateLimiterGrainReference` has 80% line coverage: its required non-generic +forwarding overload is unreachable from the registered bounded interfaces, which all +return metadata. Every sequence point on their constructor/generic dispatch path is +covered by real RPC tests. The forwarding line remains included in total coverage; +there are no exclusions or weaker assertions. This narrow exception avoids adding an +artificial public RPC solely to execute generator scaffolding. Remove the overload and +exception when Orleans' proxy validation accepts inherited dispatch overloads. diff --git a/docs/ProjectReview.md b/docs/ProjectReview.md index 1eb7915..5c3d559 100644 --- a/docs/ProjectReview.md +++ b/docs/ProjectReview.md @@ -1,6 +1,13 @@ # Project Review -Date: 2026-05-05 +Current review: [September 2026 .NET/Orleans modernization](ModernizationReview-2026-09.md). +Security changes and verification: [10.2.0 security review](SecurityReview-10.2.0.md). + +The May notes below are historical. Persistent quota snapshots and named policies +have since been implemented; they are not outstanding missing features. Use the +current architecture and modernization review for present decisions. + +## Historical review — 2026-05-05 ## Findings diff --git a/docs/SecurityReview-10.2.0.md b/docs/SecurityReview-10.2.0.md new file mode 100644 index 0000000..f1bc6d0 --- /dev/null +++ b/docs/SecurityReview-10.2.0.md @@ -0,0 +1,248 @@ +# Security review: 10.2.0 + +Date: 2026-09-07. Baseline: `e6db662` (10.1.0). + +## Scope and delivery plan + +Fix GHSA-mfr4-hpp8-752w and reproducible adjacent rate-limit bypasses; review +HTTP/SignalR identity extraction, partition isolation, holder accounting, lease +lifecycle, grain configuration/persistence, and dependency advisories. Update all +central NuGet and local tool pins to the latest stable versions and bump the +package minor version. No new runtime dependencies or grain interface changes. + +Verification order: failing regression tests, focused security tests, complete +TUnit suite, Release build/analyze, `dotnet format` verification, coverage with the +existing 85% gate, NuGet vulnerability/outdated audit, package creation, diff +review, then authorized commit/push. Use the dotnet/orleans/project-setup skills +for implementation and format/quality-ci/complexity guidance for final checks. +Complexity remains governed by root AGENTS.md limits; this repo has no configured +CA1502/CodeMetricsConfig gate. New test classes and methods remain within those +limits. Existing oversized grain partials are outside this change. + +## Fixed findings + +| ID | Impact and trigger | Correction and evidence | +| --- | --- | --- | +| SEC-01 / GHSA-mfr4-hpp8-752w | High: rotating untrusted forwarded IP headers gives a fresh HTTP limiter partition. | Parameterless IP resolution uses only `Connection.RemoteIpAddress`. Real TestServer requests through all five HTTP modes exercise three spoofed header names, multiple values, and real Orleans quota enforcement. Trusted-proxy and unknown-proxy tests exercise standard ASP.NET Core forwarding. | +| SEC-02 | IP limits could be skipped when a transport supplies no connection address; mapped/native IPv4 representations split identity. | A shared `unknown-ip` fallback keeps IP limiting active; normalize IPv4-mapped IPv6. HTTP and real SignalR reconnection tests cover missing addresses and forged headers; a resolver test covers mapped IPv4. | +| SEC-03 | Missing attribute configuration silently allowed protected HTTP endpoints to run. | Throw `RateLimitConfigurationNotFoundException` before endpoint execution. Tests cover IP, anonymous, authorized, and role attributes. | +| SEC-04 | The holder overload receiving a permit count and null options acquired one permit regardless of the requested cost. | Forward the requested permit count to acquisition. Test all four limiter types against real grains, assert no quota remains and the next acquisition is rejected. | +| SEC-05 | Stacked IP and anonymous HTTP attributes with different configurations selected the same grain, repeatedly resetting each other's quotas. | Include a normalized configuration name in the HTTP attribute grain key. A combined-attribute endpoint must reject its second request despite the different permit limits. | + +## Trust boundaries and migration + +```mermaid +flowchart LR + Client["Untrusted HTTP/SignalR client"] --> Proxy["Host-owned trusted proxy processing"] + Proxy --> Address["Connection address, canonicalized"] + Address --> Partition["Stable partition + configuration"] + Identity["Host-validated identity/custom policy"] --> Partition + Partition --> Grain["Trusted Orleans cluster"] + Grain --> Decision["Acquire or reject before protected work"] +``` + +- Configure `KnownProxies`/`KnownIPNetworks` and a bounded `ForwardLimit` in the + consumer app; run forwarded headers before authentication/rate limiting. + Do not enable blanket forwarding from arbitrary clients. The legacy explicit + `GetClientIpAddress(headers)` overload preserves compatibility and requires the + caller to independently establish header trust. No built-in integration uses it. +- HTTP attribute keys now include configuration identity. Old keys are not migrated; + rollout starts new counters for those policies. Changing raw-header defaults + likewise moves affected clients to their connection-derived partition. Deploy + all application nodes consistently to avoid splitting quotas between versions. +- Authentication and authorization remain the consuming application's responsibility. + Optional user/group/tenant/custom rules intentionally skip absent keys; mark them + required where necessary and combine them with IP/shared limits for anonymous + traffic. Do not derive trusted identities from unchecked request headers. +- Core grain APIs, including configure, reset, delete, manual replenishment, and + lease release, assume trusted cluster callers. Do not expose them as arbitrary + public HTTP operations. Lease IDs are capabilities and must remain server-side. +- Composite key escaping already protects `:` and `%` boundaries. Existing + collision tests remain in the suite. Custom metadata cardinality and policy + selection are controlled by the host; use stable authenticated keys and combine + per-identity limits with shared limits to bound abuse from many identities. +- Group disposal and middleware `await using` release acquired concurrency leases; + rejection and timeout paths fail closed. Persistent concurrency leases require + eventual disposal by trusted callers. Quota snapshots flush periodically, so an + abrupt silo crash can lose the latest unflushed quota. This is an existing + availability/durability tradeoff, not an exact durable billing ledger. + +This is a source review and local integration verification, not a production +penetration test or a guarantee that no other vulnerabilities exist. The private +advisory itself is not published or modified by this change. + +## Dependencies + +Latest stable NuGet registry versions were checked for every central pin and local +tool. Updated 20 central package pins and all three local tools. Orleans is 10.3.1, +Microsoft runtime/test-host packages are 10.0.11, TUnit is 1.66.27. Three central pins +were already current. NuGet reported no outdated direct packages and no known +vulnerable direct/transitive dependencies after restore. + +## Verification + +Status: complete for local verification on .NET SDK 10.0.400 (macOS arm64). + +- Regression evidence: the first 14 security cases failed before their corrections; + the separate stacked-policy regression also failed before configuration key isolation. +- Focused final security suite: 17 passed, 0 failed, 0 skipped. +- Complete final suite under Coverlet: 89 passed, 0 failed, 0 skipped (2m 24s). +- Earlier complete suite before the final isolated-key correction: 88 passed. +- Release build and analyzer build: 0 warnings, 0 errors. +- `dotnet format ManagedCode.Orleans.RateLimiting.sln --verify-no-changes`: passed. +- Coverage: 92.17% total lines (85% gate), 75.93% branches, 93.49% methods. + Client: 93.75%; Core: 92.03%; Server: 91.51% line coverage. +- Coverage report generated successfully with the updated ReportGenerator tool. +- NuGet audits: no outdated direct packages; no known vulnerable direct/transitive packages. +- Pack: Core, Client, and Server 10.2.0 packages and symbol packages created successfully. +- Diff whitespace and documentation links checked. No production deployment or + NuGet publication is part of this local verification snapshot. + +Commands are the root AGENTS.md commands. Focused tests additionally use +`--treenode-filter '/*/*/*SecurityTests/*'`. Source review and fixes used the +Orleans skill; package/tool updates used project-setup; formatting followed format; +quality-ci/complexity guidance was applied without adding or lowering quality gates. +CI initially passed both full test runs but produced an empty coverage report +because deterministic PDB paths use `/_/`. The same behavior was reproduced +locally with `-p:ContinuousIntegrationBuild=true`. The workflow now passes a +source-root mapping to Coverlet, as documented in its +[path mapping guide](https://github.com/coverlet-coverage/coverlet/blob/master/Documentation/GlobalTool.md#path-mappings), +so instrumentation resolves the actual checkout without changing exclusions or +the coverage gate. A focused deterministic build verified all 17 security cases +and produced coverage for all three production modules. The SignalR test endpoint +also reads the live connection state to satisfy CA1822 while remaining an instance +hub method. The complete deterministic-build coverage run then passed all 89 +tests and measured the same 92.17% line coverage. No remaining local verification +failures. + +## Follow-up review: grain policies and lease ownership + +Baseline: `152ba74`. The first pass added 17 security cases in four files. This +follow-up adds 22 cases in three further test classes, including real Orleans +calls and deterministic coordination of real concurrency leases. Package version +remains 10.2.0 because this release branch has not been published. + +| ID | Confirmed issue | Fix and regression evidence | +| --- | --- | --- | +| SEC-06 | Grain call filters used the same limiter identity for different named or inline configurations. Alternating protected operations reset time quotas or released an in-flight concurrency permit. | Named keys include the normalized configuration name. Inline keys encode all relevant options, while identical inline options still share quota. Tests cover all four limiter kinds, including nested protected calls while a concurrency permit remains held. | +| SEC-07 | An explicit key partition without a key, or an explicitly empty configuration name, silently disabled grain call limiting. | Invalid keys throw before invoking application code; empty named configurations throw the existing configuration exception. Tests exercise all four limiter kinds. This closes misconfiguration-driven bypasses; these attributes are host-owned metadata. | +| SEC-08 | Rate-limit attributes on grain interface methods were ignored. | Resolve implementation-method attributes first, then interface-method attributes, then implementation-class attributes. Actual interface-declared calls are limited for all four algorithms. | +| SEC-09 | Overlapping group acquisition could overwrite a lease; disposal could complete before a pending lease arrived. An acquisition exception retained earlier permits until a later disposal. | Reserve group ownership before awaiting, disallow mutation during acquisition/ownership, roll back partial acquisitions on exceptions, and let every disposal caller await the same cleanup. Six lifecycle tests exercise real concurrency quotas using a gated response wrapper; no limiter outcomes are mocked. Rejected groups remain retryable after rollback. This is availability hardening for callers sharing or concurrently disposing groups, rather than a standalone anonymous HTTP exploit. | +| SEC-10 | Library exceptions lacked generated Orleans codecs. Rejected grain calls surfaced `CodecNotFoundException` instead of their intended exception and retry metadata. | Generate serializers with explicit member IDs for the three exception types. Round-trip tests serialize them polymorphically as `Exception` and assert retained reason, retry interval, configuration name, and partition kind. This repairs the rejection contract without relaxing admission. | + +```mermaid +flowchart LR + Metadata["Implementation method → interface method → class"] --> Key["Validate key and named configuration"] + Key --> Policy["Partition + named configuration or inline options"] + Policy --> Acquire["Acquire group; reserve lifecycle ownership"] + Acquire -->|"Reject or exception"| Rollback["Release previous permits"] + Acquire -->|"Success"| Work["Protected work"] + Acquire -->|"Concurrent disposal"| Wait["Wait for pending acquisition"] + Work --> Release["Release owned permits"] + Wait --> Release +``` + +Migration: named and inline grain-attribute keys now start fresh counters, just +as the HTTP key correction did. Upgrade clients and silos consistently. Default +silo-option keys and externally implemented filter option types retain their +existing key behavior. No grain method signatures, runtime dependencies, or +persistence layouts changed. The serializers add previously missing wire support; +all communicating nodes should run the updated library to preserve typed errors. + +Scope remains source review and local integration verification. Proxy trust, +authentication, high-cardinality custom keys, cluster administration, and abrupt +crash durability retain the boundaries described above. Configuration/update APIs +are trusted application capabilities. No claim is made of an exhaustive security +proof or production penetration test. + +Follow-up verification: + +- All 39 focused security cases passed; 22 were added in this follow-up. +- The full suite passed all 111 tests, with zero failures and zero skipped tests. +- Coverlet line coverage is 93.12% overall (Core 93.35%, Client 93.75%, Server + 92.44%). Policy-key construction and the three exception types have 100% line + coverage; the changed filter and group holder meet the 90% critical-contract + target. No thresholds or exclusions were weakened. +- Release build, analyzer build, formatting verification, coverage report, and + packaging all passed. Build and analyzer output contain zero warnings/errors. +- NuGet audit reported no known vulnerable direct or transitive packages. +- Regression controls ran the new tests against the previous implementations: + they reproduced policy bypasses, ignored metadata, lease leaks, and missing + exception codecs before applying the fixes. The retry-after-rejection test also + preserves an existing supported behavior. + + +## Completion review: rejected updates and workflow permissions + +Baseline: `f6ed127`. Plan: reproduce failed configuration replacement and negative +permit-count side effects through all four real grain types, preserve the existing +limiter when input validation fails, then verify the focused and full suites, +coverage, analyzer build, formatting, and packages. Also fix the remaining CodeQL +`actions/missing-workflow-permissions` finding in the analysis workflow and replace +outdated Actions with current releases. Validate CI and CodeQL on the pushed SHA. +No public grain signatures, publishing triggers, package identities, or runtime +dependencies change. Configure/reset operations remain trusted application APIs; +the correction prevents invalid requests from corrupting their existing state. + +| ID | Confirmed issue | Correction | +| --- | --- | --- | +| SEC-11 | Invalid configuration replacement disposed the working limiter before validating its replacement. Invalid permit counts could commit changed configuration and reset quota before rejecting acquisition. | Build and validate the candidate before disposing the old limiter; reject negative counts before configuration work. Sixteen real Orleans regression cases cover both update entry points and negative/excessive counts across all four algorithms, asserting retained configuration, exhausted quota, and concurrency lease ownership. All sixteen failed before the correction and passed afterward. | +| SEC-12 | CodeQL alert 5 identified implicit token permissions in the analysis workflow. CI also used deprecated Actions runtimes. | Set read-only workflow defaults and grant only contents read/security-events write to analysis jobs. Scan C# and Actions. Update official checkout/setup/artifact actions and Codecov, quote shell inputs, and pass dynamic release values/secrets through environment variables. Publishing conditions and behavior are unchanged. | + +```mermaid +flowchart LR + Request["Configuration + optional permit count"] --> Validate["Construct and validate candidate"] + Validate -->|"Invalid"| Preserve["Reject; retain old options, quota, leases"] + Validate -->|"Valid"| Replace["Dispose old limiter; install candidate"] + Replace --> Persist["Persist configuration"] +``` + +The existing protected factory reads candidate options synchronously; they are +restored in `finally` before any await. This preserves subclass signatures. The +existing oversized generic grain type remains a documented maintainability +exception; replacement logic is isolated in a new partial file of 50 lines. +Future work can split the remaining lifecycle partial without changing contracts. + +Actions releases were verified against upstream releases: +[checkout 7.0.1](https://github.com/actions/checkout/releases/tag/v7.0.1), +[setup-dotnet 6.0.0](https://github.com/actions/setup-dotnet/releases/tag/v6.0.0), +[upload-artifact 7.0.1](https://github.com/actions/upload-artifact/releases/tag/v7.0.1), +[download-artifact 8.0.1](https://github.com/actions/download-artifact/releases/tag/v8.0.1), +and [Codecov 7.0.0](https://github.com/codecov/codecov-action/releases/tag/v7.0.0). + +Verification for this completion pass: all 55 security cases and all 127 full-suite +tests passed, with zero failures or skipped tests. Coverlet line coverage is +93.34% overall (Client 93.75%, Core 93.50%, Server 92.91%). Release/analyzer builds, +format verification, coverage report, package creation, and `actionlint` passed. +Release-note scripts passed isolated dry runs for an initial release, a previous +revision, and a shell-shaped tag passed literally through the environment. No +publishing step was executed. GitHub reported zero open Dependabot alerts. +The remaining CodeQL alert is corrected in source; default-branch alert status +will only update after this branch is integrated and analyzed there. + +## Modernization follow-up: cancellation and persistence boundaries + +| ID | Confirmed issue | Correction | +| --- | --- | --- | +| SEC-13 | Queued limiter requests could continue after HTTP abort or SignalR disconnect because acquisition had no cancellation path. | Add optional cancellable RPC/holder capabilities, forward request/connection tokens through group ownership and grain locks into native queues, and release partial/late concurrency leases. Real HTTP, SignalR and four-algorithm queue tests verify cancellation and retained capacity. | +| SEC-14 | A failed persistent-state clear disposed the active limiter before storage confirmed deletion. | Clear storage before replacing the runtime, and mark state deleted only after success. An injected clear failure preserves state and active quota. | +| SEC-15 | Token-bucket snapshot restoration could overflow after a long outage or subtract permits when UTC moved backwards. | Clamp saved quota, ignore backwards elapsed time, and bound replenishment before multiplication. Deterministic boundary tests cover both failures and exact partial replenishment. | + +A controlled return to the previous clear/restoration logic failed all three targeted +regression tests; the corrected implementation passes them. Dirty-state retry is +also exercised through an actual Orleans grain timer, a real memory provider with +an injected write failure, and an isolated controllable grain clock. Shutdown +cancellation is verified with a cancellable persistent-state test double. +See [implementation details and deployment order](CancellationAndObservability.md). + +## Performance and timeout follow-up + +| ID | Confirmed issue | Correction | +| --- | --- | --- | +| SEC-16 | An Orleans response timeout with `CancelRequestOnTimeout=false` rejected the caller while leaving the native acquisition queued. Cancelling only after the timeout is too late because Orleans has removed the callback's cancellation registration. | A scalar budget bounds native queues and configuration waits on the server. The configured fast path avoids client deadline timers and forced cancellation traffic; all 10.2 holders use the bounded capability. Eighteen real RPC tests cover client/silo calls and 16-request storms for all four algorithms, checking empty queues and returned concurrency capacity. The original queue-cleanup regression failed against the previous behavior. | +| SEC-17 | Time-based acquisitions retained native leases keyed by a fresh GUID until client disposal, even though disposal cannot replenish their quota. Clients which omitted disposal caused avoidable server retention. | Dispose known time-based native leases immediately and send an additive optional-release flag. Older payloads still require release; concurrency retains explicit ownership. Real RPC counters verify zero release calls for time-based leases and exactly one for concurrency, with quota unchanged. | + +See [benchmark data, deadline semantics and compatibility](PerformanceAndTimeouts.md). +These changes do not provide a distributed lease expiry guarantee during network +partitions, lost replies or client crashes. Concurrency leases still require explicit +release, and the existing persistence model remains a best-effort snapshot. diff --git a/docs/performance/2026-09-07-baseline.json b/docs/performance/2026-09-07-baseline.json new file mode 100644 index 0000000..22b3497 --- /dev/null +++ b/docs/performance/2026-09-07-baseline.json @@ -0,0 +1,101 @@ +{ + "FrameworkDescription": ".NET 10.0.11", + "OSDescription": "macOS 26.6.2", + "ProcessArchitecture": 3, + "ProcessorCount": 12, + "Timestamp": "2026-09-07T19:03:20.573599+00:00", + "Topology": "Two silos; preferred gateway 0; verified grain placement on silo 0", + "MetadataPayload": "IEAhMsVyuxo8rk6uUmeflS9SeyEgIGCKc/8pQTFyYXRlLWxpbWl0LWNvbXBhdGliaWxpdHng4CFg+BZJ50EbbGVhc2UtcGF5bG9hZODgAQMh4OA=", + "Results": [ + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 12580.024029043701,"P50Milliseconds": 0.0557,"P95Milliseconds": 0.1797,"P99Milliseconds": 0.295,"ProcessAllocatedBytesPerOperation": 6245.990234375,"ProcessRetainedBytesDelta": 404176,"Gen0Collections": 24,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 26104.849433063187,"P50Milliseconds": 0.0355,"P95Milliseconds": 0.0654,"P99Milliseconds": 0.0975,"ProcessAllocatedBytesPerOperation": 6153.379150390625,"ProcessRetainedBytesDelta": 2200496,"Gen0Collections": 24,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 23038.864795935406,"P50Milliseconds": 0.0375,"P95Milliseconds": 0.0788,"P99Milliseconds": 0.1072,"ProcessAllocatedBytesPerOperation": 6156.709228515625,"ProcessRetainedBytesDelta": -2601464,"Gen0Collections": 24,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 26614.680536926488,"P50Milliseconds": 0.0363,"P95Milliseconds": 0.0484,"P99Milliseconds": 0.0752,"ProcessAllocatedBytesPerOperation": 8993.87255859375,"ProcessRetainedBytesDelta": 94856,"Gen0Collections": 35,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 24714.021632311047,"P50Milliseconds": 0.0371,"P95Milliseconds": 0.0688,"P99Milliseconds": 0.1077,"ProcessAllocatedBytesPerOperation": 8995.02392578125,"ProcessRetainedBytesDelta": -274440,"Gen0Collections": 35,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 24349.70902290922,"P50Milliseconds": 0.0378,"P95Milliseconds": 0.0662,"P99Milliseconds": 0.1092,"ProcessAllocatedBytesPerOperation": 8994.903564453125,"ProcessRetainedBytesDelta": -195152,"Gen0Collections": 35,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 59408.48956888596,"P50Milliseconds": 0.236,"P95Milliseconds": 0.3728,"P99Milliseconds": 1.0562,"ProcessAllocatedBytesPerOperation": 7449.175537109375,"ProcessRetainedBytesDelta": 226248,"Gen0Collections": 29,"Gen1Collections": 5,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 58367.06521497117,"P50Milliseconds": 0.2357,"P95Milliseconds": 0.4465,"P99Milliseconds": 1.202,"ProcessAllocatedBytesPerOperation": 6583.388427734375,"ProcessRetainedBytesDelta": 2252872,"Gen0Collections": 26,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 55695.70510103175,"P50Milliseconds": 0.2365,"P95Milliseconds": 0.5095,"P99Milliseconds": 1.2516,"ProcessAllocatedBytesPerOperation": 6202.44384765625,"ProcessRetainedBytesDelta": -2381008,"Gen0Collections": 24,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 56668.4133432254,"P50Milliseconds": 0.2361,"P95Milliseconds": 0.4649,"P99Milliseconds": 1.2634,"ProcessAllocatedBytesPerOperation": 9846.56494140625,"ProcessRetainedBytesDelta": 1558584,"Gen0Collections": 39,"Gen1Collections": 5,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 60232.54822206635,"P50Milliseconds": 0.2344,"P95Milliseconds": 0.3746,"P99Milliseconds": 1.2451,"ProcessAllocatedBytesPerOperation": 9107.672607421875,"ProcessRetainedBytesDelta": -1580792,"Gen0Collections": 36,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 57872.69455428329,"P50Milliseconds": 0.2362,"P95Milliseconds": 0.4733,"P99Milliseconds": 1.3007,"ProcessAllocatedBytesPerOperation": 9198.90234375,"ProcessRetainedBytesDelta": -128592,"Gen0Collections": 36,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 126145.8530628712,"P50Milliseconds": 0.1045,"P95Milliseconds": 0.1884,"P99Milliseconds": 1.0825,"ProcessAllocatedBytesPerOperation": 6191.292236328125,"ProcessRetainedBytesDelta": 118328,"Gen0Collections": 24,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 126099.5419809727,"P50Milliseconds": 0.1057,"P95Milliseconds": 0.1894,"P99Milliseconds": 1.0869,"ProcessAllocatedBytesPerOperation": 6191.273193359375,"ProcessRetainedBytesDelta": -648672,"Gen0Collections": 24,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 118569.33762481388,"P50Milliseconds": 0.1053,"P95Milliseconds": 0.2197,"P99Milliseconds": 1.198,"ProcessAllocatedBytesPerOperation": 6205.318115234375,"ProcessRetainedBytesDelta": -271320,"Gen0Collections": 24,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 74820.81410562321,"P50Milliseconds": 0.1312,"P95Milliseconds": 0.4555,"P99Milliseconds": 1.7591,"ProcessAllocatedBytesPerOperation": 9070.500244140625,"ProcessRetainedBytesDelta": 427584,"Gen0Collections": 36,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 74722.58942231147,"P50Milliseconds": 0.1353,"P95Milliseconds": 0.4848,"P99Milliseconds": 1.5338,"ProcessAllocatedBytesPerOperation": 9080.23388671875,"ProcessRetainedBytesDelta": -3433656,"Gen0Collections": 36,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 108211.10544209262,"P50Milliseconds": 0.109,"P95Milliseconds": 0.2649,"P99Milliseconds": 1.1968,"ProcessAllocatedBytesPerOperation": 9047.375,"ProcessRetainedBytesDelta": -221552,"Gen0Collections": 35,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "sequential","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 21349.220155363008,"P50Milliseconds": 0.0382,"P95Milliseconds": 0.0903,"P99Milliseconds": 0.137,"ProcessAllocatedBytesPerOperation": 5959.28173828125,"ProcessRetainedBytesDelta": -554216,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "sequential","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 23624.47183249638,"P50Milliseconds": 0.037,"P95Milliseconds": 0.0772,"P99Milliseconds": 0.1222,"ProcessAllocatedBytesPerOperation": 5956.2041015625,"ProcessRetainedBytesDelta": -265184,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "sequential","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 27310.720032698184,"P50Milliseconds": 0.0348,"P95Milliseconds": 0.0562,"P99Milliseconds": 0.093,"ProcessAllocatedBytesPerOperation": 5950.108154296875,"ProcessRetainedBytesDelta": -280192,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "sequential","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 24150.948736206235,"P50Milliseconds": 0.0373,"P95Milliseconds": 0.0709,"P99Milliseconds": 0.1075,"ProcessAllocatedBytesPerOperation": 8853.137451171875,"ProcessRetainedBytesDelta": 55176,"Gen0Collections": 34,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "sequential","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 21419.701026260445,"P50Milliseconds": 0.0362,"P95Milliseconds": 0.0891,"P99Milliseconds": 0.1963,"ProcessAllocatedBytesPerOperation": 8860.4404296875,"ProcessRetainedBytesDelta": 97656,"Gen0Collections": 34,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "sequential","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 21251.311020667366,"P50Milliseconds": 0.0397,"P95Milliseconds": 0.0844,"P99Milliseconds": 0.1137,"ProcessAllocatedBytesPerOperation": 8860.4296875,"ProcessRetainedBytesDelta": -1186072,"Gen0Collections": 34,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "hot-key-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 58693.11104654933,"P50Milliseconds": 0.2407,"P95Milliseconds": 0.3962,"P99Milliseconds": 1.2573,"ProcessAllocatedBytesPerOperation": 6331.080322265625,"ProcessRetainedBytesDelta": 1458168,"Gen0Collections": 25,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "hot-key-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 53052.957433614996,"P50Milliseconds": 0.2437,"P95Milliseconds": 0.5344,"P99Milliseconds": 1.4038,"ProcessAllocatedBytesPerOperation": 6097.853515625,"ProcessRetainedBytesDelta": 1429976,"Gen0Collections": 24,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "hot-key-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 61132.46929244502,"P50Milliseconds": 0.2373,"P95Milliseconds": 0.3442,"P99Milliseconds": 1.1821,"ProcessAllocatedBytesPerOperation": 5902.53955078125,"ProcessRetainedBytesDelta": -3401696,"Gen0Collections": 23,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "hot-key-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 60445.620431829804,"P50Milliseconds": 0.2372,"P95Milliseconds": 0.3262,"P99Milliseconds": 1.1888,"ProcessAllocatedBytesPerOperation": 9295.243408203125,"ProcessRetainedBytesDelta": -3220896,"Gen0Collections": 36,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "hot-key-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 59844.40236229057,"P50Milliseconds": 0.2318,"P95Milliseconds": 0.393,"P99Milliseconds": 1.2074,"ProcessAllocatedBytesPerOperation": 8910.83984375,"ProcessRetainedBytesDelta": -409776,"Gen0Collections": 35,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "hot-key-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 59819.32985117459,"P50Milliseconds": 0.2362,"P95Milliseconds": 0.341,"P99Milliseconds": 1.2062,"ProcessAllocatedBytesPerOperation": 9277.8466796875,"ProcessRetainedBytesDelta": -322464,"Gen0Collections": 36,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "partitioned-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 126244.17043425328,"P50Milliseconds": 0.089,"P95Milliseconds": 0.232,"P99Milliseconds": 1.0576,"ProcessAllocatedBytesPerOperation": 5982.574951171875,"ProcessRetainedBytesDelta": -1374704,"Gen0Collections": 23,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "partitioned-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 117224.00058383048,"P50Milliseconds": 0.1019,"P95Milliseconds": 0.2322,"P99Milliseconds": 1.0937,"ProcessAllocatedBytesPerOperation": 5992.413330078125,"ProcessRetainedBytesDelta": -448720,"Gen0Collections": 23,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "partitioned-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 119541.46101933463,"P50Milliseconds": 0.1057,"P95Milliseconds": 0.233,"P99Milliseconds": 1.0957,"ProcessAllocatedBytesPerOperation": 5982.05078125,"ProcessRetainedBytesDelta": -323368,"Gen0Collections": 23,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "partitioned-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 54277.19466676207,"P50Milliseconds": 0.147,"P95Milliseconds": 0.9768,"P99Milliseconds": 2.5965,"ProcessAllocatedBytesPerOperation": 8955.001953125,"ProcessRetainedBytesDelta": -1413304,"Gen0Collections": 35,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "partitioned-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 89755.91584287237,"P50Milliseconds": 0.1224,"P95Milliseconds": 0.3621,"P99Milliseconds": 1.2632,"ProcessAllocatedBytesPerOperation": 8904.58349609375,"ProcessRetainedBytesDelta": 1845016,"Gen0Collections": 35,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "partitioned-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 103252.9663013047,"P50Milliseconds": 0.1159,"P95Milliseconds": 0.2879,"P99Milliseconds": 1.1369,"ProcessAllocatedBytesPerOperation": 8891.074951171875,"ProcessRetainedBytesDelta": -3044160,"Gen0Collections": 35,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "abandoned-time-leases","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 37235.449675326156,"P50Milliseconds": 0.0206,"P95Milliseconds": 0.0515,"P99Milliseconds": 0.0853,"ProcessAllocatedBytesPerOperation": 3512.7646484375,"ProcessRetainedBytesDelta": 4553424,"Gen0Collections": 13,"Gen1Collections": 6,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "abandoned-time-leases","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 45937.777359074076,"P50Milliseconds": 0.0182,"P95Milliseconds": 0.0347,"P99Milliseconds": 0.0682,"ProcessAllocatedBytesPerOperation": 3538.09521484375,"ProcessRetainedBytesDelta": 4483344,"Gen0Collections": 13,"Gen1Collections": 5,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "abandoned-time-leases","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 44009.97369582108,"P50Milliseconds": 0.0164,"P95Milliseconds": 0.0455,"P99Milliseconds": 0.081,"ProcessAllocatedBytesPerOperation": 3405.826171875,"ProcessRetainedBytesDelta": 7453344,"Gen0Collections": 13,"Gen1Collections": 5,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "abandoned-time-leases","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 35751.615303541774,"P50Milliseconds": 0.0215,"P95Milliseconds": 0.0535,"P99Milliseconds": 0.0844,"ProcessAllocatedBytesPerOperation": 6403.24951171875,"ProcessRetainedBytesDelta": 7590640,"Gen0Collections": 25,"Gen1Collections": 6,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "abandoned-time-leases","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 35268.638142782445,"P50Milliseconds": 0.0187,"P95Milliseconds": 0.058,"P99Milliseconds": 0.1212,"ProcessAllocatedBytesPerOperation": 6437.761962890625,"ProcessRetainedBytesDelta": 1842568,"Gen0Collections": 25,"Gen1Collections": 6,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "abandoned-time-leases","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 43948.786680599165,"P50Milliseconds": 0.0177,"P95Milliseconds": 0.0412,"P99Milliseconds": 0.0817,"ProcessAllocatedBytesPerOperation": 6302.851806640625,"ProcessRetainedBytesDelta": 3911216,"Gen0Collections": 24,"Gen1Collections": 5,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "sequential","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 24563.346192209083,"P50Milliseconds": 0.0335,"P95Milliseconds": 0.0805,"P99Milliseconds": 0.1405,"ProcessAllocatedBytesPerOperation": 5986.486328125,"ProcessRetainedBytesDelta": -5992,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "sequential","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 21087.742138512513,"P50Milliseconds": 0.0377,"P95Milliseconds": 0.0914,"P99Milliseconds": 0.175,"ProcessAllocatedBytesPerOperation": 5993.719482421875,"ProcessRetainedBytesDelta": -245536,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "sequential","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 25276.01480677093,"P50Milliseconds": 0.034,"P95Milliseconds": 0.075,"P99Milliseconds": 0.1124,"ProcessAllocatedBytesPerOperation": 5985.739501953125,"ProcessRetainedBytesDelta": -304584,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "sequential","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 25105.355842446425,"P50Milliseconds": 0.0363,"P95Milliseconds": 0.0656,"P99Milliseconds": 0.1137,"ProcessAllocatedBytesPerOperation": 8906.361572265625,"ProcessRetainedBytesDelta": 1528456,"Gen0Collections": 35,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "sequential","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 26944.935399747625,"P50Milliseconds": 0.0339,"P95Milliseconds": 0.0567,"P99Milliseconds": 0.0953,"ProcessAllocatedBytesPerOperation": 8903.961181640625,"ProcessRetainedBytesDelta": 1226552,"Gen0Collections": 35,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "sequential","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 24279.176745634504,"P50Milliseconds": 0.0356,"P95Milliseconds": 0.0773,"P99Milliseconds": 0.1275,"ProcessAllocatedBytesPerOperation": 8907.42578125,"ProcessRetainedBytesDelta": -3177056,"Gen0Collections": 35,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "hot-key-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 62440.93997548325,"P50Milliseconds": 0.2348,"P95Milliseconds": 0.3264,"P99Milliseconds": 0.9969,"ProcessAllocatedBytesPerOperation": 6503.1513671875,"ProcessRetainedBytesDelta": -280,"Gen0Collections": 25,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "hot-key-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 63988.12720296039,"P50Milliseconds": 0.233,"P95Milliseconds": 0.307,"P99Milliseconds": 0.9919,"ProcessAllocatedBytesPerOperation": 6257.34521484375,"ProcessRetainedBytesDelta": -235432,"Gen0Collections": 24,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "hot-key-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 60082.75680987112,"P50Milliseconds": 0.2381,"P95Milliseconds": 0.35,"P99Milliseconds": 1.0456,"ProcessAllocatedBytesPerOperation": 6969.21875,"ProcessRetainedBytesDelta": -301136,"Gen0Collections": 27,"Gen1Collections": 5,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "hot-key-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 58424.58311328428,"P50Milliseconds": 0.2357,"P95Milliseconds": 0.41,"P99Milliseconds": 1.0324,"ProcessAllocatedBytesPerOperation": 9490.355712890625,"ProcessRetainedBytesDelta": 15952,"Gen0Collections": 37,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "hot-key-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 56679.372015805464,"P50Milliseconds": 0.2399,"P95Milliseconds": 0.4831,"P99Milliseconds": 1.1802,"ProcessAllocatedBytesPerOperation": 9213.642578125,"ProcessRetainedBytesDelta": -351968,"Gen0Collections": 36,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "hot-key-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 58631.265558944666,"P50Milliseconds": 0.236,"P95Milliseconds": 0.4082,"P99Milliseconds": 1.1527,"ProcessAllocatedBytesPerOperation": 9265.73828125,"ProcessRetainedBytesDelta": -304704,"Gen0Collections": 36,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "partitioned-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 125394.63261377855,"P50Milliseconds": 0.1032,"P95Milliseconds": 0.2039,"P99Milliseconds": 0.9627,"ProcessAllocatedBytesPerOperation": 6009.082763671875,"ProcessRetainedBytesDelta": -124640,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "partitioned-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 130431.00984521666,"P50Milliseconds": 0.1026,"P95Milliseconds": 0.1738,"P99Milliseconds": 0.9671,"ProcessAllocatedBytesPerOperation": 6008.670166015625,"ProcessRetainedBytesDelta": -589960,"Gen0Collections": 23,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "partitioned-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 149396.56426199657,"P50Milliseconds": 0.1002,"P95Milliseconds": 0.1365,"P99Milliseconds": 0.7899,"ProcessAllocatedBytesPerOperation": 5996.030029296875,"ProcessRetainedBytesDelta": -232288,"Gen0Collections": 23,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "partitioned-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 129081.25721867077,"P50Milliseconds": 0.1032,"P95Milliseconds": 0.1614,"P99Milliseconds": 0.9818,"ProcessAllocatedBytesPerOperation": 8918.0361328125,"ProcessRetainedBytesDelta": 3155424,"Gen0Collections": 35,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "partitioned-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 134368.87470988167,"P50Milliseconds": 0.1022,"P95Milliseconds": 0.149,"P99Milliseconds": 0.939,"ProcessAllocatedBytesPerOperation": 8927.72607421875,"ProcessRetainedBytesDelta": 3255464,"Gen0Collections": 35,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "partitioned-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 134353.99949978065,"P50Milliseconds": 0.1042,"P95Milliseconds": 0.1349,"P99Milliseconds": 0.9597,"ProcessAllocatedBytesPerOperation": 8929.25146484375,"ProcessRetainedBytesDelta": -3765800,"Gen0Collections": 35,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "abandoned-time-leases","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 51633.942951489335,"P50Milliseconds": 0.018,"P95Milliseconds": 0.0244,"P99Milliseconds": 0.0362,"ProcessAllocatedBytesPerOperation": 3523.937255859375,"ProcessRetainedBytesDelta": 4891776,"Gen0Collections": 13,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "abandoned-time-leases","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 48229.01718364794,"P50Milliseconds": 0.0185,"P95Milliseconds": 0.0269,"P99Milliseconds": 0.0529,"ProcessAllocatedBytesPerOperation": 3552.855224609375,"ProcessRetainedBytesDelta": 4571216,"Gen0Collections": 13,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "abandoned-time-leases","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 50414.36304710885,"P50Milliseconds": 0.0184,"P95Milliseconds": 0.0248,"P99Milliseconds": 0.0376,"ProcessAllocatedBytesPerOperation": 3433.1337890625,"ProcessRetainedBytesDelta": 8464320,"Gen0Collections": 13,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "abandoned-time-leases","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 45369.96012405848,"P50Milliseconds": 0.0191,"P95Milliseconds": 0.0318,"P99Milliseconds": 0.058,"ProcessAllocatedBytesPerOperation": 6448.427490234375,"ProcessRetainedBytesDelta": 9112528,"Gen0Collections": 25,"Gen1Collections": 7,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "abandoned-time-leases","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 45921.54126415131,"P50Milliseconds": 0.019,"P95Milliseconds": 0.028,"P99Milliseconds": 0.049,"ProcessAllocatedBytesPerOperation": 6482.345947265625,"ProcessRetainedBytesDelta": 357616,"Gen0Collections": 25,"Gen1Collections": 6,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "abandoned-time-leases","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 46489.62077504928,"P50Milliseconds": 0.0191,"P95Milliseconds": 0.0273,"P99Milliseconds": 0.0469,"ProcessAllocatedBytesPerOperation": 6356.129150390625,"ProcessRetainedBytesDelta": 4640208,"Gen0Collections": 25,"Gen1Collections": 7,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "sequential","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 27432.143577401668,"P50Milliseconds": 0.0345,"P95Milliseconds": 0.0546,"P99Milliseconds": 0.0975,"ProcessAllocatedBytesPerOperation": 5984.752197265625,"ProcessRetainedBytesDelta": 27592,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "sequential","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 28541.711724008055,"P50Milliseconds": 0.034,"P95Milliseconds": 0.0452,"P99Milliseconds": 0.0802,"ProcessAllocatedBytesPerOperation": 5982.66357421875,"ProcessRetainedBytesDelta": -291144,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "sequential","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 28820.879657787238,"P50Milliseconds": 0.0337,"P95Milliseconds": 0.0437,"P99Milliseconds": 0.0717,"ProcessAllocatedBytesPerOperation": 5978.805419921875,"ProcessRetainedBytesDelta": -390560,"Gen0Collections": 23,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "sequential","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 27684.31533320576,"P50Milliseconds": 0.0347,"P95Milliseconds": 0.0463,"P99Milliseconds": 0.0778,"ProcessAllocatedBytesPerOperation": 8879.327880859375,"ProcessRetainedBytesDelta": -2710448,"Gen0Collections": 35,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "sequential","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 27985.967729193402,"P50Milliseconds": 0.0348,"P95Milliseconds": 0.0439,"P99Milliseconds": 0.0612,"ProcessAllocatedBytesPerOperation": 8878.6396484375,"ProcessRetainedBytesDelta": 636816,"Gen0Collections": 35,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "sequential","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 26620.17235196794,"P50Milliseconds": 0.036,"P95Milliseconds": 0.0483,"P99Milliseconds": 0.0818,"ProcessAllocatedBytesPerOperation": 8880.560791015625,"ProcessRetainedBytesDelta": 646096,"Gen0Collections": 35,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "hot-key-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 61422.48691341881,"P50Milliseconds": 0.2372,"P95Milliseconds": 0.3375,"P99Milliseconds": 0.9712,"ProcessAllocatedBytesPerOperation": 6951.06005859375,"ProcessRetainedBytesDelta": 39840,"Gen0Collections": 27,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "hot-key-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 62497.449025327405,"P50Milliseconds": 0.238,"P95Milliseconds": 0.3099,"P99Milliseconds": 0.9991,"ProcessAllocatedBytesPerOperation": 6361.901123046875,"ProcessRetainedBytesDelta": 1321024,"Gen0Collections": 25,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "hot-key-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 62186.53379153391,"P50Milliseconds": 0.2388,"P95Milliseconds": 0.3249,"P99Milliseconds": 0.9426,"ProcessAllocatedBytesPerOperation": 6247.404541015625,"ProcessRetainedBytesDelta": -1986576,"Gen0Collections": 24,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "hot-key-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 62680.83769817784,"P50Milliseconds": 0.2306,"P95Milliseconds": 0.3282,"P99Milliseconds": 1.0084,"ProcessAllocatedBytesPerOperation": 9280.429443359375,"ProcessRetainedBytesDelta": -3495816,"Gen0Collections": 36,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "hot-key-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 62216.13461378169,"P50Milliseconds": 0.2352,"P95Milliseconds": 0.3261,"P99Milliseconds": 0.9492,"ProcessAllocatedBytesPerOperation": 9502.8212890625,"ProcessRetainedBytesDelta": -318088,"Gen0Collections": 37,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "hot-key-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 47912.23965594661,"P50Milliseconds": 0.2392,"P95Milliseconds": 0.5764,"P99Milliseconds": 1.9328,"ProcessAllocatedBytesPerOperation": 8910.65673828125,"ProcessRetainedBytesDelta": 2423984,"Gen0Collections": 35,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "partitioned-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 71313.2581984784,"P50Milliseconds": 0.143,"P95Milliseconds": 0.4884,"P99Milliseconds": 1.546,"ProcessAllocatedBytesPerOperation": 6051.264404296875,"ProcessRetainedBytesDelta": -3426864,"Gen0Collections": 23,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "partitioned-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 61545.31557469498,"P50Milliseconds": 0.1615,"P95Milliseconds": 0.6607,"P99Milliseconds": 1.912,"ProcessAllocatedBytesPerOperation": 6063.725830078125,"ProcessRetainedBytesDelta": -720424,"Gen0Collections": 23,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "partitioned-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 88837.67901803566,"P50Milliseconds": 0.1182,"P95Milliseconds": 0.3641,"P99Milliseconds": 1.1937,"ProcessAllocatedBytesPerOperation": 6025.37255859375,"ProcessRetainedBytesDelta": -700000,"Gen0Collections": 23,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "partitioned-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 126062.62428621386,"P50Milliseconds": 0.1023,"P95Milliseconds": 0.1847,"P99Milliseconds": 1.049,"ProcessAllocatedBytesPerOperation": 8903.5068359375,"ProcessRetainedBytesDelta": 2767648,"Gen0Collections": 35,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "partitioned-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 125523.89409074663,"P50Milliseconds": 0.1023,"P95Milliseconds": 0.176,"P99Milliseconds": 0.9927,"ProcessAllocatedBytesPerOperation": 8898.8896484375,"ProcessRetainedBytesDelta": -3016936,"Gen0Collections": 35,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "partitioned-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 125922.71785248889,"P50Milliseconds": 0.1012,"P95Milliseconds": 0.1792,"P99Milliseconds": 1.0253,"ProcessAllocatedBytesPerOperation": 8911.01513671875,"ProcessRetainedBytesDelta": 2977992,"Gen0Collections": 35,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "abandoned-time-leases","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 44182.750578476094,"P50Milliseconds": 0.0197,"P95Milliseconds": 0.0412,"P99Milliseconds": 0.0604,"ProcessAllocatedBytesPerOperation": 3532.678955078125,"ProcessRetainedBytesDelta": 4913832,"Gen0Collections": 13,"Gen1Collections": 5,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "abandoned-time-leases","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 45474.579865286,"P50Milliseconds": 0.0188,"P95Milliseconds": 0.0358,"P99Milliseconds": 0.0623,"ProcessAllocatedBytesPerOperation": 3562.486083984375,"ProcessRetainedBytesDelta": 4496560,"Gen0Collections": 13,"Gen1Collections": 5,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "abandoned-time-leases","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 47994.914601333745,"P50Milliseconds": 0.0174,"P95Milliseconds": 0.0344,"P99Milliseconds": 0.0669,"ProcessAllocatedBytesPerOperation": 3430.3408203125,"ProcessRetainedBytesDelta": 8327536,"Gen0Collections": 13,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "abandoned-time-leases","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 37101.86095814922,"P50Milliseconds": 0.0207,"P95Milliseconds": 0.0534,"P99Milliseconds": 0.0895,"ProcessAllocatedBytesPerOperation": 6426.2265625,"ProcessRetainedBytesDelta": 8100096,"Gen0Collections": 25,"Gen1Collections": 7,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "abandoned-time-leases","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 40294.01547546951,"P50Milliseconds": 0.0184,"P95Milliseconds": 0.0475,"P99Milliseconds": 0.0859,"ProcessAllocatedBytesPerOperation": 6462.166259765625,"ProcessRetainedBytesDelta": 1356768,"Gen0Collections": 25,"Gen1Collections": 6,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "abandoned-time-leases","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 44664.35557341451,"P50Milliseconds": 0.0197,"P95Milliseconds": 0.0283,"P99Milliseconds": 0.0531,"ProcessAllocatedBytesPerOperation": 6328.238525390625,"ProcessRetainedBytesDelta": 3801904,"Gen0Collections": 24,"Gen1Collections": 7,"Gen2Collections": 0,"TokenCanBeCanceled": false} + ] +} diff --git a/docs/performance/2026-09-07-caller-token-baseline.json b/docs/performance/2026-09-07-caller-token-baseline.json new file mode 100644 index 0000000..db43613 --- /dev/null +++ b/docs/performance/2026-09-07-caller-token-baseline.json @@ -0,0 +1,20 @@ +{ + "FrameworkDescription": ".NET 10.0.11", + "OSDescription": "macOS 26.6.2", + "ProcessArchitecture": 3, + "ProcessorCount": 12, + "Timestamp": "2026-09-07T19:05:54.934706+00:00", + "Topology": "Two silos; preferred gateway 0; verified grain placement on silo 0", + "MetadataPayload": "IEAhMsVyuxo8rk6uUmeflS9SeyEgIGCKc/8pQTFyYXRlLWxpbWl0LWNvbXBhdGliaWxpdHng4CFg+BZJ50EbbGVhc2UtcGF5bG9hZODgAQMh4OA=", + "Results": [ + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 26843.84522026281,"P50Milliseconds": 0.037,"P95Milliseconds": 0.045,"P99Milliseconds": 0.0522,"ProcessAllocatedBytesPerOperation": 8992.63037109375,"ProcessRetainedBytesDelta": -2313760,"Gen0Collections": 35,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 25745.894744484092,"P50Milliseconds": 0.038,"P95Milliseconds": 0.0478,"P99Milliseconds": 0.0795,"ProcessAllocatedBytesPerOperation": 8993.54833984375,"ProcessRetainedBytesDelta": -243712,"Gen0Collections": 35,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 27036.86355220723,"P50Milliseconds": 0.0371,"P95Milliseconds": 0.045,"P99Milliseconds": 0.0562,"ProcessAllocatedBytesPerOperation": 8992.273193359375,"ProcessRetainedBytesDelta": -242680,"Gen0Collections": 35,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 58479.51129060755,"P50Milliseconds": 0.2367,"P95Milliseconds": 0.4044,"P99Milliseconds": 1.1122,"ProcessAllocatedBytesPerOperation": 9597.2294921875,"ProcessRetainedBytesDelta": 1960832,"Gen0Collections": 38,"Gen1Collections": 5,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 59975.30557791573,"P50Milliseconds": 0.2383,"P95Milliseconds": 0.3487,"P99Milliseconds": 1.1235,"ProcessAllocatedBytesPerOperation": 9644.939208984375,"ProcessRetainedBytesDelta": -1585704,"Gen0Collections": 38,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 59913.30416167816,"P50Milliseconds": 0.238,"P95Milliseconds": 0.3585,"P99Milliseconds": 1.0309,"ProcessAllocatedBytesPerOperation": 9985.26806640625,"ProcessRetainedBytesDelta": -122192,"Gen0Collections": 39,"Gen1Collections": 5,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 125630.05766606743,"P50Milliseconds": 0.1061,"P95Milliseconds": 0.1425,"P99Milliseconds": 1.2117,"ProcessAllocatedBytesPerOperation": 9033.150634765625,"ProcessRetainedBytesDelta": -1230872,"Gen0Collections": 36,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 127719.06794079118,"P50Milliseconds": 0.105,"P95Milliseconds": 0.1441,"P99Milliseconds": 1.1384,"ProcessAllocatedBytesPerOperation": 9032.904541015625,"ProcessRetainedBytesDelta": -1924960,"Gen0Collections": 35,"Gen1Collections": 5,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 127221.01973269143,"P50Milliseconds": 0.1051,"P95Milliseconds": 0.1458,"P99Milliseconds": 1.1322,"ProcessAllocatedBytesPerOperation": 9033.5478515625,"ProcessRetainedBytesDelta": -103352,"Gen0Collections": 35,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": true} + ] +} diff --git a/docs/performance/2026-09-07-caller-token-updated.json b/docs/performance/2026-09-07-caller-token-updated.json new file mode 100644 index 0000000..6166be7 --- /dev/null +++ b/docs/performance/2026-09-07-caller-token-updated.json @@ -0,0 +1,20 @@ +{ + "FrameworkDescription": ".NET 10.0.11", + "OSDescription": "macOS 26.6.2", + "ProcessArchitecture": 3, + "ProcessorCount": 12, + "Timestamp": "2026-09-07T19:06:11.63701+00:00", + "Topology": "Two silos; preferred gateway 0; verified grain placement on silo 0", + "MetadataPayload": "IEAhMsVyuxo8rk6uUmeflS9SeyEgIGCKc/8pQTFyYXRlLWxpbWl0LWNvbXBhdGliaWxpdHng4CFg+BZJ50EbbGVhc2UtcGF5bG9hZODgAQMh4AEB4A==", + "Results": [ + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 26701.181768490456,"P50Milliseconds": 0.0363,"P95Milliseconds": 0.0514,"P99Milliseconds": 0.088,"ProcessAllocatedBytesPerOperation": 6016.210693359375,"ProcessRetainedBytesDelta": 32832,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 27824.21326417132,"P50Milliseconds": 0.0359,"P95Milliseconds": 0.0438,"P99Milliseconds": 0.0557,"ProcessAllocatedBytesPerOperation": 6015.23388671875,"ProcessRetainedBytesDelta": -303712,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 27097.682564310067,"P50Milliseconds": 0.0367,"P95Milliseconds": 0.0444,"P99Milliseconds": 0.056,"ProcessAllocatedBytesPerOperation": 6015.77880859375,"ProcessRetainedBytesDelta": -266144,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 62538.576079376355,"P50Milliseconds": 0.235,"P95Milliseconds": 0.3431,"P99Milliseconds": 0.8672,"ProcessAllocatedBytesPerOperation": 6682.251708984375,"ProcessRetainedBytesDelta": 287184,"Gen0Collections": 26,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 60366.724906211246,"P50Milliseconds": 0.2416,"P95Milliseconds": 0.3517,"P99Milliseconds": 0.9145,"ProcessAllocatedBytesPerOperation": 7160.54443359375,"ProcessRetainedBytesDelta": -227144,"Gen0Collections": 28,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 62137.15951186073,"P50Milliseconds": 0.2428,"P95Milliseconds": 0.3048,"P99Milliseconds": 0.9075,"ProcessAllocatedBytesPerOperation": 6292.819091796875,"ProcessRetainedBytesDelta": -258208,"Gen0Collections": 24,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 139706.8322388182,"P50Milliseconds": 0.1039,"P95Milliseconds": 0.1317,"P99Milliseconds": 0.861,"ProcessAllocatedBytesPerOperation": 6048.412109375,"ProcessRetainedBytesDelta": 611560,"Gen0Collections": 24,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 137570.32699402035,"P50Milliseconds": 0.1048,"P95Milliseconds": 0.1396,"P99Milliseconds": 0.8264,"ProcessAllocatedBytesPerOperation": 6059.131591796875,"ProcessRetainedBytesDelta": 917192,"Gen0Collections": 24,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 140389.0603812629,"P50Milliseconds": 0.1038,"P95Milliseconds": 0.1323,"P99Milliseconds": 0.9482,"ProcessAllocatedBytesPerOperation": 6060.626708984375,"ProcessRetainedBytesDelta": -2039984,"Gen0Collections": 24,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": true} + ] +} diff --git a/docs/performance/2026-09-07-concurrency-baseline.json b/docs/performance/2026-09-07-concurrency-baseline.json new file mode 100644 index 0000000..4a9731b --- /dev/null +++ b/docs/performance/2026-09-07-concurrency-baseline.json @@ -0,0 +1,29 @@ +{ + "FrameworkDescription": ".NET 10.0.11", + "OSDescription": "macOS 26.6.2", + "ProcessArchitecture": 3, + "ProcessorCount": 12, + "Timestamp": "2026-09-07T19:05:08.137099+00:00", + "Topology": "Two silos; preferred gateway 0; verified grain placement on silo 0", + "MetadataPayload": "IEAhMsVyuxo8rk6uUmeflS9SeyEgIGCKc/8pQTFyYXRlLWxpbWl0LWNvbXBhdGliaWxpdHng4CFg+BZJ50EbbGVhc2UtcGF5bG9hZODgAQMh4OA=", + "Results": [ + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 27699.135720693168,"P50Milliseconds": 0.0353,"P95Milliseconds": 0.0465,"P99Milliseconds": 0.0764,"ProcessAllocatedBytesPerOperation": 6152.695556640625,"ProcessRetainedBytesDelta": 2413408,"Gen0Collections": 24,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 27368.242932578054,"P50Milliseconds": 0.0355,"P95Milliseconds": 0.0477,"P99Milliseconds": 0.0807,"ProcessAllocatedBytesPerOperation": 6150.81640625,"ProcessRetainedBytesDelta": -2600136,"Gen0Collections": 24,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 28223.609400680678,"P50Milliseconds": 0.0352,"P95Milliseconds": 0.0423,"P99Milliseconds": 0.0537,"ProcessAllocatedBytesPerOperation": 6150.625244140625,"ProcessRetainedBytesDelta": 2147528,"Gen0Collections": 24,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 25800.711393516358,"P50Milliseconds": 0.0233,"P95Milliseconds": 0.0597,"P99Milliseconds": 0.1965,"ProcessAllocatedBytesPerOperation": 8989.8837890625,"ProcessRetainedBytesDelta": 2761464,"Gen0Collections": 35,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 21655.37956227293,"P50Milliseconds": 0.0244,"P95Milliseconds": 0.0911,"P99Milliseconds": 0.2022,"ProcessAllocatedBytesPerOperation": 8996.661376953125,"ProcessRetainedBytesDelta": -2980328,"Gen0Collections": 35,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 25104.23066963513,"P50Milliseconds": 0.0233,"P95Milliseconds": 0.0564,"P99Milliseconds": 0.1492,"ProcessAllocatedBytesPerOperation": 8990.834228515625,"ProcessRetainedBytesDelta": -285024,"Gen0Collections": 35,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 32984.62653476708,"P50Milliseconds": 0.2897,"P95Milliseconds": 0.914,"P99Milliseconds": 2.895,"ProcessAllocatedBytesPerOperation": 6328.61376953125,"ProcessRetainedBytesDelta": 2741592,"Gen0Collections": 25,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 33265.39077596031,"P50Milliseconds": 0.2903,"P95Milliseconds": 0.9612,"P99Milliseconds": 2.0828,"ProcessAllocatedBytesPerOperation": 6133.5068359375,"ProcessRetainedBytesDelta": -2256504,"Gen0Collections": 24,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 32995.065441354316,"P50Milliseconds": 0.2895,"P95Milliseconds": 0.9305,"P99Milliseconds": 2.9049,"ProcessAllocatedBytesPerOperation": 6144.578369140625,"ProcessRetainedBytesDelta": 2454416,"Gen0Collections": 24,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 36242.411385656174,"P50Milliseconds": 0.3113,"P95Milliseconds": 1.0185,"P99Milliseconds": 2.4142,"ProcessAllocatedBytesPerOperation": 8998.734375,"ProcessRetainedBytesDelta": 112104,"Gen0Collections": 35,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 31533.419332984653,"P50Milliseconds": 0.313,"P95Milliseconds": 1.0335,"P99Milliseconds": 2.6811,"ProcessAllocatedBytesPerOperation": 9003.026123046875,"ProcessRetainedBytesDelta": -282400,"Gen0Collections": 35,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 27151.99532529612,"P50Milliseconds": 0.3135,"P95Milliseconds": 1.1807,"P99Milliseconds": 3.7584,"ProcessAllocatedBytesPerOperation": 8992.583740234375,"ProcessRetainedBytesDelta": -236584,"Gen0Collections": 35,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 65744.7791205753,"P50Milliseconds": 0.137,"P95Milliseconds": 0.4812,"P99Milliseconds": 1.939,"ProcessAllocatedBytesPerOperation": 6251.639892578125,"ProcessRetainedBytesDelta": 251624,"Gen0Collections": 24,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 83261.6152214673,"P50Milliseconds": 0.1113,"P95Milliseconds": 0.3772,"P99Milliseconds": 1.6575,"ProcessAllocatedBytesPerOperation": 6231.25537109375,"ProcessRetainedBytesDelta": -134920,"Gen0Collections": 24,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 66987.95042793542,"P50Milliseconds": 0.1151,"P95Milliseconds": 0.3866,"P99Milliseconds": 1.6975,"ProcessAllocatedBytesPerOperation": 6247.608154296875,"ProcessRetainedBytesDelta": -126248,"Gen0Collections": 24,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 131670.786066717,"P50Milliseconds": 0.1041,"P95Milliseconds": 0.1352,"P99Milliseconds": 1.1247,"ProcessAllocatedBytesPerOperation": 9038.567138671875,"ProcessRetainedBytesDelta": -2472512,"Gen0Collections": 35,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 130953.19925763346,"P50Milliseconds": 0.1035,"P95Milliseconds": 0.1346,"P99Milliseconds": 1.1411,"ProcessAllocatedBytesPerOperation": 9036.944580078125,"ProcessRetainedBytesDelta": -161680,"Gen0Collections": 35,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 131175.2086541691,"P50Milliseconds": 0.1034,"P95Milliseconds": 0.1374,"P99Milliseconds": 1.132,"ProcessAllocatedBytesPerOperation": 9034.869873046875,"ProcessRetainedBytesDelta": -683792,"Gen0Collections": 35,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": false} + ] +} diff --git a/docs/performance/2026-09-07-concurrency-updated.json b/docs/performance/2026-09-07-concurrency-updated.json new file mode 100644 index 0000000..72ba1ae --- /dev/null +++ b/docs/performance/2026-09-07-concurrency-updated.json @@ -0,0 +1,29 @@ +{ + "FrameworkDescription": ".NET 10.0.11", + "OSDescription": "macOS 26.6.2", + "ProcessArchitecture": 3, + "ProcessorCount": 12, + "Timestamp": "2026-09-07T19:05:37.639546+00:00", + "Topology": "Two silos; preferred gateway 0; verified grain placement on silo 0", + "MetadataPayload": "IEAhMsVyuxo8rk6uUmeflS9SeyEgIGCKc/8pQTFyYXRlLWxpbWl0LWNvbXBhdGliaWxpdHng4CFg+BZJ50EbbGVhc2UtcGF5bG9hZODgAQMh4AEB4A==", + "Results": [ + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 27700.50318748527,"P50Milliseconds": 0.036,"P95Milliseconds": 0.0437,"P99Milliseconds": 0.0527,"ProcessAllocatedBytesPerOperation": 5887.041748046875,"ProcessRetainedBytesDelta": 2141368,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 27672.139531493995,"P50Milliseconds": 0.036,"P95Milliseconds": 0.0437,"P99Milliseconds": 0.0535,"ProcessAllocatedBytesPerOperation": 5887.121337890625,"ProcessRetainedBytesDelta": -2429568,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 27642.122248230444,"P50Milliseconds": 0.036,"P95Milliseconds": 0.044,"P99Milliseconds": 0.0578,"ProcessAllocatedBytesPerOperation": 5886.8935546875,"ProcessRetainedBytesDelta": 1830632,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 28065.189213059562,"P50Milliseconds": 0.0355,"P95Milliseconds": 0.044,"P99Milliseconds": 0.0562,"ProcessAllocatedBytesPerOperation": 5887.3896484375,"ProcessRetainedBytesDelta": 2225728,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 28533.20947579806,"P50Milliseconds": 0.0351,"P95Milliseconds": 0.044,"P99Milliseconds": 0.0612,"ProcessAllocatedBytesPerOperation": 5887.2880859375,"ProcessRetainedBytesDelta": -2481888,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 26729.46612035275,"P50Milliseconds": 0.0364,"P95Milliseconds": 0.0498,"P99Milliseconds": 0.0803,"ProcessAllocatedBytesPerOperation": 5888.45556640625,"ProcessRetainedBytesDelta": 1885544,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 63150.502401869846,"P50Milliseconds": 0.2351,"P95Milliseconds": 0.3182,"P99Milliseconds": 0.9165,"ProcessAllocatedBytesPerOperation": 6248.6865234375,"ProcessRetainedBytesDelta": 61240,"Gen0Collections": 24,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 63342.5733693656,"P50Milliseconds": 0.2366,"P95Milliseconds": 0.3093,"P99Milliseconds": 0.8956,"ProcessAllocatedBytesPerOperation": 6229.83740234375,"ProcessRetainedBytesDelta": -143504,"Gen0Collections": 24,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 62412.13532051073,"P50Milliseconds": 0.2377,"P95Milliseconds": 0.324,"P99Milliseconds": 1.024,"ProcessAllocatedBytesPerOperation": 6172.748046875,"ProcessRetainedBytesDelta": -105264,"Gen0Collections": 24,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 60970.6630782891,"P50Milliseconds": 0.2363,"P95Milliseconds": 0.3522,"P99Milliseconds": 1.0015,"ProcessAllocatedBytesPerOperation": 6664.426025390625,"ProcessRetainedBytesDelta": 12520,"Gen0Collections": 26,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 63089.42976728264,"P50Milliseconds": 0.2352,"P95Milliseconds": 0.322,"P99Milliseconds": 0.9329,"ProcessAllocatedBytesPerOperation": 6192.216064453125,"ProcessRetainedBytesDelta": -276216,"Gen0Collections": 24,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 63696.07517256528,"P50Milliseconds": 0.2355,"P95Milliseconds": 0.3185,"P99Milliseconds": 0.8962,"ProcessAllocatedBytesPerOperation": 6330.84619140625,"ProcessRetainedBytesDelta": 717448,"Gen0Collections": 25,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 142997.3497836144,"P50Milliseconds": 0.1014,"P95Milliseconds": 0.1297,"P99Milliseconds": 0.8516,"ProcessAllocatedBytesPerOperation": 5931.0244140625,"ProcessRetainedBytesDelta": 1456,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 144248.5893414168,"P50Milliseconds": 0.1009,"P95Milliseconds": 0.131,"P99Milliseconds": 0.8817,"ProcessAllocatedBytesPerOperation": 5928.8525390625,"ProcessRetainedBytesDelta": -281904,"Gen0Collections": 23,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 141859.56907038862,"P50Milliseconds": 0.1022,"P95Milliseconds": 0.1317,"P99Milliseconds": 0.8969,"ProcessAllocatedBytesPerOperation": 5930.629638671875,"ProcessRetainedBytesDelta": -271328,"Gen0Collections": 23,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 142260.07179861655,"P50Milliseconds": 0.1027,"P95Milliseconds": 0.1285,"P99Milliseconds": 0.8832,"ProcessAllocatedBytesPerOperation": 5920.78955078125,"ProcessRetainedBytesDelta": 8160,"Gen0Collections": 23,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 141302.5291979805,"P50Milliseconds": 0.1031,"P95Milliseconds": 0.1342,"P99Milliseconds": 0.8978,"ProcessAllocatedBytesPerOperation": 5929.827392578125,"ProcessRetainedBytesDelta": -264224,"Gen0Collections": 23,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 130036.28698145013,"P50Milliseconds": 0.1022,"P95Milliseconds": 0.161,"P99Milliseconds": 0.9452,"ProcessAllocatedBytesPerOperation": 5932.62158203125,"ProcessRetainedBytesDelta": -702720,"Gen0Collections": 23,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false} + ] +} diff --git a/docs/performance/2026-09-07-first-ci-0-baseline-false.json b/docs/performance/2026-09-07-first-ci-0-baseline-false.json new file mode 100644 index 0000000..a9b1d30 --- /dev/null +++ b/docs/performance/2026-09-07-first-ci-0-baseline-false.json @@ -0,0 +1,30 @@ +{ + "FrameworkDescription": ".NET 10.0.11", + "OSDescription": "Ubuntu 24.04.4 LTS", + "ProcessArchitecture": 1, + "ProcessorCount": 4, + "Timestamp": "2026-09-07T19:30:55.6302218+00:00", + "Topology": "Two silos; preferred gateway 0; verified grain placement on silo 0", + "WarmWorkload": true, + "MetadataPayload": "IEAhMsVyuxo8rk6uUmeflS9SeyEgIGCKc/8pQTFyYXRlLWxpbWl0LWNvbXBhdGliaWxpdHng4CFg+BZJ50EbbGVhc2UtcGF5bG9hZODgAQMh4OA=", + "Results": [ + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 11636.878121919806,"P50Milliseconds": 0.0819,"P95Milliseconds": 0.1102,"P99Milliseconds": 0.1289,"ProcessAllocatedBytesPerOperation": 6186.63818359375,"ProcessRetainedBytesDelta": -124624,"Gen0Collections": 12,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 11849.834019872484,"P50Milliseconds": 0.0806,"P95Milliseconds": 0.1094,"P99Milliseconds": 0.1279,"ProcessAllocatedBytesPerOperation": 6182.04833984375,"ProcessRetainedBytesDelta": -210752,"Gen0Collections": 12,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 13265.321034897454,"P50Milliseconds": 0.0725,"P95Milliseconds": 0.0978,"P99Milliseconds": 0.1101,"ProcessAllocatedBytesPerOperation": 6175.653076171875,"ProcessRetainedBytesDelta": -232184,"Gen0Collections": 12,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 11678.705579718286,"P50Milliseconds": 0.0821,"P95Milliseconds": 0.1095,"P99Milliseconds": 0.1267,"ProcessAllocatedBytesPerOperation": 9021.60400390625,"ProcessRetainedBytesDelta": -634704,"Gen0Collections": 17,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 11829.269789514952,"P50Milliseconds": 0.0817,"P95Milliseconds": 0.1079,"P99Milliseconds": 0.1192,"ProcessAllocatedBytesPerOperation": 9022.27099609375,"ProcessRetainedBytesDelta": -230416,"Gen0Collections": 17,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 11931.395785058206,"P50Milliseconds": 0.081,"P95Milliseconds": 0.1073,"P99Milliseconds": 0.1182,"ProcessAllocatedBytesPerOperation": 9022.400634765625,"ProcessRetainedBytesDelta": -229584,"Gen0Collections": 17,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 24331.491021859507,"P50Milliseconds": 0.5592,"P95Milliseconds": 0.9791,"P99Milliseconds": 1.3466,"ProcessAllocatedBytesPerOperation": 9887.130126953125,"ProcessRetainedBytesDelta": -229632,"Gen0Collections": 19,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 24730.780682247434,"P50Milliseconds": 0.5599,"P95Milliseconds": 0.9753,"P99Milliseconds": 1.363,"ProcessAllocatedBytesPerOperation": 9445.864501953125,"ProcessRetainedBytesDelta": -238624,"Gen0Collections": 18,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 24710.566331069105,"P50Milliseconds": 0.5572,"P95Milliseconds": 0.9731,"P99Milliseconds": 1.2981,"ProcessAllocatedBytesPerOperation": 9593.163818359375,"ProcessRetainedBytesDelta": -240928,"Gen0Collections": 18,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 26184.15415984688,"P50Milliseconds": 0.5612,"P95Milliseconds": 0.9316,"P99Milliseconds": 1.3091,"ProcessAllocatedBytesPerOperation": 10961.9267578125,"ProcessRetainedBytesDelta": -246072,"Gen0Collections": 21,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 26978.884461659367,"P50Milliseconds": 0.5568,"P95Milliseconds": 0.9195,"P99Milliseconds": 1.3214,"ProcessAllocatedBytesPerOperation": 10151.182861328125,"ProcessRetainedBytesDelta": -222576,"Gen0Collections": 20,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 27004.06585570411,"P50Milliseconds": 0.5605,"P95Milliseconds": 0.897,"P99Milliseconds": 1.3116,"ProcessAllocatedBytesPerOperation": 10593.970458984375,"ProcessRetainedBytesDelta": -218320,"Gen0Collections": 20,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 59460.56881306957,"P50Milliseconds": 0.2425,"P95Milliseconds": 0.4346,"P99Milliseconds": 0.6239,"ProcessAllocatedBytesPerOperation": 6276.409912109375,"ProcessRetainedBytesDelta": -254464,"Gen0Collections": 12,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 57981.969815685,"P50Milliseconds": 0.2411,"P95Milliseconds": 0.4516,"P99Milliseconds": 1.1053,"ProcessAllocatedBytesPerOperation": 6275.579345703125,"ProcessRetainedBytesDelta": -259392,"Gen0Collections": 12,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 57343.99713280015,"P50Milliseconds": 0.2419,"P95Milliseconds": 0.4517,"P99Milliseconds": 1.182,"ProcessAllocatedBytesPerOperation": 6287.423095703125,"ProcessRetainedBytesDelta": -248016,"Gen0Collections": 12,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 51482.2514435392,"P50Milliseconds": 0.2681,"P95Milliseconds": 0.503,"P99Milliseconds": 1.2741,"ProcessAllocatedBytesPerOperation": 9141.2470703125,"ProcessRetainedBytesDelta": -220440,"Gen0Collections": 18,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 51273.328871744234,"P50Milliseconds": 0.2741,"P95Milliseconds": 0.489,"P99Milliseconds": 1.1928,"ProcessAllocatedBytesPerOperation": 9141.943359375,"ProcessRetainedBytesDelta": -274032,"Gen0Collections": 18,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 52271.60047958173,"P50Milliseconds": 0.2646,"P95Milliseconds": 0.4727,"P99Milliseconds": 1.2805,"ProcessAllocatedBytesPerOperation": 9142.362548828125,"ProcessRetainedBytesDelta": -260664,"Gen0Collections": 18,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false} + ] +} diff --git a/docs/performance/2026-09-07-first-ci-0-baseline-true.json b/docs/performance/2026-09-07-first-ci-0-baseline-true.json new file mode 100644 index 0000000..301f659 --- /dev/null +++ b/docs/performance/2026-09-07-first-ci-0-baseline-true.json @@ -0,0 +1,21 @@ +{ + "FrameworkDescription": ".NET 10.0.11", + "OSDescription": "Ubuntu 24.04.4 LTS", + "ProcessArchitecture": 1, + "ProcessorCount": 4, + "Timestamp": "2026-09-07T19:31:37.2685782+00:00", + "Topology": "Two silos; preferred gateway 0; verified grain placement on silo 0", + "WarmWorkload": true, + "MetadataPayload": "IEAhMsVyuxo8rk6uUmeflS9SeyEgIGCKc/8pQTFyYXRlLWxpbWl0LWNvbXBhdGliaWxpdHng4CFg+BZJ50EbbGVhc2UtcGF5bG9hZODgAQMh4OA=", + "Results": [ + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 12260.985982633762,"P50Milliseconds": 0.079,"P95Milliseconds": 0.1044,"P99Milliseconds": 0.1159,"ProcessAllocatedBytesPerOperation": 9019.717041015625,"ProcessRetainedBytesDelta": -239520,"Gen0Collections": 17,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 12247.302672493155,"P50Milliseconds": 0.0789,"P95Milliseconds": 0.1048,"P99Milliseconds": 0.1165,"ProcessAllocatedBytesPerOperation": 9019.831787109375,"ProcessRetainedBytesDelta": -239288,"Gen0Collections": 17,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 12290.238820629162,"P50Milliseconds": 0.0787,"P95Milliseconds": 0.1042,"P99Milliseconds": 0.115,"ProcessAllocatedBytesPerOperation": 9019.685791015625,"ProcessRetainedBytesDelta": -245448,"Gen0Collections": 17,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 24050.183620450993,"P50Milliseconds": 0.5683,"P95Milliseconds": 0.9707,"P99Milliseconds": 1.3909,"ProcessAllocatedBytesPerOperation": 12862.20751953125,"ProcessRetainedBytesDelta": -249616,"Gen0Collections": 25,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 23323.799436109,"P50Milliseconds": 0.584,"P95Milliseconds": 0.9761,"P99Milliseconds": 1.5814,"ProcessAllocatedBytesPerOperation": 13050.51904296875,"ProcessRetainedBytesDelta": -437360,"Gen0Collections": 25,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 26119.733726659793,"P50Milliseconds": 0.5606,"P95Milliseconds": 0.9242,"P99Milliseconds": 1.3162,"ProcessAllocatedBytesPerOperation": 10206.787109375,"ProcessRetainedBytesDelta": -210128,"Gen0Collections": 20,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 55006.21522558205,"P50Milliseconds": 0.2525,"P95Milliseconds": 0.4512,"P99Milliseconds": 1.1501,"ProcessAllocatedBytesPerOperation": 9125.56884765625,"ProcessRetainedBytesDelta": -259752,"Gen0Collections": 17,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 54901.40098429794,"P50Milliseconds": 0.2567,"P95Milliseconds": 0.4548,"P99Milliseconds": 1.094,"ProcessAllocatedBytesPerOperation": 9126.379150390625,"ProcessRetainedBytesDelta": -251024,"Gen0Collections": 17,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 55763.131938483566,"P50Milliseconds": 0.2529,"P95Milliseconds": 0.4388,"P99Milliseconds": 1.1302,"ProcessAllocatedBytesPerOperation": 9123.005859375,"ProcessRetainedBytesDelta": -302336,"Gen0Collections": 17,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": true} + ] +} diff --git a/docs/performance/2026-09-07-first-ci-1-updated-false.json b/docs/performance/2026-09-07-first-ci-1-updated-false.json new file mode 100644 index 0000000..c66895c --- /dev/null +++ b/docs/performance/2026-09-07-first-ci-1-updated-false.json @@ -0,0 +1,30 @@ +{ + "FrameworkDescription": ".NET 10.0.11", + "OSDescription": "Ubuntu 24.04.4 LTS", + "ProcessArchitecture": 1, + "ProcessorCount": 4, + "Timestamp": "2026-09-07T19:32:48.227553+00:00", + "Topology": "Two silos; preferred gateway 0; verified grain placement on silo 0", + "WarmWorkload": true, + "MetadataPayload": "IEAhMsVyuxo8rk6uUmeflS9SeyEgIGCKc/8pQTFyYXRlLWxpbWl0LWNvbXBhdGliaWxpdHng4CFg+BZJ50EbbGVhc2UtcGF5bG9hZODgAQMh4AEB4A==", + "Results": [ + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 13723.041236675179,"P50Milliseconds": 0.0704,"P95Milliseconds": 0.0942,"P99Milliseconds": 0.1033,"ProcessAllocatedBytesPerOperation": 5910.17333984375,"ProcessRetainedBytesDelta": -248712,"Gen0Collections": 11,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 13460.409214021452,"P50Milliseconds": 0.0712,"P95Milliseconds": 0.0956,"P99Milliseconds": 0.1075,"ProcessAllocatedBytesPerOperation": 5911.841552734375,"ProcessRetainedBytesDelta": -254440,"Gen0Collections": 11,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 13518.90918641399,"P50Milliseconds": 0.0713,"P95Milliseconds": 0.0952,"P99Milliseconds": 0.1051,"ProcessAllocatedBytesPerOperation": 5910.62646484375,"ProcessRetainedBytesDelta": -258088,"Gen0Collections": 11,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 13540.748218076296,"P50Milliseconds": 0.0711,"P95Milliseconds": 0.0951,"P99Milliseconds": 0.1038,"ProcessAllocatedBytesPerOperation": 5911.885009765625,"ProcessRetainedBytesDelta": -254656,"Gen0Collections": 11,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 13271.815638085429,"P50Milliseconds": 0.0719,"P95Milliseconds": 0.0969,"P99Milliseconds": 0.1111,"ProcessAllocatedBytesPerOperation": 5947.80419921875,"ProcessRetainedBytesDelta": -883064,"Gen0Collections": 11,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 13499.866847016448,"P50Milliseconds": 0.0713,"P95Milliseconds": 0.0959,"P99Milliseconds": 0.107,"ProcessAllocatedBytesPerOperation": 5911.681396484375,"ProcessRetainedBytesDelta": -253824,"Gen0Collections": 11,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 24201.86041461787,"P50Milliseconds": 0.5584,"P95Milliseconds": 0.9701,"P99Milliseconds": 1.2825,"ProcessAllocatedBytesPerOperation": 10416.9921875,"ProcessRetainedBytesDelta": -242648,"Gen0Collections": 20,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 23877.91019317439,"P50Milliseconds": 0.5659,"P95Milliseconds": 0.9813,"P99Milliseconds": 1.2836,"ProcessAllocatedBytesPerOperation": 10730.130859375,"ProcessRetainedBytesDelta": -249304,"Gen0Collections": 21,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 24819.344364237208,"P50Milliseconds": 0.5564,"P95Milliseconds": 0.9334,"P99Milliseconds": 1.1986,"ProcessAllocatedBytesPerOperation": 10451.243896484375,"ProcessRetainedBytesDelta": -258928,"Gen0Collections": 20,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 23628.163316988022,"P50Milliseconds": 0.5762,"P95Milliseconds": 0.9501,"P99Milliseconds": 1.2638,"ProcessAllocatedBytesPerOperation": 10996.427734375,"ProcessRetainedBytesDelta": -256576,"Gen0Collections": 21,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 24477.774363146844,"P50Milliseconds": 0.5628,"P95Milliseconds": 0.8997,"P99Milliseconds": 1.233,"ProcessAllocatedBytesPerOperation": 11070.755615234375,"ProcessRetainedBytesDelta": -251200,"Gen0Collections": 21,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 24322.95732956969,"P50Milliseconds": 0.5649,"P95Milliseconds": 0.9041,"P99Milliseconds": 1.196,"ProcessAllocatedBytesPerOperation": 9774.28076171875,"ProcessRetainedBytesDelta": -256536,"Gen0Collections": 19,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 59866.24731549172,"P50Milliseconds": 0.2437,"P95Milliseconds": 0.4385,"P99Milliseconds": 0.6433,"ProcessAllocatedBytesPerOperation": 6006.44140625,"ProcessRetainedBytesDelta": -186536,"Gen0Collections": 11,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 60518.250311890246,"P50Milliseconds": 0.2422,"P95Milliseconds": 0.4333,"P99Milliseconds": 0.6836,"ProcessAllocatedBytesPerOperation": 6000.8095703125,"ProcessRetainedBytesDelta": -184656,"Gen0Collections": 11,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 58855.56390556096,"P50Milliseconds": 0.2395,"P95Milliseconds": 0.4409,"P99Milliseconds": 0.95,"ProcessAllocatedBytesPerOperation": 6013.66650390625,"ProcessRetainedBytesDelta": -352312,"Gen0Collections": 11,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 61598.10694099079,"P50Milliseconds": 0.2401,"P95Milliseconds": 0.4253,"P99Milliseconds": 0.5759,"ProcessAllocatedBytesPerOperation": 6003.96435546875,"ProcessRetainedBytesDelta": -188384,"Gen0Collections": 11,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 61358.15971958901,"P50Milliseconds": 0.2353,"P95Milliseconds": 0.42,"P99Milliseconds": 0.7852,"ProcessAllocatedBytesPerOperation": 6001.568359375,"ProcessRetainedBytesDelta": -283808,"Gen0Collections": 11,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 61737.74571313803,"P50Milliseconds": 0.2377,"P95Milliseconds": 0.4217,"P99Milliseconds": 0.6478,"ProcessAllocatedBytesPerOperation": 6002.220703125,"ProcessRetainedBytesDelta": -268296,"Gen0Collections": 11,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false} + ] +} diff --git a/docs/performance/2026-09-07-first-ci-1-updated-true.json b/docs/performance/2026-09-07-first-ci-1-updated-true.json new file mode 100644 index 0000000..481c4a9 --- /dev/null +++ b/docs/performance/2026-09-07-first-ci-1-updated-true.json @@ -0,0 +1,21 @@ +{ + "FrameworkDescription": ".NET 10.0.11", + "OSDescription": "Ubuntu 24.04.4 LTS", + "ProcessArchitecture": 1, + "ProcessorCount": 4, + "Timestamp": "2026-09-07T19:33:26.1627712+00:00", + "Topology": "Two silos; preferred gateway 0; verified grain placement on silo 0", + "WarmWorkload": true, + "MetadataPayload": "IEAhMsVyuxo8rk6uUmeflS9SeyEgIGCKc/8pQTFyYXRlLWxpbWl0LWNvbXBhdGliaWxpdHng4CFg+BZJ50EbbGVhc2UtcGF5bG9hZODgAQMh4AEB4A==", + "Results": [ + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 13698.59921288237,"P50Milliseconds": 0.0703,"P95Milliseconds": 0.0939,"P99Milliseconds": 0.1031,"ProcessAllocatedBytesPerOperation": 6038.74169921875,"ProcessRetainedBytesDelta": -255392,"Gen0Collections": 11,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 13750.019459751966,"P50Milliseconds": 0.0698,"P95Milliseconds": 0.0939,"P99Milliseconds": 0.1042,"ProcessAllocatedBytesPerOperation": 6038.101318359375,"ProcessRetainedBytesDelta": -248912,"Gen0Collections": 11,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 13936.160747626029,"P50Milliseconds": 0.0692,"P95Milliseconds": 0.0928,"P99Milliseconds": 0.102,"ProcessAllocatedBytesPerOperation": 6037.513916015625,"ProcessRetainedBytesDelta": -248104,"Gen0Collections": 11,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 24025.177752808577,"P50Milliseconds": 0.5613,"P95Milliseconds": 0.9476,"P99Milliseconds": 1.2795,"ProcessAllocatedBytesPerOperation": 10495.531982421875,"ProcessRetainedBytesDelta": -719664,"Gen0Collections": 20,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 22609.666681294504,"P50Milliseconds": 0.7714,"P95Milliseconds": 0.959,"P99Milliseconds": 1.2582,"ProcessAllocatedBytesPerOperation": 12147.5458984375,"ProcessRetainedBytesDelta": -255584,"Gen0Collections": 23,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 24679.1402970866,"P50Milliseconds": 0.5536,"P95Milliseconds": 0.9137,"P99Milliseconds": 1.1982,"ProcessAllocatedBytesPerOperation": 10071.03515625,"ProcessRetainedBytesDelta": -257080,"Gen0Collections": 19,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 59384.43739189814,"P50Milliseconds": 0.2446,"P95Milliseconds": 0.442,"P99Milliseconds": 0.644,"ProcessAllocatedBytesPerOperation": 6144.51171875,"ProcessRetainedBytesDelta": -259872,"Gen0Collections": 12,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 59278.49132187346,"P50Milliseconds": 0.2366,"P95Milliseconds": 0.4367,"P99Milliseconds": 0.9229,"ProcessAllocatedBytesPerOperation": 6143.173828125,"ProcessRetainedBytesDelta": -287440,"Gen0Collections": 12,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 59216.60522666779,"P50Milliseconds": 0.2381,"P95Milliseconds": 0.4385,"P99Milliseconds": 0.9045,"ProcessAllocatedBytesPerOperation": 6142.2724609375,"ProcessRetainedBytesDelta": -259552,"Gen0Collections": 12,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": true} + ] +} diff --git a/docs/performance/2026-09-07-first-ci-2-updated-false.json b/docs/performance/2026-09-07-first-ci-2-updated-false.json new file mode 100644 index 0000000..19c79e1 --- /dev/null +++ b/docs/performance/2026-09-07-first-ci-2-updated-false.json @@ -0,0 +1,30 @@ +{ + "FrameworkDescription": ".NET 10.0.11", + "OSDescription": "Ubuntu 24.04.4 LTS", + "ProcessArchitecture": 1, + "ProcessorCount": 4, + "Timestamp": "2026-09-07T19:34:36.3840678+00:00", + "Topology": "Two silos; preferred gateway 0; verified grain placement on silo 0", + "WarmWorkload": true, + "MetadataPayload": "IEAhMsVyuxo8rk6uUmeflS9SeyEgIGCKc/8pQTFyYXRlLWxpbWl0LWNvbXBhdGliaWxpdHng4CFg+BZJ50EbbGVhc2UtcGF5bG9hZODgAQMh4AEB4A==", + "Results": [ + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 13723.692417073602,"P50Milliseconds": 0.0703,"P95Milliseconds": 0.0939,"P99Milliseconds": 0.1034,"ProcessAllocatedBytesPerOperation": 5911.132568359375,"ProcessRetainedBytesDelta": -254832,"Gen0Collections": 11,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 13572.619791064993,"P50Milliseconds": 0.0708,"P95Milliseconds": 0.0949,"P99Milliseconds": 0.1061,"ProcessAllocatedBytesPerOperation": 5911.0390625,"ProcessRetainedBytesDelta": -256472,"Gen0Collections": 11,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 13569.252028524086,"P50Milliseconds": 0.0709,"P95Milliseconds": 0.0946,"P99Milliseconds": 0.1048,"ProcessAllocatedBytesPerOperation": 5911.609130859375,"ProcessRetainedBytesDelta": -248376,"Gen0Collections": 11,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 13486.272692285675,"P50Milliseconds": 0.0713,"P95Milliseconds": 0.0953,"P99Milliseconds": 0.1063,"ProcessAllocatedBytesPerOperation": 5912.43115234375,"ProcessRetainedBytesDelta": -252528,"Gen0Collections": 11,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 13404.033940087307,"P50Milliseconds": 0.0714,"P95Milliseconds": 0.0962,"P99Milliseconds": 0.1092,"ProcessAllocatedBytesPerOperation": 5946.602294921875,"ProcessRetainedBytesDelta": -913248,"Gen0Collections": 11,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 13401.226665381944,"P50Milliseconds": 0.0714,"P95Milliseconds": 0.0957,"P99Milliseconds": 0.1089,"ProcessAllocatedBytesPerOperation": 5913.03173828125,"ProcessRetainedBytesDelta": -268544,"Gen0Collections": 11,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 24357.31273903512,"P50Milliseconds": 0.5625,"P95Milliseconds": 0.9602,"P99Milliseconds": 1.2493,"ProcessAllocatedBytesPerOperation": 10062.7607421875,"ProcessRetainedBytesDelta": -257656,"Gen0Collections": 19,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 25253.54309105433,"P50Milliseconds": 0.5584,"P95Milliseconds": 0.9389,"P99Milliseconds": 1.3044,"ProcessAllocatedBytesPerOperation": 9270.060302734375,"ProcessRetainedBytesDelta": -258824,"Gen0Collections": 18,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 25474.43491813632,"P50Milliseconds": 0.5593,"P95Milliseconds": 0.921,"P99Milliseconds": 1.2759,"ProcessAllocatedBytesPerOperation": 9063.181640625,"ProcessRetainedBytesDelta": -258608,"Gen0Collections": 17,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 24166.54086072504,"P50Milliseconds": 0.559,"P95Milliseconds": 0.9542,"P99Milliseconds": 1.2391,"ProcessAllocatedBytesPerOperation": 10219.409423828125,"ProcessRetainedBytesDelta": -259240,"Gen0Collections": 20,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 23768.651406120865,"P50Milliseconds": 0.5635,"P95Milliseconds": 0.9724,"P99Milliseconds": 1.2601,"ProcessAllocatedBytesPerOperation": 10630.858642578125,"ProcessRetainedBytesDelta": -249248,"Gen0Collections": 20,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 24559.98443415049,"P50Milliseconds": 0.5582,"P95Milliseconds": 0.9648,"P99Milliseconds": 1.2057,"ProcessAllocatedBytesPerOperation": 10478.339111328125,"ProcessRetainedBytesDelta": -259776,"Gen0Collections": 20,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 60256.10684278632,"P50Milliseconds": 0.232,"P95Milliseconds": 0.4276,"P99Milliseconds": 0.9211,"ProcessAllocatedBytesPerOperation": 6008.45361328125,"ProcessRetainedBytesDelta": -229024,"Gen0Collections": 11,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 59506.222566129574,"P50Milliseconds": 0.2381,"P95Milliseconds": 0.4368,"P99Milliseconds": 0.8103,"ProcessAllocatedBytesPerOperation": 6012.98291015625,"ProcessRetainedBytesDelta": -245384,"Gen0Collections": 11,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 58563.91863532916,"P50Milliseconds": 0.241,"P95Milliseconds": 0.4492,"P99Milliseconds": 0.9073,"ProcessAllocatedBytesPerOperation": 6013.52734375,"ProcessRetainedBytesDelta": -302736,"Gen0Collections": 11,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 60900.81463760596,"P50Milliseconds": 0.236,"P95Milliseconds": 0.4244,"P99Milliseconds": 0.7461,"ProcessAllocatedBytesPerOperation": 6001.881103515625,"ProcessRetainedBytesDelta": -213672,"Gen0Collections": 11,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 61037.03350480131,"P50Milliseconds": 0.2387,"P95Milliseconds": 0.427,"P99Milliseconds": 0.6391,"ProcessAllocatedBytesPerOperation": 6003.024169921875,"ProcessRetainedBytesDelta": -248576,"Gen0Collections": 11,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 61107.32029071479,"P50Milliseconds": 0.2344,"P95Milliseconds": 0.4203,"P99Milliseconds": 0.777,"ProcessAllocatedBytesPerOperation": 6001.36083984375,"ProcessRetainedBytesDelta": -259456,"Gen0Collections": 11,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false} + ] +} diff --git a/docs/performance/2026-09-07-first-ci-2-updated-true.json b/docs/performance/2026-09-07-first-ci-2-updated-true.json new file mode 100644 index 0000000..99bb8d4 --- /dev/null +++ b/docs/performance/2026-09-07-first-ci-2-updated-true.json @@ -0,0 +1,21 @@ +{ + "FrameworkDescription": ".NET 10.0.11", + "OSDescription": "Ubuntu 24.04.4 LTS", + "ProcessArchitecture": 1, + "ProcessorCount": 4, + "Timestamp": "2026-09-07T19:35:14.1218319+00:00", + "Topology": "Two silos; preferred gateway 0; verified grain placement on silo 0", + "WarmWorkload": true, + "MetadataPayload": "IEAhMsVyuxo8rk6uUmeflS9SeyEgIGCKc/8pQTFyYXRlLWxpbWl0LWNvbXBhdGliaWxpdHng4CFg+BZJ50EbbGVhc2UtcGF5bG9hZODgAQMh4AEB4A==", + "Results": [ + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 13901.214578746065,"P50Milliseconds": 0.0694,"P95Milliseconds": 0.0929,"P99Milliseconds": 0.1023,"ProcessAllocatedBytesPerOperation": 6038.64306640625,"ProcessRetainedBytesDelta": -231712,"Gen0Collections": 11,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 13679.788333997743,"P50Milliseconds": 0.0703,"P95Milliseconds": 0.0941,"P99Milliseconds": 0.106,"ProcessAllocatedBytesPerOperation": 6038.16357421875,"ProcessRetainedBytesDelta": -254880,"Gen0Collections": 11,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 13578.832503020823,"P50Milliseconds": 0.0709,"P95Milliseconds": 0.095,"P99Milliseconds": 0.1055,"ProcessAllocatedBytesPerOperation": 6038.692138671875,"ProcessRetainedBytesDelta": -258256,"Gen0Collections": 11,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 25576.309942915377,"P50Milliseconds": 0.5546,"P95Milliseconds": 0.9158,"P99Milliseconds": 1.1848,"ProcessAllocatedBytesPerOperation": 8799.19384765625,"ProcessRetainedBytesDelta": -226912,"Gen0Collections": 17,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 23488.257054200687,"P50Milliseconds": 0.5685,"P95Milliseconds": 0.9484,"P99Milliseconds": 1.3221,"ProcessAllocatedBytesPerOperation": 10900.720947265625,"ProcessRetainedBytesDelta": -250264,"Gen0Collections": 21,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 23867.3462683745,"P50Milliseconds": 0.5566,"P95Milliseconds": 0.9248,"P99Milliseconds": 1.3265,"ProcessAllocatedBytesPerOperation": 10332.384521484375,"ProcessRetainedBytesDelta": -254880,"Gen0Collections": 20,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 60994.54161395786,"P50Milliseconds": 0.2412,"P95Milliseconds": 0.4313,"P99Milliseconds": 0.5893,"ProcessAllocatedBytesPerOperation": 6140.165283203125,"ProcessRetainedBytesDelta": -258048,"Gen0Collections": 12,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 60003.96998922537,"P50Milliseconds": 0.2343,"P95Milliseconds": 0.4348,"P99Milliseconds": 0.9319,"ProcessAllocatedBytesPerOperation": 6138.526611328125,"ProcessRetainedBytesDelta": -261384,"Gen0Collections": 12,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 60743.68751703133,"P50Milliseconds": 0.2393,"P95Milliseconds": 0.4288,"P99Milliseconds": 0.6023,"ProcessAllocatedBytesPerOperation": 6140.291259765625,"ProcessRetainedBytesDelta": -257664,"Gen0Collections": 12,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": true} + ] +} diff --git a/docs/performance/2026-09-07-first-ci-3-baseline-false.json b/docs/performance/2026-09-07-first-ci-3-baseline-false.json new file mode 100644 index 0000000..63789ed --- /dev/null +++ b/docs/performance/2026-09-07-first-ci-3-baseline-false.json @@ -0,0 +1,30 @@ +{ + "FrameworkDescription": ".NET 10.0.11", + "OSDescription": "Ubuntu 24.04.4 LTS", + "ProcessArchitecture": 1, + "ProcessorCount": 4, + "Timestamp": "2026-09-07T19:36:28.5523288+00:00", + "Topology": "Two silos; preferred gateway 0; verified grain placement on silo 0", + "WarmWorkload": true, + "MetadataPayload": "IEAhMsVyuxo8rk6uUmeflS9SeyEgIGCKc/8pQTFyYXRlLWxpbWl0LWNvbXBhdGliaWxpdHng4CFg+BZJ50EbbGVhc2UtcGF5bG9hZODgAQMh4OA=", + "Results": [ + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 13280.143591552584,"P50Milliseconds": 0.073,"P95Milliseconds": 0.0969,"P99Milliseconds": 0.107,"ProcessAllocatedBytesPerOperation": 6176.531005859375,"ProcessRetainedBytesDelta": -219920,"Gen0Collections": 12,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 13032.534524503595,"P50Milliseconds": 0.0738,"P95Milliseconds": 0.0985,"P99Milliseconds": 0.1087,"ProcessAllocatedBytesPerOperation": 6177.21044921875,"ProcessRetainedBytesDelta": -243808,"Gen0Collections": 12,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 13377.492776651963,"P50Milliseconds": 0.0721,"P95Milliseconds": 0.0962,"P99Milliseconds": 0.1058,"ProcessAllocatedBytesPerOperation": 6176.038818359375,"ProcessRetainedBytesDelta": -251320,"Gen0Collections": 12,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 11877.309796580408,"P50Milliseconds": 0.0809,"P95Milliseconds": 0.1074,"P99Milliseconds": 0.124,"ProcessAllocatedBytesPerOperation": 9056.486572265625,"ProcessRetainedBytesDelta": -885576,"Gen0Collections": 17,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 12054.092297752119,"P50Milliseconds": 0.0801,"P95Milliseconds": 0.106,"P99Milliseconds": 0.1185,"ProcessAllocatedBytesPerOperation": 9021.15966796875,"ProcessRetainedBytesDelta": -242600,"Gen0Collections": 17,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 12045.501765286517,"P50Milliseconds": 0.0802,"P95Milliseconds": 0.1053,"P99Milliseconds": 0.1157,"ProcessAllocatedBytesPerOperation": 9021.660400390625,"ProcessRetainedBytesDelta": -218424,"Gen0Collections": 17,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 24104.314126409503,"P50Milliseconds": 0.5611,"P95Milliseconds": 0.9721,"P99Milliseconds": 1.3341,"ProcessAllocatedBytesPerOperation": 11133.59716796875,"ProcessRetainedBytesDelta": -222488,"Gen0Collections": 21,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 23151.44854108711,"P50Milliseconds": 0.5959,"P95Milliseconds": 0.9798,"P99Milliseconds": 1.2936,"ProcessAllocatedBytesPerOperation": 12055.095703125,"ProcessRetainedBytesDelta": -251016,"Gen0Collections": 23,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 25184.23581200177,"P50Milliseconds": 0.5584,"P95Milliseconds": 0.962,"P99Milliseconds": 1.2846,"ProcessAllocatedBytesPerOperation": 9391.78955078125,"ProcessRetainedBytesDelta": -242976,"Gen0Collections": 18,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 24940.558006929135,"P50Milliseconds": 0.5598,"P95Milliseconds": 0.9652,"P99Milliseconds": 1.2745,"ProcessAllocatedBytesPerOperation": 12777.395751953125,"ProcessRetainedBytesDelta": -256112,"Gen0Collections": 25,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 26724.434768580977,"P50Milliseconds": 0.5576,"P95Milliseconds": 0.9187,"P99Milliseconds": 1.2206,"ProcessAllocatedBytesPerOperation": 10805.647216796875,"ProcessRetainedBytesDelta": -223376,"Gen0Collections": 21,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 25262.964423226815,"P50Milliseconds": 0.5576,"P95Milliseconds": 0.955,"P99Milliseconds": 1.2535,"ProcessAllocatedBytesPerOperation": 12738.5048828125,"ProcessRetainedBytesDelta": -226192,"Gen0Collections": 25,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 60610.82497347999,"P50Milliseconds": 0.2377,"P95Milliseconds": 0.4267,"P99Milliseconds": 0.6498,"ProcessAllocatedBytesPerOperation": 6267.234375,"ProcessRetainedBytesDelta": -251064,"Gen0Collections": 12,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 59587.97591458669,"P50Milliseconds": 0.234,"P95Milliseconds": 0.4296,"P99Milliseconds": 0.9792,"ProcessAllocatedBytesPerOperation": 6274.740234375,"ProcessRetainedBytesDelta": -223408,"Gen0Collections": 12,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 59608.679786348875,"P50Milliseconds": 0.2424,"P95Milliseconds": 0.4376,"P99Milliseconds": 0.7,"ProcessAllocatedBytesPerOperation": 6275.7890625,"ProcessRetainedBytesDelta": -258320,"Gen0Collections": 12,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 55969.970798870214,"P50Milliseconds": 0.2596,"P95Milliseconds": 0.4441,"P99Milliseconds": 0.9521,"ProcessAllocatedBytesPerOperation": 9123.408203125,"ProcessRetainedBytesDelta": -217456,"Gen0Collections": 17,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 56329.524970613,"P50Milliseconds": 0.2623,"P95Milliseconds": 0.4417,"P99Milliseconds": 0.8426,"ProcessAllocatedBytesPerOperation": 9125.8134765625,"ProcessRetainedBytesDelta": -250688,"Gen0Collections": 17,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 56438.64372951132,"P50Milliseconds": 0.2654,"P95Milliseconds": 0.4419,"P99Milliseconds": 0.6387,"ProcessAllocatedBytesPerOperation": 9123.0615234375,"ProcessRetainedBytesDelta": -243464,"Gen0Collections": 17,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false} + ] +} diff --git a/docs/performance/2026-09-07-first-ci-3-baseline-true.json b/docs/performance/2026-09-07-first-ci-3-baseline-true.json new file mode 100644 index 0000000..ee347ab --- /dev/null +++ b/docs/performance/2026-09-07-first-ci-3-baseline-true.json @@ -0,0 +1,21 @@ +{ + "FrameworkDescription": ".NET 10.0.11", + "OSDescription": "Ubuntu 24.04.4 LTS", + "ProcessArchitecture": 1, + "ProcessorCount": 4, + "Timestamp": "2026-09-07T19:37:09.9460664+00:00", + "Topology": "Two silos; preferred gateway 0; verified grain placement on silo 0", + "WarmWorkload": true, + "MetadataPayload": "IEAhMsVyuxo8rk6uUmeflS9SeyEgIGCKc/8pQTFyYXRlLWxpbWl0LWNvbXBhdGliaWxpdHng4CFg+BZJ50EbbGVhc2UtcGF5bG9hZODgAQMh4OA=", + "Results": [ + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 12192.023325579,"P50Milliseconds": 0.0793,"P95Milliseconds": 0.1048,"P99Milliseconds": 0.1162,"ProcessAllocatedBytesPerOperation": 9021.486572265625,"ProcessRetainedBytesDelta": -219064,"Gen0Collections": 17,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 12215.247968079912,"P50Milliseconds": 0.0791,"P95Milliseconds": 0.1045,"P99Milliseconds": 0.1161,"ProcessAllocatedBytesPerOperation": 9019.529296875,"ProcessRetainedBytesDelta": -235824,"Gen0Collections": 17,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 12194.17890209073,"P50Milliseconds": 0.0792,"P95Milliseconds": 0.1042,"P99Milliseconds": 0.115,"ProcessAllocatedBytesPerOperation": 9020.57275390625,"ProcessRetainedBytesDelta": -244056,"Gen0Collections": 17,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 24253.520342817825,"P50Milliseconds": 0.5604,"P95Milliseconds": 0.9757,"P99Milliseconds": 1.2412,"ProcessAllocatedBytesPerOperation": 12831.8310546875,"ProcessRetainedBytesDelta": -241240,"Gen0Collections": 25,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 26152.523130273774,"P50Milliseconds": 0.5546,"P95Milliseconds": 0.9189,"P99Milliseconds": 1.297,"ProcessAllocatedBytesPerOperation": 11061.656494140625,"ProcessRetainedBytesDelta": -409928,"Gen0Collections": 21,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 25023.72702632776,"P50Milliseconds": 0.562,"P95Milliseconds": 0.9543,"P99Milliseconds": 1.2986,"ProcessAllocatedBytesPerOperation": 11649.198974609375,"ProcessRetainedBytesDelta": -197840,"Gen0Collections": 22,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 54848.643153904115,"P50Milliseconds": 0.2546,"P95Milliseconds": 0.4511,"P99Milliseconds": 1.1283,"ProcessAllocatedBytesPerOperation": 9124.8466796875,"ProcessRetainedBytesDelta": -107840,"Gen0Collections": 17,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 54402.06292197584,"P50Milliseconds": 0.256,"P95Milliseconds": 0.4525,"P99Milliseconds": 1.209,"ProcessAllocatedBytesPerOperation": 9127.2783203125,"ProcessRetainedBytesDelta": -259768,"Gen0Collections": 17,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": true}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 55249.84842036825,"P50Milliseconds": 0.253,"P95Milliseconds": 0.4424,"P99Milliseconds": 1.1776,"ProcessAllocatedBytesPerOperation": 9124.05908203125,"ProcessRetainedBytesDelta": -258992,"Gen0Collections": 17,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": true} + ] +} diff --git a/docs/performance/2026-09-07-repeat-baseline.json b/docs/performance/2026-09-07-repeat-baseline.json new file mode 100644 index 0000000..696bc3e --- /dev/null +++ b/docs/performance/2026-09-07-repeat-baseline.json @@ -0,0 +1,101 @@ +{ + "FrameworkDescription": ".NET 10.0.11", + "OSDescription": "macOS 26.6.2", + "ProcessArchitecture": 3, + "ProcessorCount": 12, + "Timestamp": "2026-09-07T19:13:57.276076+00:00", + "Topology": "Two silos; preferred gateway 0; verified grain placement on silo 0", + "MetadataPayload": "IEAhMsVyuxo8rk6uUmeflS9SeyEgIGCKc/8pQTFyYXRlLWxpbWl0LWNvbXBhdGliaWxpdHng4CFg+BZJ50EbbGVhc2UtcGF5bG9hZODgAQMh4OA=", + "Results": [ + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 14846.297686391534,"P50Milliseconds": 0.0538,"P95Milliseconds": 0.1387,"P99Milliseconds": 0.1569,"ProcessAllocatedBytesPerOperation": 6262.6103515625,"ProcessRetainedBytesDelta": 571704,"Gen0Collections": 24,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 30485.456331035137,"P50Milliseconds": 0.0321,"P95Milliseconds": 0.041,"P99Milliseconds": 0.0499,"ProcessAllocatedBytesPerOperation": 6150.1669921875,"ProcessRetainedBytesDelta": 2169536,"Gen0Collections": 24,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 30365.415452408368,"P50Milliseconds": 0.0324,"P95Milliseconds": 0.0409,"P99Milliseconds": 0.0486,"ProcessAllocatedBytesPerOperation": 6149.857177734375,"ProcessRetainedBytesDelta": -2606352,"Gen0Collections": 24,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 29401.65197660789,"P50Milliseconds": 0.0326,"P95Milliseconds": 0.0426,"P99Milliseconds": 0.0508,"ProcessAllocatedBytesPerOperation": 8991.020751953125,"ProcessRetainedBytesDelta": 28016,"Gen0Collections": 35,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 29619.476324259867,"P50Milliseconds": 0.0322,"P95Milliseconds": 0.0426,"P99Milliseconds": 0.0491,"ProcessAllocatedBytesPerOperation": 8990.783447265625,"ProcessRetainedBytesDelta": -235696,"Gen0Collections": 35,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 28468.942067944405,"P50Milliseconds": 0.0346,"P95Milliseconds": 0.0436,"P99Milliseconds": 0.0567,"ProcessAllocatedBytesPerOperation": 8991.63232421875,"ProcessRetainedBytesDelta": -230808,"Gen0Collections": 35,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 63181.29626933804,"P50Milliseconds": 0.2376,"P95Milliseconds": 0.2815,"P99Milliseconds": 0.8831,"ProcessAllocatedBytesPerOperation": 6325.28466796875,"ProcessRetainedBytesDelta": 726240,"Gen0Collections": 25,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 61199.19265176394,"P50Milliseconds": 0.2372,"P95Milliseconds": 0.3571,"P99Milliseconds": 0.9839,"ProcessAllocatedBytesPerOperation": 6745.27197265625,"ProcessRetainedBytesDelta": -833536,"Gen0Collections": 26,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 61212.06539723114,"P50Milliseconds": 0.2369,"P95Milliseconds": 0.3598,"P99Milliseconds": 0.8358,"ProcessAllocatedBytesPerOperation": 7771.033447265625,"ProcessRetainedBytesDelta": -229776,"Gen0Collections": 30,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 58290.84441674111,"P50Milliseconds": 0.2364,"P95Milliseconds": 0.3669,"P99Milliseconds": 1.1308,"ProcessAllocatedBytesPerOperation": 10786.182861328125,"ProcessRetainedBytesDelta": 586480,"Gen0Collections": 42,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 59966.96400042311,"P50Milliseconds": 0.2341,"P95Milliseconds": 0.3551,"P99Milliseconds": 1.0548,"ProcessAllocatedBytesPerOperation": 10491.254638671875,"ProcessRetainedBytesDelta": -251048,"Gen0Collections": 41,"Gen1Collections": 6,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 61322.31098728209,"P50Milliseconds": 0.2341,"P95Milliseconds": 0.3425,"P99Milliseconds": 1.0117,"ProcessAllocatedBytesPerOperation": 10021.892333984375,"ProcessRetainedBytesDelta": -258240,"Gen0Collections": 39,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 136831.79769204912,"P50Milliseconds": 0.1046,"P95Milliseconds": 0.1317,"P99Milliseconds": 1.0695,"ProcessAllocatedBytesPerOperation": 6195.49365234375,"ProcessRetainedBytesDelta": 100256,"Gen0Collections": 24,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 138438.594490002,"P50Milliseconds": 0.1035,"P95Milliseconds": 0.1304,"P99Milliseconds": 1.0381,"ProcessAllocatedBytesPerOperation": 6180.802490234375,"ProcessRetainedBytesDelta": -616328,"Gen0Collections": 24,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 141131.5765105166,"P50Milliseconds": 0.1023,"P95Milliseconds": 0.1292,"P99Milliseconds": 0.9645,"ProcessAllocatedBytesPerOperation": 6181.15234375,"ProcessRetainedBytesDelta": -638688,"Gen0Collections": 24,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 130966.80746024793,"P50Milliseconds": 0.1039,"P95Milliseconds": 0.1322,"P99Milliseconds": 1.0987,"ProcessAllocatedBytesPerOperation": 9034.995849609375,"ProcessRetainedBytesDelta": -2437904,"Gen0Collections": 36,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 131305.145416201,"P50Milliseconds": 0.1026,"P95Milliseconds": 0.1328,"P99Milliseconds": 1.1043,"ProcessAllocatedBytesPerOperation": 9034.11376953125,"ProcessRetainedBytesDelta": 346080,"Gen0Collections": 36,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 132908.58397682538,"P50Milliseconds": 0.1027,"P95Milliseconds": 0.1325,"P99Milliseconds": 1.0412,"ProcessAllocatedBytesPerOperation": 9033.558837890625,"ProcessRetainedBytesDelta": -1504912,"Gen0Collections": 35,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "sequential","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 25340.41908202847,"P50Milliseconds": 0.0345,"P95Milliseconds": 0.0657,"P99Milliseconds": 0.1018,"ProcessAllocatedBytesPerOperation": 5952.992431640625,"ProcessRetainedBytesDelta": -128624,"Gen0Collections": 23,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "sequential","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 24445.007759171316,"P50Milliseconds": 0.0341,"P95Milliseconds": 0.0746,"P99Milliseconds": 0.1139,"ProcessAllocatedBytesPerOperation": 5953.007568359375,"ProcessRetainedBytesDelta": -203872,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "sequential","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 22216.59183731445,"P50Milliseconds": 0.0323,"P95Milliseconds": 0.0899,"P99Milliseconds": 0.1358,"ProcessAllocatedBytesPerOperation": 5955.357421875,"ProcessRetainedBytesDelta": -361376,"Gen0Collections": 23,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "sequential","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 21262.752346751055,"P50Milliseconds": 0.04,"P95Milliseconds": 0.0836,"P99Milliseconds": 0.1143,"ProcessAllocatedBytesPerOperation": 8853.847412109375,"ProcessRetainedBytesDelta": -3251848,"Gen0Collections": 34,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "sequential","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 24886.165614363923,"P50Milliseconds": 0.0371,"P95Milliseconds": 0.0658,"P99Milliseconds": 0.096,"ProcessAllocatedBytesPerOperation": 8850.984619140625,"ProcessRetainedBytesDelta": -230024,"Gen0Collections": 34,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "sequential","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 24730.730287114362,"P50Milliseconds": 0.0378,"P95Milliseconds": 0.0609,"P99Milliseconds": 0.0911,"ProcessAllocatedBytesPerOperation": 8848.617919921875,"ProcessRetainedBytesDelta": -286160,"Gen0Collections": 34,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "hot-key-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 55904.88526259328,"P50Milliseconds": 0.2407,"P95Milliseconds": 0.5065,"P99Milliseconds": 1.2474,"ProcessAllocatedBytesPerOperation": 6707.94140625,"ProcessRetainedBytesDelta": 83360,"Gen0Collections": 26,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "hot-key-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 49509.62535228845,"P50Milliseconds": 0.2426,"P95Milliseconds": 0.6022,"P99Milliseconds": 1.52,"ProcessAllocatedBytesPerOperation": 5993.66552734375,"ProcessRetainedBytesDelta": -153680,"Gen0Collections": 23,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "hot-key-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 60952.53968295304,"P50Milliseconds": 0.2387,"P95Milliseconds": 0.3376,"P99Milliseconds": 1.1077,"ProcessAllocatedBytesPerOperation": 6249.937255859375,"ProcessRetainedBytesDelta": 132632,"Gen0Collections": 24,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "hot-key-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 50181.93401369712,"P50Milliseconds": 0.2379,"P95Milliseconds": 0.6153,"P99Milliseconds": 1.5219,"ProcessAllocatedBytesPerOperation": 8951.361328125,"ProcessRetainedBytesDelta": -3860112,"Gen0Collections": 35,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "hot-key-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 54300.88603508744,"P50Milliseconds": 0.239,"P95Milliseconds": 0.5414,"P99Milliseconds": 1.3601,"ProcessAllocatedBytesPerOperation": 8915.960693359375,"ProcessRetainedBytesDelta": 2472528,"Gen0Collections": 35,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "hot-key-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 51916.915823394396,"P50Milliseconds": 0.2382,"P95Milliseconds": 0.5457,"P99Milliseconds": 1.3478,"ProcessAllocatedBytesPerOperation": 8839.53369140625,"ProcessRetainedBytesDelta": -3167024,"Gen0Collections": 35,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "partitioned-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 134386.61908234216,"P50Milliseconds": 0.1016,"P95Milliseconds": 0.1637,"P99Milliseconds": 1.033,"ProcessAllocatedBytesPerOperation": 5983.484619140625,"ProcessRetainedBytesDelta": -2637200,"Gen0Collections": 23,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "partitioned-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 139847.545348665,"P50Milliseconds": 0.0996,"P95Milliseconds": 0.138,"P99Milliseconds": 1.0602,"ProcessAllocatedBytesPerOperation": 5980.722900390625,"ProcessRetainedBytesDelta": -293192,"Gen0Collections": 23,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "partitioned-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 139421.56728426632,"P50Milliseconds": 0.0995,"P95Milliseconds": 0.141,"P99Milliseconds": 1.0784,"ProcessAllocatedBytesPerOperation": 5971.11962890625,"ProcessRetainedBytesDelta": -684992,"Gen0Collections": 23,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "partitioned-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 116249.53348347005,"P50Milliseconds": 0.1025,"P95Milliseconds": 0.2328,"P99Milliseconds": 1.1155,"ProcessAllocatedBytesPerOperation": 8891.1083984375,"ProcessRetainedBytesDelta": -2943376,"Gen0Collections": 35,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "partitioned-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 101553.26789354137,"P50Milliseconds": 0.115,"P95Milliseconds": 0.3149,"P99Milliseconds": 1.1153,"ProcessAllocatedBytesPerOperation": 8903.61865234375,"ProcessRetainedBytesDelta": 2143688,"Gen0Collections": 35,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "partitioned-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 106549.36524958526,"P50Milliseconds": 0.1083,"P95Milliseconds": 0.2962,"P99Milliseconds": 1.1192,"ProcessAllocatedBytesPerOperation": 8890.43408203125,"ProcessRetainedBytesDelta": -2846016,"Gen0Collections": 35,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "abandoned-time-leases","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 38892.52213875638,"P50Milliseconds": 0.0207,"P95Milliseconds": 0.0512,"P99Milliseconds": 0.071,"ProcessAllocatedBytesPerOperation": 3511.69677734375,"ProcessRetainedBytesDelta": 4726568,"Gen0Collections": 13,"Gen1Collections": 6,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "abandoned-time-leases","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 47998.62660179743,"P50Milliseconds": 0.0189,"P95Milliseconds": 0.0265,"P99Milliseconds": 0.0473,"ProcessAllocatedBytesPerOperation": 3537.2802734375,"ProcessRetainedBytesDelta": 4483416,"Gen0Collections": 13,"Gen1Collections": 5,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "abandoned-time-leases","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 46747.41799978173,"P50Milliseconds": 0.0189,"P95Milliseconds": 0.0341,"P99Milliseconds": 0.0563,"ProcessAllocatedBytesPerOperation": 3404.654541015625,"ProcessRetainedBytesDelta": 7461464,"Gen0Collections": 13,"Gen1Collections": 5,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "abandoned-time-leases","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 44174.33439895647,"P50Milliseconds": 0.02,"P95Milliseconds": 0.0303,"P99Milliseconds": 0.0564,"ProcessAllocatedBytesPerOperation": 6390.659912109375,"ProcessRetainedBytesDelta": 6929192,"Gen0Collections": 25,"Gen1Collections": 7,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "abandoned-time-leases","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 38618.18368669301,"P50Milliseconds": 0.0209,"P95Milliseconds": 0.0484,"P99Milliseconds": 0.0719,"ProcessAllocatedBytesPerOperation": 6429.630859375,"ProcessRetainedBytesDelta": 7357088,"Gen0Collections": 25,"Gen1Collections": 5,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "abandoned-time-leases","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 43148.32103678168,"P50Milliseconds": 0.0198,"P95Milliseconds": 0.0357,"P99Milliseconds": 0.0598,"ProcessAllocatedBytesPerOperation": 6304.23828125,"ProcessRetainedBytesDelta": -860896,"Gen0Collections": 24,"Gen1Collections": 5,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "sequential","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 25957.789598521686,"P50Milliseconds": 0.0361,"P95Milliseconds": 0.0607,"P99Milliseconds": 0.0895,"ProcessAllocatedBytesPerOperation": 5986.112060546875,"ProcessRetainedBytesDelta": 27168,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "sequential","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 25531.81608833223,"P50Milliseconds": 0.0352,"P95Milliseconds": 0.0732,"P99Milliseconds": 0.1113,"ProcessAllocatedBytesPerOperation": 5983.29296875,"ProcessRetainedBytesDelta": -335704,"Gen0Collections": 23,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "sequential","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 23108.333146452587,"P50Milliseconds": 0.0384,"P95Milliseconds": 0.075,"P99Milliseconds": 0.0955,"ProcessAllocatedBytesPerOperation": 5989.616455078125,"ProcessRetainedBytesDelta": -290168,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "sequential","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 22711.370898907437,"P50Milliseconds": 0.0371,"P95Milliseconds": 0.083,"P99Milliseconds": 0.1182,"ProcessAllocatedBytesPerOperation": 8911.3515625,"ProcessRetainedBytesDelta": 1426840,"Gen0Collections": 35,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "sequential","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 21976.640832086272,"P50Milliseconds": 0.0297,"P95Milliseconds": 0.0913,"P99Milliseconds": 0.1523,"ProcessAllocatedBytesPerOperation": 8913.592529296875,"ProcessRetainedBytesDelta": 984520,"Gen0Collections": 35,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "sequential","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 24670.830973638018,"P50Milliseconds": 0.0311,"P95Milliseconds": 0.077,"P99Milliseconds": 0.1113,"ProcessAllocatedBytesPerOperation": 8908.331298828125,"ProcessRetainedBytesDelta": -2867576,"Gen0Collections": 35,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "hot-key-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 57036.03393476921,"P50Milliseconds": 0.2475,"P95Milliseconds": 0.434,"P99Milliseconds": 1.135,"ProcessAllocatedBytesPerOperation": 6200.09765625,"ProcessRetainedBytesDelta": 63960,"Gen0Collections": 24,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "hot-key-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 56015.77221437863,"P50Milliseconds": 0.2455,"P95Milliseconds": 0.521,"P99Milliseconds": 1.1594,"ProcessAllocatedBytesPerOperation": 6116.864501953125,"ProcessRetainedBytesDelta": 1682288,"Gen0Collections": 24,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "hot-key-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 38902.23237886652,"P50Milliseconds": 0.2955,"P95Milliseconds": 0.8781,"P99Milliseconds": 2.1606,"ProcessAllocatedBytesPerOperation": 5890.4091796875,"ProcessRetainedBytesDelta": -2159424,"Gen0Collections": 23,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "hot-key-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 43850.88024272147,"P50Milliseconds": 0.3105,"P95Milliseconds": 0.8242,"P99Milliseconds": 1.4845,"ProcessAllocatedBytesPerOperation": 8812.0322265625,"ProcessRetainedBytesDelta": -3439976,"Gen0Collections": 34,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "hot-key-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 55258.4574040397,"P50Milliseconds": 0.2457,"P95Milliseconds": 0.5295,"P99Milliseconds": 1.1593,"ProcessAllocatedBytesPerOperation": 9019.54150390625,"ProcessRetainedBytesDelta": -263824,"Gen0Collections": 35,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "hot-key-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 58664.004634685516,"P50Milliseconds": 0.2402,"P95Milliseconds": 0.4015,"P99Milliseconds": 1.0683,"ProcessAllocatedBytesPerOperation": 9166.842529296875,"ProcessRetainedBytesDelta": 2620448,"Gen0Collections": 36,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "partitioned-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 120012.55502437205,"P50Milliseconds": 0.1088,"P95Milliseconds": 0.2383,"P99Milliseconds": 0.942,"ProcessAllocatedBytesPerOperation": 6019.133544921875,"ProcessRetainedBytesDelta": -260952,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "partitioned-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 117102.51330125594,"P50Milliseconds": 0.1063,"P95Milliseconds": 0.251,"P99Milliseconds": 0.966,"ProcessAllocatedBytesPerOperation": 6018.369140625,"ProcessRetainedBytesDelta": -279672,"Gen0Collections": 23,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "partitioned-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 109179.46474474203,"P50Milliseconds": 0.1121,"P95Milliseconds": 0.2891,"P99Milliseconds": 1.0085,"ProcessAllocatedBytesPerOperation": 6019.91796875,"ProcessRetainedBytesDelta": -149048,"Gen0Collections": 23,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "partitioned-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 100170.54793913342,"P50Milliseconds": 0.1178,"P95Milliseconds": 0.3176,"P99Milliseconds": 1.1343,"ProcessAllocatedBytesPerOperation": 8943.3017578125,"ProcessRetainedBytesDelta": 35200,"Gen0Collections": 35,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "partitioned-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 104367.32405850277,"P50Milliseconds": 0.1156,"P95Milliseconds": 0.304,"P99Milliseconds": 1.0799,"ProcessAllocatedBytesPerOperation": 8951.8408203125,"ProcessRetainedBytesDelta": -296336,"Gen0Collections": 35,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "partitioned-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 107606.4012147754,"P50Milliseconds": 0.1125,"P95Milliseconds": 0.2736,"P99Milliseconds": 1.1409,"ProcessAllocatedBytesPerOperation": 8941.581787109375,"ProcessRetainedBytesDelta": -226976,"Gen0Collections": 35,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "abandoned-time-leases","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 35846.34032144435,"P50Milliseconds": 0.0139,"P95Milliseconds": 0.057,"P99Milliseconds": 0.1701,"ProcessAllocatedBytesPerOperation": 3541.017578125,"ProcessRetainedBytesDelta": 4449464,"Gen0Collections": 13,"Gen1Collections": 6,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "abandoned-time-leases","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 45638.72840541475,"P50Milliseconds": 0.015,"P95Milliseconds": 0.0465,"P99Milliseconds": 0.0729,"ProcessAllocatedBytesPerOperation": 3563.540283203125,"ProcessRetainedBytesDelta": 4480536,"Gen0Collections": 13,"Gen1Collections": 6,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "abandoned-time-leases","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 41281.77596296533,"P50Milliseconds": 0.0164,"P95Milliseconds": 0.0505,"P99Milliseconds": 0.0745,"ProcessAllocatedBytesPerOperation": 3439.44140625,"ProcessRetainedBytesDelta": 8607664,"Gen0Collections": 13,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "abandoned-time-leases","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 23598.894838865235,"P50Milliseconds": 0.0196,"P95Milliseconds": 0.0665,"P99Milliseconds": 0.2469,"ProcessAllocatedBytesPerOperation": 6484.947021484375,"ProcessRetainedBytesDelta": 4926088,"Gen0Collections": 25,"Gen1Collections": 7,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "abandoned-time-leases","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 24695.38117691639,"P50Milliseconds": 0.0177,"P95Milliseconds": 0.0717,"P99Milliseconds": 0.2625,"ProcessAllocatedBytesPerOperation": 6506.489501953125,"ProcessRetainedBytesDelta": 4471520,"Gen0Collections": 25,"Gen1Collections": 6,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "abandoned-time-leases","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 26146.64463096909,"P50Milliseconds": 0.0205,"P95Milliseconds": 0.0727,"P99Milliseconds": 0.2372,"ProcessAllocatedBytesPerOperation": 6376.139892578125,"ProcessRetainedBytesDelta": 4808952,"Gen0Collections": 25,"Gen1Collections": 7,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "sequential","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 24114.680731797547,"P50Milliseconds": 0.0235,"P95Milliseconds": 0.0919,"P99Milliseconds": 0.227,"ProcessAllocatedBytesPerOperation": 5987.44287109375,"ProcessRetainedBytesDelta": -27200,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "sequential","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 17126.206995675067,"P50Milliseconds": 0.0241,"P95Milliseconds": 0.1157,"P99Milliseconds": 0.3771,"ProcessAllocatedBytesPerOperation": 6006.54736328125,"ProcessRetainedBytesDelta": -312968,"Gen0Collections": 23,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "sequential","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 19301.389596381367,"P50Milliseconds": 0.0391,"P95Milliseconds": 0.0928,"P99Milliseconds": 0.2305,"ProcessAllocatedBytesPerOperation": 5999.451171875,"ProcessRetainedBytesDelta": -379104,"Gen0Collections": 23,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "sequential","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 24809.033734774108,"P50Milliseconds": 0.0337,"P95Milliseconds": 0.0767,"P99Milliseconds": 0.1172,"ProcessAllocatedBytesPerOperation": 8883.024658203125,"ProcessRetainedBytesDelta": -3042320,"Gen0Collections": 35,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "sequential","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 19346.30644247119,"P50Milliseconds": 0.0433,"P95Milliseconds": 0.0904,"P99Milliseconds": 0.1237,"ProcessAllocatedBytesPerOperation": 8896.132080078125,"ProcessRetainedBytesDelta": 1051384,"Gen0Collections": 35,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "sequential","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 17261.351334978626,"P50Milliseconds": 0.046,"P95Milliseconds": 0.0986,"P99Milliseconds": 0.2273,"ProcessAllocatedBytesPerOperation": 8903.986572265625,"ProcessRetainedBytesDelta": 1067664,"Gen0Collections": 35,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "hot-key-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 55237.36829542282,"P50Milliseconds": 0.24,"P95Milliseconds": 0.5287,"P99Milliseconds": 1.134,"ProcessAllocatedBytesPerOperation": 6287.48388671875,"ProcessRetainedBytesDelta": 29216,"Gen0Collections": 24,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "hot-key-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 51947.343706949,"P50Milliseconds": 0.2436,"P95Milliseconds": 0.5772,"P99Milliseconds": 1.3311,"ProcessAllocatedBytesPerOperation": 5907.857177734375,"ProcessRetainedBytesDelta": 3169360,"Gen0Collections": 23,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "hot-key-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 42665.23338148276,"P50Milliseconds": 0.2705,"P95Milliseconds": 0.7506,"P99Milliseconds": 1.5443,"ProcessAllocatedBytesPerOperation": 5923.661376953125,"ProcessRetainedBytesDelta": -3814360,"Gen0Collections": 23,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "hot-key-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 41366.59223507859,"P50Milliseconds": 0.249,"P95Milliseconds": 0.9518,"P99Milliseconds": 2.2704,"ProcessAllocatedBytesPerOperation": 8859.540771484375,"ProcessRetainedBytesDelta": -2082224,"Gen0Collections": 35,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "hot-key-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 38141.69135493796,"P50Milliseconds": 0.2608,"P95Milliseconds": 1.0468,"P99Milliseconds": 2.1888,"ProcessAllocatedBytesPerOperation": 8832.613525390625,"ProcessRetainedBytesDelta": 7432,"Gen0Collections": 35,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "hot-key-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 27972.476885658063,"P50Milliseconds": 0.3032,"P95Milliseconds": 1.6182,"P99Milliseconds": 4.3796,"ProcessAllocatedBytesPerOperation": 8875.17041015625,"ProcessRetainedBytesDelta": 1543712,"Gen0Collections": 35,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "partitioned-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 77271.04550073255,"P50Milliseconds": 0.1219,"P95Milliseconds": 0.4825,"P99Milliseconds": 1.7173,"ProcessAllocatedBytesPerOperation": 6052.167724609375,"ProcessRetainedBytesDelta": 25888,"Gen0Collections": 23,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "partitioned-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 67546.25768419074,"P50Milliseconds": 0.1282,"P95Milliseconds": 0.512,"P99Milliseconds": 1.8598,"ProcessAllocatedBytesPerOperation": 6064.066162109375,"ProcessRetainedBytesDelta": -71856,"Gen0Collections": 24,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "partitioned-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 60049.75802703705,"P50Milliseconds": 0.1405,"P95Milliseconds": 0.5583,"P99Milliseconds": 2.4202,"ProcessAllocatedBytesPerOperation": 6076.812744140625,"ProcessRetainedBytesDelta": 409920,"Gen0Collections": 24,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "partitioned-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 84774.06733132695,"P50Milliseconds": 0.1238,"P95Milliseconds": 0.4176,"P99Milliseconds": 1.3833,"ProcessAllocatedBytesPerOperation": 8936.460693359375,"ProcessRetainedBytesDelta": 3341408,"Gen0Collections": 35,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "partitioned-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 97870.43397747977,"P50Milliseconds": 0.1199,"P95Milliseconds": 0.3267,"P99Milliseconds": 1.2055,"ProcessAllocatedBytesPerOperation": 8923.409423828125,"ProcessRetainedBytesDelta": -3592464,"Gen0Collections": 35,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "partitioned-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 98887.78403303905,"P50Milliseconds": 0.1161,"P95Milliseconds": 0.2987,"P99Milliseconds": 1.1855,"ProcessAllocatedBytesPerOperation": 8923.77392578125,"ProcessRetainedBytesDelta": 2867088,"Gen0Collections": 35,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "abandoned-time-leases","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 28583.852757568053,"P50Milliseconds": 0.0209,"P95Milliseconds": 0.0703,"P99Milliseconds": 0.2023,"ProcessAllocatedBytesPerOperation": 3543.615234375,"ProcessRetainedBytesDelta": 4908760,"Gen0Collections": 13,"Gen1Collections": 5,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "abandoned-time-leases","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 41083.476388284995,"P50Milliseconds": 0.0196,"P95Milliseconds": 0.0495,"P99Milliseconds": 0.0731,"ProcessAllocatedBytesPerOperation": 3560.371337890625,"ProcessRetainedBytesDelta": 4496688,"Gen0Collections": 13,"Gen1Collections": 5,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "abandoned-time-leases","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 46216.44466515855,"P50Milliseconds": 0.0178,"P95Milliseconds": 0.0399,"P99Milliseconds": 0.067,"ProcessAllocatedBytesPerOperation": 3429.22021484375,"ProcessRetainedBytesDelta": 8281200,"Gen0Collections": 13,"Gen1Collections": 5,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "abandoned-time-leases","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 41551.585504674615,"P50Milliseconds": 0.0143,"P95Milliseconds": 0.0434,"P99Milliseconds": 0.1532,"ProcessAllocatedBytesPerOperation": 6433.25048828125,"ProcessRetainedBytesDelta": 7825824,"Gen0Collections": 25,"Gen1Collections": 6,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "abandoned-time-leases","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 26104.24010674952,"P50Milliseconds": 0.0145,"P95Milliseconds": 0.0745,"P99Milliseconds": 0.2836,"ProcessAllocatedBytesPerOperation": 6476.367431640625,"ProcessRetainedBytesDelta": 1732800,"Gen0Collections": 25,"Gen1Collections": 6,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "abandoned-time-leases","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 36588.616237248054,"P50Milliseconds": 0.0177,"P95Milliseconds": 0.0561,"P99Milliseconds": 0.1312,"ProcessAllocatedBytesPerOperation": 6339.228759765625,"ProcessRetainedBytesDelta": 3884888,"Gen0Collections": 24,"Gen1Collections": 5,"Gen2Collections": 0,"TokenCanBeCanceled": false} + ] +} diff --git a/docs/performance/2026-09-07-repeat-updated.json b/docs/performance/2026-09-07-repeat-updated.json new file mode 100644 index 0000000..e57c8a3 --- /dev/null +++ b/docs/performance/2026-09-07-repeat-updated.json @@ -0,0 +1,101 @@ +{ + "FrameworkDescription": ".NET 10.0.11", + "OSDescription": "macOS 26.6.2", + "ProcessArchitecture": 3, + "ProcessorCount": 12, + "Timestamp": "2026-09-07T19:12:09.897973+00:00", + "Topology": "Two silos; preferred gateway 0; verified grain placement on silo 0", + "MetadataPayload": "IEAhMsVyuxo8rk6uUmeflS9SeyEgIGCKc/8pQTFyYXRlLWxpbWl0LWNvbXBhdGliaWxpdHng4CFg+BZJ50EbbGVhc2UtcGF5bG9hZODgAQMh4AEB4A==", + "Results": [ + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 11226.959558927952,"P50Milliseconds": 0.0662,"P95Milliseconds": 0.2278,"P99Milliseconds": 0.3857,"ProcessAllocatedBytesPerOperation": 5988.986572265625,"ProcessRetainedBytesDelta": -2091792,"Gen0Collections": 23,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 23527.64969133243,"P50Milliseconds": 0.0368,"P95Milliseconds": 0.0814,"P99Milliseconds": 0.121,"ProcessAllocatedBytesPerOperation": 5894.61669921875,"ProcessRetainedBytesDelta": 2118328,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 24499.032614187636,"P50Milliseconds": 0.0358,"P95Milliseconds": 0.0778,"P99Milliseconds": 0.12,"ProcessAllocatedBytesPerOperation": 5890.04638671875,"ProcessRetainedBytesDelta": -2578384,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 23254.005156076044,"P50Milliseconds": 0.0367,"P95Milliseconds": 0.0824,"P99Milliseconds": 0.1188,"ProcessAllocatedBytesPerOperation": 5892.821044921875,"ProcessRetainedBytesDelta": 2339768,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 23739.708240956297,"P50Milliseconds": 0.0364,"P95Milliseconds": 0.0831,"P99Milliseconds": 0.1235,"ProcessAllocatedBytesPerOperation": 5890.378662109375,"ProcessRetainedBytesDelta": -2560176,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 23008.623248329175,"P50Milliseconds": 0.0374,"P95Milliseconds": 0.085,"P99Milliseconds": 0.1256,"ProcessAllocatedBytesPerOperation": 5892.183349609375,"ProcessRetainedBytesDelta": 1982952,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 61469.20939994173,"P50Milliseconds": 0.2398,"P95Milliseconds": 0.3296,"P99Milliseconds": 0.9923,"ProcessAllocatedBytesPerOperation": 6164.93798828125,"ProcessRetainedBytesDelta": 279336,"Gen0Collections": 24,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 58184.60757174083,"P50Milliseconds": 0.244,"P95Milliseconds": 0.4032,"P99Milliseconds": 1.0851,"ProcessAllocatedBytesPerOperation": 6357.783447265625,"ProcessRetainedBytesDelta": 2827192,"Gen0Collections": 25,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 59242.02104820121,"P50Milliseconds": 0.2426,"P95Milliseconds": 0.388,"P99Milliseconds": 1.0415,"ProcessAllocatedBytesPerOperation": 6319.5498046875,"ProcessRetainedBytesDelta": -3169936,"Gen0Collections": 25,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 62779.6590685934,"P50Milliseconds": 0.2349,"P95Milliseconds": 0.3223,"P99Milliseconds": 1.06,"ProcessAllocatedBytesPerOperation": 6111.368408203125,"ProcessRetainedBytesDelta": 2292304,"Gen0Collections": 24,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 60046.22577182984,"P50Milliseconds": 0.2363,"P95Milliseconds": 0.3694,"P99Milliseconds": 1.0987,"ProcessAllocatedBytesPerOperation": 6099.329833984375,"ProcessRetainedBytesDelta": -2530088,"Gen0Collections": 24,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 61920.943840871034,"P50Milliseconds": 0.2352,"P95Milliseconds": 0.3433,"P99Milliseconds": 1.0275,"ProcessAllocatedBytesPerOperation": 6133.80908203125,"ProcessRetainedBytesDelta": -328712,"Gen0Collections": 24,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 112727.88195222069,"P50Milliseconds": 0.1085,"P95Milliseconds": 0.2696,"P99Milliseconds": 1.0451,"ProcessAllocatedBytesPerOperation": 5942.4912109375,"ProcessRetainedBytesDelta": 13496,"Gen0Collections": 23,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 140023.8358446149,"P50Milliseconds": 0.1027,"P95Milliseconds": 0.1324,"P99Milliseconds": 0.9361,"ProcessAllocatedBytesPerOperation": 5917.516357421875,"ProcessRetainedBytesDelta": -739568,"Gen0Collections": 23,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 137647.01236924416,"P50Milliseconds": 0.1025,"P95Milliseconds": 0.1534,"P99Milliseconds": 0.9323,"ProcessAllocatedBytesPerOperation": 5915.320068359375,"ProcessRetainedBytesDelta": -575592,"Gen0Collections": 23,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 123501.45934341606,"P50Milliseconds": 0.1053,"P95Milliseconds": 0.2207,"P99Milliseconds": 0.9768,"ProcessAllocatedBytesPerOperation": 5941.82177734375,"ProcessRetainedBytesDelta": 8480,"Gen0Collections": 23,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 88615.18757440263,"P50Milliseconds": 0.1134,"P95Milliseconds": 0.4066,"P99Milliseconds": 1.4286,"ProcessAllocatedBytesPerOperation": 5954.98583984375,"ProcessRetainedBytesDelta": -216560,"Gen0Collections": 23,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 98543.64829829663,"P50Milliseconds": 0.1053,"P95Milliseconds": 0.3153,"P99Milliseconds": 1.2282,"ProcessAllocatedBytesPerOperation": 5942.03173828125,"ProcessRetainedBytesDelta": -747368,"Gen0Collections": 23,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "sequential","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 44358.82892041862,"P50Milliseconds": 0.0192,"P95Milliseconds": 0.0467,"P99Milliseconds": 0.0686,"ProcessAllocatedBytesPerOperation": 3603.563720703125,"ProcessRetainedBytesDelta": 2003272,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "sequential","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 38578.08306202232,"P50Milliseconds": 0.0212,"P95Milliseconds": 0.0548,"P99Milliseconds": 0.081,"ProcessAllocatedBytesPerOperation": 3605.78173828125,"ProcessRetainedBytesDelta": -2281888,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "sequential","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 45617.09482809505,"P50Milliseconds": 0.0197,"P95Milliseconds": 0.0415,"P99Milliseconds": 0.0653,"ProcessAllocatedBytesPerOperation": 3603.32958984375,"ProcessRetainedBytesDelta": 1790672,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "sequential","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 40691.921620365036,"P50Milliseconds": 0.02,"P95Milliseconds": 0.0521,"P99Milliseconds": 0.0766,"ProcessAllocatedBytesPerOperation": 3604.20361328125,"ProcessRetainedBytesDelta": 2024736,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "sequential","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 51036.110290828576,"P50Milliseconds": 0.0188,"P95Milliseconds": 0.0252,"P99Milliseconds": 0.0424,"ProcessAllocatedBytesPerOperation": 3601.875,"ProcessRetainedBytesDelta": -2287192,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "sequential","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 50510.25874021853,"P50Milliseconds": 0.0192,"P95Milliseconds": 0.0254,"P99Milliseconds": 0.0382,"ProcessAllocatedBytesPerOperation": 3601.826171875,"ProcessRetainedBytesDelta": 1705400,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "hot-key-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 110856.62862961748,"P50Milliseconds": 0.1335,"P95Milliseconds": 0.1793,"P99Milliseconds": 0.37,"ProcessAllocatedBytesPerOperation": 3815.242431640625,"ProcessRetainedBytesDelta": 1145864,"Gen0Collections": 15,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "hot-key-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 104667.7578075989,"P50Milliseconds": 0.1353,"P95Milliseconds": 0.2157,"P99Milliseconds": 0.4653,"ProcessAllocatedBytesPerOperation": 3736.965087890625,"ProcessRetainedBytesDelta": -1393384,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "hot-key-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 109574.63919587302,"P50Milliseconds": 0.1334,"P95Milliseconds": 0.1773,"P99Milliseconds": 0.3851,"ProcessAllocatedBytesPerOperation": 3604.5869140625,"ProcessRetainedBytesDelta": 2688664,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "hot-key-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 101268.38904516345,"P50Milliseconds": 0.1357,"P95Milliseconds": 0.2255,"P99Milliseconds": 0.7939,"ProcessAllocatedBytesPerOperation": 3846.642333984375,"ProcessRetainedBytesDelta": 2228384,"Gen0Collections": 15,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "hot-key-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 97771.35145441152,"P50Milliseconds": 0.1363,"P95Milliseconds": 0.2503,"P99Milliseconds": 0.6891,"ProcessAllocatedBytesPerOperation": 3978.5673828125,"ProcessRetainedBytesDelta": -2504432,"Gen0Collections": 15,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "hot-key-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 85943.23937925103,"P50Milliseconds": 0.1386,"P95Milliseconds": 0.3584,"P99Milliseconds": 0.8368,"ProcessAllocatedBytesPerOperation": 3633.304931640625,"ProcessRetainedBytesDelta": -28096,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "partitioned-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 183993.5494448974,"P50Milliseconds": 0.0587,"P95Milliseconds": 0.1669,"P99Milliseconds": 0.6601,"ProcessAllocatedBytesPerOperation": 3634.900390625,"ProcessRetainedBytesDelta": -120960,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "partitioned-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 238795.8764569478,"P50Milliseconds": 0.0568,"P95Milliseconds": 0.0946,"P99Milliseconds": 0.1839,"ProcessAllocatedBytesPerOperation": 3610.509765625,"ProcessRetainedBytesDelta": 1936704,"Gen0Collections": 14,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "partitioned-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 227137.2444636683,"P50Milliseconds": 0.0569,"P95Milliseconds": 0.1004,"P99Milliseconds": 0.259,"ProcessAllocatedBytesPerOperation": 3623.267822265625,"ProcessRetainedBytesDelta": -342760,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "partitioned-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 254033.6210024281,"P50Milliseconds": 0.0562,"P95Milliseconds": 0.0813,"P99Milliseconds": 0.1491,"ProcessAllocatedBytesPerOperation": 3623.699951171875,"ProcessRetainedBytesDelta": 4328,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "partitioned-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 242781.67579221894,"P50Milliseconds": 0.0553,"P95Milliseconds": 0.098,"P99Milliseconds": 0.2349,"ProcessAllocatedBytesPerOperation": 3623.359130859375,"ProcessRetainedBytesDelta": 2864832,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "partitioned-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 253354.62147189697,"P50Milliseconds": 0.0548,"P95Milliseconds": 0.0836,"P99Milliseconds": 0.1675,"ProcessAllocatedBytesPerOperation": 3622.77099609375,"ProcessRetainedBytesDelta": -3440848,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "abandoned-time-leases","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 38307.17542916416,"P50Milliseconds": 0.021,"P95Milliseconds": 0.0557,"P99Milliseconds": 0.083,"ProcessAllocatedBytesPerOperation": 3610.83837890625,"ProcessRetainedBytesDelta": 2194656,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "abandoned-time-leases","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 45793.299599532234,"P50Milliseconds": 0.0195,"P95Milliseconds": 0.0414,"P99Milliseconds": 0.0634,"ProcessAllocatedBytesPerOperation": 3607.340576171875,"ProcessRetainedBytesDelta": -2558256,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "abandoned-time-leases","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 40738.55539749805,"P50Milliseconds": 0.0207,"P95Milliseconds": 0.0496,"P99Milliseconds": 0.074,"ProcessAllocatedBytesPerOperation": 3608.396484375,"ProcessRetainedBytesDelta": 1890792,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "abandoned-time-leases","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 42464.484849867156,"P50Milliseconds": 0.02,"P95Milliseconds": 0.048,"P99Milliseconds": 0.0712,"ProcessAllocatedBytesPerOperation": 3608.468017578125,"ProcessRetainedBytesDelta": 2151312,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "abandoned-time-leases","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 47256.0608479469,"P50Milliseconds": 0.0162,"P95Milliseconds": 0.0427,"P99Milliseconds": 0.0696,"ProcessAllocatedBytesPerOperation": 3604.65478515625,"ProcessRetainedBytesDelta": -2466192,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "abandoned-time-leases","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 49122.53139341709,"P50Milliseconds": 0.0195,"P95Milliseconds": 0.0265,"P99Milliseconds": 0.0494,"ProcessAllocatedBytesPerOperation": 3604.89794921875,"ProcessRetainedBytesDelta": 1782000,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "sequential","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 47019.881138768484,"P50Milliseconds": 0.0199,"P95Milliseconds": 0.031,"P99Milliseconds": 0.0538,"ProcessAllocatedBytesPerOperation": 3632.044189453125,"ProcessRetainedBytesDelta": 3052840,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "sequential","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 47668.02294372735,"P50Milliseconds": 0.0198,"P95Milliseconds": 0.0287,"P99Milliseconds": 0.0505,"ProcessAllocatedBytesPerOperation": 3631.611083984375,"ProcessRetainedBytesDelta": -3303032,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "sequential","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 44785.30919398607,"P50Milliseconds": 0.0199,"P95Milliseconds": 0.0412,"P99Milliseconds": 0.0661,"ProcessAllocatedBytesPerOperation": 3632.077880859375,"ProcessRetainedBytesDelta": 2746520,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "sequential","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 45829.80649326455,"P50Milliseconds": 0.0199,"P95Milliseconds": 0.0375,"P99Milliseconds": 0.0604,"ProcessAllocatedBytesPerOperation": 3631.544921875,"ProcessRetainedBytesDelta": 2957360,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "sequential","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 49745.91901440898,"P50Milliseconds": 0.0197,"P95Milliseconds": 0.025,"P99Milliseconds": 0.0374,"ProcessAllocatedBytesPerOperation": 3630.793212890625,"ProcessRetainedBytesDelta": -3237272,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "sequential","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 50156.95477730912,"P50Milliseconds": 0.0194,"P95Milliseconds": 0.0251,"P99Milliseconds": 0.0397,"ProcessAllocatedBytesPerOperation": 3629.6171875,"ProcessRetainedBytesDelta": 2650632,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "hot-key-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 111679.17865635306,"P50Milliseconds": 0.1325,"P95Milliseconds": 0.1782,"P99Milliseconds": 0.3266,"ProcessAllocatedBytesPerOperation": 3836.547607421875,"ProcessRetainedBytesDelta": 2052160,"Gen0Collections": 15,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "hot-key-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 109540.79314781689,"P50Milliseconds": 0.1335,"P95Milliseconds": 0.1836,"P99Milliseconds": 0.3399,"ProcessAllocatedBytesPerOperation": 3775.15625,"ProcessRetainedBytesDelta": -2320152,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "hot-key-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 107701.85963852431,"P50Milliseconds": 0.1338,"P95Milliseconds": 0.2026,"P99Milliseconds": 0.3126,"ProcessAllocatedBytesPerOperation": 4338.274658203125,"ProcessRetainedBytesDelta": 1246784,"Gen0Collections": 17,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "hot-key-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 111924.34176819146,"P50Milliseconds": 0.1334,"P95Milliseconds": 0.1633,"P99Milliseconds": 0.3155,"ProcessAllocatedBytesPerOperation": 3723.459716796875,"ProcessRetainedBytesDelta": 178432,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "hot-key-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 113807.92897562792,"P50Milliseconds": 0.1321,"P95Milliseconds": 0.1622,"P99Milliseconds": 0.2587,"ProcessAllocatedBytesPerOperation": 3708.06201171875,"ProcessRetainedBytesDelta": -371264,"Gen0Collections": 14,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "hot-key-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 113392.63653798106,"P50Milliseconds": 0.1325,"P95Milliseconds": 0.1605,"P99Milliseconds": 0.2744,"ProcessAllocatedBytesPerOperation": 3672.033447265625,"ProcessRetainedBytesDelta": -376160,"Gen0Collections": 14,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "partitioned-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 253936.3702164132,"P50Milliseconds": 0.0562,"P95Milliseconds": 0.0792,"P99Milliseconds": 0.1312,"ProcessAllocatedBytesPerOperation": 3637.264404296875,"ProcessRetainedBytesDelta": -20736,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "partitioned-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 259427.48294063206,"P50Milliseconds": 0.0555,"P95Milliseconds": 0.0777,"P99Milliseconds": 0.1284,"ProcessAllocatedBytesPerOperation": 3635.146728515625,"ProcessRetainedBytesDelta": -743224,"Gen0Collections": 14,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "partitioned-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 256384.7774668076,"P50Milliseconds": 0.0552,"P95Milliseconds": 0.0785,"P99Milliseconds": 0.1296,"ProcessAllocatedBytesPerOperation": 3635.5263671875,"ProcessRetainedBytesDelta": -767904,"Gen0Collections": 14,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "partitioned-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 250801.35809915105,"P50Milliseconds": 0.0567,"P95Milliseconds": 0.0845,"P99Milliseconds": 0.1687,"ProcessAllocatedBytesPerOperation": 3647.530517578125,"ProcessRetainedBytesDelta": -190120,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "partitioned-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 237446.6944199882,"P50Milliseconds": 0.0559,"P95Milliseconds": 0.1001,"P99Milliseconds": 0.2416,"ProcessAllocatedBytesPerOperation": 3647.872802734375,"ProcessRetainedBytesDelta": -283048,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "partitioned-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 253319.95404865092,"P50Milliseconds": 0.0556,"P95Milliseconds": 0.079,"P99Milliseconds": 0.1393,"ProcessAllocatedBytesPerOperation": 3647.943359375,"ProcessRetainedBytesDelta": -287616,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "abandoned-time-leases","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 46041.71631288584,"P50Milliseconds": 0.0201,"P95Milliseconds": 0.0368,"P99Milliseconds": 0.058,"ProcessAllocatedBytesPerOperation": 3635.48828125,"ProcessRetainedBytesDelta": 3241544,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "abandoned-time-leases","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 49405.350638072036,"P50Milliseconds": 0.0199,"P95Milliseconds": 0.025,"P99Milliseconds": 0.042,"ProcessAllocatedBytesPerOperation": 3634.185791015625,"ProcessRetainedBytesDelta": 2989880,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "abandoned-time-leases","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 48543.09962979369,"P50Milliseconds": 0.0194,"P95Milliseconds": 0.03,"P99Milliseconds": 0.0545,"ProcessAllocatedBytesPerOperation": 3633.652587890625,"ProcessRetainedBytesDelta": -3513192,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "abandoned-time-leases","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 46789.56447637112,"P50Milliseconds": 0.02,"P95Milliseconds": 0.0329,"P99Milliseconds": 0.0551,"ProcessAllocatedBytesPerOperation": 3634.915771484375,"ProcessRetainedBytesDelta": 3129472,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "abandoned-time-leases","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 50343.76272306167,"P50Milliseconds": 0.0195,"P95Milliseconds": 0.0246,"P99Milliseconds": 0.038,"ProcessAllocatedBytesPerOperation": 3633.99609375,"ProcessRetainedBytesDelta": -3413552,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "abandoned-time-leases","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 49417.83086668964,"P50Milliseconds": 0.0199,"P95Milliseconds": 0.0249,"P99Milliseconds": 0.042,"ProcessAllocatedBytesPerOperation": 3633.27392578125,"ProcessRetainedBytesDelta": 2790264,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "sequential","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 48835.805253515326,"P50Milliseconds": 0.0194,"P95Milliseconds": 0.027,"P99Milliseconds": 0.0495,"ProcessAllocatedBytesPerOperation": 3627.901611328125,"ProcessRetainedBytesDelta": 3009136,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "sequential","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 47503.14218324836,"P50Milliseconds": 0.0197,"P95Milliseconds": 0.0305,"P99Milliseconds": 0.0535,"ProcessAllocatedBytesPerOperation": 3625.906982421875,"ProcessRetainedBytesDelta": -3340968,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "sequential","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 49808.16529841655,"P50Milliseconds": 0.0197,"P95Milliseconds": 0.0245,"P99Milliseconds": 0.0349,"ProcessAllocatedBytesPerOperation": 3627.66943359375,"ProcessRetainedBytesDelta": 2655736,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "sequential","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 49822.97850730247,"P50Milliseconds": 0.0192,"P95Milliseconds": 0.0256,"P99Milliseconds": 0.0431,"ProcessAllocatedBytesPerOperation": 3625.359619140625,"ProcessRetainedBytesDelta": 2923400,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "sequential","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 49495.12495374182,"P50Milliseconds": 0.0194,"P95Milliseconds": 0.026,"P99Milliseconds": 0.0446,"ProcessAllocatedBytesPerOperation": 3624.6279296875,"ProcessRetainedBytesDelta": -3178952,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "sequential","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 49067.34900948883,"P50Milliseconds": 0.0191,"P95Milliseconds": 0.0289,"P99Milliseconds": 0.0533,"ProcessAllocatedBytesPerOperation": 3626.9033203125,"ProcessRetainedBytesDelta": 2498816,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "hot-key-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 109446.2888863653,"P50Milliseconds": 0.1335,"P95Milliseconds": 0.1883,"P99Milliseconds": 0.399,"ProcessAllocatedBytesPerOperation": 3877.529541015625,"ProcessRetainedBytesDelta": 3169424,"Gen0Collections": 15,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "hot-key-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 113590.19618990306,"P50Milliseconds": 0.1323,"P95Milliseconds": 0.1587,"P99Milliseconds": 0.3092,"ProcessAllocatedBytesPerOperation": 3785.6416015625,"ProcessRetainedBytesDelta": -3460864,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "hot-key-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 111915.28209871208,"P50Milliseconds": 0.1335,"P95Milliseconds": 0.1621,"P99Milliseconds": 0.3365,"ProcessAllocatedBytesPerOperation": 3716.53466796875,"ProcessRetainedBytesDelta": -296224,"Gen0Collections": 14,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "hot-key-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 114532.4191618106,"P50Milliseconds": 0.1317,"P95Milliseconds": 0.155,"P99Milliseconds": 0.2914,"ProcessAllocatedBytesPerOperation": 3703.911865234375,"ProcessRetainedBytesDelta": 4552,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "hot-key-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 115075.08958635057,"P50Milliseconds": 0.1313,"P95Milliseconds": 0.1531,"P99Milliseconds": 0.2989,"ProcessAllocatedBytesPerOperation": 3708.9921875,"ProcessRetainedBytesDelta": -345192,"Gen0Collections": 14,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "hot-key-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 112180.91363356882,"P50Milliseconds": 0.1317,"P95Milliseconds": 0.1837,"P99Milliseconds": 0.2686,"ProcessAllocatedBytesPerOperation": 3936.2734375,"ProcessRetainedBytesDelta": -358128,"Gen0Collections": 15,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "partitioned-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 268530.7350012702,"P50Milliseconds": 0.0545,"P95Milliseconds": 0.0832,"P99Milliseconds": 0.1805,"ProcessAllocatedBytesPerOperation": 3628.02734375,"ProcessRetainedBytesDelta": 3232568,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "partitioned-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 261749.90933650295,"P50Milliseconds": 0.0558,"P95Milliseconds": 0.0804,"P99Milliseconds": 0.1792,"ProcessAllocatedBytesPerOperation": 3627.222900390625,"ProcessRetainedBytesDelta": 3112448,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "partitioned-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 240312.6528630633,"P50Milliseconds": 0.0565,"P95Milliseconds": 0.0965,"P99Milliseconds": 0.237,"ProcessAllocatedBytesPerOperation": 3640.564697265625,"ProcessRetainedBytesDelta": -260960,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "partitioned-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 250594.21296225488,"P50Milliseconds": 0.0562,"P95Milliseconds": 0.0832,"P99Milliseconds": 0.1475,"ProcessAllocatedBytesPerOperation": 3641.5947265625,"ProcessRetainedBytesDelta": -5248,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "partitioned-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 231874.2127683664,"P50Milliseconds": 0.0559,"P95Milliseconds": 0.1051,"P99Milliseconds": 0.286,"ProcessAllocatedBytesPerOperation": 3639.047119140625,"ProcessRetainedBytesDelta": -418704,"Gen0Collections": 14,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "partitioned-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 249798.17484500058,"P50Milliseconds": 0.0561,"P95Milliseconds": 0.0847,"P99Milliseconds": 0.164,"ProcessAllocatedBytesPerOperation": 3639.79052734375,"ProcessRetainedBytesDelta": -358624,"Gen0Collections": 14,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "abandoned-time-leases","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 49159.74020109466,"P50Milliseconds": 0.0198,"P95Milliseconds": 0.0254,"P99Milliseconds": 0.0417,"ProcessAllocatedBytesPerOperation": 3632.353271484375,"ProcessRetainedBytesDelta": 3161744,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "abandoned-time-leases","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 48433.62281961491,"P50Milliseconds": 0.02,"P95Milliseconds": 0.0264,"P99Milliseconds": 0.0467,"ProcessAllocatedBytesPerOperation": 3629.60791015625,"ProcessRetainedBytesDelta": -3378624,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "abandoned-time-leases","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 50080.79593741551,"P50Milliseconds": 0.019,"P95Milliseconds": 0.0263,"P99Milliseconds": 0.0473,"ProcessAllocatedBytesPerOperation": 3628.871826171875,"ProcessRetainedBytesDelta": 2568720,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "abandoned-time-leases","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 53505.589283206675,"P50Milliseconds": 0.0179,"P95Milliseconds": 0.0232,"P99Milliseconds": 0.03,"ProcessAllocatedBytesPerOperation": 3627.864501953125,"ProcessRetainedBytesDelta": 3047728,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "abandoned-time-leases","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 53715.170913025126,"P50Milliseconds": 0.0177,"P95Milliseconds": 0.0232,"P99Milliseconds": 0.0306,"ProcessAllocatedBytesPerOperation": 3626.913330078125,"ProcessRetainedBytesDelta": -3409528,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "abandoned-time-leases","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 54104.47175902772,"P50Milliseconds": 0.0174,"P95Milliseconds": 0.0232,"P99Milliseconds": 0.03,"ProcessAllocatedBytesPerOperation": 3628.16455078125,"ProcessRetainedBytesDelta": 2765216,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false} + ] +} diff --git a/docs/performance/2026-09-07-updated.json b/docs/performance/2026-09-07-updated.json new file mode 100644 index 0000000..857c4d3 --- /dev/null +++ b/docs/performance/2026-09-07-updated.json @@ -0,0 +1,101 @@ +{ + "FrameworkDescription": ".NET 10.0.11", + "OSDescription": "macOS 26.6.2", + "ProcessArchitecture": 3, + "ProcessorCount": 12, + "Timestamp": "2026-09-07T19:04:33.338645+00:00", + "Topology": "Two silos; preferred gateway 0; verified grain placement on silo 0", + "MetadataPayload": "IEAhMsVyuxo8rk6uUmeflS9SeyEgIGCKc/8pQTFyYXRlLWxpbWl0LWNvbXBhdGliaWxpdHng4CFg+BZJ50EbbGVhc2UtcGF5bG9hZODgAQMh4AEB4A==", + "Results": [ + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 11515.340110707935,"P50Milliseconds": 0.0745,"P95Milliseconds": 0.1749,"P99Milliseconds": 0.3152,"ProcessAllocatedBytesPerOperation": 6062.298583984375,"ProcessRetainedBytesDelta": 301504,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 22698.800672346675,"P50Milliseconds": 0.0387,"P95Milliseconds": 0.0797,"P99Milliseconds": 0.1104,"ProcessAllocatedBytesPerOperation": 5892.624755859375,"ProcessRetainedBytesDelta": 2141960,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 24024.818411656554,"P50Milliseconds": 0.0374,"P95Milliseconds": 0.0779,"P99Milliseconds": 0.1114,"ProcessAllocatedBytesPerOperation": 5890.83642578125,"ProcessRetainedBytesDelta": -2655464,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 23296.352885145385,"P50Milliseconds": 0.0382,"P95Milliseconds": 0.076,"P99Milliseconds": 0.1128,"ProcessAllocatedBytesPerOperation": 5892.0029296875,"ProcessRetainedBytesDelta": 2316816,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 21440.865695890818,"P50Milliseconds": 0.04,"P95Milliseconds": 0.0849,"P99Milliseconds": 0.1168,"ProcessAllocatedBytesPerOperation": 5892.4404296875,"ProcessRetainedBytesDelta": -2589576,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 22514.084185278105,"P50Milliseconds": 0.0392,"P95Milliseconds": 0.0789,"P99Milliseconds": 0.1094,"ProcessAllocatedBytesPerOperation": 5892.599853515625,"ProcessRetainedBytesDelta": 2074048,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 58803.35671553966,"P50Milliseconds": 0.2434,"P95Milliseconds": 0.3613,"P99Milliseconds": 0.9907,"ProcessAllocatedBytesPerOperation": 7067.80517578125,"ProcessRetainedBytesDelta": 1141000,"Gen0Collections": 28,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 60759.64764299554,"P50Milliseconds": 0.2444,"P95Milliseconds": 0.3451,"P99Milliseconds": 0.918,"ProcessAllocatedBytesPerOperation": 6535.3271484375,"ProcessRetainedBytesDelta": -995824,"Gen0Collections": 25,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 60578.68842185011,"P50Milliseconds": 0.244,"P95Milliseconds": 0.3475,"P99Milliseconds": 0.9014,"ProcessAllocatedBytesPerOperation": 6258.051513671875,"ProcessRetainedBytesDelta": -264480,"Gen0Collections": 24,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 59654.626510481066,"P50Milliseconds": 0.238,"P95Milliseconds": 0.3808,"P99Milliseconds": 0.9771,"ProcessAllocatedBytesPerOperation": 6482.756591796875,"ProcessRetainedBytesDelta": 79720,"Gen0Collections": 25,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 60478.2968035906,"P50Milliseconds": 0.2413,"P95Milliseconds": 0.3515,"P99Milliseconds": 1.0492,"ProcessAllocatedBytesPerOperation": 6071.7333984375,"ProcessRetainedBytesDelta": 928320,"Gen0Collections": 24,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 58598.33246542183,"P50Milliseconds": 0.2441,"P95Milliseconds": 0.4057,"P99Milliseconds": 1.0432,"ProcessAllocatedBytesPerOperation": 6314.875732421875,"ProcessRetainedBytesDelta": -142608,"Gen0Collections": 25,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 125203.46326962608,"P50Milliseconds": 0.1006,"P95Milliseconds": 0.2092,"P99Milliseconds": 1.017,"ProcessAllocatedBytesPerOperation": 5930.38330078125,"ProcessRetainedBytesDelta": 5624,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 126372.36345012595,"P50Milliseconds": 0.0995,"P95Milliseconds": 0.186,"P99Milliseconds": 1.0639,"ProcessAllocatedBytesPerOperation": 5931.515625,"ProcessRetainedBytesDelta": -273456,"Gen0Collections": 23,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 135090.3990089193,"P50Milliseconds": 0.0992,"P95Milliseconds": 0.1725,"P99Milliseconds": 0.9606,"ProcessAllocatedBytesPerOperation": 5918.333251953125,"ProcessRetainedBytesDelta": -632416,"Gen0Collections": 23,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 132609.0985835011,"P50Milliseconds": 0.1035,"P95Milliseconds": 0.1725,"P99Milliseconds": 0.9421,"ProcessAllocatedBytesPerOperation": 5929.37548828125,"ProcessRetainedBytesDelta": 514136,"Gen0Collections": 23,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 135201.7649507744,"P50Milliseconds": 0.1001,"P95Milliseconds": 0.1704,"P99Milliseconds": 0.9477,"ProcessAllocatedBytesPerOperation": 5915.8359375,"ProcessRetainedBytesDelta": -719048,"Gen0Collections": 23,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 138769.5467587054,"P50Milliseconds": 0.1011,"P95Milliseconds": 0.1526,"P99Milliseconds": 0.9229,"ProcessAllocatedBytesPerOperation": 5918.39404296875,"ProcessRetainedBytesDelta": -773728,"Gen0Collections": 23,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "sequential","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 37116.19153078464,"P50Milliseconds": 0.0225,"P95Milliseconds": 0.053,"P99Milliseconds": 0.076,"ProcessAllocatedBytesPerOperation": 3607.59228515625,"ProcessRetainedBytesDelta": 2169024,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "sequential","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 40454.23108936301,"P50Milliseconds": 0.02,"P95Milliseconds": 0.0507,"P99Milliseconds": 0.0749,"ProcessAllocatedBytesPerOperation": 3604.679443359375,"ProcessRetainedBytesDelta": -2411584,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "sequential","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 42104.105381559464,"P50Milliseconds": 0.02,"P95Milliseconds": 0.0495,"P99Milliseconds": 0.0719,"ProcessAllocatedBytesPerOperation": 3604.361572265625,"ProcessRetainedBytesDelta": 1609408,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "sequential","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 47543.317188125024,"P50Milliseconds": 0.0196,"P95Milliseconds": 0.029,"P99Milliseconds": 0.0571,"ProcessAllocatedBytesPerOperation": 3603.5322265625,"ProcessRetainedBytesDelta": 2075872,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "sequential","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 46224.90713897478,"P50Milliseconds": 0.0198,"P95Milliseconds": 0.036,"P99Milliseconds": 0.0583,"ProcessAllocatedBytesPerOperation": 3602.11962890625,"ProcessRetainedBytesDelta": -2326376,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "sequential","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 45075.326609725504,"P50Milliseconds": 0.0194,"P95Milliseconds": 0.042,"P99Milliseconds": 0.0677,"ProcessAllocatedBytesPerOperation": 3603.810546875,"ProcessRetainedBytesDelta": 1722680,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "hot-key-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 105232.46329003027,"P50Milliseconds": 0.1389,"P95Milliseconds": 0.1946,"P99Milliseconds": 0.3826,"ProcessAllocatedBytesPerOperation": 3635.4794921875,"ProcessRetainedBytesDelta": -108032,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "hot-key-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 101451.20926881628,"P50Milliseconds": 0.1409,"P95Milliseconds": 0.219,"P99Milliseconds": 0.4367,"ProcessAllocatedBytesPerOperation": 3877.039306640625,"ProcessRetainedBytesDelta": -294504,"Gen0Collections": 15,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "hot-key-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 103855.00867148755,"P50Milliseconds": 0.1391,"P95Milliseconds": 0.2021,"P99Milliseconds": 0.4511,"ProcessAllocatedBytesPerOperation": 3956.576416015625,"ProcessRetainedBytesDelta": -360200,"Gen0Collections": 15,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "hot-key-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 105938.03232907518,"P50Milliseconds": 0.1372,"P95Milliseconds": 0.1975,"P99Milliseconds": 0.372,"ProcessAllocatedBytesPerOperation": 3757.13671875,"ProcessRetainedBytesDelta": 8144,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "hot-key-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 106271.71089735835,"P50Milliseconds": 0.1377,"P95Milliseconds": 0.1921,"P99Milliseconds": 0.3505,"ProcessAllocatedBytesPerOperation": 3682.404052734375,"ProcessRetainedBytesDelta": 120504,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "hot-key-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 103692.88829692468,"P50Milliseconds": 0.1362,"P95Milliseconds": 0.2068,"P99Milliseconds": 0.4622,"ProcessAllocatedBytesPerOperation": 3784.17529296875,"ProcessRetainedBytesDelta": 197184,"Gen0Collections": 15,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "partitioned-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 203315.0377927214,"P50Milliseconds": 0.0565,"P95Milliseconds": 0.1564,"P99Milliseconds": 0.4855,"ProcessAllocatedBytesPerOperation": 3623.78955078125,"ProcessRetainedBytesDelta": -217248,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "partitioned-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 211843.669612316,"P50Milliseconds": 0.0561,"P95Milliseconds": 0.1362,"P99Milliseconds": 0.3926,"ProcessAllocatedBytesPerOperation": 3623.68408203125,"ProcessRetainedBytesDelta": -702312,"Gen0Collections": 14,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "partitioned-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 213126.8666812359,"P50Milliseconds": 0.0552,"P95Milliseconds": 0.139,"P99Milliseconds": 0.4359,"ProcessAllocatedBytesPerOperation": 3623.470703125,"ProcessRetainedBytesDelta": -329528,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "partitioned-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 233215.0698727096,"P50Milliseconds": 0.0545,"P95Milliseconds": 0.1134,"P99Milliseconds": 0.2471,"ProcessAllocatedBytesPerOperation": 3613.6357421875,"ProcessRetainedBytesDelta": 2837312,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "partitioned-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 209559.62293593236,"P50Milliseconds": 0.0567,"P95Milliseconds": 0.1399,"P99Milliseconds": 0.3972,"ProcessAllocatedBytesPerOperation": 3635.6259765625,"ProcessRetainedBytesDelta": -3111864,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "partitioned-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 235060.17821762295,"P50Milliseconds": 0.0541,"P95Milliseconds": 0.1002,"P99Milliseconds": 0.241,"ProcessAllocatedBytesPerOperation": 3623.372802734375,"ProcessRetainedBytesDelta": -360480,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "abandoned-time-leases","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 26806.4603503999,"P50Milliseconds": 0.0237,"P95Milliseconds": 0.0688,"P99Milliseconds": 0.2215,"ProcessAllocatedBytesPerOperation": 3621.636474609375,"ProcessRetainedBytesDelta": 2402360,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "abandoned-time-leases","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 36207.71480236751,"P50Milliseconds": 0.0227,"P95Milliseconds": 0.0519,"P99Milliseconds": 0.074,"ProcessAllocatedBytesPerOperation": 3612.45654296875,"ProcessRetainedBytesDelta": -2783104,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "abandoned-time-leases","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 38731.01095408682,"P50Milliseconds": 0.0218,"P95Milliseconds": 0.0515,"P99Milliseconds": 0.0777,"ProcessAllocatedBytesPerOperation": 3611.3701171875,"ProcessRetainedBytesDelta": 1942128,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "abandoned-time-leases","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 44710.49517746661,"P50Milliseconds": 0.0188,"P95Milliseconds": 0.0435,"P99Milliseconds": 0.0741,"ProcessAllocatedBytesPerOperation": 3609.173828125,"ProcessRetainedBytesDelta": 2153488,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "abandoned-time-leases","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 40058.23896803091,"P50Milliseconds": 0.0157,"P95Milliseconds": 0.0554,"P99Milliseconds": 0.1242,"ProcessAllocatedBytesPerOperation": 3609.60107421875,"ProcessRetainedBytesDelta": -2456976,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "fixed","Scenario": "abandoned-time-leases","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 37988.64236048958,"P50Milliseconds": 0.0217,"P95Milliseconds": 0.0515,"P99Milliseconds": 0.0786,"ProcessAllocatedBytesPerOperation": 3609.20263671875,"ProcessRetainedBytesDelta": 1804904,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "sequential","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 34977.287925908095,"P50Milliseconds": 0.0223,"P95Milliseconds": 0.0596,"P99Milliseconds": 0.1082,"ProcessAllocatedBytesPerOperation": 3639.376220703125,"ProcessRetainedBytesDelta": 3166832,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "sequential","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 36074.68896204572,"P50Milliseconds": 0.0227,"P95Milliseconds": 0.0547,"P99Milliseconds": 0.0795,"ProcessAllocatedBytesPerOperation": 3637.0458984375,"ProcessRetainedBytesDelta": 2847640,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "sequential","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 41221.167008876495,"P50Milliseconds": 0.02,"P95Milliseconds": 0.0502,"P99Milliseconds": 0.0727,"ProcessAllocatedBytesPerOperation": 3633.393798828125,"ProcessRetainedBytesDelta": -3432032,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "sequential","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 32154.38816434339,"P50Milliseconds": 0.0228,"P95Milliseconds": 0.0623,"P99Milliseconds": 0.1414,"ProcessAllocatedBytesPerOperation": 3640.868408203125,"ProcessRetainedBytesDelta": 47760,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "sequential","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 43465.48646976232,"P50Milliseconds": 0.0207,"P95Milliseconds": 0.0409,"P99Milliseconds": 0.0646,"ProcessAllocatedBytesPerOperation": 3631.23876953125,"ProcessRetainedBytesDelta": 2582384,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "sequential","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 41569.17548210247,"P50Milliseconds": 0.021,"P95Milliseconds": 0.0478,"P99Milliseconds": 0.0734,"ProcessAllocatedBytesPerOperation": 3632.755126953125,"ProcessRetainedBytesDelta": -3322240,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "hot-key-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 109242.25189460364,"P50Milliseconds": 0.1351,"P95Milliseconds": 0.1777,"P99Milliseconds": 0.3775,"ProcessAllocatedBytesPerOperation": 3653.508544921875,"ProcessRetainedBytesDelta": 7536,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "hot-key-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 105661.74505041086,"P50Milliseconds": 0.1366,"P95Milliseconds": 0.2054,"P99Milliseconds": 0.368,"ProcessAllocatedBytesPerOperation": 3832.873291015625,"ProcessRetainedBytesDelta": 1474032,"Gen0Collections": 15,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "hot-key-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 106058.0412729166,"P50Milliseconds": 0.1356,"P95Milliseconds": 0.2128,"P99Milliseconds": 0.395,"ProcessAllocatedBytesPerOperation": 3761.280517578125,"ProcessRetainedBytesDelta": -2124800,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "hot-key-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 106806.04995255853,"P50Milliseconds": 0.1347,"P95Milliseconds": 0.2079,"P99Milliseconds": 0.3661,"ProcessAllocatedBytesPerOperation": 3718.925048828125,"ProcessRetainedBytesDelta": 39464,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "hot-key-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 106600.3190722441,"P50Milliseconds": 0.1351,"P95Milliseconds": 0.1956,"P99Milliseconds": 0.3587,"ProcessAllocatedBytesPerOperation": 3796.82275390625,"ProcessRetainedBytesDelta": 429056,"Gen0Collections": 15,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "hot-key-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 105571.1256785461,"P50Milliseconds": 0.1353,"P95Milliseconds": 0.2141,"P99Milliseconds": 0.3781,"ProcessAllocatedBytesPerOperation": 3821.033203125,"ProcessRetainedBytesDelta": 1088344,"Gen0Collections": 15,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "partitioned-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 252055.33402254712,"P50Milliseconds": 0.0553,"P95Milliseconds": 0.0827,"P99Milliseconds": 0.1553,"ProcessAllocatedBytesPerOperation": 3648.9404296875,"ProcessRetainedBytesDelta": 3920,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "partitioned-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 226467.88065098456,"P50Milliseconds": 0.0552,"P95Milliseconds": 0.11,"P99Milliseconds": 0.2983,"ProcessAllocatedBytesPerOperation": 3650.1240234375,"ProcessRetainedBytesDelta": -282952,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "partitioned-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 216585.49998149293,"P50Milliseconds": 0.0555,"P95Milliseconds": 0.1347,"P99Milliseconds": 0.4856,"ProcessAllocatedBytesPerOperation": 3647.431884765625,"ProcessRetainedBytesDelta": -743696,"Gen0Collections": 14,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "partitioned-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 250546.11989176238,"P50Milliseconds": 0.0546,"P95Milliseconds": 0.0893,"P99Milliseconds": 0.188,"ProcessAllocatedBytesPerOperation": 3637.311767578125,"ProcessRetainedBytesDelta": -209464,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "partitioned-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 253524.76640177888,"P50Milliseconds": 0.0544,"P95Milliseconds": 0.0844,"P99Milliseconds": 0.1837,"ProcessAllocatedBytesPerOperation": 3637.052978515625,"ProcessRetainedBytesDelta": -742800,"Gen0Collections": 14,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "partitioned-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 176681.1888021395,"P50Milliseconds": 0.0568,"P95Milliseconds": 0.1762,"P99Milliseconds": 0.7896,"ProcessAllocatedBytesPerOperation": 3650.106201171875,"ProcessRetainedBytesDelta": -704200,"Gen0Collections": 14,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "abandoned-time-leases","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 48204.70287128971,"P50Milliseconds": 0.019,"P95Milliseconds": 0.0302,"P99Milliseconds": 0.0587,"ProcessAllocatedBytesPerOperation": 3634.904052734375,"ProcessRetainedBytesDelta": 3135568,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "abandoned-time-leases","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 45571.791216999634,"P50Milliseconds": 0.0182,"P95Milliseconds": 0.0426,"P99Milliseconds": 0.0737,"ProcessAllocatedBytesPerOperation": 3635.735107421875,"ProcessRetainedBytesDelta": -3415664,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "abandoned-time-leases","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 46481.31165586127,"P50Milliseconds": 0.0199,"P95Milliseconds": 0.0332,"P99Milliseconds": 0.0597,"ProcessAllocatedBytesPerOperation": 3635.600830078125,"ProcessRetainedBytesDelta": 2794216,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "abandoned-time-leases","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 49273.09108499593,"P50Milliseconds": 0.0187,"P95Milliseconds": 0.0299,"P99Milliseconds": 0.0562,"ProcessAllocatedBytesPerOperation": 3635.898681640625,"ProcessRetainedBytesDelta": 3075464,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "abandoned-time-leases","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 52038.58554398331,"P50Milliseconds": 0.0187,"P95Milliseconds": 0.0246,"P99Milliseconds": 0.033,"ProcessAllocatedBytesPerOperation": 3631.5458984375,"ProcessRetainedBytesDelta": -3326680,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "sliding","Scenario": "abandoned-time-leases","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 49161.923334225015,"P50Milliseconds": 0.0196,"P95Milliseconds": 0.0262,"P99Milliseconds": 0.0452,"ProcessAllocatedBytesPerOperation": 3633.681884765625,"ProcessRetainedBytesDelta": -395632,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "sequential","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 49545.557705802385,"P50Milliseconds": 0.0193,"P95Milliseconds": 0.0264,"P99Milliseconds": 0.0456,"ProcessAllocatedBytesPerOperation": 3627.978271484375,"ProcessRetainedBytesDelta": 2904720,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "sequential","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 46803.485351343224,"P50Milliseconds": 0.0195,"P95Milliseconds": 0.0345,"P99Milliseconds": 0.0594,"ProcessAllocatedBytesPerOperation": 3627.2490234375,"ProcessRetainedBytesDelta": -3238352,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "sequential","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 50588.558229178154,"P50Milliseconds": 0.0192,"P95Milliseconds": 0.025,"P99Milliseconds": 0.0346,"ProcessAllocatedBytesPerOperation": 3625.743408203125,"ProcessRetainedBytesDelta": 2746112,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "sequential","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 50327.54835817278,"P50Milliseconds": 0.0193,"P95Milliseconds": 0.025,"P99Milliseconds": 0.0379,"ProcessAllocatedBytesPerOperation": 3627.2197265625,"ProcessRetainedBytesDelta": 2907296,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "sequential","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 50536.931766042755,"P50Milliseconds": 0.0191,"P95Milliseconds": 0.0256,"P99Milliseconds": 0.0376,"ProcessAllocatedBytesPerOperation": 3627.705078125,"ProcessRetainedBytesDelta": -3234688,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "sequential","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 49119.34300480325,"P50Milliseconds": 0.0193,"P95Milliseconds": 0.0269,"P99Milliseconds": 0.0497,"ProcessAllocatedBytesPerOperation": 3626.689453125,"ProcessRetainedBytesDelta": 2563520,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "hot-key-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 104828.31516187638,"P50Milliseconds": 0.1378,"P95Milliseconds": 0.2115,"P99Milliseconds": 0.4212,"ProcessAllocatedBytesPerOperation": 3912.023193359375,"ProcessRetainedBytesDelta": -108512,"Gen0Collections": 15,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "hot-key-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 111311.72071519683,"P50Milliseconds": 0.1331,"P95Milliseconds": 0.1819,"P99Milliseconds": 0.331,"ProcessAllocatedBytesPerOperation": 3709.518310546875,"ProcessRetainedBytesDelta": -203712,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "hot-key-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 111726.47199319986,"P50Milliseconds": 0.135,"P95Milliseconds": 0.1668,"P99Milliseconds": 0.261,"ProcessAllocatedBytesPerOperation": 3738.9404296875,"ProcessRetainedBytesDelta": -365320,"Gen0Collections": 14,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "hot-key-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 109907.65449391094,"P50Milliseconds": 0.1361,"P95Milliseconds": 0.1774,"P99Milliseconds": 0.2689,"ProcessAllocatedBytesPerOperation": 3834.575927734375,"ProcessRetainedBytesDelta": 1755000,"Gen0Collections": 15,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "hot-key-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 109172.48072539996,"P50Milliseconds": 0.1371,"P95Milliseconds": 0.176,"P99Milliseconds": 0.2868,"ProcessAllocatedBytesPerOperation": 3857.400634765625,"ProcessRetainedBytesDelta": -2119112,"Gen0Collections": 15,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "hot-key-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 109350.15944673517,"P50Milliseconds": 0.1369,"P95Milliseconds": 0.1735,"P99Milliseconds": 0.3025,"ProcessAllocatedBytesPerOperation": 3790.088623046875,"ProcessRetainedBytesDelta": 171496,"Gen0Collections": 15,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "partitioned-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 284134.905116388,"P50Milliseconds": 0.0484,"P95Milliseconds": 0.0799,"P99Milliseconds": 0.1547,"ProcessAllocatedBytesPerOperation": 3628.89306640625,"ProcessRetainedBytesDelta": -27088,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "partitioned-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 194622.80842945783,"P50Milliseconds": 0.0582,"P95Milliseconds": 0.1631,"P99Milliseconds": 0.5741,"ProcessAllocatedBytesPerOperation": 3653.40234375,"ProcessRetainedBytesDelta": -282416,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "partitioned-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 224844.92506998955,"P50Milliseconds": 0.0575,"P95Milliseconds": 0.124,"P99Milliseconds": 0.248,"ProcessAllocatedBytesPerOperation": 3640.48388671875,"ProcessRetainedBytesDelta": -307040,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "partitioned-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 250605.71204816332,"P50Milliseconds": 0.0574,"P95Milliseconds": 0.0845,"P99Milliseconds": 0.166,"ProcessAllocatedBytesPerOperation": 3640.624755859375,"ProcessRetainedBytesDelta": -120,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "partitioned-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 257757.78728532314,"P50Milliseconds": 0.0547,"P95Milliseconds": 0.0904,"P99Milliseconds": 0.1891,"ProcessAllocatedBytesPerOperation": 3642.082763671875,"ProcessRetainedBytesDelta": -297312,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "partitioned-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 251683.6258172344,"P50Milliseconds": 0.0574,"P95Milliseconds": 0.0858,"P99Milliseconds": 0.169,"ProcessAllocatedBytesPerOperation": 3638.309814453125,"ProcessRetainedBytesDelta": -276168,"Gen0Collections": 14,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "abandoned-time-leases","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 48168.58534474319,"P50Milliseconds": 0.02,"P95Milliseconds": 0.0257,"P99Milliseconds": 0.0415,"ProcessAllocatedBytesPerOperation": 3632.86767578125,"ProcessRetainedBytesDelta": 3059800,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "abandoned-time-leases","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 50134.45483064321,"P50Milliseconds": 0.0196,"P95Milliseconds": 0.0248,"P99Milliseconds": 0.034,"ProcessAllocatedBytesPerOperation": 3630.986328125,"ProcessRetainedBytesDelta": -3419120,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "abandoned-time-leases","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 47257.124010002335,"P50Milliseconds": 0.0196,"P95Milliseconds": 0.0343,"P99Milliseconds": 0.0581,"ProcessAllocatedBytesPerOperation": 3629.496337890625,"ProcessRetainedBytesDelta": 2557120,"Gen0Collections": 14,"Gen1Collections": 1,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "abandoned-time-leases","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 46201.86101795525,"P50Milliseconds": 0.0201,"P95Milliseconds": 0.0357,"P99Milliseconds": 0.0583,"ProcessAllocatedBytesPerOperation": 3631.86279296875,"ProcessRetainedBytesDelta": 3058600,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "abandoned-time-leases","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 49263.23885649992,"P50Milliseconds": 0.0198,"P95Milliseconds": 0.025,"P99Milliseconds": 0.0402,"ProcessAllocatedBytesPerOperation": 3632.94287109375,"ProcessRetainedBytesDelta": -3384272,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "token","Scenario": "abandoned-time-leases","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 48955.89248588007,"P50Milliseconds": 0.0198,"P95Milliseconds": 0.0258,"P99Milliseconds": 0.0439,"ProcessAllocatedBytesPerOperation": 3629.926513671875,"ProcessRetainedBytesDelta": 2729256,"Gen0Collections": 14,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false} + ] +} diff --git a/docs/performance/2026-09-07-warmed-0-baseline.json b/docs/performance/2026-09-07-warmed-0-baseline.json new file mode 100644 index 0000000..bbd18b0 --- /dev/null +++ b/docs/performance/2026-09-07-warmed-0-baseline.json @@ -0,0 +1,30 @@ +{ + "FrameworkDescription": ".NET 10.0.11", + "OSDescription": "macOS 26.6.2", + "ProcessArchitecture": 3, + "ProcessorCount": 12, + "Timestamp": "2026-09-07T19:19:27.071038+00:00", + "Topology": "Two silos; preferred gateway 0; verified grain placement on silo 0", + "WarmWorkload": true, + "MetadataPayload": "IEAhMsVyuxo8rk6uUmeflS9SeyEgIGCKc/8pQTFyYXRlLWxpbWl0LWNvbXBhdGliaWxpdHng4CFg+BZJ50EbbGVhc2UtcGF5bG9hZODgAQMh4OA=", + "Results": [ + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 22167.947912899337,"P50Milliseconds": 0.0244,"P95Milliseconds": 0.0832,"P99Milliseconds": 0.3164,"ProcessAllocatedBytesPerOperation": 6154.4462890625,"ProcessRetainedBytesDelta": -2145112,"Gen0Collections": 24,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 29493.60785814554,"P50Milliseconds": 0.0323,"P95Milliseconds": 0.0433,"P99Milliseconds": 0.0564,"ProcessAllocatedBytesPerOperation": 6150.97021484375,"ProcessRetainedBytesDelta": 2061072,"Gen0Collections": 24,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 24536.53849854625,"P50Milliseconds": 0.0251,"P95Milliseconds": 0.0561,"P99Milliseconds": 0.2027,"ProcessAllocatedBytesPerOperation": 6152.723388671875,"ProcessRetainedBytesDelta": -2548840,"Gen0Collections": 24,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 19123.828893089343,"P50Milliseconds": 0.026,"P95Milliseconds": 0.1066,"P99Milliseconds": 0.3055,"ProcessAllocatedBytesPerOperation": 8999.339599609375,"ProcessRetainedBytesDelta": -290368,"Gen0Collections": 35,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 23443.054047300055,"P50Milliseconds": 0.0365,"P95Milliseconds": 0.0804,"P99Milliseconds": 0.121,"ProcessAllocatedBytesPerOperation": 8996.027099609375,"ProcessRetainedBytesDelta": -238368,"Gen0Collections": 35,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 18206.24483976749,"P50Milliseconds": 0.0305,"P95Milliseconds": 0.1184,"P99Milliseconds": 0.294,"ProcessAllocatedBytesPerOperation": 9000.77880859375,"ProcessRetainedBytesDelta": -288384,"Gen0Collections": 35,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 35632.49638433684,"P50Milliseconds": 0.2979,"P95Milliseconds": 1.0395,"P99Milliseconds": 2.3844,"ProcessAllocatedBytesPerOperation": 6252.36767578125,"ProcessRetainedBytesDelta": -198168,"Gen0Collections": 24,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 55179.05661124007,"P50Milliseconds": 0.241,"P95Milliseconds": 0.5325,"P99Milliseconds": 1.2023,"ProcessAllocatedBytesPerOperation": 6270.95703125,"ProcessRetainedBytesDelta": -192824,"Gen0Collections": 24,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 40904.21687423776,"P50Milliseconds": 0.2452,"P95Milliseconds": 0.9628,"P99Milliseconds": 1.9764,"ProcessAllocatedBytesPerOperation": 6300.096435546875,"ProcessRetainedBytesDelta": -214128,"Gen0Collections": 24,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 41473.08951102179,"P50Milliseconds": 0.2645,"P95Milliseconds": 0.883,"P99Milliseconds": 1.6932,"ProcessAllocatedBytesPerOperation": 9138.492431640625,"ProcessRetainedBytesDelta": 1720768,"Gen0Collections": 36,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 37271.8099747362,"P50Milliseconds": 0.2975,"P95Milliseconds": 0.9701,"P99Milliseconds": 2.0313,"ProcessAllocatedBytesPerOperation": 8986.94140625,"ProcessRetainedBytesDelta": -2260688,"Gen0Collections": 35,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 55427.13297538899,"P50Milliseconds": 0.2361,"P95Milliseconds": 0.5497,"P99Milliseconds": 1.2794,"ProcessAllocatedBytesPerOperation": 8938.853759765625,"ProcessRetainedBytesDelta": -332624,"Gen0Collections": 35,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 131689.8889589956,"P50Milliseconds": 0.1006,"P95Milliseconds": 0.187,"P99Milliseconds": 0.9942,"ProcessAllocatedBytesPerOperation": 6190.712158203125,"ProcessRetainedBytesDelta": -705584,"Gen0Collections": 24,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 86863.34980577155,"P50Milliseconds": 0.1192,"P95Milliseconds": 0.4255,"P99Milliseconds": 1.3384,"ProcessAllocatedBytesPerOperation": 6225.29736328125,"ProcessRetainedBytesDelta": -309864,"Gen0Collections": 24,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 90964.83856989104,"P50Milliseconds": 0.1088,"P95Milliseconds": 0.3527,"P99Milliseconds": 1.2761,"ProcessAllocatedBytesPerOperation": 6217.225341796875,"ProcessRetainedBytesDelta": -175992,"Gen0Collections": 24,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 113937.01266527004,"P50Milliseconds": 0.1083,"P95Milliseconds": 0.2301,"P99Milliseconds": 1.13,"ProcessAllocatedBytesPerOperation": 9044.15234375,"ProcessRetainedBytesDelta": -592240,"Gen0Collections": 35,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 95350.27725141653,"P50Milliseconds": 0.1149,"P95Milliseconds": 0.3364,"P99Milliseconds": 1.2985,"ProcessAllocatedBytesPerOperation": 9056.342529296875,"ProcessRetainedBytesDelta": -362384,"Gen0Collections": 35,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 54244.80318131998,"P50Milliseconds": 0.1232,"P95Milliseconds": 0.7531,"P99Milliseconds": 2.658,"ProcessAllocatedBytesPerOperation": 9107.243408203125,"ProcessRetainedBytesDelta": 91608,"Gen0Collections": 36,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": false} + ] +} diff --git a/docs/performance/2026-09-07-warmed-1-updated.json b/docs/performance/2026-09-07-warmed-1-updated.json new file mode 100644 index 0000000..57bc5c5 --- /dev/null +++ b/docs/performance/2026-09-07-warmed-1-updated.json @@ -0,0 +1,30 @@ +{ + "FrameworkDescription": ".NET 10.0.11", + "OSDescription": "macOS 26.6.2", + "ProcessArchitecture": 3, + "ProcessorCount": 12, + "Timestamp": "2026-09-07T19:20:16.423451+00:00", + "Topology": "Two silos; preferred gateway 0; verified grain placement on silo 0", + "WarmWorkload": true, + "MetadataPayload": "IEAhMsVyuxo8rk6uUmeflS9SeyEgIGCKc/8pQTFyYXRlLWxpbWl0LWNvbXBhdGliaWxpdHng4CFg+BZJ50EbbGVhc2UtcGF5bG9hZODgAQMh4AEB4A==", + "Results": [ + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 20624.847011508886,"P50Milliseconds": 0.04,"P95Milliseconds": 0.0906,"P99Milliseconds": 0.1392,"ProcessAllocatedBytesPerOperation": 5894.64404296875,"ProcessRetainedBytesDelta": -2587272,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 17258.254868525888,"P50Milliseconds": 0.0411,"P95Milliseconds": 0.107,"P99Milliseconds": 0.232,"ProcessAllocatedBytesPerOperation": 5898.8271484375,"ProcessRetainedBytesDelta": 2055112,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 15001.986957501085,"P50Milliseconds": 0.0493,"P95Milliseconds": 0.1197,"P99Milliseconds": 0.3227,"ProcessAllocatedBytesPerOperation": 5906.603759765625,"ProcessRetainedBytesDelta": -2582568,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 21053.18885651013,"P50Milliseconds": 0.039,"P95Milliseconds": 0.0912,"P99Milliseconds": 0.1482,"ProcessAllocatedBytesPerOperation": 5895.47802734375,"ProcessRetainedBytesDelta": -2713784,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 22858.86173639452,"P50Milliseconds": 0.0385,"P95Milliseconds": 0.0798,"P99Milliseconds": 0.1098,"ProcessAllocatedBytesPerOperation": 5892.909423828125,"ProcessRetainedBytesDelta": 2088008,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 24565.638827683455,"P50Milliseconds": 0.0369,"P95Milliseconds": 0.0728,"P99Milliseconds": 0.1124,"ProcessAllocatedBytesPerOperation": 5889.781494140625,"ProcessRetainedBytesDelta": -2655136,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 40626.47693758093,"P50Milliseconds": 0.2497,"P95Milliseconds": 0.988,"P99Milliseconds": 2.2287,"ProcessAllocatedBytesPerOperation": 6257.14501953125,"ProcessRetainedBytesDelta": -236024,"Gen0Collections": 24,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 49383.155141745236,"P50Milliseconds": 0.2459,"P95Milliseconds": 0.6283,"P99Milliseconds": 1.2383,"ProcessAllocatedBytesPerOperation": 5982.480712890625,"ProcessRetainedBytesDelta": -304936,"Gen0Collections": 23,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 38692.43926970855,"P50Milliseconds": 0.2459,"P95Milliseconds": 0.9068,"P99Milliseconds": 2.378,"ProcessAllocatedBytesPerOperation": 5964.171875,"ProcessRetainedBytesDelta": -270552,"Gen0Collections": 23,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 54333.443653924085,"P50Milliseconds": 0.2434,"P95Milliseconds": 0.5424,"P99Milliseconds": 1.102,"ProcessAllocatedBytesPerOperation": 5915.710693359375,"ProcessRetainedBytesDelta": -1898136,"Gen0Collections": 23,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 47257.15127119689,"P50Milliseconds": 0.2505,"P95Milliseconds": 0.7522,"P99Milliseconds": 1.2674,"ProcessAllocatedBytesPerOperation": 5850.935302734375,"ProcessRetainedBytesDelta": 1375248,"Gen0Collections": 23,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 43372.41721808723,"P50Milliseconds": 0.2554,"P95Milliseconds": 0.7574,"P99Milliseconds": 1.6134,"ProcessAllocatedBytesPerOperation": 5840.44580078125,"ProcessRetainedBytesDelta": -1967016,"Gen0Collections": 23,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 68629.28666005674,"P50Milliseconds": 0.1198,"P95Milliseconds": 0.5504,"P99Milliseconds": 1.8365,"ProcessAllocatedBytesPerOperation": 5967.372802734375,"ProcessRetainedBytesDelta": -484296,"Gen0Collections": 23,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 64396.946804914674,"P50Milliseconds": 0.137,"P95Milliseconds": 0.6342,"P99Milliseconds": 2.3401,"ProcessAllocatedBytesPerOperation": 5991.3505859375,"ProcessRetainedBytesDelta": -731376,"Gen0Collections": 23,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 56686.637657898005,"P50Milliseconds": 0.1141,"P95Milliseconds": 0.8381,"P99Milliseconds": 2.9881,"ProcessAllocatedBytesPerOperation": 5997.119873046875,"ProcessRetainedBytesDelta": -257024,"Gen0Collections": 23,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 108170.91850208631,"P50Milliseconds": 0.1094,"P95Milliseconds": 0.2775,"P99Milliseconds": 1.0894,"ProcessAllocatedBytesPerOperation": 5942.087158203125,"ProcessRetainedBytesDelta": -291312,"Gen0Collections": 23,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 128154.33586620135,"P50Milliseconds": 0.1027,"P95Milliseconds": 0.1935,"P99Milliseconds": 0.8848,"ProcessAllocatedBytesPerOperation": 5929.810791015625,"ProcessRetainedBytesDelta": -713768,"Gen0Collections": 23,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 143244.95639439576,"P50Milliseconds": 0.1019,"P95Milliseconds": 0.1306,"P99Milliseconds": 0.7894,"ProcessAllocatedBytesPerOperation": 5932.166259765625,"ProcessRetainedBytesDelta": -280904,"Gen0Collections": 23,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false} + ] +} diff --git a/docs/performance/2026-09-07-warmed-2-updated.json b/docs/performance/2026-09-07-warmed-2-updated.json new file mode 100644 index 0000000..9d12717 --- /dev/null +++ b/docs/performance/2026-09-07-warmed-2-updated.json @@ -0,0 +1,30 @@ +{ + "FrameworkDescription": ".NET 10.0.11", + "OSDescription": "macOS 26.6.2", + "ProcessArchitecture": 3, + "ProcessorCount": 12, + "Timestamp": "2026-09-07T19:21:06.396779+00:00", + "Topology": "Two silos; preferred gateway 0; verified grain placement on silo 0", + "WarmWorkload": true, + "MetadataPayload": "IEAhMsVyuxo8rk6uUmeflS9SeyEgIGCKc/8pQTFyYXRlLWxpbWl0LWNvbXBhdGliaWxpdHng4CFg+BZJ50EbbGVhc2UtcGF5bG9hZODgAQMh4AEB4A==", + "Results": [ + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 16710.395806082302,"P50Milliseconds": 0.0408,"P95Milliseconds": 0.1108,"P99Milliseconds": 0.3089,"ProcessAllocatedBytesPerOperation": 5900.034912109375,"ProcessRetainedBytesDelta": -2813088,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 17041.327455337374,"P50Milliseconds": 0.0478,"P95Milliseconds": 0.1091,"P99Milliseconds": 0.2083,"ProcessAllocatedBytesPerOperation": 5899.856689453125,"ProcessRetainedBytesDelta": 2108024,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 22053.698143640995,"P50Milliseconds": 0.0387,"P95Milliseconds": 0.0824,"P99Milliseconds": 0.1145,"ProcessAllocatedBytesPerOperation": 5893.885986328125,"ProcessRetainedBytesDelta": -2662744,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 20284.86969595972,"P50Milliseconds": 0.0392,"P95Milliseconds": 0.0917,"P99Milliseconds": 0.157,"ProcessAllocatedBytesPerOperation": 5895.2802734375,"ProcessRetainedBytesDelta": -2447552,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 17575.854740452287,"P50Milliseconds": 0.0397,"P95Milliseconds": 0.111,"P99Milliseconds": 0.2446,"ProcessAllocatedBytesPerOperation": 5899.646484375,"ProcessRetainedBytesDelta": 2082912,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 23560.12566646619,"P50Milliseconds": 0.0369,"P95Milliseconds": 0.0739,"P99Milliseconds": 0.1244,"ProcessAllocatedBytesPerOperation": 5890.704833984375,"ProcessRetainedBytesDelta": -2627312,"Gen0Collections": 23,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 39453.50252779101,"P50Milliseconds": 0.2582,"P95Milliseconds": 0.9555,"P99Milliseconds": 2.0165,"ProcessAllocatedBytesPerOperation": 6129.98486328125,"ProcessRetainedBytesDelta": 259856,"Gen0Collections": 24,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 50301.75217974771,"P50Milliseconds": 0.2437,"P95Milliseconds": 0.645,"P99Milliseconds": 1.314,"ProcessAllocatedBytesPerOperation": 5974.188720703125,"ProcessRetainedBytesDelta": -822184,"Gen0Collections": 23,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 39534.72855888265,"P50Milliseconds": 0.271,"P95Milliseconds": 0.9088,"P99Milliseconds": 2.0037,"ProcessAllocatedBytesPerOperation": 5979.0576171875,"ProcessRetainedBytesDelta": -310456,"Gen0Collections": 23,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 42688.8504169289,"P50Milliseconds": 0.2624,"P95Milliseconds": 0.7844,"P99Milliseconds": 1.4828,"ProcessAllocatedBytesPerOperation": 6005.357666015625,"ProcessRetainedBytesDelta": -313344,"Gen0Collections": 23,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 52988.219952859115,"P50Milliseconds": 0.2389,"P95Milliseconds": 0.571,"P99Milliseconds": 1.1475,"ProcessAllocatedBytesPerOperation": 6059.02880859375,"ProcessRetainedBytesDelta": 483440,"Gen0Collections": 24,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 37182.529249412866,"P50Milliseconds": 0.2663,"P95Milliseconds": 0.9815,"P99Milliseconds": 2.5659,"ProcessAllocatedBytesPerOperation": 5899.90478515625,"ProcessRetainedBytesDelta": -1075752,"Gen0Collections": 23,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 101605.41391152005,"P50Milliseconds": 0.1199,"P95Milliseconds": 0.3006,"P99Milliseconds": 1.1107,"ProcessAllocatedBytesPerOperation": 5941.993408203125,"ProcessRetainedBytesDelta": -675848,"Gen0Collections": 23,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 115214.8024017617,"P50Milliseconds": 0.1088,"P95Milliseconds": 0.243,"P99Milliseconds": 1.0389,"ProcessAllocatedBytesPerOperation": 5931.873291015625,"ProcessRetainedBytesDelta": -686392,"Gen0Collections": 23,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 118172.054876725,"P50Milliseconds": 0.1081,"P95Milliseconds": 0.2317,"P99Milliseconds": 0.9613,"ProcessAllocatedBytesPerOperation": 5930.90869140625,"ProcessRetainedBytesDelta": -720688,"Gen0Collections": 23,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 135762.8789950365,"P50Milliseconds": 0.1025,"P95Milliseconds": 0.1587,"P99Milliseconds": 0.8946,"ProcessAllocatedBytesPerOperation": 5930.743896484375,"ProcessRetainedBytesDelta": -271480,"Gen0Collections": 23,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 111153.51281124969,"P50Milliseconds": 0.1064,"P95Milliseconds": 0.2855,"P99Milliseconds": 0.9633,"ProcessAllocatedBytesPerOperation": 5929.720703125,"ProcessRetainedBytesDelta": -653152,"Gen0Collections": 23,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 96692.79823375118,"P50Milliseconds": 0.1046,"P95Milliseconds": 0.2825,"P99Milliseconds": 1.0468,"ProcessAllocatedBytesPerOperation": 5941.00390625,"ProcessRetainedBytesDelta": -774248,"Gen0Collections": 23,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false} + ] +} diff --git a/docs/performance/2026-09-07-warmed-3-baseline.json b/docs/performance/2026-09-07-warmed-3-baseline.json new file mode 100644 index 0000000..b31472c --- /dev/null +++ b/docs/performance/2026-09-07-warmed-3-baseline.json @@ -0,0 +1,30 @@ +{ + "FrameworkDescription": ".NET 10.0.11", + "OSDescription": "macOS 26.6.2", + "ProcessArchitecture": 3, + "ProcessorCount": 12, + "Timestamp": "2026-09-07T19:21:53.746912+00:00", + "Topology": "Two silos; preferred gateway 0; verified grain placement on silo 0", + "WarmWorkload": true, + "MetadataPayload": "IEAhMsVyuxo8rk6uUmeflS9SeyEgIGCKc/8pQTFyYXRlLWxpbWl0LWNvbXBhdGliaWxpdHng4CFg+BZJ50EbbGVhc2UtcGF5bG9hZODgAQMh4OA=", + "Results": [ + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 24092.47369003056,"P50Milliseconds": 0.0355,"P95Milliseconds": 0.0791,"P99Milliseconds": 0.133,"ProcessAllocatedBytesPerOperation": 6154.523193359375,"ProcessRetainedBytesDelta": -2617008,"Gen0Collections": 24,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 22269.53834610891,"P50Milliseconds": 0.0352,"P95Milliseconds": 0.0849,"P99Milliseconds": 0.1836,"ProcessAllocatedBytesPerOperation": 6156.1884765625,"ProcessRetainedBytesDelta": 2196512,"Gen0Collections": 24,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 26791.867628158507,"P50Milliseconds": 0.0344,"P95Milliseconds": 0.0605,"P99Milliseconds": 0.0934,"ProcessAllocatedBytesPerOperation": 6153.44140625,"ProcessRetainedBytesDelta": -2620408,"Gen0Collections": 24,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 19871.624020723055,"P50Milliseconds": 0.038,"P95Milliseconds": 0.1023,"P99Milliseconds": 0.226,"ProcessAllocatedBytesPerOperation": 8997.840576171875,"ProcessRetainedBytesDelta": -284336,"Gen0Collections": 35,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 15776.152723634714,"P50Milliseconds": 0.0405,"P95Milliseconds": 0.1318,"P99Milliseconds": 0.3237,"ProcessAllocatedBytesPerOperation": 9005.97265625,"ProcessRetainedBytesDelta": -304664,"Gen0Collections": 35,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "sequential","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 19033.980197062803,"P50Milliseconds": 0.0394,"P95Milliseconds": 0.1025,"P99Milliseconds": 0.1952,"ProcessAllocatedBytesPerOperation": 9001.053466796875,"ProcessRetainedBytesDelta": -295328,"Gen0Collections": 35,"Gen1Collections": 0,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 61386.17200042713,"P50Milliseconds": 0.2355,"P95Milliseconds": 0.347,"P99Milliseconds": 1.059,"ProcessAllocatedBytesPerOperation": 6387.870849609375,"ProcessRetainedBytesDelta": 2831712,"Gen0Collections": 25,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 41009.11353960287,"P50Milliseconds": 0.25,"P95Milliseconds": 0.8997,"P99Milliseconds": 1.8876,"ProcessAllocatedBytesPerOperation": 6220.702392578125,"ProcessRetainedBytesDelta": -169584,"Gen0Collections": 24,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 47101.76384376152,"P50Milliseconds": 0.2564,"P95Milliseconds": 0.6582,"P99Milliseconds": 1.3605,"ProcessAllocatedBytesPerOperation": 6171.55810546875,"ProcessRetainedBytesDelta": -238168,"Gen0Collections": 24,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 39303.745094678394,"P50Milliseconds": 0.2511,"P95Milliseconds": 0.9858,"P99Milliseconds": 2.1552,"ProcessAllocatedBytesPerOperation": 9054.94677734375,"ProcessRetainedBytesDelta": -2150144,"Gen0Collections": 35,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 57965.09746850244,"P50Milliseconds": 0.2366,"P95Milliseconds": 0.5078,"P99Milliseconds": 1.1692,"ProcessAllocatedBytesPerOperation": 9141.59130859375,"ProcessRetainedBytesDelta": 2454560,"Gen0Collections": 36,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "hot-key-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 45963.49403349859,"P50Milliseconds": 0.2417,"P95Milliseconds": 0.693,"P99Milliseconds": 1.7238,"ProcessAllocatedBytesPerOperation": 9238.933349609375,"ProcessRetainedBytesDelta": -3015856,"Gen0Collections": 36,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 80629.34590279745,"P50Milliseconds": 0.1169,"P95Milliseconds": 0.5467,"P99Milliseconds": 1.377,"ProcessAllocatedBytesPerOperation": 6228.95947265625,"ProcessRetainedBytesDelta": -628256,"Gen0Collections": 24,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 86986.66901689243,"P50Milliseconds": 0.126,"P95Milliseconds": 0.391,"P99Milliseconds": 1.3297,"ProcessAllocatedBytesPerOperation": 6226.03515625,"ProcessRetainedBytesDelta": 115096,"Gen0Collections": 24,"Gen1Collections": 2,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": false,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 54766.982101619426,"P50Milliseconds": 0.1554,"P95Milliseconds": 0.8633,"P99Milliseconds": 2.5863,"ProcessAllocatedBytesPerOperation": 6269.707763671875,"ProcessRetainedBytesDelta": -585360,"Gen0Collections": 24,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 0,"Operations": 32768,"OperationsPerSecond": 118005.40329916718,"P50Milliseconds": 0.1042,"P95Milliseconds": 0.2207,"P99Milliseconds": 1.0573,"ProcessAllocatedBytesPerOperation": 9046.20068359375,"ProcessRetainedBytesDelta": -244368,"Gen0Collections": 35,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 1,"Operations": 32768,"OperationsPerSecond": 130502.48714609444,"P50Milliseconds": 0.102,"P95Milliseconds": 0.1509,"P99Milliseconds": 1.0056,"ProcessAllocatedBytesPerOperation": 9032.124755859375,"ProcessRetainedBytesDelta": 13488,"Gen0Collections": 35,"Gen1Collections": 3,"Gen2Collections": 0,"TokenCanBeCanceled": false}, + {"Algorithm": "concurrency","Scenario": "partitioned-16","Cancellable": true,"Repetition": 2,"Operations": 32768,"OperationsPerSecond": 127225.95925181756,"P50Milliseconds": 0.104,"P95Milliseconds": 0.1617,"P99Milliseconds": 1.0518,"ProcessAllocatedBytesPerOperation": 9034.717041015625,"ProcessRetainedBytesDelta": -721264,"Gen0Collections": 35,"Gen1Collections": 4,"Gen2Collections": 0,"TokenCanBeCanceled": false} + ] +} diff --git a/scripts/summarize-performance.py b/scripts/summarize-performance.py new file mode 100644 index 0000000..ac83411 --- /dev/null +++ b/scripts/summarize-performance.py @@ -0,0 +1,55 @@ +"""Summarize every paired ABBA measurement; never discard unfavorable samples.""" + +import collections +import json +import pathlib +import statistics +import sys + +BASELINE = "baseline" +UPDATED = "updated" +VERSIONS = (BASELINE, UPDATED) +EXPECTED_SAMPLES = 6 +PERCENT = 100 +OPERATION_RATE = "OperationsPerSecond" +TAIL_LATENCY = "P99Milliseconds" +ALLOCATION = "ProcessAllocatedBytesPerOperation" +METRICS = (OPERATION_RATE, TAIL_LATENCY, ALLOCATION) +CASE_FIELDS = ("Algorithm", "Scenario", "Cancellable", "TokenCanBeCanceled") + + +def read_measurements(directory): + groups = {version: collections.defaultdict(list) for version in VERSIONS} + for path in sorted(directory.glob("*.json")): + version = next(version for version in VERSIONS if f"-{version}-" in path.name) + report = json.loads(path.read_text()) + if not report.get("WarmWorkload") or not report.get("Topology"): + raise ValueError(f"Missing warmed topology evidence: {path}") + for row in report["Results"]: + groups[version][tuple(row[field] for field in CASE_FIELDS)].append(row) + if not groups[BASELINE] or groups[BASELINE].keys() != groups[UPDATED].keys(): + raise ValueError("Baseline and updated cases must match and be nonempty") + return groups + + +def summarize(groups): + print("\nMedians of all six repetitions per case (two processes per revision).") + print("Throughput includes successful acquisition and disposal; p99 is the median of per-repetition p99 values.") + print("Raw JSON retains each repetition, allocations, GC counts, timestamps and verified topology.\n") + print("| Scenario | Token overload | Cancellable token | Baseline ops/s | Updated ops/s | Change | p99 ms baseline → updated | B/op baseline → updated |") + print("| --- | --- | --- | ---: | ---: | ---: | ---: | ---: |") + for case in sorted(groups[BASELINE]): + values = {} + for version in VERSIONS: + rows = groups[version][case] + if len(rows) != EXPECTED_SAMPLES: + raise ValueError(f"Expected {EXPECTED_SAMPLES} samples for {version} {case}") + values[version] = {metric: statistics.median(row[metric] for row in rows) for metric in METRICS} + before, after = values[BASELINE], values[UPDATED] + change = (after[OPERATION_RATE] - before[OPERATION_RATE]) / before[OPERATION_RATE] * PERCENT + _, scenario, overload, token = case + print(f"| {scenario} | {overload} | {token} | {before[OPERATION_RATE]:,.0f} | {after[OPERATION_RATE]:,.0f} | {change:+.1f}% | {before[TAIL_LATENCY]:.3f} → {after[TAIL_LATENCY]:.3f} | {before[ALLOCATION]:,.0f} → {after[ALLOCATION]:,.0f} |") + + +if __name__ == "__main__": + summarize(read_measurements(pathlib.Path(sys.argv[-1])))