Scale unique answer tracking
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed class AnswerStatisticsMaintenanceService : BackgroundService
|
||||
{
|
||||
private readonly AnswerStatisticsStore _statistics;
|
||||
private readonly QuestionAttemptStore _attempts;
|
||||
private readonly ServiceMetrics _metrics;
|
||||
private readonly ILogger<AnswerStatisticsMaintenanceService> _logger;
|
||||
private readonly TimeSpan _interval;
|
||||
private readonly string _owner = $"{Environment.MachineName}-{Guid.NewGuid():N}";
|
||||
|
||||
public AnswerStatisticsMaintenanceService(
|
||||
AnswerStatisticsStore statistics,
|
||||
QuestionAttemptStore attempts,
|
||||
ServiceMetrics metrics,
|
||||
IConfiguration configuration,
|
||||
ILogger<AnswerStatisticsMaintenanceService> logger)
|
||||
{
|
||||
_statistics = statistics;
|
||||
_attempts = attempts;
|
||||
_metrics = metrics;
|
||||
_logger = logger;
|
||||
_interval = TimeSpan.FromSeconds(Math.Max(30, configuration.GetValue("AnswerStatistics:ReconcileSeconds", 300)));
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (await _statistics.TryAcquireBackfillLeaseAsync(_owner, TimeSpan.FromMinutes(30)))
|
||||
{
|
||||
_logger.LogInformation("Backfilling materialized answer statistics");
|
||||
await _statistics.RebuildAllAsync();
|
||||
await _statistics.MarkBackfillCompleteAsync(_owner);
|
||||
_metrics.Reconciled();
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogError(exception, "Initial answer-statistics backfill failed");
|
||||
}
|
||||
|
||||
using var timer = new PeriodicTimer(_interval);
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
var activeIds = await _attempts.GetActiveQuestionIdsAsync(DateTime.UtcNow.Subtract(_interval).Subtract(_interval));
|
||||
await _statistics.RebuildQuestionsAsync(activeIds);
|
||||
_metrics.Reconciled();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogError(exception, "Answer-statistics reconciliation failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed class AnswerStatisticsStore
|
||||
{
|
||||
private const string StateId = "answer-statistics-v1";
|
||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
|
||||
|
||||
private readonly IMongoCollection<QuestionAttemptCounter> _counters;
|
||||
private readonly IMongoCollection<AnswerStatisticsState> _state;
|
||||
private readonly QuestionAttemptStore _attempts;
|
||||
private readonly ConcurrentDictionary<string, CachedPercentages> _cache = new();
|
||||
private volatile bool _backfillComplete;
|
||||
|
||||
public AnswerStatisticsStore(MongoDatabaseProvider mongo, QuestionAttemptStore attempts)
|
||||
{
|
||||
_attempts = attempts;
|
||||
_counters = mongo.Database.GetCollection<QuestionAttemptCounter>("QuestionAttemptCounters");
|
||||
_state = mongo.Database.GetCollection<AnswerStatisticsState>("SystemState");
|
||||
|
||||
var counterIndex = Builders<QuestionAttemptCounter>.IndexKeys
|
||||
.Ascending(counter => counter.QuestionExternalId)
|
||||
.Ascending(counter => counter.Bucket)
|
||||
.Ascending(counter => counter.ChoiceId);
|
||||
_counters.Indexes.CreateOne(new CreateIndexModel<QuestionAttemptCounter>(
|
||||
counterIndex,
|
||||
new CreateIndexOptions { Unique = true }));
|
||||
}
|
||||
|
||||
public async Task IncrementAsync(QuestionAttempt attempt)
|
||||
{
|
||||
if (attempt.IsSkip)
|
||||
return;
|
||||
|
||||
var filter = Builders<QuestionAttemptCounter>.Filter.And(
|
||||
Builders<QuestionAttemptCounter>.Filter.Eq(counter => counter.QuestionExternalId, attempt.QuestionExternalId),
|
||||
Builders<QuestionAttemptCounter>.Filter.Eq(counter => counter.Bucket, attempt.CounterBucket),
|
||||
Builders<QuestionAttemptCounter>.Filter.Eq(counter => counter.ChoiceId, attempt.ChoiceId));
|
||||
var update = Builders<QuestionAttemptCounter>.Update
|
||||
.Inc(counter => counter.Count, 1)
|
||||
.Set(counter => counter.UpdatedUtc, DateTime.UtcNow)
|
||||
.SetOnInsert(counter => counter.QuestionExternalId, attempt.QuestionExternalId)
|
||||
.SetOnInsert(counter => counter.Bucket, attempt.CounterBucket)
|
||||
.SetOnInsert(counter => counter.ChoiceId, attempt.ChoiceId);
|
||||
|
||||
try
|
||||
{
|
||||
await _counters.UpdateOneAsync(filter, update, new UpdateOptions { IsUpsert = true });
|
||||
}
|
||||
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
await _counters.UpdateOneAsync(filter, update);
|
||||
}
|
||||
_cache.TryRemove(attempt.QuestionExternalId, out _);
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, int>> GetPercentagesForQuestionAsync(string questionExternalId)
|
||||
{
|
||||
var result = await GetPercentagesByQuestionAsync([questionExternalId]);
|
||||
return result.GetValueOrDefault(questionExternalId, []);
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, Dictionary<string, int>>> GetPercentagesByQuestionAsync(
|
||||
IEnumerable<string> questionExternalIds)
|
||||
{
|
||||
var ids = questionExternalIds.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().ToList();
|
||||
var output = new Dictionary<string, Dictionary<string, int>>();
|
||||
var missing = new List<string>();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
foreach (var id in ids)
|
||||
{
|
||||
if (_cache.TryGetValue(id, out var cached) && cached.ExpiresUtc > now)
|
||||
output[id] = new Dictionary<string, int>(cached.Percentages);
|
||||
else
|
||||
missing.Add(id);
|
||||
}
|
||||
|
||||
if (missing.Count == 0)
|
||||
return output;
|
||||
|
||||
var backfillComplete = await IsBackfillCompleteAsync();
|
||||
if (!backfillComplete)
|
||||
{
|
||||
var rawPercentages = await _attempts.GetPercentagesByQuestionAsync(missing);
|
||||
foreach (var id in missing)
|
||||
{
|
||||
var percentages = rawPercentages.GetValueOrDefault(id, []);
|
||||
output[id] = percentages;
|
||||
_cache[id] = new CachedPercentages(percentages, now.Add(CacheDuration));
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
var totals = await _counters.Aggregate()
|
||||
.Match(counter => missing.Contains(counter.QuestionExternalId))
|
||||
.Group(
|
||||
counter => new { counter.QuestionExternalId, counter.ChoiceId },
|
||||
group => new QuestionAttemptCounterTotal
|
||||
{
|
||||
Key = new QuestionChoiceCounterKey
|
||||
{
|
||||
QuestionExternalId = group.Key.QuestionExternalId,
|
||||
ChoiceId = group.Key.ChoiceId
|
||||
},
|
||||
Count = group.Sum(counter => counter.Count)
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var grouped = totals.GroupBy(total => total.Key.QuestionExternalId)
|
||||
.ToDictionary(group => group.Key, group => group.ToList());
|
||||
|
||||
foreach (var id in missing)
|
||||
{
|
||||
Dictionary<string, int> percentages;
|
||||
if (grouped.TryGetValue(id, out var questionTotals))
|
||||
{
|
||||
var total = questionTotals.Sum(item => item.Count);
|
||||
percentages = total == 0
|
||||
? []
|
||||
: questionTotals.ToDictionary(
|
||||
item => item.Key.ChoiceId,
|
||||
item => (int)Math.Round(item.Count * 100.0 / total));
|
||||
}
|
||||
else
|
||||
{
|
||||
percentages = [];
|
||||
}
|
||||
|
||||
output[id] = percentages;
|
||||
_cache[id] = new CachedPercentages(percentages, now.Add(CacheDuration));
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
public async Task<bool> TryAcquireBackfillLeaseAsync(string owner, TimeSpan duration)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var filter = Builders<AnswerStatisticsState>.Filter.And(
|
||||
Builders<AnswerStatisticsState>.Filter.Eq(state => state.Id, StateId),
|
||||
Builders<AnswerStatisticsState>.Filter.Eq(state => state.Completed, false),
|
||||
Builders<AnswerStatisticsState>.Filter.Or(
|
||||
Builders<AnswerStatisticsState>.Filter.Lt(state => state.LeaseUntilUtc, now),
|
||||
Builders<AnswerStatisticsState>.Filter.Eq(state => state.LeaseOwner, owner)));
|
||||
var update = Builders<AnswerStatisticsState>.Update
|
||||
.SetOnInsert(state => state.Id, StateId)
|
||||
.SetOnInsert(state => state.Completed, false)
|
||||
.Set(state => state.LeaseOwner, owner)
|
||||
.Set(state => state.LeaseUntilUtc, now.Add(duration))
|
||||
.Set(state => state.UpdatedUtc, now);
|
||||
|
||||
try
|
||||
{
|
||||
var leased = await _state.FindOneAndUpdateAsync(
|
||||
filter,
|
||||
update,
|
||||
new FindOneAndUpdateOptions<AnswerStatisticsState>
|
||||
{
|
||||
IsUpsert = true,
|
||||
ReturnDocument = ReturnDocument.After
|
||||
});
|
||||
return leased?.LeaseOwner == owner;
|
||||
}
|
||||
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task MarkBackfillCompleteAsync(string owner)
|
||||
{
|
||||
var update = Builders<AnswerStatisticsState>.Update
|
||||
.Set(state => state.Completed, true)
|
||||
.Set(state => state.LeaseOwner, owner)
|
||||
.Set(state => state.UpdatedUtc, DateTime.UtcNow);
|
||||
await _state.UpdateOneAsync(state => state.Id == StateId && state.LeaseOwner == owner, update);
|
||||
_backfillComplete = true;
|
||||
}
|
||||
|
||||
public async Task RebuildAllAsync() => await RebuildAsync(null);
|
||||
|
||||
public async Task RebuildQuestionsAsync(IReadOnlyCollection<string> questionExternalIds)
|
||||
{
|
||||
if (questionExternalIds.Count > 0)
|
||||
await RebuildAsync(questionExternalIds);
|
||||
}
|
||||
|
||||
private async Task RebuildAsync(IReadOnlyCollection<string>? questionExternalIds)
|
||||
{
|
||||
var aggregate = _attempts.Collection.Aggregate().Match(attempt => !attempt.IsSkip);
|
||||
if (questionExternalIds is not null)
|
||||
aggregate = aggregate.Match(attempt => questionExternalIds.Contains(attempt.QuestionExternalId));
|
||||
|
||||
var totals = await aggregate
|
||||
.Group(
|
||||
attempt => new { attempt.QuestionExternalId, attempt.CounterBucket, attempt.ChoiceId },
|
||||
group => new QuestionAttemptCounterAggregate
|
||||
{
|
||||
Key = new QuestionChoiceBucketKey
|
||||
{
|
||||
QuestionExternalId = group.Key.QuestionExternalId,
|
||||
Bucket = group.Key.CounterBucket,
|
||||
ChoiceId = group.Key.ChoiceId
|
||||
},
|
||||
Count = group.Sum(_ => 1L)
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
if (totals.Count > 0)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var replacements = totals.Select(total =>
|
||||
{
|
||||
var counter = new QuestionAttemptCounter
|
||||
{
|
||||
QuestionExternalId = total.Key.QuestionExternalId,
|
||||
Bucket = total.Key.Bucket,
|
||||
ChoiceId = total.Key.ChoiceId,
|
||||
Count = total.Count,
|
||||
UpdatedUtc = now
|
||||
};
|
||||
var filter = Builders<QuestionAttemptCounter>.Filter.And(
|
||||
Builders<QuestionAttemptCounter>.Filter.Eq(item => item.QuestionExternalId, counter.QuestionExternalId),
|
||||
Builders<QuestionAttemptCounter>.Filter.Eq(item => item.Bucket, counter.Bucket),
|
||||
Builders<QuestionAttemptCounter>.Filter.Eq(item => item.ChoiceId, counter.ChoiceId));
|
||||
return new ReplaceOneModel<QuestionAttemptCounter>(filter, counter) { IsUpsert = true };
|
||||
}).ToList();
|
||||
await _counters.BulkWriteAsync(replacements, new BulkWriteOptions { IsOrdered = false });
|
||||
}
|
||||
|
||||
if (questionExternalIds is null)
|
||||
_cache.Clear();
|
||||
else
|
||||
foreach (var id in questionExternalIds)
|
||||
_cache.TryRemove(id, out _);
|
||||
}
|
||||
|
||||
private async Task<bool> IsBackfillCompleteAsync()
|
||||
{
|
||||
if (_backfillComplete)
|
||||
return true;
|
||||
_backfillComplete = (await _state.Find(state => state.Id == StateId).FirstOrDefaultAsync())?.Completed == true;
|
||||
return _backfillComplete;
|
||||
}
|
||||
|
||||
private sealed record CachedPercentages(Dictionary<string, int> Percentages, DateTime ExpiresUtc);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed class MongoDatabaseProvider
|
||||
{
|
||||
public IMongoClient Client { get; }
|
||||
public IMongoDatabase Database { get; }
|
||||
|
||||
public MongoDatabaseProvider(IConfiguration configuration)
|
||||
{
|
||||
var connectionString = configuration["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017";
|
||||
var databaseName = configuration["MongoDB:DatabaseName"] ?? "GameDb";
|
||||
Client = new MongoClient(connectionString);
|
||||
Database = Client.GetDatabase(databaseName);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
@@ -7,13 +9,11 @@ public class QuestionAttemptStore
|
||||
{
|
||||
private readonly IMongoCollection<QuestionAttempt> _col;
|
||||
|
||||
public QuestionAttemptStore(IConfiguration cfg)
|
||||
public const int CounterBucketCount = 32;
|
||||
|
||||
public QuestionAttemptStore(MongoDatabaseProvider mongo)
|
||||
{
|
||||
var cs = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017";
|
||||
var dbName = cfg["MongoDB:DatabaseName"] ?? "GameDb";
|
||||
var client = new MongoClient(cs);
|
||||
var db = client.GetDatabase(dbName);
|
||||
_col = db.GetCollection<QuestionAttempt>("QuestionAttempts");
|
||||
_col = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
|
||||
|
||||
var firstAttemptIndex = Builders<QuestionAttempt>.IndexKeys
|
||||
.Ascending(a => a.QuestionExternalId)
|
||||
@@ -24,9 +24,17 @@ public class QuestionAttemptStore
|
||||
|
||||
var questionIndex = Builders<QuestionAttempt>.IndexKeys.Ascending(a => a.QuestionExternalId);
|
||||
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(questionIndex));
|
||||
|
||||
var createdIndex = Builders<QuestionAttempt>.IndexKeys.Ascending(a => a.CreatedUtc);
|
||||
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(createdIndex));
|
||||
}
|
||||
|
||||
public async Task<bool> RecordFirstAttemptAsync(string questionExternalId, string userId, string choiceId, bool isCorrect, bool isSkip = false)
|
||||
public async Task<QuestionAttemptRecordResult> RecordFirstAttemptAsync(
|
||||
string questionExternalId,
|
||||
string userId,
|
||||
string choiceId,
|
||||
bool isCorrect,
|
||||
bool isSkip = false)
|
||||
{
|
||||
var attempt = new QuestionAttempt
|
||||
{
|
||||
@@ -35,17 +43,22 @@ public class QuestionAttemptStore
|
||||
ChoiceId = choiceId,
|
||||
IsCorrect = isCorrect,
|
||||
IsSkip = isSkip,
|
||||
CounterBucket = GetCounterBucket(userId),
|
||||
RatingApplied = false,
|
||||
CreatedUtc = DateTime.UtcNow
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await _col.InsertOneAsync(attempt);
|
||||
return true;
|
||||
return new QuestionAttemptRecordResult { Inserted = true, Attempt = attempt };
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
return false;
|
||||
var existing = await _col
|
||||
.Find(a => a.QuestionExternalId == questionExternalId && a.UserId == userId)
|
||||
.FirstAsync();
|
||||
return new QuestionAttemptRecordResult { Inserted = false, Attempt = existing };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,22 +74,80 @@ public class QuestionAttemptStore
|
||||
if (ids.Count == 0)
|
||||
return [];
|
||||
|
||||
var attempts = await _col.Find(a => ids.Contains(a.QuestionExternalId) && !a.IsSkip).ToListAsync();
|
||||
var totals = await _col.Aggregate()
|
||||
.Match(a => ids.Contains(a.QuestionExternalId) && !a.IsSkip)
|
||||
.Group(
|
||||
attempt => new { attempt.QuestionExternalId, attempt.ChoiceId },
|
||||
group => new QuestionAttemptCounterTotal
|
||||
{
|
||||
Key = new QuestionChoiceCounterKey
|
||||
{
|
||||
QuestionExternalId = group.Key.QuestionExternalId,
|
||||
ChoiceId = group.Key.ChoiceId
|
||||
},
|
||||
Count = group.Sum(_ => 1L)
|
||||
})
|
||||
.ToListAsync();
|
||||
var output = new Dictionary<string, Dictionary<string, int>>();
|
||||
|
||||
foreach (var group in attempts.GroupBy(a => a.QuestionExternalId))
|
||||
foreach (var group in totals.GroupBy(total => total.Key.QuestionExternalId))
|
||||
{
|
||||
var total = group.Count();
|
||||
var total = group.Sum(item => item.Count);
|
||||
if (total == 0)
|
||||
continue;
|
||||
|
||||
output[group.Key] = group
|
||||
.GroupBy(a => a.ChoiceId)
|
||||
.ToDictionary(
|
||||
choiceGroup => choiceGroup.Key,
|
||||
choiceGroup => (int)Math.Round(choiceGroup.Count() * 100.0 / total));
|
||||
item => item.Key.ChoiceId,
|
||||
item => (int)Math.Round(item.Count * 100.0 / total));
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
public Task<List<string>> GetActiveQuestionIdsAsync(DateTime sinceUtc) =>
|
||||
_col.Distinct(
|
||||
attempt => attempt.QuestionExternalId,
|
||||
Builders<QuestionAttempt>.Filter.Gte(attempt => attempt.CreatedUtc, sinceUtc))
|
||||
.ToListAsync();
|
||||
|
||||
public IMongoCollection<QuestionAttempt> Collection => _col;
|
||||
|
||||
public async Task<bool> TryAcquireRatingLeaseAsync(string attemptId, string owner, TimeSpan duration)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var filter = Builders<QuestionAttempt>.Filter.And(
|
||||
Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.Id, attemptId),
|
||||
Builders<QuestionAttempt>.Filter.Exists(attempt => attempt.RatingApplied, true),
|
||||
Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.RatingApplied, false),
|
||||
Builders<QuestionAttempt>.Filter.Or(
|
||||
Builders<QuestionAttempt>.Filter.Lt(attempt => attempt.RatingLeaseUntilUtc, now),
|
||||
Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.RatingLeaseOwner, owner)));
|
||||
var update = Builders<QuestionAttempt>.Update
|
||||
.Set(attempt => attempt.RatingLeaseOwner, owner)
|
||||
.Set(attempt => attempt.RatingLeaseUntilUtc, now.Add(duration));
|
||||
var leased = await _col.FindOneAndUpdateAsync(
|
||||
filter,
|
||||
update,
|
||||
new FindOneAndUpdateOptions<QuestionAttempt> { ReturnDocument = ReturnDocument.After });
|
||||
return leased?.RatingLeaseOwner == owner;
|
||||
}
|
||||
|
||||
public Task MarkRatingAppliedAsync(string attemptId, string owner) =>
|
||||
_col.UpdateOneAsync(
|
||||
attempt => attempt.Id == attemptId && attempt.RatingLeaseOwner == owner,
|
||||
Builders<QuestionAttempt>.Update
|
||||
.Set(attempt => attempt.RatingApplied, true)
|
||||
.Set(attempt => attempt.RatingLeaseUntilUtc, DateTime.MinValue));
|
||||
|
||||
public Task ReleaseRatingLeaseAsync(string attemptId, string owner) =>
|
||||
_col.UpdateOneAsync(
|
||||
attempt => attempt.Id == attemptId && attempt.RatingLeaseOwner == owner && attempt.RatingApplied == false,
|
||||
Builders<QuestionAttempt>.Update.Set(attempt => attempt.RatingLeaseUntilUtc, DateTime.MinValue));
|
||||
|
||||
public static int GetCounterBucket(string userId)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(userId));
|
||||
return (int)(BitConverter.ToUInt32(hash, 0) % CounterBucketCount);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,9 @@ public class QuestionMediaStore
|
||||
{
|
||||
private readonly IMongoCollection<QuestionMediaAsset> _col;
|
||||
|
||||
public QuestionMediaStore(IConfiguration cfg)
|
||||
public QuestionMediaStore(MongoDatabaseProvider mongo)
|
||||
{
|
||||
var cs = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017";
|
||||
var dbName = cfg["MongoDB:DatabaseName"] ?? "GameDb";
|
||||
var client = new MongoClient(cs);
|
||||
var db = client.GetDatabase(dbName);
|
||||
_col = db.GetCollection<QuestionMediaAsset>("QuestionMediaAssets");
|
||||
_col = mongo.Database.GetCollection<QuestionMediaAsset>("QuestionMediaAssets");
|
||||
}
|
||||
|
||||
public async Task<QuestionMediaAsset> CreateAsync(string fileName, string contentType, byte[] bytes)
|
||||
|
||||
@@ -8,13 +8,9 @@ public class QuestionReportStore
|
||||
{
|
||||
private readonly IMongoCollection<QuestionReport> _reports;
|
||||
|
||||
public QuestionReportStore(IConfiguration cfg)
|
||||
public QuestionReportStore(MongoDatabaseProvider mongo)
|
||||
{
|
||||
var connectionString = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017";
|
||||
var databaseName = cfg["MongoDB:DatabaseName"] ?? "GameDb";
|
||||
var client = new MongoClient(connectionString);
|
||||
var database = client.GetDatabase(databaseName);
|
||||
_reports = database.GetCollection<QuestionReport>("QuestionReports");
|
||||
_reports = mongo.Database.GetCollection<QuestionReport>("QuestionReports");
|
||||
|
||||
_reports.Indexes.CreateMany(
|
||||
[
|
||||
|
||||
@@ -8,13 +8,9 @@ public class QuestionStore
|
||||
{
|
||||
private readonly IMongoCollection<Question> _col;
|
||||
|
||||
public QuestionStore(IConfiguration cfg)
|
||||
public QuestionStore(MongoDatabaseProvider mongo)
|
||||
{
|
||||
var cs = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017";
|
||||
var dbName = cfg["MongoDB:DatabaseName"] ?? "GameDb";
|
||||
var client = new MongoClient(cs);
|
||||
var db = client.GetDatabase(dbName);
|
||||
_col = db.GetCollection<Question>("Questions");
|
||||
_col = mongo.Database.GetCollection<Question>("Questions");
|
||||
|
||||
var externalIdIndex = Builders<Question>.IndexKeys.Ascending(q => q.ExternalId);
|
||||
_col.Indexes.CreateOne(new CreateIndexModel<Question>(externalIdIndex, new CreateIndexOptions { Unique = true }));
|
||||
|
||||
@@ -11,14 +11,10 @@ public class RatingStore
|
||||
private readonly IMongoCollection<UserRating> _users;
|
||||
private readonly IMongoCollection<QuestionRating> _questions;
|
||||
|
||||
public RatingStore(IConfiguration cfg)
|
||||
public RatingStore(MongoDatabaseProvider mongo)
|
||||
{
|
||||
var cs = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017";
|
||||
var dbName = cfg["MongoDB:DatabaseName"] ?? "GameDb";
|
||||
var client = new MongoClient(cs);
|
||||
var db = client.GetDatabase(dbName);
|
||||
_users = db.GetCollection<UserRating>("UserRatings");
|
||||
_questions = db.GetCollection<QuestionRating>("QuestionRatings");
|
||||
_users = mongo.Database.GetCollection<UserRating>("UserRatings");
|
||||
_questions = mongo.Database.GetCollection<QuestionRating>("QuestionRatings");
|
||||
|
||||
_users.Indexes.CreateOne(new CreateIndexModel<UserRating>(
|
||||
Builders<UserRating>.IndexKeys.Ascending(r => r.UserId),
|
||||
@@ -89,64 +85,107 @@ public class RatingStore
|
||||
var categoryDelta = (int)Math.Round(userK * (actual - categoryExpected));
|
||||
var questionDelta = (int)Math.Round(questionK * (overallExpected - actual));
|
||||
|
||||
userRating.OverallElo = Math.Max(MinElo, oldOverall + overallDelta);
|
||||
userRating.CategoryElos[category] = Math.Max(MinElo, oldCategory + categoryDelta);
|
||||
userRating.RatedQuestionCount++;
|
||||
userRating.UpdatedUtc = DateTime.UtcNow;
|
||||
var now = DateTime.UtcNow;
|
||||
var categoryPath = $"CategoryElos.{category}";
|
||||
var userUpdate = Builders<UserRating>.Update
|
||||
.Inc(rating => rating.OverallElo, overallDelta)
|
||||
.Inc(categoryPath, categoryDelta)
|
||||
.Inc(rating => rating.RatedQuestionCount, 1)
|
||||
.Set(rating => rating.UpdatedUtc, now);
|
||||
await _users.UpdateOneAsync(rating => rating.UserId == userId, userUpdate);
|
||||
await _users.UpdateOneAsync(
|
||||
rating => rating.UserId == userId,
|
||||
Builders<UserRating>.Update
|
||||
.Max(rating => rating.OverallElo, MinElo)
|
||||
.Max(categoryPath, MinElo));
|
||||
|
||||
questionRating.Elo = Math.Max(MinElo, oldQuestion + questionDelta);
|
||||
questionRating.AttemptCount++;
|
||||
questionRating.UpdatedUtc = DateTime.UtcNow;
|
||||
var questionUpdate = Builders<QuestionRating>.Update
|
||||
.Inc(rating => rating.Elo, questionDelta)
|
||||
.Inc(rating => rating.AttemptCount, 1)
|
||||
.Set(rating => rating.UpdatedUtc, now);
|
||||
await _questions.UpdateOneAsync(rating => rating.QuestionExternalId == question.ExternalId, questionUpdate);
|
||||
await _questions.UpdateOneAsync(
|
||||
rating => rating.QuestionExternalId == question.ExternalId,
|
||||
Builders<QuestionRating>.Update.Max(rating => rating.Elo, MinElo));
|
||||
|
||||
await _users.ReplaceOneAsync(r => r.UserId == userRating.UserId, userRating, new ReplaceOptions { IsUpsert = true });
|
||||
await _questions.ReplaceOneAsync(r => r.QuestionExternalId == questionRating.QuestionExternalId, questionRating, new ReplaceOptions { IsUpsert = true });
|
||||
var updatedUser = await _users.Find(rating => rating.UserId == userId).FirstAsync();
|
||||
var updatedQuestion = await _questions.Find(rating => rating.QuestionExternalId == question.ExternalId).FirstAsync();
|
||||
|
||||
return new RatingChangeResponse
|
||||
{
|
||||
Rated = true,
|
||||
Category = category,
|
||||
UserOverallElo = userRating.OverallElo,
|
||||
UserOverallDelta = userRating.OverallElo - oldOverall,
|
||||
UserCategoryElo = userRating.CategoryElos[category],
|
||||
UserCategoryDelta = userRating.CategoryElos[category] - oldCategory,
|
||||
QuestionElo = questionRating.Elo,
|
||||
QuestionEloDelta = questionRating.Elo - oldQuestion
|
||||
UserOverallElo = updatedUser.OverallElo,
|
||||
UserOverallDelta = overallDelta,
|
||||
UserCategoryElo = updatedUser.CategoryElos.GetValueOrDefault(category, BaseElo),
|
||||
UserCategoryDelta = categoryDelta,
|
||||
QuestionElo = updatedQuestion.Elo,
|
||||
QuestionEloDelta = questionDelta
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<UserRating> GetOrCreateUserRatingAsync(string userId)
|
||||
{
|
||||
var rating = await _users.Find(r => r.UserId == userId).FirstOrDefaultAsync();
|
||||
if (rating is null)
|
||||
var create = Builders<UserRating>.Update
|
||||
.SetOnInsert(rating => rating.UserId, userId)
|
||||
.SetOnInsert(rating => rating.OverallElo, BaseElo)
|
||||
.SetOnInsert(rating => rating.RatedQuestionCount, 0)
|
||||
.SetOnInsert(rating => rating.CategoryElos, DefaultCategoryElos())
|
||||
.SetOnInsert(rating => rating.UpdatedUtc, DateTime.UtcNow);
|
||||
UserRating rating;
|
||||
try
|
||||
{
|
||||
rating = new UserRating { UserId = userId, CategoryElos = DefaultCategoryElos() };
|
||||
await _users.InsertOneAsync(rating);
|
||||
return rating;
|
||||
rating = await _users.FindOneAndUpdateAsync(
|
||||
item => item.UserId == userId,
|
||||
create,
|
||||
new FindOneAndUpdateOptions<UserRating>
|
||||
{
|
||||
IsUpsert = true,
|
||||
ReturnDocument = ReturnDocument.After
|
||||
});
|
||||
}
|
||||
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
rating = await _users.Find(item => item.UserId == userId).FirstAsync();
|
||||
}
|
||||
|
||||
var changed = false;
|
||||
foreach (var category in RatingCategory.Categories)
|
||||
var missingCategories = RatingCategory.Categories
|
||||
.Where(category => !rating.CategoryElos.ContainsKey(category))
|
||||
.ToList();
|
||||
if (missingCategories.Count > 0)
|
||||
{
|
||||
if (!rating.CategoryElos.ContainsKey(category))
|
||||
{
|
||||
rating.CategoryElos[category] = BaseElo;
|
||||
changed = true;
|
||||
}
|
||||
var updates = missingCategories
|
||||
.Select(category => Builders<UserRating>.Update.Set($"CategoryElos.{category}", BaseElo))
|
||||
.ToList();
|
||||
if (updates.Count > 0)
|
||||
await _users.UpdateOneAsync(item => item.UserId == userId, Builders<UserRating>.Update.Combine(updates));
|
||||
rating = await _users.Find(item => item.UserId == userId).FirstAsync();
|
||||
}
|
||||
if (changed)
|
||||
await _users.ReplaceOneAsync(r => r.UserId == userId, rating);
|
||||
return rating;
|
||||
}
|
||||
|
||||
private async Task<QuestionRating> GetOrCreateQuestionRatingAsync(string questionExternalId)
|
||||
{
|
||||
var rating = await _questions.Find(r => r.QuestionExternalId == questionExternalId).FirstOrDefaultAsync();
|
||||
if (rating is not null)
|
||||
return rating;
|
||||
|
||||
rating = new QuestionRating { QuestionExternalId = questionExternalId };
|
||||
await _questions.InsertOneAsync(rating);
|
||||
return rating;
|
||||
var create = Builders<QuestionRating>.Update
|
||||
.SetOnInsert(rating => rating.QuestionExternalId, questionExternalId)
|
||||
.SetOnInsert(rating => rating.Elo, BaseElo)
|
||||
.SetOnInsert(rating => rating.AttemptCount, 0)
|
||||
.SetOnInsert(rating => rating.UpdatedUtc, DateTime.UtcNow);
|
||||
try
|
||||
{
|
||||
return await _questions.FindOneAndUpdateAsync(
|
||||
rating => rating.QuestionExternalId == questionExternalId,
|
||||
create,
|
||||
new FindOneAndUpdateOptions<QuestionRating>
|
||||
{
|
||||
IsUpsert = true,
|
||||
ReturnDocument = ReturnDocument.After
|
||||
});
|
||||
}
|
||||
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
return await _questions.Find(rating => rating.QuestionExternalId == questionExternalId).FirstAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, int> DefaultCategoryElos() =>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Text;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed class ServiceMetrics
|
||||
{
|
||||
private long _attemptsCreated;
|
||||
private long _attemptDuplicates;
|
||||
private long _counterFailures;
|
||||
private long _ratingFailures;
|
||||
private long _reconciliations;
|
||||
private long _lastReconciliationUnixSeconds;
|
||||
private long _httpRequests;
|
||||
private long _httpDurationMicroseconds;
|
||||
|
||||
public void AttemptCreated() => Interlocked.Increment(ref _attemptsCreated);
|
||||
public void AttemptDuplicate() => Interlocked.Increment(ref _attemptDuplicates);
|
||||
public void CounterFailure() => Interlocked.Increment(ref _counterFailures);
|
||||
public void RatingFailure() => Interlocked.Increment(ref _ratingFailures);
|
||||
public void Reconciled()
|
||||
{
|
||||
Interlocked.Increment(ref _reconciliations);
|
||||
Interlocked.Exchange(ref _lastReconciliationUnixSeconds, DateTimeOffset.UtcNow.ToUnixTimeSeconds());
|
||||
}
|
||||
public void HttpRequest(TimeSpan duration)
|
||||
{
|
||||
Interlocked.Increment(ref _httpRequests);
|
||||
Interlocked.Add(ref _httpDurationMicroseconds, (long)(duration.TotalMilliseconds * 1000));
|
||||
}
|
||||
|
||||
public string RenderPrometheus()
|
||||
{
|
||||
var output = new StringBuilder();
|
||||
AppendCounter(output, "step3_question_attempts_created_total", Interlocked.Read(ref _attemptsCreated));
|
||||
AppendCounter(output, "step3_question_attempt_duplicates_total", Interlocked.Read(ref _attemptDuplicates));
|
||||
AppendCounter(output, "step3_answer_counter_failures_total", Interlocked.Read(ref _counterFailures));
|
||||
AppendCounter(output, "step3_rating_failures_total", Interlocked.Read(ref _ratingFailures));
|
||||
AppendCounter(output, "step3_answer_reconciliations_total", Interlocked.Read(ref _reconciliations));
|
||||
AppendCounter(output, "step3_http_requests_total", Interlocked.Read(ref _httpRequests));
|
||||
output.AppendLine("# TYPE step3_http_request_duration_seconds summary");
|
||||
output.Append("step3_http_request_duration_seconds_count ")
|
||||
.Append(Interlocked.Read(ref _httpRequests))
|
||||
.AppendLine();
|
||||
output.Append("step3_http_request_duration_seconds_sum ")
|
||||
.Append((Interlocked.Read(ref _httpDurationMicroseconds) / 1_000_000.0).ToString("0.######", System.Globalization.CultureInfo.InvariantCulture))
|
||||
.AppendLine();
|
||||
output.AppendLine("# TYPE step3_answer_last_reconciliation_unixtime gauge");
|
||||
output.Append("step3_answer_last_reconciliation_unixtime ")
|
||||
.Append(Interlocked.Read(ref _lastReconciliationUnixSeconds))
|
||||
.AppendLine();
|
||||
return output.ToString();
|
||||
}
|
||||
|
||||
private static void AppendCounter(StringBuilder output, string name, long value)
|
||||
{
|
||||
output.Append("# TYPE ").Append(name).AppendLine(" counter");
|
||||
output.Append(name).Append(' ').Append(value).AppendLine();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user