Skip to content

Commit 029bc3b

Browse files
authored
fix: api announcements are not deleted from user subscriptions (#1748)
1 parent 20c2806 commit 029bc3b

24 files changed

Lines changed: 764 additions & 91 deletions

.github/workflows/api-deploy-developer.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ jobs:
225225
--update-secrets "FEEDS_DATABASE_URL=DEV_FEEDS_DATABASE_URL:latest,USERS_DATABASE_URL=DEV_USERS_DATABASE_URL:latest,S2S_JWT_SECRET=DEV_S2S_JWT_SECRET:latest" \
226226
--set-env-vars "PROJECT_ID=${{ env.PROJECT_ID }}" \
227227
--vpc-connector "projects/${{ env.VPC_CONNECTOR_PROJECT }}/locations/${{ env.REGION }}/connectors/${{ env.VPC_CONNECTOR }}" \
228-
--vpc-egress all
228+
--vpc-egress private-ranges-only
229229
230230
- name: Get service URL
231231
id: get_url

api/.openapi-generator/FILES

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ src/user_service/impl/__init__.py
4747
src/user_service_gen/apis/__init__.py
4848
src/user_service_gen/apis/notifications_api.py
4949
src/user_service_gen/apis/notifications_api_base.py
50+
src/user_service_gen/apis/subscriptions_api.py
51+
src/user_service_gen/apis/subscriptions_api_base.py
5052
src/user_service_gen/apis/users_api.py
5153
src/user_service_gen/apis/users_api_base.py
5254
src/user_service_gen/main.py
@@ -60,5 +62,3 @@ src/user_service_gen/models/update_notification_subscription_request.py
6062
src/user_service_gen/models/update_user_request.py
6163
src/user_service_gen/models/user_profile.py
6264
src/user_service_gen/security_api.py
63-
src/user_service_gen/apis/subscriptions_api.py
64-
src/user_service_gen/apis/subscriptions_api_base.py

api/src/main.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535

3636
from middleware.request_context_middleware import RequestContextMiddleware
3737
from utils.logger import global_logging_setup
38+
from utils.route_offload import offload_blocking_routes
3839

3940
app = FastAPI(
4041
title="Mobility Data Catalog API",
@@ -58,9 +59,12 @@
5859
app.include_router(MetadataApiRouter)
5960
app.include_router(SearchApiRouter)
6061
app.include_router(LicensesApiRouter)
61-
app.include_router(UsersApiRouter)
62-
app.include_router(NotificationsApiRouter)
63-
app.include_router(SubscriptionsApiRouter)
62+
# The user-service routes are generated as ``async def`` but call blocking
63+
# synchronous impls (database + Brevo HTTP). Offload them to the threadpool so a
64+
# slow Brevo call cannot freeze the event loop for every other request.
65+
app.include_router(offload_blocking_routes(UsersApiRouter))
66+
app.include_router(offload_blocking_routes(NotificationsApiRouter))
67+
app.include_router(offload_blocking_routes(SubscriptionsApiRouter))
6468

6569

6670
@app.on_event("startup")

api/src/shared/common/brevo.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,21 @@
3030
from enum import Enum
3131

3232
import sib_api_v3_sdk
33+
import urllib3
3334

3435
logger = logging.getLogger(__name__)
3536

37+
# (connect, read) timeout in seconds for every Brevo API call. Without this the SDK uses no
38+
# timeout, so when Brevo is unreachable the underlying urllib3 retries hang for a long time while
39+
# holding the caller's DB connection from the pool, which can exhaust the pool and make requests
40+
# appear "stuck". A short connect timeout makes the failure fast and bounded.
41+
BREVO_REQUEST_TIMEOUT = (3.05, 10)
42+
43+
# Do not retry on connection failures. The Brevo call runs inside an async FastAPI route (which
44+
# executes on the event loop), so a long retry loop would block the whole API, not just this
45+
# request. With no retries, an unreachable Brevo fails within ~the connect timeout above.
46+
_BREVO_RETRIES = urllib3.Retry(total=0, connect=0, read=0, redirect=0, status=0)
47+
3648

3749
class BrevoSubscriptionStatus(Enum):
3850
SUBSCRIBED = "subscribed"
@@ -47,7 +59,10 @@ def _get_contacts_api() -> "sib_api_v3_sdk.ContactsApi":
4759
raise RuntimeError("BREVO_API_KEY environment variable is not set")
4860
configuration = sib_api_v3_sdk.Configuration()
4961
configuration.api_key["api-key"] = api_key
50-
return sib_api_v3_sdk.ContactsApi(sib_api_v3_sdk.ApiClient(configuration))
62+
api = sib_api_v3_sdk.ContactsApi(sib_api_v3_sdk.ApiClient(configuration))
63+
# Disable urllib3 retries so a connection failure fails fast instead of looping.
64+
api.api_client.rest_client.pool_manager.connection_pool_kw["retries"] = _BREVO_RETRIES
65+
return api
5166

5267

5368
def get_announcements_list_id() -> int:
@@ -71,15 +86,20 @@ def add_contact_to_list(email: str, list_id: int, subscription_id: str) -> None:
7186
attributes={"MDB_SUBSCRIPTION_ID": subscription_id},
7287
list_ids=[list_id],
7388
update_enabled=True,
74-
)
89+
),
90+
_request_timeout=BREVO_REQUEST_TIMEOUT,
7591
)
7692

7793

7894
def remove_contact_from_list(email: str, list_id: int) -> None:
7995
"""Remove a Brevo contact from the list. No-op if the contact is not on the list."""
8096
api = _get_contacts_api()
8197
try:
82-
api.remove_contact_from_list(list_id, sib_api_v3_sdk.RemoveContactFromList(emails=[email]))
98+
api.remove_contact_from_list(
99+
list_id,
100+
sib_api_v3_sdk.RemoveContactFromList(emails=[email]),
101+
_request_timeout=BREVO_REQUEST_TIMEOUT,
102+
)
83103
except sib_api_v3_sdk.rest.ApiException as exc:
84104
# 400 "Contact already removed from list" / 404 contact-not-found are idempotent no-ops.
85105
if exc.status in (400, 404):
@@ -107,7 +127,7 @@ def get_contact_subscription_status(
107127
api = _get_contacts_api()
108128

109129
try:
110-
contact = api.get_contact_info(email)
130+
contact = api.get_contact_info(email, _request_timeout=BREVO_REQUEST_TIMEOUT)
111131
except sib_api_v3_sdk.rest.ApiException as exc:
112132
if exc.status == 404:
113133
return BrevoSubscriptionStatus.NOT_FOUND

api/src/shared/database/users_database.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@
2020
from contextlib import contextmanager
2121

2222
from dotenv import load_dotenv
23-
from sqlalchemy import create_engine
24-
from sqlalchemy.orm import sessionmaker
23+
from sqlalchemy import create_engine, event
24+
from sqlalchemy.orm import sessionmaker, mapper
2525

2626
from shared.common.logging_utils import get_env_logging_level
2727

@@ -115,3 +115,24 @@ def wrapper(*args, **kwargs):
115115

116116
wrapper.__wrapped__ = func
117117
return wrapper
118+
119+
120+
@event.listens_for(mapper, "mapper_configured")
121+
def _configure_users_passive_deletes(mapper_, class_):
122+
"""Enable ``passive_deletes`` on ``NotificationSubscription.notification_logs``.
123+
124+
Deleting a subscription then relies on the database ``ON DELETE CASCADE`` to remove its
125+
``notification_log`` rows, instead of SQLAlchemy trying to NULL the NOT NULL
126+
``notification_log.subscription_id`` (which would raise a NotNullViolation).
127+
128+
This mirrors the ``set_cascade`` mechanism in ``shared.database.database`` but is scoped to
129+
the users models, so ``users_database`` stays independent of the feeds ``database_gen``.
130+
131+
The mapper is matched by class name rather than by importing the model, so importing this
132+
module does not require ``shared.users_database_gen`` to be present (functions that symlink
133+
``shared`` without the users models must still be able to import ``users_database``).
134+
"""
135+
if class_.__name__ == "NotificationSubscription" and "notification_logs" in mapper_.relationships:
136+
rel = mapper_.relationships["notification_logs"]
137+
rel.cascade = "all, delete-orphan"
138+
rel.passive_deletes = True

api/src/shared/db_models/notification_subscription_impl.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,5 @@ def from_orm(cls, sub: NotificationSubscriptionOrm | None) -> NotificationSubscr
1919
user_id=sub.user_id,
2020
notification_id=sub.notification_type_id,
2121
active=sub.active,
22-
last_notified_at=sub.last_notified_at,
2322
created_at=sub.created_at,
2423
)

api/src/user_service/impl/subscription_helpers.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from fastapi import HTTPException
2121

2222
import sib_api_v3_sdk
23+
import urllib3
2324
from shared.common.brevo import add_contact_to_list, get_announcements_list_id, remove_contact_from_list
2425

2526
logger = logging.getLogger(__name__)
@@ -34,6 +35,8 @@ def sync_announcements(email: str, subscribe: bool, subscription_id: str | None
3435
add_contact_to_list(email, get_announcements_list_id(), subscription_id)
3536
else:
3637
remove_contact_from_list(email, get_announcements_list_id())
37-
except (RuntimeError, sib_api_v3_sdk.rest.ApiException) as exc:
38+
except (RuntimeError, sib_api_v3_sdk.rest.ApiException, urllib3.exceptions.HTTPError, OSError) as exc:
39+
# urllib3.exceptions.HTTPError / OSError cover connection failures and timeouts (e.g. Brevo
40+
# unreachable), so the request fails fast with a 502 instead of hanging on retries.
3841
logger.error("Brevo sync failed for %s: %s", email, exc)
3942
raise HTTPException(status_code=502, detail="Failed to sync subscription with email provider.")

api/src/user_service/impl/subscriptions_api_impl.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@
2222
AppUser,
2323
NotificationSubscription as NotificationSubscriptionOrm,
2424
)
25-
from user_service.impl.subscription_helpers import ANNOUNCEMENTS_NOTIFICATION_TYPE_ID, sync_announcements
25+
from user_service.impl.subscription_helpers import (
26+
ANNOUNCEMENTS_NOTIFICATION_TYPE_ID,
27+
sync_announcements,
28+
)
2629
from user_service_gen.apis.subscriptions_api_base import BaseSubscriptionsApi
2730
from user_service_gen.models.notification_subscription import NotificationSubscription
2831

@@ -42,14 +45,24 @@ def get_subscription(self, id: str, db_session=None) -> NotificationSubscription
4245

4346
@with_users_db_session
4447
def delete_subscription(self, id: str, db_session=None) -> None:
48+
"""Removes a subscription by ID.
49+
50+
The announcements subscription cannot be deleted; it is disabled instead.
51+
"""
4552
sub = db_session.get(NotificationSubscriptionOrm, id)
4653
if sub is None:
4754
raise HTTPException(status_code=404, detail="Subscription not found.")
4855

4956
if sub.notification_type_id == ANNOUNCEMENTS_NOTIFICATION_TYPE_ID:
5057
user = db_session.get(AppUser, sub.user_id)
51-
if user is not None:
52-
sync_announcements(user.email, subscribe=False)
53-
54-
db_session.delete(sub)
58+
email = user.email if user is not None else None
59+
# Release the pooled DB connection before the (potentially slow) Brevo call so a slow
60+
# or unreachable provider never holds a connection while we talk to it. The read above
61+
# took no row locks, so committing here only returns the connection to the pool.
62+
db_session.commit()
63+
if email is not None:
64+
sync_announcements(email, subscribe=False)
65+
sub.active = False
66+
else:
67+
db_session.delete(sub)
5568
db_session.flush()

api/src/user_service/impl/users_api_impl.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,10 @@
3434
NotificationSubscription as NotificationSubscriptionOrm,
3535
NotificationType,
3636
)
37-
from user_service.impl.subscription_helpers import ANNOUNCEMENTS_NOTIFICATION_TYPE_ID, sync_announcements
37+
from user_service.impl.subscription_helpers import (
38+
ANNOUNCEMENTS_NOTIFICATION_TYPE_ID,
39+
sync_announcements,
40+
)
3841
from user_service_gen.apis.users_api_base import BaseUsersApi
3942
from user_service_gen.models.create_notification_subscription_request import (
4043
CreateNotificationSubscriptionRequest,
@@ -192,15 +195,23 @@ def update_user_subscription(
192195

193196
@with_users_db_session
194197
def delete_user_subscription(self, id: str, db_session=None) -> None:
195-
"""Removes a notification subscription by ID."""
198+
"""Removes a notification subscription by ID.
199+
200+
The announcements subscription cannot be deleted; it is disabled instead.
201+
"""
196202
user_id = self._require_user_id()
197203
sub = self._get_owned_subscription(db_session, id, user_id)
198204

199205
if sub.notification_type_id == ANNOUNCEMENTS_NOTIFICATION_TYPE_ID:
200-
user = db_session.get(AppUser, user_id)
201-
sync_announcements(user.email, subscribe=False)
202-
203-
db_session.delete(sub)
206+
email = db_session.get(AppUser, user_id).email
207+
# Release the pooled DB connection before the (potentially slow) Brevo call so a slow
208+
# or unreachable provider never holds a connection while we talk to it. The reads above
209+
# took no row locks, so committing here only returns the connection to the pool.
210+
db_session.commit()
211+
sync_announcements(email, subscribe=False)
212+
sub.active = False
213+
else:
214+
db_session.delete(sub)
204215
db_session.flush()
205216

206217
# ── Helpers ──────────────────────────────────────────────────────────────

api/src/utils/route_offload.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
#
2+
# MobilityData 2024
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
#
16+
"""Utilities to keep blocking endpoints off the event loop.
17+
18+
The code generated for the user service declares every route as ``async def`` but
19+
the route bodies are fully synchronous: they simply call into a synchronous impl
20+
that performs blocking I/O (database access and Brevo HTTP calls). FastAPI runs
21+
``async def`` path operations directly on the event loop, so a single slow Brevo
22+
call freezes the entire API for every concurrent request.
23+
24+
``offload_blocking_routes`` rewrites those coroutine endpoints into plain
25+
synchronous functions. FastAPI then dispatches them through the anyio threadpool
26+
(see ``fastapi.routing.run_endpoint_function``), so blocking work no longer stalls
27+
the event loop and other requests stay responsive.
28+
"""
29+
30+
import asyncio
31+
import functools
32+
33+
from fastapi import APIRouter
34+
from fastapi.routing import APIRoute
35+
36+
37+
def _to_sync_endpoint(coroutine_endpoint):
38+
"""Wrap a coroutine endpoint whose body is synchronous into a plain function.
39+
40+
The generated user-service endpoints contain no ``await`` expressions, so the
41+
underlying coroutine completes on the first step. Driving it once and reading
42+
the ``StopIteration`` value yields the same result while letting FastAPI run
43+
the wrapper in a threadpool instead of on the event loop.
44+
"""
45+
46+
@functools.wraps(coroutine_endpoint)
47+
def sync_endpoint(*args, **kwargs):
48+
coro = coroutine_endpoint(*args, **kwargs)
49+
try:
50+
coro.send(None)
51+
except StopIteration as stop:
52+
return stop.value
53+
else:
54+
coro.close()
55+
raise RuntimeError(
56+
f"Endpoint {coroutine_endpoint.__qualname__} awaited; it cannot be "
57+
"offloaded to a threadpool. Remove it from offload_blocking_routes."
58+
)
59+
60+
return sync_endpoint
61+
62+
63+
def offload_blocking_routes(router: APIRouter) -> APIRouter:
64+
"""Convert every coroutine route in ``router`` to run in the threadpool.
65+
66+
Must be called before ``app.include_router(router)`` so FastAPI builds the
67+
route handlers from the synchronous endpoints.
68+
"""
69+
for route in router.routes:
70+
if isinstance(route, APIRoute) and asyncio.iscoroutinefunction(route.endpoint):
71+
route.endpoint = _to_sync_endpoint(route.endpoint)
72+
return router

0 commit comments

Comments
 (0)