Skip to content

Commit d68ac67

Browse files
committed
Implement caching for the most expensive queries.
Works by fetching the entire database into memory and then running queries directly against that data.
1 parent 166b8df commit d68ac67

5 files changed

Lines changed: 134 additions & 39 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
using System.Collections.Generic;
2+
using System.ComponentModel;
3+
4+
namespace RP1AnalyticsWebApp.Models
5+
{
6+
/// <summary>
7+
/// Wrapper for cache entry.
8+
/// Needs to be a 🦭ed class and have ImmutableObject attribute to prevent serialization and deserialization.
9+
/// </summary>
10+
[ImmutableObject(true)]
11+
public sealed class CachedCareerLogs
12+
{
13+
public List<CareerLog> Items { get; set; }
14+
}
15+
}

RP1AnalyticsWebApp/RP1AnalyticsWebApp.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
<PackageReference Include="Microsoft.ApplicationInsights.AspNetCore" Version="2.23.0" />
1515
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="9.0.8" />
1616
<PackageReference Include="Microsoft.AspNetCore.OData" Version="8.3.1" />
17+
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="9.10.0" />
1718
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="9.0.0" />
1819
<PackageReference Include="MongoDB.Driver" Version="3.4.3" />
1920
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.3" />
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
using Microsoft.ApplicationInsights;
2+
using Microsoft.Extensions.Caching.Hybrid;
3+
using MongoDB.Driver;
4+
using MongoDB.Driver.Linq;
5+
using RP1AnalyticsWebApp.Models;
6+
using System;
7+
using System.Collections.Generic;
8+
using System.Diagnostics;
9+
using System.Linq;
10+
using System.Threading;
11+
using System.Threading.Tasks;
12+
13+
namespace RP1AnalyticsWebApp.Services
14+
{
15+
public class CacheService
16+
{
17+
private const string CacheKey = "AllCareerLogs";
18+
19+
private readonly HybridCache _cache;
20+
private readonly TelemetryClient _telemetry;
21+
22+
public CacheService(HybridCache cache, TelemetryClient telemetry)
23+
{
24+
_cache = cache;
25+
_telemetry = telemetry;
26+
}
27+
28+
public async Task<List<CareerLog>> FetchAllCareersAsync(IMongoCollection<CareerLog> careerLogs)
29+
{
30+
CachedCareerLogs cacheEntry = await _cache.GetOrCreateAsync(
31+
CacheKey,
32+
FetchFromDB(careerLogs));
33+
34+
return cacheEntry.Items;
35+
}
36+
37+
public async Task InvalidateAsync()
38+
{
39+
_telemetry.TrackEvent("InvalidateCache");
40+
await _cache.RemoveAsync(CacheKey);
41+
}
42+
43+
private Func<CancellationToken, ValueTask<CachedCareerLogs>> FetchFromDB(IMongoCollection<CareerLog> careerLogs)
44+
{
45+
return async cancel =>
46+
{
47+
var sw = Stopwatch.StartNew();
48+
int totalCount = (int)await careerLogs.EstimatedDocumentCountAsync(cancellationToken: cancel);
49+
int batchCount = Math.Clamp(totalCount / 50, 1, 10);
50+
int defBatchSize = totalCount / batchCount;
51+
52+
var subLists = new List<CareerLog>[batchCount];
53+
await Parallel.ForAsync(0, batchCount, async (int i, CancellationToken ct) =>
54+
{
55+
var sw = Stopwatch.StartNew();
56+
int skip = i * defBatchSize;
57+
int curBatchSize = i == batchCount - 1 ? int.MaxValue : defBatchSize;
58+
List<CareerLog> itemBatch = await careerLogs.AsQueryable().Skip(skip).Take(curBatchSize).ToListAsync(ct);
59+
Console.WriteLine($"AllCareerLogs subbatch: {sw.ElapsedMilliseconds:N0} ms");
60+
subLists[i] = itemBatch;
61+
});
62+
63+
List<CareerLog> allItems = subLists.SelectMany(l => l).ToList();
64+
Console.WriteLine($"AllCareerLogs: {allItems.Count} items in {sw.ElapsedMilliseconds:N0} ms");
65+
_telemetry.TrackEvent("FetchAllCareers", metrics: new Dictionary<string, double>
66+
{
67+
{ "QueryDuration", sw.ElapsedMilliseconds }
68+
});
69+
return new CachedCareerLogs { Items = allItems };
70+
};
71+
}
72+
}
73+
}

RP1AnalyticsWebApp/Services/CareerLogService.cs

Lines changed: 32 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,21 @@ public class CareerLogService
1818
private readonly IProgramSettings _programSettings;
1919
private readonly ILeaderSettings _leaderSettings;
2020
private readonly UserManager<WebAppUser> _userManager;
21+
private readonly CacheService _cache;
2122

2223
private List<WebAppUser> _allUsers;
2324
private List<WebAppUser> AllUsers => _allUsers ??= _userManager.Users.ToList();
2425

2526
public CareerLogService(ICareerLogDatabaseSettings dbSettings, IContractSettings contractSettings,
2627
ITechTreeSettings techTreeSettings, IProgramSettings programSettings, ILeaderSettings leaderSettings,
27-
UserManager<WebAppUser> userManager)
28+
UserManager<WebAppUser> userManager, CacheService cache)
2829
{
2930
_contractSettings = contractSettings;
3031
_techTreeSettings = techTreeSettings;
3132
_programSettings = programSettings;
3233
_leaderSettings = leaderSettings;
3334
_userManager = userManager;
35+
_cache = cache;
3436

3537
var client = new MongoClient(dbSettings.ConnectionString);
3638
var database = client.GetDatabase(dbSettings.DatabaseName);
@@ -39,17 +41,20 @@ public CareerLogService(ICareerLogDatabaseSettings dbSettings, IContractSettings
3941

4042
public async Task<List<CareerLog>> GetAsync(ODataQueryOptions<CareerLog> queryOptions = null)
4143
{
42-
var q = _careerLogs.AsQueryable();
44+
List<CareerLog> all = await _cache.FetchAllCareersAsync(_careerLogs);
4345
if (queryOptions != null)
4446
{
47+
var q = all.AsQueryable();
4548
q = (IQueryable<CareerLog>)queryOptions.ApplyTo(q,
4649
new ODataQuerySettings
4750
{
4851
HandleNullPropagation = HandleNullPropagationOption.False
4952
},
5053
AllowedQueryOptions.Supported ^ AllowedQueryOptions.Filter);
54+
return q.ToList();
5155
}
52-
return await q.ToListAsync();
56+
57+
return all;
5358
}
5459

5560
public async Task<CareerLog> GetAsync(string id)
@@ -166,17 +171,10 @@ public async Task<List<ContractEventWithCount>> GetRepeatableContractCompletionC
166171

167172
public async Task<List<ContractRecord>> GetContractRecordsAsync(ODataQueryOptions<CareerLog> queryOptions = null)
168173
{
169-
var q = _careerLogs.AsQueryable();
170-
if (queryOptions != null)
171-
{
172-
q = (IQueryable<CareerLog>)queryOptions.ApplyTo(q, new ODataQuerySettings
173-
{
174-
HandleNullPropagation = HandleNullPropagationOption.False
175-
});
176-
}
174+
var q = (await GetAsync(queryOptions)).AsQueryable();
177175

178-
var result = await q
179-
.Where(c => c.EligibleForRecords)
176+
var result = q
177+
.Where(c => c.EligibleForRecords && c.ContractEventEntries != null)
180178
.SelectMany(c => c.ContractEventEntries, (c, e) => new
181179
{
182180
CareerId = c.Id,
@@ -198,7 +196,7 @@ public async Task<List<ContractRecord>> GetContractRecordsAsync(ODataQueryOption
198196
Date = g.Min(e => e.EventDate)
199197
})
200198
.OrderBy(c => c.Date)
201-
.ToListAsync();
199+
.ToList();
202200

203201
result.ForEach(r =>
204202
{
@@ -211,17 +209,10 @@ public async Task<List<ContractRecord>> GetContractRecordsAsync(ODataQueryOption
211209

212210
public async Task<List<ProgramRecord>> GetProgramRecordsAsync(ProgramRecordType type, ODataQueryOptions<CareerLog> queryOptions = null)
213211
{
214-
var q = _careerLogs.AsQueryable();
215-
if (queryOptions != null)
216-
{
217-
q = (IQueryable<CareerLog>)queryOptions.ApplyTo(q, new ODataQuerySettings
218-
{
219-
HandleNullPropagation = HandleNullPropagationOption.False
220-
});
221-
}
212+
var q = (await GetAsync(queryOptions)).AsQueryable();
222213

223214
var allPrograms = q
224-
.Where(c => c.EligibleForRecords)
215+
.Where(c => c.EligibleForRecords && c.Programs != null)
225216
.SelectMany(c => c.Programs, (c, p) => new ProgramRecord
226217
{
227218
CareerId = c.Id,
@@ -237,7 +228,7 @@ public async Task<List<ProgramRecord>> GetProgramRecordsAsync(ProgramRecordType
237228
allPrograms = allPrograms.Where(p => p.Date.HasValue);
238229
}
239230

240-
var result = await allPrograms.OrderBy(p => p.Date)
231+
var result = allPrograms.OrderBy(p => p.Date)
241232
.GroupBy(p => p.ProgramName)
242233
.Select(g => new ProgramRecord
243234
{
@@ -248,7 +239,7 @@ public async Task<List<ProgramRecord>> GetProgramRecordsAsync(ProgramRecordType
248239
Date = g.Min(p => p.Date)
249240
})
250241
.OrderBy(c => c.Date)
251-
.ToListAsync();
242+
.ToList();
252243

253244
result.ForEach(r =>
254245
{
@@ -261,17 +252,10 @@ public async Task<List<ProgramRecord>> GetProgramRecordsAsync(ProgramRecordType
261252

262253
public async Task<List<ProgramItemWithCareerInfo>> GetProgramRecordsAsync(ProgramRecordType type, string program, ODataQueryOptions<CareerLog> queryOptions = null)
263254
{
264-
var q = _careerLogs.AsQueryable();
265-
if (queryOptions != null)
266-
{
267-
q = (IQueryable<CareerLog>)queryOptions.ApplyTo(q, new ODataQuerySettings
268-
{
269-
HandleNullPropagation = HandleNullPropagationOption.False
270-
});
271-
}
255+
var q = (await GetAsync(queryOptions)).AsQueryable();
272256

273257
var allPrograms = q
274-
.Where(c => c.EligibleForRecords)
258+
.Where(c => c.EligibleForRecords && c.Programs != null)
275259
.SelectMany(c => c.Programs, (c, p) => new ProgramItemWithCareerInfo
276260
{
277261
CareerId = c.Id,
@@ -298,10 +282,10 @@ public async Task<List<ProgramItemWithCareerInfo>> GetProgramRecordsAsync(Progra
298282
allPrograms = allPrograms.Where(p => p.Completed.HasValue);
299283
}
300284

301-
var result = await allPrograms
285+
var result = allPrograms
302286
.OrderBy(c => type == ProgramRecordType.Accepted ? c.Accepted :
303287
type == ProgramRecordType.ObjectivesCompleted ? c.ObjectivesCompleted : c.Completed)
304-
.ToListAsync();
288+
.ToList();
305289

306290
result.ForEach(r =>
307291
{
@@ -607,6 +591,7 @@ public async Task<CareerLog> CreateAsync(CareerLog log)
607591
careerLog.LaunchEventEntries = log.LaunchEventEntries;
608592

609593
await _careerLogs.InsertOneAsync(careerLog);
594+
await _cache.InvalidateAsync();
610595

611596
return careerLog;
612597
}
@@ -645,7 +630,9 @@ public async Task<CareerLog> UpdateAsync(string token, CareerLogDto careerLogDto
645630
.Set(nameof(CareerLog.Leaders), leaders);
646631
var opts = new FindOneAndUpdateOptions<CareerLog> { ReturnDocument = ReturnDocument.After };
647632

648-
return await _careerLogs.FindOneAndUpdateAsync(entry => entry.Token == token, updateDef, opts);
633+
var res = await _careerLogs.FindOneAndUpdateAsync(entry => entry.Token == token, updateDef, opts);
634+
await _cache.InvalidateAsync();
635+
return res;
649636
}
650637

651638
public async Task<CareerLog> GetByTokenAsync(string token)
@@ -656,6 +643,7 @@ public async Task<CareerLog> GetByTokenAsync(string token)
656643
public async Task DeleteByTokenAsync(string token)
657644
{
658645
await _careerLogs.DeleteOneAsync(entry => entry.Token == token);
646+
await _cache.InvalidateAsync();
659647
}
660648

661649
public async Task<CareerLog> UpdateMetaByTokenAsync(string token, string careerName, CareerLogMeta meta)
@@ -664,15 +652,19 @@ public async Task<CareerLog> UpdateMetaByTokenAsync(string token, string careerN
664652
.Set(nameof(CareerLog.Name), careerName);
665653
var opts = new FindOneAndUpdateOptions<CareerLog> { ReturnDocument = ReturnDocument.After };
666654

667-
return await _careerLogs.FindOneAndUpdateAsync(entry => entry.Token == token, updateDefinition, opts);
655+
var res = await _careerLogs.FindOneAndUpdateAsync(entry => entry.Token == token, updateDefinition, opts);
656+
await _cache.InvalidateAsync();
657+
return res;
668658
}
669659

670660
public async Task<CareerLog> UpdateRaceAsync(string careerId, string race)
671661
{
672662
var updateDefinition = Builders<CareerLog>.Update.Set(nameof(CareerLog.Race), race);
673663
var opts = new FindOneAndUpdateOptions<CareerLog> { ReturnDocument = ReturnDocument.After };
674664

675-
return await _careerLogs.FindOneAndUpdateAsync(entry => entry.Id == careerId, updateDefinition, opts);
665+
var res = await _careerLogs.FindOneAndUpdateAsync(entry => entry.Id == careerId, updateDefinition, opts);
666+
await _cache.InvalidateAsync();
667+
return res;
676668
}
677669

678670
public async Task UpdateLaunchAsync(string careerId, LaunchEvent launch)
@@ -682,6 +674,7 @@ public async Task UpdateLaunchAsync(string careerId, LaunchEvent launch)
682674
var updateDef = Builders<CareerLog>.Update.Set(c => c.LaunchEventEntries.FirstMatchingElement(), launch);
683675

684676
await _careerLogs.FindOneAndUpdateAsync(filterDef, updateDef);
677+
await _cache.InvalidateAsync();
685678
}
686679

687680
private string ResolveContractName(string name)

RP1AnalyticsWebApp/Startup.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using Microsoft.AspNetCore.OData;
77
using Microsoft.AspNetCore.OData.Formatter;
88
using Microsoft.AspNetCore.OData.Formatter.MediaType;
9+
using Microsoft.Extensions.Caching.Hybrid;
910
using Microsoft.Extensions.Configuration;
1011
using Microsoft.Extensions.DependencyInjection;
1112
using Microsoft.Extensions.Hosting;
@@ -20,6 +21,7 @@
2021
using RP1AnalyticsWebApp.Models;
2122
using RP1AnalyticsWebApp.OData;
2223
using RP1AnalyticsWebApp.Services;
24+
using System;
2325
using System.Linq;
2426
using Vite.AspNetCore;
2527

@@ -76,6 +78,7 @@ public void ConfigureServices(IServiceCollection services)
7678
BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.CSharpLegacy));
7779

7880
services.AddTransient<CareerLogService>();
81+
services.AddTransient<CacheService>();
7982

8083
services.AddIdentityMongoDbProvider<WebAppUser, MongoRole>(identityOptions =>
8184
{
@@ -121,6 +124,16 @@ public void ConfigureServices(IServiceCollection services)
121124
options.LoginPath = "/Identity/Account/Login";
122125
});
123126

127+
services.AddHybridCache(options =>
128+
{
129+
options.MaximumPayloadBytes = 512 * 1024 * 1024; // 512 MB
130+
options.DefaultEntryOptions = new HybridCacheEntryOptions
131+
{
132+
Expiration = TimeSpan.FromHours(8),
133+
LocalCacheExpiration = TimeSpan.FromHours(8)
134+
};
135+
});
136+
124137
services.AddHostedService<StartupHostedService>();
125138

126139
services.AddViteServices(options =>

0 commit comments

Comments
 (0)