using System.Collections.Concurrent; using BlueLaminate.Core.CsMoney; using BlueLaminate.EFCore.Data; using Microsoft.EntityFrameworkCore; namespace BlueLaminate.C2; /// /// Hands out scrape jobs to workers, one skin+wear at a time, driven directly by the /// catalogue's per-band checkpoints (SkinCondition.ListingsSweptAt) rather than /// a pre-built queue. Each claim picks the stalest band (never-swept first), leases it /// in memory so two workers can't get the same one, and builds a free-text search. On /// completion the ingest stamps ListingsSweptAt, so the band drops to the back — /// the sweep loops the whole catalogue continuously and resumes cleanly after restarts. /// public sealed class JobQueue { // A leased condition can't be re-handed-out until released or the lease expires // (so a crashed worker's band returns to the pool instead of stalling forever). private static readonly TimeSpan LeaseTtl = TimeSpan.FromMinutes(15); private const int CandidateBatch = 100; private readonly SemaphoreSlim _gate = new(1, 1); private readonly ConcurrentDictionary _leases = new(); // conditionId -> leasedAt private readonly ConcurrentDictionary _inFlight = new(); // jobId -> mapping public async Task ClaimNextAsync(SkinTrackerDbContext db, int maxPages, CancellationToken ct) { await _gate.WaitAsync(ct); try { // Reclaim expired leases first. var cutoff = DateTimeOffset.UtcNow - LeaseTtl; foreach (var (cid, at) in _leases) { if (at < cutoff) { _leases.TryRemove(cid, out _); } } // Stalest bands first (never-swept null sorts before any timestamp). var candidates = await db.SkinConditions .OrderBy(c => c.ListingsSweptAt.HasValue) .ThenBy(c => c.ListingsSweptAt) .Select(c => new Candidate( c.Id, c.SkinId, c.Skin.Weapon.Name, c.Skin.Name, c.Condition)) .Take(CandidateBatch) .ToListAsync(ct); var pick = candidates.FirstOrDefault(c => !_leases.ContainsKey(c.ConditionId)); if (pick is null) { return null; // everything in the stalest batch is already in flight } _leases[pick.ConditionId] = DateTimeOffset.UtcNow; var jobId = Guid.NewGuid().ToString("N"); _inFlight[jobId] = new JobMapping(pick.SkinId, pick.ConditionId); var code = Wear.ToCode(pick.Condition) ?? pick.Condition; var search = $"{pick.Weapon} {pick.SkinName} {code}".Trim(); return new ScrapeJobDto(jobId, pick.SkinId, pick.ConditionId, search, maxPages); } finally { _gate.Release(); } } /// Resolve a posted job to its skin+condition and release its lease. public JobMapping? Complete(string jobId) { if (_inFlight.TryRemove(jobId, out var mapping)) { _leases.TryRemove(mapping.ConditionId, out _); return mapping; } return null; } public int InFlight => _inFlight.Count; public sealed record JobMapping(int SkinId, int ConditionId); private sealed record Candidate(int ConditionId, int SkinId, string Weapon, string SkinName, string Condition); }