Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
226 changes: 223 additions & 3 deletions CLAUDE.md

Large diffs are not rendered by default.

40 changes: 40 additions & 0 deletions MarketExtension/Data/IQuoteCacheDataSource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
namespace MarketExtension;

// A process-wide store of the latest QuoteEntity per symbol, exposed as OBSERVABLE state every surface
// can subscribe to — so two surfaces showing the same symbol read the SAME entry and can never drift
// apart (the out-of-sync prices the favorites dock and the screens show today). Keyed by
// WatchlistStore.Normalize, the one cache key used everywhere.
//
// This is the quote CACHE DATA SOURCE — one of the sources MarketRepository orchestrates over (alongside
// the IMarketDataProviders). It's an interface so the storage mechanism stays an implementation detail:
// the in-memory implementation can be swapped for a database-backed one later (the "local cache that will
// probably be a database layer") without touching MarketRepository or any UI surface. MarketRepository
// owns one instance, writes through to it on every fetch, and every priced surface OBSERVES it instead of
// fetching independently.
//
// Data layer: holds QuoteEntity (the storage model, no formatting), NOT DomainQuote. MarketRepository maps
// QuoteEntity <-> DomainQuote at its boundary, so the entity never escapes the data source; surfaces still
// observe DomainQuote off the repository and project it to UiQuote as they do today.
internal interface IQuoteCacheDataSource
{
// Current cached quote for a symbol, or null if it was never fetched / has been cleared.
// Synchronous snapshot read.
QuoteEntity? Get(string symbol);

// Per-symbol observable entry: replays the current value (null until the first fetch lands) on
// subscribe, then pushes each change. Lazily created on first access. Distinct-until-changed via
// QuoteEntity value equality (an identical re-fetch does NOT re-emit). This is the seam a surface
// waits on for the first fetch: subscribe → null (render a spinner) → the quote when Upsert lands.
StateFlow<QuoteEntity?> Observe(string symbol);

// Write a freshly fetched quote through to the cache. keepLastGood:true (the default) drops a
// transient invalid quote (e.g. a 429 mapped to IsValid:false) when a valid quote is already
// cached — the SINGLE home for the "keep last good" guard currently copy-pasted across the priced
// surfaces. keepLastGood:false overwrites unconditionally (a hard refresh / data-source flip).
void Upsert(QuoteEntity quote, bool keepLastGood = true);

// Reset every entry to null, KEEPING observer subscriptions live (so observers re-emit a "loading"
// state and a re-fetch refills). Used when the data SOURCE flips (demo mode) so prices from the old
// source can neither linger nor be preserved by keep-last-good.
void Clear();
}
82 changes: 82 additions & 0 deletions MarketExtension/Data/InMemoryQuoteCacheDataSource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;

namespace MarketExtension;

// In-memory IQuoteCacheDataSource: one MutableStateFlow<QuoteEntity?> per normalized symbol, lazily created.
// The single shared store of live quotes; MarketRepository owns one instance for the whole process.
// Mirrors the WatchlistStore state-holder idiom — a Lock guarding a dictionary, snapshot under the
// lock then Update() OUTSIDE it (Update fans handlers out that may re-enter Get/Observe, and
// System.Threading.Lock is non-reentrant, so updating under the lock could deadlock a re-entrant
// handler).
//
// Deadlock note (the reason this was briefly stubbed): the hang was NOT this cache's lock — Update()
// already fires outside it. It was that the per-symbol fan-out reached a surface's RaiseItemsChanged
// while Rx still held the CombineLatest/Switch gate lock in MarketRepository.ObserveQuotes, and that
// host call re-entered Command Palette's STA → a lock-ordering cycle. That is fixed at the seam:
// ObserveQuotes now delivers via ObserveOn, so surfaces are notified only AFTER the Rx gate locks are
// released (no host call under a producer-side lock). This cache can therefore stay the simple,
// correct in-memory version. Swap it for a database-backed IQuoteCacheDataSource later via MarketRepository's
// injectable ctor overload — no repository or UI change.
[SuppressMessage("Reliability", "CA1001:Types that own disposable fields should be disposable",
Justification = "Owns per-symbol MutableStateFlow<QuoteEntity?> (BehaviorSubject-backed) entries for " +
"the life of the process via the single MarketRepository; they are intentionally never " +
"completed or disposed — mirrors the StateFlow singleton convention (see StateFlow.cs).")]
internal sealed class InMemoryQuoteCacheDataSource : IQuoteCacheDataSource
{
private readonly Lock _lock = new();

// key = WatchlistStore.Normalize(symbol). Grows only with distinct observed symbols (watchlist +
// favorites + portfolio membership — tens), so no eviction is needed.
private readonly Dictionary<string, MutableStateFlow<QuoteEntity?>> _entries = [];

public QuoteEntity? Get(string symbol)
{
lock (_lock)
return _entries.TryGetValue(WatchlistStore.Normalize(symbol), out var flow) ? flow.Value : null;
}

public StateFlow<QuoteEntity?> Observe(string symbol)
{
lock (_lock)
return GetOrCreate(WatchlistStore.Normalize(symbol));
}

public void Upsert(QuoteEntity quote, bool keepLastGood = true)
{
var key = WatchlistStore.Normalize(quote.Symbol);

MutableStateFlow<QuoteEntity?> flow;
QuoteEntity? next = quote;
lock (_lock)
{
flow = GetOrCreate(key);
// Keep-last-good: a transient invalid quote must not overwrite a price that was fine.
// Decide the value to write under the lock (reads flow.Value); Update fires outside.
if (keepLastGood && !quote.IsValid && flow.Value is { IsValid: true })
next = flow.Value; // Update below no-ops (distinct-until-changed)
}

flow.Update(next); // fan handlers out OUTSIDE the lock (handlers re-read the cache)
}

public void Clear()
{
List<MutableStateFlow<QuoteEntity?>> flows;
lock (_lock)
flows = [.. _entries.Values]; // keep entries so observers stay subscribed; reset the values

foreach (var flow in flows)
flow.Update(null);
}

// Caller must hold _lock.
private MutableStateFlow<QuoteEntity?> GetOrCreate(string key)
{
if (!_entries.TryGetValue(key, out var flow))
_entries[key] = flow = new MutableStateFlow<QuoteEntity?>(null); // default comparer = record value equality
return flow;
}
}
Loading
Loading