Skip to content

Remove Observer/LazyObject reactive pattern — [v2.0] #315

@amaggiulli

Description

@amaggiulli

Summary

This issue proposes the full removal of the Observer/LazyObject reactive notification pattern from QLNet, targeting a v2.0 release. This is a breaking change that significantly improves thread safety, readability, performance and long-term maintainability.


Why the pattern exists

QLNet inherited the Observer/LazyObject pattern from C++ QuantLib, which was designed for Excel RTD (Real-Time Data) use cases:

Cell A1 = yield curve rate  → changes
Cell B1 = bond NPV          → auto-notified, recalculates
Cell C1 = duration          → auto-notified, recalculates

When a single market input changes, the dirty flag propagates through a dependency chain, and all dependent objects recalculate on next access. This makes perfect sense in a live spreadsheet.


Why it does not fit QLNet's use case

QLNet targets C# microservices and applications, not Excel. The typical lifecycle is:

Request in → build objects → calculate → return result → GC everything

Objects do not outlive a request. Nobody registers observers on pricing objects. The entire notification chain fires zero times in practice, yet carries its full complexity cost in every deployment.


Scope (measured from codebase)

Metric Count
registerWith() occurrences 467 across 196 files
notifyObservers() occurrences 150 across 43 files
update() implementations 62
Files implementing IObservable/IObserver 26
Infrastructure files to delete 4 (Observer.cs, WeakEventSource.cs, ObservableValue.cs, LazyObject.cs simplified)

What we gain permanently

Thread safety — the full picture

Removing the observer pattern eliminates the largest category of thread-safety issues:

1. LazyObject — foundational flaw (45+ subclasses affected)
calculated_ and frozen_ are plain bool fields with no synchronisation. Two threads can simultaneously pass the if (!calculated_ && !frozen_) guard and both execute performCalculations(), corrupting the cached state. This affects every Instrument, PricingEngine, TermStructure, and calibration helper in the library.

// Thread A and Thread B both reach here at the same time:
if (!calculated_ && !frozen_)   // both evaluate TRUE
{
    calculated_ = true;
    performCalculations();       // runs TWICE — state corrupted
}

2. Handle.Link.linkTo() — broken observer chain under concurrency
linkTo() is not atomic. Two threads racing on linkTo() can leave h_ pointing to one object while the observer is registered with a different object — notifications are silently lost and the dependency graph is wrong.

// Thread A: linkTo(objectX, true)
// Thread B: linkTo(objectY, true)
// Result: h_ = objectY, but registered with objectX → notifications lost

3. ObservableValue<T> — unprotected read/write on backing field
Assign(t) writes value_ while value() reads it with no synchronisation. Lower severity in practice because writes are usually done during setup, but unsafe if any background thread refreshes market data.

4. WeakEventSource — recursive notification chains
Although WeakEventSource itself locks its handler list, the notification chain it drives (InstrumentTermStructureIndex → ...) can form cycles and re-enter locks on the same thread, or deadlock if two chains notify each other concurrently.


Beyond the observer pattern, the following independent static shared state issues exist and are also addressed in v2.0:

Class File Issue Fix
IndexManager.data_ Indexmanager.cs Plain DictionaryContainsKey+Add not atomic, Clear unsafe mid-read. Thread A calling getHistory() while Thread B calls clearHistories() → exception or null reference. CRITICAL — all index fixing access goes through here. ConcurrentDictionary + lock compound ops
SeedGenerator.rng_ SeedGenerator.cs Singleton with shared MersenneTwisterUniformRng rng_. get() both reads and advances the RNG state with no lock → two threads get the same seed or corrupt the RNG internal state. lock on get()
ExchangeRateManager ExchangeRateManager.cs Shared Dictionary<int, List<Entry>> — concurrent add/lookup unprotected. ReaderWriterLockSlim or ConcurrentDictionary

After both the observer removal and these fixes, the library will be fully thread-safe for concurrent calculation workloads.

Readability — every class loses ~20% noise

Every instrument, term structure, model and pricer currently contains boilerplate registerWith(...) / update() / notifyObservers() calls unrelated to financial logic. Removing them makes the financial math the only thing in each class.

Performance

  • Zero WeakReference allocations per object
  • No virtual dispatch through observer chains on market data changes
  • No WeakEventSource list traversal
  • Predictable, deterministic calculation — no hidden re-triggers

Microservice fit

Objects become effectively immutable after construction — safe to use from any thread with no setup, no SavedSettings backup/restore needed in production code.

Simpler onboarding

New contributors currently need to understand the full observer chain before safely modifying any pricing class. After removal: read performCalculations(), that is the entire contract.


What is removed

  • IObservable / IObserver interfaces, Callback delegate
  • WeakEventSource.cs
  • ObservableValue<T>
  • LazyObject: frozen_, update(), notifyObservers(), freeze(), unfreeze(), event source
  • All registerWith() / unregisterWith() / notifyObservers() / update() calls across all 196 affected files
  • Settings.notifyObservers() on setEvaluationDate()

What is kept


Migration guide for users

If you used registerWith / update(): Remove these calls. The reactive push pattern is gone. Call instrument.recalculate() explicitly after changing inputs, or rebuild the object.

If you used freeze() / unfreeze(): Remove these calls. Objects cache automatically for their lifetime.

If you relied on cascading recalculation: Replace with explicit recalculate() after input changes, or build → calculate → discard.


Implementation plan

  • Phase 1 — Delete pattern infrastructure (Observer.cs, WeakEventSource.cs, ObservableValue.cs, simplify LazyObject.cs)
  • Phase 2 — Strip observer calls from all 196 source files, grouped by domain
  • Phase 3 — Update test suite, add concurrent-access tests
  • Phase 4 — Fix independent static shared state thread-safety issues (IndexManager, SeedGenerator, ExchangeRateManager)
  • Phase 5 — Update ChangeLog.txt, bump to v2.0

Feedback welcome before implementation starts.

Metadata

Metadata

Assignees

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions