From e4835d8211273edb96edc86422514d132288cafa Mon Sep 17 00:00:00 2001 From: Jim Huang Date: Sat, 4 Jul 2026 00:03:20 +0800 Subject: [PATCH] Do time-driven purge of expired cache entries An idle mount only reclaimed expired TTL-cache entries when a later read happened to sweep them (the opportunistic sweep in dnsfs_cache_lookup) or under memory pressure. The timer callback runs in softirq context and does the I6 minimum: it sets an atomic purge_pending flag and wakes the resolver kthread, with no cache walk, allocation, or sleeping. The kthread reaps every entry whose TTL has elapsed and that is not mid-refresh, under query_lock in process context, then re-arms while entries remain. Arming happens from the cache-store paths, guarded on the kthread's existence and made idempotent via timer_pending. The sweep skips refreshing entries, and DNSFS_PURGE_INTERVAL_SEC (5s) sits above the gap between an entry's expiry and a demand read, so an entry read soon after expiry is still served stale; entries idle past the interval are reclaimed. A bounded stale window, documented at the constant. Teardown is ordered: dnsfs_free_config calls timer_shutdown_sync after kthread_stop (the only side that re-arms), so no sweep can fire or re-arm before the cache it walks is drained. timer_setup runs in dnsfs_cache_init. The softirq/process split uses an atomic flag rather than taking the process-context query_queue_lock in the timer, avoiding a lock-context inversion. --- src/dns.c | 63 ++++++++++++++++++++++++++++++++++++++++++++++--- src/dnsfs.h | 8 +++++++ tests/driver.sh | 40 +++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 3 deletions(-) diff --git a/src/dns.c b/src/dns.c index 8332af2..e5565f4 100644 --- a/src/dns.c +++ b/src/dns.c @@ -35,6 +35,7 @@ #include #include #include +#include #include #include #include @@ -71,8 +72,54 @@ static void dnsfs_cache_remove_entry(struct dnsfs_config *cfg, call_rcu(&entry->rcu, dnsfs_cache_free_rcu); } +/* Schedule the next purge sweep while the cache is non-empty. Only the kthread + * runs the sweep, so do nothing without one; timer_pending keeps a burst of + * stores from repeatedly pushing the deadline out. + */ +static void dnsfs_cache_arm_timer(struct dnsfs_config *cfg) +{ + if (cfg->query_thread && !timer_pending(&cfg->cache_timer)) + mod_timer(&cfg->cache_timer, jiffies + DNSFS_PURGE_INTERVAL_SEC * HZ); +} + +/* Timer/softirq context: do only what the context allows -- no cache walk, no + * allocation, no sleeping (I6). Flag the sweep and wake the kthread, which does + * the reaping under query_lock in process context. + */ +static void dnsfs_cache_timer_fn(struct timer_list *t) +{ + struct dnsfs_config *cfg = timer_container_of(cfg, t, cache_timer); + + atomic_set(&cfg->purge_pending, 1); + wake_up(&cfg->query_wait); +} + +/* Time-driven counterpart to the opportunistic sweep in dnsfs_cache_lookup: + * reap every entry whose TTL has elapsed and that is not mid-refresh, so an + * idle mount does not keep dead entries alive until the next miss or memory + * pressure. Runs in the kthread (process context) under query_lock, never in + * the timer itself. An entry being served stale (refreshing) is left for the + * demand path to replace. Re-arm while entries remain so later expiries are + * still collected. + */ +static void dnsfs_cache_purge_expired(struct dnsfs_config *cfg) +{ + struct dnsfs_cache_entry *entry; + struct dnsfs_cache_entry *tmp; + + mutex_lock(&cfg->query_lock); + list_for_each_entry_safe (entry, tmp, &cfg->cache, list) { + if (time_after_eq(jiffies, entry->expiry) && !entry->refreshing) + dnsfs_cache_remove_entry(cfg, entry); + } + if (!list_empty(&cfg->cache)) + dnsfs_cache_arm_timer(cfg); + mutex_unlock(&cfg->query_lock); +} + int dnsfs_cache_init(struct dnsfs_config *cfg) { + timer_setup(&cfg->cache_timer, dnsfs_cache_timer_fn, 0); return rhashtable_init(&cfg->cache_ht, &dnsfs_cache_params); } @@ -90,6 +137,11 @@ void dnsfs_free_config(struct dnsfs_config *cfg) kthread_stop(cfg->query_thread); cfg->query_thread = NULL; } + /* The kthread is the only side that re-arms the timer, so once it is + * stopped timer_shutdown_sync cancels any pending or running sweep for + * good, before the cache it walks is drained below. + */ + timer_shutdown_sync(&cfg->cache_timer); if (cfg->sock) sock_release(cfg->sock); while (!list_empty(&cfg->cache)) { @@ -942,6 +994,7 @@ static bool dnsfs_cache_store(struct dnsfs_config *cfg, entry->generation = atomic_long_inc_return(&cfg->cache_generation); stored = dnsfs_cache_insert_entry(cfg, entry); dnsfs_cache_evict_tail(cfg); + dnsfs_cache_arm_timer(cfg); return stored; } @@ -972,6 +1025,7 @@ static bool dnsfs_cache_store_error(struct dnsfs_config *cfg, entry->generation = atomic_long_inc_return(&cfg->cache_generation); stored = dnsfs_cache_insert_entry(cfg, entry); dnsfs_cache_evict_tail(cfg); + dnsfs_cache_arm_timer(cfg); return stored; } @@ -1093,9 +1147,12 @@ int dnsfs_query_thread(void *data) for (;;) { struct dnsfs_query_request *req = NULL; - wait_event_interruptible( - cfg->query_wait, - kthread_should_stop() || !list_empty(&cfg->query_queue)); + wait_event_interruptible(cfg->query_wait, + kthread_should_stop() || + !list_empty(&cfg->query_queue) || + atomic_read(&cfg->purge_pending)); + if (atomic_xchg(&cfg->purge_pending, 0)) + dnsfs_cache_purge_expired(cfg); spin_lock(&cfg->query_queue_lock); if (!list_empty(&cfg->query_queue)) { req = list_first_entry(&cfg->query_queue, diff --git a/src/dnsfs.h b/src/dnsfs.h index e187954..1761d10 100644 --- a/src/dnsfs.h +++ b/src/dnsfs.h @@ -15,6 +15,7 @@ #include #include #include +#include #include #include @@ -29,6 +30,11 @@ #define DNSFS_MAX_TTL_SEC 3600 #define DNSFS_RECORD_TEXT_MAX 384 #define DNSFS_MAX_CACHE_ENTRIES 64 +/* How often the cache_timer sweeps expired entries (E2). Kept well above the + * gap between an entry's expiry and a demand read, so the M11 stale-serve path + * still wins for entries that are actually read soon after they expire. + */ +#define DNSFS_PURGE_INTERVAL_SEC 5 #define DNSFS_STORAGE_INDEX "index" #define DNSFS_MAX_STORAGE_CHUNKS 64 #define DNSFS_MAX_STORAGE_SIZE (DNSFS_CHUNK_SIZE * DNSFS_MAX_STORAGE_CHUNKS) @@ -81,6 +87,8 @@ struct dnsfs_config { struct rhashtable cache_ht; unsigned int cache_entries; atomic_long_t cache_generation; + struct timer_list cache_timer; /* E2: time-driven expired-entry purge */ + atomic_t purge_pending; /* timer asks the kthread to sweep */ /* Query kthread queue: readers enqueue misses/refreshes here and either * wait or coalesce onto a request already on query_pending (in-flight * dedup). diff --git a/tests/driver.sh b/tests/driver.sh index 493b49e..d7cddd8 100755 --- a/tests/driver.sh +++ b/tests/driver.sh @@ -31,6 +31,7 @@ dns_bad_index_port=$((22000 + ($$ % 5000))) dns_publisher_port=$((29000 + ($$ % 5000))) dns_multi_port=$((21000 + ($$ % 5000))) dns_signal_port=$((46000 + ($$ % 3000))) +dns_purge_port=$((33000 + ($$ % 3000))) dns_count=${TMPDIR:-/tmp}/dnsfs-count.$$ parallel_dir=${TMPDIR:-/tmp}/dnsfs-parallel.$$ storage_out=${TMPDIR:-/tmp}/dnsfs-storage.$$ @@ -725,6 +726,44 @@ PY stop_dns } +# E2: the cache_timer purges expired entries time-driven, with no read to trigger +# the demand-path sweep. Fill one entry (TTL 1), never read it again, and after +# the purge interval elapses the entry must be gone -- a read then blocks on the +# slow resolver (a real miss), where a lingering stale entry would have answered +# instantly. THOROUGH only (sleeps past TTL + purge interval). +test_purge_timer() +{ + [ "$thorough" = 1 ] || return 0 + start_dns "$dns_purge_port" --ttl1 --delay-ms=600 --count-file "$dns_count" + rm -f "$dns_count" + mount_dnsfs -o nameserver=127.0.0.1,port="$dns_purge_port",timeout=2000,retries=1 example.org + expect_file_content "$mnt/TXT" "dnsfs live" "purge timer fill" + expect_dns_count 1 "purge timer fill count" + sleep 7 + python3 - "$mnt/TXT" "$dns_count" <<'PY' +import pathlib +import sys +import time + +path = pathlib.Path(sys.argv[1]) +count_file = pathlib.Path(sys.argv[2]) +start = time.monotonic() +got = path.read_text().strip() +elapsed = time.monotonic() - start +if got != "dnsfs live": + raise SystemExit(f"unexpected purge-timer read: {got!r}") +# A lingering stale entry answers instantly; a real miss blocks on the 600ms +# resolver, proving the timer purged the expired entry with no read to trigger it. +if elapsed < 0.3: + raise SystemExit(f"read too fast ({elapsed:.3f}s): entry was not purged") +count = int(count_file.read_text().strip()) +if count != 2: + raise SystemExit(f"expected 2 wire queries after purge + miss, got {count}") +PY + unmount_dnsfs + stop_dns +} + # A signal must abort a read blocked on a slow resolver promptly, not wait out # the whole query (E1). The resolver delays every answer 3s; a cat is # interrupted ~0.3s in and must die from the signal well before that, proving @@ -1464,6 +1503,7 @@ test_cache_eviction test_cache_reclaim test_positive_ttl test_async_ttl_refresh +test_purge_timer test_signal_interrupt test_tcp_fallback test_multi_record