622ee940f4
Proxy chains any inbound XFF with the connection IP before relaying upstream; UseForwardedHeaders resolves it to the limiter's partition key. Documents the EventRepository first-play counter race (unique index is the backstop).
128 lines
5.3 KiB
C#
128 lines
5.3 KiB
C#
using DeepDrftData.Data;
|
|
using DeepDrftModels.Entities;
|
|
using DeepDrftModels.Enums;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace DeepDrftData.Repositories;
|
|
|
|
/// <summary>
|
|
/// Data access for the Phase 16 anonymous telemetry tables (all SQL — the FileDatabase vault is not
|
|
/// involved). Owns the append-only writes to <c>play_event</c> / <c>share_event</c> and the
|
|
/// incremental-on-write bump of the <c>play_counter</c> rollup (D6). Server-side release resolution
|
|
/// (§2.3 / D4) lives here: a play event carries only the track key, and this repository joins
|
|
/// track→release at write time and stamps the release id on the row.
|
|
///
|
|
/// <para>
|
|
/// Unlike <see cref="TrackRepository"/> these entities are not <c>BaseEntity</c>/<c>IEntity</c> (no
|
|
/// soft-delete lifecycle), so this is a plain context-backed repository rather than an extension of the
|
|
/// BlazorBlocks <c>Repository<></c> base. It holds the same scoped <see cref="DeepDrftContext"/>
|
|
/// the rest of the SQL layer uses, never a service locator.
|
|
/// </para>
|
|
/// </summary>
|
|
public class EventRepository
|
|
{
|
|
private readonly DeepDrftContext _context;
|
|
|
|
public EventRepository(DeepDrftContext context)
|
|
{
|
|
_context = context;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Append one play event and bump the track's counter in a single transaction (D6). The release id
|
|
/// is resolved here from the track key (§2.3 / D4): a live track contributes its release id (null
|
|
/// for a loose track); an unknown key records the event with a null release and no counter bump
|
|
/// (there is no track to roll up against). Returns true when the event was written.
|
|
/// </summary>
|
|
public async Task<bool> RecordPlayAsync(
|
|
string trackEntryKey, PlayBucket bucket, string? anonId, CancellationToken ct = default)
|
|
{
|
|
// Resolve the track→release link server-side. Soft-deleted tracks resolve to null so a play of
|
|
// a since-removed track still logs (with no counter bump) rather than throwing.
|
|
var track = await _context.Tracks
|
|
.Where(t => t.EntryKey == trackEntryKey && !t.IsDeleted)
|
|
.Select(t => new { t.Id, t.ReleaseId })
|
|
.FirstOrDefaultAsync(ct);
|
|
|
|
// The append and the counter bump must commit together — wrap them in one transaction so a
|
|
// counter that drifts from the log is impossible. Reuse an ambient transaction if the caller
|
|
// already opened one.
|
|
var ownsTransaction = _context.Database.CurrentTransaction is null;
|
|
var transaction = ownsTransaction
|
|
? await _context.Database.BeginTransactionAsync(ct)
|
|
: null;
|
|
try
|
|
{
|
|
_context.PlayEvents.Add(new PlayEvent
|
|
{
|
|
TrackEntryKey = trackEntryKey,
|
|
ReleaseId = track?.ReleaseId,
|
|
Bucket = bucket,
|
|
AnonId = anonId,
|
|
CreatedAt = DateTime.UtcNow,
|
|
});
|
|
|
|
if (track is not null)
|
|
await BumpCounterAsync(track.Id, bucket, ct);
|
|
|
|
await _context.SaveChangesAsync(ct);
|
|
if (transaction is not null)
|
|
await transaction.CommitAsync(ct);
|
|
return true;
|
|
}
|
|
catch
|
|
{
|
|
if (transaction is not null)
|
|
await transaction.RollbackAsync(ct);
|
|
throw;
|
|
}
|
|
finally
|
|
{
|
|
if (transaction is not null)
|
|
await transaction.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
/// <summary>Append one share event. No rollup table for shares in wave 16.1 — a plain insert.</summary>
|
|
public async Task RecordShareAsync(
|
|
ShareTargetType targetType, string targetKey, ShareChannel channel, string? anonId,
|
|
CancellationToken ct = default)
|
|
{
|
|
_context.ShareEvents.Add(new ShareEvent
|
|
{
|
|
TargetType = targetType,
|
|
TargetKey = targetKey,
|
|
Channel = channel,
|
|
AnonId = anonId,
|
|
CreatedAt = DateTime.UtcNow,
|
|
});
|
|
await _context.SaveChangesAsync(ct);
|
|
}
|
|
|
|
// Bump the matching bucket column on the track's counter row, creating the row on first play. The
|
|
// row is added to the change tracker but not saved here — the caller's SaveChanges/commit persists
|
|
// it inside the same transaction as the event append.
|
|
//
|
|
// Race note: two concurrent first-plays of the same track can both reach this method, find no
|
|
// counter row, and both Add a new PlayCounter. The second SaveChanges will hit the unique index on
|
|
// (track_id) and throw, causing the outer transaction to roll back and the event to be dropped —
|
|
// no crash, no counter corruption. At the expected play volume this is an acceptable loss; the
|
|
// unique index is the integrity backstop.
|
|
private async Task BumpCounterAsync(long trackId, PlayBucket bucket, CancellationToken ct)
|
|
{
|
|
var counter = await _context.PlayCounters.FirstOrDefaultAsync(c => c.TrackId == trackId, ct);
|
|
if (counter is null)
|
|
{
|
|
counter = new PlayCounter { TrackId = trackId };
|
|
_context.PlayCounters.Add(counter);
|
|
}
|
|
|
|
switch (bucket)
|
|
{
|
|
case PlayBucket.Partial: counter.PartialCount++; break;
|
|
case PlayBucket.Sampled: counter.SampledCount++; break;
|
|
case PlayBucket.Complete: counter.CompleteCount++; break;
|
|
}
|
|
}
|
|
}
|