diff --git a/.gitignore b/.gitignore index 6769e21..b0b6f3a 100644 --- a/.gitignore +++ b/.gitignore @@ -157,4 +157,4 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ \ No newline at end of file +.idea/ \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..118892b --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "runserver", + "5047" + ], + "django": true, + "autoStartBrowser": false + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index b5cdf43..3f4a3c3 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,18 @@ Versão Python, Django e PostgreSQL da [rinha de backend 2ª edição - 2024/Q1] - PostgreSQL - nginx -## Running the project +## Executando o projeto -```sh -python manage.py runserver 5047 -``` \ No newline at end of file +```bash +docker compose up api1 api2 nginx +``` + +## Resultados dos testes locais com gatling + +![Métricas do gatling](docs/gatling.png) + + +## Outras versões da rinha + +- [aspnet com EF Core e PostgreSQL](https://github.com/rafaelpadovezi/rinha-2) +- [aspnet com MongoDB](https://github.com/rafaelpadovezi/rinha-2-mongo) \ No newline at end of file diff --git a/conf/nginx.conf b/conf/nginx.conf index c12cf44..f0f65e3 100644 --- a/conf/nginx.conf +++ b/conf/nginx.conf @@ -1,5 +1,5 @@ events { - worker_connections 1024; + worker_connections 2048; } http { diff --git a/conf/postgresql.conf b/conf/postgresql.conf new file mode 100644 index 0000000..960eb62 --- /dev/null +++ b/conf/postgresql.conf @@ -0,0 +1,6 @@ +listen_addresses = '*' + +max_connections = 200 +client_encoding = utf8 +default_transaction_isolation = 'read committed' +timezone = UTC \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index f94faae..5bbe159 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,8 @@ services: - "5432:5432" volumes: - ./conf/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d + - ./conf/postgresql.conf:/etc/postgresql/postgresql.conf + command: postgres -c config_file=/etc/postgresql/postgresql.conf environment: POSTGRES_PASSWORD: postgres POSTGRES_DB: rinha @@ -15,9 +17,8 @@ services: deploy: resources: limits: - cpus: "0.4" - memory: "200MB" - command: postgres -c max_connections=500 + cpus: "0.40" + memory: "250MB" healthcheck: test: [ @@ -46,16 +47,21 @@ services: condition: service_healthy environment: - DB_HOST=db + - USE_STATIC_FILE_HANDLER_FROM_WSGI=TRUE + - DATABASE_URL=host=db dbname=rinha user=postgres password=postgres + - GUNICORN_WORKERS=2 + - DB_POOL_MAX_SIZE=45 + - DEBUG=False deploy: resources: limits: cpus: "0.47" - memory: "165MB" + memory: "140MB" api2: <<: *api1 hostname: api2 ports: - - "8080:8080" + - "8081:8080" nginx: image: nginx:alpine ports: diff --git a/docs/gatling.png b/docs/gatling.png new file mode 100644 index 0000000..7a84fe1 Binary files /dev/null and b/docs/gatling.png differ diff --git a/gunicorn.conf.py b/gunicorn.conf.py index db6e3ea..748168d 100644 --- a/gunicorn.conf.py +++ b/gunicorn.conf.py @@ -1,7 +1,6 @@ -from gevent import monkey -import multiprocessing +import os -monkey.patch_all() - -workers = 4 -worker_class = "gevent" +backlog = int(os.getenv("GUNICORN_BACKLOG", "2048")) +workers = int(os.getenv("GUNICORN_WORKERS", "3")) +worker_class = os.getenv("GUNICORN_WORKER_CLASS", "gevent") +worker_connections = int(os.getenv("GUNICORN_WORKER_CONNECTIONS", "100")) diff --git a/poetry.lock b/poetry.lock index dcac7d8..f7bc9d7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. [[package]] name = "asgiref" @@ -80,13 +80,13 @@ pycparser = "*" [[package]] name = "django" -version = "5.0.2" +version = "5.0.3" description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." optional = false python-versions = ">=3.10" files = [ - {file = "Django-5.0.2-py3-none-any.whl", hash = "sha256:56ab63a105e8bb06ee67381d7b65fe6774f057e41a8bab06c8020c8882d8ecd4"}, - {file = "Django-5.0.2.tar.gz", hash = "sha256:b5bb1d11b2518a5f91372a282f24662f58f66749666b0a286ab057029f728080"}, + {file = "Django-5.0.3-py3-none-any.whl", hash = "sha256:5c7d748ad113a81b2d44750ccc41edc14e933f56581683db548c9257e078cc83"}, + {file = "Django-5.0.3.tar.gz", hash = "sha256:5fb37580dcf4a262f9258c1f4373819aacca906431f505e4688e37f3a99195df"}, ] [package.dependencies] @@ -460,13 +460,13 @@ test = ["pytest", "pytest-cov"] [[package]] name = "typing-extensions" -version = "4.9.0" +version = "4.10.0" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" files = [ - {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"}, - {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"}, + {file = "typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475"}, + {file = "typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 256fbb5..6990807 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "rinha-2-django" version = "0.1.0" description = "" authors = ["Rafael Miranda "] -readme = "README.md" +package-mode = false [tool.poetry.dependencies] python = "^3.12" diff --git a/rinha.http b/rinha.http index 45866d1..361d992 100644 --- a/rinha.http +++ b/rinha.http @@ -16,7 +16,6 @@ POST http://localhost:5047/clientes/1/transacoes Content-Type: application/json { - "valor": 1.2, - "tipo" : "c", - "descricao" : "descricao" + "valor": 1, + "tipo" : "c" } \ No newline at end of file diff --git a/rinha/apps/core/views.py b/rinha/apps/core/views.py index f8e9334..4de28ad 100644 --- a/rinha/apps/core/views.py +++ b/rinha/apps/core/views.py @@ -1,44 +1,53 @@ from rest_framework.request import Request from rest_framework.response import Response -from rest_framework.views import APIView from rest_framework.decorators import api_view -from rinha.apps.core.models import Cliente -from rinha.apps.core.models import Transacao -from django.db import transaction -from rinha.apps.core.serializers import TransacaoSerializer import logging +from psycopg.rows import dict_row +from psycopg_pool import ConnectionPool +from rinha.settings import DATABASE_URL +from rinha.settings import DB_POOL_MAX_SIZE logger = logging.getLogger(__name__) - -class TransacaoView(APIView): - def post(self, request): - pass - +pool = ConnectionPool(DATABASE_URL, open=True, min_size=5, max_size=DB_POOL_MAX_SIZE) @api_view(["GET"]) def get_extrato(request: Request, id: int) -> Response: - try: - cliente = Cliente.objects.get(pk=id) - except Cliente.DoesNotExist: - return Response({"message": "Cliente não encontrado"}, status=404) - transacoes = Transacao.objects.order_by("-id").filter(cliente__id=id)[:10] ultimas_transacoes = [] - for transacao in transacoes: - ultimas_transacoes.append( - { - "valor": transacao.valor, - "tipo": transacao.tipo, - "descricao": transacao.descricao, - "realizada_em": transacao.realizada_em, - } - ) + cliente = None + with pool.connection() as conn: + with conn.cursor(row_factory=dict_row) as cur: + cur.execute(""" + SELECT c.limite, c.saldo, t.* + FROM core_cliente c + LEFT JOIN ( + SELECT * + FROM core_transacao + ORDER BY id DESC + LIMIT 10 + ) t ON c.id = t.cliente_id + WHERE c.id = %s;""", [id]) + for record in cur: + if cliente is None: + cliente = { + "limite": record["limite"], + "saldo": record["saldo"], + } + if record["valor"] is not None: + ultimas_transacoes.append({ + "valor": record["valor"], + "tipo": record["tipo"], + "descricao": record["descricao"], + "realizado_em": record["realizada_em"], + }) + if cliente is None: + return Response({"message": "Cliente não encontrado"}, status=404) return Response( { "saldo": { - "limite": cliente.limite, - "total": cliente.saldo, + "limite": cliente["limite"], + "total": cliente["saldo"], }, "ultimas_transacoes": ultimas_transacoes, } @@ -47,26 +56,36 @@ def get_extrato(request: Request, id: int) -> Response: @api_view(["POST"]) def create_transacao(request: Request, id: int) -> Response: - serializer = TransacaoSerializer(data=request.data) - if not serializer.is_valid(): - return Response(serializer.errors, status=422) - - transacao = serializer.validated_data + transacao = request.data + tipo = transacao.get("tipo") + valor = transacao.get("valor") + descricao = transacao.get("descricao") + if tipo not in ["c", "d"]: + return Response({"message": "Tipo inválido"}, status=422) + if not isinstance(valor, int) or valor < 1: + return Response({"message": "Valor inválido"}, status=422) + if descricao is None or not (1 <= len(descricao) <= 10): + return Response({"message": "Descrição inválida"}, status=422) valor_transacao = ( transacao["valor"] if transacao["tipo"] == "c" else transacao["valor"] * -1 ) - with transaction.atomic(): - cliente = Cliente.objects.select_for_update().get(pk=id) - if cliente.saldo + valor_transacao < cliente.limite * -1: - return Response({"message": "Saldo insuficiente"}, status=422) + with pool.connection() as conn: + + with conn.cursor(row_factory=dict_row) as cur: + cur.execute("SELECT c.limite, c.saldo FROM core_cliente c WHERE c.id = %s FOR UPDATE", [id]) + result = cur.fetchone() + cliente = { + "limite": result["limite"], + "saldo": result["saldo"], + } + novo_saldo = cliente["saldo"] + valor_transacao + if novo_saldo < cliente["limite"] * -1: + return Response({"message": "Saldo insuficiente"}, status=422) + cur.execute("UPDATE core_cliente SET saldo = %s WHERE id = %s", (novo_saldo, id)) + cur.execute( + """INSERT INTO core_transacao (cliente_id, valor, tipo, descricao, realizada_em) + VALUES (%s, %s, %s, %s, 'now'); + """, (id, transacao["valor"], transacao["tipo"], transacao["descricao"])) - cliente.saldo += valor_transacao - cliente.save() - Transacao.objects.create( - cliente=cliente, - valor=transacao["valor"], - tipo=transacao["tipo"], - descricao=transacao["descricao"], - ) - return Response({"saldo": cliente.saldo, "limite": cliente.limite}) + return Response({"saldo": novo_saldo, "limite": cliente["limite"]}) diff --git a/rinha/settings.py b/rinha/settings.py index a3cb90d..30da886 100644 --- a/rinha/settings.py +++ b/rinha/settings.py @@ -1,15 +1,3 @@ -""" -Django settings for rinha project. - -Generated by 'django-admin startproject' using Django 5.0.2. - -For more information on this file, see -https://docs.djangoproject.com/en/5.0/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/5.0/ref/settings/ -""" - import os from pathlib import Path @@ -24,7 +12,7 @@ SECRET_KEY = "django-insecure-k!x96799qq7@lcd(9l2ra*4+p29bw2%024)nha$3n^*bjzdw@)" # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +DEBUG = os.getenv("DEBUG", True) ALLOWED_HOSTS = ["*"] @@ -38,17 +26,17 @@ }, "root": { "handlers": ["console"], - "level": "WARNING", + "level": os.getenv("DJANGO_LOG_LEVEL", "ERROR"), }, "loggers": { "django": { "handlers": ["console"], - "level": os.getenv("DJANGO_LOG_LEVEL", "WARNING"), + "level": os.getenv("DJANGO_LOG_LEVEL", "ERROR"), "propagate": False, }, "django.db.backends": { "handlers": ["console"], - "level": "WARNING", + "level": os.getenv("DJANGO_LOG_LEVEL", "ERROR"), "propagate": True, }, }, @@ -59,22 +47,12 @@ INSTALLED_APPS = [ "rinha.apps.core", - "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", - "django.contrib.sessions", - "django.contrib.messages", - "django.contrib.staticfiles", ] MIDDLEWARE = [ - "django.middleware.security.SecurityMiddleware", - "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", - "django.middleware.csrf.CsrfViewMiddleware", - "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.messages.middleware.MessageMiddleware", - "django.middleware.clickjacking.XFrameOptionsMiddleware", ] ROOT_URLCONF = "rinha.urls" @@ -153,3 +131,7 @@ # https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +USE_STATIC_FILE_HANDLER_FROM_WSGI = os.getenv("USE_STATIC_FILE_HANDLER_FROM_WSGI", False) +DATABASE_URL = os.getenv("DATABASE_URL", "host=localhost dbname=rinha user=postgres password=postgres") +DB_POOL_MAX_SIZE = int(os.getenv("DB_POOL_MAX_SIZE", "15")) diff --git a/rinha/urls.py b/rinha/urls.py index 731ebbd..7546a18 100644 --- a/rinha/urls.py +++ b/rinha/urls.py @@ -3,7 +3,7 @@ from rinha.apps.core import views urlpatterns = [ - path("admin/", admin.site.urls), + # path("admin/", admin.site.urls), path("clientes//extrato", views.get_extrato), path("clientes//transacoes", views.create_transacao), ] diff --git a/rinha/wsgi.py b/rinha/wsgi.py index 5cdd769..fd8c280 100644 --- a/rinha/wsgi.py +++ b/rinha/wsgi.py @@ -10,7 +10,12 @@ import os from django.core.wsgi import get_wsgi_application +from rinha import settings +from django.contrib.staticfiles.handlers import StaticFilesHandler os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'rinha.settings') -application = get_wsgi_application() +if settings.USE_STATIC_FILE_HANDLER_FROM_WSGI: + application = StaticFilesHandler(get_wsgi_application()) +else: + application = get_wsgi_application()