156 lines
4.8 KiB
Python
156 lines
4.8 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
快取單一來源模組。
|
||
|
||
ADR-017 Phase 3f-2: 將 sales/import/export/daily 會共同碰到的
|
||
module-level cache 收斂到這裡,避免各 route 各自持有一份 dict。
|
||
"""
|
||
|
||
import os
|
||
import logging
|
||
import time
|
||
from pathlib import Path
|
||
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
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:
|
||
logger.debug("cache fingerprint check failed for %s", self.name, exc_info=True)
|
||
return entry['data']
|
||
|
||
def set(self, key, data):
|
||
try:
|
||
fp = self._fp_fn()
|
||
except Exception:
|
||
logger.debug("cache fingerprint write failed for %s", self.name, exc_info=True)
|
||
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
|
||
_BASE_DIR = Path(__file__).resolve().parents[1]
|
||
_DASHBOARD_SHARED_CACHE_FILE = _BASE_DIR / "data" / "dashboard_full_cache.pkl"
|
||
_DASHBOARD_STALE_CACHE_FILE = _BASE_DIR / "data" / "dashboard_full_cache_stale.pkl"
|
||
_SALES_ANALYSIS_PAGE_CACHE_DIR = _BASE_DIR / "data" / "sales_analysis_page_cache"
|
||
|
||
|
||
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()
|
||
try:
|
||
if _SALES_ANALYSIS_PAGE_CACHE_DIR.exists():
|
||
for cache_file in _SALES_ANALYSIS_PAGE_CACHE_DIR.glob("*.pkl"):
|
||
try:
|
||
cache_file.unlink()
|
||
except OSError:
|
||
logger.debug("sales analysis page cache file cleanup failed: %s", cache_file, exc_info=True)
|
||
except OSError:
|
||
logger.debug("sales analysis page cache cleanup failed", exc_info=True)
|
||
|
||
|
||
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,
|
||
})
|
||
try:
|
||
if os.path.exists(_DASHBOARD_SHARED_CACHE_FILE):
|
||
try:
|
||
os.replace(_DASHBOARD_SHARED_CACHE_FILE, _DASHBOARD_STALE_CACHE_FILE)
|
||
except OSError:
|
||
os.remove(_DASHBOARD_SHARED_CACHE_FILE)
|
||
except FileNotFoundError:
|
||
pass
|
||
except OSError:
|
||
logger.debug("dashboard shared cache cleanup failed", exc_info=True)
|