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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:

permissions:
contents: read

env:
SONAR_PROJECT_KEY: CodeMaru-Dreamine_Dreamine.Threading
SONAR_ORGANIZATION: codemaru-dreamine
SONAR_PROJECT_VERSION: 1.0.1
SONAR_SCANNER_VERSION: 11.2.1
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

jobs:
build-test-analyze:
runs-on: windows-2025
timeout-minutes: 15
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
fetch-depth: 0
- uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5
with:
dotnet-version: 8.0.x
- name: Install analysis tools
if: env.SONAR_TOKEN != ''
run: |
dotnet tool install --global dotnet-sonarscanner --version $env:SONAR_SCANNER_VERSION
dotnet tool install --global dotnet-coverage
- name: Align SonarCloud project
if: env.SONAR_TOKEN != ''
shell: pwsh
run: |
$headers = @{ Authorization = "Bearer $env:SONAR_TOKEN" }
$basic = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("$($env:SONAR_TOKEN):"))
try {
Invoke-RestMethod -Method Post -Uri 'https://sonarcloud.io/api/autoscan/activation' -Headers @{ Authorization = "Basic $basic" } -ContentType 'application/x-www-form-urlencoded' -Body @{ projectKey = $env:SONAR_PROJECT_KEY; enable = 'false' } | Out-Null
Invoke-RestMethod -Method Post -Uri 'https://sonarcloud.io/api/new_code_periods/set' -Headers $headers -ContentType 'application/x-www-form-urlencoded' -Body @{ project = $env:SONAR_PROJECT_KEY; type = 'NUMBER_OF_DAYS'; value = '30' } | Out-Null
} catch { Write-Warning $_ }
- name: Restore
run: dotnet restore tests/Dreamine.Threading.Tests/Dreamine.Threading.Tests.csproj
- name: Begin Sonar analysis
if: env.SONAR_TOKEN != ''
run: >
dotnet sonarscanner begin
/k:"$env:SONAR_PROJECT_KEY"
/o:"$env:SONAR_ORGANIZATION"
/v:"$env:SONAR_PROJECT_VERSION"
/d:sonar.token="$env:SONAR_TOKEN"
/d:sonar.cs.vscoveragexml.reportsPaths="$env:RUNNER_TEMP\TestResults\coverage.xml"
/d:sonar.cs.vstest.reportsPaths="$env:RUNNER_TEMP\TestResults\**\*.trx"
/d:sonar.exclusions="**/bin/**,**/obj/**,**/TestResults/**"
/d:sonar.coverage.exclusions="**/tests/**"
/d:sonar.cpd.exclusions="**/tests/**"
- name: Build and test
shell: pwsh
run: |
$project = 'tests/Dreamine.Threading.Tests/Dreamine.Threading.Tests.csproj'
if ($env:SONAR_TOKEN -ne '') {
dotnet-coverage collect "dotnet test $project --no-restore --configuration Release --results-directory $env:RUNNER_TEMP\TestResults --logger `"trx;LogFileName=tests.trx`"" -f xml -o "$env:RUNNER_TEMP\TestResults\coverage.xml"
} else {
dotnet test $project --no-restore --configuration Release
}
- name: Pack
run: dotnet pack Dreamine.Threading.csproj --no-restore --configuration Release --output "${{ runner.temp }}\packages"
- name: End Sonar analysis
if: success() && env.SONAR_TOKEN != ''
run: dotnet sonarscanner end /d:sonar.token="$env:SONAR_TOKEN"
12 changes: 12 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Dreamine.Logging" Version="1.0.2" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.1" />
</ItemGroup>
</Project>
9 changes: 7 additions & 2 deletions Dreamine.Threading.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\Logging\Dreamine.Logging.csproj" />
<PackageReference Include="Dreamine.Logging" />
</ItemGroup>

<ItemGroup>
<Compile Remove="tests\**\*.cs" />
<None Remove="tests\**\*" />
</ItemGroup>

<ItemGroup>
Expand All @@ -31,4 +36,4 @@
<None Update="icon.png" Pack="true" PackagePath="\" />
</ItemGroup>

</Project>
</Project>
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Dreamine.Threading

[![CI](https://github.com/CodeMaru-Dreamine/Dreamine.Threading/actions/workflows/ci.yml/badge.svg)](https://github.com/CodeMaru-Dreamine/Dreamine.Threading/actions/workflows/ci.yml) [![Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=CodeMaru-Dreamine_Dreamine.Threading&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=CodeMaru-Dreamine_Dreamine.Threading) [![Security](https://sonarcloud.io/api/project_badges/measure?project=CodeMaru-Dreamine_Dreamine.Threading&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=CodeMaru-Dreamine_Dreamine.Threading) [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=CodeMaru-Dreamine_Dreamine.Threading&metric=coverage)](https://sonarcloud.io/summary/new_code?id=CodeMaru-Dreamine_Dreamine.Threading)

[![License](https://img.shields.io/github/license/CodeMaru-Dreamine/Dreamine.Threading)](LICENSE) ![.NET](https://img.shields.io/badge/.NET-8-512BD4) [![NuGet](https://img.shields.io/nuget/v/Dreamine.Threading)](https://www.nuget.org/packages/Dreamine.Threading) [![Downloads](https://img.shields.io/nuget/dt/Dreamine.Threading)](https://www.nuget.org/packages/Dreamine.Threading)

[![Docs](https://img.shields.io/badge/Docs-dreamine.kr-2496ED)](https://dreamine.kr) [![Guide](https://img.shields.io/badge/Guide-dreamine.kr-2496ED)](https://dreamine.kr) [![Playground](https://img.shields.io/badge/Playground-dreamine.kr-6F42C1)](https://dreamine.kr) [![Book](https://img.shields.io/badge/Book-Practical_MVVM_Architecture-111111)](https://dreamine.kr)

**Dreamine.Threading** provides the core threading abstraction layer for Dreamine applications.

It defines contracts, models, scheduling policies, worker-thread concepts, polling-job structure, CPU-aware cycle policies, and thread manager infrastructure used by Dreamine-based applications.
Expand Down
6 changes: 6 additions & 0 deletions README_KO.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Dreamine.Threading

[![CI](https://github.com/CodeMaru-Dreamine/Dreamine.Threading/actions/workflows/ci.yml/badge.svg)](https://github.com/CodeMaru-Dreamine/Dreamine.Threading/actions/workflows/ci.yml) [![품질 게이트](https://sonarcloud.io/api/project_badges/measure?project=CodeMaru-Dreamine_Dreamine.Threading&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=CodeMaru-Dreamine_Dreamine.Threading) [![보안](https://sonarcloud.io/api/project_badges/measure?project=CodeMaru-Dreamine_Dreamine.Threading&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=CodeMaru-Dreamine_Dreamine.Threading) [![커버리지](https://sonarcloud.io/api/project_badges/measure?project=CodeMaru-Dreamine_Dreamine.Threading&metric=coverage)](https://sonarcloud.io/summary/new_code?id=CodeMaru-Dreamine_Dreamine.Threading)

[![라이선스](https://img.shields.io/github/license/CodeMaru-Dreamine/Dreamine.Threading)](LICENSE) ![.NET](https://img.shields.io/badge/.NET-8-512BD4) [![NuGet](https://img.shields.io/nuget/v/Dreamine.Threading)](https://www.nuget.org/packages/Dreamine.Threading) [![다운로드](https://img.shields.io/nuget/dt/Dreamine.Threading)](https://www.nuget.org/packages/Dreamine.Threading)

[![문서](https://img.shields.io/badge/문서-dreamine.kr-2496ED)](https://dreamine.kr) [![가이드](https://img.shields.io/badge/가이드-dreamine.kr-2496ED)](https://dreamine.kr) [![실습장](https://img.shields.io/badge/실습장-dreamine.kr-6F42C1)](https://dreamine.kr) [![도서](https://img.shields.io/badge/도서-실전_MVVM_아키텍처-111111)](https://dreamine.kr)

**Dreamine.Threading**은 Dreamine 애플리케이션을 위한 핵심 스레딩 추상화 계층입니다.

이 패키지는 Dreamine 기반 애플리케이션에서 사용할 계약, 모델, 스케줄링 정책, Worker Thread 개념, Polling Job 구조, CPU 기반 Cycle Policy, Thread Manager 구조를 정의합니다.
Expand Down
18 changes: 18 additions & 0 deletions tests/Dreamine.Threading.Tests/Dreamine.Threading.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" PrivateAssets="all" />
<PackageReference Include="coverlet.collector" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Dreamine.Threading.csproj" />
</ItemGroup>
</Project>
166 changes: 166 additions & 0 deletions tests/Dreamine.Threading.Tests/ThreadingTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
using Dreamine.Threading.Allocators;
using Dreamine.Threading.Interfaces;
using Dreamine.Threading.Models;
using Dreamine.Threading.Policies;
using Dreamine.Threading.Services;
using Xunit;

namespace Dreamine.Threading.Tests;

public sealed class ThreadingTests
{
[Fact]
public void ThreadOptions_NormalizeInvalidValues()
{
var source = new DreamineThreadOptions
{
Name = " ",
IntervalMs = -1,
AutoThreadsPerCore = 0,
OverflowPollingIntervalMs = -1,
StopTimeout = TimeSpan.Zero,
AutoStart = false
};

var result = source.Normalize();

Assert.Equal("DreamineThread", result.Name);
Assert.Equal(10, result.IntervalMs);
Assert.Equal(2, result.AutoThreadsPerCore);
Assert.Equal(100, result.OverflowPollingIntervalMs);
Assert.Equal(TimeSpan.FromSeconds(2), result.StopTimeout);
Assert.False(result.AutoStart);
}

[Fact]
public void JobOptions_NormalizeAndPreserveFlags()
{
var result = new DreamineThreadJobOptions
{
Name = "",
IntervalMs = -4,
IsEnabled = false,
IsOverflowPolling = true
}.Normalize();

Assert.Equal("DreamineThreadJob", result.Name);
Assert.Equal(10, result.IntervalMs);
Assert.False(result.IsEnabled);
Assert.True(result.IsOverflowPolling);
}

[Fact]
public async Task Job_TracksSuccessFailureAndSchedule()
{
var calls = 0;
var job = new DreamineThreadJob(
new DreamineThreadJobOptions { Name = "poll", IntervalMs = 100 },
_ => { calls++; return ValueTask.CompletedTask; });

Assert.True(job.ShouldRun(DateTimeOffset.UtcNow));
await job.ExecuteAsync(default);
Assert.Equal(1, calls);
Assert.Equal(1, job.ExecuteCount);
Assert.NotNull(job.LastExecutedAt);
Assert.False(job.ShouldRun(job.LastExecutedAt!.Value.AddMilliseconds(99)));
Assert.True(job.ShouldRun(job.LastExecutedAt.Value.AddMilliseconds(100)));

var failure = new InvalidOperationException("boom");
var failing = new DreamineThreadJob(new(), _ => ValueTask.FromException(failure));
Assert.Same(failure, await Assert.ThrowsAsync<InvalidOperationException>(
() => failing.ExecuteAsync(default).AsTask()));
Assert.Same(failure, failing.LastException);
Assert.Equal(0, failing.ExecuteCount);
}

[Fact]
public void Job_DisabledNeverRunsAndZeroIntervalAlwaysRuns()
{
var disabled = new DreamineThreadJob(
new DreamineThreadJobOptions { IsEnabled = false },
_ => ValueTask.CompletedTask);
var immediate = new DreamineThreadJob(
new DreamineThreadJobOptions { IntervalMs = 0 },
_ => ValueTask.CompletedTask);

Assert.False(disabled.ShouldRun(DateTimeOffset.UtcNow));
Assert.True(immediate.ShouldRun(DateTimeOffset.UtcNow));
}

[Theory]
[InlineData(20, 0)]
[InlineData(30, 1)]
[InlineData(50, 3)]
[InlineData(70, 5)]
public void AdaptivePolicy_UsesCpuThresholds(double cpu, int expected)
{
var policy = new AdaptiveCpuCyclePolicy(new CpuUsage(cpu));
var delay = policy.GetDelayMs(
new DreamineThreadOptions { IntervalMs = 0 },
DreamineThreadCoreAssignment.None(),
Context());

Assert.Equal(expected, delay);
}

[Fact]
public void CyclePolicies_HandleFixedAndOverflowIntervals()
{
var options = new DreamineThreadOptions { IntervalMs = 25, OverflowPollingIntervalMs = 77 };
var normal = DreamineThreadCoreAssignment.None();
var overflow = DreamineThreadCoreAssignment.Overflow();
var context = Context();

Assert.Equal(25, new FixedIntervalCyclePolicy().GetDelayMs(options, normal, context));
Assert.Equal(77, new FixedIntervalCyclePolicy().GetDelayMs(options, overflow, context));
Assert.Equal(77, new OverflowPollingPolicy().GetDelayMs(options, normal, context));
}

[Fact]
public void Allocator_AssignsLeastUsedCoreAndOverflowsAtCapacity()
{
var allocator = new AutoCoreAllocator(2);
var options = new DreamineThreadOptions
{
CoreMode = DreamineThreadCoreMode.Auto,
AutoThreadsPerCore = 1
};

var first = allocator.Allocate(options);
var second = allocator.Allocate(options);
var overflow = allocator.Allocate(options);

Assert.Equal(0, first.CoreIndex);
Assert.Equal(1, second.CoreIndex);
Assert.True(overflow.IsOverflowPolling);

allocator.Release(first);
Assert.Equal(0, allocator.Allocate(options).CoreIndex);
}

[Fact]
public void Allocator_HandlesNoneManualAndInvalidManualCore()
{
var allocator = new AutoCoreAllocator(4);

Assert.Null(allocator.Allocate(new() { CoreMode = DreamineThreadCoreMode.None }).CoreIndex);
Assert.Equal(2, allocator.Allocate(new()
{
CoreMode = DreamineThreadCoreMode.Manual,
CoreIndex = 2
}).CoreIndex);
Assert.Throws<ArgumentOutOfRangeException>(() => allocator.Allocate(new()
{
CoreMode = DreamineThreadCoreMode.Manual,
CoreIndex = 9
}));
}

private sealed class CpuUsage(double value) : ICpuUsageProvider
{
public double GetTotalCpuUsagePercent() => value;
}

private static DreamineThreadCycleContext Context() =>
new("worker", 1, 1, null, false, DateTimeOffset.UtcNow);
}
Loading