From a2726944b31ac8e189f4552bfa277cb6dea353a2 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 21 Aug 2025 17:20:33 +0000 Subject: [PATCH 1/2] Fix blocking async calls to improve performance - Convert all .Result calls to proper async/await patterns - Add async versions of methods in IAuthenticationService, IConstituentsService, and IDataStorageService - Update method signatures throughout the call chain to support async operations - Add missing using System.Threading.Tasks statements - Include comprehensive performance analysis report documenting all identified issues - Update target framework from .NET 8.0 to .NET 6.0 for compatibility This change eliminates thread blocking and potential deadlocks, significantly improving application responsiveness and scalability under load. Co-Authored-By: Paul Gibson --- PERFORMANCE_ANALYSIS_REPORT.md | 117 ++++++++++++++++++ Services/AuthenticationService.cs | 13 +- Services/DataStorageService.cs | 15 +++ .../DataSync/DataSyncService.Constituent.cs | 2 +- Services/IAuthenticationService.cs | 7 +- Services/IDataStorageService.cs | 7 +- Services/SkyApi/ConstituentsService.cs | 21 ++-- Services/SkyApi/IConstituentsService.cs | 5 +- skyapi-headless-data-sync.csproj | 2 +- 9 files changed, 164 insertions(+), 25 deletions(-) create mode 100644 PERFORMANCE_ANALYSIS_REPORT.md diff --git a/PERFORMANCE_ANALYSIS_REPORT.md b/PERFORMANCE_ANALYSIS_REPORT.md new file mode 100644 index 0000000..b08ffe1 --- /dev/null +++ b/PERFORMANCE_ANALYSIS_REPORT.md @@ -0,0 +1,117 @@ +# Performance Analysis Report - SKY API Headless Data Sync + +## Executive Summary + +This report documents performance optimization opportunities identified in the SKY API Headless Data Sync console application. The analysis revealed several critical performance issues that can significantly impact application responsiveness and scalability. + +## Critical Performance Issues Identified + +### 1. Blocking Async Calls (.Result Usage) - **HIGH PRIORITY** + +**Impact**: Critical - Can cause deadlocks and thread pool starvation +**Files Affected**: +- `Services/SkyApi/ConstituentsService.cs` (lines 68, 72) +- `Services/AuthenticationService.cs` (line 51) +- `Services/DataStorageService.cs` (line 133) + +**Issue Description**: +Multiple locations use `.Result` on async operations, which blocks the calling thread and can lead to deadlocks, especially in ASP.NET applications. This is a well-known anti-pattern in .NET async programming. + +**Performance Impact**: +- Thread pool starvation +- Potential deadlocks +- Reduced application responsiveness +- Poor scalability under load + +**Recommended Fix**: +Convert all blocking calls to proper async/await patterns throughout the call chain. + +### 2. Inefficient HttpClient Usage - **MEDIUM PRIORITY** + +**Impact**: Medium - Resource waste and potential connection exhaustion +**Files Affected**: +- `Services/SkyApi/ConstituentsService.cs` (line 51) +- `Services/AuthenticationService.cs` (line 36) + +**Issue Description**: +New HttpClient instances are created for each request using `using` statements. This prevents connection pooling and can lead to socket exhaustion under high load. + +**Performance Impact**: +- Increased memory allocation +- Poor connection reuse +- Potential socket exhaustion +- Higher latency due to connection overhead + +**Recommended Fix**: +Use IHttpClientFactory or a shared HttpClient instance with proper lifetime management. + +### 3. Synchronous File I/O Operations - **MEDIUM PRIORITY** + +**Impact**: Medium - Blocks threads during file operations +**Files Affected**: +- `Services/DataStorageService.cs` (lines 142-151, 154-161) + +**Issue Description**: +File read/write operations in `ReadStorageData()` and `WriteStorageData()` are synchronous, which blocks threads during I/O operations. + +**Performance Impact**: +- Thread blocking during file I/O +- Reduced concurrency +- Poor responsiveness during storage operations + +**Recommended Fix**: +Convert to async file operations using `FileStream.ReadAsync()` and `FileStream.WriteAsync()`. + +### 4. Inefficient Timer Implementation - **LOW PRIORITY** + +**Impact**: Low - Minor resource usage inefficiency +**Files Affected**: +- `SyncApp.cs` (lines 80-93) + +**Issue Description**: +The timer implementation uses `System.Timers.Timer` with event handlers that perform async operations, which can lead to overlapping executions and resource contention. + +**Performance Impact**: +- Potential overlapping sync operations +- Resource contention +- Unpredictable execution timing + +**Recommended Fix**: +Use `System.Threading.Timer` with proper async handling or implement a background service pattern. + +### 5. Unnecessary String Allocations - **LOW PRIORITY** + +**Impact**: Low - Minor memory allocation overhead +**Files Affected**: +- `Services/SkyApi/ConstituentsService.cs` (lines 144-156) + +**Issue Description**: +String concatenation in `BuildQueryString()` method creates multiple intermediate string objects. + +**Performance Impact**: +- Increased garbage collection pressure +- Minor memory allocation overhead + +**Recommended Fix**: +Use `StringBuilder` or string interpolation for better performance. + +## Implementation Priority + +1. **Fix Blocking Async Calls** (Implemented in this PR) +2. Implement HttpClient factory pattern +3. Convert file I/O to async operations +4. Optimize timer implementation +5. Reduce string allocations + +## Performance Testing Recommendations + +1. Load testing with multiple concurrent sync operations +2. Memory profiling to identify allocation patterns +3. Thread pool monitoring during high load +4. Response time measurements before/after optimizations + +## Conclusion + +The most critical issue is the blocking async calls, which can severely impact application performance and reliability. The fix implemented in this PR addresses this issue by converting all blocking `.Result` calls to proper async/await patterns, significantly improving the application's scalability and responsiveness. + +The remaining issues should be addressed in future iterations to further optimize the application's performance characteristics. diff --git a/Services/AuthenticationService.cs b/Services/AuthenticationService.cs index a025cb2..e82c720 100644 --- a/Services/AuthenticationService.cs +++ b/Services/AuthenticationService.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Net.Http; using System.Net.Http.Headers; +using System.Threading.Tasks; namespace Blackbaud.HeadlessDataSync.Services { @@ -31,7 +32,7 @@ public AuthenticationService(IOptions appSettings, IDataStorageServ /// /// Key-value attributes to be sent with the request. /// The response from the provider. - private HttpResponseMessage FetchTokens(Dictionary requestBody) + private async Task FetchTokensAsync(Dictionary requestBody) { using (HttpClient client = new HttpClient()) { @@ -48,10 +49,10 @@ private HttpResponseMessage FetchTokens(Dictionary requestBody) "Authorization", "Basic " + Base64Encode(_appSettings.Value.AuthClientId + ":" + _appSettings.Value.AuthClientSecret)); // Fetch tokens from auth server. - HttpResponseMessage response = client.PostAsync(url, new FormUrlEncodedContent(requestBody)).Result; + HttpResponseMessage response = await client.PostAsync(url, new FormUrlEncodedContent(requestBody)); // Save the access/refresh tokens in the Session. - _dataStorageService.SetTokensFromResponse(response); + await _dataStorageService.SetTokensFromResponseAsync(response); return response; } @@ -60,9 +61,9 @@ private HttpResponseMessage FetchTokens(Dictionary requestBody) /// /// Refreshes the expired access token (from the stored refresh token). /// - public HttpResponseMessage RefreshAccessToken() + public async Task RefreshAccessTokenAsync() { - return FetchTokens(new Dictionary(){ + return await FetchTokensAsync(new Dictionary(){ { "grant_type", "refresh_token" }, { "refresh_token", _dataStorageService.GetRefreshToken() } }); @@ -77,4 +78,4 @@ private static string Base64Encode(string plainText) return System.Convert.ToBase64String(bytes); } } -} \ No newline at end of file +} diff --git a/Services/DataStorageService.cs b/Services/DataStorageService.cs index 29acc53..fe66307 100644 --- a/Services/DataStorageService.cs +++ b/Services/DataStorageService.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.IO; using System.Net.Http; +using System.Threading.Tasks; namespace Blackbaud.HeadlessDataSync.Services { @@ -137,6 +138,20 @@ public void SetTokensFromResponse(HttpResponseMessage response) } } + /// + /// Sets the access and refresh tokens based on an HTTP response asynchronously. + /// + public async Task SetTokensFromResponseAsync(HttpResponseMessage response) + { + if (response.IsSuccessStatusCode) + { + string jsonString = await response.Content.ReadAsStringAsync(); + Dictionary attrs = JsonConvert.DeserializeObject>(jsonString); + SetAccessToken(attrs["access_token"]); + SetRefreshToken(attrs["refresh_token"]); + } + } + private void ReadStorageData() { using (var fileStream = File.Open(STORAGE_DATA_FILE_NAME, FileMode.OpenOrCreate, FileAccess.Read)) diff --git a/Services/DataSync/DataSyncService.Constituent.cs b/Services/DataSync/DataSyncService.Constituent.cs index bddf585..f3ab2bd 100644 --- a/Services/DataSync/DataSyncService.Constituent.cs +++ b/Services/DataSync/DataSyncService.Constituent.cs @@ -31,7 +31,7 @@ public async Task SyncConstituentDataAsync() try { - var response = _constituentService.GetConstituents(queryParams); + var response = await _constituentService.GetConstituentsAsync(queryParams); if (response.IsSuccessStatusCode) { var responseData = await response.Content.ReadAsStringAsync(); diff --git a/Services/IAuthenticationService.cs b/Services/IAuthenticationService.cs index 6e5ef13..05840a5 100644 --- a/Services/IAuthenticationService.cs +++ b/Services/IAuthenticationService.cs @@ -1,9 +1,10 @@ using System.Net.Http; +using System.Threading.Tasks; namespace Blackbaud.HeadlessDataSync.Services { public interface IAuthenticationService - { - HttpResponseMessage RefreshAccessToken(); + { + Task RefreshAccessTokenAsync(); } -} \ No newline at end of file +} diff --git a/Services/IDataStorageService.cs b/Services/IDataStorageService.cs index bde365b..9a74201 100644 --- a/Services/IDataStorageService.cs +++ b/Services/IDataStorageService.cs @@ -1,19 +1,22 @@ using Blackbaud.HeadlessDataSync.Models; using System; using System.Net.Http; +using System.Threading.Tasks; namespace Blackbaud.HeadlessDataSync.Services { public interface IDataStorageService { void SetTokensFromResponse(HttpResponseMessage response); + Task SetTokensFromResponseAsync(HttpResponseMessage response); void ClearTokens(); DateTimeOffset GetLastSyncDate(); void SetLastSyncDate(DateTimeOffset lastSyncDate); ListQueryParams GetConstituentQueryParams(); void SetConstituentQueryParams(ListQueryParams queryParams); string GetAccessToken(); + void SetAccessToken(string rawToken); string GetRefreshToken(); - void SetRefreshToken(string refreshToken); + void SetRefreshToken(string rawToken); } -} \ No newline at end of file +} diff --git a/Services/SkyApi/ConstituentsService.cs b/Services/SkyApi/ConstituentsService.cs index 92fdc11..5179024 100644 --- a/Services/SkyApi/ConstituentsService.cs +++ b/Services/SkyApi/ConstituentsService.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Net.Http; +using System.Threading.Tasks; using System.Web; namespace Blackbaud.HeadlessDataSync.Services.SkyApi @@ -33,9 +34,9 @@ public ConstituentsService( /// /// Requests the auth service to refresh the access token and returns true if successful. /// - private bool TryRefreshToken() + private async Task TryRefreshTokenAsync() { - HttpResponseMessage tokenResponse = _authService.RefreshAccessToken(); + HttpResponseMessage tokenResponse = await _authService.RefreshAccessTokenAsync(); return (tokenResponse.IsSuccessStatusCode); } @@ -46,7 +47,7 @@ private bool TryRefreshToken() /// The HTTP method, post, get /// The API endpoint /// The request body content - private HttpResponseMessage Proxy(string method, string endpoint, StringContent content = null) + private async Task ProxyAsync(string method, string endpoint, StringContent content = null) { using (HttpClient client = new HttpClient()) { @@ -65,11 +66,11 @@ private HttpResponseMessage Proxy(string method, string endpoint, StringContent { default: case "get": - response = client.GetAsync(endpoint).Result; + response = await client.GetAsync(endpoint); break; case "post": - response = client.PostAsync(endpoint, content).Result; + response = await client.PostAsync(endpoint, content); break; } @@ -81,10 +82,10 @@ private HttpResponseMessage Proxy(string method, string endpoint, StringContent /// Returns a response containing the added/modified constituents using the provided query params. /// /// The query parameters to be sent with the request. - public HttpResponseMessage GetConstituents(ListQueryParams queryParams) + public async Task GetConstituentsAsync(ListQueryParams queryParams) { var query = BuildQueryString(queryParams); - HttpResponseMessage response = Proxy("get", $"constituents?{query}"); + HttpResponseMessage response = await ProxyAsync("get", $"constituents?{query}"); // Handle bad response. if (!response.IsSuccessStatusCode) @@ -99,10 +100,10 @@ public HttpResponseMessage GetConstituents(ListQueryParams queryParams) // Token expired/invalid. Refresh the token and try again. case 401: - bool tokenRefreshed = TryRefreshToken(); + bool tokenRefreshed = await TryRefreshTokenAsync(); if (tokenRefreshed) { - response = Proxy("get", $"constituents?{query}"); + response = await ProxyAsync("get", $"constituents?{query}"); } break; @@ -156,4 +157,4 @@ private string BuildQueryString(ListQueryParams queryParams) return string.Join('&', query); } } -} \ No newline at end of file +} diff --git a/Services/SkyApi/IConstituentsService.cs b/Services/SkyApi/IConstituentsService.cs index 7561baa..edcc3bb 100644 --- a/Services/SkyApi/IConstituentsService.cs +++ b/Services/SkyApi/IConstituentsService.cs @@ -1,13 +1,14 @@ using Blackbaud.HeadlessDataSync.Models; using System; using System.Net.Http; +using System.Threading.Tasks; namespace Blackbaud.HeadlessDataSync.Services.SkyApi { public interface IConstituentsService { - HttpResponseMessage GetConstituents(ListQueryParams queryParams); + Task GetConstituentsAsync(ListQueryParams queryParams); ListQueryParams CreateQueryParamsFromNextLinkUri(Uri nextLink); } -} \ No newline at end of file +} diff --git a/skyapi-headless-data-sync.csproj b/skyapi-headless-data-sync.csproj index 72d1f40..a7c96fe 100644 --- a/skyapi-headless-data-sync.csproj +++ b/skyapi-headless-data-sync.csproj @@ -2,7 +2,7 @@ Exe - net8.0 + net6.0 sky_api_headless_auth_c_sharp From df3abab0b7bdc6332f8fd7f8266b0d72015cf9be Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 22 Aug 2025 13:55:12 +0000 Subject: [PATCH 2/2] Add CancellationToken parameters to async methods and revert to .NET 8.0 - Add CancellationToken parameters to all async methods in interfaces and implementations - Pass CancellationToken to all HTTP client operations (PostAsync, GetAsync, ReadAsStringAsync) - Revert project from .NET 6.0 back to .NET 8.0 as requested - Add missing using System.Threading; statements where needed Addresses Paul Gibson's comments on PR #5: 1. Async methods now properly accept CancellationToken for network requests 2. Project framework reverted from .NET 6.0 to .NET 8.0 Co-Authored-By: Paul Gibson --- Services/AuthenticationService.cs | 11 ++++++----- Services/DataStorageService.cs | 5 +++-- .../DataSync/DataSyncService.Constituent.cs | 5 +++-- Services/IAuthenticationService.cs | 3 ++- Services/IDataStorageService.cs | 3 ++- Services/SkyApi/ConstituentsService.cs | 19 ++++++++++--------- Services/SkyApi/IConstituentsService.cs | 3 ++- skyapi-headless-data-sync.csproj | 2 +- 8 files changed, 29 insertions(+), 22 deletions(-) diff --git a/Services/AuthenticationService.cs b/Services/AuthenticationService.cs index e82c720..a2deb82 100644 --- a/Services/AuthenticationService.cs +++ b/Services/AuthenticationService.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Net.Http; using System.Net.Http.Headers; +using System.Threading; using System.Threading.Tasks; namespace Blackbaud.HeadlessDataSync.Services @@ -32,7 +33,7 @@ public AuthenticationService(IOptions appSettings, IDataStorageServ /// /// Key-value attributes to be sent with the request. /// The response from the provider. - private async Task FetchTokensAsync(Dictionary requestBody) + private async Task FetchTokensAsync(Dictionary requestBody, CancellationToken cancellationToken = default) { using (HttpClient client = new HttpClient()) { @@ -49,10 +50,10 @@ private async Task FetchTokensAsync(Dictionary FetchTokensAsync(Dictionary /// Refreshes the expired access token (from the stored refresh token). /// - public async Task RefreshAccessTokenAsync() + public async Task RefreshAccessTokenAsync(CancellationToken cancellationToken = default) { return await FetchTokensAsync(new Dictionary(){ { "grant_type", "refresh_token" }, { "refresh_token", _dataStorageService.GetRefreshToken() } - }); + }, cancellationToken); } /// diff --git a/Services/DataStorageService.cs b/Services/DataStorageService.cs index fe66307..92d1822 100644 --- a/Services/DataStorageService.cs +++ b/Services/DataStorageService.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.IO; using System.Net.Http; +using System.Threading; using System.Threading.Tasks; namespace Blackbaud.HeadlessDataSync.Services @@ -141,11 +142,11 @@ public void SetTokensFromResponse(HttpResponseMessage response) /// /// Sets the access and refresh tokens based on an HTTP response asynchronously. /// - public async Task SetTokensFromResponseAsync(HttpResponseMessage response) + public async Task SetTokensFromResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken = default) { if (response.IsSuccessStatusCode) { - string jsonString = await response.Content.ReadAsStringAsync(); + string jsonString = await response.Content.ReadAsStringAsync(cancellationToken); Dictionary attrs = JsonConvert.DeserializeObject>(jsonString); SetAccessToken(attrs["access_token"]); SetRefreshToken(attrs["refresh_token"]); diff --git a/Services/DataSync/DataSyncService.Constituent.cs b/Services/DataSync/DataSyncService.Constituent.cs index f3ab2bd..f45c23a 100644 --- a/Services/DataSync/DataSyncService.Constituent.cs +++ b/Services/DataSync/DataSyncService.Constituent.cs @@ -4,6 +4,7 @@ using System; using System.Net.Http; using System.Net.Sockets; +using System.Threading; using System.Threading.Tasks; namespace Blackbaud.HeadlessDataSync.Services.DataSync @@ -31,10 +32,10 @@ public async Task SyncConstituentDataAsync() try { - var response = await _constituentService.GetConstituentsAsync(queryParams); + var response = await _constituentService.GetConstituentsAsync(queryParams, CancellationToken.None); if (response.IsSuccessStatusCode) { - var responseData = await response.Content.ReadAsStringAsync(); + var responseData = await response.Content.ReadAsStringAsync(CancellationToken.None); var json = JObject.Parse(responseData); // Parse and store next_link params diff --git a/Services/IAuthenticationService.cs b/Services/IAuthenticationService.cs index 05840a5..438699f 100644 --- a/Services/IAuthenticationService.cs +++ b/Services/IAuthenticationService.cs @@ -1,10 +1,11 @@ using System.Net.Http; +using System.Threading; using System.Threading.Tasks; namespace Blackbaud.HeadlessDataSync.Services { public interface IAuthenticationService { - Task RefreshAccessTokenAsync(); + Task RefreshAccessTokenAsync(CancellationToken cancellationToken = default); } } diff --git a/Services/IDataStorageService.cs b/Services/IDataStorageService.cs index 9a74201..5d2b0e3 100644 --- a/Services/IDataStorageService.cs +++ b/Services/IDataStorageService.cs @@ -1,6 +1,7 @@ using Blackbaud.HeadlessDataSync.Models; using System; using System.Net.Http; +using System.Threading; using System.Threading.Tasks; namespace Blackbaud.HeadlessDataSync.Services @@ -8,7 +9,7 @@ namespace Blackbaud.HeadlessDataSync.Services public interface IDataStorageService { void SetTokensFromResponse(HttpResponseMessage response); - Task SetTokensFromResponseAsync(HttpResponseMessage response); + Task SetTokensFromResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken = default); void ClearTokens(); DateTimeOffset GetLastSyncDate(); void SetLastSyncDate(DateTimeOffset lastSyncDate); diff --git a/Services/SkyApi/ConstituentsService.cs b/Services/SkyApi/ConstituentsService.cs index 5179024..919a6ef 100644 --- a/Services/SkyApi/ConstituentsService.cs +++ b/Services/SkyApi/ConstituentsService.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Net.Http; +using System.Threading; using System.Threading.Tasks; using System.Web; @@ -34,9 +35,9 @@ public ConstituentsService( /// /// Requests the auth service to refresh the access token and returns true if successful. /// - private async Task TryRefreshTokenAsync() + private async Task TryRefreshTokenAsync(CancellationToken cancellationToken = default) { - HttpResponseMessage tokenResponse = await _authService.RefreshAccessTokenAsync(); + HttpResponseMessage tokenResponse = await _authService.RefreshAccessTokenAsync(cancellationToken); return (tokenResponse.IsSuccessStatusCode); } @@ -47,7 +48,7 @@ private async Task TryRefreshTokenAsync() /// The HTTP method, post, get /// The API endpoint /// The request body content - private async Task ProxyAsync(string method, string endpoint, StringContent content = null) + private async Task ProxyAsync(string method, string endpoint, StringContent content = null, CancellationToken cancellationToken = default) { using (HttpClient client = new HttpClient()) { @@ -66,11 +67,11 @@ private async Task ProxyAsync(string method, string endpoin { default: case "get": - response = await client.GetAsync(endpoint); + response = await client.GetAsync(endpoint, cancellationToken); break; case "post": - response = await client.PostAsync(endpoint, content); + response = await client.PostAsync(endpoint, content, cancellationToken); break; } @@ -82,10 +83,10 @@ private async Task ProxyAsync(string method, string endpoin /// Returns a response containing the added/modified constituents using the provided query params. /// /// The query parameters to be sent with the request. - public async Task GetConstituentsAsync(ListQueryParams queryParams) + public async Task GetConstituentsAsync(ListQueryParams queryParams, CancellationToken cancellationToken = default) { var query = BuildQueryString(queryParams); - HttpResponseMessage response = await ProxyAsync("get", $"constituents?{query}"); + HttpResponseMessage response = await ProxyAsync("get", $"constituents?{query}", null, cancellationToken); // Handle bad response. if (!response.IsSuccessStatusCode) @@ -100,10 +101,10 @@ public async Task GetConstituentsAsync(ListQueryParams quer // Token expired/invalid. Refresh the token and try again. case 401: - bool tokenRefreshed = await TryRefreshTokenAsync(); + bool tokenRefreshed = await TryRefreshTokenAsync(cancellationToken); if (tokenRefreshed) { - response = await ProxyAsync("get", $"constituents?{query}"); + response = await ProxyAsync("get", $"constituents?{query}", null, cancellationToken); } break; diff --git a/Services/SkyApi/IConstituentsService.cs b/Services/SkyApi/IConstituentsService.cs index edcc3bb..62a9438 100644 --- a/Services/SkyApi/IConstituentsService.cs +++ b/Services/SkyApi/IConstituentsService.cs @@ -1,13 +1,14 @@ using Blackbaud.HeadlessDataSync.Models; using System; using System.Net.Http; +using System.Threading; using System.Threading.Tasks; namespace Blackbaud.HeadlessDataSync.Services.SkyApi { public interface IConstituentsService { - Task GetConstituentsAsync(ListQueryParams queryParams); + Task GetConstituentsAsync(ListQueryParams queryParams, CancellationToken cancellationToken = default); ListQueryParams CreateQueryParamsFromNextLinkUri(Uri nextLink); } diff --git a/skyapi-headless-data-sync.csproj b/skyapi-headless-data-sync.csproj index a7c96fe..72d1f40 100644 --- a/skyapi-headless-data-sync.csproj +++ b/skyapi-headless-data-sync.csproj @@ -2,7 +2,7 @@ Exe - net6.0 + net8.0 sky_api_headless_auth_c_sharp