Repository navigation
fix(amqp): resolve match sport and tournament without a successful summary fetch - #63
Conversation
a98e60f to
59afdd7
Compare
stetinatomas-oddin
left a comment
There was a problem hiding this comment.
Verified the branch locally: 21/21 green, and the partial-update work is a real root-cause fix rather than a workaround. Two findings gate the merge for me.
Headline concerns
Match.FetchTournamentreturns on a nullSportIdbefore the probe, so the new fixture fallback never runs for anyMatchbuilt without a routing-key sport —SportDataProvider.GetMatch/GetMatchesandSportDataBuilder.BuildMatches. The compatibility table claims otherwise for those callers.- The fallback goes through
FixtureCache, which short-circuits on a cache hit, and only its API path publishes the response that populatesMatchCache. A warm fixture entry silently turns the fix back into the old null behaviour.
I confirmed both by running them rather than by reading — details inline.
Lenses run: correctness/invariants · concurrency/race · domain-correctness · design-coherence · test-coverage · maintainability. → 1 CRITICAL + 5 MAJOR + 3 MINOR + 3 NIT inline, plus the two notes below.
Without a file anchor
- The branch predates
main's tip, so the diff showsci.yml,Oddin.OddsFeedSdk.Tests.csprojandCoverageBootstrapTest.csas changes even though all three are byte-identical tomainand merge as no-ops. A rebase makes the diff match what actually lands — 8 files rather than 11. - The description says "#62 stays open for now", but #62 is closed as superseded.
I also went through this manually alongside the structured pass and have no findings beyond the ones here.
Worth calling out as good: chasing the partial-update data loss to its root instead of guarding around it, and PartialFixtureUpdatePreservesCompleteCachedMatchFieldsAndUpdatesName asserting that Name did update — that is what stops a preservation test from passing vacuously. Documenting the declined review items with reasons, including the corrected THROW-diagnosis claim, made this much faster to review.
Co-Authored-By: Claude Opus 5 (1M context)
59afdd7 to
2d2d677
Compare
|
@stetinatomas-oddin all 12 inline findings and both unanchored notes are addressed in On the unanchored notes: branch rebased onto 35/35 tests pass (was 21), verified in two independent environments. Single commit, amended. Prepared by Claude (AI) on behalf of @hrubymar10. |
153a3e2 to
0d3d3e3
Compare
stetinatomas-oddin
left a comment
There was a problem hiding this comment.
All 12 inline findings and both headline items from my previous review verified against 0d3d3e3 by reading the code, not the replies — everything is genuinely fixed. Note the replies cite 2d2d677, which the later force-push orphaned; the 63/28 delta that landed after them (the PeekMatch re-probe and the LoadedLocals redesign) is audited here too. 37/37 pass locally, and I mutation-tested six of the fixes to confirm the tests actually lock them in rather than passing vacuously.
Highlights:
- CRITICAL, fallback unreachable for REST-built matches —
FetchTournamentnow probes, loads the fixture on a miss, re-probes, and only then resolves_localSportId ?? match?.SportId. Reinstating the early return failsTournamentFallbackWorksForMatchWithoutRoutingKeySport. - MAJOR, warm
FixtureCacheno-ops the fallback —IMatchCache.LoadFixturegoes straight toIApiClient.GetFixture, and the end-to-end test with a pre-warmed realFixtureCacheplus a realMatchCacheasserts the second API call. The divergence I demonstrated is closed. - MAJOR, scripted-fake test — every fallback claim now runs against a real
MatchCachebehind a publishingDispatchProxy, and both cases I asked for are present. - MAJOR, mapper coverage — restored and extended. One correction to the reply: reverting the mapper line fails two tests, not four; the two "-"-routing-key cases pass either way because the sport is null on both sides. The coverage concern is satisfied regardless.
- MAJOR, logger init —
[ModuleInitializer]is the right shape; the uninitialized-factory throw is no longer ordering-dependent. PeekMatchre-probe (F022) — good catch on your side, and pinned: swapping it back toGetMatchfails four tests on the summary-call count.
Four new findings from auditing the delta, one MAJOR and three smaller, all inline except the last. None is a regression — the MAJOR is a pre-existing throw whose reach this PR extends, so it is a small hardening rather than a rework.
Without a file anchor — the description contradicts itself. "Fixture-derived entries do not suppress summary retries" still describes two maintained sets ("fixture responses add the culture to the fixture-only set, summary and schedule responses move it to the loaded set"), but F009 removed FixtureOnlyLocals and MarkCultureLoaded now simply skips fixture-borne cultures. The council-rounds bullet above it says as much, so the two sections disagree. Same section: the Match no longer takes IFixtureCache line describes a delta against the revision I first reviewed, not against main — main's Match never took it, so a future reader diffing against main will not find that change.
One observation explicitly not a change request, since the file is outside this diff: FixtureCache.LoadAndCacheItem builds its ExtraInfo with a bare extra_info?.ToDictionary(...), so it still throws on the duplicate and null keys you just made MatchCache tolerate — and its try/catch only wraps the API call, so that one reaches the consumer. Worth knowing about, not worth widening this PR for.
Worth calling out as good: the partial-update work held up under mutation testing on every field I checked, SuccessfulSummaryAfterFixtureFallbackStopsFurtherRetries is exactly the test that stops the retry semantics from silently regressing, and the deliberate choice to keep per-access summary retry — with the reasoning written into the code as a comment rather than left in the PR body — is the right call and the right place for it.
Co-Authored-By: Claude Opus 5 (1M context)
…mmary IMatch.SportId and IMatch.Tournament both returned null when the match summary REST call failed, so the documented usage in #57 — await match.Tournament.GetSportAsync() — threw a NullReferenceException after any transient summary error. The sport URN is present in every AMQP routing key but was not forwarded when building match events, so SportId always fell back to the summary. Forward it, as the tournament branch already did. The tournament URN is not in the routing key at all, so Tournament needs an API source. FetchTournament now probes the match cache first and, only when nothing is cached, loads the fixture through MatchCache.LoadFixture — a direct API call whose response populates the cache via the existing response subscription — before re-reading. The sport is then resolved from the routing key or the freshly cached match, so the fallback also repairs matches built without a routing-key sport (SportDataProvider and batch paths). Routing the fallback through FixtureCache instead would silently no-op whenever the fixture was already cached there, since only its API path publishes into MatchCache. On the failure path this also replaces an unbounded per-access summary retry with a lookup that populates the cache. Preserve match data on partial updates. RefreshOrInsertItem's update branch assigned every field unconditionally, so any payload omitting a field erased the cached value. Triggering a fixture fetch from Tournament widened that exposure: GetMatch releases its semaphore before the HTTP call, so a concurrent populate could turn the intended insert into an update and blank Competitors, causing a NullReferenceException in Match.Competitors. Absent fields are now preserved, while an explicitly empty collection still clears. SportFormat and LiveOddsAvailability are only written when the payload actually carries them, so a partial response can no longer silently flip Race to Classic or NOT_AVAILABLE to AVAILABLE. Insert-branch defaults are unchanged. The fixture payload's extra_info hides the base sportEvent slot (CS0108 in the generated XML models), so fixture-borne extra_info never reached the cache. Bridge it by passing the derived value alongside the payload instead of mutating the shared response object, and tolerate duplicate extra_info keys (last wins, logged) instead of throwing. The routing-key sport takes precedence over the API value; a mismatch is logged once per match instance, from any property read that loads the cached match, rather than resolved silently. Round-2 review hardening (stetinatomas-oddin): An unrecognised sport_format value no longer throws. The throw ran inside the MatchCache Rx subscription arms, which catch nothing, so it escaped OnNext and starved every cache subscribed after MatchCache; the extra_info bridge made it reachable for fixture payloads for the first time. An unknown value now logs a warning and maps to SportFormat.Unknown, left ungated (hasSportFormat stays false) so a fresh insert reports it honestly while an update preserves a previously cached known value rather than demoting it. As a defence in depth for the same class — refid, sport, tournament and competitor URNs all throw on malformed server input — HandleMatchData now wraps each item in try/catch, so one bad event cannot drop its siblings or the response's later subscribers. ExtraInfo updates now merge instead of replacing the whole dictionary, so a partial payload no longer erases keys it omits — the same preservation rule the other fields already follow. This subsumes the sport_format lockstep special-case, which is removed. An explicitly empty extra_info therefore preserves rather than clears, a deliberate exception to the "empty collection clears" rule: an empty bag from a subset-carrying endpoint is not a credible "server cleared everything" signal. The routing-key vs cached-API sport conflict warning is now reachable from SportId, not only Tournament, via a non-loading PeekMatch behind a guarded Func overload that keeps the summary hot path clear. FixtureCache builds its extra_info through the same tolerant builder as MatchCache (lifted to a shared ExtraInfoHelper), so the duplicate/null-key payload MatchCache now survives no longer throws out of FixtureCache into the consumer. Refs #57
0d3d3e3 to
501302a
Compare
stetinatomas-oddin
left a comment
There was a problem hiding this comment.
Round 3 verified against 501302a. All four findings from my previous review are genuinely fixed, plus both unanchored notes — I read the code rather than the replies, ran the suite (45/45), and re-ran your mutation table myself: reinstating the throw, ungating Unknown, removing the per-item try/catch, reverting merge to replace, reverting copy-on-write to in-place, dropping the FetchSportId peek, and reverting FixtureCache to the bare ToDictionary each fail exactly the test that pins them. No regressions in the delta.
Taking both routes on the sport_format throw was the better call than either alone, and the reasoning is right: the unguarded new URN(...) sites made it a class rather than an instance, and per-item rather than per-batch isolation is what keeps a malformed sibling from dropping the rest — MalformedIdInScheduleBatchDoesNotDropSiblingMatches is the test that actually distinguishes the two. Gating Unknown so an insert reports it honestly while an update refuses to demote a cached known value is a nicer answer than either option I offered. And PreviouslyReturnedExtraInfoReferenceIsNotMutatedByLaterUpdate catches a real defect that my note only indirectly pointed at — the in-place merge stripping the snapshot semantics every sibling field keeps was worth finding.
Approving. Two MINOR notes inline, both consequences of this round's fixes rather than new ground, and neither gates the merge — land them here or don't, your call.
Two smaller things not worth an anchor: FixtureCache._log is declared as SdkLoggerFactory.GetLogger(typeof(TournamentsCache)), a pre-existing copy-paste, so the new duplicate-key warning will surface under the TournamentsCache category — the file is already in the diff, so it is a one-line fix if you want it. And the preservation bullet in the description still lists ExtraInfo under "an explicitly empty collection still clears", which now contradicts both the new merge bullet and the compatibility-table row you added.
Co-Authored-By: Claude Opus 5 (1M context)
| // Merge rather than replace: different endpoints carry different extra_info keys, so | ||
| // a present-but-partial payload must not erase keys it omits (the same preservation | ||
| // rule the fields above follow). Present keys win; absent keys are preserved — which | ||
| // also keeps ExtraInfo[sport_format] aligned with the SportFormat gate above without a |
There was a problem hiding this comment.
[MINOR] — The comment claims merge "keeps ExtraInfo[sport_format] aligned with the SportFormat gate above without a special case". That holds when the key is absent, but not when the value is present and unrecognised — which is the case the gate exists for. I confirmed it: a match cached from a summary as race, then updated by a fixture carrying sport_format = "future_value", ends up with SportFormat == Race and ExtraInfo["sport_format"] == "future_value". F019's "duplicates cannot produce divergent state" no longer holds in general either.
Raw passthrough in the dictionary is a defensible choice — I am not asking you to reinstate the carve-out. The two things worth fixing are that the comment currently asserts an invariant the code does not hold, and that UnknownSportFormatUpdatePreservesCachedKnownSportFormat asserts only the typed field, so whichever behaviour you intend is unpinned and free to flip.
Co-Authored-By: Claude Opus 5 (1M context)
| // We accept that per-read peek rather than a one-shot flag: the two sports can only be | ||
| // compared once something has cached the match, which often happens after the first SportId | ||
| // read, and a one-shot would disarm the diagnostic before that ever occurs. | ||
| LogSportMismatchOnce(() => _matchCache.PeekMatch(Id)); |
There was a problem hiding this comment.
[MINOR] — The peek runs on every SportId read for the whole life of the instance, not only while a comparison is impossible: 100 reads produce 100 PeekMatch calls both when nothing is cached and when the cached sport agrees, because it disarms only once a conflict is actually logged. Scale first, so this is not overstated — the feed builds a Match per message, so in practice it is roughly one extra MemoryCache.Get per message; the unbounded case is a consumer holding one instance and polling it.
Your objection to a one-shot flag was that it would disarm before anything has cached the match. Disarming after the first comparison that actually saw a non-null match.SportId avoids that and bounds the cost — nothing further is learned once the two have been compared.
Also worth a line: SportIdUsesRoutingKeyWithoutReadingMatchCache no longer verifies what its name claims, since SequenceMatchCache.PeekMatch does not advance the counter it asserts on. A peek-count assertion would keep it honest.
Co-Authored-By: Claude Opus 5 (1M context)
Summary
Resolves both halves of #57 —
IMatch.SportIdandIMatch.Tournamentreturning null after a failed match-summary fetch.Supersedes the approach in #62 (closed as superseded), which fixed only the
SportIdhalf.SportId—FeedMessageMapper.MapSportEventnow forwards the routing-key sport URN toBuildMatch(the signature already accepted it, and the tournament branch already did this).Match._localSportIdis populated, soSportIdresolves with no cache work and without touching the process-wide semaphore inMatchCache.GetMatchon the hot feed path.Tournament— the tournament URN is not in the routing key (TopicParsingHelperparses sport at index 4 and event at index 6, nothing tournament-shaped), so it can only come from the API.Match.FetchTournamentnow probes the match cache first and, only when no cache item exists at all, loads the fixture via a new internalIMatchCache.LoadFixture— a directIApiClient.GetFixturecall whose response populatesMatchCachethrough the API client's existing response subscription — before re-reading. The sport is then resolved asrouting-key sport ?? cached match sport, so the fallback repairs all construction paths, including matches built without a routing-key sport (SportDataProvider.GetMatch/GetMatches,SportDataBuilder.BuildMatches). So a summary failure no longer leavesTournamentnull when the fixture endpoint answers, andawait match.Tournament.GetSportAsync()stops throwing.FixtureCache—FixtureCache.GetFixtureshort-circuits on its own cache hit and only its API path publishes intoMatchCache. The two caches are independentMemoryCacheinstances with separate expiry and memory-pressure trimming, so warm-fixture + evicted-match would silently turn the fallback into a no-op. The direct API call is immune to that divergence; traffic stays bounded because it only fires after aMatchCacheprobe miss.FetchMatchhelper (it cannot live inFetchSportId: when the routing-key sport is set,FetchSportIdnever touches the API value — fetching there just to compare would reinstate the hot-path summary call this PR removes), fires on any property read that loads the cached match, and logs once perMatchinstance.RefreshOrInsertItemno longer destroys data on partial payloadsThe update branch (
MatchCache.cs) assigned every field unconditionally, so any payload omitting a field erased the cached value. Triggering a fixture fetch fromTournamentmade that materially more reachable:GetMatchreleases its semaphore before the HTTP call, so a concurrent summary/schedule response can populate the same match during the window, and our fixture response then arrives to find an existing entry and takes the update branch. A blankedCompetitorsis aNullReferenceExceptioninMatch.Competitors.Guarding on "no cache item" before the fetch is not sufficient, because the branch is chosen after the fetch. So:
RefId,SportId,TournamentId,Competitors,ExtraInfo) are preserved; an explicitly empty collection still clears, so absent and empty stay distinguishableScheduledTime/ScheduledEndTimeare written only under theirSpecifiedflagsSportFormatis written only when thesport_formatkey is actually present, andLiveOddsAvailabilityonly whenliveoddsis non-empty — these are non-nullable, so a partial payload previously flippedRace→ClassicandNOT_AVAILABLE→AVAILABLEsilentlyMatch.CompetitorsandGetHomeAwayCompetitorare null-safe; a null collection is treated as "fewer than two competitors" and follows the existingArgumentException/CATCH-returns-null contract rather than introducing a new exception typeHandleMatchDataskips null ids instead of passing them toRefreshOrInsertItem, which dereferences themBehaviour and compatibility
No public API changes —
Match,IMatchCache,IFixtureCacheandISportDataBuilderare allinternal.TournamentnullTournamentresolvesSportIdnull,TournamentnullCATCHTHROW, feed-built matchItemNotFoundException("Unable to fetch match")THROW, REST-built matchItemNotFoundException("Cannot load sport")ItemNotFoundException("Unable to fetch match")— the failure is now attributed to the missing match rather than the sport derived from it"Cannot load tournament"underTHROWsport_formatArgumentExceptionout ofOnNext: on the summary path nothing cached for that culture, on the fixture path every cache subscribing afterMatchCachestarved of the whole responseSportFormat.Unknown, an update preserves the cached known value.HomeCompetitor/AwayCompetitorthen return null (or throw underTHROW) via the existingIsClassic()gate, namingunknownextra_infosport_format)Competitors, where empty still clearsCost: reading
Tournamentcan now reach the fixture endpoint, which it previously never did. On the failure path that is 1 summary attempt plus 1 fixture attempt per access (the post-fixture re-probe is a non-loadingPeekMatch, so the summary is not retried within the same access) versus 1 summary today — and today's failure path already re-attempts the REST call on every access, unbounded, becauseLoadAndCacheItemswallows summary errors and caches nothing. Once the fixture answers, the cache populates and further accesses cost nothing, a net reduction. Only in a full API outage is this more traffic, and then by exactly one fixture call per access.Review items from #62 adopted and declined
Adopted from @dsaiko's review: scope claims corrected,
IMatch-level tests added, sport-conflict visibility,#57kept open.Declined — inverting
FetchSportIdprecedence to prefer the API value. It is the compat-safer option in isolation, but it reinstates the per-access REST retry on the failure path and, underExceptionHandlingStrategy.THROW,FetchMatchthrows before the routing-key fallback is reachable — defeating the fix for THROW users entirely. The underlying concern is addressed by the warning log instead.Corrected — the claim that
THROWdiagnosis regresses (exception flipping from"Cannot load sport"to"Cannot load tournament"). Neither message is reachable when the summary fails:FetchMatchthrows"Unable to fetch match"first, identically before and after. The other two only fire when a cache item exists with that one field null. A test pins this.Review round 2 — items from @stetinatomas-oddin's review, all adopted
FetchTournament: probe → fixture load on miss → re-probe → resolve sport from routing key or cached match. Regression test added.FixtureCacheno-ops the fallback — fixed by bypassingFixtureCacheentirely (IMatchCache.LoadFixture, per the review's first suggested option). End-to-end regression test: pre-warmed realFixtureCache, realMatchCache, publishing API-client proxy.FetchMatchhelper with once-per-instance dedup (see Summary for why it cannot live inFetchSportIdliterally).MatchCachefed by a publishingDispatchProxyAPI client; the scripted sequence fake is no longer used for fallback claims.[ModuleInitializer], safe under xunit parallel collections.FeedMessageMapperTestsandTopicParsingHelperTestsrestored from fix(amqp): pass sport URN from routing key when building match events #62 and extended with throwing inputs (wrong section count, non-numeric sport section) plus theodds_changeno-sport case.extra_infobridge no longer mutates the shared response payload (the derived value is passed alongside instead; regression test asserts the payload is untouched); duplicateextra_infokeys are last-wins with a warning (null keys skipped) instead of throwing;System.ReactiveandMicrosoft.Extensions.Logging.Abstractionsare now explicit test dependencies.LoadedLocalsis a maintained set instead of a per-read LINQExcept;fromFixtureis passed by name at call sites; theCoverageBootstrapTeststub is deleted (the suite now carries real tests).maintip, so the previously byte-identicalci.yml/csproj-registration/bootstrap files are out of the diff; fix(amqp): pass sport URN from routing key when building match events #62 wording corrected above.Council rounds (ragnar-cr runs 5–7): F011 — fixture updates no longer demote summary-loaded cultures (regression test pins summary→fixture→access with no refetch); F009 — the write-only
FixtureOnlyLocalsset is removed; F019 —SportFormatis derived from the same last-winsextra_infovalue asExtraInfo, so duplicates cannot produce divergent state and an invalid-then-valid duplicate no longer aborts the update; F022 — the post-fixture re-probe uses a non-loadingPeekMatch, so the fallback no longer fires a second synchronous summary attempt under the process-wide semaphore.Review round 3 — items from @stetinatomas-oddin's review, all adopted
sport_formatthrew into the Rx dispatch —RefreshOrInsertItemthrew on asport_formatvalue this SDK version does not recognise, and the twoMatchCachesubscription arms catch nothing, so the exception escapedOnNextand starved every cache subscribed afterMatchCache(theextra_infobridge made this reachable for fixture payloads for the first time). An unknown value now logs a warning and maps toSportFormat.Unknown, left ungated (hasSportFormatstays false) so a fresh insert reports it honestly while an update preserves a previously cached known value rather than demoting it. As defence in depth for the same class —refid/sport/tournament/competitor URNs all throw on malformed input —HandleMatchDatanow wraps each item intry/catch, so one bad event cannot drop its siblings or the response's later subscribers.SportIdreaders never saw the sport-conflict warning — it is now reachable fromFetchSportIdvia a non-loadingPeekMatchbehind a guardedFuncoverload (cheap guards run before any lookup), so a consumer reading onlySportId— the property the precedence actually governs — sees the conflict without a summary fetch on the hot path.ExtraInfowas whole-dictionary replace — updates now merge: present keys win, omitted keys are preserved, matching the partial-update rule the other nine fields already follow. This subsumes and removes thesport_formatlockstep carve-out. The merge is copy-on-write (a new dictionary is built and the reference swapped), soIMatch.ExtraInfokeeps the snapshot semantics every sibling field already has — a reference a consumer already read is never mutated underneath it. One deliberate consequence, stated rather than buried: an explicitly emptyextra_infonow preserves rather than clears — unlikeCompetitors, an empty bag from a subset-carrying endpoint is not a credible "server cleared everything" signal.FixtureCache.LoadAndCacheItembuilt itsextra_infowith a bareToDictionary, so the duplicate/null-key payloadMatchCachewas just taught to tolerate still threw out ofFixtureCacheinto the consumer (itscatchwraps only the API call). Both caches now share one tolerantExtraInfoHelper.ToExtraInfoDictionary. The reviewer flagged this as not-a-change-request; it is included because this PR is what created the inconsistency.SportDataBuilder.csre-indentation is fully reverted, including the unmentioned trailing-newline change, so the file leaves the diff entirely.FixtureCache.LoadAndCacheItem'stv_channelsand other bare projections are untouched; only theextra_infobuilder was shared.extra_infofrom fixture payloads was silently discardedfixturere-declaresextra_infowith its own backing field, hidingsportEvent.extra_info(FixturesEndpointModel.cs:130— the compiler has been emittingCS0108for this all along).RefreshOrInsertItemtakes asportEvent, so it read the base slot and sawnullfor every fixture payload:extra_info, and thereforesport_format, has never arrived through the fixture endpoint.The two declarations differ by more than storage — the base carries
[XmlArrayItem(IsNullable = true)]and the derivedIsNullable = false— so this originates in the XSD rather than being a generator accident. Fixed at the boundary in non-generated code instead: theFixturesEndpointModelarm of theMatchCachesubscription passes the derivedextra_infoalongside the payload intoRefreshOrInsertItem, without mutating the shared response object that other subscribers see.Consequence beyond the bug fix: fixture responses can now update a cached match's
ExtraInfoandSportFormatwith the data they actually carry, which includes theMatch.Fixturepath and not only the new fallback. The preservation rules above keep that safe — absent values preserve cached state, and an omittedsport_formatentry is carried forward.Fixture-derived entries do not suppress summary retries
Caching a fixture-derived entry created a second-order problem:
RefreshOrInsertItemalways writesName[culture],LoadedLocalsderives from loaded cultures, andGetMatchonly loadscultures.Except(LoadedLocals). So once the fallback populated a culture, the summary was never retried for it — a transient summary failure pinned the match to fixture-derived data for the cache TTL, leaving fields the fixture does not carry absent even after the summary endpoint recovered. Before this change a failed summary retried on every access and self-healed.LocalizedMatchtracks a single set of loaded cultures (LoadedLocalSet);MarkCultureLoadedadds a culture only when the response did not come from a fixture, so a fixture-borne culture is simply never marked loaded and stays eligible for summary retry, while a culture already loaded from a summary or schedule is never demoted by a later fixture update.Tournamenttherefore resolves immediately from the fixture while the summary keeps being retried until it succeeds, at which point the culture is marked loaded and retries stop naturally.This deliberately restores per-access summary retry while the summary endpoint is unavailable — the pre-existing behaviour. Automatic recovery is worth more than the saved calls.
Tests
45 passing (was 21 at first review; 37 before round 2).
IMatch-level rather than against a capturing builder. New in round 2: unrecognised-sport_format-does-not-throw-and-still-reaches-a-later-subscriber, unknown-sport_format-preserves-a-cached-known-value, malformed-id-in-a-multi-event-schedule-does-not-drop-siblings, theSportId-path conflict warning,ExtraInfomerge (payload-key-wins, omitted-key-survives, empty-preserves, and a previously-read reference not mutated by a later update), andFixtureCacheduplicate/null-key tolerance. New since the first review: REST-built (localSportId: null) fallback regression, warm-FixtureCacheend-to-end regression, payload-mutation regression, duplicate-extra_info-key handling, restored mapper/topic-parser coverage with throwing inputs, and the fallback tests now run against a realMatchCachefed by a publishing API-client proxy instead of a scripted cache fake. Retained from round 1: routing-keySportIdwith zero cache reads, both-endpoints-fail with the exact exception asserted, the hot-path skip, cached-item-without-tournament-id, tournament-without-summary-sport, conflict logging, the non-reentrant-semaphore test, and the deterministic partial-update coverage asserting all nine vulnerable fields survive whileNamedid update.Also adds
InternalsVisibleTofor the test assembly —mainhad the test project but a solution build compiled no test code at all.Validation
45/45 tests pass on
501302a, verified locally, and each round-3 fix was mutation-tested — reverting it fails exactly the test that pins it. Branch rebased ontomaintip (9c536ba), single commit.Note this repo does not build warning-free and did not before this change: pre-existing
CS8632,CS0108,CS0618, and anNU1903advisory forMicrosoft.Extensions.Caching.Memory6.0.1. None originate here and none are addressed here.Prepared by Claude (AI) on behalf of @hrubymar10.