跳至主要内容

ADR-007: CacheManager 層快取啟用開關(disableAll/狀態上提)

ADR 編號: 007 狀態: 已接受 (Accepted) 決策日期: 2026-06-22 決策者: Development Team(框架維護者)


摘要

在 cache 模組新增 CacheManager 層級的啟用/停用總閘disableAll() / enableAll())與個別快取開關(disableCache() / enableCache()),同時把既有 per-cache 的 enabled 狀態從臨時的 ManagedCache wrapper 上提到 manager 持有的共享旗標,修掉「getCache() 重取後 disable() 失效」的既有缺陷;服務判定改為 managerEnabled && perCacheEnabled 兩層 AND;createCache 在包裝時即套用當前 latch,使懶建快取自動納管。目的是為除錯/測試提供可靠的「停用快取、強制讀資料源」開關

框架只提供介面原語;property 接線(如 app.cache.enabled)由消費端承擔——對齊 10-java.md「提供工具集而非預設」哲學,框架不出貨 @AutoConfiguration / @ConfigurationProperties,property→disableAll()disableCache() 的接線在參考實作 app-serverCacheConfig 示範。


背景 (Context)

問題陳述

除錯/測試常需「暫時停用快取、讓每次 get 都 miss、強制 fallthrough 去資料源讀新鮮值」。盤點現況發現兩件事:

  1. 既有機制只有 per-cache 程式化 disable()Cache.disable() / enable() / isEnabled(),由 ManagedCache 一個私有 AtomicBoolean 支撐)。停用後 get()nullput()→no-op、containsKey()→false(remove/clear 不受 enabled 影響)。

  2. 這個 enabled 狀態掛在臨時 wrapper 上、不可靠EhcacheCacheManager.createCache()getCache() 每次都 new ManagedCache<>(...)——每個 wrapper 各自帶一個全新的 AtomicBoolean enabled = true,且沒有任何 registry 保存 ManagedCache 實例。後果:

    manager.getCache("x", K, V).disable(); // 關掉「這個臨時 wrapper」
    manager.getCache("x", K, V).get(key); // 取得「另一個新 wrapper」(enabled=true) → 停用蒸發

    只有當呼叫端一直握著同一個 Cache 參照重用disable() 才碰巧持久。

  3. 沒有 CacheManager.disableAll();要全關得迭代 getCacheNames()getCache()disable(),但因第 2 點,那是對一堆即將被 GC 的拋棄式 wrapper 喊停用,實際為 no-op

  4. 沒有任何外部設定驅動的停用(掃過 cache 模組,@Value / @ConfigurationProperties / @ConditionalOnProperty / getProperty / System.getenv 全部 0 命中)。

需求:提供一次停用所有快取、且停得徹底的可靠機制,並修掉既有 disable() 的持久性缺陷。

限制條件

  • Ehcache 底層無「邏輯停用但保留資料」概念;停用語意只能由框架 wrapper 層承擔(不可靠底層 evict / remove 表達,那會丟資料)。
  • ManagedCache每次取用即時包裝(無共享實例),故 enabled 狀態不能放在 wrapper 自身
  • CacheManager 為公開 API(io.leandev.appfuse.cache.api),介面變更須走 CHANGELOG / SemVer(見 30-public-api.mdm-changelog-format.md)。
  • 框架內部數個功能採懶建快取security/lockoutCacheAttemptStoresecurity/blacklistCacheTokenBlacklistStore 於首次使用才建 cache)——全域停用須涵蓋「停用後才建立」的快取。

假設前提

  • 停用語意 = 邏輯旁路get→miss、put→no-op),非清空資料;底層資料保留,重新啟用即可服務(如需新鮮資料,另以 clear() 處理)。
  • 主要情境為 dev/test 暫時停用;少數情境為 runtime ops 切換(如線上排查快取一致性問題)。
  • 框架走「提供工具集而非預設實作」(見 10-java.md);框架只出貨介面原語property 接線(讀設定→呼叫 disableAll()disableCache())由消費端承擔,框架不出貨 @AutoConfiguration / @ConfigurationProperties

考量的方案 (Options Considered)

方案 A: 狀態上提到 manager + disableAll/enableAll 總閘 + 兩層 AND — 本案

說明: 把 enabled 狀態從 ManagedCache wrapper 上提到 manager 持有的共享旗標:manager 持一個 managerEnabled(全域 latch)+ per-name 旗標表(cacheGates);ManagedCache 改持有共享旗標的參照,不再自帶私有 AtomicBoolean。服務判定為 managerEnabled && perCacheEnabled 兩層 AND。createCachegetCache 包裝時注入共享旗標並套用當前 latch ⇒ 停用期間新建(含懶建)的快取自動受管。CacheManager 介面以 default 方法新增 disableAll() / enableAll() / isEnabled() / disableCache(name) / enableCache(name) / isCacheEnabled(name)ManagedCache 保留原 2-arg 建構子(standalone 自有旗標)並新增 4-arg(注入共享旗標)。

優點

  • 修掉根因:狀態不再隨拋棄式 wrapper 蒸發,disable() / disableAll()getCache() 重取一致且持久。
  • 全域開關涵蓋懶建createCache 套用 latch,停用後才建的快取也是停用的,無漏。
  • 可組合、可逆:總閘與 per-cache disable() 正交(兩層 AND);enableAll() 翻回 latch 時不踩掉個別 cache 自己的停用狀態。
  • 非破壞性:介面新方法皆 defaultManagedCache 保留原建構子 ⇒ 既有呼叫端與第三方實作不需改動即可編譯。
  • ✅ 純框架 wrapper 層、不丟資料、不依賴外部基礎設施。

缺點

  • ❌ 仍是程式化 API——要達成「不動業務碼」需一段 property 接線(由消費端 app-server CacheConfig 承擔,框架不出貨 auto-config)。

評分: 5


方案 B: 只加 disableAll() 為「迭代 getCache+disable」的便利方法,不動狀態位置

說明CacheManager.disableAll() 內部 getCacheNames()getCache()disable() fan-out,狀態仍留在 wrapper。

優點

  • ✅ 介面新增最小、不改 ManagedCache

缺點

  • 是個 no-op 陷阱:因狀態在臨時 wrapper,fan-out 等於對拋棄式物件喊停用,對下次 getCache() 毫無影響。
  • ❌ 不修既有 disable() 持久性缺陷,反而用「看似能用」的 API 放大誤用。

評分: 1


方案 C: 維持現狀,停用交由各 app 自理

說明: 不動框架;app 自行在外層包停用旗標,或測試以 clear() 求乾淨。

優點

  • ✅ 零框架改動、零 API 變更。

缺點

  • ❌ 每個 app 重造輪子;既有 disable() 缺陷仍在、易誤用。
  • ❌ 無一致的全域開關,dev/test 體驗差。

評分: 2


方案 D: 在底層(Ehcache adapter)做停用,而非 wrapper

說明: 停用時對底層 Ehcache removeCache / 不建立,啟用時重建。

優點

  • ✅ 停用期間真的不佔記憶體。

缺點

  • ❌ Ehcache 無「邏輯停用」語意,只能移除/重建 ⇒ 丟資料、重建代價高,與「保留資料、邏輯旁路」目標衝突。
  • ❌ 與懶建、共享配置交互複雜。

評分: 2


決策 (Decision)

選擇方案: A

核心理由:

  1. 不修「狀態存在臨時 wrapper」根因,任何 disableAll 都是 no-op(方案 B 的陷阱);狀態上提是正確性前提,不是 nice-to-have。
  2. createCache 套用 latch 才能涵蓋懶建快取——本 codebase 的 lockout / blacklist 確實是懶建,否則停用期間新建的快取會漏。
  3. 兩層 AND 讓總閘與既有 per-cache disable() 正交可組合,enableAll() 不踩個別狀態,語意乾淨可逆。

實作以 default 介面方法 + 保留 ManagedCache 原建構子達成非破壞性,同時修正了一個既有缺陷(per-cache disable()getCache() 重取後失效)。故落在 CHANGELOG 的 Added + Fixed,無 Breaking Changes。


權衡分析 (Trade-offs)

我們獲得什麼 (Gains)

  • ✅ 可靠的「一次停用所有快取」開關,且停得徹底(含懶建)。
  • ✅ 連帶修掉既有 per-cache disable() 的持久性缺陷。
  • ✅ 除錯/測試可強制每次讀資料源;消費端以 property 接線即達成零改業務碼。
  • ✅ 非破壞性(default 方法 + 保留建構子):既有呼叫端與第三方實作不需改動。

我們放棄什麼 (Losses)

  • ❌ 仍需一段 property 接線才完全零碼(總閘本身是程式化原語,接線責任落在消費端)。
  • ❌ 停用不清資料:重新啟用後可能服務到停用期間殘留的舊值(需搭配 clear() 求新鮮)。
  • ❌ 未經 ManagedCache 的 unmanaged 快取(managed(false))不受開關管制——其 disable 本就拋 UnsupportedOperationException(屬刻意「不納管」)。

風險與緩解措施 (Risks & Mitigations)

風險嚴重性機率緩解措施
重新啟用後服務到停用期間殘留舊資料,誤判「快取沒停用」文件明示「停用=旁路、不清空」;如需新鮮資料配 clear();可評估 disableAll(boolean clearOnDisable) 選項
破壞自訂 CacheManager 實作者(第三方實作介面)介面新方法以 default 提供合理預設(內建 Ehcache 實作覆寫為「狀態上提」正解;第三方未覆寫則退化為盡力而為並於文件標注)
誤把 disableAll() 當「清快取」命名與文件嚴格區隔 disable(旁路)vs clear(清空)
並行 toggle 與讀取競態AtomicBoolean;語意為最終一致的旁路,無強一致需求

影響 (Consequences)

正面影響

  • ➕ 除錯/測試一鍵停用;連帶修既有缺陷。
  • ➕ 框架提供原語、消費端 app-server CacheConfig 示範 property 接線,下游可照抄。

負面影響

  • ➖ 介面表面積增加 6 個 default 方法(非破壞,但 API 變大)。
  • ➖ CHANGELOG 須記 Added + Breaking Changes。

中性影響

  • 🔸 未使用者 inert:預設 enabled、行為不變;不呼叫 disableAll 即無感。

實作指南 (Implementation Guidelines)

必須遵守的規則

  1. 狀態上提enabled 改由 manager 持有——managerEnabledAtomicBoolean)+ per-name 旗標表(ConcurrentHashMap<String, AtomicBoolean>cacheGates,經 gateFor(name) 取得);ManagedCache 改持有共享旗標參照,移除自帶私有 AtomicBoolean,但保留原 2-arg 建構子(standalone 自有旗標,供單元測試)。
  2. 兩層 ANDget / put / containsKey 的服務判定 = managerEnabled.get() && cacheEnabled.get()
  3. createCachegetCache 套用 latch:包裝 ManagedCache 時注入共享旗標 ⇒ 停用期間新建(含懶建)快取自動受管。
  4. 介面新增(皆 default,非破壞)CacheManager.disableAll() / enableAll() / isEnabled() / disableCache(name) / enableCache(name) / isCacheEnabled(name)DualLayerCache 既有「傳遞到雙層」行為保留(兩層各為經 manager 建立的 ManagedCache,自動共享旗標)。
  5. 停用語意 = 旁路不清空:不在 disable* 內動 delegate 資料;清空走獨立 clear() / invalidate。
  6. 預設啟用:safe/向後相容,未採用者行為不變。
  7. property 接線在消費端:框架不出貨 @AutoConfiguration / @ConfigurationProperties;參考實作 app-server CacheConfigapp.cache.enabled / app.cache.disabled-caches 並呼叫原語。

API 草案

// CacheManager(介面新增,皆 default、非破壞)
default void disableAll(); // 拉總閘:managerEnabled = false
default void enableAll(); // 放總閘(不動各 cache 個別狀態)
default boolean isEnabled(); // manager latch 狀態(預設 true)
default void disableCache(String name); // 個別停用(可在建立前先指名)
default void enableCache(String name); // 個別啟用
default boolean isCacheEnabled(String name); // = managerEnabled && cacheEnabled
// mutator 預設拋 UnsupportedOperationException;query 回 sane 預設;EhcacheCacheManager 覆寫為正解

// Cache(既有,語意修正為「跨 getCache 重取一致且持久」)
void disable(); // per-cache,flip manager 持有的 cacheGates[name]
void enable();
boolean isEnabled(); // = managerEnabled && cacheEnabled(有效服務狀態)

// 服務判定(ManagedCache.served())
// get(k): if (!served()) return null; // served = managerEnabled && cacheEnabled
// put(k,v):if (!served()) return;

// property 接線(消費端 app-server CacheConfig,非框架 auto-config)
// app.cache.enabled=false → cacheManager.disableAll()
// app.cache.disabled-caches=a,b → names.forEach(cacheManager::disableCache)

建議的最佳實踐

  1. 測試需「乾淨狀態」時:disableAll() 強制讀源,或視需要再 clear();或用 app.cache.enabled=false@TestPropertySource
  2. 別把 disableAll() 當清快取——它是旁路,不清資料。
  3. runtime ops 切換建議搭配 Actuator/管理端點呼叫 disableAll()enableAll(),免重啟。

檢查清單

  • ManagedCache.enabled 改讀 manager 共享旗標(移除私有 AtomicBoolean,保留 2-arg 建構子)
  • EhcacheCacheManagermanagerEnabled + cacheGates per-name 旗標表(gateFor(name)
  • createCache / getCache 注入共享旗標、包裝時套用當前 latch
  • CacheManager 介面以 default 新增 6 個開關方法(第三方實作不需改動即相容)
  • DualLayerCache 行為與新模型對齊(雙層各為經 manager 建立的 ManagedCache、自動共享旗標)
  • 停用語意(旁路不清空)文件化;與 clear() 嚴格區隔
  • 測試:①getCache 重取後 disable 仍持久 ②disableAll 涵蓋懶建快取 ③enableAll 不踩個別 disable 狀態 ④兩層 AND ⑤disableCache 預先停用(CacheEnableToggleTest
  • 消費端 app-server CacheConfigTestCacheConfigapp.cache.enabled / disabled-cachesapp-server.yml 文件化設定
  • CHANGELOG.md [Unreleased] 補 Added + Fixed(無 Breaking Changes;依 m-changelog-format.md
  • 更新 decision-records/README.md ADR 索引與「快取/記憶體管理」主題
  • (未採)CacheStatus 細分 manager latch vs per-cache 兩層狀態——目前 isEnabled() 回有效服務狀態(兩者 AND)已足;如需診斷再評估

相關文檔 (References)

內部文檔

  • cache 模組原始碼:appfuse-server io.leandev.appfuse.cache.{api,core,builder,adapter}CacheManagerManagedCacheEhcacheCacheManagerDualLayerCache
  • HTTP/Cache/CSV/Content 工具指南:../guides/core/cache.md

相關 ADR

  • ADR-006: CacheManager 層記憶體預算管制:./006-cache-memory-budget.md(同模組相鄰決策;本案的「狀態上提 + createCache 攔截」與 ADR-006 的 enforceBudget 共用 createCache 包裝點)

相關規範

  • 30-public-api.md(公開 API 範圍觸發 CHANGELOG)、m-changelog-format.md(Added/Fixed 格式)、10-java.md(「工具集而非預設」——框架只出貨介面原語,property 接線由消費端 CacheConfig 承擔)

外部資源


變更歷史 (Change Log)

日期變更內容變更者
2026-06-22初版草稿(提議中):CacheManager 層 disableAll/enableAll 總閘;把 per-cache enabled 狀態從臨時 ManagedCache wrapper 上提到 manager 共享旗標(修「getCache() 重取後 disable() 失效」缺陷);兩層 AND;createCache 套用 latch 涵蓋懶建快取;停用語意=旁路不清空;可選 app.cache.enabled property 接線Development Team + AI
2026-06-22實作落地、狀態 → 已接受。定案兩處:①介面 6 方法(disableAll/enableAll/isEnabled/disableCache/enableCache/isCacheEnabled)全採 defaultManagedCache 保留 2-arg 建構子 ⇒ 非破壞性(CHANGELOG 落 Added + Fixed,無 Breaking Changes);②property 接線改由消費端承擔——框架不出貨 auto-config,app-server CacheConfig/TestCacheConfigapp.cache.enabled/disabled-caches 呼叫原語、app-server.yml 文件化。新增 CacheEnableToggleTest(5 案全綠),cache 套件測試通過Development Team + AI

文檔維護者: Development Team + AI Assistant 最後審閱: 2026-06-22