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-server 的 CacheConfig 示範。
背景 (Context)
問題陳述
除錯/測試常需「暫時停用快取、讓每次 get 都 miss、強制 fallthrough 去資料源讀新鮮值」。盤點現況發現兩件事:
-
既有機制只有 per-cache 程式化
disable()(Cache.disable()/enable()/isEnabled(),由ManagedCache一個私有AtomicBoolean支撐)。停用後get()→null、put()→no-op、containsKey()→false(remove/clear不受 enabled 影響)。 -
這個
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()才碰巧持久。 -
沒有
CacheManager.disableAll();要全關得迭代getCacheNames()→getCache()→disable(),但因第 2 點,那是對一堆即將被 GC 的拋棄式 wrapper 喊停用,實際為 no-op。 -
沒有任何外部設定驅動的停用(掃過 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.md、m-changelog-format.md)。- 框架內部數個功能採懶建快取(
security/lockout的CacheAttemptStore、security/blacklist的CacheTokenBlacklistStore於首次使用才建 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。createCache/getCache 包裝時注入共享旗標並套用當前 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 自己的停用狀態。 - ✅ 非破壞性:介面新方法皆
default、ManagedCache保留原建構子 ⇒ 既有呼叫端與第三方實作不需改動即可編譯。 - ✅ 純框架 wrapper 層、不丟資料、不依賴外部基礎設施。
缺點:
- ❌ 仍是程式化 API——要達成「不動業務碼」需一段
property接線(由消費端app-serverCacheConfig承擔,框架不出貨 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
核心理由:
- 不修「狀態存在臨時 wrapper」根因,任何
disableAll都是 no-op(方案 B 的陷阱);狀態上提是正確性前提,不是 nice-to-have。 createCache套用 latch 才能涵蓋懶建快取——本 codebase 的lockout/blacklist確實是懶建,否則停用期間新建的快取會漏。- 兩層 AND 讓總閘與既有 per-cache
disable()正交可組合,enableAll()不踩個別狀態,語意乾淨可逆。
實作以
default介面方法 + 保留ManagedCache原建構子達成非破壞性,同時修正了一個既有缺陷(per-cachedisable()在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-serverCacheConfig示範property接線,下游可照抄。
負面影響
- ➖ 介面表面積增加 6 個
default方法(非破壞,但 API 變大)。 - ➖ CHANGELOG 須記 Added + Breaking Changes。
中性影響
- 🔸 未使用者 inert:預設
enabled、行為不變;不呼叫disableAll即無感。
實作指南 (Implementation Guidelines)
必須遵守的規則
- 狀態上提:
enabled改由 manager 持有——managerEnabled(AtomicBoolean)+ per-name 旗標表(ConcurrentHashMap<String, AtomicBoolean>名cacheGates,經gateFor(name)取得);ManagedCache改持有共享旗標參照,移除自帶私有AtomicBoolean,但保留原 2-arg 建構子(standalone 自有旗標,供單元測試)。 - 兩層 AND:
get/put/containsKey的服務判定 =managerEnabled.get() && cacheEnabled.get()。 createCache/getCache套用 latch:包裝ManagedCache時注入共享旗標 ⇒ 停用期間新建(含懶建)快取自動受管。- 介面新增(皆
default,非破壞):CacheManager.disableAll()/enableAll()/isEnabled()/disableCache(name)/enableCache(name)/isCacheEnabled(name);DualLayerCache既有「傳遞到雙層」行為保留(兩層各為經 manager 建立的ManagedCache,自動共享旗標)。 - 停用語意 = 旁路不清空:不在
disable*內動 delegate 資料;清空走獨立clear()/ invalidate。 - 預設啟用:safe/向後相容,未採用者行為不變。
- property 接線在消費端:框架不出貨
@AutoConfiguration/@ConfigurationProperties;參考實作app-serverCacheConfig讀app.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)
建議的最佳實踐
- 測試需「乾淨狀態」時:
disableAll()強制讀源,或視需要再clear();或用app.cache.enabled=false的@TestPropertySource。 - 別把
disableAll()當清快取——它是旁路,不清資料。 - runtime ops 切換建議搭配 Actuator/管理端點呼叫
disableAll()/enableAll(),免重啟。
檢查清單
-
ManagedCache.enabled改讀 manager 共享旗標(移除私有AtomicBoolean,保留 2-arg 建構子) -
EhcacheCacheManager持managerEnabled+cacheGatesper-name 旗標表(gateFor(name)) -
createCache/getCache注入共享旗標、包裝時套用當前 latch -
CacheManager介面以default新增 6 個開關方法(第三方實作不需改動即相容) -
DualLayerCache行為與新模型對齊(雙層各為經 manager 建立的ManagedCache、自動共享旗標) - 停用語意(旁路不清空)文件化;與
clear()嚴格區隔 - 測試:①
getCache重取後disable仍持久 ②disableAll涵蓋懶建快取 ③enableAll不踩個別disable狀態 ④兩層 AND ⑤disableCache預先停用(CacheEnableToggleTest) - 消費端
app-serverCacheConfig/TestCacheConfig接app.cache.enabled/disabled-caches;app-server.yml文件化設定 -
CHANGELOG.md[Unreleased]補 Added + Fixed(無 Breaking Changes;依m-changelog-format.md) - 更新
decision-records/README.mdADR 索引與「快取/記憶體管理」主題 - (未採)
CacheStatus細分 manager latch vs per-cache 兩層狀態——目前isEnabled()回有效服務狀態(兩者 AND)已足;如需診斷再評估
相關文檔 (References)
內部文檔
- cache 模組原始碼:
appfuse-serverio.leandev.appfuse.cache.{api,core,builder,adapter}(CacheManager、ManagedCache、EhcacheCacheManager、DualLayerCache) - 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)全採 default、ManagedCache 保留 2-arg 建構子 ⇒ 非破壞性(CHANGELOG 落 Added + Fixed,無 Breaking Changes);②property 接線改由消費端承擔——框架不出貨 auto-config,app-server CacheConfig/TestCacheConfig 讀 app.cache.enabled/disabled-caches 呼叫原語、app-server.yml 文件化。新增 CacheEnableToggleTest(5 案全綠),cache 套件測試通過 | Development Team + AI |
文檔維護者: Development Team + AI Assistant 最後審閱: 2026-06-22