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:
125
services/cache_manager.py
Normal file
125
services/cache_manager.py
Normal 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,
|
||||
})
|
||||
Reference in New Issue
Block a user