Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
e3d0da5
Bump actions/checkout from 4 to 7
dependabot[bot] Aug 31, 2026
e4736b8
Bump docker/build-push-action from 6 to 7
dependabot[bot] Aug 31, 2026
6ed708b
Bump azure/login from 2 to 3
dependabot[bot] Aug 31, 2026
3f3cdbb
Bump actions/setup-dotnet from 4 to 6
dependabot[bot] Aug 31, 2026
0a2cdc8
Bump docker/setup-buildx-action from 3 to 4
dependabot[bot] Aug 31, 2026
5734cc9
Bump the next group in /F1Predictor.Web with 2 updates
dependabot[bot] Aug 31, 2026
88fee89
Bump @types/react-dom in /F1Predictor.Web in the react group
dependabot[bot] Aug 31, 2026
d84621b
Bump typescript from 5.9.3 to 7.0.2 in /F1Predictor.Web
dependabot[bot] Aug 31, 2026
55235ad
Bump motion from 13.1.0 to 13.1.1 in /F1Predictor.Web
dependabot[bot] Aug 31, 2026
8ea9248
Bump @types/node from 20.19.43 to 26.4.0 in /F1Predictor.Web
dependabot[bot] Aug 31, 2026
f50e1b5
Bump the dotnet group with 18 updates
dependabot[bot] Aug 31, 2026
fc2f53b
Merge pull request #11 from tsholofelondawonde/dependabot/github_acti…
tsholofelondawonde Sep 1, 2026
aceb46a
Merge pull request #12 from tsholofelondawonde/dependabot/github_acti…
tsholofelondawonde Sep 1, 2026
dd59eb0
Merge pull request #13 from tsholofelondawonde/dependabot/github_acti…
tsholofelondawonde Sep 1, 2026
77d081d
Merge pull request #14 from tsholofelondawonde/dependabot/github_acti…
tsholofelondawonde Sep 1, 2026
c9eaa8f
Merge pull request #15 from tsholofelondawonde/dependabot/github_acti…
tsholofelondawonde Sep 1, 2026
8a78cab
Merge pull request #21 from tsholofelondawonde/dependabot/nuget/dotne…
tsholofelondawonde Sep 1, 2026
db3895a
Merge pull request #16 from tsholofelondawonde/dependabot/npm_and_yar…
tsholofelondawonde Sep 1, 2026
a26ba68
Merge pull request #17 from tsholofelondawonde/dependabot/npm_and_yar…
tsholofelondawonde Sep 1, 2026
429f2a9
Merge branch 'dev' into dependabot/npm_and_yarn/F1Predictor.Web/types…
tsholofelondawonde Sep 1, 2026
ede772e
Merge pull request #18 from tsholofelondawonde/dependabot/npm_and_yar…
tsholofelondawonde Sep 1, 2026
0199b01
Merge branch 'dev' into dependabot/npm_and_yarn/F1Predictor.Web/motio…
tsholofelondawonde Sep 3, 2026
0cbeba8
Merge pull request #19 from tsholofelondawonde/dependabot/npm_and_yar…
tsholofelondawonde Sep 3, 2026
1f3de26
Merge pull request #20 from tsholofelondawonde/dependabot/npm_and_yar…
tsholofelondawonde Sep 3, 2026
22419ec
Bump azure/login from 2 to 3
dependabot[bot] Sep 7, 2026
d77fb77
Bump actions/setup-dotnet from 4 to 6
dependabot[bot] Sep 7, 2026
4cb1541
Bump the next group in /F1Predictor.Web with 2 updates
dependabot[bot] Sep 7, 2026
f91d0d4
Bump axios from 1.19.0 to 1.20.0 in /F1Predictor.Web
dependabot[bot] Sep 7, 2026
0f44cf2
Bump zustand from 5.0.14 to 5.0.15 in /F1Predictor.Web
dependabot[bot] Sep 7, 2026
897c4d4
Bump typescript from 5.9.3 to 7.0.2 in /F1Predictor.Web
dependabot[bot] Sep 7, 2026
87e2d14
Bump the dotnet group with 18 updates
dependabot[bot] Sep 7, 2026
0b3f3a4
Add license and engine requirements to package-lock.json; update bina…
tsholofelondawonde Sep 14, 2026
530f3ed
Bump the react group across 1 directory with 4 updates
dependabot[bot] Sep 14, 2026
cd04b96
Bump the dotnet group with 18 updates
dependabot[bot] Sep 14, 2026
6babea2
Add roadmap page and update navigation links in Marketing components
tsholofelondawonde Sep 14, 2026
fc1b313
Add verification step for trained models in CI workflow
tsholofelondawonde Sep 14, 2026
7781f73
Add data status feature to track staleness of season data
tsholofelondawonde Sep 14, 2026
869833d
Add sprint and classification status to SeasonRace type and update Ra…
tsholofelondawonde Sep 14, 2026
cb74aa2
Update package versions for Aspire.Hosting.JavaScript and related Mic…
tsholofelondawonde Sep 14, 2026
e0126f9
Add advisory lock mechanism for session ingestion and prevent concurr…
tsholofelondawonde Sep 14, 2026
77c61df
Update podium and points model zip files
tsholofelondawonde Sep 14, 2026
2ddcc8f
Merge pull request #23 from tsholofelondawonde/dependabot/github_acti…
tsholofelondawonde Sep 14, 2026
6aca93f
Merge pull request #24 from tsholofelondawonde/dependabot/github_acti…
tsholofelondawonde Sep 14, 2026
f89d2d2
Bump docker/setup-buildx-action from 3 to 4
dependabot[bot] Sep 14, 2026
066dc56
Bump docker/build-push-action from 6 to 7
dependabot[bot] Sep 14, 2026
1d54415
Merge pull request #26 from tsholofelondawonde/dependabot/github_acti…
tsholofelondawonde Sep 14, 2026
4c54e42
Merge pull request #27 from tsholofelondawonde/dependabot/github_acti…
tsholofelondawonde Sep 14, 2026
5a44da4
Merge branch 'dev' into dependabot/npm_and_yarn/F1Predictor.Web/next-…
tsholofelondawonde Sep 14, 2026
954ed36
Merge pull request #28 from tsholofelondawonde/dependabot/npm_and_yar…
tsholofelondawonde Sep 14, 2026
fc2ebb7
Merge branch 'dev' into roadmap
tsholofelondawonde Sep 14, 2026
fe2cd9d
Merge pull request #37 from tsholofelondawonde/roadmap
tsholofelondawonde Sep 14, 2026
5b3c766
Merge pull request #30 from tsholofelondawonde/dependabot/npm_and_yar…
tsholofelondawonde Sep 14, 2026
772dee9
Merge pull request #31 from tsholofelondawonde/dependabot/npm_and_yar…
tsholofelondawonde Sep 14, 2026
bf0b67c
Merge branch 'dev' into dependabot/npm_and_yarn/F1Predictor.Web/types…
tsholofelondawonde Sep 14, 2026
2c5f297
Merge pull request #32 from tsholofelondawonde/dependabot/npm_and_yar…
tsholofelondawonde Sep 14, 2026
d417912
Merge branch 'dev' into dependabot/nuget/dotnet-7a16311806
tsholofelondawonde Sep 14, 2026
fdf3cd1
Merge pull request #33 from tsholofelondawonde/dependabot/nuget/dotne…
tsholofelondawonde Sep 14, 2026
2960d08
Merge branch 'dev' into dependabot/npm_and_yarn/F1Predictor.Web/react…
tsholofelondawonde Sep 14, 2026
427942f
Merge pull request #34 from tsholofelondawonde/dependabot/npm_and_yar…
tsholofelondawonde Sep 14, 2026
4c3dff2
Merge branch 'dev' into dependabot/nuget/dotnet-8caa84a249
tsholofelondawonde Sep 14, 2026
f7ab17e
Merge pull request #36 from tsholofelondawonde/dependabot/nuget/dotne…
tsholofelondawonde Sep 14, 2026
65a8a9c
Add /docs to .gitignore and ensure F1Predictor.WebApi/models/*.zip is…
tsholofelondawonde Sep 14, 2026
0a78fe1
Merge branch 'dev' of https://github.com/tsholofelondawonde/F1Predict…
tsholofelondawonde Sep 14, 2026
dcaeb98
Update Microsoft.Extensions.DependencyInjection version to 10.0.12
tsholofelondawonde Sep 14, 2026
b6b2246
Update dependencies in package-lock.json to latest versions
tsholofelondawonde Sep 14, 2026
668d01e
Refactor ClearSessionAsync call to always execute, ensuring orphaned …
tsholofelondawonde Sep 14, 2026
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
11 changes: 10 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,17 @@ jobs:
- name: Checkout
uses: actions/checkout@v7

- name: Verify trained models are present
run: |
for f in F1Predictor.WebApi/models/podium-model.zip F1Predictor.WebApi/models/points-model.zip; do
if [ ! -s "$f" ]; then
echo "::error::Missing or empty $f — the Dockerfile bakes this into the production image; commit the trained model before merging." >&2
exit 1
fi
done

- name: Setup .NET 10
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v6
with:
dotnet-version: '10.x'

Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ jobs:
uses: actions/checkout@v7

- name: Setup .NET 10
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v6
with:
dotnet-version: '10.x'

Expand Down Expand Up @@ -78,7 +78,7 @@ jobs:
# setting; a credential registered with the older `repo:<owner>/<repo>:...` form is not
# matched and login fails with AADSTS700213.
- name: Sign in to Azure
uses: azure/login@v2
uses: azure/login@v3
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
Expand All @@ -90,13 +90,13 @@ jobs:
run: az acr login --name "$REGISTRY_NAME"

- name: Set up Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4

# Context is the repo root, not the Dockerfile's directory: central package management
# puts Directory.Build.props and Directory.Packages.props there, and a restore without
# them fails. .dockerignore keeps the upload small.
- name: Build and push image
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
file: F1Predictor.WebApi/Dockerfile
Expand Down
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,7 @@ appsettings.*.local.json
# Local planning and prompt files
DOTNET_2026_STANDARDS_PROMPT.md
OTEL_ASPIRE_MIGRATION_PROMPT.md
/docs/

/graphify-out

Expand All @@ -408,4 +409,6 @@ OTEL_ASPIRE_MIGRATION_PROMPT.md
# ML.NET model artifacts are trained locally and baked into the WebApi image at
# build time; only the ones under F1Predictor.WebApi/models/ are intentionally tracked.
*.zip
!F1Predictor.WebApi/models/*.zip
!F1Predictor.WebApi/models/*.zip

/docs
48 changes: 24 additions & 24 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,20 @@
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="AspNetCore.HealthChecks.NpgSql" Version="9.0.0" />
<PackageVersion Include="Aspire.Hosting.JavaScript" Version="13.4.6" />
<PackageVersion Include="Aspire.Hosting.JavaScript" Version="13.5.3" />
<PackageVersion Include="FluentValidation" Version="12.1.1" />
<PackageVersion Include="MessagePack" Version="3.1.7" />
<PackageVersion Include="MessagePack" Version="3.1.8" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageVersion>
<PackageVersion Include="Microsoft.Extensions.Caching.Hybrid" Version="10.9.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Configuration.FileExtensions" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Caching.Hybrid" Version="10.10.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Configuration.FileExtensions" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.5.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Debug" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Logging.Debug" Version="10.0.11" />
<PackageVersion Include="Microsoft.Data.Sqlite.Core" Version="10.0.9" />
<PackageVersion Include="AspNetCore.HealthChecks.UI.Client" Version="9.0.0" />
<PackageVersion Include="Bogus" Version="35.6.5" />
Expand All @@ -32,7 +32,7 @@
<PackageVersion Include="Microsoft.AspNetCore.Components.Analyzers" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Http.Features" Version="5.0.17" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.11" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker" Version="2.1.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.0.2" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.ServiceBus" Version="5.23.0" />
Expand All @@ -50,13 +50,13 @@
<PackageVersion Include="SQLitePCLRaw.core" Version="2.1.12" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Azure" Version="1.13.1" />
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.11" />
<PackageVersion Include="Microsoft.ML" Version="5.0.0" />
<PackageVersion Include="Microsoft.Extensions.ML" Version="5.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.12" />
<PackageVersion Include="Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk" Version="10.0.9" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
<PackageVersion Include="Microsoft.OpenApi" Version="2.10.0" />
Expand All @@ -70,19 +70,19 @@
<PackageVersion Include="System.Text.Json" Version="10.0.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.7.0" />
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.7.0" />
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.117" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.16.0" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.16.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.16.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.16.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.10.0" />
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.10.0" />
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.257" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.18.0" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.18.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.18.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.18.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.18.0" />
<PackageVersion Include="Quartz" Version="3.18.1" />
<PackageVersion Include="Quartz.Extensions.Hosting" Version="3.18.1" />
<PackageVersion Include="SonarAnalyzer.CSharp" Version="10.27.0.140913" />
<PackageVersion Include="Roslynator.Analyzers" Version="4.15.0" />
<PackageVersion Include="SonarAnalyzer.CSharp" Version="10.34.0.3385" />
<PackageVersion Include="Roslynator.Analyzers" Version="5.0.0" />
<PackageVersion Include="xunit.analyzers" Version="1.26.0" />
</ItemGroup>
</Project>
2 changes: 1 addition & 1 deletion F1Predictor.AppHost/F1Predictor.AppHost.csproj
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project Sdk="Aspire.AppHost.Sdk/13.4.6">
<Project Sdk="Aspire.AppHost.Sdk/13.5.3">

<PropertyGroup>
<OutputType>Exe</OutputType>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using F1Predictor.Domain.RaceData.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Infrastructure;

namespace F1Predictor.Application.Abstractions.Data;

Expand All @@ -25,5 +26,21 @@ public interface IApplicationDbContext
/// </summary>
ChangeTracker ChangeTracker { get; }

/// <summary>
/// Provides access to database-level operations such as transactions. Used for advanced
/// scenarios like serializing concurrent writers with an advisory lock (see
/// <see cref="AcquireSessionAdvisoryLockAsync"/>).
/// </summary>
DatabaseFacade Database { get; }

/// <summary>
/// Takes a Postgres transaction-scoped advisory lock keyed on <paramref name="sessionKey"/>.
/// Must be called inside an active transaction — the lock releases automatically when that
/// transaction commits or rolls back. Serializes concurrent ingestion of the same session
/// across requests, scheduler ticks, and — once scaled beyond one replica — processes, none
/// of which otherwise coordinate with each other.
/// </summary>
Task AcquireSessionAdvisoryLockAsync(int sessionKey, CancellationToken cancellationToken);

Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace F1Predictor.Application.Features.Seasons.GetDataStatus;

/// <param name="IsStale">True if any of the three signals below means the pipeline needs a re-run.</param>
/// <param name="PendingRaceName">
/// Name of the earliest Grand Prix that has happened but has no classified result yet, or null if
/// none. Null cannot distinguish "nothing pending" from "OpenF1 never published this round's
/// result" — the same ambiguity <c>ChampionshipPoints</c> already accepts elsewhere.
/// </param>
/// <param name="PendingRaceDate">Start date of <see cref="PendingRaceName"/>, if any.</param>
/// <param name="FeaturesStale">True if a classified Grand Prix has no feature rows yet.</param>
/// <param name="ModelsAvailable">False if the models have never been trained.</param>
public sealed record DataStatusResponse(
bool IsStale,
string? PendingRaceName,
DateTimeOffset? PendingRaceDate,
bool FeaturesStale,
bool ModelsAvailable);
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using F1Predictor.Application.Abstractions.Messaging;

namespace F1Predictor.Application.Features.Seasons.GetDataStatus;

/// <summary>
/// Whether a season's standings and predictions are current with what has actually raced.
/// </summary>
public sealed record GetDataStatusQuery(int Year) : IQuery<DataStatusResponse>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using F1Predictor.Application.Abstractions.Data;
using F1Predictor.Application.Abstractions.MachineLearning;
using F1Predictor.Application.Abstractions.Messaging;
using Microsoft.EntityFrameworkCore;
using SharedKernel;

namespace F1Predictor.Application.Features.Seasons.GetDataStatus;

internal sealed class GetDataStatusQueryHandler(IApplicationDbContext context, IRacePredictor predictor)
: IQueryHandler<GetDataStatusQuery, DataStatusResponse>
{
public async Task<Result<DataStatusResponse>> Handle(
GetDataStatusQuery query,
CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;

var pending = await (
from session in context.RaceSessions
join meeting in context.Meetings on session.MeetingKey equals meeting.MeetingKey
where meeting.Year == query.Year
&& !session.IsSprint
&& !session.IsClassified
&& session.DateStart < now
orderby session.DateStart
select new { meeting.MeetingName, session.DateStart })
.AsNoTracking()
.FirstOrDefaultAsync(cancellationToken);

var featuresStale = await context.RaceSessions
.Join(context.Meetings, session => session.MeetingKey, meeting => meeting.MeetingKey, (session, meeting) => new { session, meeting })
.Where(x => x.meeting.Year == query.Year && !x.session.IsSprint && x.session.IsClassified)
.AnyAsync(x => !context.DriverRaceFeatures.Any(f => f.SessionKey == x.session.SessionKey), cancellationToken);

var modelsAvailable = predictor.ModelsAvailable;

var isStale = pending is not null || featuresStale || !modelsAvailable;

return Result.Success(new DataStatusResponse(
isStale,
pending?.MeetingName,
pending?.DateStart,
featuresStale,
modelsAvailable));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -134,28 +134,31 @@ private static IngestOutcome Summarise(IReadOnlyList<IngestOutcome> outcomes)
var isSprint = string.Equals(session.SessionName, SprintSessionName, StringComparison.Ordinal);
var label = $"{meeting.MeetingName} ({session.SessionName})";

var existing = await context.RaceSessions
// Cheap early-out before bothering with a lock or the OpenF1 round trips below. The
// authoritative check — the one that actually decides skip vs. ingest — runs again
// after the advisory lock is acquired, since a concurrent writer may commit between
// this read and that point.
var precheck = await context.RaceSessions
.AsNoTracking()
.FirstOrDefaultAsync(s => s.SessionKey == session.SessionKey, cancellationToken);

// A session already stored with results is settled; anything else is re-checked so a
// scheduled race is picked up automatically once it runs. `force` re-fetches either way,
// which is the only route by which a provisional classification ever gets corrected.
if (existing is { IsClassified: true } && !force)
if (precheck is { IsClassified: true } && !force)
{
logger.LogInformation("{Label}: already ingested, skipping.", label);
return (IngestOutcome.AlreadyPresent, SessionTotals.Empty);
}

// No reason to hold a DB lock across network I/O, so fetch before taking it.
var payload = await FetchSessionAsync(session, allSessions, isSprint, cancellationToken);

if (existing is not null)
var written = await WriteSessionUnderLockAsync(session, isSprint, force, payload, cancellationToken);

if (!written)
{
await ClearSessionAsync(session.SessionKey, cancellationToken);
logger.LogInformation("{Label}: already ingested by a concurrent run, skipping.", label);
return (IngestOutcome.AlreadyPresent, SessionTotals.Empty);
}

await PersistSessionAsync(session, isSprint, payload, cancellationToken);

var totals = new SessionTotals(
payload.Results.Count,
payload.Grid.Count,
Expand All @@ -181,6 +184,45 @@ private static IngestOutcome Summarise(IReadOnlyList<IngestOutcome> outcomes)
return (IngestOutcome.Ingested, totals);
}

/// <summary>
/// Clears and re-persists one session's rows inside a transaction guarded by a Postgres
/// advisory lock keyed on <paramref name="session"/>'s key. Two triggers can otherwise race
/// to ingest the same session — a manual API call overlapping the Quartz coordinator's
/// startup tick, or, once this app scales beyond one replica, each replica's own scheduler
/// firing independently. The lock, held for the life of the transaction, serializes them at
/// the database so the loser re-checks post-commit state and skips instead of racing the
/// unique index on <c>DriverEntries</c>.
/// </summary>
/// <returns>False if a concurrent run already classified this session and <paramref name="force"/> is not set.</returns>
private async Task<bool> WriteSessionUnderLockAsync(
OpenF1Session session,
bool isSprint,
bool force,
SessionPayload payload,
CancellationToken cancellationToken)
{
await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken);

await context.AcquireSessionAdvisoryLockAsync(session.SessionKey, cancellationToken);

var existing = await context.RaceSessions
.AsNoTracking()
.FirstOrDefaultAsync(s => s.SessionKey == session.SessionKey, cancellationToken);

if (existing is { IsClassified: true } && !force)
{
return false;
}

await ClearSessionAsync(session.SessionKey, cancellationToken);

await PersistSessionAsync(session, isSprint, payload, cancellationToken);

await transaction.CommitAsync(cancellationToken);

return true;
}

/// <summary>
/// Pulls everything OpenF1 has for one session. How much that is depends on whether the
/// session has run — see the remarks on each call below.
Expand Down Expand Up @@ -225,9 +267,14 @@ private async Task<SessionPayload> FetchSessionAsync(
}

/// <summary>
/// Removes everything previously stored for a session so it can be written fresh. Used
/// both by <c>force</c> and by the scheduled-to-classified transition, where the placeholder
/// row and its entry list have to give way to the real thing.
/// Removes everything previously stored for a session so it can be written fresh. Called
/// unconditionally, even when no <see cref="RaceSession"/> row exists yet: a prior partial
/// write can otherwise leave orphaned child rows (e.g. <c>DriverEntries</c>) for a
/// <c>SessionKey</c> whose <c>RaceSessions</c> row never committed, which would collide with
/// the fresh insert below since <c>existing is null</c> would skip this clear. Each delete is
/// a no-op when nothing matches, so this is cheap on the common brand-new-session path too.
/// Also used by <c>force</c> and by the scheduled-to-classified transition, where the
/// placeholder row and its entry list have to give way to the real thing.
/// </summary>
private async Task ClearSessionAsync(int sessionKey, CancellationToken cancellationToken)
{
Expand Down
4 changes: 4 additions & 0 deletions F1Predictor.Infrastructure/Database/ApplicationDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ public class ApplicationDbContext(
public DbSet<DriverEntry> DriverEntries { get; set; } = null!;
public DbSet<DriverRaceFeature> DriverRaceFeatures { get; set; } = null!;

public Task AcquireSessionAdvisoryLockAsync(int sessionKey, CancellationToken cancellationToken) =>
Database.ExecuteSqlInterpolatedAsync(
$"SELECT pg_advisory_xact_lock({sessionKey})", cancellationToken);

public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
int result = await base.SaveChangesAsync(cancellationToken);
Expand Down
Loading
Loading