From 8fda535e2b8284a7ae248130b95d3bddf4271ccc Mon Sep 17 00:00:00 2001 From: Saqib Date: Thu, 3 Sep 2026 09:34:44 +0530 Subject: [PATCH] fix: stop rewriting the user row on every login This is the actual cause of the connection exhaustion. My earlier fix (more uvicorn workers) addressed a real constraint but not this one, and concurrency did not improve as a result - the bottleneck is a row lock in Postgres, which no number of application workers can help. Captured from pg_stat_activity during a burst of concurrent logins: 36 active | Lock | tuple 31 active | Lock | transactionid 16 idle in transaction | Client| ClientRead 67 UPDATE "ds_user" SET "password" = ..., "last_login" = ... Every login called user.save() unconditionally, rewriting every column of the same row. Concurrent logins for one user therefore queued on that row's lock, and each waiting request held a database connection while it waited - which is what walked the connection count up to max_connections and produced "sorry, too many clients already", the 504s, and the failed deploy. Two changes: - sync_user_from_keycloak compares against the stored values and saves only when a field actually changed, with update_fields to keep the UPDATE narrow. A repeat login of an unchanged user now performs no write at all, so there is no lock to contend on. - validate_token no longer calls user.save() on the Django-JWT path. It rewrote an unchanged row on every authenticated request for no benefit. New-user creation is untouched: that INSERT is genuine work. --- api/utils/keycloak_utils.py | 42 ++++++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/api/utils/keycloak_utils.py b/api/utils/keycloak_utils.py index d6f4f46..d1a6ffc 100644 --- a/api/utils/keycloak_utils.py +++ b/api/utils/keycloak_utils.py @@ -86,7 +86,11 @@ def validate_token( from authorization.models import User user = User.objects.get(id=user_id) - user.save() + # Deliberately no user.save() here. It rewrote every + # column of an unchanged row on every authenticated + # request, taking a row lock for no benefit - and since + # concurrent requests for one user contend on that single + # row, it serialized them against each other. # NOTE: Organizations are managed in DataSpace database, not Keycloak # Organization memberships should be created/managed through DataSpace's @@ -412,13 +416,35 @@ def sync_user_from_keycloak( ) if user: - # Update existing user - user.keycloak_id = keycloak_id - user.username = username - user.email = email - user.first_name = user_info.get("given_name", "") or user.first_name - user.last_name = user_info.get("family_name", "") or user.last_name - user.is_active = True + # Update existing user, but only write when something actually + # changed. The unconditional save this replaces was the cause + # of the connection exhaustion: every login rewrote the same + # row, so concurrent logins for one user queued on a row lock + # (pg_stat_activity showed "Lock: tuple" and + # "Lock: transactionid" on UPDATE "ds_user"), each holding a + # database connection while it waited. + desired = { + "keycloak_id": keycloak_id, + "username": username, + "email": email, + "first_name": user_info.get("given_name", "") or user.first_name, + "last_name": user_info.get("family_name", "") or user.last_name, + "is_active": True, + "is_staff": "admin" in roles, + "is_superuser": "admin" in roles, + } + changed = [ + field + for field, value in desired.items() + if getattr(user, field) != value + ] + if changed: + for field in changed: + setattr(user, field, desired[field]) + # update_fields keeps the UPDATE narrow instead of + # rewriting every column. + user.save(update_fields=changed) + return user else: # Create new user user = User(