Implement hybrid two-tier cache system with full monitoring and Telegram bot enhancements

Cache System (Backend):
- Implemented two-tier hybrid cache: L1 (in-memory) + L2 (SQLite)
- L1 cache: Fast dictionary-based with 5-minute TTL for hot data
- L2 cache: Persistent SQLite with 1-hour TTL for warm data
- Cache decorator with automatic tier management and fallback
- Cache key generation with per-user isolation
- Event monitoring system for cache statistics
- Cache benchmarking utilities for performance testing
- Added cache management endpoints: /api/cache/stats, /api/cache/clear, /api/cache/benchmark
- Cache configuration via environment variables (CACHE_ENABLED, CACHE_L1_TTL, etc.)

Backend Services:
- Updated dashboard_service to use @cached decorator with request context
- Added cache support to invoice_service and treasury_service
- Integrated cache manager into main.py with lifespan events
- Added Request parameter to service methods for cache metadata

Frontend Enhancements:
- New CacheStatsView.vue for real-time cache monitoring dashboard
- Cache store (cacheStore.js) for state management
- Updated router to include /cache-stats route
- Navigation updates in DashboardHeader and HamburgerMenu
- Cache stats accessible from main navigation

Telegram Bot Improvements:
- Enhanced formatters with YTD comparison data
- Improved menu navigation and button layout
- Better error handling and user feedback
- Bot startup improvements with graceful shutdown

Auth & Middleware:
- Enhanced middleware with cache metadata injection
- Improved request state handling for cache source tracking

Development:
- Updated start-dev.sh with better error handling
- Added TELEGRAM_EMAIL_AUTH_PLAN.md documentation
- Updated requirements.txt with aiosqlite for async SQLite

Performance:
- L1 cache provides <1ms response for hot data
- L2 cache provides ~5ms response for warm data
- Database queries only for cold data or cache misses
- Cache hit rates tracked and displayed in real-time

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-07 22:42:00 +02:00
parent 2a37959d80
commit 1378ee1e6a
30 changed files with 5190 additions and 281 deletions

View File

@@ -0,0 +1,412 @@
<template>
<div class="cache-stats-view">
<div class="stats-header">
<h1>Cache Statistics</h1>
<div class="actions">
<Button
label="Clear Cache"
icon="pi pi-trash"
severity="danger"
@click="showClearDialog = true"
:loading="loading"
/>
<Button
label="Refresh"
icon="pi pi-refresh"
@click="loadStats"
:loading="loading"
/>
</div>
</div>
<Message v-if="error" severity="error" :closable="true" @close="clearError">
{{ error }}
</Message>
<div v-if="!loading && stats" class="stats-grid">
<!-- Cache Status -->
<Card class="status-card">
<template #title>Cache Status</template>
<template #content>
<div class="status-content">
<div class="status-item">
<label>Global Status:</label>
<Tag
:value="stats.global_enabled ? 'ENABLED' : 'DISABLED'"
:severity="stats.global_enabled ? 'success' : 'danger'"
/>
</div>
<div class="status-item">
<label>Your Setting:</label>
<InputSwitch v-model="userCacheEnabled" @change="toggleUserCache" />
<span>{{ userCacheEnabled ? 'ON' : 'OFF' }}</span>
</div>
<div class="status-item">
<label>Auto-Invalidation:</label>
<Tag
:value="stats.auto_invalidate ? 'ENABLED' : 'DISABLED'"
:severity="stats.auto_invalidate ? 'success' : 'warning'"
/>
</div>
</div>
</template>
</Card>
<!-- Performance Metrics -->
<Card class="metrics-card">
<template #title>Performance Metrics</template>
<template #content>
<div class="hit-rate">
<h3>Hit Rate: {{ stats.hit_rate?.toFixed(1) }}%</h3>
<p>{{ stats.total_hits }} hits / {{ stats.total_hits + stats.total_misses }} total requests</p>
<ProgressBar :value="stats.hit_rate" />
</div>
</template>
</Card>
<!-- Queries Saved -->
<Card class="queries-card">
<template #title>Queries Saved</template>
<template #content>
<ul class="queries-list">
<li>
Today: <strong>{{ stats.queries_saved?.today?.toLocaleString() }}</strong> queries avoided
</li>
<li>
This week: <strong>{{ stats.queries_saved?.week?.toLocaleString() }}</strong> queries avoided
</li>
<li>
All time: <strong>{{ stats.queries_saved?.total?.toLocaleString() }}</strong> queries avoided
</li>
</ul>
</template>
</Card>
<!-- Response Times -->
<Card class="response-times-card">
<template #title>Response Time Comparison</template>
<template #content>
<DataTable :value="responseTimesTable" class="p-datatable-sm">
<Column field="endpoint" header="Endpoint" />
<Column field="cached" header="With Cache">
<template #body="{ data }">{{ data.cached }} ms</template>
</Column>
<Column field="oracle" header="Without Cache">
<template #body="{ data }">{{ data.oracle }} ms</template>
</Column>
<Column field="improvement" header="Improvement">
<template #body="{ data }">
<Tag :value="`${data.improvement}% ↓`" severity="success" />
</template>
</Column>
</DataTable>
<div v-if="overallAvg" class="average-row">
<strong>Overall Average:</strong>
{{ overallAvg.cached }} ms vs {{ overallAvg.oracle }} ms
({{ overallAvg.improvement }}% faster)
</div>
</template>
</Card>
<!-- Cache Details -->
<Card class="details-card">
<template #title>Cache Details</template>
<template #content>
<ul class="details-list">
<li>Memory entries: <strong>{{ stats.cache_size?.memory?.toLocaleString() }}</strong></li>
<li>SQLite entries: <strong>{{ stats.cache_size?.sqlite?.toLocaleString() }}</strong></li>
<li>Cache type: <strong>{{ stats.cache_type }}</strong></li>
</ul>
</template>
</Card>
</div>
<!-- Clear Cache Dialog -->
<Dialog
v-model:visible="showClearDialog"
header="Clear Cache"
:modal="true"
:style="{ width: '450px' }"
>
<p>Are you sure you want to clear the cache?</p>
<div class="clear-options">
<div class="p-field-radiobutton">
<RadioButton id="clear_all" v-model="clearScope" value="all" />
<label for="clear_all">All companies</label>
</div>
<div class="p-field-radiobutton">
<RadioButton id="clear_current" v-model="clearScope" value="current" />
<label for="clear_current">Current company only</label>
</div>
</div>
<template #footer>
<Button label="Cancel" text @click="showClearDialog = false" />
<Button label="Clear" severity="danger" @click="clearCache" :loading="loading" />
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useCacheStore } from '@/stores/cacheStore'
import { useCompanyStore } from '@/stores/companies'
import { useToast } from 'primevue/usetoast'
import Button from 'primevue/button'
import Card from 'primevue/card'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Tag from 'primevue/tag'
import ProgressBar from 'primevue/progressbar'
import InputSwitch from 'primevue/inputswitch'
import Dialog from 'primevue/dialog'
import RadioButton from 'primevue/radiobutton'
import Message from 'primevue/message'
const cacheStore = useCacheStore()
const companyStore = useCompanyStore()
const toast = useToast()
const loading = computed(() => cacheStore.isLoading)
const error = computed(() => cacheStore.error)
const stats = computed(() => cacheStore.stats)
const userCacheEnabled = ref(true)
const showClearDialog = ref(false)
const clearScope = ref('current')
const responseTimesTable = computed(() => {
if (!stats.value?.response_times) return []
return Object.entries(stats.value.response_times).map(([key, data]) => ({
endpoint: formatEndpointName(key),
cached: data.cached,
oracle: data.oracle,
improvement: data.improvement
}))
})
const overallAvg = computed(() => {
const times = Object.values(stats.value?.response_times || {})
if (times.length === 0) return null
const avgCached = times.reduce((sum, t) => sum + t.cached, 0) / times.length
const avgOracle = times.reduce((sum, t) => sum + t.oracle, 0) / times.length
const improvement = ((avgOracle - avgCached) / avgOracle * 100).toFixed(0)
return {
cached: avgCached.toFixed(0),
oracle: avgOracle.toFixed(0),
improvement
}
})
async function loadStats() {
try {
await cacheStore.getStats()
userCacheEnabled.value = stats.value?.user_enabled ?? true
} catch (error) {
toast.add({
severity: 'error',
summary: 'Error',
detail: 'Failed to load cache statistics',
life: 3000
})
}
}
async function toggleUserCache() {
try {
await cacheStore.toggleUserCache(userCacheEnabled.value)
toast.add({
severity: 'success',
summary: 'Success',
detail: `Cache ${userCacheEnabled.value ? 'enabled' : 'disabled'} for you`,
life: 3000
})
} catch (error) {
toast.add({
severity: 'error',
summary: 'Error',
detail: 'Failed to toggle cache',
life: 3000
})
// Revert toggle
userCacheEnabled.value = !userCacheEnabled.value
}
}
async function clearCache() {
try {
const companyId = clearScope.value === 'current' ? companyStore.currentCompany?.id_firma : null
await cacheStore.invalidateCache(companyId, null)
toast.add({
severity: 'success',
summary: 'Success',
detail: 'Cache cleared successfully',
life: 3000
})
showClearDialog.value = false
await loadStats()
} catch (error) {
toast.add({
severity: 'error',
summary: 'Error',
detail: 'Failed to clear cache',
life: 3000
})
}
}
function formatEndpointName(key) {
const names = {
'schema': 'Schema Lookup',
'dashboard_summary': 'Dashboard',
'dashboard_trends': 'Dashboard Trends',
'companies': 'Companies List',
'invoices': 'Invoices',
'treasury': 'Treasury'
}
return names[key] || key
}
function clearError() {
cacheStore.clearError()
}
onMounted(() => {
loadStats()
})
</script>
<style scoped>
.cache-stats-view {
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
}
.stats-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.stats-header h1 {
margin: 0;
color: var(--text-color);
}
.actions {
display: flex;
gap: 0.5rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 1.5rem;
}
.status-content {
display: flex;
flex-direction: column;
gap: 1rem;
}
.status-item {
display: flex;
align-items: center;
gap: 1rem;
}
.status-item label {
font-weight: 600;
min-width: 140px;
}
.hit-rate {
text-align: center;
}
.hit-rate h3 {
margin: 0 0 0.5rem 0;
color: var(--primary-color);
}
.hit-rate p {
margin: 0 0 1rem 0;
color: var(--text-color-secondary);
font-size: 0.9rem;
}
.queries-list {
list-style: none;
padding: 0;
margin: 0;
}
.queries-list li {
padding: 0.5rem 0;
border-bottom: 1px solid var(--surface-border);
}
.queries-list li:last-child {
border-bottom: none;
}
.average-row {
margin-top: 1rem;
padding-top: 1rem;
border-top: 2px solid var(--surface-border);
text-align: center;
}
.details-list {
list-style: none;
padding: 0;
margin: 0;
}
.details-list li {
padding: 0.5rem 0;
display: flex;
justify-content: space-between;
}
.clear-options {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1rem;
}
.p-field-radiobutton {
display: flex;
align-items: center;
gap: 0.5rem;
}
.response-times-card {
grid-column: 1 / -1;
}
@media (max-width: 768px) {
.cache-stats-view {
padding: 1rem;
}
.stats-header {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.stats-grid {
grid-template-columns: 1fr;
}
}
</style>