Repository navigation
fix(cache): never hold a cache semaphore across an API call [CORE-4248] - #66
Conversation
An API response is published on the thread that made the call, so a sibling cache's side-load observer runs there and takes a second semaphore. If the caller still holds its own, SportDataCache and TournamentsCache can wait on each other forever: the feed then goes silent with the AMQP connection up and nothing logged. These tests are committed before the fix so CI records them failing against the current code. - ConcurrentColdSportAndTournamentLoadsDoNotDeadlock forces both loads to sit inside their API calls at the same instant via a timed barrier, and asserts the overlap actually happened so a green run cannot be vacuous. Dedicated background threads rather than the pool: on a failing run both bodies block forever, and consuming pool threads would starve every later test. - The two per-cache tests assert the invariant directly by probing the private semaphore while the call is in flight. Semaphore(1,1) has no owning thread, so a zero-timeout wait reports the permit state exactly — no timing, no luck. - CacheObserverResilienceTests covers the other half: an exception escaping an observer disposes the subscription permanently and rethrows into the API caller. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six caches side-load from every REST response. The response is published
synchronously on the thread that made the call, so a sibling cache's observer
runs on that thread and takes its own semaphore. Holding ours across the call
therefore nests two cache locks, and SportDataCache.GetSportTournaments and
TournamentsCache.GetTournament do it in opposite orders:
sport lock held -> GET /sports/{id}/tournaments -> TournamentsCache observer
wants the tournament lock
tournament lock held -> GET /tournaments/{id}/info -> SportDataCache observer
wants the sport lock
Semaphore(1,1) is not reentrant and the wait is untimed, so the two threads
park permanently. Because listeners run on the AMQP delivery thread and
messages are auto-acked, the client's feed just stops: connection up, nothing
logged, no alert.
Establishes one invariant across all six caches: the semaphore protects cache
read/compute and cache write, and is never held across an API call. Each
loader now fetches unlocked and takes the lock only around its write, which is
also correct for CompetitorCache.LoadAndCacheItem, the one loader reachable
from an unlocked caller.
Also guards each observer body. In Rx.NET an escaping exception disposes the
subscription for good, rethrows into the API caller, and starves every cache
subscribed after the throwing one, so one malformed payload could silently
switch a cache off for the rest of the process.
Trade-off: the cache check and the load are no longer one critical section, so
concurrent cold reads of the same key may each issue a GET where one used to
be de-duplicated. Idempotent and last-write-wins, and it removes the global
serialisation that made lookups of different keys run one at a time.
_loadedLocales becomes a set, because its check and its add now sit in
different critical sections.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fix added roughly forty lines of comment for a lock change. Reduced to a single line at each spot that is genuinely non-obvious, and dropped the rest. One of them had also gone stale: it still said "plain List" after the field became a HashSet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Only the comments this branch introduced should be in the diff. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both said the Rx subscription arms have no catch. This branch adds one, so the consequence of throwing changed: the response is dropped rather than escaping OnNext and starving later subscribers. Only that clause is touched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review found the "duplicate fetch is idempotent" argument does not hold for every writer the split critical sections now let run twice. HandleTournamentData appends to TournamentIds without a dedup, so two concurrent cold loads of the same tournament put the same id in the list twice and ISport.Tournaments hands the client the same tournament back N times. The entry is NotRemovable with no expiry, so it never shrinks. HashSet fixes it; URN already has value equality. Sport.FetchTournaments now takes a copy: the cached collection is appended to by observer threads, so enumerating the live instance can throw. Pre-existing, but this change raises the append rate. The response-level catch added with the observer guard would drop the rest of a batch on one malformed item, so HandleTournamentData, HandlePlayersData and HandleTeamData get per-item catches, matching what TournamentsCache and MatchCache already do. Two deferred locks that protected nothing: GetSports wrapped a deferred _cache.Select enumerated after the release, and GetSportTournaments returned a lazy projection that re-ran new URN per element for the caller. Both materialized inside the lock. Tests: the semaphore-free assertion now runs over all six caches instead of two, and OverlapAchieved uses >= 2 so a third API call cannot turn it into a false "proved nothing" failure. New SideLoadDeliveryTests pins the contract the design rests on — RestClient delivering on the calling thread, and the two call sites that read the cache straight after their fetch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dsaiko
left a comment
There was a problem hiding this comment.
Changes requested
- 5 unresolved finding(s) at high or above: i1, i6, i8, i9, i13
- 3 of 3 reviewer(s) completed every lens (quorum 2)
Blocking (5)
HIGH · src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/SportDataCache.cs:271
RefreshOrInsertItem initializes TournamentIds but never adds the tournamentId
GetSportTournaments calls RefreshOrInsertItem(id, culture, tournamentId: tournamentId) once per tournament, but the only thing that overload does with tournamentId is localizedSport.TournamentIds ??= new HashSet<URN>() — it never adds the id. The sport is therefore left with an empty, non-null set. Sport.FetchTournaments (Sport.cs:74) reads sport.TournamentIds?.ToList() ?? _cache.GetSportTournaments(...), so the null-check that used to fall through to the API now sees an empty collection instead. The defect predates this PR, but the line is changed by it (List -> HashSet) and it directly contradicts the PR's stated goal of making ISport.Tournaments return a correct tournament list.
Suggested: Add the id in the tournamentId branch, e.g. if (tournamentId != null) (localizedSport.TournamentIds ??= new HashSet<URN>()).Add(tournamentId); — HashSet gives the same dedup guarantee the PR relies on in HandleTournamentData.
HIGH · src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/Entities/Sport.cs:74 · reported by 2 reviewers
TournamentIds HashSet is copied outside the semaphore while observers Add to it in place
SportDataCache.HandleTournamentData (SportDataCache.cs:204) mutates LocalizedSport.TournamentIds in place via sportTournaments.Add(...) while holding the sport semaphore. Sport.FetchTournaments reads the same instance with no lock: GetSport -> Read(id) releases the semaphore before returning the entity, and only then does line 74 run sport.TournamentIds?.ToList(). The added comment claims the copy makes this safe, but the copy is the racing read. Enumerable.ToList on an ICollection<T> goes through new List<URN>(source), which reads Count, allocates URN[count], then calls HashSet<URN>.CopyTo(array, 0) — CopyTo copies the set's current _count, not the count that sized the array. This is the one collection in the six caches still mutated in place; MatchCache.ExtraInfo and TournamentsCache.CompetitorIds are both swapped copy-on-write for exactly this reason. Both writers are on ordinary paths (TournamentInfoModel from every cold TournamentsCache.GetTournament, and TournamentScheduleModel), and this PR deliberately raises the append rate, so the window widens rather than narrows.
Suggested: Make the publication copy-on-write in SportDataCache.HandleTournamentData so no published instance is ever mutated, matching the MatchCache.ExtraInfo / TournamentsCache.CompetitorIds idiom: build the new set under the semaphore and swap the reference — var updated = sport.TournamentIds is null ? new HashSet<URN>() : new HashSet<URN>(sport.TournamentIds); updated.Add(tournamentId); sport.TournamentIds = updated;. Then Sport.cs:74 can drop its .ToList() and its comment, since the reference it reads is immutable after publication. The alternative is to keep the mutation and have SportDataCache expose a snapshot taken under the semaphore (e.g. an ISportDataCache.GetSportTournamentIds(URN) that copies inside the lock); either way the snapshot must not be taken by the caller outside the lock.
HIGH · src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/Entities/Sport.cs:74
TournamentIds HashSet enumerated without the SportDataCache lock
Sport.FetchTournaments does sport.TournamentIds?.ToList() on a LocalizedSport obtained via SportDataCache.Read, which releases the SportDataCache semaphore before returning. SportDataCache.HandleTournamentData (under that same semaphore) mutates the HashSet via sportTournaments.Add(tournamentId) and reassigns sport.TournamentIds (lines 200-205 of SportDataCache.cs). Because HashSet<T> is not thread-safe, concurrent enumeration and Add can throw InvalidOperationException or return a torn view. The PR added the .ToList() to copy on reassignment, but did not address that Add under the lock vs. enumeration after lock release still races. The read path is reached whenever a client enumerates Sport.Tournaments after an API side-load has begun firing.
Suggested: Either snapshot the HashSet under the SportDataCache semaphore (e.g. expose a ReadTournamentIds(URN) that copies inside the lock) or change HandleTournamentData to swap a fresh immutable snapshot (ConcurrentDictionary<URN,byte> or copy-on-write ImmutableHashSet/ImmutableArray) so readers never enumerate a HashSet that another thread is mutating.
The panel disagreed about this one.
HIGH · src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/SportDataCache.cs:203 · reported by 2 reviewers
TournamentIds dedup fix ships with no test
Commit 1664a71 changed LocalizedSport.TournamentIds from List<URN> to HashSet<URN> to fix client-visible duplicates: HandleTournamentData appends on every TournamentInfoModel/TournamentScheduleModel response, the sport entry is NotRemovable with no expiry, and ISport.Tournaments therefore handed the same tournament back once per repeated side-load while the collection grew unboundedly. No test in the branch touches TournamentIds. Every new test would stay green if the field went back to a List, or if a future refactor reintroduced an ordered collection, so the fix for the round-1 finding has no regression guard at all.
Suggested: Add a SportDataCache test using the existing PublishOnlyApiClientProxy pattern: publish the same TournamentInfoModel (tournament od:tournament:1, sport od:sport:1) twice with culture "en", then assert (await cache.GetSport(new URN("od:sport:1"), new[]{Culture})).TournamentIds.Count == 1 and that it contains that id. After the first publish Name["en"] is set, so LoadedLocals covers "en" and GetSport makes no API call — the assertion is deterministic.
HIGH · src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk.Tests/API/CacheSideLoadDeadlockTests.cs:39 · reported by 2 reviewers
Dedup invariant in SportDataCache has no test
The1664a71 fix changed SportDataCache.HandleTournamentData to use HashSet<URN> for TournamentIds, with the explicit rationale that two concurrent cold loads would otherwise put the same tournament id in the list twice and ISport.Tournaments would hand the client the same tournament back N times. The only existing concurrent-load test (ConcurrentColdSportAndTournamentLoadsDoNotDeadlock) asserts the loads finish and overlap, but never asserts the resulting ISport.Tournaments collection contains each id exactly once. A future regression that drops the HashSet (e.g. someone re-introducing a List<T> to make a different test pass) would not be caught.
Suggested: Add a test that triggers two concurrent cold loads of the same sport/tournament and asserts ISport.Tournaments returns each distinct id exactly once. The existing test scaffolding (Worker + SideLoadApiClientProxy) is sufficient; the gap is the assertion, not the apparatus.
The panel disagreed about this one.
Other findings (5)
MEDIUM · src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/TournamentsCache.cs:110
URN parse sits outside the per-item try, so one bad id drops the rest of the batch
Commit 1664a71 added per-item catches to HandleTournamentData, HandlePlayersData and HandleTeamData because the new response-level catch would otherwise discard the remainder of a batch on a single malformed item, and states TournamentsCache already matched that shape. It does not: new URN(tournament.id) at line 110 is evaluated before the try block, so an ArgumentException from a malformed server id escapes HandleTournamentsData, is caught only by the new response-level handler in the subscription, and every tournament after the bad one in that response is never cached. The three sibling caches now behave correctly here; this one does not.
Suggested: Move the URN construction inside the existing try, matching SportDataCache.HandleTournamentData / PlayerCache.HandlePlayersData / CompetitorCache.HandleTeamData, and include the raw id in the log message.
MEDIUM · src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/SportDataCache.cs:119
GetSportTournaments returns a deferred Select; URN parsing bypasses its own per-item catch
Commit 1664a71 claims both deferred projections were 'materialized inside the lock'. GetSports got .ToList(); GetSportTournaments did not — line 119 is still a lazy Select. Two consequences. First, the try/catch inside the foreach at lines 126-133 exists to log-and-continue on a bad tournament, but the most likely thrower, new URN(t.id), runs in the enumerator's MoveNext at line 124, outside that try, so a single malformed id aborts the entire insert loop and propagates out of GetSportTournaments into Sport.FetchTournaments / ISport.Tournaments as an unhandled ArgumentException regardless of ExceptionHandlingStrategy. Second, the returned sequence is enumerated again by the caller, re-running URN construction for every element and keeping the whole TournamentsModel response alive.
Suggested: Materialize the projection per item inside the loop's try (parse the id and insert in one guarded step), and return a materialized List<URN> so the caller does not re-run URN construction over the live response.
MEDIUM · src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/PlayerCache.cs:166
Per-item catches added for batch survival are untested
Commit 1664a71 added per-item try/catch to HandlePlayersData (PlayerCache.cs:166), HandleTeamData (CompetitorCache.cs:242) and HandleTournamentData (SportDataCache.cs:192) specifically so the new response-level catch would not drop a whole batch because of one malformed id. CacheObserverResilienceTests only covers the response-level guard, using players = null — a payload with no valid items at all. Nothing asserts that a batch containing one bad item still caches the good ones, so removing any of the three per-item catches leaves the suite green while a single malformed id silently discards every sibling in the response.
Suggested: Extend CacheObserverResilienceTests: publish a competitorProfileEndpoint whose players are [{id="bogus"},{id="od:player:1"}] and assert GetPlayer(od:player:1) is non-null; mirror it for CompetitorCache (teams) and SportDataCache (TournamentScheduleModel with one unparseable tournament key). All three fail if the per-item catch is removed, because the response-level catch aborts the loop.
MEDIUM · src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/MatchStatusCache.cs:37
Observer guard tested for one of six caches; MatchStatusCache's reachable throw is uncovered
Commit 8965106 added an identical observer guard to all six caches, but CacheObserverResilienceTests exercises only PlayerCache. MatchStatusCache is the highest-value miss and is trivially reachable: HandleResponse passes summary.sport_event_status to RefreshOrInsertApiItem, which dereferences summary.status (MatchStatusCache.cs:145) and NREs when the endpoint returns a summary with no status. The deadlock suite's own MatchSummary() fixture (CacheSideLoadDeadlockTests.cs:284) is exactly that payload, so the case occurs in the branch already but is never asserted. Before the guard this exception escaped OnNext, permanently disposed the subscription and rethrew into the GetMatchSummary caller; if the guard is later removed from MatchStatusCache, SportDataCache, TournamentsCache, MatchCache or CompetitorCache, nothing fails.
Suggested: Add a case per remaining cache to CacheObserverResilienceTests using the PublishOnlyApiClientProxy: for MatchStatusCache publish a MatchSummaryModel with sport_event_status = null, then one with a valid status, and assert no exception reached the publisher and GetMatchStatus returns the second status. Use TournamentInfoModel with a null tournament for SportDataCache/TournamentsCache and a FixturesEndpointModel with a null fixture for MatchCache/CompetitorCache.
LOW · src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk.Tests/API/CacheSideLoadDeadlockTests.cs:105
SportData semaphore theory omits its async loader
The SportData case exercises only GetSportTournaments. It never calls GetSports or GetSport, whose shared async LoadAndCacheItem path was separately changed in 8965106 to release the semaphore across IApiClient.GetSports. The test named for the cache-wide invariant therefore stays green if that async path again holds the semaphore across I/O.
Suggested: Add an async SportData case that pauses GetSports while in flight, probes the semaphore, completes the response, and asserts that the returned sport data was written.
Reviewed by claude, codex, minimax-ollama over pull request # 66.
🤖 Reviewed by AI panel · claude, codex, minimax-ollama · run 20260910-203814
Review found four defects in the round-one changes. RefreshOrInsertItem initialised TournamentIds but never added the id, so GetSportTournaments left the sport with an empty non-null set. Because the entry is NotRemovable with no expiry, the first Sport.Tournaments returned the ids GetSportTournaments handed back and every later one returned empty. HandleTournamentData mutated the set in place while Sport.FetchTournaments enumerated the same instance outside the semaphore, and the .ToList() added for that was itself the racing read. Publication is now copy-on-write, matching the MatchCache.ExtraInfo and TournamentsCache.CompetitorIds idiom, so a published set is never mutated again and the caller can read the reference directly. GetSportTournaments still returned a deferred Select, so new URN ran in MoveNext outside the per-item catch: one malformed id aborted the whole insert loop and escaped as an unhandled ArgumentException regardless of ExceptionHandlingStrategy. Parse and insert are now one guarded step and the result is materialised. TournamentsCache.HandleTournamentsData parsed the id before its try, unlike the three sibling caches, so a bad id dropped every tournament after it in the response. Each fix has a test that turns red when the fix is reverted. Observer-guard and per-item-survival coverage extended from one cache to all six, and the semaphore probe now covers the async GetSports loader as well as the synchronous path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-up audit of the previous commit found it had made things worse in one place. RefreshOrInsertItem was called once per tournament and each call copied the whole accumulated set, so a response of N tournaments did N copies and N(N-1)/2 hashes, all under the semaphore. Worse, each copy was published, so a reader taking the reference without the lock could pick up a partially filled set and hand it to the client as a complete tournament list. Before the copy was introduced a reader saw either null or a complete set. Ids are now collected across the response and published once, and HandleTournamentData groups by sport so a schedule carrying many tournaments for one sport also publishes once. The collection stays ordered rather than becoming a set: ISport.Tournaments enumerates it, and a client was getting server order from the first call and whatever order the set produced afterwards. Duplicates are still dropped. WithTournaments returns the current collection untouched when there is nothing to add, so an empty batch can no longer turn "not loaded yet" into "loaded and empty" — the defect the previous commit fixed. Tests: a batch case for MatchCache, so per-item survival is covered for all five caches that process batches (MatchStatusCache handles a single status per response). Dropped a publish in the tournament-ids test that was a no-op and a comment that described the opposite of what it did; the proxy serves GetSports directly now instead of relying on a swallowed failure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
What is broken
When a client asks the SDK for data, the SDK also shares that answer with its other caches. It is a good idea — one request warms several caches. But it shares the answer on the same thread, while the first cache still holds its own lock.
So two caches can end up holding the lock the other one needs. Like two people each standing in a doorway, waiting for the other door to open. Neither moves, ever.
It takes two normal calls on two threads while the caches are still cold:
sport.Tournamentstakes the sport lock, then waits for the tournament locktournament.GetNameAsync()takes the tournament lock, then waits for the sport lockFor the client the feed simply goes quiet. The connection stays up, messages keep being acked, nothing is written to any log. Only a restart clears it.
This is the same bug that was fixed in the Java SDK in oddin-gg/javasdk#58. Its locks are
synchronized, which is re-entrant, so the failure modes differ. CORE-4248.What this changes
One rule, in the six caches that subscribe to API responses: never hold the semaphore across an API call. Each loader now fetches first, then takes the lock only to write.
Each observer is also wrapped in try/catch. Without it, one bad response from the server switches that cache off for the rest of the process, silently.
Trade-off
The cache check and the load are no longer one step. Previously the semaphore serialised cold loads, so N concurrent callers asking for the same missing item produced exactly one HTTP call. Now they produce N.
That is safe where a response is replaced, which is every cache but one.
SportDataCacheappends tournament ids, so a double load needed care: the collection is now a set, it is published copy-on-write so a handed-out instance is never mutated, andRefreshOrInsertItemnow actually adds the id it was given — previously it created an empty set and never filled it, which leftISport.Tournamentsempty on every call after the first.Getting the coalescing back without reintroducing the deadlock needs an in-flight map (
ConcurrentDictionary<key, Lazy<T>>) on top of this change. Follow-up, not this PR.Behaviour that changes
No public type or signature changes. Three things a client could observe:
sport.Tournamentsused to return the list once and then empty on every later call, because the ids were never actually stored. It now returns them consistently, in the order the server sent, de-duplicated.ArgumentExceptionout ofsport.Tournamentsregardless ofExceptionHandlingStrategy. It is now logged and skipped, so the list is shorter rather than the call failing.Tests
The tests were pushed before the fix so CI recorded them failing first:
Now 70 tests. Every fix in this PR was checked by reverting it alone and confirming a named test turns red.
ConcurrentColdSportAndTournamentLoadsDoNotDeadlock— forces both loads to sit inside their API calls at the same moment, and asserts that overlap happened, so a green run cannot be empty. Uses dedicated threads, because on a failing run both block forever.CacheDoesNotHoldItsSemaphoreWhileTheApiCallIsInFlight— the rule itself, over all six caches, plus the asyncGetSportsloader. No timing involved.SideLoadDeliveryTests— pins the contract the design rests on:RestClientdelivering on the calling thread, and the two call sites that read the cache straight after their fetch.CacheObserverResilienceTests— for each of the six: a bad response does not kill the subscription, and one malformed item in a batch does not drop its siblings.SportDataCacheTournamentIdsTests— ids are added, deduplicated, and never mutated after publication.Why not the javasdk approach
javasdk moved delivery onto a scheduler. That does not port cleanly, and it would not satisfy "no lock held across I/O" anyway — the lock would still be held across the HTTP call. It also breaks three things that rely on delivery being inline:
MatchStatusCache.GetMatchStatusreads the cache right after fetchingMatchCache.LoadFixturediscards its result; only the observer writes it, andMatch.FetchTournamentreads the cache on the next line — this would silently revert the earlier fix that lets a match resolve its sport and tournament without a successful summarySportDataProvider.GetListOfMatchesrelies on the schedule side-load landing inline, otherwise up to 1000 extra callsSideLoadDeliveryTestsexists so that this cannot be broken silently later.Not in this PR
MarketDescriptionCache,MarketVoidReasonsCacheandLocalizedStaticDataOfMatchStatusCachehold alockacross their own HTTP calls, the same shape this PR removes from the six. No cycle exists today — none of the six observers reads a market description, andMonitoris re-entrant so there is no self-deadlock either — so this is a follow-up, not a blocker.Semaphore(1,1)→lock. After this change no lock region spans I/O or anawait, so the kernel semaphore now costs two syscalls per warm read and buys nothing, and it is not re-entrant. Worth converting, but it is a change of primitive rather than of where the lock is held, and it would invalidate the reflection-based test that pins this PR's invariant. Its own PR.winner_id(the .NET and Java feed models disagree — needs the schema owner), the disposal chain (Feedresolves noIDisposable), and the unbounded growth ofSportDataCacheentries, which areNotRemovablewith no expiry.🤖 Generated with Claude Code