towerops/lib/snmpkit/snmp_lib/cache.ex
Graham McIntire fb1d4c564f
security: fix critical vulnerabilities and atom exhaustion risks
Critical fixes:
- Add [:safe] option to binary_to_term to prevent RCE attacks
- Implement whitelist validation for String.to_atom conversions
- Add input validation before String.to_existing_atom usage

Changes:
- MIB compiler and cache: Use safe binary deserialization
- SNMP contexts: Whitelist protocol, device type, and source atoms
- API controllers: Validate error message keys before atom conversion
- Reduce function nesting to comply with Credo standards

All 6,145 tests passing with zero Credo issues.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-08 10:30:30 -06:00

760 lines
21 KiB
Elixir

defmodule SnmpKit.SnmpLib.Cache do
@moduledoc """
Intelligent caching system for SNMP operations with adaptive strategies.
This module provides sophisticated caching capabilities designed to optimize
SNMP polling performance in high-throughput environments. Based on patterns
proven in the DDumb project for managing thousands of concurrent device polls.
## Features
- **Multi-Level Caching**: L1 (in-memory), L2 (ETS), L3 (persistent storage)
- **Adaptive TTL**: Dynamic cache expiration based on data volatility
- **Smart Invalidation**: Automatic cache invalidation based on data patterns
- **Compression**: Efficient storage of large SNMP responses
- **Hot/Cold Data Management**: Automatic promotion of frequently accessed data
- **Cache Warming**: Proactive loading of expected data
## Caching Strategies
### Time-Based Caching
Standard TTL-based caching for static or slowly changing data.
### Volatility-Based Caching
Dynamic TTL adjustment based on observed change frequency.
### Dependency-Based Caching
Cache invalidation based on related data changes.
### Predictive Caching
Pre-loading data based on access patterns and time of day.
## Performance Benefits
- **50-80% reduction** in redundant SNMP queries
- **Improved response times** for frequently accessed data
- **Reduced network load** on monitored devices
- **Better scalability** for large device inventories
## Usage Patterns
# Cache SNMP response data
SnmpKit.SnmpLib.Cache.put("device_123:sysDescr", response_data, ttl: 300_000)
# Retrieve cached data
case SnmpKit.SnmpLib.Cache.get("device_123:sysDescr") do
{:ok, data} -> data
:miss -> perform_snmp_query()
end
# Cache with adaptive TTL
SnmpKit.SnmpLib.Cache.put_adaptive("device_123:ifTable", interface_data,
base_ttl: 60_000,
volatility: :medium
)
# Warm cache for predictable access
SnmpKit.SnmpLib.Cache.warm_cache("device_123", [:sysDescr, :sysUpTime, :ifTable])
# Invalidate related caches
SnmpKit.SnmpLib.Cache.invalidate_pattern("device_123:*")
## Cache Key Patterns
- `device_id:oid` - Single OID values
- `device_id:table:index` - Table row data
- `device_id:walk:base_oid` - Walk results
- `device_id:bulk:oids` - Bulk query results
- `global:topology` - Cross-device topology data
"""
use GenServer
require Logger
@cache_table :snmp_lib_cache
@stats_table :snmp_lib_cache_stats
@access_table :snmp_lib_cache_access
# 5 minutes
@default_ttl 300_000
# Max cache entries
@default_max_size 100_000
# 1 minute
@default_cleanup_interval 60_000
# Compress data larger than 1KB
@compression_threshold 1024
@type cache_key :: binary()
@type cache_value :: any()
@type cache_ttl :: pos_integer()
@type volatility :: :low | :medium | :high | :extreme
@type cache_strategy :: :time_based | :volatility_based | :dependency_based | :predictive
@type cache_opts :: [
ttl: cache_ttl(),
strategy: cache_strategy(),
volatility: volatility(),
compress: boolean(),
dependencies: [cache_key()],
tags: [atom()]
]
@type cache_stats :: %{
total_entries: non_neg_integer(),
hit_rate: float(),
miss_rate: float(),
eviction_count: non_neg_integer(),
memory_usage_mb: float(),
compression_ratio: float()
}
defstruct [
:max_size,
:cleanup_interval,
:compression_enabled,
:adaptive_ttl_enabled,
:predictive_enabled,
hit_count: 0,
miss_count: 0,
eviction_count: 0,
last_cleanup: nil
]
## Public API
@doc """
Starts the cache manager with specified configuration.
## Options
- `max_size`: Maximum number of cache entries (default: 100,000)
- `cleanup_interval`: Cleanup frequency in milliseconds (default: 60,000)
- `compression_enabled`: Enable compression for large values (default: true)
- `adaptive_ttl_enabled`: Enable adaptive TTL based on volatility (default: true)
- `predictive_enabled`: Enable predictive caching (default: false)
## Examples
# Start with defaults
{:ok, _pid} = SnmpKit.SnmpLib.Cache.start_link()
# Start with custom configuration
{:ok, _pid} = SnmpKit.SnmpLib.Cache.start_link(
max_size: 50_000,
compression_enabled: true,
predictive_enabled: true
)
"""
@spec start_link(keyword()) :: {:ok, pid()} | {:error, any()}
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc """
Stores a value in the cache with specified options.
## Parameters
- `key`: Unique cache key
- `value`: Data to cache
- `opts`: Caching options
## Options
- `ttl`: Time-to-live in milliseconds (default: 300,000)
- `strategy`: Caching strategy (default: :time_based)
- `volatility`: Data change frequency (default: :medium)
- `compress`: Force compression for this entry (default: auto)
- `dependencies`: Keys that invalidate this entry when changed
- `tags`: Metadata tags for grouping and invalidation
## Examples
# Simple time-based caching
:ok = SnmpKit.SnmpLib.Cache.put("device_1:sysDescr", "Cisco Router", ttl: 600_000)
# Adaptive caching based on volatility
:ok = SnmpKit.SnmpLib.Cache.put("device_1:ifTable", interface_data,
strategy: :volatility_based,
volatility: :high,
tags: [:interface_data]
)
# Dependency-based caching
:ok = SnmpKit.SnmpLib.Cache.put("device_1:route_summary", summary_data,
dependencies: ["device_1:routeTable", "device_1:arpTable"]
)
"""
@spec put(cache_key(), cache_value(), cache_opts()) :: :ok
def put(key, value, opts \\ []) do
GenServer.call(__MODULE__, {:put, key, value, opts})
end
@doc """
Retrieves a value from the cache.
## Returns
- `{:ok, value}`: Cache hit with the stored value
- `:miss`: Cache miss, value not found or expired
## Examples
case SnmpKit.SnmpLib.Cache.get("device_1:sysDescr") do
{:ok, description} ->
Logger.debug("Cache hit for system description")
description
:miss ->
Logger.debug("Cache miss, performing SNMP query")
perform_snmp_get(device, [1,3,6,1,2,1,1,1,0])
end
"""
@spec get(cache_key()) :: {:ok, cache_value()} | :miss
def get(key) do
GenServer.call(__MODULE__, {:get, key})
end
@doc """
Stores a value with adaptive TTL based on observed volatility.
The cache automatically adjusts TTL based on how frequently the data changes.
## Parameters
- `key`: Cache key
- `value`: Data to cache
- `base_ttl`: Starting TTL value
- `volatility`: Expected change frequency
## Examples
# Interface counters change frequently
SnmpKit.SnmpLib.Cache.put_adaptive("device_1:ifInOctets", counter_data,
base_ttl: 30_000,
volatility: :high
)
# System description rarely changes
SnmpKit.SnmpLib.Cache.put_adaptive("device_1:sysDescr", description,
base_ttl: 3_600_000,
volatility: :low
)
"""
@spec put_adaptive(cache_key(), cache_value(), cache_ttl(), volatility()) :: :ok
def put_adaptive(key, value, base_ttl, volatility) do
adaptive_ttl = calculate_adaptive_ttl(key, base_ttl, volatility)
put(key, value, ttl: adaptive_ttl, strategy: :volatility_based, volatility: volatility)
end
@doc """
Removes a specific key from the cache.
## Examples
:ok = SnmpKit.SnmpLib.Cache.delete("device_1:sysDescr")
"""
@spec delete(cache_key()) :: :ok
def delete(key) do
GenServer.call(__MODULE__, {:delete, key})
end
@doc """
Invalidates multiple cache entries matching a pattern.
Supports wildcards (*) for pattern matching.
## Examples
# Invalidate all data for a device
SnmpKit.SnmpLib.Cache.invalidate_pattern("device_1:*")
# Invalidate all interface data
SnmpKit.SnmpLib.Cache.invalidate_pattern("*:ifTable")
# Invalidate by tag
SnmpKit.SnmpLib.Cache.invalidate_by_tag(:interface_data)
"""
@spec invalidate_pattern(binary()) :: :ok
def invalidate_pattern(pattern) do
GenServer.call(__MODULE__, {:invalidate_pattern, pattern})
end
@doc """
Invalidates cache entries by tag.
## Examples
SnmpKit.SnmpLib.Cache.invalidate_by_tag(:routing_data)
"""
@spec invalidate_by_tag(atom()) :: :ok
def invalidate_by_tag(tag) do
GenServer.call(__MODULE__, {:invalidate_by_tag, tag})
end
@doc """
Pre-loads cache with expected data to improve response times.
## Parameters
- `device_id`: Target device identifier
- `oids`: List of OIDs to pre-load
- `strategy`: Warming strategy (:immediate, :scheduled, :predictive)
## Examples
# Immediate cache warming
SnmpKit.SnmpLib.Cache.warm_cache("device_1",
["1.3.6.1.2.1.1.1.0", "1.3.6.1.2.1.1.3.0"],
strategy: :immediate
)
# Predictive warming based on historical access
SnmpKit.SnmpLib.Cache.warm_cache("device_1", :auto,
strategy: :predictive
)
"""
@spec warm_cache(binary(), [binary()] | :auto, keyword()) :: :ok
def warm_cache(device_id, oids, opts \\ []) do
GenServer.cast(__MODULE__, {:warm_cache, device_id, oids, opts})
end
@doc """
Gets comprehensive cache performance statistics.
## Returns
Statistics including hit rates, memory usage, and performance metrics.
## Examples
cache_stats = SnmpKit.SnmpLib.Cache.get_stats()
IO.puts "Cache hit rate: " <> Float.to_string(Float.round(cache_stats.hit_rate * 100, 2)) <> "%"
IO.puts "Memory usage: " <> Float.to_string(Float.round(cache_stats.memory_usage_mb, 2)) <> " MB"
"""
@spec get_stats() :: cache_stats()
def get_stats do
GenServer.call(__MODULE__, :get_stats)
end
@doc """
Clears all cached data.
## Examples
:ok = SnmpKit.SnmpLib.Cache.clear()
"""
@spec clear() :: :ok
def clear do
GenServer.call(__MODULE__, :clear)
end
## GenServer Implementation
@impl GenServer
def init(opts) do
# Create ETS tables
_ = :ets.new(@cache_table, [:named_table, :set, :public, read_concurrency: true])
_ = :ets.new(@stats_table, [:named_table, :set, :public])
_ = :ets.new(@access_table, [:named_table, :bag, :public])
state = %__MODULE__{
max_size: Keyword.get(opts, :max_size, @default_max_size),
cleanup_interval: Keyword.get(opts, :cleanup_interval, @default_cleanup_interval),
compression_enabled: Keyword.get(opts, :compression_enabled, true),
adaptive_ttl_enabled: Keyword.get(opts, :adaptive_ttl_enabled, true),
predictive_enabled: Keyword.get(opts, :predictive_enabled, false),
last_cleanup: System.system_time(:millisecond)
}
# Schedule cleanup
schedule_cleanup(state.cleanup_interval)
Logger.info("SnmpKit.SnmpLib.Cache started with max_size=#{state.max_size}")
{:ok, state}
end
@impl GenServer
def handle_call({:put, key, value, opts}, _from, state) do
result = store_cache_entry(key, value, opts, state)
{:reply, result, state}
end
@impl GenServer
def handle_call({:get, key}, _from, state) do
{result, new_state} = retrieve_cache_entry(key, state)
{:reply, result, new_state}
end
@impl GenServer
def handle_call({:delete, key}, _from, state) do
:ets.delete(@cache_table, key)
:ets.delete(@access_table, key)
{:reply, :ok, state}
end
@impl GenServer
def handle_call({:invalidate_pattern, pattern}, _from, state) do
invalidate_by_pattern(pattern)
{:reply, :ok, state}
end
@impl GenServer
def handle_call({:invalidate_by_tag, tag}, _from, state) do
invalidate_entries_by_tag(tag)
{:reply, :ok, state}
end
@impl GenServer
def handle_call(:get_stats, _from, state) do
stats = calculate_cache_stats(state)
{:reply, stats, state}
end
@impl GenServer
def handle_call(:clear, _from, state) do
:ets.delete_all_objects(@cache_table)
:ets.delete_all_objects(@access_table)
new_state = %{state | hit_count: 0, miss_count: 0, eviction_count: 0}
{:reply, :ok, new_state}
end
@impl GenServer
def handle_cast({:warm_cache, device_id, oids, opts}, state) do
perform_cache_warming(device_id, oids, opts)
{:noreply, state}
end
@impl GenServer
def handle_info(:cleanup, state) do
new_state = perform_cleanup(state)
schedule_cleanup(state.cleanup_interval)
{:noreply, new_state}
end
## Private Implementation
# Cache entry management
defp store_cache_entry(key, value, opts, state) do
ttl = Keyword.get(opts, :ttl, @default_ttl)
strategy = Keyword.get(opts, :strategy, :time_based)
volatility = Keyword.get(opts, :volatility, :medium)
compress = Keyword.get(opts, :compress, should_compress?(value, state))
dependencies = Keyword.get(opts, :dependencies, [])
tags = Keyword.get(opts, :tags, [])
# Calculate expiration
expires_at = System.system_time(:millisecond) + ttl
# Compress if needed
stored_value =
if compress do
compress_value(value)
else
value
end
# Create cache entry
cache_entry = %{
key: key,
value: stored_value,
expires_at: expires_at,
strategy: strategy,
volatility: volatility,
compressed: compress,
dependencies: dependencies,
tags: tags,
created_at: System.system_time(:millisecond),
access_count: 0,
last_accessed: System.system_time(:millisecond)
}
# Check cache size limits
if :ets.info(@cache_table, :size) >= state.max_size do
evict_lru_entries(state)
end
# Store entry
:ets.insert(@cache_table, {key, cache_entry})
# Record dependencies
Enum.each(dependencies, fn dep_key ->
:ets.insert(@access_table, {dep_key, {:dependent, key}})
end)
:ok
end
defp retrieve_cache_entry(key, state) do
case :ets.lookup(@cache_table, key) do
[{^key, cache_entry}] ->
current_time = System.system_time(:millisecond)
if current_time <= cache_entry.expires_at do
# Cache hit - update access stats
updated_entry = %{
cache_entry
| access_count: cache_entry.access_count + 1,
last_accessed: current_time
}
:ets.insert(@cache_table, {key, updated_entry})
# Record access for adaptive TTL
:ets.insert(@access_table, {key, {:access, current_time}})
# Decompress if needed
value =
if cache_entry.compressed do
decompress_value(cache_entry.value)
else
cache_entry.value
end
new_state = %{state | hit_count: state.hit_count + 1}
{{:ok, value}, new_state}
else
# Expired - remove and return miss
:ets.delete(@cache_table, key)
new_state = %{state | miss_count: state.miss_count + 1}
{:miss, new_state}
end
[] ->
# Cache miss
new_state = %{state | miss_count: state.miss_count + 1}
{:miss, new_state}
end
end
# Adaptive TTL calculation
defp calculate_adaptive_ttl(key, base_ttl, volatility) do
# Get historical access patterns
access_pattern = analyze_access_pattern(key)
# Adjust TTL based on volatility and access patterns
volatility_multiplier =
case volatility do
:low -> 2.0
:medium -> 1.0
:high -> 0.5
:extreme -> 0.1
end
access_multiplier =
case access_pattern do
# Shorter TTL for frequently accessed data
:frequent -> 0.8
:normal -> 1.0
# Longer TTL for rarely accessed data
:rare -> 1.5
end
round(base_ttl * volatility_multiplier * access_multiplier)
end
defp analyze_access_pattern(key) do
current_time = System.system_time(:millisecond)
one_hour_ago = current_time - 3_600_000
recent_accesses =
:ets.select(@access_table, [
{{key, {:access, :"$1"}}, [{:>=, :"$1", one_hour_ago}], [:"$1"]}
])
access_count = length(recent_accesses)
cond do
access_count > 20 -> :frequent
access_count > 5 -> :normal
true -> :rare
end
end
# Compression
defp should_compress?(value, state) do
state.compression_enabled and
byte_size(:erlang.term_to_binary(value)) > @compression_threshold
end
defp compress_value(value) do
value
|> :erlang.term_to_binary()
|> :zlib.compress()
end
defp decompress_value(compressed_value) do
# Use safe: true to prevent arbitrary code execution via malicious cached terms
# sobelow_skip ["Misc.BinToTerm"]
compressed_value
|> :zlib.uncompress()
|> :erlang.binary_to_term([:safe])
end
# Pattern matching and invalidation
defp invalidate_by_pattern(pattern) do
regex = pattern_to_regex(pattern)
matching_keys =
@cache_table
|> :ets.select([
{{:"$1", :_}, [], [:"$1"]}
])
|> Enum.filter(fn key ->
Regex.match?(regex, key)
end)
Enum.each(matching_keys, fn key ->
:ets.delete(@cache_table, key)
:ets.delete(@access_table, key)
end)
Logger.debug("Invalidated #{length(matching_keys)} cache entries matching pattern '#{pattern}'")
end
defp pattern_to_regex(pattern) do
escaped = Regex.escape(pattern)
regex_pattern = String.replace(escaped, "\\*", ".*")
Regex.compile!("^#{regex_pattern}$")
end
defp invalidate_entries_by_tag(tag) do
matching_entries =
@cache_table
|> :ets.select([
{{:"$1", %{tags: :"$2"}}, [], [{{:"$1", :"$2"}}]}
])
|> Enum.filter(fn {_key, tags} ->
tag in tags
end)
Enum.each(matching_entries, fn {key, _tags} ->
:ets.delete(@cache_table, key)
:ets.delete(@access_table, key)
end)
Logger.debug("Invalidated #{length(matching_entries)} cache entries with tag '#{tag}'")
end
# Cache warming
defp perform_cache_warming(device_id, oids, opts) do
strategy = Keyword.get(opts, :strategy, :immediate)
case {oids, strategy} do
{:auto, :predictive} ->
warm_predictive_cache(device_id)
{oid_list, :immediate} when is_list(oid_list) ->
warm_immediate_cache(device_id, oid_list)
{oid_list, :scheduled} when is_list(oid_list) ->
schedule_cache_warming(device_id, oid_list)
_ ->
Logger.warning("Invalid cache warming configuration")
end
end
defp warm_immediate_cache(device_id, oids) do
# This would perform actual SNMP queries to warm the cache
# For now, we'll just log the operation
Logger.debug("Warming cache for device #{device_id} with #{length(oids)} OIDs")
end
defp warm_predictive_cache(device_id) do
# Analyze historical access patterns to determine what to pre-load
Logger.debug("Performing predictive cache warming for device #{device_id}")
end
defp schedule_cache_warming(device_id, _oids) do
# Schedule cache warming for later execution
Logger.debug("Scheduled cache warming for device #{device_id}")
end
# Cleanup and eviction
defp perform_cleanup(state) do
current_time = System.system_time(:millisecond)
# Remove expired entries
expired_keys =
:ets.select(@cache_table, [
{{:"$1", %{expires_at: :"$2"}}, [{:<, :"$2", current_time}], [:"$1"]}
])
Enum.each(expired_keys, fn key ->
:ets.delete(@cache_table, key)
:ets.delete(@access_table, key)
end)
# Clean up old access records
# 1 hour
cleanup_threshold = current_time - 3_600_000
old_access_records =
:ets.select(@access_table, [
{{:_, {:access, :"$1"}}, [{:<, :"$1", cleanup_threshold}], [:"$_"]}
])
Enum.each(old_access_records, fn record ->
:ets.delete_object(@access_table, record)
end)
Logger.debug(
"Cache cleanup: removed #{length(expired_keys)} expired entries and #{length(old_access_records)} old access records"
)
%{state | last_cleanup: current_time}
end
defp evict_lru_entries(state) do
# Get least recently used entries
lru_entries =
@cache_table
|> :ets.select([
{{:"$1", %{last_accessed: :"$2"}}, [], [{{:"$1", :"$2"}}]}
])
|> Enum.sort_by(fn {_key, last_accessed} -> last_accessed end)
# Evict 10% of cache
|> Enum.take(div(state.max_size, 10))
Enum.each(lru_entries, fn {key, _last_accessed} ->
:ets.delete(@cache_table, key)
:ets.delete(@access_table, key)
end)
Logger.debug("Evicted #{length(lru_entries)} LRU cache entries")
end
# Statistics calculation
defp calculate_cache_stats(state) do
total_requests = state.hit_count + state.miss_count
hit_rate = if total_requests > 0, do: state.hit_count / total_requests, else: 0.0
miss_rate = if total_requests > 0, do: state.miss_count / total_requests, else: 0.0
cache_size = :ets.info(@cache_table, :size)
memory_words = :ets.info(@cache_table, :memory)
memory_mb = memory_words * :erlang.system_info(:wordsize) / (1024 * 1024)
# Calculate compression ratio
compressed_entries =
:ets.select(@cache_table, [
{{:_, %{compressed: true}}, [], [true]}
])
compression_ratio = if cache_size > 0, do: length(compressed_entries) / cache_size, else: 0.0
%{
total_entries: cache_size,
hit_rate: hit_rate,
miss_rate: miss_rate,
eviction_count: state.eviction_count,
memory_usage_mb: memory_mb,
compression_ratio: compression_ratio
}
end
# Scheduling
defp schedule_cleanup(interval) do
Process.send_after(self(), :cleanup, interval)
end
end