Skip to content

Commit b5be132

Browse files
Merge main: update _Factory -> Factory to match refactored IntegrationTestBase
2 parents b910957 + 391cea6 commit b5be132

40 files changed

Lines changed: 1921 additions & 405 deletions

.github/workflows/Build-Test-And-Deploy.yml

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,12 +149,13 @@ jobs:
149149
needs: [deploy-development]
150150
concurrency:
151151
group: deploy-production
152-
cancel-in-progress: false
152+
cancel-in-progress: true
153153
environment:
154154
name: "Production"
155155
permissions:
156156
id-token: write
157157
contents: write # needed for git deploy tag
158+
actions: write
158159

159160
steps:
160161
- uses: actions/checkout@v6
@@ -230,6 +231,63 @@ jobs:
230231
# --retry-all-errors ensures HTTP 5xx (cold-start 503s) also trigger retries
231232
curl --fail --retry 10 --retry-delay 15 --retry-all-errors --max-time 30 "https://$FQDN/health"
232233
234+
- name: Cancel pending deployment workflow runs
235+
uses: actions/github-script@v9
236+
with:
237+
script: |
238+
// Cancel pending workflow runs queued from earlier commits.
239+
// Uses context.workflow instead of hardcoded filename for resilience.
240+
let totalCancelledCount = 0;
241+
let page = 1;
242+
243+
try {
244+
while (true) {
245+
const { data } = await github.rest.actions.listWorkflowRuns({
246+
owner: context.repo.owner,
247+
repo: context.repo.repo,
248+
workflow_id: context.workflow,
249+
status: 'queued',
250+
per_page: 100,
251+
page: page
252+
});
253+
const runs = data.workflow_runs;
254+
255+
if (runs.length === 0) {
256+
break;
257+
}
258+
259+
for (const run of runs) {
260+
if (run.id === context.runId) {
261+
core.debug(`Skipping current run #${run.id}`);
262+
continue;
263+
}
264+
265+
try {
266+
core.info(`Cancelling queued run #${run.id} (commit: ${run.head_sha.substring(0, 7)}, branch: ${run.head_branch}, created: ${run.created_at})`);
267+
await github.rest.actions.cancelWorkflowRun({
268+
owner: context.repo.owner,
269+
repo: context.repo.repo,
270+
run_id: run.id
271+
});
272+
totalCancelledCount++;
273+
} catch (error) {
274+
core.warning(`Failed to cancel run #${run.id}: ${error.message}`);
275+
}
276+
}
277+
278+
// Last page has fewer results than requested (pagination)
279+
if (runs.length < 100) {
280+
break;
281+
}
282+
page++;
283+
}
284+
} catch (error) {
285+
core.warning(`Failed to list workflow runs: ${error.message}`);
286+
core.info('Continuing with deployment despite cancellation script error');
287+
}
288+
289+
core.info(`Cancelled ${totalCancelledCount} pending workflow run(s)`);
290+
233291
- name: Tag commit as deployed
234292
run: |
235293
git config user.email "github-actions[bot]@users.noreply.github.com"
@@ -246,3 +304,4 @@ jobs:
246304
az logout
247305
az cache purge
248306
az account clear
307+

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ TestResults/
3232
!Michaelis_TableOfContents.docx
3333

3434
# Old or generated files to not commit
35+
build_output.txt
3536
wwwroot/sitemap.xml
3637
wwwroot/Chapters
3738
EssentialCSharp.Web/wwwroot/Chapters

Directory.Packages.props

Lines changed: 18 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,12 @@
22
<PropertyGroup>
33
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
44
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
5-
<ToolingPackagesVersion>1.1.1.19071</ToolingPackagesVersion>
5+
<ToolingPackagesVersion>1.1.1.19094</ToolingPackagesVersion>
66
<AccessToNugetFeed Condition="'$(AccessToNugetFeed)' == ''">false</AccessToNugetFeed>
7-
<!-- Disable NuGet vulnerability audit when the private feed is unavailable (e.g. CI without credentials).
8-
NuGet audit queries all sources in nuget.config regardless of RestoreSources, causing NU1900 which is
9-
escalated to an error by TreatWarningsAsErrors. -->
10-
<NuGetAudit Condition="'$(AccessToNugetFeed)' != 'true'">false</NuGetAudit>
11-
</PropertyGroup>
12-
<PropertyGroup>
13-
<SemanticKernelVersion>1.72.0</SemanticKernelVersion>
7+
<!-- Enable NuGet vulnerability audit on public nuget.org feed. Restrict to nuget.org's service index
8+
to avoid NU1900 errors when the private Azure DevOps feed is unavailable (e.g. CI without credentials). -->
9+
<NuGetAudit>true</NuGetAudit>
10+
<NuGetAuditSources Condition="'$(AccessToNugetFeed)' != 'true'">https://api.nuget.org/v3/index.json</NuGetAuditSources>
1411
</PropertyGroup>
1512
<ItemGroup Condition="$(AccessToNugetFeed)">
1613
<PackageVersion Include="ContentFeedNuget" Version="$(ToolingPackagesVersion)" />
@@ -23,8 +20,9 @@
2320
<PackageVersion Include="Azure.Identity" Version="1.21.0" />
2421
<PackageVersion Include="Azure.Monitor.OpenTelemetry.AspNetCore" Version="1.5.0" />
2522
<!-- TODO: update to stable release when Azure.Monitor.OpenTelemetry.Profiler reaches GA -->
26-
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Profiler" Version="1.0.0-beta9" />
27-
<PackageVersion Include="TUnit" Version="1.40.5" />
23+
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Profiler" Version="1.0.1-beta.1" />
24+
<PackageVersion Include="TUnit" Version="1.45.0" />
25+
<PackageVersion Include="TUnit.AspNetCore" Version="1.45.0" />
2826
<PackageVersion Include="EssentialCSharp.Shared.Models" Version="$(ToolingPackagesVersion)" />
2927
<PackageVersion Include="HtmlAgilityPack" Version="1.12.4" />
3028
<PackageVersion Include="IntelliTect.Multitool" Version="2.1.0" />
@@ -39,22 +37,22 @@
3937
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
4038
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.8" />
4139
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.8" />
42-
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.5.0" />
43-
<PackageVersion Include="Microsoft.SemanticKernel" Version="$(SemanticKernelVersion)" />
44-
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.PgVector" Version="$(SemanticKernelVersion)-preview" />
45-
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="10.0.203" />
40+
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.6.0" />
41+
<PackageVersion Include="Microsoft.SemanticKernel" Version="1.76.0" />
42+
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.PgVector" Version="1.74.0-preview" />
43+
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="10.0.300" />
4644
<!-- Pin to patched versions; NuGet.Protocol 6.12.5+ fixes GHSA-g4vj-cjjj-v7hg (pulled in by Microsoft.VisualStudio.Web.CodeGeneration.Design) -->
47-
<PackageVersion Include="NuGet.Packaging" Version="7.3.1" />
48-
<PackageVersion Include="NuGet.Protocol" Version="7.3.1" />
45+
<PackageVersion Include="NuGet.Packaging" Version="7.6.0" />
46+
<PackageVersion Include="NuGet.Protocol" Version="7.6.0" />
4947
<PackageVersion Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
5048
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" />
51-
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.0.1-preview.1.25571.5" />
52-
<PackageVersion Include="ModelContextProtocol" Version="1.2.0" />
53-
<PackageVersion Include="ModelContextProtocol.AspNetCore" Version="1.2.0" />
49+
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.6.0" />
50+
<PackageVersion Include="ModelContextProtocol" Version="1.3.0" />
51+
<PackageVersion Include="ModelContextProtocol.AspNetCore" Version="1.3.0" />
5452
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
5553
<PackageVersion Include="Moq" Version="4.20.72" />
5654
<PackageVersion Include="Moq.AutoMock" Version="4.0.2" />
57-
<PackageVersion Include="System.CommandLine" Version="2.0.7" />
55+
<PackageVersion Include="System.CommandLine" Version="2.0.8" />
5856
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
5957
<PackageVersion Include="Octokit" Version="14.0.0" />
6058
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />

EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
using Azure.AI.OpenAI;
22
using Azure.Core;
33
using Azure.Identity;
4+
using EssentialCSharp.Chat.Common.Models;
45
using EssentialCSharp.Chat.Common.Services;
56
using Microsoft.Extensions.AI;
67
using Microsoft.Extensions.Configuration;
78
using Microsoft.Extensions.DependencyInjection;
9+
using Microsoft.Extensions.Options;
810
using Microsoft.SemanticKernel;
911
using Npgsql;
1012

@@ -65,6 +67,15 @@ public static IServiceCollection AddAzureOpenAIServices(
6567
.UseOpenTelemetry();
6668
#pragma warning restore SKEXP0010
6769

70+
// Ensure options are available even when caller provides AIOptions directly.
71+
services.AddOptions<EmbeddingRetryOptions>()
72+
.ValidateDataAnnotations()
73+
.Validate(options =>
74+
{
75+
options.Validate();
76+
return true;
77+
}, "Embedding retry configuration is invalid.");
78+
6879
// Register shared AI services
6980
services.AddSingleton<EmbeddingService>();
7081
services.AddSingleton<AISearchService>();
@@ -89,6 +100,17 @@ public static IServiceCollection AddAzureOpenAIServices(
89100
// Configure AI options from configuration
90101
services.Configure<AIOptions>(configuration.GetSection("AIOptions"));
91102

103+
// Configure retry options from configuration section.
104+
// Environment variables can override via AIOptions__EmbeddingRetry__*.
105+
services.AddOptions<EmbeddingRetryOptions>()
106+
.Bind(configuration.GetSection(EmbeddingRetryOptions.SectionPath))
107+
.ValidateDataAnnotations()
108+
.Validate(options =>
109+
{
110+
options.Validate();
111+
return true;
112+
}, "Embedding retry configuration is invalid.");
113+
92114
var aiOptions = configuration.GetSection("AIOptions").Get<AIOptions>();
93115
if (aiOptions == null)
94116
{
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
using System.ComponentModel.DataAnnotations;
2+
3+
namespace EssentialCSharp.Chat.Common.Models;
4+
5+
/// <summary>
6+
/// Configuration options for retry logic when calling external services like Azure OpenAI.
7+
/// </summary>
8+
public sealed class EmbeddingRetryOptions
9+
{
10+
/// <summary>
11+
/// Configuration section path in appsettings.json.
12+
/// </summary>
13+
public const string SectionPath = "AIOptions:EmbeddingRetry";
14+
15+
/// <summary>
16+
/// Maximum number of retries for transient failures.
17+
/// Default is 5 retries (initial attempt + 5 retries = 6 total attempts).
18+
/// </summary>
19+
[Range(0, 20)]
20+
public int MaxRetries { get; set; } = 5;
21+
22+
/// <summary>
23+
/// Base delay in milliseconds before the first retry.
24+
/// Subsequent retries use exponential backoff: baseDelay * (backoffMultiplier ^ attemptNumber).
25+
/// Default is 1000ms (1 second).
26+
/// </summary>
27+
[Range(0, 600000)]
28+
public int BaseDelayMs { get; set; } = 1000;
29+
30+
/// <summary>
31+
/// Maximum delay in milliseconds for exponential backoff before jitter.
32+
/// This caps retry delays to avoid overflow and unbounded waits.
33+
/// </summary>
34+
[Range(1, 600000)]
35+
public int MaxDelayMs { get; set; } = 60000;
36+
37+
/// <summary>
38+
/// Maximum embedding request payload size sent per API call.
39+
/// The service may adaptively downshift below this value when throttled.
40+
/// </summary>
41+
[Range(1, 2048)]
42+
public int MaxEmbeddingBatchSize { get; set; } = 1024;
43+
44+
/// <summary>
45+
/// Minimum delay between embedding API requests in milliseconds.
46+
/// This adds request pacing to reduce sustained rate-limit pressure.
47+
/// </summary>
48+
[Range(0, 600000)]
49+
public int MinInterRequestDelayMs { get; set; } = 250;
50+
51+
/// <summary>
52+
/// Exponential backoff multiplier. Each retry delay is multiplied by this value.
53+
/// For example, with baseDelay=1000ms and multiplier=2.0:
54+
/// - 1st retry: 1000ms
55+
/// - 2nd retry: 2000ms
56+
/// - 3rd retry: 4000ms
57+
/// - 4th retry: 8000ms
58+
/// Default is 2.0 (double each time).
59+
/// </summary>
60+
[Range(1.0, 10.0)]
61+
public double BackoffMultiplier { get; set; } = 2.0;
62+
63+
/// <summary>
64+
/// Maximum jitter fraction added to each retry delay to prevent thundering herd.
65+
/// Jitter is a random value in range [0, maxDelay * maxJitterFraction].
66+
/// For example, with maxJitterFraction=0.2 and delay=1000ms:
67+
/// actual delay will be between 1000ms and 1200ms.
68+
/// Default is 0.2 (20% jitter).
69+
/// </summary>
70+
[Range(0.0, 1.0)]
71+
public double MaxJitterFraction { get; set; } = 0.2;
72+
73+
/// <summary>
74+
/// Validates that configuration values are reasonable.
75+
/// </summary>
76+
/// <exception cref="InvalidOperationException">Thrown if configuration is invalid.</exception>
77+
public void Validate()
78+
{
79+
if (MaxRetries < 0)
80+
throw new InvalidOperationException("MaxRetries must be non-negative.");
81+
82+
if (BaseDelayMs < 0)
83+
throw new InvalidOperationException("BaseDelayMs must be non-negative.");
84+
85+
if (MaxDelayMs <= 0)
86+
throw new InvalidOperationException("MaxDelayMs must be positive.");
87+
88+
if (BaseDelayMs > MaxDelayMs)
89+
throw new InvalidOperationException("BaseDelayMs must be less than or equal to MaxDelayMs.");
90+
91+
if (MaxEmbeddingBatchSize <= 0)
92+
throw new InvalidOperationException("MaxEmbeddingBatchSize must be positive.");
93+
94+
if (MaxEmbeddingBatchSize > 2048)
95+
throw new InvalidOperationException("MaxEmbeddingBatchSize cannot exceed Azure embedding API limit (2048).");
96+
97+
if (MinInterRequestDelayMs < 0)
98+
throw new InvalidOperationException("MinInterRequestDelayMs must be non-negative.");
99+
100+
if (BackoffMultiplier < 1.0)
101+
throw new InvalidOperationException("BackoffMultiplier must be >= 1.0.");
102+
103+
if (MaxJitterFraction < 0.0 || MaxJitterFraction > 1.0)
104+
throw new InvalidOperationException("MaxJitterFraction must be between 0.0 and 1.0.");
105+
}
106+
}

0 commit comments

Comments
 (0)