refactor(cache): 統一 cache SOT 並啟用 gunicorn preload

ADR-017 Phase 3f-2:新增 services/cache_manager.py,讓 sales/import/export/daily/dashboard 共用同一份 in-memory cache;cache_service 改為相容 shim;Dockerfile/docker-compose 啟用 gunicorn --preload。
This commit is contained in:
OoO
2026-04-29 21:35:56 +08:00
parent 2550ab45b1
commit 13fa165ee2
10 changed files with 172 additions and 155 deletions

125
services/cache_manager.py Normal file
View File

@@ -0,0 +1,125 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
快取單一來源模組。
ADR-017 Phase 3f-2: 將 sales/import/export/daily 會共同碰到的
module-level cache 收斂到這裡,避免各 route 各自持有一份 dict。
"""
import time
class FingerprintCache:
"""TTL + fingerprint 的小型 in-memory cache。"""
def __init__(self, name, fingerprint_fn):
self.name = name
self._store = {}
self._fp_fn = fingerprint_fn
def get(self, key, ttl=300):
entry = self._store.get(key)
if not entry:
return None
if time.time() - entry['ts'] > ttl:
return None
try:
if self._fp_fn() != entry['fp']:
return None
except Exception:
pass
return entry['data']
def set(self, key, data):
try:
fp = self._fp_fn()
except Exception:
fp = None
self._store[key] = {'data': data, 'ts': time.time(), 'fp': fp}
def clear(self):
self._store.clear()
_SALES_DF_CACHE = {}
_SALES_PROCESSED_CACHE = {}
_SALES_OPTIONS_CACHE = {}
_SALES_ANALYSIS_RESULT_CACHE = {}
_SALES_CACHE_MAX_ENTRIES = 10
_SALES_CACHE_TTL = 600
_DAILY_SALES_PROCESSED_CACHE = {}
_DASHBOARD_DATA_CACHE = {
'consolidated_data': None,
'consolidated_timestamp': None,
'today_start': None,
'full_data': None,
'full_timestamp': None,
}
_DASHBOARD_CACHE_TTL = 1800
def cleanup_sales_cache():
"""清理 sales 處理後快取的過期與超額條目。"""
current_time = time.time()
expired_keys = [
key for key, value in _SALES_PROCESSED_CACHE.items()
if value.get('time') and current_time - value['time'] > _SALES_CACHE_TTL
]
for key in expired_keys:
_SALES_PROCESSED_CACHE.pop(key, None)
if len(_SALES_PROCESSED_CACHE) > _SALES_CACHE_MAX_ENTRIES:
sorted_items = sorted(
[(key, value.get('time', 0)) for key, value in _SALES_PROCESSED_CACHE.items()],
key=lambda item: item[1],
)
for key, _ in sorted_items[:-_SALES_CACHE_MAX_ENTRIES]:
_SALES_PROCESSED_CACHE.pop(key, None)
def set_sales_processed_cache(key, entry, aliases=()):
"""寫入 sales cache並可同步寫入別名 key。"""
entry.setdefault('time', time.time())
_SALES_PROCESSED_CACHE[key] = entry
for alias in aliases:
_SALES_PROCESSED_CACHE[alias] = entry
cleanup_sales_cache()
def clear_sales_cache_for_table(table_name):
"""匯入資料後清除指定表對應的所有 sales cache key。"""
_SALES_DF_CACHE.pop(table_name, None)
keys_to_delete = [
key for key in list(_SALES_PROCESSED_CACHE.keys())
if key == table_name or key.startswith(f"{table_name}_")
]
for key in keys_to_delete:
_SALES_PROCESSED_CACHE.pop(key, None)
def clear_sales_cache():
"""清除所有 sales cache。"""
_SALES_DF_CACHE.clear()
_SALES_PROCESSED_CACHE.clear()
_SALES_OPTIONS_CACHE.clear()
_SALES_ANALYSIS_RESULT_CACHE.clear()
def clear_daily_sales_cache():
"""清除當日業績 cache。"""
_DAILY_SALES_PROCESSED_CACHE.clear()
def clear_dashboard_cache():
"""清除商品看板 cache。"""
_DASHBOARD_DATA_CACHE.clear()
_DASHBOARD_DATA_CACHE.update({
'consolidated_data': None,
'consolidated_timestamp': None,
'today_start': None,
'full_data': None,
'full_timestamp': None,
})