using System.Collections.Concurrent; using System.Threading.Channels; using Microsoft.EntityFrameworkCore; using Nuuru.Server.Data; using Nuuru.Server.Services.BBCode; using static Nuuru.Server.Data.AvatarLookupExtensions; namespace Nuuru.Server.Services; public interface IForumPostHtmlEnrichmentScheduler { void Enqueue(int postId); } public sealed class ForumPostHtmlEnrichmentWorker : BackgroundService, IForumPostHtmlEnrichmentScheduler { private readonly Channel _queue = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }); private readonly ConcurrentDictionary _queuedOrRunning = new(); private readonly ConcurrentDictionary _rerunRequested = new(); private readonly ConcurrentDictionary _locks = new(); private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; public ForumPostHtmlEnrichmentWorker( IServiceScopeFactory scopeFactory, ILogger logger) { _scopeFactory = scopeFactory; _logger = logger; } public void Enqueue(int postId) { if (!_queuedOrRunning.TryAdd(postId, 0)) { _rerunRequested[postId] = 0; return; } if (!_queue.Writer.TryWrite(postId)) _queuedOrRunning.TryRemove(postId, out _); } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await foreach (var postId in _queue.Reader.ReadAllAsync(stoppingToken)) { try { await ProcessAsync(postId, stoppingToken); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; } catch (Exception ex) { _logger.LogError(ex, "Deferred forum post HTML enrichment failed for post {PostId}", postId); } finally { if (_rerunRequested.TryRemove(postId, out _)) { if (!_queue.Writer.TryWrite(postId)) _queuedOrRunning.TryRemove(postId, out _); } else { _queuedOrRunning.TryRemove(postId, out _); } } } } private async Task ProcessAsync(int postId, CancellationToken cancellationToken) { var gate = _locks.GetOrAdd(postId, static _ => new SemaphoreSlim(1, 1)); await gate.WaitAsync(cancellationToken); try { string originalRaw; HtmlEnrichmentContext enrichmentContext; HtmlEnrichmentResult enriched; await using (var readScope = _scopeFactory.CreateAsyncScope()) { var readContext = readScope.ServiceProvider.GetRequiredService(); var bbCodeService = readScope.ServiceProvider.GetRequiredService(); var htmlEnrichmentService = readScope.ServiceProvider.GetRequiredService(); var post = await readContext.ForumPosts.FirstOrDefaultAsync(p => p.Id == postId, cancellationToken); if (post == null) return; enrichmentContext = HtmlEnrichmentContext.ForForumPost(post.Id); if (!htmlEnrichmentService.NeedsEnrichment(post.ContentHtml, post.ContentHtmlVersion, enrichmentContext)) return; originalRaw = post.ContentRaw; var parseResult = bbCodeService.ParseWithMentions(post.ContentRaw, BBCodeContext.Forum, readContext.CreateAvatarLookup()); enriched = await htmlEnrichmentService.EnrichAsync(parseResult.Html, enrichmentContext, cancellationToken); } await using var writeScope = _scopeFactory.CreateAsyncScope(); var writeContext = writeScope.ServiceProvider.GetRequiredService(); var writeHtmlEnrichmentService = writeScope.ServiceProvider.GetRequiredService(); var currentPost = await writeContext.ForumPosts.FirstOrDefaultAsync(p => p.Id == postId, cancellationToken); if (currentPost == null) return; if (!string.Equals(currentPost.ContentRaw, originalRaw, StringComparison.Ordinal)) { _rerunRequested[postId] = 0; return; } if (!writeHtmlEnrichmentService.NeedsEnrichment(currentPost.ContentHtml, currentPost.ContentHtmlVersion, enrichmentContext)) return; currentPost.ContentHtml = enriched.Html; currentPost.ContentHtmlVersion = enriched.Version; await writeContext.SaveChangesAsync(cancellationToken); } finally { gate.Release(); } } }