Merge pull request #48 from ineszang/feat/db-timescaledb
feat(db): PostgreSQL 17 + TimescaleDB et connexion backend
This commit is contained in:
@@ -0,0 +1,17 @@
|
|||||||
|
# Variables lues par docker-compose.yml a la racine.
|
||||||
|
# Le backend lance hors conteneur (`make dev`) lit apps/backend/.env, pas ce fichier.
|
||||||
|
|
||||||
|
POSTGRES_USER=enervision
|
||||||
|
POSTGRES_PASSWORD=change_me
|
||||||
|
POSTGRES_DB=enervision
|
||||||
|
# 5432 est souvent deja pris par une autre base du poste.
|
||||||
|
POSTGRES_PORT=5433
|
||||||
|
# `basic` renvoie des statistiques d'usage a Timescale.
|
||||||
|
TIMESCALEDB_TELEMETRY=off
|
||||||
|
|
||||||
|
APP_ENV=local
|
||||||
|
APP_DEBUG=true
|
||||||
|
APP_LOG_LEVEL=INFO
|
||||||
|
APP_SECRET_KEY=change_me
|
||||||
|
APP_CORS_ORIGINS=http://localhost:4200
|
||||||
|
BACKEND_PORT=8000
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
BACKEND := apps/backend
|
BACKEND := apps/backend
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
.PHONY: help install dev lint format typecheck test check docker-build
|
.PHONY: help install dev lint format typecheck test test-integration check docker-build \
|
||||||
|
db-up db-down db-reset db-logs db-psql migrate
|
||||||
|
|
||||||
help: ## Liste les cibles disponibles
|
help: ## Liste les cibles disponibles
|
||||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
|
||||||
@@ -21,10 +22,31 @@ format: ## Formate et corrige le backend
|
|||||||
typecheck: ## Verifie le typage du backend
|
typecheck: ## Verifie le typage du backend
|
||||||
cd $(BACKEND) && uv run mypy app
|
cd $(BACKEND) && uv run mypy app
|
||||||
|
|
||||||
test: ## Execute les tests backend
|
test: ## Execute les tests backend ne demandant pas de base
|
||||||
cd $(BACKEND) && uv run pytest
|
cd $(BACKEND) && uv run pytest
|
||||||
|
|
||||||
|
test-integration: ## Execute les tests exigeant une base joignable
|
||||||
|
cd $(BACKEND) && uv run pytest -m integration
|
||||||
|
|
||||||
check: lint typecheck test ## Chaine de verification complete
|
check: lint typecheck test ## Chaine de verification complete
|
||||||
|
|
||||||
docker-build: ## Construit l'image du backend
|
docker-build: ## Construit l'image du backend
|
||||||
docker build -t enervision-backend:local $(BACKEND)
|
docker build -t enervision-backend:local $(BACKEND)
|
||||||
|
|
||||||
|
db-up: ## Demarre la base PostgreSQL TimescaleDB
|
||||||
|
docker compose up -d db
|
||||||
|
|
||||||
|
db-down: ## Arrete la base en conservant ses donnees
|
||||||
|
docker compose stop db
|
||||||
|
|
||||||
|
db-reset: ## Detruit la base et rejoue db/init
|
||||||
|
docker compose down -v && docker compose up -d db
|
||||||
|
|
||||||
|
db-logs: ## Suit les journaux de la base
|
||||||
|
docker compose logs -f db
|
||||||
|
|
||||||
|
db-psql: ## Ouvre une session psql sur la base applicative
|
||||||
|
docker compose exec db psql -U $${POSTGRES_USER:-enervision} -d $${POSTGRES_DB:-enervision}
|
||||||
|
|
||||||
|
migrate: ## Applique les migrations Alembic
|
||||||
|
cd $(BACKEND) && uv run alembic upgrade head
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ series temporelles energetiques, deployee sur une machine on-premise.
|
|||||||
|------------|-------------------------------------|---------------------|---------------|
|
|------------|-------------------------------------|---------------------|---------------|
|
||||||
| Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise |
|
| Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise |
|
||||||
| Frontend | Angular, Node 24 LTS | `apps/frontend` | A initialiser |
|
| Frontend | Angular, Node 24 LTS | `apps/frontend` | A initialiser |
|
||||||
| Base | PostgreSQL + TimescaleDB | `db` | A initialiser |
|
| Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise |
|
||||||
| ETL | Apache Airflow | `etl/airflow` | A initialiser |
|
| ETL | Apache Airflow | `etl/airflow` | A initialiser |
|
||||||
| Infra | Terraform | `infra/terraform` | A initialiser |
|
| Infra | Terraform | `infra/terraform` | A initialiser |
|
||||||
| CI/CD | GitHub Actions | `.github/workflows` | A initialiser |
|
| CI/CD | GitHub Actions | `.github/workflows` | A initialiser |
|
||||||
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser |
|
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser |
|
||||||
|
|
||||||
Seul le backend est initialise a ce stade. Les autres dossiers portent l'arborescence et
|
Le backend et la base sont initialises a ce stade. Les autres dossiers portent
|
||||||
un README de cadrage, leur contenu fait l'objet d'un ticket dedie.
|
l'arborescence et un README de cadrage, leur contenu fait l'objet d'un ticket dedie.
|
||||||
|
|
||||||
## Arborescence
|
## Arborescence
|
||||||
|
|
||||||
@@ -50,13 +50,33 @@ un README de cadrage, leur contenu fait l'objet d'un ticket dedie.
|
|||||||
Prerequis : uv, Docker. Le poste doit disposer de Python 3.14, que `uv` installe seul.
|
Prerequis : uv, Docker. Le poste doit disposer de Python 3.14, que `uv` installe seul.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
cp .env.example .env # variables de docker-compose
|
||||||
|
cp apps/backend/.env.example apps/backend/.env # variables du backend hors conteneur
|
||||||
|
|
||||||
|
make db-up # PostgreSQL + TimescaleDB, publie sur le port 5433
|
||||||
make install # dependances du backend
|
make install # dependances du backend
|
||||||
|
make migrate # applique les migrations Alembic
|
||||||
make dev # API sur http://localhost:8000, docs sur /docs
|
make dev # API sur http://localhost:8000, docs sur /docs
|
||||||
make check # lint + typage + tests
|
make check # lint + typage + tests
|
||||||
```
|
```
|
||||||
|
|
||||||
`make help` liste les cibles disponibles.
|
`make help` liste les cibles disponibles.
|
||||||
|
|
||||||
|
Deux fichiers d'environnement, deux usages : `.env` a la racine alimente `docker-compose.yml`,
|
||||||
|
`apps/backend/.env` alimente le backend lance sur le poste. Le port 5433 est publie plutot que
|
||||||
|
5432, souvent deja pris par une autre base.
|
||||||
|
|
||||||
|
La boucle de developpement est `make db-up` puis `make dev` : seule la base tourne en
|
||||||
|
conteneur. Le service `backend` du `docker-compose.yml` sert la stack complete et la recette,
|
||||||
|
et n'embarque pas le source, donc toute modification y demande un
|
||||||
|
`docker compose up -d --build backend`.
|
||||||
|
|
||||||
|
Verifier que la base repond et que l'extension est chargee :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s localhost:8000/api/v1/health/ready
|
||||||
|
```
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- Branches : `feat/`, `fix/`, `chore/`, `docs/` suivi d'un libelle court.
|
- Branches : `feat/`, `fix/`, `chore/`, `docs/` suivi d'un libelle court.
|
||||||
|
|||||||
@@ -3,4 +3,4 @@ APP_DEBUG=true
|
|||||||
APP_LOG_LEVEL=INFO
|
APP_LOG_LEVEL=INFO
|
||||||
APP_SECRET_KEY=change_me
|
APP_SECRET_KEY=change_me
|
||||||
APP_CORS_ORIGINS=http://localhost:4200
|
APP_CORS_ORIGINS=http://localhost:4200
|
||||||
DATABASE_URL=postgresql+asyncpg://enervision:change_me@localhost:5432/enervision
|
DATABASE_URL=postgresql+asyncpg://enervision:change_me@localhost:5433/enervision
|
||||||
|
|||||||
+13
-1
@@ -22,6 +22,9 @@ uv sync --all-groups
|
|||||||
`APP_SECRET_KEY` et `DATABASE_URL` n'ont pas de valeur par defaut : l'application refuse
|
`APP_SECRET_KEY` et `DATABASE_URL` n'ont pas de valeur par defaut : l'application refuse
|
||||||
de demarrer sans elles.
|
de demarrer sans elles.
|
||||||
|
|
||||||
|
`DATABASE_URL` pointe sur `localhost:5433`, le port publie par le service `db` du
|
||||||
|
`docker-compose.yml` racine. Demarrer la base depuis la racine avec `make db-up`.
|
||||||
|
|
||||||
## Commandes
|
## Commandes
|
||||||
|
|
||||||
Depuis la racine du monorepo, via le `Makefile` : `make install`, `make dev`, `make lint`,
|
Depuis la racine du monorepo, via le `Makefile` : `make install`, `make dev`, `make lint`,
|
||||||
@@ -35,8 +38,13 @@ uv run ruff check . # lint
|
|||||||
uv run ruff format . # format
|
uv run ruff format . # format
|
||||||
uv run mypy app # typage strict
|
uv run mypy app # typage strict
|
||||||
uv run pytest # tests + couverture
|
uv run pytest # tests + couverture
|
||||||
|
uv run pytest -m integration # tests exigeant une base joignable
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`pytest` ecarte par defaut les tests marques `integration`, pour que `make check` reste
|
||||||
|
jouable sans Docker. Ces tests visent la base `enervision_test`, creee par
|
||||||
|
`db/init/110-test-database.sql` au premier demarrage du conteneur.
|
||||||
|
|
||||||
L'application est exposee par une factory (`create_app`) et non par un objet module :
|
L'application est exposee par une factory (`create_app`) et non par un objet module :
|
||||||
aucune configuration n'est lue a l'import, ce qui rend les tests et les migrations
|
aucune configuration n'est lue a l'import, ce qui rend les tests et les migrations
|
||||||
independants de l'environnement.
|
independants de l'environnement.
|
||||||
@@ -73,7 +81,7 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie
|
|||||||
| Route | Role |
|
| Route | Role |
|
||||||
|------------------------|-------------------------------------------------|
|
|------------------------|-------------------------------------------------|
|
||||||
| `/api/v1/health/live` | Sonde de vivacite, aucune dependance externe |
|
| `/api/v1/health/live` | Sonde de vivacite, aucune dependance externe |
|
||||||
| `/api/v1/health/ready` | Sonde de disponibilite, verifie la base |
|
| `/api/v1/health/ready` | Sonde de disponibilite, verifie la base et TimescaleDB |
|
||||||
| `/metrics` | Metriques au format Prometheus |
|
| `/metrics` | Metriques au format Prometheus |
|
||||||
| `/docs`, `/openapi.json` | Documentation, desactivee quand `APP_ENV=prod` |
|
| `/docs`, `/openapi.json` | Documentation, desactivee quand `APP_ENV=prod` |
|
||||||
|
|
||||||
@@ -86,6 +94,10 @@ uv run alembic upgrade head
|
|||||||
|
|
||||||
L'URL de connexion vient de `DATABASE_URL`, pas de `alembic.ini`.
|
L'URL de connexion vient de `DATABASE_URL`, pas de `alembic.ini`.
|
||||||
|
|
||||||
|
La premiere revision ne cree aucune table : elle refuse de s'appliquer si l'extension
|
||||||
|
TimescaleDB manque, ce qui arrive quand `db/init` n'a pas ete joue. Le DDL propre a
|
||||||
|
TimescaleDB qui ne depend pas du schema applicatif vit dans `db/`, pas ici.
|
||||||
|
|
||||||
## Image Docker
|
## Image Docker
|
||||||
|
|
||||||
Build multi-stage, dependances resolues par uv depuis `uv.lock`, execution sous un
|
Build multi-stage, dependances resolues par uv depuis `uv.lock`, execution sous un
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""socle garde extension timescaledb
|
||||||
|
|
||||||
|
Revision ID: 5353c0e4f094
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-09-14 14:17:17.556764
|
||||||
|
|
||||||
|
Premiere revision du schema applicatif. Elle ne cree aucune table : elle etablit
|
||||||
|
alembic_version et refuse de s'appliquer sur une base ou l'extension TimescaleDB
|
||||||
|
manque, cas qui se produit quand db/init n'a pas ete joue.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "5353c0e4f094"
|
||||||
|
down_revision: str | Sequence[str] | None = None
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
GARDE_EXTENSION = """
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') THEN
|
||||||
|
RAISE EXCEPTION 'extension timescaledb absente, voir db/init et db/README.md';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(GARDE_EXTENSION)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -9,6 +9,8 @@ from app.schemas.health import LivenessStatus, ReadinessStatus
|
|||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
router = APIRouter(tags=["health"])
|
router = APIRouter(tags=["health"])
|
||||||
|
|
||||||
|
TIMESCALEDB_VERSION = text("SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/live", summary="Sonde de vivacite")
|
@router.get("/live", summary="Sonde de vivacite")
|
||||||
async def liveness(settings: SettingsDep) -> LivenessStatus:
|
async def liveness(settings: SettingsDep) -> LivenessStatus:
|
||||||
@@ -23,11 +25,19 @@ async def liveness(settings: SettingsDep) -> LivenessStatus:
|
|||||||
@router.get("/ready", summary="Sonde de disponibilite")
|
@router.get("/ready", summary="Sonde de disponibilite")
|
||||||
async def readiness(session: SessionDep) -> ReadinessStatus:
|
async def readiness(session: SessionDep) -> ReadinessStatus:
|
||||||
try:
|
try:
|
||||||
await session.execute(text("SELECT 1"))
|
version: str | None = await session.scalar(TIMESCALEDB_VERSION)
|
||||||
except SQLAlchemyError, OSError:
|
except SQLAlchemyError, OSError:
|
||||||
logger.exception("Base de donnees injoignable")
|
logger.exception("Base de donnees injoignable")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
detail="Base de donnees injoignable",
|
detail="Base de donnees injoignable",
|
||||||
) from None
|
) from None
|
||||||
return ReadinessStatus(status="ready", database="reachable")
|
|
||||||
|
if version is None:
|
||||||
|
logger.error("Extension TimescaleDB absente de la base")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Extension TimescaleDB absente",
|
||||||
|
)
|
||||||
|
|
||||||
|
return ReadinessStatus(status="ready", database="reachable", timescaledb=version)
|
||||||
|
|||||||
@@ -13,3 +13,4 @@ class LivenessStatus(BaseModel):
|
|||||||
class ReadinessStatus(BaseModel):
|
class ReadinessStatus(BaseModel):
|
||||||
status: Literal["ready"]
|
status: Literal["ready"]
|
||||||
database: Literal["reachable"]
|
database: Literal["reachable"]
|
||||||
|
timescaledb: str
|
||||||
|
|||||||
@@ -79,7 +79,8 @@ disallow_untyped_defs = false
|
|||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
addopts = "-q --strict-markers --cov=app --cov-report=term-missing"
|
addopts = "-q --strict-markers -m 'not integration' --cov=app --cov-report=term-missing"
|
||||||
|
markers = ["integration: requiert une base PostgreSQL joignable, hors `make test`"]
|
||||||
|
|
||||||
[tool.coverage.run]
|
[tool.coverage.run]
|
||||||
source = ["app"]
|
source = ["app"]
|
||||||
|
|||||||
@@ -20,6 +20,44 @@ async def test_liveness_exposes_service_metadata(client: AsyncClient) -> None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_readiness_reports_the_timescaledb_version(app: FastAPI, client: AsyncClient) -> None:
|
||||||
|
class ReadySession:
|
||||||
|
async def scalar(self, *_: object, **__: object) -> str:
|
||||||
|
return "2.22.1"
|
||||||
|
|
||||||
|
async def override() -> AsyncIterator[ReadySession]:
|
||||||
|
yield ReadySession()
|
||||||
|
|
||||||
|
app.dependency_overrides[get_session] = override
|
||||||
|
|
||||||
|
response = await client.get("/api/v1/health/ready")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"status": "ready",
|
||||||
|
"database": "reachable",
|
||||||
|
"timescaledb": "2.22.1",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_readiness_returns_503_when_the_extension_is_missing(
|
||||||
|
app: FastAPI, client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
class SessionWithoutExtension:
|
||||||
|
async def scalar(self, *_: object, **__: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def override() -> AsyncIterator[SessionWithoutExtension]:
|
||||||
|
yield SessionWithoutExtension()
|
||||||
|
|
||||||
|
app.dependency_overrides[get_session] = override
|
||||||
|
|
||||||
|
response = await client.get("/api/v1/health/ready")
|
||||||
|
|
||||||
|
assert response.status_code == 503
|
||||||
|
assert response.json()["detail"] == "Extension TimescaleDB absente"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"failure",
|
"failure",
|
||||||
[
|
[
|
||||||
@@ -32,7 +70,7 @@ async def test_readiness_returns_503_when_database_is_unreachable(
|
|||||||
app: FastAPI, client: AsyncClient, failure: Exception
|
app: FastAPI, client: AsyncClient, failure: Exception
|
||||||
) -> None:
|
) -> None:
|
||||||
class UnreachableSession:
|
class UnreachableSession:
|
||||||
async def execute(self, *_: object, **__: object) -> None:
|
async def scalar(self, *_: object, **__: object) -> None:
|
||||||
raise failure
|
raise failure
|
||||||
|
|
||||||
async def override() -> AsyncIterator[UnreachableSession]:
|
async def override() -> AsyncIterator[UnreachableSession]:
|
||||||
@@ -49,3 +87,14 @@ async def test_readiness_returns_503_when_database_is_unreachable(
|
|||||||
@pytest.mark.parametrize("path", ["/openapi.json", "/metrics"])
|
@pytest.mark.parametrize("path", ["/openapi.json", "/metrics"])
|
||||||
async def test_technical_endpoints_are_served(client: AsyncClient, path: str) -> None:
|
async def test_technical_endpoints_are_served(client: AsyncClient, path: str) -> None:
|
||||||
assert (await client.get(path)).status_code == 200
|
assert (await client.get(path)).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_readiness_reaches_the_real_database(client: AsyncClient) -> None:
|
||||||
|
response = await client.get("/api/v1/health/ready")
|
||||||
|
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
body = response.json()
|
||||||
|
assert body["status"] == "ready"
|
||||||
|
assert body["database"] == "reachable"
|
||||||
|
assert body["timescaledb"]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from fastapi import FastAPI
|
|||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
|
from app.db.session import get_engine, get_session_factory
|
||||||
from app.main import create_app
|
from app.main import create_app
|
||||||
|
|
||||||
|
|
||||||
@@ -13,13 +14,24 @@ from app.main import create_app
|
|||||||
def environment() -> Iterator[None]:
|
def environment() -> Iterator[None]:
|
||||||
os.environ.setdefault("APP_SECRET_KEY", "secret-de-test")
|
os.environ.setdefault("APP_SECRET_KEY", "secret-de-test")
|
||||||
os.environ.setdefault(
|
os.environ.setdefault(
|
||||||
"DATABASE_URL", "postgresql+asyncpg://enervision:enervision@localhost:5432/enervision_test"
|
"DATABASE_URL", "postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_test"
|
||||||
)
|
)
|
||||||
get_settings.cache_clear()
|
get_settings.cache_clear()
|
||||||
yield
|
yield
|
||||||
get_settings.cache_clear()
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
# Piege : get_engine est lru_cache et pytest-asyncio ouvre une boucle par test. Sans ce
|
||||||
|
# recyclage, le 2e test touchant vraiment la base heriterait d une boucle morte.
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
async def engine_per_test() -> AsyncIterator[None]:
|
||||||
|
yield
|
||||||
|
if get_engine.cache_info().currsize:
|
||||||
|
await get_engine().dispose()
|
||||||
|
get_engine.cache_clear()
|
||||||
|
get_session_factory.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def app() -> FastAPI:
|
def app() -> FastAPI:
|
||||||
return create_app()
|
return create_app()
|
||||||
|
|||||||
+30
-1
@@ -1,6 +1,7 @@
|
|||||||
# Base de donnees
|
# Base de donnees
|
||||||
|
|
||||||
PostgreSQL avec l'extension TimescaleDB. Non initialise, voir le ticket dedie.
|
PostgreSQL 17 avec l'extension TimescaleDB, servie en local par le service `db` du
|
||||||
|
`docker-compose.yml` racine (image `timescale/timescaledb-ha:pg17`).
|
||||||
|
|
||||||
- `init` : scripts de bootstrap joues au premier demarrage du conteneur.
|
- `init` : scripts de bootstrap joues au premier demarrage du conteneur.
|
||||||
- `migrations` : migrations SQL versionnees.
|
- `migrations` : migrations SQL versionnees.
|
||||||
@@ -8,3 +9,31 @@ PostgreSQL avec l'extension TimescaleDB. Non initialise, voir le ticket dedie.
|
|||||||
|
|
||||||
Les migrations du schema applicatif expose par l'API vivent dans
|
Les migrations du schema applicatif expose par l'API vivent dans
|
||||||
`apps/backend/alembic`, pas ici.
|
`apps/backend/alembic`, pas ici.
|
||||||
|
|
||||||
|
## `init` ne rejoue jamais
|
||||||
|
|
||||||
|
Ces scripts sont montes sur `/docker-entrypoint-initdb.d`, dont PostgreSQL ne joue le
|
||||||
|
contenu qu'a la toute premiere initialisation, quand `PGDATA` est vide. Modifier ou
|
||||||
|
ajouter un script ensuite reste sans effet sur une base existante : il faut detruire
|
||||||
|
le volume, ce que fait `make db-reset`.
|
||||||
|
|
||||||
|
L'image apporte ses propres scripts dans ce dossier, et ils comptent :
|
||||||
|
|
||||||
|
| Script | Origine | Role |
|
||||||
|
|---|---|---|
|
||||||
|
| `000_install_timescaledb.sh` | image | Cree l'extension dans `postgres`, `template1` et la base applicative, et fixe `timescaledb.telemetry_level`. |
|
||||||
|
| `001_timescaledb_tune.sh` | image | Lance `timescaledb-tune` sur la memoire et les CPU vus par le conteneur. |
|
||||||
|
| `010_install_timescaledb_toolkit.sh` | image | Ajoute `timescaledb_toolkit`. |
|
||||||
|
| `100-extensions.sql` | ce depot | Declare explicitement les extensions attendues. |
|
||||||
|
| `110-test-database.sql` | ce depot | Cree `enervision_test`, attendue par la suite de tests du backend. |
|
||||||
|
|
||||||
|
D'ou deux contraintes dans `docker-compose.yml`. Nos fichiers sont **montes un par un**,
|
||||||
|
et non par leur dossier : un montage de `./db/init` sur `/docker-entrypoint-initdb.d`
|
||||||
|
remplacerait le dossier de l'image au lieu de s'y ajouter, et ferait disparaitre les trois
|
||||||
|
scripts ci-dessus sans le moindre message. Ajouter un fichier ici impose donc d'ajouter
|
||||||
|
une ligne la-bas. Et leur numerotation commence a `100` pour passer apres `010`, y compris
|
||||||
|
en locale C ou un prefixe a deux chiffres se trierait avant.
|
||||||
|
|
||||||
|
Comme un bootstrap peut toujours avoir ete saute, deux gardes le rattrapent :
|
||||||
|
`/api/v1/health/ready` repond 503 si l'extension n'est pas chargee, et la premiere
|
||||||
|
revision Alembic refuse de s'appliquer.
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Piege : ce script ne rejoue qu'a la premiere initialisation, quand PGDATA est vide.
|
||||||
|
-- Le modifier ensuite reste sans effet tant que le volume n'est pas detruit.
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS timescaledb;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Contrainte : le nom de cette base est code en dur dans apps/backend/tests/conftest.py.
|
||||||
|
-- Elle sert la suite de tests de la stack locale, pas un deploiement.
|
||||||
|
|
||||||
|
CREATE DATABASE enervision_test;
|
||||||
|
|
||||||
|
\connect enervision_test
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS timescaledb;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Piege : PGDATA de l'image timescaledb-ha vaut /home/postgres/pgdata/data, pas le chemin
|
||||||
|
# habituel de l'image postgres. Monte ailleurs, le volume ne retient rien, sans erreur.
|
||||||
|
# Piege : db/init est monte fichier par fichier. Monter le dossier masquerait les scripts
|
||||||
|
# d'init de l'image, dont timescaledb-tune. Ajouter un fichier impose une ligne ici.
|
||||||
|
|
||||||
|
name: enervision
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: timescale/timescaledb-ha:pg17
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:?}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:?}
|
||||||
|
TIMESCALEDB_TELEMETRY: ${TIMESCALEDB_TELEMETRY:-off}
|
||||||
|
ports:
|
||||||
|
- "${POSTGRES_PORT:-5433}:5432"
|
||||||
|
volumes:
|
||||||
|
- pgdata:/home/postgres/pgdata/data
|
||||||
|
- ./db/init/100-extensions.sql:/docker-entrypoint-initdb.d/100-extensions.sql:ro
|
||||||
|
- ./db/init/110-test-database.sql:/docker-entrypoint-initdb.d/110-test-database.sql:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 40s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build: ./apps/backend
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
APP_ENV: ${APP_ENV:-local}
|
||||||
|
APP_DEBUG: ${APP_DEBUG:-false}
|
||||||
|
APP_LOG_LEVEL: ${APP_LOG_LEVEL:-INFO}
|
||||||
|
APP_SECRET_KEY: ${APP_SECRET_KEY:?}
|
||||||
|
APP_CORS_ORIGINS: ${APP_CORS_ORIGINS:-http://localhost:4200}
|
||||||
|
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
|
ports:
|
||||||
|
- "${BACKEND_PORT:-8000}:8000"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# 0001 - PostgreSQL avec l'extension TimescaleDB
|
||||||
|
|
||||||
|
- Statut : accepte
|
||||||
|
- Date : 2026-09-14
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
EnerVision collecte, stocke et restitue des series temporelles energetiques sur une machine
|
||||||
|
on-premise. La charge est dominee par des insertions horodatees en flux et par des lectures
|
||||||
|
agregees sur des fenetres de temps. Airflow produira des agregations continues, Grafana lira
|
||||||
|
les memes donnees, et l'API FastAPI les exposera.
|
||||||
|
|
||||||
|
Un SGBD relationnel generaliste sait faire, mais degrade a mesure que la table de mesures
|
||||||
|
grossit : les index se fragmentent, les balayages de fenetre deviennent couteux, et il faut
|
||||||
|
ecrire a la main le partitionnement, la retention et les agregats pre-calcules.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
PostgreSQL 17 avec l'extension TimescaleDB, servie en local par l'image
|
||||||
|
`timescale/timescaledb-ha:pg17`.
|
||||||
|
|
||||||
|
PostgreSQL reste une base relationnelle standard : un seul SGBD pour les donnees metier et
|
||||||
|
les mesures, un seul dialecte SQL, un seul pilote (`asyncpg`), et l'outillage habituel.
|
||||||
|
TimescaleDB ajoute le partitionnement automatique, les agregations continues et les
|
||||||
|
politiques de retention sans changer de moteur.
|
||||||
|
|
||||||
|
L'image `-ha` plutot que l'image alpine : elle embarque `timescaledb_toolkit`, `postgis` et
|
||||||
|
`pgvector`. Le toolkit porte les fonctions de comblement de trous et d'analyse de series dont
|
||||||
|
l'ETL aura besoin, et changer d'image plus tard imposerait une reinitialisation du volume.
|
||||||
|
|
||||||
|
PG17 plutot que PG18 : c'est la version la mieux couverte par Airflow et Grafana a ce jour.
|
||||||
|
|
||||||
|
## Frontiere entre `db/` et `apps/backend/alembic/`
|
||||||
|
|
||||||
|
C'est la regle que ce document existe surtout pour fixer.
|
||||||
|
|
||||||
|
- `db/init/` : bootstrap joue **une seule fois**, a la premiere initialisation du conteneur.
|
||||||
|
Extensions, bases annexes. Ne rejoue jamais sur un volume existant.
|
||||||
|
- `db/migrations/` : SQL versionne qui ne decoule pas du schema applicatif, typiquement les
|
||||||
|
politiques de retention et de compression TimescaleDB.
|
||||||
|
- `apps/backend/alembic/` : le schema expose par l'API, et lui seul. C'est `Base.metadata`
|
||||||
|
qui fait foi.
|
||||||
|
|
||||||
|
Une hypertable relevera des deux : Alembic cree la table, et le `create_hypertable()` vit
|
||||||
|
dans la meme revision Alembic, parce que separer les deux rendrait le schema irreproductible
|
||||||
|
depuis un seul `alembic upgrade head`.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Le projet se lie a une extension, donc a un hebergement qui l'autorise. C'est acquis
|
||||||
|
puisque le deploiement est on-premise.
|
||||||
|
- `CREATE EXTENSION` demande le superutilisateur : cela reste un acte de bootstrap, pas une
|
||||||
|
migration applicative.
|
||||||
|
- Un bootstrap saute ne se voit pas au demarrage de l'API. Deux gardes couvrent ce cas :
|
||||||
|
`/api/v1/health/ready` repond 503 si l'extension est absente, et la premiere revision
|
||||||
|
Alembic refuse de s'appliquer.
|
||||||
|
- L'image `-ha` pese environ 1 Go, a telecharger une fois par poste.
|
||||||
|
|
||||||
|
## Alternatives ecartees
|
||||||
|
|
||||||
|
- **PostgreSQL nu, partitionnement manuel** : faisable, mais il faudrait reecrire ce que
|
||||||
|
TimescaleDB fournit, et le maintenir.
|
||||||
|
- **InfluxDB** : tres bon sur la serie temporelle, mais imposerait un second SGBD pour le
|
||||||
|
relationnel, donc deux dialectes, deux sauvegardes et des jointures applicatives.
|
||||||
|
- **ClickHouse** : taille pour un volume analytique que le projet n'atteindra pas, et moins
|
||||||
|
a l'aise sur les ecritures unitaires frequentes du flux d'ingestion.
|
||||||
Reference in New Issue
Block a user