diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..cf80467
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -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"
diff --git a/Directory.Packages.props b/Directory.Packages.props
new file mode 100644
index 0000000..92ff302
--- /dev/null
+++ b/Directory.Packages.props
@@ -0,0 +1,12 @@
+
+
+ true
+
+
+
+
+
+
+
+
+
diff --git a/Dreamine.Threading.csproj b/Dreamine.Threading.csproj
index 3859327..81ab959 100644
--- a/Dreamine.Threading.csproj
+++ b/Dreamine.Threading.csproj
@@ -22,7 +22,12 @@
-
+
+
+
+
+
+
@@ -31,4 +36,4 @@
-
\ No newline at end of file
+
diff --git a/README.md b/README.md
index 404206e..318b0a3 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,11 @@
# Dreamine.Threading
+[](https://github.com/CodeMaru-Dreamine/Dreamine.Threading/actions/workflows/ci.yml) [](https://sonarcloud.io/summary/new_code?id=CodeMaru-Dreamine_Dreamine.Threading) [](https://sonarcloud.io/summary/new_code?id=CodeMaru-Dreamine_Dreamine.Threading) [](https://sonarcloud.io/summary/new_code?id=CodeMaru-Dreamine_Dreamine.Threading)
+
+[](LICENSE)  [](https://www.nuget.org/packages/Dreamine.Threading) [](https://www.nuget.org/packages/Dreamine.Threading)
+
+[](https://dreamine.kr) [](https://dreamine.kr) [](https://dreamine.kr) [](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.
diff --git a/README_KO.md b/README_KO.md
index ac05b0c..23d8a39 100644
--- a/README_KO.md
+++ b/README_KO.md
@@ -1,5 +1,11 @@
# Dreamine.Threading
+[](https://github.com/CodeMaru-Dreamine/Dreamine.Threading/actions/workflows/ci.yml) [](https://sonarcloud.io/summary/new_code?id=CodeMaru-Dreamine_Dreamine.Threading) [](https://sonarcloud.io/summary/new_code?id=CodeMaru-Dreamine_Dreamine.Threading) [](https://sonarcloud.io/summary/new_code?id=CodeMaru-Dreamine_Dreamine.Threading)
+
+[](LICENSE)  [](https://www.nuget.org/packages/Dreamine.Threading) [](https://www.nuget.org/packages/Dreamine.Threading)
+
+[](https://dreamine.kr) [](https://dreamine.kr) [](https://dreamine.kr) [](https://dreamine.kr)
+
**Dreamine.Threading**은 Dreamine 애플리케이션을 위한 핵심 스레딩 추상화 계층입니다.
이 패키지는 Dreamine 기반 애플리케이션에서 사용할 계약, 모델, 스케줄링 정책, Worker Thread 개념, Polling Job 구조, CPU 기반 Cycle Policy, Thread Manager 구조를 정의합니다.
diff --git a/tests/Dreamine.Threading.Tests/Dreamine.Threading.Tests.csproj b/tests/Dreamine.Threading.Tests/Dreamine.Threading.Tests.csproj
new file mode 100644
index 0000000..084588a
--- /dev/null
+++ b/tests/Dreamine.Threading.Tests/Dreamine.Threading.Tests.csproj
@@ -0,0 +1,18 @@
+
+
+ net8.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Dreamine.Threading.Tests/ThreadingTests.cs b/tests/Dreamine.Threading.Tests/ThreadingTests.cs
new file mode 100644
index 0000000..5c1b153
--- /dev/null
+++ b/tests/Dreamine.Threading.Tests/ThreadingTests.cs
@@ -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(
+ () => 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(() => 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);
+}