From d07d152a734ac2e89649016eae488c6de1722a67 Mon Sep 17 00:00:00 2001 From: Tikhanovskii Dmitrii Date: Sun, 28 Sep 2025 10:26:40 +0300 Subject: [PATCH 1/5] Implement ASGI application with fibonacci, factorial, and mean endpoints --- hw1/app.py | 121 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 120 insertions(+), 1 deletion(-) diff --git a/hw1/app.py b/hw1/app.py index 6107b870..2e395267 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,4 +1,37 @@ from typing import Any, Awaitable, Callable +import json +import math +from urllib.parse import parse_qs + + +async def _send_json(send: Callable[[dict[str, Any]], Awaitable[None]], status: int, payload: dict[str, Any] | None = None) -> None: + body_bytes = json.dumps(payload or {}).encode() + await send( + { + "type": "http.response.start", + "status": status, + "headers": [ + (b"content-type", b"application/json; charset=utf-8"), + ], + } + ) + await send({"type": "http.response.body", "body": body_bytes}) + + +def _parse_int(value: str | None) -> tuple[bool, int | None]: + if value is None: + return False, None + try: + return True, int(value) + except Exception: + return False, None + + +def _fibonacci(n: int) -> int: + a, b = 0, 1 + for _ in range(n): + a, b = b, a + b + return b async def application( @@ -12,8 +45,94 @@ async def application( receive: Корутина для получения сообщений от клиента send: Корутина для отправки сообщений клиенту """ - # TODO: Ваша реализация здесь + if scope.get("type") != "http": + await _send_json(send, 404, {"detail": "Not Found"}) + return + + method: str = scope.get("method", "GET") + path: str = scope.get("path", "") + + if method != "GET": + await _send_json(send, 404, {"detail": "Not Found"}) + return + + # /factorial?n= + if path == "/factorial": + query_raw: bytes = scope.get("query_string", b"") + query_params = parse_qs(query_raw.decode()) + values = query_params.get("n") + if not values or len(values) != 1: + await _send_json(send, 422, {"detail": "Invalid query params"}) + return + ok, n = _parse_int(values[0]) + if not ok: + await _send_json(send, 422, {"detail": "Parameter n must be integer"}) + return + if n is None or n < 0: + await _send_json(send, 400, {"detail": "Invalid value for n, must be non-negative"}) + return + result = math.factorial(n) + await _send_json(send, 200, {"result": result}) + return + + # /fibonacci/{n} + if path.startswith("/fibonacci/") and path.count("/") == 2: + n_str = path.split("/")[-1] + ok, n = _parse_int(n_str) + if not ok: + await _send_json(send, 422, {"detail": "Path parameter n must be integer"}) + return + if n is None or n < 0: + await _send_json(send, 400, {"detail": "Invalid value for n, must be non-negative"}) + return + result = _fibonacci(n) + await _send_json(send, 200, {"result": result}) + return + + if path == "/mean": + body = b"" + while True: + message = await receive() + body += message.get("body", b"") + if not message.get("more_body", False): + break + + if not body: + await _send_json(send, 422, {"detail": "Request body required"}) + return + + try: + data = json.loads(body) + except Exception: + await _send_json(send, 422, {"detail": "Invalid JSON"}) + return + + if not isinstance(data, list): + await _send_json(send, 422, {"detail": "Body must be a JSON array"}) + return + + if len(data) == 0: + await _send_json(send, 400, {"detail": "Array must be non-empty"}) + return + + def _is_number(x: Any) -> bool: + # bool is a subclass of int -> exclude + return isinstance(x, (int, float)) and not isinstance(x, bool) + + if not all(_is_number(x) for x in data): + await _send_json(send, 422, {"detail": "Array must contain only numbers"}) + return + + nums = [float(x) for x in data] + mean_value = sum(nums) / len(nums) + await _send_json(send, 200, {"result": mean_value}) + return + + # default 404 + await _send_json(send, 404, {"detail": "Not Found"}) + if __name__ == "__main__": import uvicorn + uvicorn.run("app:application", host="0.0.0.0", port=8000, reload=True) From cfa484c5f93de0079f41be333adab85eb36b4e37 Mon Sep 17 00:00:00 2001 From: Tikhanovskii Dmitrii Date: Sat, 4 Oct 2025 14:16:38 +0300 Subject: [PATCH 2/5] Hw2 --- hw2/hw/shop_api/main.py | 296 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 295 insertions(+), 1 deletion(-) diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..7fdb43c7 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,297 @@ -from fastapi import FastAPI +from typing import Any, Dict, List, Optional +from dataclasses import dataclass, field +from uuid import uuid4 + +from fastapi import FastAPI, HTTPException, Query, Response, WebSocket, WebSocketDisconnect +from pydantic import BaseModel, ConfigDict, Field app = FastAPI(title="Shop API") + + +# In-memory storage +_items: Dict[int, Dict[str, Any]] = {} +_item_id_seq: int = 1 + +_carts: Dict[int, Dict[str, Any]] = {} +_cart_id_seq: int = 1 + + +# Models +class ItemCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + name: str = Field(...) + price: float = Field(..., ge=0) + + +class ItemPut(BaseModel): + model_config = ConfigDict(extra="forbid") + name: str = Field(...) + price: float = Field(..., ge=0) + + +class ItemPatch(BaseModel): + model_config = ConfigDict(extra="forbid") + name: Optional[str] = None + price: Optional[float] = Field(None, ge=0) + + +def _get_item_or_404(item_id: int) -> Dict[str, Any]: + item = _items.get(item_id) + if item is None or item.get("deleted", False): + raise HTTPException(status_code=404) + return item + + +def _cart_price(cart: Dict[str, Any]) -> float: + price = 0.0 + for it in cart["items"]: + item = _items.get(it["id"]) + if item and not item.get("deleted", False): + price += float(item["price"]) * int(it["quantity"]) + return price + + +def _cart_total_quantity(cart: Dict[str, Any]) -> int: + return sum(int(it["quantity"]) for it in cart["items"]) + + +# Cart endpoints +@app.post("/cart") +def create_cart(response: Response) -> Dict[str, int]: + global _cart_id_seq + cart_id = _cart_id_seq + _cart_id_seq += 1 + _carts[cart_id] = {"id": cart_id, "items": []} + response.headers["location"] = f"/cart/{cart_id}" + response.status_code = 201 + return {"id": cart_id} + + +@app.get("/cart/{cart_id}") +def get_cart(cart_id: int) -> Dict[str, Any]: + cart = _carts.get(cart_id) + if not cart: + raise HTTPException(status_code=404) + + items: List[Dict[str, Any]] = [] + for it in cart["items"]: + item = _items.get(it["id"]) or {} + items.append( + { + "id": it["id"], + "name": item.get("name"), + "quantity": it["quantity"], + "available": bool(item) and not item.get("deleted", False), + } + ) + + return {"id": cart_id, "items": items, "price": _cart_price(cart)} + + +@app.get("/cart") +def get_cart_list( + offset: int = Query(0, ge=0), + limit: int = Query(10, gt=0), + min_price: Optional[float] = Query(None, ge=0.0), + max_price: Optional[float] = Query(None, ge=0.0), + min_quantity: Optional[int] = Query(None, ge=0), + max_quantity: Optional[int] = Query(None, ge=0), +) -> List[Dict[str, Any]]: + carts = list(_carts.values()) + + def with_view(cart: Dict[str, Any]) -> Dict[str, Any]: + return { + "id": cart["id"], + "items": cart["items"], + "price": _cart_price(cart), + "_quantity": _cart_total_quantity(cart), + } + + cart_views = [with_view(c) for c in carts] + + if min_price is not None: + cart_views = [c for c in cart_views if c["price"] >= min_price] + if max_price is not None: + cart_views = [c for c in cart_views if c["price"] <= max_price] + if min_quantity is not None: + cart_views = [c for c in cart_views if c["_quantity"] >= min_quantity] + if max_quantity is not None: + cart_views = [c for c in cart_views if c["_quantity"] <= max_quantity] + + cart_views = cart_views[offset : offset + limit] + + # Build response + result: List[Dict[str, Any]] = [] + for c in cart_views: + cart = _carts[c["id"]] + result.append( + { + "id": c["id"], + "items": [ + { + "id": it["id"], + "name": _items.get(it["id"], {}).get("name"), + "quantity": it["quantity"], + "available": (_items.get(it["id"]) is not None) + and not _items.get(it["id"], {}).get("deleted", False), + } + for it in cart["items"] + ], + "price": c["price"], + } + ) + + return result + + +@app.post("/cart/{cart_id}/add/{item_id}") +def add_to_cart(cart_id: int, item_id: int) -> Dict[str, Any]: + cart = _carts.get(cart_id) + if not cart: + raise HTTPException(status_code=404) + + item = _items.get(item_id) + if item is None or item.get("deleted", False): + raise HTTPException(status_code=404) + + for it in cart["items"]: + if it["id"] == item_id: + it["quantity"] += 1 + break + else: + cart["items"].append({"id": item_id, "quantity": 1}) + + return {"id": cart_id} + + +# Item endpoints +@app.post("/item") +def create_item(item: ItemCreate, response: Response) -> Dict[str, Any]: + global _item_id_seq + item_id = _item_id_seq + _item_id_seq += 1 + data = {"id": item_id, "name": item.name, "price": float(item.price), "deleted": False} + _items[item_id] = data + response.status_code = 201 + return data + + +@app.get("/item/{item_id}") +def get_item(item_id: int) -> Dict[str, Any]: + item = _items.get(item_id) + if item is None or item.get("deleted", False): + raise HTTPException(status_code=404) + return item + + +@app.get("/item") +def list_items( + offset: int = Query(0, ge=0), + limit: int = Query(10, gt=0), + min_price: Optional[float] = Query(None, ge=0.0), + max_price: Optional[float] = Query(None, ge=0.0), + show_deleted: bool = Query(False), +) -> List[Dict[str, Any]]: + items = list(_items.values()) + if not show_deleted: + items = [it for it in items if not it.get("deleted", False)] + + if min_price is not None: + items = [it for it in items if float(it["price"]) >= min_price] + if max_price is not None: + items = [it for it in items if float(it["price"]) <= max_price] + + return items[offset : offset + limit] + + +@app.put("/item/{item_id}") +def put_item(item_id: int, body: ItemPut) -> Dict[str, Any]: + item = _items.get(item_id) + if item is None or item.get("deleted", False): + raise HTTPException(status_code=404) + + item.update({"name": body.name, "price": float(body.price)}) + return item + + +@app.patch("/item/{item_id}") +def patch_item(item_id: int, body: ItemPatch) -> Dict[str, Any]: + item = _items.get(item_id) + if item is None: + raise HTTPException(status_code=404) + + if item.get("deleted", False): + return Response(status_code=304) + + updates: Dict[str, Any] = {} + if body.name is not None: + updates["name"] = body.name + if body.price is not None: + updates["price"] = float(body.price) + + if updates: + item.update(updates) + + return item + + +@app.delete("/item/{item_id}") +def delete_item(item_id: int) -> Dict[str, Any]: + item = _items.get(item_id) + if item is None: + raise HTTPException(status_code=404) + + item["deleted"] = True + return {"id": item_id} + + +# --- WebSocket Chat rooms --- +@dataclass(slots=True) +class Room: + name: str + subscribers: List[WebSocket] = field(init=False, default_factory=list) + + async def subscribe(self, ws: WebSocket) -> None: + await ws.accept() + self.subscribers.append(ws) + + def unsubscribe(self, ws: WebSocket) -> None: + if ws in self.subscribers: + self.subscribers.remove(ws) + + async def publish(self, message: str) -> None: + for ws in list(self.subscribers): + try: + await ws.send_text(message) + except Exception: + self.unsubscribe(ws) + + +@dataclass(slots=True) +class ChatHub: + rooms: Dict[str, Room] = field(init=False, default_factory=dict) + + def get_room(self, name: str) -> Room: + room = self.rooms.get(name) + if room is None: + room = Room(name=name) + self.rooms[name] = room + return room + + +_chat_hub = ChatHub() + + +@app.websocket("/chat/{chat_name}") +async def ws_chat(chat_name: str, ws: WebSocket): + room = _chat_hub.get_room(chat_name) + client_id = str(uuid4())[:8] + await room.subscribe(ws) + await room.publish(f"[{chat_name}] client {client_id} joined") + try: + while True: + text = await ws.receive_text() + await room.publish(f"{client_id}::{text}") + except WebSocketDisconnect: + room.unsubscribe(ws) + await room.publish(f"[{chat_name}] client {client_id} left") From cc710dde0a68f875b741f6ba133394b06d55870a Mon Sep 17 00:00:00 2001 From: Tikhanovskii Dmitrii Date: Sun, 12 Oct 2025 15:49:14 +0300 Subject: [PATCH 3/5] HW3 --- hw2/hw/Dockerfile | 19 + hw2/hw/docker-compose.yml | 58 +++ .../provisioning/dashboards/dashboard.yml | 12 + .../dashboards/shop-api-dashboard.json | 333 ++++++++++++++++++ .../provisioning/datasources/prometheus.yml | 8 + hw2/hw/prometheus.yml | 19 + hw2/hw/requirements.txt | 1 + hw2/hw/shop_api/main.py | 37 +- 8 files changed, 486 insertions(+), 1 deletion(-) create mode 100644 hw2/hw/Dockerfile create mode 100644 hw2/hw/docker-compose.yml create mode 100644 hw2/hw/grafana/provisioning/dashboards/dashboard.yml create mode 100644 hw2/hw/grafana/provisioning/dashboards/shop-api-dashboard.json create mode 100644 hw2/hw/grafana/provisioning/datasources/prometheus.yml create mode 100644 hw2/hw/prometheus.yml diff --git a/hw2/hw/Dockerfile b/hw2/hw/Dockerfile new file mode 100644 index 00000000..86519559 --- /dev/null +++ b/hw2/hw/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . + +RUN pip install --no-cache-dir -r requirements.txt + +COPY shop_api/ ./shop_api/ + +RUN adduser --disabled-password --gecos '' appuser && chown -R appuser:appuser /app +USER appuser + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD python -c "import requests; requests.get('http://localhost:8000/health', timeout=2)" + +CMD ["uvicorn", "shop_api.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/hw2/hw/docker-compose.yml b/hw2/hw/docker-compose.yml new file mode 100644 index 00000000..3d16c396 --- /dev/null +++ b/hw2/hw/docker-compose.yml @@ -0,0 +1,58 @@ +version: '3.8' + +services: + shop-api: + build: . + ports: + - "8000:8000" + environment: + - PYTHONUNBUFFERED=1 + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + networks: + - monitoring + + prometheus: + image: prom/prometheus:latest + ports: + - "9090:9090" + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml + - prometheus_data:/prometheus + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--web.console.libraries=/etc/prometheus/console_libraries' + - '--web.console.templates=/etc/prometheus/consoles' + - '--storage.tsdb.retention.time=200h' + - '--web.enable-lifecycle' + networks: + - monitoring + + grafana: + image: grafana/grafana:latest + ports: + - "3000:3000" + volumes: + - grafana_data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin + - GF_USERS_ALLOW_SIGN_UP=false + depends_on: + - prometheus + networks: + - monitoring + +networks: + monitoring: + driver: bridge + +volumes: + prometheus_data: + grafana_data: \ No newline at end of file diff --git a/hw2/hw/grafana/provisioning/dashboards/dashboard.yml b/hw2/hw/grafana/provisioning/dashboards/dashboard.yml new file mode 100644 index 00000000..80bea3b1 --- /dev/null +++ b/hw2/hw/grafana/provisioning/dashboards/dashboard.yml @@ -0,0 +1,12 @@ +apiVersion: 1 + +providers: + - name: 'default' + orgId: 1 + folder: '' + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: true + options: + path: /etc/grafana/provisioning/dashboards \ No newline at end of file diff --git a/hw2/hw/grafana/provisioning/dashboards/shop-api-dashboard.json b/hw2/hw/grafana/provisioning/dashboards/shop-api-dashboard.json new file mode 100644 index 00000000..4440ec37 --- /dev/null +++ b/hw2/hw/grafana/provisioning/dashboards/shop-api-dashboard.json @@ -0,0 +1,333 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "pluginVersion": "8.0.0", + "targets": [ + { + "expr": "rate(shop_api_requests_total[5m])", + "refId": "A", + "legendFormat": "{{method}} {{endpoint}}" + } + ], + "title": "Request Rate by Endpoint", + "type": "timeseries" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 3, + "options": { + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "pluginVersion": "8.0.0", + "targets": [ + { + "expr": "histogram_quantile(0.95, rate(shop_api_request_duration_seconds_bucket[5m]))", + "refId": "A" + } + ], + "title": "95th Percentile Response Time", + "type": "gauge" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "pluginVersion": "8.0.0", + "targets": [ + { + "expr": "shop_api_items_created_total", + "refId": "A" + } + ], + "title": "Items Created", + "type": "timeseries" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 5, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "pluginVersion": "8.0.0", + "targets": [ + { + "expr": "shop_api_carts_created_total", + "refId": "A" + } + ], + "title": "Carts Created", + "type": "timeseries" + } + ], + "refresh": "5s", + "schemaVersion": 27, + "style": "dark", + "tags": ["shop-api"], + "templating": { + "list": [] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Shop API Dashboard", + "uid": "shop-api-dashboard", + "version": 1 +} \ No newline at end of file diff --git a/hw2/hw/grafana/provisioning/datasources/prometheus.yml b/hw2/hw/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 00000000..8049912b --- /dev/null +++ b/hw2/hw/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,8 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true \ No newline at end of file diff --git a/hw2/hw/prometheus.yml b/hw2/hw/prometheus.yml new file mode 100644 index 00000000..2d166bc0 --- /dev/null +++ b/hw2/hw/prometheus.yml @@ -0,0 +1,19 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +rule_files: + # - "first_rules.yml" + # - "second_rules.yml" + +scrape_configs: + - job_name: 'shop-api' + static_configs: + - targets: ['shop-api:8000'] + metrics_path: '/metrics' + scrape_interval: 10s + scrape_timeout: 5s + + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] \ No newline at end of file diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 207dcf5c..b1482bfc 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -1,6 +1,7 @@ # Основные зависимости для ASGI приложения fastapi>=0.117.1 uvicorn>=0.24.0 +prometheus-client>=0.19.0 # Зависимости для тестирования pytest>=7.4.0 diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index 7fdb43c7..4aba7d31 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,12 +1,19 @@ from typing import Any, Dict, List, Optional from dataclasses import dataclass, field from uuid import uuid4 - +import time +from prometheus_client import Counter, Histogram, generate_latest, CONTENT_TYPE_LATEST from fastapi import FastAPI, HTTPException, Query, Response, WebSocket, WebSocketDisconnect from pydantic import BaseModel, ConfigDict, Field app = FastAPI(title="Shop API") +# Prometheus metrics +REQUEST_COUNT = Counter('shop_api_requests_total', 'Total requests', ['method', 'endpoint', 'status']) +REQUEST_DURATION = Histogram('shop_api_request_duration_seconds', 'Request duration', ['method', 'endpoint']) +ITEMS_CREATED = Counter('shop_api_items_created_total', 'Total items created') +CARTS_CREATED = Counter('shop_api_carts_created_total', 'Total carts created') + # In-memory storage _items: Dict[int, Dict[str, Any]] = {} @@ -64,6 +71,7 @@ def create_cart(response: Response) -> Dict[str, int]: _carts[cart_id] = {"id": cart_id, "items": []} response.headers["location"] = f"/cart/{cart_id}" response.status_code = 201 + CARTS_CREATED.inc() return {"id": cart_id} @@ -173,6 +181,7 @@ def create_item(item: ItemCreate, response: Response) -> Dict[str, Any]: data = {"id": item_id, "name": item.name, "price": float(item.price), "deleted": False} _items[item_id] = data response.status_code = 201 + ITEMS_CREATED.inc() return data @@ -295,3 +304,29 @@ async def ws_chat(chat_name: str, ws: WebSocket): except WebSocketDisconnect: room.unsubscribe(ws) await room.publish(f"[{chat_name}] client {client_id} left") + + +# Middleware for metrics +@app.middleware("http") +async def metrics_middleware(request, call_next): + start_time = time.time() + method = request.method + endpoint = request.url.path + + response = await call_next(request) + + duration = time.time() - start_time + REQUEST_DURATION.labels(method=method, endpoint=endpoint).observe(duration) + REQUEST_COUNT.labels(method=method, endpoint=endpoint, status=response.status_code).inc() + + return response + +# Health check endpoint +@app.get("/health") +def health_check(): + return {"status": "healthy"} + +# Prometheus metrics endpoint +@app.get("/metrics") +def metrics(): + return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST) From 2eed19529d9e7c22249161655695584805fb01c5 Mon Sep 17 00:00:00 2001 From: Tikhanovskii Dmitrii Date: Sun, 19 Oct 2025 20:03:56 +0300 Subject: [PATCH 4/5] HW4 --- hw2/hw/docker-compose.yml | 28 +++- hw2/hw/requirements.txt | 2 + hw2/hw/shop_api/main.py | 319 +++++++++++++++++++----------------- hw2/hw/tx_anomalies_demo.py | 145 ++++++++++++++++ 4 files changed, 340 insertions(+), 154 deletions(-) create mode 100644 hw2/hw/tx_anomalies_demo.py diff --git a/hw2/hw/docker-compose.yml b/hw2/hw/docker-compose.yml index 3d16c396..8a548557 100644 --- a/hw2/hw/docker-compose.yml +++ b/hw2/hw/docker-compose.yml @@ -1,12 +1,33 @@ -version: '3.8' - services: + db: + image: mysql:8.0 + environment: + MYSQL_DATABASE: shop + MYSQL_USER: shop + MYSQL_PASSWORD: shop + MYSQL_ROOT_PASSWORD: root + ports: + - "3306:3306" + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h localhost -uroot -proot || exit 1"] + interval: 10s + timeout: 5s + retries: 10 + volumes: + - mysql_data:/var/lib/mysql + networks: + - monitoring + shop-api: build: . ports: - "8000:8000" environment: - PYTHONUNBUFFERED=1 + - DATABASE_URL=mysql+pymysql://shop:shop@db:3306/shop + depends_on: + db: + condition: service_healthy healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/health"] interval: 30s @@ -55,4 +76,5 @@ networks: volumes: prometheus_data: - grafana_data: \ No newline at end of file + grafana_data: + mysql_data: \ No newline at end of file diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index b1482bfc..6fc8c938 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -2,6 +2,8 @@ fastapi>=0.117.1 uvicorn>=0.24.0 prometheus-client>=0.19.0 +SQLAlchemy>=2.0.32 +PyMySQL>=1.1.1 # Зависимости для тестирования pytest>=7.4.0 diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index 4aba7d31..4a51a847 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -2,12 +2,45 @@ from dataclasses import dataclass, field from uuid import uuid4 import time +import os from prometheus_client import Counter, Histogram, generate_latest, CONTENT_TYPE_LATEST from fastapi import FastAPI, HTTPException, Query, Response, WebSocket, WebSocketDisconnect from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy import create_engine, Column, Integer, String, Float, Boolean, ForeignKey, func, select, case +from sqlalchemy.orm import declarative_base, sessionmaker + app = FastAPI(title="Shop API") +# --- Database configuration (HW4) --- +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./shop.db") +_engine_connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {} +engine = create_engine(DATABASE_URL, pool_pre_ping=True, future=True, echo=False, connect_args=_engine_connect_args) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False, future=True) +Base = declarative_base() + +class ItemModel(Base): + __tablename__ = "items" + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(255), nullable=False) + price = Column(Float, nullable=False) + deleted = Column(Boolean, nullable=False, default=False) + +class CartModel(Base): + __tablename__ = "carts" + id = Column(Integer, primary_key=True, autoincrement=True) + +class CartItemModel(Base): + __tablename__ = "cart_items" + cart_id = Column(Integer, ForeignKey("carts.id", ondelete="CASCADE"), primary_key=True) + item_id = Column(Integer, ForeignKey("items.id", ondelete="RESTRICT"), primary_key=True) + quantity = Column(Integer, nullable=False, default=1) + +@app.on_event("startup") +def _create_tables_on_startup() -> None: + Base.metadata.create_all(bind=engine) + + # Prometheus metrics REQUEST_COUNT = Counter('shop_api_requests_total', 'Total requests', ['method', 'endpoint', 'status']) REQUEST_DURATION = Histogram('shop_api_request_duration_seconds', 'Request duration', ['method', 'endpoint']) @@ -42,58 +75,58 @@ class ItemPatch(BaseModel): price: Optional[float] = Field(None, ge=0) -def _get_item_or_404(item_id: int) -> Dict[str, Any]: - item = _items.get(item_id) - if item is None or item.get("deleted", False): - raise HTTPException(status_code=404) - return item +def _cart_price_db(db, cart_id: int) -> float: + result = db.execute( + select(func.coalesce(func.sum(CartItemModel.quantity * ItemModel.price), 0.0)) + .join(ItemModel, CartItemModel.item_id == ItemModel.id) + .where(CartItemModel.cart_id == cart_id, ItemModel.deleted.is_(False)) + ).scalar_one() + return float(result or 0.0) -def _cart_price(cart: Dict[str, Any]) -> float: - price = 0.0 - for it in cart["items"]: - item = _items.get(it["id"]) - if item and not item.get("deleted", False): - price += float(item["price"]) * int(it["quantity"]) - return price +def _cart_items_view(db, cart_id: int) -> List[Dict[str, Any]]: + rows = db.execute( + select(CartItemModel.item_id, CartItemModel.quantity, ItemModel.name, ItemModel.deleted) + .join(ItemModel, CartItemModel.item_id == ItemModel.id, isouter=True) + .where(CartItemModel.cart_id == cart_id) + ).all() + items: List[Dict[str, Any]] = [] + for item_id, quantity, name, deleted in rows: + available = (name is not None) and (not bool(deleted)) + items.append({"id": item_id, "name": name, "quantity": int(quantity), "available": available}) + return items -def _cart_total_quantity(cart: Dict[str, Any]) -> int: - return sum(int(it["quantity"]) for it in cart["items"]) +def _cart_total_quantity_db(db, cart_id: int) -> int: + result = db.execute( + select(func.coalesce(func.sum(CartItemModel.quantity), 0)).where(CartItemModel.cart_id == cart_id) + ).scalar_one() + return int(result or 0) # Cart endpoints @app.post("/cart") def create_cart(response: Response) -> Dict[str, int]: - global _cart_id_seq - cart_id = _cart_id_seq - _cart_id_seq += 1 - _carts[cart_id] = {"id": cart_id, "items": []} - response.headers["location"] = f"/cart/{cart_id}" - response.status_code = 201 - CARTS_CREATED.inc() - return {"id": cart_id} + with SessionLocal() as db: + cart = CartModel() + db.add(cart) + db.flush() + response.headers["location"] = f"/cart/{cart.id}" + response.status_code = 201 + CARTS_CREATED.inc() + db.commit() + return {"id": cart.id} @app.get("/cart/{cart_id}") def get_cart(cart_id: int) -> Dict[str, Any]: - cart = _carts.get(cart_id) - if not cart: - raise HTTPException(status_code=404) - - items: List[Dict[str, Any]] = [] - for it in cart["items"]: - item = _items.get(it["id"]) or {} - items.append( - { - "id": it["id"], - "name": item.get("name"), - "quantity": it["quantity"], - "available": bool(item) and not item.get("deleted", False), - } - ) - - return {"id": cart_id, "items": items, "price": _cart_price(cart)} + with SessionLocal() as db: + cart = db.get(CartModel, cart_id) + if cart is None: + raise HTTPException(status_code=404) + items = _cart_items_view(db, cart_id) + price = _cart_price_db(db, cart_id) + return {"id": cart_id, "items": items, "price": price} @app.get("/cart") @@ -105,92 +138,75 @@ def get_cart_list( min_quantity: Optional[int] = Query(None, ge=0), max_quantity: Optional[int] = Query(None, ge=0), ) -> List[Dict[str, Any]]: - carts = list(_carts.values()) - - def with_view(cart: Dict[str, Any]) -> Dict[str, Any]: - return { - "id": cart["id"], - "items": cart["items"], - "price": _cart_price(cart), - "_quantity": _cart_total_quantity(cart), - } - - cart_views = [with_view(c) for c in carts] - - if min_price is not None: - cart_views = [c for c in cart_views if c["price"] >= min_price] - if max_price is not None: - cart_views = [c for c in cart_views if c["price"] <= max_price] - if min_quantity is not None: - cart_views = [c for c in cart_views if c["_quantity"] >= min_quantity] - if max_quantity is not None: - cart_views = [c for c in cart_views if c["_quantity"] <= max_quantity] - - cart_views = cart_views[offset : offset + limit] - - # Build response - result: List[Dict[str, Any]] = [] - for c in cart_views: - cart = _carts[c["id"]] - result.append( - { - "id": c["id"], - "items": [ - { - "id": it["id"], - "name": _items.get(it["id"], {}).get("name"), - "quantity": it["quantity"], - "available": (_items.get(it["id"]) is not None) - and not _items.get(it["id"], {}).get("deleted", False), - } - for it in cart["items"] - ], - "price": c["price"], - } - ) - - return result + with SessionLocal() as db: + price_case = case((ItemModel.deleted.is_(False), ItemModel.price), else_=0.0) + rows = db.execute( + select( + CartModel.id.label("id"), + func.coalesce(func.sum(CartItemModel.quantity * price_case), 0.0).label("price"), + func.coalesce(func.sum(CartItemModel.quantity), 0).label("quantity"), + ) + .outerjoin(CartItemModel, CartItemModel.cart_id == CartModel.id) + .outerjoin(ItemModel, ItemModel.id == CartItemModel.item_id) + .group_by(CartModel.id) + ).all() + views = [ + {"id": r.id, "price": float(r.price or 0.0), "_quantity": int(r.quantity or 0)} for r in rows + ] + if min_price is not None: + views = [v for v in views if v["price"] >= min_price] + if max_price is not None: + views = [v for v in views if v["price"] <= max_price] + if min_quantity is not None: + views = [v for v in views if v["_quantity"] >= min_quantity] + if max_quantity is not None: + views = [v for v in views if v["_quantity"] <= max_quantity] + views = views[offset : offset + limit] + result: List[Dict[str, Any]] = [] + for v in views: + items = _cart_items_view(db, v["id"]) + result.append({"id": v["id"], "items": items, "price": v["price"]}) + return result @app.post("/cart/{cart_id}/add/{item_id}") def add_to_cart(cart_id: int, item_id: int) -> Dict[str, Any]: - cart = _carts.get(cart_id) - if not cart: - raise HTTPException(status_code=404) - - item = _items.get(item_id) - if item is None or item.get("deleted", False): - raise HTTPException(status_code=404) - - for it in cart["items"]: - if it["id"] == item_id: - it["quantity"] += 1 - break - else: - cart["items"].append({"id": item_id, "quantity": 1}) - - return {"id": cart_id} + with SessionLocal() as db: + cart = db.get(CartModel, cart_id) + if cart is None: + raise HTTPException(status_code=404) + item = db.get(ItemModel, item_id) + if item is None or item.deleted: + raise HTTPException(status_code=404) + ci = db.get(CartItemModel, (cart_id, item_id)) + if ci: + ci.quantity = int(ci.quantity) + 1 + else: + db.add(CartItemModel(cart_id=cart_id, item_id=item_id, quantity=1)) + db.commit() + return {"id": cart_id} # Item endpoints @app.post("/item") def create_item(item: ItemCreate, response: Response) -> Dict[str, Any]: - global _item_id_seq - item_id = _item_id_seq - _item_id_seq += 1 - data = {"id": item_id, "name": item.name, "price": float(item.price), "deleted": False} - _items[item_id] = data - response.status_code = 201 - ITEMS_CREATED.inc() - return data + with SessionLocal() as db: + m = ItemModel(name=item.name, price=float(item.price), deleted=False) + db.add(m) + db.flush() + response.status_code = 201 + ITEMS_CREATED.inc() + db.commit() + return {"id": m.id, "name": m.name, "price": float(m.price), "deleted": bool(m.deleted)} @app.get("/item/{item_id}") def get_item(item_id: int) -> Dict[str, Any]: - item = _items.get(item_id) - if item is None or item.get("deleted", False): - raise HTTPException(status_code=404) - return item + with SessionLocal() as db: + m = db.get(ItemModel, item_id) + if m is None or m.deleted: + raise HTTPException(status_code=404) + return {"id": m.id, "name": m.name, "price": float(m.price), "deleted": bool(m.deleted)} @app.get("/item") @@ -201,57 +217,58 @@ def list_items( max_price: Optional[float] = Query(None, ge=0.0), show_deleted: bool = Query(False), ) -> List[Dict[str, Any]]: - items = list(_items.values()) - if not show_deleted: - items = [it for it in items if not it.get("deleted", False)] - - if min_price is not None: - items = [it for it in items if float(it["price"]) >= min_price] - if max_price is not None: - items = [it for it in items if float(it["price"]) <= max_price] - - return items[offset : offset + limit] + with SessionLocal() as db: + q = select(ItemModel) + if not show_deleted: + q = q.where(ItemModel.deleted.is_(False)) + if min_price is not None: + q = q.where(ItemModel.price >= float(min_price)) + if max_price is not None: + q = q.where(ItemModel.price <= float(max_price)) + q = q.offset(offset).limit(limit) + rows = db.execute(q).scalars().all() + return [ + {"id": m.id, "name": m.name, "price": float(m.price), "deleted": bool(m.deleted)} for m in rows + ] @app.put("/item/{item_id}") def put_item(item_id: int, body: ItemPut) -> Dict[str, Any]: - item = _items.get(item_id) - if item is None or item.get("deleted", False): - raise HTTPException(status_code=404) - - item.update({"name": body.name, "price": float(body.price)}) - return item + with SessionLocal() as db: + m = db.get(ItemModel, item_id) + if m is None or m.deleted: + raise HTTPException(status_code=404) + m.name = body.name + m.price = float(body.price) + db.commit() + return {"id": m.id, "name": m.name, "price": float(m.price), "deleted": bool(m.deleted)} @app.patch("/item/{item_id}") def patch_item(item_id: int, body: ItemPatch) -> Dict[str, Any]: - item = _items.get(item_id) - if item is None: - raise HTTPException(status_code=404) - - if item.get("deleted", False): - return Response(status_code=304) - - updates: Dict[str, Any] = {} - if body.name is not None: - updates["name"] = body.name - if body.price is not None: - updates["price"] = float(body.price) - - if updates: - item.update(updates) - - return item + with SessionLocal() as db: + m = db.get(ItemModel, item_id) + if m is None: + raise HTTPException(status_code=404) + if m.deleted: + return Response(status_code=304) + if body.name is not None: + m.name = body.name + if body.price is not None: + m.price = float(body.price) + db.commit() + return {"id": m.id, "name": m.name, "price": float(m.price), "deleted": bool(m.deleted)} @app.delete("/item/{item_id}") def delete_item(item_id: int) -> Dict[str, Any]: - item = _items.get(item_id) - if item is None: - raise HTTPException(status_code=404) - - item["deleted"] = True - return {"id": item_id} + with SessionLocal() as db: + m = db.get(ItemModel, item_id) + if m is None: + raise HTTPException(status_code=404) + m.deleted = True + db.commit() + return {"id": item_id} # --- WebSocket Chat rooms --- diff --git a/hw2/hw/tx_anomalies_demo.py b/hw2/hw/tx_anomalies_demo.py new file mode 100644 index 00000000..4d29de99 --- /dev/null +++ b/hw2/hw/tx_anomalies_demo.py @@ -0,0 +1,145 @@ +import os +import time +import threading +from typing import Tuple + +from sqlalchemy import create_engine, select, text, delete +from sqlalchemy.orm import declarative_base, sessionmaker +from sqlalchemy import Column, Integer, String +from sqlalchemy.engine import Connection + +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./shop.db") +engine = create_engine(DATABASE_URL, future=True, pool_pre_ping=True) +SessionLocal = sessionmaker(bind=engine, future=True, autocommit=False, autoflush=False) +Base = declarative_base() + + +class Note(Base): + __tablename__ = "tx_notes" + id = Column(Integer, primary_key=True, autoincrement=True) + category = Column(String(64), nullable=False) + value = Column(Integer, nullable=False, default=0) + + +def setup_demo_data() -> None: + Base.metadata.create_all(bind=engine) + with engine.begin() as conn: + conn.execute(text("TRUNCATE TABLE tx_notes")) + with SessionLocal() as db: + db.add_all([ + Note(category="demo", value=10), + Note(category="demo", value=20), + Note(category="other", value=5), + ]) + db.commit() + + +def connect_with_isolation(level: str) -> Connection: + conn = engine.connect().execution_options(isolation_level=level) + return conn + + +# --- Dirty Read --- + +def dirty_read(reader_isolation: str) -> Tuple[int, int]: + setup_demo_data() + with engine.begin() as conn: + conn.execute(text("UPDATE tx_notes SET value = 10 WHERE id = 1")) + + writer = connect_with_isolation("READ COMMITTED") + reader = connect_with_isolation(reader_isolation) + try: + wtx = writer.begin() + writer.execute(text("UPDATE tx_notes SET value = 999 WHERE id = 1")) + + rtx = reader.begin() + val_before = reader.execute(text("SELECT value FROM tx_notes WHERE id = 1")).scalar_one() + + wtx.rollback() + rtx.commit() + + with engine.begin() as conn: + val_after = conn.execute(text("SELECT value FROM tx_notes WHERE id = 1")).scalar_one() + return int(val_before), int(val_after) + finally: + writer.close() + reader.close() + + +# --- Non-Repeatable Read --- + +def non_repeatable_read(tx_isolation: str) -> Tuple[int, int]: + setup_demo_data() + with engine.begin() as conn: + conn.execute(text("UPDATE tx_notes SET value = 10 WHERE id = 1")) + + reader = connect_with_isolation(tx_isolation) + writer = connect_with_isolation("READ COMMITTED") + try: + rtx = reader.begin() + v1 = reader.execute(text("SELECT value FROM tx_notes WHERE id = 1")).scalar_one() + + with writer.begin(): + writer.execute(text("UPDATE tx_notes SET value = 777 WHERE id = 1")) + + v2 = reader.execute(text("SELECT value FROM tx_notes WHERE id = 1")).scalar_one() + rtx.commit() + return int(v1), int(v2) + finally: + reader.close() + writer.close() + + +# --- Phantom Reads --- + +def phantom_read(reader_isolation: str) -> Tuple[int, int]: + setup_demo_data() + + reader = connect_with_isolation(reader_isolation) + try: + rtx = reader.begin() + c1 = reader.execute(text("SELECT COUNT(*) FROM tx_notes WHERE category = 'demo'")) .scalar_one() + + w = connect_with_isolation("READ COMMITTED") + try: + with w.begin(): + if reader_isolation.upper() == "SERIALIZABLE": + w.execute(text("SET SESSION innodb_lock_wait_timeout = 1")) + try: + w.execute(text("INSERT INTO tx_notes (category, value) VALUES ('demo', 30)")) + except Exception: + pass + finally: + w.close() + + c2 = reader.execute(text("SELECT COUNT(*) FROM tx_notes WHERE category = 'demo'")) .scalar_one() + rtx.commit() + return int(c1), int(c2) + finally: + reader.close() + + +def run_all(): + print(f"Using DATABASE_URL={DATABASE_URL}") + print("\n=== Dirty Read Demo ===") + before, after = dirty_read("READ UNCOMMITTED") + print(f"READ UNCOMMITTED: saw uncommitted value={before}, after rollback committed value={after}") + before_rc, after_rc = dirty_read("READ COMMITTED") + print(f"READ COMMITTED: saw committed value only={before_rc}, final value={after_rc}") + + print("\n=== Non-Repeatable Read Demo ===") + v1_rc, v2_rc = non_repeatable_read("READ COMMITTED") + print(f"READ COMMITTED: first={v1_rc}, second={v2_rc} (changed)") + v1_rr, v2_rr = non_repeatable_read("REPEATABLE READ") + print(f"REPEATABLE READ: first={v1_rr}, second={v2_rr} (stable)") + + print("\n=== Phantom Read Demo ===") + c1_rc, c2_rc = phantom_read("READ COMMITTED") + print(f"READ COMMITTED: count1={c1_rc}, count2={c2_rc} (phantom)") + c1_ser, c2_ser = phantom_read("SERIALIZABLE") + print(f"SERIALIZABLE: count1={c1_ser}, count2={c2_ser} (no phantom expected)") + + +if __name__ == "__main__": + print(f"Using DATABASE_URL={DATABASE_URL}") + run_all() \ No newline at end of file From ac7121c4e81cf6ffc524f2e8012c87164cbfa0c9 Mon Sep 17 00:00:00 2001 From: Tikhanovskii Dmitrii Date: Sun, 19 Oct 2025 20:22:48 +0300 Subject: [PATCH 5/5] micro fix to pass tests --- hw2/hw/shop_api/main.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index 4a51a847..0566b831 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -36,6 +36,11 @@ class CartItemModel(Base): item_id = Column(Integer, ForeignKey("items.id", ondelete="RESTRICT"), primary_key=True) quantity = Column(Integer, nullable=False, default=1) +try: + Base.metadata.create_all(bind=engine) +except Exception: + pass + @app.on_event("startup") def _create_tables_on_startup() -> None: Base.metadata.create_all(bind=engine)