diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml
new file mode 100644
index 0000000..c1b0b10
--- /dev/null
+++ b/.github/workflows/build_and_test.yml
@@ -0,0 +1,185 @@
+name: Build, Test, Release
+
+on:
+ workflow_dispatch:
+ push:
+ tags:
+ - '*'
+ branches:
+ - 'main'
+ - 'gh-actions*'
+ pull_request:
+ branches:
+ - '*'
+
+env:
+ TIMEZONE: 'America/Chicago'
+ DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1
+ DOTNET_NOLOGO: true
+ ArtifactDirectory: ${{ github.workspace}}/artifacts
+
+defaults:
+ run:
+ shell: pwsh
+
+jobs:
+ build_and_test:
+ runs-on: ubuntu-latest
+ permissions:
+ checks: write
+ pull-requests: write
+ steps:
+ - uses: actions/checkout@v2
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v3
+ with:
+ dotnet-version: 8
+ dotnet-quality: ga
+
+ - name: Restore dependencies
+ run: dotnet restore
+
+ - name: Build
+ run: dotnet build --no-restore --configuration:Release
+
+ - name: Test Core
+ if: ${{ always() && github.event_name == 'pull_request' || github.ref == 'refs/heads/main' }}
+ run: |
+ dotnet test 'tests\Distributed.Collections.Tests\' `
+ --no-build `
+ --configuration:Release `
+ --logger:trx `
+ --results-directory ${{ env.ArtifactDirectory }}
+
+ - name: Test Redis
+ if: ${{ always() && github.event_name == 'pull_request' || github.ref == 'refs/heads/main' }}
+ run: |
+ dotnet test 'tests\Distributed.Collections.Redis.Tests\' `
+ --no-build `
+ --configuration:Release `
+ --logger:trx `
+ --results-directory ${{ env.ArtifactDirectory }}
+
+ - name: Upload Test Results
+ if: ${{ always() && github.event_name == 'pull_request' || github.ref == 'refs/heads/main' }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: test-results
+ path: ${{ env.ArtifactDirectory }}/**/*.trx
+
+ # Cosmos DB Emulator is broken on Ubuntu
+ # https://github.com/Azure/azure-cosmos-db-emulator-docker/issues/56
+
+ build_and_test_cosmos:
+ if: false
+ runs-on: windows-2022
+ permissions:
+ checks: write
+ pull-requests: write
+ # Reenable when Ubuntu is fixed
+ # services:
+ # cosmos:
+ # image: mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator
+ # ports:
+ # - 8081:8081
+ # - 10250-10255:10250-10255
+ # env:
+ # AZURE_COSMOS_EMULATOR_PARTITION_COUNT: 3
+ # AZURE_COSMOS_EMULATOR_ENABLE_DATA_PERSISTENCE: true
+ steps:
+ - uses: actions/checkout@v2
+
+ # Remove when Ubuntu is fixed
+ - name: Start Cosmos DB Emulator
+ if: ${{ github.event_name == 'pull_request' || github.ref == 'refs/heads/main' }}
+ uses: southpolesteve/cosmos-emulator-github-action@v1
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v3
+ with:
+ dotnet-version: 8
+ dotnet-quality: ga
+
+ - name: Restore dependencies
+ run: dotnet restore
+
+ - name: Build
+ run: dotnet build --no-restore --configuration:Release
+
+ # Reenable when Ubuntu is fixed
+ # - name: Wait for Cosmos Emulator
+ # uses: cygnetdigital/wait_for_response@v2.0.0
+ # with:
+ # url: 'https://localhost:8081/_explorer/index.html'
+ # responseCode: '200,500'
+ # timeout: 5000
+ # interval: 500
+
+ - name: Test Cosmos
+ if: ${{ github.event_name == 'pull_request' || github.ref == 'refs/heads/main' }}
+ run: |
+ dotnet test 'tests\Distributed.Collections.Azure.Cosmos.Tests\' `
+ --no-build `
+ --configuration:Release `
+ --logger:trx `
+ --results-directory ${{ env.ArtifactDirectory }}
+
+ - name: Upload Test Results
+ if: ${{ always() && github.event_name == 'pull_request' || github.ref == 'refs/heads/main' }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: cosmos-test-results
+ path: ${{ env.ArtifactDirectory }}/**/*.trx
+
+ test_results_comment:
+ if: ${{ always() && github.event_name == 'pull_request' || github.ref == 'refs/heads/main' }}
+ runs-on: ubuntu-latest
+ permissions:
+ checks: write
+ pull-requests: write
+ needs:
+ - build_and_test
+ #- build_and_test_cosmos
+
+ steps:
+ - name: Download Test Results
+ uses: actions/download-artifact@v4
+ with:
+ path: ${{ env.ArtifactDirectory }}
+
+ - name: Test Results Comment
+ uses: im-open/process-dotnet-test-results@v2.4
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ timezone: ${{ env.TIMEZONE }}
+ create-status-check: false
+ create-pr-comment: true
+ comment-identifier: 'TestResults'
+
+ tag:
+ if: ${{ github.event_name != 'pull_request' }}
+ uses: ./.github/workflows/tag.yml
+
+ test-tag-output:
+ runs-on: ubuntu-latest
+ needs:
+ - tag
+ steps:
+ - name: Echo tag output
+ run: |
+ echo version: "${{ needs.tag.outputs.version }}"
+ echo prerelease-depth: "${{ fromJSON(needs.tag.outputs.prerelease-depth) }}"
+ echo is-prerelease: "${{ fromJSON(needs.tag.outputs.is-prerelease) }}"
+
+ release:
+ needs:
+ - tag
+ - build_and_test
+ #- build_and_test_cosmos
+ if: ${{ github.ref_type == 'tag' || github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && fromJSON(needs.tag.outputs.prerelease-depth) < 2 }}
+ uses: ./.github/workflows/release.yml
+ with:
+ version: ${{ needs.tag.outputs.version }}
+ is-prerelease: ${{ fromJSON(needs.tag.outputs.is-prerelease) }}
+ secrets: inherit
\ No newline at end of file
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..d96148b
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,69 @@
+name: Pack & Release
+
+on:
+ workflow_call:
+ inputs:
+ version:
+ required: true
+ type: string
+ is-prerelease:
+ required: true
+ type: boolean
+ secrets:
+ NUGET_ORG_API_KEY:
+ required: true
+env:
+ MinVerVersionOverride: ${{ inputs.version }}
+ DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1
+ DOTNET_NOLOGO: true
+ ArtifactDirectory: ${{ github.workspace }}/artifacts
+
+defaults:
+ run:
+ shell: pwsh
+
+jobs:
+ release:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ packages: write
+ steps:
+ - uses: actions/checkout@v3
+ with:
+ fetch-depth: 0
+
+ - run: |
+ dotnet nuget add source `
+ --username USERNAME `
+ --password ${{ secrets.GITHUB_TOKEN }} `
+ --store-password-in-clear-text `
+ --name github "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json"
+
+ - run: |
+ dotnet pack `
+ -c Release `
+ -o ${{ env.ArtifactDirectory }} `
+ /p:RepositoryUrl="${{ github.server_url }}/${{ github.repository }}" `
+ /p:Authors=${{ github.repository_owner }}
+
+ - name: Echo artifact directory
+ run: |
+ echo ${{ env.ArtifactDirectory }}
+ ls ${{ env.ArtifactDirectory }}
+
+ - name: Create GitHub release
+ id: release
+ uses: ncipollo/release-action@v1
+ with:
+ tag: v${{ inputs.version }}
+ prerelease: ${{ inputs.is-prerelease }}
+ artifacts: "${{ env.ArtifactDirectory }}/*.nupkg"
+
+ - name: Publish to pkg.github.com
+ run: |
+ dotnet nuget push "${{ env.ArtifactDirectory }}/*.nupkg" -s "github"
+
+ - name: Publish to nuget.org
+ run: |
+ dotnet nuget push "${{ env.ArtifactDirectory }}/*.nupkg" -k ${{ secrets.NUGET_ORG_API_KEY }} -s https://api.nuget.org/v3/index.json
\ No newline at end of file
diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml
new file mode 100644
index 0000000..b0d8831
--- /dev/null
+++ b/.github/workflows/tag.yml
@@ -0,0 +1,102 @@
+name: Tag
+
+on:
+ workflow_call:
+ outputs:
+ version:
+ description: "Generated version"
+ value: ${{ jobs.tag.outputs.version }}
+ major:
+ description: "Generated major version"
+ value: ${{ jobs.tag.outputs.major }}
+ minor:
+ description: "Generated minor version"
+ value: ${{ jobs.tag.outputs.minor }}
+ patch:
+ description: "Generated patch version"
+ value: ${{ jobs.tag.outputs.patch }}
+ prerelease:
+ description: "Generated prerelease version"
+ value: ${{ jobs.tag.outputs.prerelease }}
+ prerelease-depth:
+ description: "Number of periods in the prerelease version"
+ value: ${{ jobs.tag.outputs.prerelease-depth }}
+ is-prerelease:
+ description: "Boolean string indicating whether the version is a prerelease"
+ value: ${{ jobs.tag.outputs.is-prerelease }}
+
+env:
+ #MinVerIgnoreHeight: true
+ DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1
+ DOTNET_NOLOGO: true
+
+jobs:
+ tag:
+ runs-on: ubuntu-latest
+ outputs:
+ version: ${{ steps.version.outputs.version }}
+ major: ${{ steps.version.outputs.major }}
+ minor: ${{ steps.version.outputs.minor }}
+ patch: ${{ steps.version.outputs.patch }}
+ prerelease: ${{ steps.version.outputs.prerelease }}
+ prerelease-depth: ${{ steps.set-prerelease-depth.outputs.prerelease_depth }}
+ is-prerelease: ${{ steps.set-is-prerelease.outputs.is_prerelease }}
+ steps:
+ - uses: actions/checkout@v2
+ with:
+ fetch-depth: 0
+
+ - name: action-minver-refreshed
+ id: version
+ uses: Stelzi79/action-minver@3.0.1
+ with:
+ minver-version: 4.3.0
+ auto-increment: patch
+ tag-prefix: v
+ default-pre-release-phase: preview
+
+ - name: Echo version #version major minor patch prerelease
+ run: |
+ echo version: ${{ steps.version.outputs.version }}
+ echo major: ${{ steps.version.outputs.major }}, minor: ${{ steps.version.outputs.minor }}, patch: ${{ steps.version.outputs.patch }}
+ echo prerelease: ${{ steps.version.outputs.prerelease }}
+
+ - name: Set prerelease depth
+ id: set-prerelease-depth
+ run: |
+ echo "prerelease_depth=$(echo '${{ steps.version.outputs.prerelease }}' | tr -cd '.' | wc -c | tr -d ' ')" >> "$GITHUB_OUTPUT"
+
+ - name: Echo prerelease depth
+ run: echo "${{ steps.set-prerelease-depth.outputs.prerelease_depth }}"
+
+ - name: Set is-prerelease
+ id: set-is-prerelease
+ run: |
+ echo "is_prerelease=$(echo '${{ contains(steps.version.outputs.version, '-') && 'true' || 'false' }}')" >> "$GITHUB_OUTPUT"
+
+ - name: Echo is-prerelease
+ run: echo "${{ steps.set-is-prerelease.outputs.is_prerelease }}"
+
+ - name: Tag version
+ if: ${{ github.ref == 'refs/heads/main' }}
+ run: |
+ git tag v${{ steps.version.outputs.version }}
+ git push --tags
+
+ # Update unstable tag on main branch
+ - name: Tag unstable
+ if: ${{ github.ref_type == 'tag' || github.ref == 'refs/heads/main' }}
+ uses: EndBug/latest-tag@v1.5.1
+ with:
+ ref: unstable
+
+ # Update latest tag on main branch when tagged RTM
+ - name: Tag latest
+ if: ${{ github.ref_type == 'tag' || github.ref == 'refs/heads/main' && steps.version.outputs.prerelease == '' }}
+ uses: EndBug/latest-tag@v1.5.1
+
+ # - name: Tag version
+ # if: ${{ github.ref == 'refs/heads/main' }}
+ # uses: EndBug/latest-tag@v1.5.1
+ # with:
+ # ref: v${{ steps.version.outputs.version }}
\ No newline at end of file
diff --git a/Distributed.Collections.sln b/Distributed.Collections.sln
new file mode 100644
index 0000000..a05d8dd
--- /dev/null
+++ b/Distributed.Collections.sln
@@ -0,0 +1,63 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.9.34616.47
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Distributed.Collections.Tests", "tests\Distributed.Collections.Tests\Distributed.Collections.Tests.csproj", "{DD19D032-3E66-4990-890D-3873537E816B}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{797491C6-8CEB-4319-8806-ED4514648ED1}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{914818D9-D689-46FB-BE8B-1C1C03D83ECA}"
+ ProjectSection(SolutionItems) = preProject
+ .github\workflows\build_and_test.yml = .github\workflows\build_and_test.yml
+ .github\workflows\release.yml = .github\workflows\release.yml
+ .github\workflows\tag.yml = .github\workflows\tag.yml
+ EndProjectSection
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Distributed.Collections", "src\Distributed.Collections\Distributed.Collections.csproj", "{226DB6D6-32D9-4A65-8A70-A20FB1197C6B}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Distributed.Collections.Redis", "src\Distributed.Collections.Redis\Distributed.Collections.Redis.csproj", "{F7894007-7DC2-4A4B-AE42-F5CE15B7A57B}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Distributed.Collections.Redis.Tests", "tests\Distributed.Collections.Redis.Tests\Distributed.Collections.Redis.Tests.csproj", "{08BDFB09-E7E7-4FCA-9BD9-DA63D24033B2}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{2BBB78B9-083B-4237-99F5-A590E49DB789}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{512531A2-6D2D-4A62-BBC7-2BEA3C531408}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {DD19D032-3E66-4990-890D-3873537E816B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {DD19D032-3E66-4990-890D-3873537E816B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {DD19D032-3E66-4990-890D-3873537E816B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {DD19D032-3E66-4990-890D-3873537E816B}.Release|Any CPU.Build.0 = Release|Any CPU
+ {226DB6D6-32D9-4A65-8A70-A20FB1197C6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {226DB6D6-32D9-4A65-8A70-A20FB1197C6B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {226DB6D6-32D9-4A65-8A70-A20FB1197C6B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {226DB6D6-32D9-4A65-8A70-A20FB1197C6B}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F7894007-7DC2-4A4B-AE42-F5CE15B7A57B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F7894007-7DC2-4A4B-AE42-F5CE15B7A57B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F7894007-7DC2-4A4B-AE42-F5CE15B7A57B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F7894007-7DC2-4A4B-AE42-F5CE15B7A57B}.Release|Any CPU.Build.0 = Release|Any CPU
+ {08BDFB09-E7E7-4FCA-9BD9-DA63D24033B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {08BDFB09-E7E7-4FCA-9BD9-DA63D24033B2}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {08BDFB09-E7E7-4FCA-9BD9-DA63D24033B2}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {08BDFB09-E7E7-4FCA-9BD9-DA63D24033B2}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {DD19D032-3E66-4990-890D-3873537E816B} = {512531A2-6D2D-4A62-BBC7-2BEA3C531408}
+ {914818D9-D689-46FB-BE8B-1C1C03D83ECA} = {797491C6-8CEB-4319-8806-ED4514648ED1}
+ {226DB6D6-32D9-4A65-8A70-A20FB1197C6B} = {2BBB78B9-083B-4237-99F5-A590E49DB789}
+ {F7894007-7DC2-4A4B-AE42-F5CE15B7A57B} = {2BBB78B9-083B-4237-99F5-A590E49DB789}
+ {08BDFB09-E7E7-4FCA-9BD9-DA63D24033B2} = {512531A2-6D2D-4A62-BBC7-2BEA3C531408}
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {A45838C5-8918-4AEE-BDC0-A6F3865CB0ED}
+ EndGlobalSection
+EndGlobal
diff --git a/src/Distributed.Collections.Redis/Distributed.Collections.Redis.csproj b/src/Distributed.Collections.Redis/Distributed.Collections.Redis.csproj
new file mode 100644
index 0000000..f126ff2
--- /dev/null
+++ b/src/Distributed.Collections.Redis/Distributed.Collections.Redis.csproj
@@ -0,0 +1,28 @@
+
+
+
+ net6.0
+ enable
+ disable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
diff --git a/src/Distributed.Collections.Redis/RedisDistributedHashSet.cs b/src/Distributed.Collections.Redis/RedisDistributedHashSet.cs
new file mode 100644
index 0000000..1f2fdab
--- /dev/null
+++ b/src/Distributed.Collections.Redis/RedisDistributedHashSet.cs
@@ -0,0 +1,57 @@
+using StackExchange.Redis;
+
+namespace Distributed.Collections.Redis;
+
+public class RedisDistributedHashSet : IDistributedHashSet
+{
+ private readonly IDatabase _database;
+ private readonly IRedisSerializer _serializer;
+ private readonly string _setKey;
+
+ public RedisDistributedHashSet(IDatabase database, string setKey) : this(database, DefaultRedisSerializer.Instance, setKey) { }
+
+ public RedisDistributedHashSet(IDatabase database, IRedisSerializer serializer, string setKey)
+ {
+ _database = database;
+ _serializer = serializer;
+ _setKey = setKey;
+ }
+
+ public async Task AddAsync(T item)
+ {
+ return await _database.SetAddAsync(_setKey, _serializer.Serialize(item));
+ }
+
+ public async Task RemoveAsync(T item)
+ {
+ return await _database.SetRemoveAsync(_setKey, _serializer.Serialize(item));
+ }
+
+ public async Task ContainsAsync(T item)
+ {
+ return await _database.SetContainsAsync(_setKey, _serializer.Serialize(item));
+ }
+
+ public async Task CountAsync()
+ {
+ return (int)await _database.SetLengthAsync(_setKey);
+ }
+
+ public async Task LongCountAsync()
+ {
+ return await _database.SetLengthAsync(_setKey);
+ }
+
+ public async Task ClearAsync()
+ {
+ await _database.KeyDeleteAsync(_setKey);
+ }
+
+ public async IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = new())
+ {
+ await foreach (var item in _database.SetScanAsync(_setKey).WithCancellation(cancellationToken))
+ {
+ yield return _serializer.Deserialize(item);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Distributed.Collections.Redis/RedisDistributedHashSetFactory.cs b/src/Distributed.Collections.Redis/RedisDistributedHashSetFactory.cs
new file mode 100644
index 0000000..5989c6f
--- /dev/null
+++ b/src/Distributed.Collections.Redis/RedisDistributedHashSetFactory.cs
@@ -0,0 +1,12 @@
+using StackExchange.Redis;
+
+namespace Distributed.Collections.Redis;
+
+public class RedisDistributedHashSetFactory : IHashSetFactory
+{
+ private readonly IDatabase _database;
+
+ public RedisDistributedHashSetFactory(IDatabase database) => _database = database;
+
+ public IDistributedHashSet Create(string name) => new RedisDistributedHashSet(_database, name);
+}
\ No newline at end of file
diff --git a/src/Distributed.Collections.Redis/RedisSerializer.cs b/src/Distributed.Collections.Redis/RedisSerializer.cs
new file mode 100644
index 0000000..91b666d
--- /dev/null
+++ b/src/Distributed.Collections.Redis/RedisSerializer.cs
@@ -0,0 +1,27 @@
+using System.Text.Json;
+using StackExchange.Redis;
+
+namespace Distributed.Collections.Redis;
+
+public interface IRedisSerializer
+{
+ RedisValue Serialize(T value);
+ T Deserialize(RedisValue redisValue);
+}
+
+internal class DefaultRedisSerializer : IRedisSerializer
+{
+ private static readonly Lazy _instance = new(() => new DefaultRedisSerializer());
+
+ public static DefaultRedisSerializer Instance => _instance.Value;
+
+ private DefaultRedisSerializer() { }
+
+ public RedisValue Serialize(T value) => new(JsonSerializer.Serialize(value));
+
+ public T Deserialize(RedisValue redisValue) => redisValue switch
+ {
+ { HasValue: true, IsNull: false } => JsonSerializer.Deserialize(redisValue.ToString()),
+ _ => default
+ };
+}
\ No newline at end of file
diff --git a/src/Distributed.Collections/Distributed.Collections.csproj b/src/Distributed.Collections/Distributed.Collections.csproj
new file mode 100644
index 0000000..a8705d1
--- /dev/null
+++ b/src/Distributed.Collections/Distributed.Collections.csproj
@@ -0,0 +1,24 @@
+
+
+
+ net6.0
+ enable
+ disable
+
+
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
diff --git a/src/Distributed.Collections/DistributedHashSet.cs b/src/Distributed.Collections/DistributedHashSet.cs
new file mode 100644
index 0000000..095591b
--- /dev/null
+++ b/src/Distributed.Collections/DistributedHashSet.cs
@@ -0,0 +1,69 @@
+using System.Collections.Concurrent;
+
+namespace Distributed.Collections;
+
+public interface IHashSetFactory
+{
+ IDistributedHashSet Create(string name);
+}
+
+public interface IDistributedHashSet { }
+
+public interface IDistributedHashSet : IDistributedHashSet, IAsyncEnumerable
+{
+ Task AddAsync(T item);
+ Task RemoveAsync(T item);
+ Task ContainsAsync(T item);
+ Task CountAsync();
+ Task LongCountAsync();
+ Task ClearAsync();
+}
+
+public class InMemoryDistributedHashSet : IDistributedHashSet
+{
+ private record Item(T Value);
+ private class ItemComparer : IEqualityComparer-
+ {
+ private readonly IEqualityComparer _comparer;
+
+ public ItemComparer(IEqualityComparer comparer) => _comparer = comparer;
+
+ public bool Equals(Item x, Item y) => _comparer.Equals(x.Value, y.Value);
+
+ public int GetHashCode(Item obj) => _comparer.GetHashCode(obj.Value);
+ }
+
+ private readonly ConcurrentDictionary
- _dictionary = new(new ItemComparer(EqualityComparer.Default));
+
+ #region IDistributedHashSet Members
+
+ public Task AddAsync(T item) =>
+ Task.FromResult(_dictionary.TryAdd(new Item(item), byte.MinValue));
+
+ public Task RemoveAsync(T item) =>
+ Task.FromResult(_dictionary.TryRemove(new Item(item), out _));
+
+ public Task ContainsAsync(T item) =>
+ Task.FromResult(_dictionary.ContainsKey(new Item(item)));
+
+ public Task CountAsync() =>
+ Task.FromResult(_dictionary.Count);
+
+ public Task LongCountAsync() =>
+ Task.FromResult((long)_dictionary.Count);
+
+ public Task ClearAsync()
+ {
+ _dictionary.Clear();
+ return Task.CompletedTask;
+ }
+
+ #endregion
+
+ #region IAsyncEnumerable Members
+
+ public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) =>
+ _dictionary.Keys.Select(x => x.Value).ToAsyncEnumerable().GetAsyncEnumerator(cancellationToken);
+
+ #endregion
+}
\ No newline at end of file
diff --git a/tests/Distributed.Collections.Redis.Tests/Distributed.Collections.Redis.Tests.csproj b/tests/Distributed.Collections.Redis.Tests/Distributed.Collections.Redis.Tests.csproj
new file mode 100644
index 0000000..55edc50
--- /dev/null
+++ b/tests/Distributed.Collections.Redis.Tests/Distributed.Collections.Redis.Tests.csproj
@@ -0,0 +1,29 @@
+
+
+
+ net8.0
+ enable
+ disable
+
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Distributed.Collections.Redis.Tests/Fixtures.cs b/tests/Distributed.Collections.Redis.Tests/Fixtures.cs
new file mode 100644
index 0000000..cf61f29
--- /dev/null
+++ b/tests/Distributed.Collections.Redis.Tests/Fixtures.cs
@@ -0,0 +1,22 @@
+using Distributed.Collections.Tests;
+using DotNet.Testcontainers.Builders;
+using DotNet.Testcontainers.Containers;
+using Ephemerally.Redis.Xunit;
+
+namespace Distributed.Collections.Redis.Tests;
+
+public class RedisCollectionFixture : PooledEphemeralRedisMultiplexerFixture;
+
+public class RedisTestContainerFixture : TestContainerFixture, IRedisInstanceFixture
+{
+ public ushort PublicPort => Container.GetMappedPublicPort(6379);
+
+ public string ConnectionString => $"localhost:{PublicPort},allowAdmin=true";
+
+ protected override IContainer CreateContainer() =>
+ new ContainerBuilder()
+ .WithImage("redis:7-alpine")
+ .WithPortBinding(6379, true)
+ .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(6379))
+ .Build();
+}
\ No newline at end of file
diff --git a/tests/Distributed.Collections.Redis.Tests/RedisDistributedHashSetTests.cs b/tests/Distributed.Collections.Redis.Tests/RedisDistributedHashSetTests.cs
new file mode 100644
index 0000000..d6468b0
--- /dev/null
+++ b/tests/Distributed.Collections.Redis.Tests/RedisDistributedHashSetTests.cs
@@ -0,0 +1,43 @@
+using Distributed.Collections.Tests;
+using Ephemerally;
+using Ephemerally.Redis;
+using Ephemerally.Redis.Xunit;
+
+namespace Distributed.Collections.Redis.Tests;
+
+[Collection(RedisTestCollection.Name)]
+public class RedisStringDistributedHashSetTests(RedisCollectionFixture multiplexerFixture) :
+ StringDistributedHashSetTests>
+{
+ protected override Task> CreateHashSet() => multiplexerFixture.HashSet();
+}
+
+[Collection(RedisTestCollection.Name)]
+public class RedisIntDistributedHashSetTests(RedisCollectionFixture multiplexerFixture) :
+ IntDistributedHashSetTests>
+{
+ protected override Task> CreateHashSet() => multiplexerFixture.HashSet();
+}
+
+internal class EphemeralRedisDistributedHashSet : RedisDistributedHashSet, IAsyncDisposable
+{
+ private readonly IEphemeralRedisDatabase _database;
+
+ public EphemeralRedisDistributedHashSet(IEphemeralRedisDatabase database, string setKey) : base(database, setKey)
+ {
+ _database = database;
+ }
+
+ public EphemeralRedisDistributedHashSet(IEphemeralRedisDatabase database, IRedisSerializer serializer, string setKey) : base(database, serializer, setKey) { }
+
+ public async ValueTask DisposeAsync() => await _database.DisposeAsync();
+}
+
+file static class Extensions
+{
+ public static Task> HashSet(this RedisMultiplexerFixture fixture) =>
+ new EphemeralRedisDistributedHashSet(
+ fixture.Multiplexer.GetEphemeralDatabase(),
+ Guid.NewGuid().ToString()
+ ).ToTask>();
+}
\ No newline at end of file
diff --git a/tests/Distributed.Collections.Redis.Tests/RedisTestCollection.cs b/tests/Distributed.Collections.Redis.Tests/RedisTestCollection.cs
new file mode 100644
index 0000000..67668ef
--- /dev/null
+++ b/tests/Distributed.Collections.Redis.Tests/RedisTestCollection.cs
@@ -0,0 +1,7 @@
+namespace Distributed.Collections.Redis.Tests;
+
+[CollectionDefinition(Name)]
+public class RedisTestCollection : ICollectionFixture
+{
+ public const string Name = "redis";
+}
\ No newline at end of file
diff --git a/tests/Distributed.Collections.Tests/Distributed.Collections.Tests.csproj b/tests/Distributed.Collections.Tests/Distributed.Collections.Tests.csproj
new file mode 100644
index 0000000..a27c230
--- /dev/null
+++ b/tests/Distributed.Collections.Tests/Distributed.Collections.Tests.csproj
@@ -0,0 +1,29 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Distributed.Collections.Tests/DistributedHashSetTests.cs b/tests/Distributed.Collections.Tests/DistributedHashSetTests.cs
new file mode 100644
index 0000000..c252bd7
--- /dev/null
+++ b/tests/Distributed.Collections.Tests/DistributedHashSetTests.cs
@@ -0,0 +1,229 @@
+using Shouldly;
+
+namespace Distributed.Collections.Tests;
+
+public abstract class StringDistributedHashSetTests
+ : DistributedHashSetTests
+ where THashSet : IDistributedHashSet
+{
+ protected sealed override string[] TestValues => ["one", "five", "negative ten", "infinity", "negative infinity"];
+}
+
+public abstract class IntDistributedHashSetTests
+ : DistributedHashSetTests
+ where THashSet : IDistributedHashSet
+{
+ protected sealed override int[] TestValues => [1, 5, -10, int.MinValue, int.MaxValue];
+}
+
+public abstract class DistributedHashSetTests :
+ IAsyncLifetime
+ where THashSet : IDistributedHashSet
+{
+ private bool _disposed;
+
+ private readonly Lazy> _hashSet;
+
+ protected DistributedHashSetTests() => _hashSet = new(CreateHashSet);
+
+ protected THashSet HashSet => _hashSet.Value.Result;
+
+
+ #region AddAsync Tests
+
+ [Fact]
+ public async Task AddAsync_should_add_item_when_it_does_not_exist()
+ {
+ // Arrange
+
+ // Act
+ var result = await HashSet.AddAsync(TestValues[0]);
+
+ // Assert
+ result.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task AddAsync_should_not_add_item_when_it_already_exists()
+ {
+ // Arrange
+ await HashSet.AddAsync(TestValues[0]);
+
+ // Act
+ var result = await HashSet.AddAsync(TestValues[0]);
+
+ // Assert
+ result.ShouldBeFalse();
+ }
+
+ #endregion
+
+ #region RemoveAsync Tests
+
+ [Fact]
+ public async Task RemoveAsync_should_remove_item_when_it_exists()
+ {
+ // Arrange
+ await HashSet.AddAsync(TestValues[0]);
+
+ // Act
+ var result = await HashSet.RemoveAsync(TestValues[0]);
+
+ // Assert
+ result.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task RemoveAsync_should_not_remove_item_when_it_does_not_exist()
+ {
+ // Arrange
+
+ // Act
+ var result = await HashSet.RemoveAsync(TestValues[0]);
+
+ // Assert
+ result.ShouldBeFalse();
+ }
+
+ #endregion
+
+ #region ContainsAsync Tests
+
+ [Fact]
+ public async Task ContainsAsync_should_return_true_when_item_exists()
+ {
+ // Arrange
+ await HashSet.AddAsync(TestValues[0]);
+
+ // Act
+ var result = await HashSet.ContainsAsync(TestValues[0]);
+
+ // Assert
+ result.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task ContainsAsync_should_return_false_when_item_does_not_exist()
+ {
+ // Arrange
+
+ // Act
+ var result = await HashSet.ContainsAsync(TestValues[0]);
+
+ // Assert
+ result.ShouldBeFalse();
+ }
+
+ #endregion
+
+ #region CountAsync Tests
+
+ [Fact]
+ public async Task CountAsync_should_return_0_when_hash_set_is_empty()
+ {
+ // Arrange
+
+ // Act
+ var result = await HashSet.CountAsync();
+
+ // Assert
+ result.ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task CountAsync_should_return_1_when_hash_set_has_one_item()
+ {
+ // Arrange
+ await HashSet.AddAsync(TestValues[0]);
+
+ // Act
+ var result = await HashSet.CountAsync();
+
+ // Assert
+ result.ShouldBe(1);
+ }
+
+ #endregion
+
+ #region LongCountAsync Tests
+
+ [Fact]
+ public async Task LongCountAsync_should_return_0_when_hash_set_is_empty()
+ {
+ // Arrange
+
+ // Act
+ var result = await HashSet.LongCountAsync();
+
+ // Assert
+ result.ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task LongCountAsync_should_return_1_when_hash_set_has_one_item()
+ {
+ // Arrange
+ await HashSet.AddAsync(TestValues[0]);
+
+ // Act
+ var result = await HashSet.LongCountAsync();
+
+ // Assert
+ result.ShouldBe(1);
+ }
+
+ #endregion
+
+ #region ClearAsync Tests
+
+ [Fact]
+ public async Task ClearAsync_should_remove_all_items()
+ {
+ // Arrange
+ await HashSet.AddAsync(TestValues[0]);
+
+ // Act
+ await HashSet.ClearAsync();
+
+ // Assert
+ var count = await HashSet.CountAsync();
+ count.ShouldBe(0);
+ }
+
+ #endregion
+
+ #region IAsyncEnumerable Tests
+
+ [Fact]
+ public async Task IAsyncEnumerable_should_enumerate_all_items()
+ {
+ // Arrange
+ foreach (var item in TestValues)
+ {
+ await HashSet.AddAsync(item);
+ }
+
+ // Act
+ var items = await HashSet.ToArrayAsync();
+
+ // Assert
+ items.ShouldBe(TestValues, true);
+ }
+
+ #endregion
+
+ protected abstract Task CreateHashSet();
+
+ protected abstract T[] TestValues { get; }
+
+ public async Task InitializeAsync() => await _hashSet.Value;
+
+ public async Task DisposeAsync()
+ {
+ if (_disposed || !_hashSet.IsValueCreated) return;
+
+ _disposed = true;
+
+ await HashSet.TryDisposeAsync();
+ }
+}
\ No newline at end of file
diff --git a/tests/Distributed.Collections.Tests/Extensions.cs b/tests/Distributed.Collections.Tests/Extensions.cs
new file mode 100644
index 0000000..a3f2c95
--- /dev/null
+++ b/tests/Distributed.Collections.Tests/Extensions.cs
@@ -0,0 +1,24 @@
+namespace Distributed.Collections.Tests;
+
+public static class Extensions
+{
+ public static Task ToTask(this T value) => Task.FromResult(value);
+
+ public static async ValueTask TryDisposeAsync(this T self)
+ {
+ if (self is not IAsyncDisposable disposable)
+ return false;
+
+ await disposable.DisposeAsync().ConfigureAwait(false);
+ return true;
+ }
+
+ public static bool TryDispose(this T self)
+ {
+ if (self is not IDisposable disposable)
+ return false;
+
+ disposable.Dispose();
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/tests/Distributed.Collections.Tests/InMemoryDistributedHashSetTests.cs b/tests/Distributed.Collections.Tests/InMemoryDistributedHashSetTests.cs
new file mode 100644
index 0000000..beb20ff
--- /dev/null
+++ b/tests/Distributed.Collections.Tests/InMemoryDistributedHashSetTests.cs
@@ -0,0 +1,15 @@
+namespace Distributed.Collections.Tests;
+
+public class InMemoryStringDistributedHashSetTests :
+ StringDistributedHashSetTests>
+{
+ protected override Task> CreateHashSet() =>
+ new InMemoryDistributedHashSet().ToTask();
+}
+
+public class InMemoryIntDistributedHashSetTests :
+ IntDistributedHashSetTests>
+{
+ protected override Task> CreateHashSet() =>
+ new InMemoryDistributedHashSet().ToTask();
+}
\ No newline at end of file
diff --git a/tests/Distributed.Collections.Tests/TestContainerFixture.cs b/tests/Distributed.Collections.Tests/TestContainerFixture.cs
new file mode 100644
index 0000000..a28dd88
--- /dev/null
+++ b/tests/Distributed.Collections.Tests/TestContainerFixture.cs
@@ -0,0 +1,21 @@
+using DotNet.Testcontainers.Containers;
+
+namespace Distributed.Collections.Tests;
+
+public abstract class TestContainerFixture : IAsyncLifetime
+{
+ private readonly Lazy _container;
+
+ protected IContainer Container => _container.Value;
+
+ protected TestContainerFixture()
+ {
+ _container = new(CreateContainer);
+ }
+
+ public Task InitializeAsync() => _container.Value.StartAsync();
+
+ public Task DisposeAsync() => _container.TryDisposeAsync().AsTask();
+
+ protected abstract IContainer CreateContainer();
+}
\ No newline at end of file