-
Notifications
You must be signed in to change notification settings - Fork 3
Feature/AB#32781 App List Performance Code Updates #2432
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
DavidBrightBcGov
merged 4 commits into
dev
from
feature/AB#32781-app-list-performance-code-updates-10update
May 8, 2026
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
73ad3f8
Pre-warm EF Core, DataTable perf, config & secrets update
DavidBrightBcGov adc0669
Merge branch 'dev' into feature/AB#32781-app-list-performance-code-up…
DavidBrightBcGov a014b5a
Apply suggestions from code review
DavidBrightBcGov 110e110
As per Copilot flag, added configurable DB warmup: options, limits, a…
DavidBrightBcGov File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
40 changes: 40 additions & 0 deletions
40
...Manager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/DbWarmupOptions.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| namespace Unity.GrantManager.EntityFrameworkCore; | ||
|
|
||
| /// <summary> | ||
| /// Configuration options for <see cref="GrantManagerDbWarmupService"/>. | ||
| /// Bind from appsettings.json under the "DbWarmup" section. | ||
| /// | ||
| /// Example: | ||
| /// <code> | ||
| /// "DbWarmup": { | ||
| /// "IsPhase2Enabled": true, | ||
| /// "MaxTenants": 5, | ||
| /// "Phase2TimeoutSeconds": 30 | ||
| /// } | ||
| /// </code> | ||
| /// </summary> | ||
| public class DbWarmupOptions | ||
| { | ||
| public const string SectionName = "DbWarmup"; | ||
|
|
||
| /// <summary> | ||
| /// When false, Phase 2 (per-tenant DB round-trips) is skipped entirely. | ||
| /// Phase 1 (EF Core model compilation) always runs regardless of this setting. | ||
| /// Default: true. | ||
| /// </summary> | ||
| public bool IsPhase2Enabled { get; set; } = true; | ||
|
|
||
| /// <summary> | ||
| /// Maximum number of tenants to warm in Phase 2. | ||
| /// 0 means no limit. Default: 0. | ||
| /// Useful in constrained environments or when tenant count is very large. | ||
| /// </summary> | ||
| public int MaxTenants { get; set; } = 0; | ||
|
|
||
| /// <summary> | ||
| /// Total seconds allowed for Phase 2 across all tenants before it is abandoned. | ||
| /// 0 means no timeout. Default: 0. | ||
| /// Remaining tenants are skipped gracefully when the timeout elapses. | ||
| /// </summary> | ||
| public int Phase2TimeoutSeconds { get; set; } = 0; | ||
| } |
225 changes: 225 additions & 0 deletions
225
...Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbWarmupService.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,225 @@ | ||
| using Microsoft.Extensions.DependencyInjection; | ||
| using Microsoft.Extensions.Hosting; | ||
| using Microsoft.Extensions.Logging; | ||
| using Microsoft.Extensions.Options; | ||
|
|
||
| using System; | ||
| using System.Collections.Generic; | ||
| using System.Linq; | ||
| using System.Threading; | ||
| using System.Threading.Tasks; | ||
|
|
||
| using Unity.GrantManager.Applications; | ||
|
|
||
| using Volo.Abp.EntityFrameworkCore; | ||
| using Volo.Abp.MultiTenancy; | ||
| using Volo.Abp.TenantManagement; | ||
| using Volo.Abp.Uow; | ||
|
|
||
| namespace Unity.GrantManager.EntityFrameworkCore; | ||
|
|
||
| /// <summary> | ||
| /// Background service that pre-warms the EF Core query pipeline after application startup. | ||
| /// | ||
| /// On first use, EF Core performs three expensive one-time operations: | ||
| /// 1. Model snapshot compilation — GrantTenantDbContext.OnModelCreating (30+ entity types) | ||
| /// 2. LINQ→SQL expression tree translation — especially costly for multi-JOIN includes | ||
| /// 3. Npgsql connection pool establishment + PostgreSQL query plan caching | ||
| /// | ||
| /// These costs are normally deferred to the first HTTP request, causing 6-8 second cold-start | ||
| /// latency for the GrantApplications DataTable. This service fires the most expensive query | ||
| /// shape (GetApplicationListRecordsAsync with typical date filters) shortly after startup so the | ||
| /// cache is warm before any user makes a request. | ||
| /// | ||
| /// Warmup is split into two independent phases: | ||
| /// Phase 1 (model compilation) — always succeeds; no DB connection required. | ||
| /// Phase 2 (per-tenant DB round-trip) — iterates tenants from the host database and warms | ||
| /// Npgsql's connection pool and PostgreSQL's query plan cache for each. | ||
| /// | ||
| /// Phase 2 behaviour is configurable via <see cref="DbWarmupOptions"/> (appsettings "DbWarmup" section): | ||
| /// IsPhase2Enabled — set false to skip Phase 2 entirely (default: true). | ||
| /// MaxTenants — cap the number of tenants warmed; 0 = unlimited (default: 0). | ||
| /// Phase2TimeoutSeconds — abandon Phase 2 after N seconds; 0 = no timeout (default: 0). | ||
| /// | ||
| /// </summary> | ||
| public class GrantManagerDbWarmupService : BackgroundService | ||
| { | ||
| private readonly IServiceScopeFactory _scopeFactory; | ||
| private readonly ILogger<GrantManagerDbWarmupService> _logger; | ||
| private readonly IHostApplicationLifetime _hostApplicationLifetime; | ||
| private readonly DbWarmupOptions _options; | ||
|
|
||
| public GrantManagerDbWarmupService( | ||
| IServiceScopeFactory scopeFactory, | ||
| ILogger<GrantManagerDbWarmupService> logger, | ||
| IHostApplicationLifetime hostApplicationLifetime, | ||
| IOptions<DbWarmupOptions> options) | ||
| { | ||
| _scopeFactory = scopeFactory; | ||
| _logger = logger; | ||
| _hostApplicationLifetime = hostApplicationLifetime; | ||
| _options = options.Value; | ||
| } | ||
|
|
||
| protected override async Task ExecuteAsync(CancellationToken stoppingToken) | ||
| { | ||
| // Wait until the host has fully started so ABP module initialization and startup hooks | ||
| // are complete before issuing any warmup queries. | ||
| if (!_hostApplicationLifetime.ApplicationStarted.IsCancellationRequested) | ||
| { | ||
| var applicationStartedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); | ||
| using var applicationStartedRegistration = _hostApplicationLifetime.ApplicationStarted.Register( | ||
| static state => ((TaskCompletionSource)state!).TrySetResult(), | ||
| applicationStartedTcs); | ||
| using var cancellationRegistration = stoppingToken.Register( | ||
| static state => ((TaskCompletionSource)state!).TrySetCanceled(), | ||
| applicationStartedTcs); | ||
|
|
||
| await applicationStartedTcs.Task; | ||
| } | ||
|
|
||
| if (stoppingToken.IsCancellationRequested) return; | ||
|
|
||
| _logger.LogInformation("[DbWarmup] Starting EF Core query pipeline warmup."); | ||
|
|
||
| // Step 1: Model | ||
| // Accessing dbContext.Model forces EF Core to run OnModelCreating synchronously. | ||
| // This is a pure in-process operation; no DB connection is opened. | ||
| using (var phase1Scope = _scopeFactory.CreateScope()) | ||
| { | ||
| var unitOfWorkManager = phase1Scope.ServiceProvider.GetRequiredService<IUnitOfWorkManager>(); | ||
| try | ||
| { | ||
| using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false); | ||
| var dbContextProvider = phase1Scope.ServiceProvider | ||
| .GetRequiredService<IDbContextProvider<GrantTenantDbContext>>(); | ||
| var dbContext = await dbContextProvider.GetDbContextAsync(); | ||
|
|
||
| // Accessing Model triggers OnModelCreating if not yet compiled. | ||
| // The result is cached for the lifetime of the application. | ||
| _ = dbContext.Model; | ||
|
|
||
| await uow.CompleteAsync(); | ||
| _logger.LogInformation("[DbWarmup] Phase 1 complete — EF Core model compiled."); | ||
| } | ||
| catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { return; } | ||
| catch (Exception ex) | ||
| { | ||
| _logger.LogWarning(ex, "[DbWarmup] Phase 1 (model compilation) failed — this is unexpected."); | ||
| } | ||
| } | ||
|
|
||
| // Step 2: Per-tenant DB connection + PostgreSQL query plan warmup | ||
| // Enumerates all tenants (ITenantRepository -> GrantManagerDbContext -> accessible without an active tenant scope). | ||
| // Foreach tenant, opens a new DI scope, activates the tenant via ICurrentTenant.Change, and issues a Take(1) query so that: | ||
| // - Opens and pools a connection to that tenant's database | ||
| // - PostgreSQL parses and caches the parameterised execution plan for the query shape | ||
| // - EFCore's compiled query cache is populated for this tenant | ||
| // Each tenant is isolated in its own scope to prevent UoW state from leaking between tenants. | ||
| // Uses GetApplicationListRecordsAsync — the same optimized projected query the DataTable endpoint calls. | ||
| if (!_options.IsPhase2Enabled) | ||
| { | ||
| _logger.LogInformation("[DbWarmup] Phase 2 disabled via configuration — skipping per-tenant warmup."); | ||
| return; | ||
| } | ||
|
|
||
| IReadOnlyList<Tenant> tenants; | ||
|
|
||
| using (var tenantListScope = _scopeFactory.CreateScope()) | ||
| { | ||
| var tenantUowManager = tenantListScope.ServiceProvider.GetRequiredService<IUnitOfWorkManager>(); | ||
| try | ||
| { | ||
| using var uow = tenantUowManager.Begin(requiresNew: true, isTransactional: false); | ||
| var tenantRepository = tenantListScope.ServiceProvider.GetRequiredService<ITenantRepository>(); | ||
| tenants = await tenantRepository.GetListAsync(); | ||
| await uow.CompleteAsync(); | ||
| } | ||
| catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { return; } | ||
| catch (Exception ex) | ||
| { | ||
| _logger.LogWarning(ex, "[DbWarmup] Phase 2 — could not retrieve tenant list from host database. Skipping per-tenant warmup."); | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| if (tenants.Count == 0) | ||
| { | ||
| _logger.LogDebug("[DbWarmup] Phase 2 — no tenants found in host database. Skipping per-tenant DB warmup."); | ||
| return; | ||
| } | ||
|
|
||
| // Apply MaxTenants cap | ||
| var tenantsToWarm = _options.MaxTenants > 0 | ||
| ? tenants.Take(_options.MaxTenants).ToList() | ||
| : (IReadOnlyList<Tenant>)tenants; | ||
|
|
||
| if (_options.MaxTenants > 0 && tenants.Count > _options.MaxTenants) | ||
| { | ||
| _logger.LogInformation( | ||
| "[DbWarmup] Phase 2 — capped at {MaxTenants} of {TotalTenants} tenant(s) (MaxTenants setting).", | ||
| _options.MaxTenants, tenants.Count); | ||
| } | ||
|
|
||
| _logger.LogInformation("[DbWarmup] Phase 2 — warming {TenantCount} tenant(s).", tenantsToWarm.Count); | ||
|
|
||
| // Apply Phase2TimeoutSeconds — link a deadline token with stoppingToken | ||
| using var phase2Cts = _options.Phase2TimeoutSeconds > 0 | ||
| ? CancellationTokenSource.CreateLinkedTokenSource(stoppingToken) | ||
| : null; | ||
| if (phase2Cts != null) | ||
| { | ||
| phase2Cts.CancelAfter(TimeSpan.FromSeconds(_options.Phase2TimeoutSeconds)); | ||
| _logger.LogDebug("[DbWarmup] Phase 2 — timeout set to {Seconds}s.", _options.Phase2TimeoutSeconds); | ||
| } | ||
| var phase2Token = phase2Cts?.Token ?? stoppingToken; | ||
|
|
||
| var warmed = 0; | ||
| foreach (var tenant in tenantsToWarm) | ||
| { | ||
| if (phase2Token.IsCancellationRequested) | ||
| { | ||
| // Distinguish between a Phase 2 timeout and a host shutdown | ||
| if (!stoppingToken.IsCancellationRequested) | ||
| _logger.LogInformation( | ||
| "[DbWarmup] Phase 2 — timeout reached after {Warmed}/{Total} tenant(s).", | ||
| warmed, tenantsToWarm.Count); | ||
| return; | ||
| } | ||
|
|
||
| using var tenantScope = _scopeFactory.CreateScope(); | ||
| var currentTenant = tenantScope.ServiceProvider.GetRequiredService<ICurrentTenant>(); | ||
| var tenantUowManager = tenantScope.ServiceProvider.GetRequiredService<IUnitOfWorkManager>(); | ||
|
|
||
| using (currentTenant.Change(tenant.Id)) | ||
| { | ||
| try | ||
| { | ||
| using var uow = tenantUowManager.Begin(requiresNew: true, isTransactional: false); | ||
| var repository = tenantScope.ServiceProvider.GetRequiredService<IApplicationRepository>(); | ||
|
|
||
| await repository.GetApplicationListRecordsAsync( | ||
| skipCount: 0, | ||
| maxResultCount: 1, | ||
| sorting: null, | ||
| submittedFromDate: DateTime.UtcNow.AddMonths(-6), | ||
| submittedToDate: DateTime.UtcNow); | ||
|
|
||
| await uow.CompleteAsync(); | ||
| warmed++; | ||
| _logger.LogDebug("[DbWarmup] Tenant '{TenantName}' ({TenantId}) warmed.", tenant.Name, tenant.Id); | ||
| } | ||
| catch (OperationCanceledException) when (phase2Token.IsCancellationRequested) { return; } | ||
| catch (Exception ex) | ||
| { | ||
| _logger.LogDebug(ex, | ||
| "[DbWarmup] Tenant '{TenantName}' ({TenantId}) — DB round-trip skipped. " + | ||
| "Tenant database may not be accessible in this environment.", | ||
| tenant.Name, tenant.Id); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| _logger.LogInformation("[DbWarmup] Phase 2 complete — {Warmed}/{Total} tenant(s) warmed.", warmed, tenantsToWarm.Count); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.