Skip to content

Commit 7e8103e

Browse files
committed
⚡️(api) cache PointDeCharge id from id_pdc_itinerance db request
Getting a point of charge identifier from its `id_pdc_itinerance` is now the most frequent database request as it's accessed almost by every dynamic endpoints (create + read / status + session). As the same point of charge may generate multiple events (and dynamic data) within a limited timeline, caching this database request result may save time and database hits in that period for the same point of charge.
1 parent aef97fb commit 7e8103e

File tree

7 files changed

+137
-45
lines changed

7 files changed

+137
-45
lines changed

.github/workflows/api.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,7 @@ jobs:
366366
QUALICHARGE_DB_NAME: test-qualicharge-api
367367
QUALICHARGE_TEST_DB_NAME: test-qualicharge-api
368368
QUALICHARGE_API_GET_USER_CACHE_INFO: true
369+
QUALICHARGE_API_GET_PDC_ID_CACHE_INFO: true
369370
# Speed up tests
370371
QUALICHARGE_API_STATIQUE_BULK_CREATE_MAX_SIZE: 10
371372
QUALICHARGE_API_STATUS_BULK_CREATE_MAX_SIZE: 10

env.d/api

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ QUALICHARGE_API_ADMIN_PASSWORD=admin
44
QUALICHARGE_API_ADMIN_USER=admin
55
QUALICHARGE_API_STATIQUE_BULK_CREATE_MAX_SIZE=1000
66
QUALICHARGE_API_GET_USER_CACHE_INFO=True
7+
QUALICHARGE_API_GET_PDC_ID_CACHE_INFO=True
78
QUALICHARGE_DB_CONNECTION_MAX_OVERFLOW=200
89
QUALICHARGE_DB_CONNECTION_POOL_SIZE=50
910
QUALICHARGE_DB_ENGINE=postgresql+psycopg

src/api/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ and this project adheres to
2222
- CLI: sort groups and operational units alphabetically in the `list-groups`
2323
command
2424
- Decrease the number of database queries for dynamic endpoints
25+
- Cache the "get PointDeCharge id from its `id_pdc_itinerance`" database query
2526

2627
## [0.16.0] - 2024-12-12
2728

src/api/qualicharge/api/v1/routers/dynamic.py

Lines changed: 34 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
"""QualiCharge API v1 dynamique router."""
22

33
import logging
4+
from threading import Lock
45
from typing import Annotated, List, cast
6+
from uuid import UUID
57

68
from annotated_types import Len
9+
from cachetools import LRUCache, cached
710
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Security
811
from fastapi import status as fa_status
912
from pydantic import UUID4, BaseModel, PastDatetime, StringConstraints
@@ -59,6 +62,31 @@ class DynamiqueItemsCreatedResponse(BaseModel):
5962
items: List[UUID4]
6063

6164

65+
@cached(
66+
LRUCache(
67+
maxsize=settings.API_GET_PDC_ID_CACHE_MAXSIZE,
68+
),
69+
lock=Lock(),
70+
key=lambda id_pdc_itinerance, session: id_pdc_itinerance,
71+
info=settings.API_GET_PDC_ID_CACHE_INFO,
72+
)
73+
def get_pdc_id(id_pdc_itinerance: str, session: Session) -> UUID | None:
74+
"""Get PointDeCharge.id from an `id_pdc_itinerance`."""
75+
pdc_id = session.exec(
76+
select(PointDeCharge.id).where(
77+
PointDeCharge.id_pdc_itinerance == id_pdc_itinerance
78+
)
79+
).one_or_none()
80+
81+
if pdc_id is not None:
82+
return pdc_id
83+
84+
raise HTTPException(
85+
status_code=fa_status.HTTP_404_NOT_FOUND,
86+
detail="Point of charge does not exist",
87+
)
88+
89+
6290
@router.get("/status/", tags=["Status"])
6391
async def list_statuses(
6492
user: Annotated[User, Security(get_user, scopes=[ScopesEnum.DYNAMIC_READ.value])],
@@ -196,16 +224,7 @@ async def read_status(
196224
raise PermissionDenied("You cannot read the status of this point of charge")
197225

198226
# Get target point de charge
199-
pdc_id = session.exec(
200-
select(PointDeCharge.id).where(
201-
PointDeCharge.id_pdc_itinerance == id_pdc_itinerance
202-
)
203-
).one_or_none()
204-
if pdc_id is None:
205-
raise HTTPException(
206-
status_code=fa_status.HTTP_404_NOT_FOUND,
207-
detail="Selected point of charge does not exist",
208-
)
227+
pdc_id = get_pdc_id(id_pdc_itinerance, session)
209228

210229
# Get latest status (if any)
211230
latest_db_status_stmt = (
@@ -264,16 +283,7 @@ async def read_status_history(
264283
if not is_pdc_allowed_for_user(id_pdc_itinerance, user):
265284
raise PermissionDenied("You cannot read statuses of this point of charge")
266285

267-
pdc_id = session.exec(
268-
select(PointDeCharge.id).where(
269-
PointDeCharge.id_pdc_itinerance == id_pdc_itinerance
270-
)
271-
).one_or_none()
272-
if pdc_id is None:
273-
raise HTTPException(
274-
status_code=fa_status.HTTP_404_NOT_FOUND,
275-
detail="Selected point of charge does not exist",
276-
)
286+
pdc_id = get_pdc_id(id_pdc_itinerance, session)
277287

278288
# Get latest statuses
279289
db_statuses_stmt = select(Status).where(Status.point_de_charge_id == pdc_id)
@@ -313,16 +323,8 @@ async def create_status(
313323
if not is_pdc_allowed_for_user(status.id_pdc_itinerance, user):
314324
raise PermissionDenied("You cannot create statuses for this point of charge")
315325

316-
pdc_id = session.exec(
317-
select(PointDeCharge.id).where(
318-
PointDeCharge.id_pdc_itinerance == status.id_pdc_itinerance
319-
)
320-
).one_or_none()
321-
if pdc_id is None:
322-
raise HTTPException(
323-
status_code=fa_status.HTTP_404_NOT_FOUND,
324-
detail="Attached point of charge does not exist",
325-
)
326+
pdc_id = get_pdc_id(status.id_pdc_itinerance, session)
327+
326328
db_status = Status(**status.model_dump(exclude={"id_pdc_itinerance"}))
327329
# Store status id so that we do not need to perform another request
328330
db_status_id = db_status.id
@@ -396,16 +398,8 @@ async def create_session(
396398
#
397399
# - `db_session` / `Session` refers to the database session, while,
398400
# - `session` / `QCSession` / `SessionCreate` refers to qualicharge charging session
399-
pdc_id = db_session.exec(
400-
select(PointDeCharge.id).where(
401-
PointDeCharge.id_pdc_itinerance == session.id_pdc_itinerance
402-
)
403-
).one_or_none()
404-
if pdc_id is None:
405-
raise HTTPException(
406-
status_code=fa_status.HTTP_404_NOT_FOUND,
407-
detail="Attached point of charge does not exist",
408-
)
401+
pdc_id = get_pdc_id(session.id_pdc_itinerance, db_session)
402+
409403
db_qc_session = QCSession(**session.model_dump(exclude={"id_pdc_itinerance"}))
410404
# Store session id so that we do not need to perform another request
411405
db_qc_session_id = db_qc_session.id

src/api/qualicharge/conf.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,8 @@ def PASSWORD_CONTEXT(self) -> CryptContext:
133133
API_GET_USER_CACHE_MAXSIZE: int = 256
134134
API_GET_USER_CACHE_TTL: int = 1800
135135
API_GET_USER_CACHE_INFO: bool = False
136+
API_GET_PDC_ID_CACHE_MAXSIZE: int = 5000
137+
API_GET_PDC_ID_CACHE_INFO: bool = False
136138

137139
model_config = SettingsConfigDict(
138140
case_sensitive=True, env_nested_delimiter="__", env_prefix="QUALICHARGE_"

src/api/tests/api/v1/routers/test_dynamic.py

Lines changed: 96 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@
77
from urllib.parse import quote_plus
88

99
import pytest
10-
from fastapi import status
10+
from fastapi import HTTPException, status
1111
from sqlalchemy import func
1212
from sqlalchemy.schema import Column as SAColumn
1313
from sqlmodel import select
1414

15+
from qualicharge.api.v1.routers.dynamic import get_pdc_id
1516
from qualicharge.auth.factories import GroupFactory
1617
from qualicharge.auth.schemas import GroupOperationalUnit, ScopesEnum, User
1718
from qualicharge.conf import settings
@@ -37,6 +38,52 @@
3738
from qualicharge.schemas.utils import save_statique, save_statiques
3839

3940

41+
def test_get_pdc_id(db_session):
42+
"""Test the get_pdc_id utility."""
43+
id_pdc_itinerance = "FRALLE0123456"
44+
with pytest.raises(HTTPException, match="Point of charge does not exist"):
45+
get_pdc_id(id_pdc_itinerance, db_session)
46+
47+
n_pdc = 4
48+
save_statiques(db_session, StatiqueFactory.batch(n_pdc))
49+
pdcs = db_session.exec(select(PointDeCharge)).all()
50+
assert len(pdcs) == n_pdc
51+
52+
for pdc in pdcs:
53+
assert pdc.id == get_pdc_id(pdc.id_pdc_itinerance, db_session)
54+
55+
56+
def test_get_pdc_id_cache(db_session):
57+
"""Test the get_pdc_id utility cache."""
58+
n_pdc = 4
59+
save_statiques(db_session, StatiqueFactory.batch(n_pdc))
60+
pdcs = db_session.exec(select(PointDeCharge)).all()
61+
assert len(pdcs) == n_pdc
62+
63+
hits_by_pdc = 9
64+
for pdc_index in range(n_pdc):
65+
pdc = pdcs[pdc_index]
66+
67+
# First call: feed the cache
68+
with SAQueryCounter(db_session.connection()) as counter:
69+
pdc_id = get_pdc_id(pdc.id_pdc_itinerance, db_session)
70+
assert pdc_id == pdc.id
71+
cache_info = get_pdc_id.cache_info() # type: ignore[attr-defined]
72+
assert counter.count == 1
73+
assert cache_info.hits == pdc_index * hits_by_pdc
74+
assert cache_info.currsize == pdc_index + 1
75+
76+
# Test cached entry
77+
for hit in range(1, hits_by_pdc + 1):
78+
with SAQueryCounter(db_session.connection()) as counter:
79+
pdc_id = get_pdc_id(pdc.id_pdc_itinerance, db_session)
80+
assert pdc_id == pdc.id
81+
cache_info = get_pdc_id.cache_info() # type: ignore[attr-defined]
82+
assert counter.count == 0
83+
assert cache_info.hits == (pdc_index * hits_by_pdc) + hit
84+
assert cache_info.currsize == pdc_index + 1
85+
86+
4087
@pytest.mark.parametrize(
4188
"client_auth",
4289
(
@@ -331,7 +378,7 @@ def test_read_status_for_non_existing_point_of_charge(client_auth):
331378
"""Test the /status/{id_pdc_itinerance} endpoint for unknown point of charge."""
332379
response = client_auth.get("/dynamique/status/FR911E1111ER1")
333380
assert response.status_code == status.HTTP_404_NOT_FOUND
334-
assert response.json() == {"detail": "Selected point of charge does not exist"}
381+
assert response.json() == {"detail": "Point of charge does not exist"}
335382

336383

337384
def test_read_status_for_non_existing_status(db_session, client_auth):
@@ -420,6 +467,50 @@ def test_read_status_for_superuser(db_session, client_auth):
420467
assert expected_status.etat_prise_type_ef == response_status.etat_prise_type_ef
421468

422469

470+
def test_read_status_get_pdc_id_cache(db_session, client_auth):
471+
"""Test the /status/{id_pdc_itinerance} endpoint's get_pdc_id cache usage."""
472+
StatusFactory.__session__ = db_session
473+
474+
# Create the PointDeCharge
475+
id_pdc_itinerance = "FR911E1111ER1"
476+
save_statique(
477+
db_session, StatiqueFactory.build(id_pdc_itinerance=id_pdc_itinerance)
478+
)
479+
pdc = db_session.exec(
480+
select(PointDeCharge).where(
481+
PointDeCharge.id_pdc_itinerance == id_pdc_itinerance
482+
)
483+
).one()
484+
485+
# Create 20 attached statuses
486+
n_statuses = 20
487+
StatusFactory.create_batch_sync(n_statuses, point_de_charge_id=pdc.id)
488+
489+
# Count queries while getting the latest status
490+
with SAQueryCounter(db_session.connection()) as counter:
491+
client_auth.get(f"/dynamique/status/{id_pdc_itinerance}")
492+
cache_info = get_pdc_id.cache_info() # type: ignore[attr-defined]
493+
assert cache_info.hits == 0
494+
assert cache_info.currsize == 1
495+
# We expect the following db request:
496+
# 1. User authentication
497+
# 2. get_user injection
498+
# 3. get_pdc_id
499+
# 4. latest db status (sub) queries
500+
# 5. get_pdc_id
501+
expected = 5
502+
assert counter.count == expected
503+
504+
for hit in range(1, 10):
505+
# Count queries while getting the latest status
506+
with SAQueryCounter(db_session.connection()) as counter:
507+
client_auth.get(f"/dynamique/status/{id_pdc_itinerance}")
508+
cache_info = get_pdc_id.cache_info() # type: ignore[attr-defined]
509+
assert cache_info.hits == hit
510+
assert cache_info.currsize == 1
511+
assert counter.count == 1
512+
513+
423514
@pytest.mark.parametrize(
424515
"client_auth",
425516
(
@@ -489,7 +580,7 @@ def test_read_status_history_for_non_existing_point_of_charge(client_auth):
489580
"""Test the /status/{id_pdc_itinerance}/history endpoint for unknown PDC."""
490581
response = client_auth.get("/dynamique/status/FR911E1111ER1/history")
491582
assert response.status_code == status.HTTP_404_NOT_FOUND
492-
assert response.json() == {"detail": "Selected point of charge does not exist"}
583+
assert response.json() == {"detail": "Point of charge does not exist"}
493584

494585

495586
def test_read_status_history_for_non_existing_status(db_session, client_auth):
@@ -709,7 +800,7 @@ def test_create_status_for_non_existing_point_of_charge(client_auth):
709800
"/dynamique/status/", json=json.loads(qc_status.model_dump_json())
710801
)
711802
assert response.status_code == status.HTTP_404_NOT_FOUND
712-
assert response.json() == {"detail": "Attached point of charge does not exist"}
803+
assert response.json() == {"detail": "Point of charge does not exist"}
713804

714805

715806
@pytest.mark.parametrize(
@@ -1180,7 +1271,7 @@ def test_create_session_for_non_existing_point_of_charge(client_auth):
11801271
"/dynamique/session/", json=json.loads(qc_session.model_dump_json())
11811272
)
11821273
assert response.status_code == status.HTTP_404_NOT_FOUND
1183-
assert response.json() == {"detail": "Attached point of charge does not exist"}
1274+
assert response.json() == {"detail": "Point of charge does not exist"}
11841275

11851276

11861277
@pytest.mark.parametrize(

src/api/tests/fixtures/app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from sqlmodel import Session
66

77
from qualicharge.api.v1 import app
8+
from qualicharge.api.v1.routers.dynamic import get_pdc_id
89
from qualicharge.auth.factories import GroupFactory, IDTokenFactory, UserFactory
910
from qualicharge.auth.oidc import get_token, get_user_from_db
1011
from qualicharge.auth.schemas import UserGroup
@@ -67,3 +68,4 @@ def clear_lru_cache():
6768

6869
# Clear the LRU cache.
6970
get_user_from_db.cache_clear()
71+
get_pdc_id.cache_clear()

0 commit comments

Comments
 (0)