diff --git a/FinlyticAssets/Migrations/20260815184053_UpdateDynamicSettingsUniqueIndex.cs b/FinlyticAssets/Migrations/20260815184053_UpdateDynamicSettingsUniqueIndex.cs
index 025e9a2..e21f173 100644
--- a/FinlyticAssets/Migrations/20260815184053_UpdateDynamicSettingsUniqueIndex.cs
+++ b/FinlyticAssets/Migrations/20260815184053_UpdateDynamicSettingsUniqueIndex.cs
@@ -1,4 +1,4 @@
-using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -10,6 +10,11 @@ namespace FinlyticAssets.Migrations
///
protected override void Up(MigrationBuilder migrationBuilder)
{
+ migrationBuilder.Sql(@"
+ DELETE FROM ""DynamicSettings"" a USING ""DynamicSettings"" b
+ WHERE a.""Key"" = b.""Key"" AND a.""Id"" < b.""Id"";
+ ");
+
migrationBuilder.DropIndex(
name: "IX_DynamicSettings_Key",
table: "DynamicSettings");
diff --git a/FinlyticAssets/Services/AssetsIndexService.cs b/FinlyticAssets/Services/AssetsIndexService.cs
index ffebb85..cefff25 100644
--- a/FinlyticAssets/Services/AssetsIndexService.cs
+++ b/FinlyticAssets/Services/AssetsIndexService.cs
@@ -36,6 +36,8 @@ public class AssetsIndexService : IAssetsIndexService
private readonly IAssetsDbService _assetsDbService;
private static readonly HttpClient _httpClient = new();
+ private static readonly SemaphoreSlim _fileLock = new(1, 1);
+
public AssetsIndexService(IFinlyticLogger finlyticLogger, IAssetsDbService assetsDbService)
{
_finlyticLogger = finlyticLogger;
@@ -70,10 +72,19 @@ public class AssetsIndexService : IAssetsIndexService
Directory.CreateDirectory(directoryPath);
}
- using (var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, 4096,
- useAsync: true))
+ await _fileLock.WaitAsync(cancellationToken);
+ try
{
- await JsonSerializer.SerializeAsync(fileStream, indexAssets, cancellationToken: cancellationToken);
+ var tempFilePath = Path.Combine(directoryPath, $"index_{Guid.NewGuid():N}.tmp");
+ using (var fileStream = new FileStream(tempFilePath, FileMode.Create, FileAccess.Write, FileShare.ReadWrite, 4096, useAsync: true))
+ {
+ await JsonSerializer.SerializeAsync(fileStream, indexAssets, cancellationToken: cancellationToken);
+ }
+ File.Move(tempFilePath, filePath, overwrite: true);
+ }
+ finally
+ {
+ _fileLock.Release();
}
await _finlyticLogger.LogInfoAsync(SettingKeys.AssetsChannel, "[AssetsIndexService] Successfully recreated asset index file with {Count} entries pointing to local logos at {Path}",
@@ -81,18 +92,15 @@ public class AssetsIndexService : IAssetsIndexService
}
catch (IOException ex)
{
- await _finlyticLogger.LogErrorAsync(SettingKeys.AssetsChannel, ex, "[AssetsIndexService] Disk I/O error occurred while writing the asset index file.");
- throw;
+ await _finlyticLogger.LogWarningAsync(SettingKeys.AssetsChannel, ex, "[AssetsIndexService] Concurrent file access while writing asset index file. Skipping cycle.");
}
catch (JsonException ex)
{
await _finlyticLogger.LogErrorAsync(SettingKeys.AssetsChannel, ex, "[AssetsIndexService] Failed to serialize the asset index data to JSON.");
- throw;
}
catch (Exception ex)
{
await _finlyticLogger.LogErrorAsync(SettingKeys.AssetsChannel, ex, "[AssetsIndexService] An unexpected error occurred while recreating the asset index file.");
- throw;
}
}
diff --git a/FinlyticCore/Dtos/TradeRepublic/TradeRepublicAssetResponse.cs b/FinlyticCore/Dtos/TradeRepublic/TradeRepublicAssetResponse.cs
index 4715a60..4c6bcab 100644
--- a/FinlyticCore/Dtos/TradeRepublic/TradeRepublicAssetResponse.cs
+++ b/FinlyticCore/Dtos/TradeRepublic/TradeRepublicAssetResponse.cs
@@ -130,4 +130,4 @@ public class TradeRepublicAssetConverter : JsonConverter
}
}
-file record TradeRepublicAssetFallback : TradeRepublicAsset;
+public record TradeRepublicAssetFallback : TradeRepublicAsset;
diff --git a/FinlyticCore/Util/FinlyticJsonSerializerContext.cs b/FinlyticCore/Util/FinlyticJsonSerializerContext.cs
index bc99a00..6274d63 100644
--- a/FinlyticCore/Util/FinlyticJsonSerializerContext.cs
+++ b/FinlyticCore/Util/FinlyticJsonSerializerContext.cs
@@ -104,6 +104,20 @@ namespace FinlyticCore.Util;
[JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicConnectRequest))]
[JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicSearchRequest))]
[JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicAssetResponse))]
+[JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicAsset))]
+[JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicStock))]
+[JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicCrypto))]
+[JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicEtf))]
+[JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicSynthetic))]
+[JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicBond))]
+[JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicDerivative))]
+[JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicAssetFallback))]
+[JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicTag))]
+[JsonSerializable(typeof(List))]
+[JsonSerializable(typeof(IReadOnlyList))]
+[JsonSerializable(typeof(IReadOnlyList))]
+[JsonSerializable(typeof(IList))]
+[JsonSerializable(typeof(List))]
[JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicDerivativesRequest))]
[JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicDerivativesResponse))]
[JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicStockDetailsRequest))]
diff --git a/FinlyticNews/Services/NewsDbService.cs b/FinlyticNews/Services/NewsDbService.cs
index 73a3912..7e7e377 100644
--- a/FinlyticNews/Services/NewsDbService.cs
+++ b/FinlyticNews/Services/NewsDbService.cs
@@ -211,52 +211,65 @@ public class NewsDbService : INewsDbService
///
public async Task SaveArticleClassificationAsync(Guid id, N8nResponsePayload payload, List matchedAssets)
{
- var article = await _context.NewsArticles
- .FirstOrDefaultAsync(a => a.Id == id);
+ DateTime? finalPublishedAt = null;
+ if (DateTime.TryParse(payload.PublishedAt, out var publishedDate))
+ {
+ finalPublishedAt = publishedDate.Kind == DateTimeKind.Unspecified
+ ? DateTime.SpecifyKind(publishedDate, DateTimeKind.Utc)
+ : publishedDate.ToUniversalTime();
+ }
- if (article == null)
+ DateTime? finalScrapedAt = null;
+ if (DateTime.TryParse(payload.ScrapedAt, out var scrapedDate))
+ {
+ finalScrapedAt = scrapedDate.ToUniversalTime();
+ }
+
+ var rowsAffected = await _context.NewsArticles
+ .Where(a => a.Id == id)
+ .ExecuteUpdateAsync(s => s
+ .SetProperty(a => a.Title, payload.Title)
+ .SetProperty(a => a.Author, payload.Author)
+ .SetProperty(a => a.Summary, payload.Summary)
+ .SetProperty(a => a.ContentRaw, payload.ContentRaw)
+ .SetProperty(a => a.Language, payload.Language)
+ .SetProperty(a => a.Status, "Completed")
+ .SetProperty(a => a.PublishedAt, a => finalPublishedAt ?? a.PublishedAt)
+ .SetProperty(a => a.ScrapedAt, a => finalScrapedAt ?? a.ScrapedAt));
+
+ if (rowsAffected == 0)
{
await _finlyticLogger.LogWarningAsync(SettingKeys.NewsChannel, "[NewsChannel] Article with ID {Id} not found for classification update.", id);
return null;
}
- article.Title = payload.Title;
- article.Author = payload.Author;
- article.Summary = payload.Summary;
- article.ContentRaw = payload.ContentRaw;
- article.Language = payload.Language;
- article.Status = "Completed";
-
- if (DateTime.TryParse(payload.PublishedAt, out var publishedDate))
- {
- article.PublishedAt = publishedDate.Kind == DateTimeKind.Unspecified
- ? DateTime.SpecifyKind(publishedDate, DateTimeKind.Utc)
- : publishedDate.ToUniversalTime();
- }
-
- if (DateTime.TryParse(payload.ScrapedAt, out var scrapedDate))
- {
- article.ScrapedAt = scrapedDate.ToUniversalTime();
- }
-
+ // Cleanly delete existing matched assets and insert new ones
await _context.MatchedAssets.Where(m => m.NewsArticleId == id).ExecuteDeleteAsync();
- article.MatchedAssets = new List();
- foreach (var asset in matchedAssets)
+ if (matchedAssets != null && matchedAssets.Count > 0)
{
- if (asset.Id == Guid.Empty)
+ foreach (var asset in matchedAssets)
{
- asset.Id = Guid.NewGuid();
+ if (asset.Id == Guid.Empty)
+ {
+ asset.Id = Guid.NewGuid();
+ }
+ asset.NewsArticleId = id;
}
-
- _context.MatchedAssets.Add(asset);
- article.MatchedAssets.Add(asset);
+ await _context.MatchedAssets.AddRangeAsync(matchedAssets);
+ await _context.SaveChangesAsync();
}
- await _context.SaveChangesAsync();
- await _finlyticLogger.LogInfoAsync(SettingKeys.NewsChannel, "[Lifecycle] Article {Id} successfully classified and marked 'Completed'. Title: '{Title}'", article.Id, article.Title);
+ _context.ChangeTracker.Clear();
- return article;
+ var completedArticle = await _context.NewsArticles
+ .Include(a => a.MatchedAssets)
+ .AsNoTracking()
+ .FirstOrDefaultAsync(a => a.Id == id);
+
+ await _finlyticLogger.LogInfoAsync(SettingKeys.NewsChannel, "[Lifecycle] Article {Id} successfully classified and marked 'Completed'. Title: '{Title}'", id, payload.Title);
+
+ return completedArticle;
}
///