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);
}
}