Scale unique answer tracking

This commit is contained in:
hz
2026-07-13 02:11:54 -05:00
parent 700da4e415
commit f2c59388eb
23 changed files with 1064 additions and 131 deletions
@@ -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);
}
}