From cb58e094b67624979a6261965f8d92e98d92be7a Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 22 Aug 2026 10:46:46 +0200 Subject: [PATCH] Drei offene Bugfixes sichern (Stand vor dem Fruehjahrsputz) Lagen unfertig im Worktree bzw. auf einem eigenen Branch und werden hier gebuendelt, damit der Ausgangsstand vor der Aufraeumaktion festgehalten ist. 1. Event-Upsert kappte nur Slug und Title. Description, ImageUrl und Tags haben in AppDbContext ebenfalls ein HasMaxLength, wurden aber ungekuerzt geschrieben. Ein Polymarket-Event mit einer Beschreibung ueber 4096 Zeichen liess MySQL den gesamten Batch mit "Data too long for column 'Description'" ablehnen - damit brach der komplette Marktsync ab, nicht nur dieses eine Event. Ersetzt durch TruncateEventStrings, analog zum bereits vorhandenen TruncateMarketStrings. Regressionstest deckt beide Zweige ab (neues und bestehendes Event); uebernommen vom Branch fix-event-description-truncation, der damit aufgeht. 2. FingerprintSnapshotService warf bei jedem Trader, der in zwei Unterkategorien derselben Kategorie gehandelt hat. Der eindeutige Index ist TraderId+Category+Subcategory, ein ToDictionary auf die Kategorie allein kollidiert deshalb. Jetzt per GroupBy summiert. 3. DcErrorReporter meldete Zustandswechsel nur auf Debug-Ebene. Ein stumm abgeschalteter oder abgewiesener Fehler-Stream fiel erst auf, wenn im Deployment Center nach einem Absturz nichts ankam. Meldet jetzt auf Warning - das erreicht den DcErrorSink nicht (Error+) und erzeugt daher keine Rueckkopplung. Transportfehler werden nur beim ersten Mal gemeldet, damit ein Netzwerkaussetzer keine Warnung pro Fehler ausloest. Co-Authored-By: Claude Opus 5 --- .../Services/MarketRepositoryTests.cs | 63 +++++++++++++++++++ src/Predictalytics.Hosting/DcErrorReporter.cs | 53 +++++++++++++--- .../Data/Repositories/MarketRepository.cs | 18 +++++- .../Services/FingerprintSnapshotService.cs | 8 ++- 4 files changed, 132 insertions(+), 10 deletions(-) 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); } }