feat(fundamentals): add KeyedLockPool for concurrent scraping synchronization and update MQTT RPC handlers

This commit is contained in:
2026-08-24 21:35:43 +02:00
parent 8112598602
commit 600ccf299e
6 changed files with 136 additions and 69 deletions
@@ -35,7 +35,7 @@ public interface IFundamentalsDbService
public class FundamentalsDbService : IFundamentalsDbService
{
private static readonly ConcurrentDictionary<string, SemaphoreSlim> IsinLocks = new();
private static readonly KeyedLockPool LockPool = new();
private readonly IServiceScopeFactory _scopeFactory;
private readonly IYahooFinanceScraper _scraper;
@@ -65,15 +65,13 @@ public class FundamentalsDbService : IFundamentalsDbService
var cleanIsin = isin.Trim().ToUpperInvariant();
var requestedTicker = ticker?.Trim().ToUpperInvariant();
var isinLock = IsinLocks.GetOrAdd(cleanIsin, _ => new SemaphoreSlim(1, 1));
await isinLock.WaitAsync(cancellationToken);
try
using (await LockPool.LockAsync(cleanIsin, cancellationToken))
{
using var scope = _scopeFactory.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<FundamentalsDbContext>();
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
// 1. Dynamic Settings lesen
bool allowForceRefresh =
await settingsService.GetSettingAsync(SettingKeys.AllowForceRefresh, cancellationToken);
@@ -380,6 +378,46 @@ public class FundamentalsDbService : IFundamentalsDbService
assetData.AssetEvents.Add(newEvent);
}
}
// Structured Trade Republic dividend data (ExpectedDividend + historical Dividends) carries
// a real ExDate per entry - a far more reliable "this is a dividend" signal than matching
// the generic Events/PastEvents feed's free-text Type/Title strings above, whose exact
// wording for dividend entries is not guaranteed. The canonical "Dividend" Type here is
// fully controlled by this codebase (not guessed from TR's free text), so
// AssetFundamentalsDto.DaysToNextExDividend can match on it reliably (Rules.md §4).
var trDividendList = new List<TradeRepublicDividendDto>();
if (trDetails.ExpectedDividend != null) trDividendList.Add(trDetails.ExpectedDividend);
if (trDetails.Dividends != null) trDividendList.AddRange(trDetails.Dividends);
foreach (var div in trDividendList)
{
if (string.IsNullOrWhiteSpace(div.ExDate) ||
!DateTime.TryParse(div.ExDate, System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out var exDate))
{
continue;
}
bool isDuplicateDividend = assetData.AssetEvents.Any(e =>
e.Date.Date == exDate.Date && string.Equals(e.Type, "Dividend", StringComparison.OrdinalIgnoreCase));
if (!isDuplicateDividend)
{
var newDividendEvent = new AssetEventEntity
{
AssetDataIsin = cleanIsin,
Ticker = new TickerEntity
{
Ticker = yahooPrimaryTicker.Ticker,
Exchange = yahooPrimaryTicker.Exchange ?? "Unknown"
},
Type = "Dividend",
Date = exDate.Date
};
context.AssetEvents.Add(newDividendEvent);
assetData.AssetEvents.Add(newDividendEvent);
}
}
}
// --- Process Modules DTO (Executives & Fundamental Data) ---
@@ -579,12 +617,9 @@ public class FundamentalsDbService : IFundamentalsDbService
return MapToDto(assetData, fundamentalData, executivesList, eventsList);
}
finally
{
isinLock.Release();
}
}
/// <inheritdoc />
public async Task<List<CorporateEventDto>> GetAllEventsAsync(CancellationToken cancellationToken = default)
{
@@ -0,0 +1,66 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace FinlyticFundamentals.Services;
public class KeyedLockPool
{
private readonly ConcurrentDictionary<string, RefCountedSemaphore> _semaphores = new(StringComparer.OrdinalIgnoreCase);
public async Task<IDisposable> LockAsync(string key, CancellationToken cancellationToken = default)
{
var item = _semaphores.AddOrUpdate(
key,
_ => new RefCountedSemaphore(),
(_, existing) =>
{
Interlocked.Increment(ref existing.RefCount);
return existing;
});
await item.Semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
return new Releaser(this, key, item);
}
private void Release(string key, RefCountedSemaphore item)
{
item.Semaphore.Release();
if (Interlocked.Decrement(ref item.RefCount) <= 0)
{
_semaphores.TryRemove(new KeyValuePair<string, RefCountedSemaphore>(key, item));
}
}
private sealed class RefCountedSemaphore
{
public int RefCount = 1;
public readonly SemaphoreSlim Semaphore = new(1, 1);
}
private sealed class Releaser : IDisposable
{
private readonly KeyedLockPool _pool;
private readonly string _key;
private readonly RefCountedSemaphore _item;
private bool _disposed;
public Releaser(KeyedLockPool pool, string key, RefCountedSemaphore item)
{
_pool = pool;
_key = key;
_item = item;
}
public void Dispose()
{
if (!_disposed)
{
_disposed = true;
_pool.Release(_key, _item);
}
}
}
}