Skip to content

Commit 77e157e

Browse files
committed
feat: усилить кэш задач и наблюдаемость
- перевести инвалидацию кэша задач в фоновый режим с логированием ошибок - добавить hit/miss метрики кэша списка задач в Prometheus - обновить Grafana dashboard и покрыть сценарии тестами
1 parent 5562505 commit 77e157e

File tree

8 files changed

+299
-11
lines changed

8 files changed

+299
-11
lines changed

app/cache/tasks_list.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from redis.asyncio import Redis
99
from redis.exceptions import RedisError
1010

11+
from app.metrics import observe_tasks_cache_hit, observe_tasks_cache_miss
1112
from app.schemas.task import TaskRead
1213

1314
logger = getLogger(__name__)
@@ -51,18 +52,23 @@ async def get_cached_tasks_list(redis: Redis, cache_key: str) -> list[TaskRead]
5152
try:
5253
raw_payload = await redis.get(cache_key)
5354
except RedisError:
55+
observe_tasks_cache_miss()
5456
logger.warning(
5557
"Не удалось прочитать кэш списка задач",
5658
extra={"cache_key": cache_key},
5759
)
5860
return None
5961

6062
if raw_payload is None:
63+
observe_tasks_cache_miss()
6164
return None
6265

6366
try:
64-
return _TASKS_LIST_ADAPTER.validate_json(raw_payload)
67+
tasks = _TASKS_LIST_ADAPTER.validate_json(raw_payload)
68+
observe_tasks_cache_hit()
69+
return tasks
6570
except ValidationError:
71+
observe_tasks_cache_miss()
6672
await delete_cached_tasks_list(redis, cache_key)
6773
return None
6874

app/metrics.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@
1414
["method", "path"],
1515
)
1616

17+
TASKS_CACHE_HITS_TOTAL = Counter(
18+
"tasks_cache_hits_total",
19+
"Количество попаданий в кэш списка задач.",
20+
)
21+
22+
TASKS_CACHE_MISSES_TOTAL = Counter(
23+
"tasks_cache_misses_total",
24+
"Количество промахов кэша списка задач.",
25+
)
26+
1727

1828
def observe_http_request(
1929
*,
@@ -28,5 +38,15 @@ def observe_http_request(
2838
)
2939

3040

41+
def observe_tasks_cache_hit() -> None:
42+
"""Регистрирует попадание в кэш списка задач."""
43+
TASKS_CACHE_HITS_TOTAL.inc()
44+
45+
46+
def observe_tasks_cache_miss() -> None:
47+
"""Регистрирует промах кэша списка задач."""
48+
TASKS_CACHE_MISSES_TOTAL.inc()
49+
50+
3151
def render_metrics() -> tuple[bytes, str]:
3252
return generate_latest(), CONTENT_TYPE_LATEST

app/routes/tasks.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
import asyncio
4+
from logging import getLogger
35
from uuid import UUID
46
from typing import Annotated
57

@@ -33,15 +35,30 @@
3335
DbSession = Annotated[AsyncSession, Depends(get_db)]
3436
RedisClient = Annotated[Redis, Depends(get_redis)]
3537
TaskFilters = Annotated[TaskListFilters, Depends()]
38+
logger = getLogger(__name__)
3639

3740
_DUE_DATE_ERROR = HTTPException(
3841
status_code=status.HTTP_400_BAD_REQUEST,
3942
detail="Дедлайн не может быть в прошлом",
4043
)
4144

4245

43-
async def _invalidate_user_tasks_cache(redis: Redis, user_id: UUID) -> None:
44-
await delete_cached_tasks_list_for_user(redis, user_id)
46+
def _schedule_invalidate_user_tasks_cache(redis: Redis, user_id: UUID) -> None:
47+
"""Запускает инвалидацию кэша списка задач в фоне (fire-and-forget).
48+
Исключения в фоновой задаче логируются и не пробрасываются в вызывающий код.
49+
"""
50+
51+
async def _run() -> None:
52+
try:
53+
await delete_cached_tasks_list_for_user(redis, user_id)
54+
except Exception: # noqa: BLE001
55+
logger.warning(
56+
"Не удалось инвалидировать кэш задач пользователя",
57+
extra={"user_id": str(user_id)},
58+
exc_info=True,
59+
)
60+
61+
asyncio.create_task(_run())
4562

4663

4764
@router.post(
@@ -59,7 +76,7 @@ async def create_task(
5976
task = await task_service.create_task(session, current_user, payload)
6077
except InvalidDueDate:
6178
raise _DUE_DATE_ERROR
62-
await _invalidate_user_tasks_cache(redis, current_user.id)
79+
_schedule_invalidate_user_tasks_cache(redis, current_user.id)
6380
return TaskRead.model_validate(task)
6481

6582

@@ -124,7 +141,7 @@ async def update_task(
124141
updated = await task_service.update_task(session, task, payload)
125142
except InvalidDueDate:
126143
raise _DUE_DATE_ERROR
127-
await _invalidate_user_tasks_cache(redis, task.user_id)
144+
_schedule_invalidate_user_tasks_cache(redis, task.user_id)
128145
return TaskRead.model_validate(updated)
129146

130147

@@ -138,7 +155,7 @@ async def delete_task(
138155
redis: RedisClient,
139156
) -> TaskDeleted:
140157
task_id = await task_service.delete_task(session, task)
141-
await _invalidate_user_tasks_cache(redis, task.user_id)
158+
_schedule_invalidate_user_tasks_cache(redis, task.user_id)
142159
return TaskDeleted(id=task_id)
143160

144161

@@ -154,7 +171,7 @@ async def attach_tag_to_task(
154171
redis: RedisClient,
155172
) -> TaskTagLink:
156173
await tag_service.attach_tag_to_task(session, task, tag)
157-
await _invalidate_user_tasks_cache(redis, task.user_id)
174+
_schedule_invalidate_user_tasks_cache(redis, task.user_id)
158175
return TaskTagLink(task_id=task.id, tag_id=tag.id)
159176

160177

@@ -169,5 +186,5 @@ async def detach_tag_from_task(
169186
redis: RedisClient,
170187
) -> TaskTagLink:
171188
await tag_service.detach_tag_from_task(session, task, tag)
172-
await _invalidate_user_tasks_cache(redis, task.user_id)
189+
_schedule_invalidate_user_tasks_cache(redis, task.user_id)
173190
return TaskTagLink(task_id=task.id, tag_id=tag.id)

monitoring/grafana/provisioning/dashboards/default.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ providers:
66
folder: Task Manager
77
type: file
88
disableDeletion: false
9-
allowUiUpdates: true
9+
allowUiUpdates: false
1010
updateIntervalSeconds: 10
1111
options:
1212
path: /etc/grafana/provisioning/dashboards/json

monitoring/grafana/provisioning/dashboards/json/task-manager-observability.json

Lines changed: 164 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -834,6 +834,169 @@
834834
],
835835
"title": "Security сигналы: 401/403/429 (за 5 минут)",
836836
"type": "timeseries"
837+
},
838+
{
839+
"datasource": {
840+
"type": "prometheus",
841+
"uid": "prometheus"
842+
},
843+
"fieldConfig": {
844+
"defaults": {
845+
"color": {
846+
"mode": "thresholds"
847+
},
848+
"mappings": [],
849+
"max": 1,
850+
"min": 0,
851+
"thresholds": {
852+
"mode": "absolute",
853+
"steps": [
854+
{
855+
"color": "red",
856+
"value": null
857+
},
858+
{
859+
"color": "orange",
860+
"value": 0.4
861+
},
862+
{
863+
"color": "green",
864+
"value": 0.7
865+
}
866+
]
867+
},
868+
"unit": "percentunit"
869+
},
870+
"overrides": []
871+
},
872+
"gridPos": {
873+
"h": 6,
874+
"w": 6,
875+
"x": 0,
876+
"y": 37
877+
},
878+
"id": 11,
879+
"options": {
880+
"colorMode": "background",
881+
"graphMode": "none",
882+
"justifyMode": "auto",
883+
"orientation": "auto",
884+
"reduceOptions": {
885+
"calcs": [
886+
"lastNotNull"
887+
],
888+
"fields": "",
889+
"values": false
890+
},
891+
"textMode": "auto"
892+
},
893+
"targets": [
894+
{
895+
"editorMode": "code",
896+
"expr": "sum(increase(tasks_cache_hits_total[5m])) / clamp_min(sum(increase(tasks_cache_hits_total[5m])) + sum(increase(tasks_cache_misses_total[5m])), 1)",
897+
"legendFormat": "cache hit ratio",
898+
"range": true,
899+
"refId": "A"
900+
}
901+
],
902+
"title": "Кэш /tasks hit ratio (5м)",
903+
"type": "stat"
904+
},
905+
{
906+
"datasource": {
907+
"type": "prometheus",
908+
"uid": "prometheus"
909+
},
910+
"fieldConfig": {
911+
"defaults": {
912+
"color": {
913+
"mode": "palette-classic"
914+
},
915+
"custom": {
916+
"axisBorderShow": false,
917+
"axisCenteredZero": false,
918+
"axisColorMode": "text",
919+
"axisLabel": "",
920+
"axisPlacement": "auto",
921+
"barAlignment": 0,
922+
"drawStyle": "line",
923+
"fillOpacity": 10,
924+
"gradientMode": "none",
925+
"hideFrom": {
926+
"legend": false,
927+
"tooltip": false,
928+
"viz": false
929+
},
930+
"insertNulls": false,
931+
"lineInterpolation": "linear",
932+
"lineWidth": 2,
933+
"pointSize": 4,
934+
"scaleDistribution": {
935+
"type": "linear"
936+
},
937+
"showPoints": "never",
938+
"spanNulls": false,
939+
"stacking": {
940+
"group": "A",
941+
"mode": "none"
942+
},
943+
"thresholdsStyle": {
944+
"mode": "off"
945+
}
946+
},
947+
"mappings": [],
948+
"thresholds": {
949+
"mode": "absolute",
950+
"steps": [
951+
{
952+
"color": "green",
953+
"value": null
954+
}
955+
]
956+
},
957+
"unit": "reqps"
958+
},
959+
"overrides": []
960+
},
961+
"gridPos": {
962+
"h": 6,
963+
"w": 18,
964+
"x": 6,
965+
"y": 37
966+
},
967+
"id": 12,
968+
"options": {
969+
"legend": {
970+
"calcs": [
971+
"lastNotNull"
972+
],
973+
"displayMode": "table",
974+
"placement": "bottom",
975+
"showLegend": true
976+
},
977+
"tooltip": {
978+
"mode": "multi",
979+
"sort": "desc"
980+
}
981+
},
982+
"targets": [
983+
{
984+
"editorMode": "code",
985+
"expr": "sum(rate(tasks_cache_hits_total[5m]))",
986+
"legendFormat": "cache hit/sec",
987+
"range": true,
988+
"refId": "A"
989+
},
990+
{
991+
"editorMode": "code",
992+
"expr": "sum(rate(tasks_cache_misses_total[5m]))",
993+
"legendFormat": "cache miss/sec",
994+
"range": true,
995+
"refId": "B"
996+
}
997+
],
998+
"title": "Кэш /tasks hit/miss в секунду",
999+
"type": "timeseries"
8371000
}
8381001
],
8391002
"refresh": "5s",
@@ -911,6 +1074,6 @@
9111074
"timezone": "",
9121075
"title": "Task Manager API - Observability",
9131076
"uid": "task-manager-observability",
914-
"version": 5,
1077+
"version": 6,
9151078
"weekStart": ""
9161079
}

tests/api/test_tasks_routes.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import asyncio
34
import json
45
from datetime import UTC, date, datetime, timedelta
56
from types import SimpleNamespace
@@ -100,6 +101,7 @@ async def test_create_task_returns_201(
100101
"priority": "medium",
101102
},
102103
)
104+
await asyncio.sleep(0.05) # дать выполниться фоновой инвалидации кэша
103105

104106
assert response.status_code == 201
105107
assert response.json()["title"] == "New task"
@@ -341,6 +343,7 @@ async def test_update_task_returns_200(
341343
f"/api/v1/tasks/{original.id}",
342344
json={"title": "Updated"},
343345
)
346+
await asyncio.sleep(0.05) # дать выполниться фоновой инвалидации кэша
344347

345348
assert response.status_code == 200
346349
assert response.json()["title"] == "Updated"
@@ -391,6 +394,7 @@ async def test_delete_task_returns_deleted_id(
391394
monkeypatch.setattr(tasks_routes.task_service, "delete_task", delete_task)
392395

393396
response = await client.delete(f"/api/v1/tasks/{task.id}")
397+
await asyncio.sleep(0.05) # дать выполниться фоновой инвалидации кэша
394398

395399
assert response.status_code == 200
396400
assert response.json() == {"id": str(task.id)}
@@ -417,6 +421,7 @@ async def test_attach_tag_to_task_returns_201(
417421
monkeypatch.setattr(tasks_routes.tag_service, "attach_tag_to_task", attach_tag)
418422

419423
response = await client.post(f"/api/v1/tasks/{task.id}/tags/{tag.id}")
424+
await asyncio.sleep(0.05) # дать выполниться фоновой инвалидации кэша
420425

421426
assert response.status_code == 201
422427
assert response.json() == {"task_id": str(task.id), "tag_id": str(tag.id)}
@@ -444,6 +449,7 @@ async def test_detach_tag_from_task_returns_200(
444449
monkeypatch.setattr(tasks_routes.tag_service, "detach_tag_from_task", detach_tag)
445450

446451
response = await client.delete(f"/api/v1/tasks/{task.id}/tags/{tag.id}")
452+
await asyncio.sleep(0.05) # дать выполниться фоновой инвалидации кэша
447453

448454
assert response.status_code == 200
449455
assert response.json() == {"task_id": str(task.id), "tag_id": str(tag.id)}

0 commit comments

Comments
 (0)