Skip to content

feat(adapter): drain-tuning bench, derived defaults, admission fix - #2

Open
cabol wants to merge 1 commit into
mainfrom
feat/drain-tuning-bench
Open

cabol wants to merge 1 commit into
mainfrom
feat/drain-tuning-bench

Conversation

@cabol

@cabol cabol commented Aug 9, 2026 •

Copy link
Copy Markdown
Member

Summary

Two things ship here, one planned and one found along the way:

  1. Writes no longer vanish from under-capacity caches. The TinyLFU admission filter used to run on every window overflow and delete the loser from ETS even when the cache had plenty of room — on a frequency tie (any brand-new key) the write was silently discarded. Filling an empty 1,000-cap cache with 500 keys left ~11 resident entries; a unique-key burst wiped the hot set (2/500 retained). Admission now runs only while the cache is actually over max_size, matching Caffeine's evictFromWindow/evictFromMain. Same scenario after the fix: 500/500 resident, and 496/500 hot keys survive a 5,000-key flood.
  2. Policy lag is now bounded by a ~100ms drain check instead of the 1s timer. When the user doesn't set drain_* options, the maintenance supervisor derives Caffeine-aligned per-buffer thresholds; explicit drain_* values still win, and unbounded caches are untouched.

The defaults were chosen from evidence, not vibes: a new internal benchmark (benchmarks/drain_tuning.exs) measures hit rate, max_size overshoot, post-burst policy lag, hot-set retention, and drain churn across configs — headline numbers below. The admission bug was itself caught by that harness (near-zero burst retention in every config flagged the discard path).

Derived drain defaults

Buffer drain_threshold Grounding
Write max(1, min(128, max_size / partitions)) Caffeine WRITE_BUFFER_MAX ≈ 128/partition, capped by max_size
Read 64 Caffeine per-stripe read capacity; keeps the sketch fresh
Queue 1 Events already coalesced upstream — drain whenever work exists

All checked every max(50, processing_interval / 10) ms (100ms at defaults).

Headline evidence (18-core saturation harness, max_size: 10_000, relative deltas are the signal):

Metric interval-only (before) derived defaults
Post-burst policy lag 1,880 ms ~210 ms
Hot-set retention through 50k-key flood 48.5% 83.4%
Standing overshoot under saturation ~17.5k entries ~9k entries
Hot path (put ips, Benchee guardrail) 192.2 K 202.4 K

Grid isolation runs answered the plan's open questions: queue threshold 1 vs 50 is indistinguishable (the check interval is the rate limiter — picked 1 to kill the second timed stage), and dropping the read threshold costs 7pp burst retention plus 5× convergence lag for zero measurable hot-path savings, so it stays.

Design decisions

  • Admission candidates are threaded per batch: window promotions collect in promotion order and are evaluated against the probation LRU only in the over-capacity loop, mirroring Caffeine's candidate/victim walk (ties retain the victim). The eviction loop decrements a running total instead of re-reading three deque sizes per iteration.
  • The bench builds its grid from the real derivation (Maintenance.Supervisor.derive_drain_opts/2, @doc false public) so bench configs can't drift from shipped defaults.
  • No backpressure remains a deliberate divergence from Caffeine — it preserves the lock-free hot path. The moduledoc now documents the consequence: a saturating unique-key flood can outrun the single-writer policy engine regardless of drain tuning.

Test plan

  • 299 tests green (NEBULEX_PATH=... mix test), including new behavioral coverage: admission-only-over-capacity regression test, candidate skip paths (deleted / read-promoted mid-batch), admission→plain-LRU fallback within one resolution, derived defaults observed on running Tidefall partitions, explicit-override precedence, max_size cap and check-interval floor.
  • Full mix test.ci gate passes (warnings-as-errors, format, credo strict, coverage, sobelow, dialyzer).
  • Tier 2 multi-agent code review (9 reviewers, run 20260809-112505-d59444f8): 5 validated findings applied in fix(review); 1 rejected by independent validation (softening the single-writer assertions would mask real policy bugs).
  • Reproduce the evidence with MIX_ENV=test mix run benchmarks/drain_tuning.exs (~8 min; BENCH_QUICK=1 for a smoke run).

Post-Deploy Monitoring & Validation

Library change, pre-release (3.0.0-dev) — no production deployment. For early adopters validating an upgrade:

  • Healthy signals: ETS size (:ets.info(cache.data_table(), :size)) converging to ≤ max_size within ~2× the drain check interval after bursts; cache hit rate stable or improved; entries put on an under-capacity cache are immediately fetchable (the fixed bug).
  • Failure signals: sustained ETS size ≫ max_size under normal (non-saturating) load; MatchError in Nebulex.TinyLFU.Maintenance.admit_or_evict/3 outside cache shutdown (at shutdown it's the known Tidefall race below); hit-rate regression vs. the previous interval-only build.
  • Mitigation: per-buffer behavior can be tuned live via Tidefall.Buffer.update_options/2, or pinned by setting explicit drain_threshold/drain_check_interval in :buffer_opts (explicit values override all derivation).
  • Validation window/owner: next bench re-run on the maintainer's machine before cutting the first release.

Follow-ups

  • Tidefall shutdown race (upstream): Tidefall.Buffer.Partition.terminate/2 runs a final blocking drain while an in-flight runner task may still be processing, so two process_maintenance calls can overlap at cache shutdown and trip the single-writer assertions (noisy MatchError during bench teardown; harmless for a dying cache). Fix belongs in cabol/tidefall: await the runner task before the terminate drain.
  • Pin :tidefall back to a Hex release once one carries key_hasher: false support (standing TODO from tidefall PR).
  • The adapter's public :buffer_opts applies one drain_* pair to all three buffers; differentiated per-buffer overrides currently require Tidefall.Buffer.update_options/2 (as the bench does). Worth a public per-buffer option surface if users ask.

Compound Engineering
Claude Code

@codecov

codecov Bot commented Aug 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.45455% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
lib/nebulex/tiny_lfu/maintenance.ex 94.59% 2 Missing ⚠️
Flag Coverage Δ
unittests-elixir-1.19.x-otp-28.x 99.50% <95.45%> (-0.50%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
lib/nebulex/adapters/tiny_lfu.ex 100.00% <ø> (ø)
lib/nebulex/adapters/tiny_lfu/options.ex 100.00% <ø> (ø)
lib/nebulex/tiny_lfu/maintenance/supervisor.ex 100.00% <100.00%> (ø)
lib/nebulex/tiny_lfu/maintenance.ex 97.77% <94.59%> (-2.23%) ⬇️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@cabol
cabol force-pushed the feat/drain-tuning-bench branch 2 times, most recently from f0f5d07 to 6d1453e Compare August 9, 2026 16:57
Add a drain-tuning bench harness (mix bench) with recorded evidence in
benchmarks/RESULTS.md; derive Caffeine-aligned per-buffer drain defaults
in Maintenance.Supervisor (explicit drain_* opts still win); and fix the
admission policy to run only under capacity pressure — window overflow
now always promotes to probation, so writes are never discarded from an
under-capacity cache.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant