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 <noreply@anthropic.com>
This commit is contained in:
@@ -90,4 +90,67 @@ public class MarketRepositoryTests
|
||||
Assert.Equal("Elections", market.Subcategory);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<AppDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
|
||||
using (var setup = new AppDbContext(options))
|
||||
{
|
||||
setup.Database.EnsureCreated();
|
||||
if (eventAlreadyExists)
|
||||
{
|
||||
setup.Set<Event>().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<Event>().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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/// <summary>(Re-)configures the reporter. An empty token switches it off.</summary>
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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) ?? "";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user