Ferricstore.MemoryGuard (ferricstore v0.3.2)

Copy Markdown View Source

Memory pressure monitor and eviction controller for FerricStore.

MemoryGuard runs as a GenServer that checks memory usage every 100ms (configurable) and takes graduated action based on pressure thresholds.

Data sources

compute_stats/1 aggregates memory from multiple sources:

  • ETS keydir bytes -- per-shard ETS table memory (:ets.info(table, :memory))
  • Rust NIF allocator -- NIF.rust_allocated_bytes() via global AtomicUsize counter in tracking_alloc.rs. Returns -1 when tracking is not installed (production cdylib), 0+ when active (tests).
  • Process RSS -- process_rss_bytes() reads /proc/self/status (Linux) or :memsup data. Includes ETS, NIF allocations, BEAM heaps, and page cache residency. Only used in standalone mode (not embedded).
  • Cgroup limits -- detects container memory limits via /sys/fs/cgroup/ and uses that as the effective memory ceiling instead of host RAM.

Pressure levels and actions

LevelThresholdEvictionPromotionEvict targetWrites
:ok< 70%NoneHot (normal)Accepted
:warning70-85%Gentle (LFU)Hot (normal)Down to 65%Accepted
:pressure85-95%Aggressive (LFU)Skip (cold)Down to 75%Accepted
:reject> 95%Emergency (LFU)Skip (cold)Down to 80%Accepted*

*Writes rejected only when eviction_policy is :noeviction.

Eviction algorithm

Target-based eviction with LFU ordering:

  1. Compute bytes_to_free = current_bytes - target_bytes based on pressure level.
  2. Sample eligible hot entries from ETS (value != nil, not :pending).
  3. Sort by effective LFU counter ascending (lowest frequency first).
  4. Evict by setting value to nil in ETS (key stays, disk location stays).
  5. Subtract each evicted value's value_size from deficit.
  6. Stop when deficit reaches zero or no more eligible entries.

Sample sizes scale with pressure:

  • :warning -- sample 50 per shard, evict as needed
  • :pressure -- sample 200 per shard, evict as needed
  • :reject -- sample 1000 per shard, evict as needed

Promotion skip (anti-thrashing)

At :pressure and :reject levels, the skip_promotion atomics flag is set. Cold reads (value=nil in ETS, pread from Bitcask) return the value to the caller but do NOT re-cache it in ETS. This prevents evict/re-promote thrashing where MemoryGuard evicts values and the next GET immediately re-caches them.

The flag is read via MemoryGuard.skip_promotion?() (~5ns atomics read) by:

  • Router.warm_ets_after_cold_read/5 (direct ETS read path)
  • Shard.cold_read_warm_ets/7 (GenServer read path)

Page cache hints (fadvise)

All pread NIFs (Bitcask cold reads + prob structure reads) use:

  • FADV_RANDOM on file open -- disables kernel readahead (hash-indexed access)
  • FADV_DONTNEED after pread -- hints kernel to evict pages immediately

This keeps page cache free for genuinely hot data. Linux-only (no-ops on macOS).

Atomics flags (lock-free hot-path reads)

Three flags published to atomics via persistent_term (~5ns read):

  • slot 1: keydir_full -- set at :reject (95%). Gates new key writes in Router.check_keydir_full/1. Updates to existing keys still allowed.
  • slot 2: reject_writes -- set at :reject + :noeviction policy. Gates ALL writes (even updates) in Router.check_keydir_full/1.
  • slot 3: skip_promotion -- set at :pressure (85%). Prevents cold reads from re-caching values in ETS.

Eviction policies

  • :volatile_lfu (default) -- Evict least frequently used keys with a TTL.
  • :volatile_lru -- Evict least recently used keys with a TTL.
  • :allkeys_lfu -- Evict least frequently used key regardless of TTL.
  • :allkeys_lru -- Evict least recently used key regardless of TTL.
  • :volatile_ttl -- Evict the key with the shortest remaining TTL first.
  • :noeviction -- Return OOM error when memory is full. No eviction.

Telemetry events

  • [:ferricstore, :memory, :check] -- every check cycle (100ms). Measurements: total_bytes, rss_bytes. Metadata: pressure_level, ratio, max_bytes, rss_ratio.

  • [:ferricstore, :memory, :pressure] -- every check, spec 2.4 levels. Measurements: total_bytes, max_bytes, ratio. Metadata: level (:ok | :warn | :pressure | :full).

  • [:ferricstore, :memory, :recovered] -- once when pressure drops to :ok.

  • [:ferricstore, :memory, :keydir_pressure] -- when keydir pressure is :pressure or :reject. Includes keydir_bytes, keydir_ratio.

  • [:ferricstore, :hot_cache, :limit_reduced] / :limit_restored -- hot cache budget changes due to pressure level transitions.

Configuration

  • :memory_guard_interval_ms -- check interval (default: 100ms)
  • :max_memory_bytes -- maximum total memory budget
  • :keydir_max_ram -- maximum ETS keydir memory
  • :eviction_policy -- eviction policy atom (default: :volatile_lfu)

Summary

Types

Spec 2.4 pressure level names used in telemetry metadata.

Functions

Returns a specification to start this module under a supervisor.

Returns the current eviction policy.

Forces an immediate memory check cycle.

Returns true if keydir memory usage is at or above 95% of keydir_max_ram.

Triggers an immediate memory check + eviction cycle without blocking the caller.

Reconfigures MemoryGuard with new budget parameters.

Returns true if memory pressure is at or above the reject threshold.

Directly sets the keydir_full flag. For use in tests only.

Directly sets the reject_writes flag. For use in tests only.

Directly sets the skip_promotion flag. For use in tests only.

Returns true when cold reads should NOT be promoted to hot cache.

Starts the MemoryGuard GenServer.

Returns the current memory usage stats for all shards.

Types

pressure_level()

@type pressure_level() :: :ok | :warning | :pressure | :reject

spec_level()

@type spec_level() :: :ok | :warn | :pressure | :full

Spec 2.4 pressure level names used in telemetry metadata.

Functions

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

eviction_policy()

@spec eviction_policy() :: atom()

Returns the current eviction policy.

force_check()

@spec force_check() :: :ok

Forces an immediate memory check cycle.

Useful in tests to synchronously update pressure levels after changing budgets.

keydir_full?()

@spec keydir_full?() :: boolean()

Returns true if keydir memory usage is at or above 95% of keydir_max_ram.

Reads from :persistent_term (~5ns) instead of GenServer.call (~1-5us). The value is updated by perform_check/1 every 100ms. The staleness window (100ms) is acceptable since memory pressure changes slowly.

When true, new key writes should be rejected, but updates to existing keys are still allowed.

nudge()

@spec nudge() :: :ok

Triggers an immediate memory check + eviction cycle without blocking the caller.

Called from the write path when a key is rejected due to memory pressure. This kicks off eviction immediately rather than waiting up to 100ms for the next periodic check. Uses cast so the write path returns the error to the client without waiting for the eviction to complete.

reconfigure(params)

@spec reconfigure(map()) :: :ok

Reconfigures MemoryGuard with new budget parameters.

Accepts a map with optional keys:

  • :keydir_max_ram -- maximum keydir ETS memory in bytes
  • :hot_cache_max_ram -- maximum hot_cache ETS memory (or :auto)
  • :hot_cache_min_ram -- minimum hot_cache budget
  • :max_memory_bytes -- total memory budget
  • :eviction_policy -- eviction policy atom

reject_writes?()

@spec reject_writes?() :: boolean()

Returns true if memory pressure is at or above the reject threshold.

Reads from :persistent_term (~5ns) instead of GenServer.call (~1-5us). The value is updated by perform_check/1 every 100ms.

set_keydir_full(value)

@spec set_keydir_full(boolean()) :: :ok

Directly sets the keydir_full flag. For use in tests only.

set_reject_writes(value)

@spec set_reject_writes(boolean()) :: :ok

Directly sets the reject_writes flag. For use in tests only.

set_skip_promotion(value)

@spec set_skip_promotion(boolean()) :: :ok

Directly sets the skip_promotion flag. For use in tests only.

skip_promotion?()

@spec skip_promotion?() :: boolean()

Returns true when cold reads should NOT be promoted to hot cache.

Set at :pressure level (85%+). Prevents evict/re-promote thrashing where MemoryGuard evicts values and the next GET re-caches them. Reads from atomics via persistent_term (~5ns).

start_link(opts \\ [])

@spec start_link(keyword()) :: GenServer.on_start()

Starts the MemoryGuard GenServer.

stats()

@spec stats() :: map()

Returns the current memory usage stats for all shards.