diff --git a/src/Predictalytics.Application.Tests/Services/MarketRepositoryTests.cs b/src/Predictalytics.Application.Tests/Services/MarketRepositoryTests.cs index 5fc434c..783ec2a 100644 --- a/src/Predictalytics.Application.Tests/Services/MarketRepositoryTests.cs +++ b/src/Predictalytics.Application.Tests/Services/MarketRepositoryTests.cs @@ -90,4 +90,67 @@ public class MarketRepositoryTests Assert.Equal("Elections", market.Subcategory); } } + + /// + /// Regression (2026-08-04): only Slug and Title were capped on the event upsert, + /// so a Polymarket event with an over-long Description made MySQL reject the whole + /// batch with "Data too long for column 'Description'" — aborting the entire market + /// sync, not just that one event. Every length-constrained Event field must be capped. + /// + /// SQLite ignores varchar limits, so asserting "no exception" would prove nothing: + /// the assertions check the stored lengths instead. + /// + [Theory] + [InlineData(true)] // event already exists → update branch + [InlineData(false)] // new event → insert branch + public async Task AddOrUpdateEventsAsync_CapsAllLengthConstrainedFields(bool eventAlreadyExists) + { + using var connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + using (var setup = new AppDbContext(options)) + { + setup.Database.EnsureCreated(); + if (eventAlreadyExists) + { + setup.Set().Add(new Event + { + Id = 1, Platform = PlatformType.Polymarket, PlatformEventId = 99L, + Slug = "existing", Title = "Existing", Description = "short" + }); + setup.SaveChanges(); + } + } + + using (var ctx = new AppDbContext(options)) + { + var repo = new MarketRepository(ctx); + await repo.AddOrUpdateEventsAsync(new[] + { + new Event + { + Platform = PlatformType.Polymarket, + PlatformEventId = 99L, + Slug = new string('s', 900), + Title = new string('t', 1500), + Description = new string('d', 6000), + ImageUrl = "https://x/" + new string('i', 2000), + Tags = new string('g', 2000) + } + }); + } + + using (var assertCtx = new AppDbContext(options)) + { + var stored = await assertCtx.Set().SingleAsync(e => e.PlatformEventId == 99L); + Assert.Equal(512, stored.Slug.Length); + Assert.Equal(1024, stored.Title.Length); + Assert.Equal(4096, stored.Description!.Length); + Assert.Equal(1024, stored.ImageUrl!.Length); + Assert.Equal(1024, stored.Tags.Length); + } + } } diff --git a/src/Predictalytics.Hosting/DcErrorReporter.cs b/src/Predictalytics.Hosting/DcErrorReporter.cs index 1de065c..12ea6b4 100644 --- a/src/Predictalytics.Hosting/DcErrorReporter.cs +++ b/src/Predictalytics.Hosting/DcErrorReporter.cs @@ -25,6 +25,7 @@ public static class DcErrorReporter private static DcApiClient? _api; private static bool _enabled; private static bool _tokenRejected; + private static bool _transportFailureLogged; private static DateTime _windowStartUtc = DateTime.UtcNow; private static int _sentInWindow; @@ -33,20 +34,41 @@ public static class DcErrorReporter /// (Re-)configures the reporter. An empty token switches it off. public static void Configure(string token, bool enabled) { + string? offReason; lock (Sync) { _api?.Dispose(); _api = null; _tokenRejected = false; + _transportFailureLogged = false; if (!enabled || string.IsNullOrWhiteSpace(token)) { _enabled = false; - return; + offReason = !enabled + ? "in den Einstellungen abgeschaltet" + : "kein Deployment-Center-Token hinterlegt"; } + else + { + _api = new DcApiClient(token); + _enabled = true; + offReason = null; + } + } - _api = new DcApiClient(token); - _enabled = true; + // Auf Warning-Ebene, damit es im Terminal des Hosts steht: ein stumm + // abgeschalteter Fehler-Stream faellt sonst erst auf, wenn im Deployment + // Center nach einem Absturz nichts ankommt. Warning erreicht den + // DcErrorSink nicht (Error+), erzeugt also keine Rueckkopplung. + if (offReason is null) + { + Log.Warning("🛰 Fehler-Stream aktiv — Error/Fatal gehen an {Url} (Projekt {Slug}, Build {Build}).", + DcConfig.BaseUrl, DcConfig.ProductSlug, DcConfig.AppVersion); + } + else + { + Log.Warning("🛰 Fehler-Stream INAKTIV ({Reason}) — Laufzeitfehler bleiben nur lokal im Log.", offReason); } } @@ -168,13 +190,30 @@ public static class DcErrorReporter catch (DcApiException ex) when (ex.IsPermanent) { lock (Sync) { _tokenRejected = true; } - // Debug level on purpose: a warning here would be logged, land in the sink and - // come straight back as the next report. - Log.Debug("Deployment Center error stream rejected the token ({Code}); reporting disabled.", ex.Code); + // Warning, nicht Error: der DcErrorSink nimmt erst ab Error an, ein Error + // hier kaeme als naechster Report zurueck. _tokenRejected sperrt weitere + // Versuche, die Meldung erscheint also genau einmal. + Log.Warning("🛰 Fehler-Stream abgewiesen ({Code}) — Token pruefen (Recht 'bugtracker:report'). Melden ist bis zum naechsten Speichern der Einstellungen aus.", ex.Code); } catch (Exception ex) { - Log.Debug(ex, "Deployment Center error report failed"); + // Erster Fehlschlag sichtbar, danach still: sonst haengt an jedem + // Netzwerkaussetzer eine Warnung pro gemeldetem Fehler. + bool first; + lock (Sync) + { + first = !_transportFailureLogged; + _transportFailureLogged = true; + } + + if (first) + { + Log.Warning("🛰 Fehlermeldung an das Deployment Center fehlgeschlagen: {Message}. Weitere Fehlschlaege werden nicht mehr gemeldet.", ex.Message); + } + else + { + Log.Debug(ex, "Deployment Center error report failed"); + } } } diff --git a/src/Predictalytics.Infrastructure/Data/Repositories/MarketRepository.cs b/src/Predictalytics.Infrastructure/Data/Repositories/MarketRepository.cs index bd46b22..67749a5 100644 --- a/src/Predictalytics.Infrastructure/Data/Repositories/MarketRepository.cs +++ b/src/Predictalytics.Infrastructure/Data/Repositories/MarketRepository.cs @@ -136,8 +136,7 @@ public class MarketRepository : IMarketRepository foreach (var ev in currentBatch) { - if (ev.Slug != null && ev.Slug.Length > 512) ev.Slug = ev.Slug[..512]; - if (ev.Title != null && ev.Title.Length > 1024) ev.Title = ev.Title[..1024]; + TruncateEventStrings(ev); if (existingEventsMap.TryGetValue(ev.PlatformEventId, out var existing)) { @@ -231,6 +230,21 @@ public class MarketRepository : IMarketRepository } } + /// + /// Kuerzt die Textfelder eines Events auf die im Modell konfigurierten Laengen. + /// Ohne das bricht der gesamte Speichervorgang mit + /// "Data too long for column 'Description'" ab und der Marktabgleich endet + /// mitten im Durchlauf — Polymarket liefert Beschreibungen weit ueber 4096 Zeichen. + /// + private static void TruncateEventStrings(Event ev) + { + ev.Slug = StringHelper.Truncate(ev.Slug, 512) ?? ""; + ev.Title = StringHelper.Truncate(ev.Title, 1024) ?? ""; + ev.Description = StringHelper.Truncate(ev.Description, 4096); + ev.ImageUrl = StringHelper.Truncate(ev.ImageUrl, 1024); + ev.Tags = StringHelper.Truncate(ev.Tags, 1024) ?? ""; + } + private void TruncateMarketStrings(Market market) { market.Question = StringHelper.Truncate(market.Question, 1024) ?? ""; diff --git a/src/Predictalytics.Infrastructure/Services/FingerprintSnapshotService.cs b/src/Predictalytics.Infrastructure/Services/FingerprintSnapshotService.cs index 49f81dd..f6334a3 100644 --- a/src/Predictalytics.Infrastructure/Services/FingerprintSnapshotService.cs +++ b/src/Predictalytics.Infrastructure/Services/FingerprintSnapshotService.cs @@ -128,7 +128,13 @@ public class FingerprintSnapshotService : IFingerprintSnapshotService var total = perfs.Sum(p => p.TotalVolume); if (total <= 0) return null; - var mix = perfs.ToDictionary(p => p.Category.ToString(), p => p.TotalVolume / total); + // Je Kategorie gibt es eine Zeile pro Unterkategorie (eindeutiger Index + // TraderId+Category+Subcategory). Ein ToDictionary auf die Kategorie allein + // wirft deshalb bei jedem Trader, der in zwei Unterkategorien derselben + // Kategorie gehandelt hat ("Economy/Inflation" und "Economy/Jobs"). + var mix = perfs + .GroupBy(p => p.Category.ToString()) + .ToDictionary(g => g.Key, g => g.Sum(p => p.TotalVolume) / total); return JsonSerializer.Serialize(mix); } }