| | | 1 | | using ArturRios.Heimdall.Data.Configuration; |
| | | 2 | | using ArturRios.Heimdall.Domain.Entities; |
| | | 3 | | using ArturRios.Heimdall.Domain.Persistence; |
| | | 4 | | using Microsoft.EntityFrameworkCore; |
| | | 5 | | |
| | | 6 | | namespace ArturRios.Heimdall.Data.Persistence; |
| | | 7 | | |
| | | 8 | | /// <summary> |
| | | 9 | | /// <see cref="IAttemptNoticeBudgets" /> on PostgreSQL: one <c>INSERT … ON CONFLICT … DO UPDATE … |
| | | 10 | | /// WHERE</c> per notice. |
| | | 11 | | /// </summary> |
| | | 12 | | /// <remarks> |
| | | 13 | | /// <para> |
| | | 14 | | /// An address never charged has no row, so the first charge inserts one and every later charge |
| | | 15 | | /// updates it. Reading first and then choosing would race: two first notices would both insert, |
| | | 16 | | /// and two notices at the limit would both read "two sent". The upsert leaves it to the |
| | | 17 | | /// database. A new address is inserted with one notice; a known one is updated only where the |
| | | 18 | | /// <c>WHERE</c> admits it — its window has ended (it starts again at one) or it has had fewer |
| | | 19 | | /// than the maximum — and every right-hand side reads the row as it was. |
| | | 20 | | /// </para> |
| | | 21 | | /// <para> |
| | | 22 | | /// The affected-row count is the answer. A refused update affects no row; an insert or an |
| | | 23 | | /// admitted update affects one. A competing upsert on the same address waits for the first to |
| | | 24 | | /// commit and then evaluates the <c>WHERE</c> against the row as that one left it, as the |
| | | 25 | | /// conditional updates of <see cref="AtomicWrites" /> do, so no burst admits more than the |
| | | 26 | | /// budget has left. |
| | | 27 | | /// </para> |
| | | 28 | | /// </remarks> |
| | 42 | 29 | | public sealed class AttemptNoticeBudgets(AppDbContext context) : IAttemptNoticeBudgets |
| | | 30 | | { |
| | | 31 | | public async Task<bool> TryChargeAsync(string addressHash, int maxNotices, DateTime now, DateTime windowEndsAt) |
| | | 32 | | { |
| | | 33 | | // The table name comes from the model, never from input, so building it into the SQL is safe; |
| | | 34 | | // the values stay parameters. |
| | 27 | 35 | | var table = context.Model.FindEntityType(typeof(AttemptNoticeBudget))!.GetSchemaQualifiedTableName(); |
| | | 36 | | |
| | 27 | 37 | | var sql = |
| | 27 | 38 | | $$""" |
| | 27 | 39 | | INSERT INTO {{table}} AS budget (address_hash, notices_sent, window_ends_at) |
| | 27 | 40 | | VALUES ({0}, 1, {2}) |
| | 27 | 41 | | ON CONFLICT (address_hash) DO UPDATE SET |
| | 27 | 42 | | notices_sent = CASE WHEN budget.window_ends_at <= {1} THEN 1 ELSE budget.notices_sent + 1 END, |
| | 27 | 43 | | window_ends_at = CASE WHEN budget.window_ends_at <= {1} THEN EXCLUDED.window_ends_at |
| | 27 | 44 | | ELSE budget.window_ends_at END |
| | 27 | 45 | | WHERE budget.window_ends_at <= {1} OR budget.notices_sent < {3} |
| | 27 | 46 | | """; |
| | | 47 | | |
| | 27 | 48 | | return await context.Database.ExecuteSqlRawAsync(sql, addressHash, now, windowEndsAt, maxNotices) == 1; |
| | 27 | 49 | | } |
| | | 50 | | } |