Class: CovLoupe::ModelDataCache

Inherits:
Object
  • Object
show all
Defined in:
lib/cov_loupe/model/model_data_cache.rb

Overview

Thread-safe singleton cache for ModelData instances. Entries are keyed by [resultset_path, root] and automatically invalidated when the file changes.

Cache invalidation uses a two-layer check:

1. Signature: file mtime + size + inode (cheap, no file read)
2. Digest: MD5 hash of file contents (catches same-mtime edits on coarse-grained filesystems)

Both must match for a cache hit. If either differs, fresh data is loaded.

The cache key includes both resultset_path and root because path normalization and case-sensitivity detection depend on the root directory. Two models with the same resultset but different roots may have different normalized coverage maps.

Why a singleton? CoverageModel instances are lightweight (created per request in MCP mode), but loading and normalizing the resultset is expensive. The singleton cache ensures that repeated requests for the same resultset reuse the parsed data until the file changes.

Constant Summary collapse

INSTANCE_MUTEX =

Mutex for thread-safe singleton initialization. Using a constant ensures it cannot be reset, avoiding race conditions in JRuby.

Mutex.new

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initializeModelDataCache

Returns a new instance of ModelDataCache.



29
30
31
32
# File 'lib/cov_loupe/model/model_data_cache.rb', line 29

def initialize
  @entries = {}
  @mutex = Mutex.new
end

Class Method Details

.instanceObject

Returns the singleton instance with thread-safe initialization



35
36
37
38
39
# File 'lib/cov_loupe/model/model_data_cache.rb', line 35

def self.instance
  INSTANCE_MUTEX.synchronize do
    @instance ||= new
  end
end

Instance Method Details

#clearObject

Clears all cached entries (primarily for testing)



92
93
94
# File 'lib/cov_loupe/model/model_data_cache.rb', line 92

def clear
  @mutex.synchronize { @entries.clear }
end

#get(resultset_path, root:, logger: nil) ⇒ ModelData

Note:

Complexity: O(1) amortized for cache hits. For cache misses, O(n) where n is the size of the resultset file, plus O(m) for parsing where m is total lines. File stat and MD5 digest are O(1) relative to file size on most filesystems.

Note:

Thread-safety: Thread-safe. This method uses a Mutex to synchronize access to the internal cache entries hash. Concurrent calls from multiple threads are guaranteed to return consistent results without data races.

Fetches ModelData for the given resultset path. Checks signature/digest on every call and reloads if the file has changed.

Parameters:

  • resultset_path (String)

    Absolute path to .resultset.json

  • root (String)

    Project root directory for path normalization

  • logger (Logger, nil) (defaults to: nil)

    Logger instance for data loading operations

Returns:

  • (ModelData)

    The cached or freshly loaded data



55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/cov_loupe/model/model_data_cache.rb', line 55

def get(resultset_path, root:, logger: nil)
  @mutex.synchronize do
    # Cache key must include both resultset_path and root because
    # path normalization and case-sensitivity depend on the root
    cache_key = [resultset_path, root]
    entry = @entries[cache_key]

    # Signature (mtime/size/inode) is cheap — no file read required. Digest (MD5)
    # is a fallback guard for filesystems with coarse mtime precision where two
    # different writes can land with the same timestamp.
    signature = compute_signature(resultset_path)
    digest = compute_digest(resultset_path)

    # Both must match: signature catches most changes; digest catches same-mtime edits.
    if entry && signature && digest &&
        entry[:signature] == signature &&
        entry[:digest] == digest
      return entry[:data]
    end

    # Load fresh data using the provided logger
    data = load_data(resultset_path, root, logger)

    # Store with signature/digest if we computed them
    if signature && digest
      @entries[cache_key] = {
        data:      data,
        signature: signature,
        digest:    digest,
      }
    end

    data
  end
end