From f1897ca2ac806459911a20ead80f72e828ad50df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Fri, 24 Apr 2026 19:05:36 +0200 Subject: [PATCH 01/19] =?UTF-8?q?=E2=9E=95=20Provide=20env=20for=20zoom=20?= =?UTF-8?q?token=20ttl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env.example | 3 ++- backend/.env.test | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index a6423959d..72174eb38 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -104,6 +104,7 @@ ZOOM_API_ENABLED=False ZOOM_AUTH_CLIENT_ID= ZOOM_AUTH_SECRET= ZOOM_AUTH_CALLBACK=http://localhost:5000/zoom/callback +ZOOM_TOKEN_TTL_IN_SECONDS=604800 # 7 days # -- SIGNED URL SECRET -- # Shared secret for url signing (e.g. create it by running `openssl rand -hex 32`) @@ -154,4 +155,4 @@ OIDC_TOKEN_INTROSPECTION_URL= OIDC_FALLBACK_MATCH_BY_EMAIL= # Required for Appointment's CalDAV auto-setup, needs to match the one in thunderbird-accounts -APPOINTMENT_CALDAV_SECRET= \ No newline at end of file +APPOINTMENT_CALDAV_SECRET= diff --git a/backend/.env.test b/backend/.env.test index fb0d364e6..343f7e440 100644 --- a/backend/.env.test +++ b/backend/.env.test @@ -63,6 +63,7 @@ ZOOM_API_ENABLED=False ZOOM_AUTH_CLIENT_ID= ZOOM_AUTH_SECRET= ZOOM_AUTH_CALLBACK=http://localhost:8090/zoom/callback +ZOOM_TOKEN_TTL_IN_SECONDS=604800 # 7 days # -- SIGNED URL SECRET -- # Shared secret for url signing (e.g. create it by running `openssl rand -hex 32`) @@ -117,4 +118,4 @@ OIDC_EXP_GRACE_PERIOD=300 OIDC_FALLBACK_MATCH_BY_EMAIL= # Required for Appointment's CalDAV auto-setup, needs to match the one in thunderbird-accounts -APPOINTMENT_CALDAV_SECRET= \ No newline at end of file +APPOINTMENT_CALDAV_SECRET= From f6d15f8f28d0c4da08342e68b9d95a9eecb67edf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Fri, 24 Apr 2026 19:06:54 +0200 Subject: [PATCH 02/19] =?UTF-8?q?=E2=9E=95=20Extend=20expiry=20check=20by?= =?UTF-8?q?=20time=20threshold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/appointment/controller/apis/zoom_client.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/backend/src/appointment/controller/apis/zoom_client.py b/backend/src/appointment/controller/apis/zoom_client.py index fed08f7cb..5988b8e4f 100644 --- a/backend/src/appointment/controller/apis/zoom_client.py +++ b/backend/src/appointment/controller/apis/zoom_client.py @@ -39,13 +39,15 @@ def scopes(self): """Returns the appropriate scopes""" return self.SCOPES - def check_expiry(self, token: dict | None): - """Checks expires_at and if expired sets expires_in to a negative number to trigger refresh""" + def check_expiry(self, token: dict | None, threshold: float): + """Checks expires_at and if expired or within the given threshold + sets expires_in to a negative number to trigger refresh + """ if not token: return token expires_at = token.get('expires_at') - if expires_at and expires_at <= time.time(): + if expires_at and expires_at <= (time.time() + threshold): token['expires_in'] = -100 elif not expires_at: # We shouldn't have to handle this but just in case alert us! @@ -53,12 +55,12 @@ def check_expiry(self, token: dict | None): return token - def setup(self, subscriber_id=None, token=None): - """Setup our oAuth session""" + def setup(self, subscriber_id=None, token=None, threshold=0.0): + """Setup our OAuth session""" if isinstance(token, str): token = json.loads(token) - token = self.check_expiry(token) + token = self.check_expiry(token, threshold) self.subscriber_id = subscriber_id self.client = OAuth2Session( From 864162a5f335e5cb11e4b3788e3c6d6ca1956411 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Fri, 24 Apr 2026 19:07:57 +0200 Subject: [PATCH 03/19] =?UTF-8?q?=E2=9E=95=20Add=20command=20for=20Zoom=20?= =?UTF-8?q?token=20renewal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../appointment/commands/renew_zoom_tokens.py | 48 +++++++++++++++++++ .../database/repo/external_connection.py | 12 +++++ 2 files changed, 60 insertions(+) create mode 100644 backend/src/appointment/commands/renew_zoom_tokens.py diff --git a/backend/src/appointment/commands/renew_zoom_tokens.py b/backend/src/appointment/commands/renew_zoom_tokens.py new file mode 100644 index 000000000..33318a060 --- /dev/null +++ b/backend/src/appointment/commands/renew_zoom_tokens.py @@ -0,0 +1,48 @@ +"""Renew expiring Zoom auth tokens. + +Run periodically (e.g., weekly) to ensure tokens don't expire. +Zoom tokens typically last ~90 days, so weekly renewal keeps a buffer. +""" + +import logging +from datetime import datetime, timedelta, timezone + +from ..database import repo +from ..dependencies.database import get_engine_and_session +from ..dependencies.zoom import get_zoom_client +from ..main import _common_setup + + +def run(): + _common_setup() + + _, SessionLocal = get_engine_and_session() + db = SessionLocal() + + # Renew tokens that expire within the next 7 days + threshold = datetime.now(tz=timezone.utc) + timedelta(days=7) + zoom_connections = repo.external_connection.get_zoom(db) + + checked = 0 + failed = 0 + + for connection in zoom_connections: + if not connection or not connection.token: + continue + + # Setup a new Zoom client to trigger token renewal if expiry date is in the past + # or within the given time threshold + try: + subscriber = repo.subscriber.get(db, connection.owner_id) + zoom_client = get_zoom_client(subscriber) + zoom_client.setup(subscriber.id, connection.token, threshold.timestamp()) + checked += 1 + except Exception as e: + logging.error(f'[renew_zoom_tokens] Failed to renew Zoom token for subscriber {subscriber.id}: {e}') + failed += 1 + + db.close() + logging.info( + f'[renew_zoom_tokens] Zoom token checks complete: ' + f'{checked} checked, {failed} failed, {len(zoom_connections)} total processed' + ) diff --git a/backend/src/appointment/database/repo/external_connection.py b/backend/src/appointment/database/repo/external_connection.py index 43b8a404b..4477927be 100644 --- a/backend/src/appointment/database/repo/external_connection.py +++ b/backend/src/appointment/database/repo/external_connection.py @@ -158,3 +158,15 @@ def get_subscriber_without_oidc_by_email(db: Session, email: str): query = db.query(models.Subscriber).filter(~oidc_exists).filter(models.Subscriber.email == email) return query.first() + + +def get_zoom(db: Session) -> list[models.ExternalConnections] | None: + """Return all external connections by Zoom type""" + query = ( + db.query(models.ExternalConnections) + .filter(models.ExternalConnections.type == models.ExternalConnectionType.zoom) + ) + + result = query.all() + + return result From 24f9764b00ceb9475cc604a0b9c49ba3d4aaa7d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Fri, 24 Apr 2026 19:09:17 +0200 Subject: [PATCH 04/19] =?UTF-8?q?=E2=9E=95=20Add=20Zoom=20renewal=20to=20c?= =?UTF-8?q?elery=20schedule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/appointment/celery_app.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/backend/src/appointment/celery_app.py b/backend/src/appointment/celery_app.py index e16747b82..c0397c93f 100644 --- a/backend/src/appointment/celery_app.py +++ b/backend/src/appointment/celery_app.py @@ -44,6 +44,9 @@ def create_celery_app() -> Celery: google_channel_ttl = float(os.getenv('GOOGLE_CHANNEL_TTL_IN_SECONDS', SEVEN_DAYS_IN_SECONDS)) google_channel_renew_interval = google_channel_ttl - ONE_DAY_IN_SECONDS + zoom_token_ttl = float(os.getenv('ZOOM_TOKEN_TTL_IN_SECONDS', SEVEN_DAYS_IN_SECONDS)) + zoom_token_renew_interval = zoom_token_ttl - ONE_DAY_IN_SECONDS + app = Celery('appointment') app.config_from_object({ @@ -68,6 +71,10 @@ def create_celery_app() -> Celery: 'task': 'appointment.tasks.google.renew_google_channels', 'schedule': google_channel_renew_interval, }, + 'renew-zoom-tokens': { + 'task': 'appointment.tasks.zoom.renew_zoom_tokens', + 'schedule': zoom_token_renew_interval, + }, }, }) From a0fbdb524c4ee8c3a6cc3988672a7849cf238530 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Fri, 24 Apr 2026 22:23:55 +0200 Subject: [PATCH 05/19] =?UTF-8?q?=E2=9E=95=20Add=20celery=20task=20and=20f?= =?UTF-8?q?ix=20missing=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/appointment/celery_app.py | 4 ++-- ..._zoom_tokens.py => refresh_zoom_tokens.py} | 4 ++-- backend/src/appointment/routes/commands.py | 9 ++++++++ backend/src/appointment/tasks/__init__.py | 1 + backend/src/appointment/tasks/zoom.py | 21 +++++++++++++++++++ 5 files changed, 35 insertions(+), 4 deletions(-) rename backend/src/appointment/commands/{renew_zoom_tokens.py => refresh_zoom_tokens.py} (88%) create mode 100644 backend/src/appointment/tasks/zoom.py diff --git a/backend/src/appointment/celery_app.py b/backend/src/appointment/celery_app.py index c0397c93f..4d594befd 100644 --- a/backend/src/appointment/celery_app.py +++ b/backend/src/appointment/celery_app.py @@ -71,8 +71,8 @@ def create_celery_app() -> Celery: 'task': 'appointment.tasks.google.renew_google_channels', 'schedule': google_channel_renew_interval, }, - 'renew-zoom-tokens': { - 'task': 'appointment.tasks.zoom.renew_zoom_tokens', + 'refresh-zoom-tokens': { + 'task': 'appointment.tasks.zoom.refresh_zoom_tokens', 'schedule': zoom_token_renew_interval, }, }, diff --git a/backend/src/appointment/commands/renew_zoom_tokens.py b/backend/src/appointment/commands/refresh_zoom_tokens.py similarity index 88% rename from backend/src/appointment/commands/renew_zoom_tokens.py rename to backend/src/appointment/commands/refresh_zoom_tokens.py index 33318a060..c413a94d8 100644 --- a/backend/src/appointment/commands/renew_zoom_tokens.py +++ b/backend/src/appointment/commands/refresh_zoom_tokens.py @@ -38,11 +38,11 @@ def run(): zoom_client.setup(subscriber.id, connection.token, threshold.timestamp()) checked += 1 except Exception as e: - logging.error(f'[renew_zoom_tokens] Failed to renew Zoom token for subscriber {subscriber.id}: {e}') + logging.error(f'[refresh_zoom_tokens] Failed to renew Zoom token for subscriber {subscriber.id}: {e}') failed += 1 db.close() logging.info( - f'[renew_zoom_tokens] Zoom token checks complete: ' + f'[refresh_zoom_tokens] Zoom token checks complete: ' f'{checked} checked, {failed} failed, {len(zoom_connections)} total processed' ) diff --git a/backend/src/appointment/routes/commands.py b/backend/src/appointment/routes/commands.py index 0f3d376ba..ed53b08d0 100644 --- a/backend/src/appointment/routes/commands.py +++ b/backend/src/appointment/routes/commands.py @@ -11,6 +11,7 @@ generate_documentation_pages, renew_google_channels, backfill_google_channels, + refresh_zoom_tokens, ) router = typer.Typer() @@ -69,3 +70,11 @@ def backfill_channels(): backfill_google_channels.run() except FileExistsError: print('backfill-google-channels is already running, skipping.') + +@router.command('refresh-zoom-tokens') +def refresh_tokens(): + try: + with cron_lock('refresh_zoom_tokens'): + refresh_zoom_tokens.run() + except FileExistsError: + print('refresh-zoom-tokens is already running, skipping.') diff --git a/backend/src/appointment/tasks/__init__.py b/backend/src/appointment/tasks/__init__.py index aeb62618f..4abe57b8d 100644 --- a/backend/src/appointment/tasks/__init__.py +++ b/backend/src/appointment/tasks/__init__.py @@ -1,2 +1,3 @@ from appointment.tasks.health import * # noqa: F401,F403 from appointment.tasks.google import * # noqa: F401,F403 +from appointment.tasks.zoom import * # noqa: F401,F403 diff --git a/backend/src/appointment/tasks/zoom.py b/backend/src/appointment/tasks/zoom.py new file mode 100644 index 000000000..1914916f3 --- /dev/null +++ b/backend/src/appointment/tasks/zoom.py @@ -0,0 +1,21 @@ +import logging + +import sentry_sdk + +from appointment.celery_app import celery + +log = logging.getLogger(__name__) + + +@celery.task +def refresh_zoom_tokens(): + from appointment.commands.refresh_zoom_tokens import run + + log.info('Starting Zoom token check') + try: + run() + except Exception as e: + log.error(f'Zoom token check failed: {e}') + sentry_sdk.capture_exception(e) + raise + log.info('Zoom token check complete') From 91dbaecd9f980e633d9789abc6028506d30322f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Wed, 29 Apr 2026 16:11:21 +0200 Subject: [PATCH 06/19] =?UTF-8?q?=F0=9F=94=A8=20Improve=20comment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/appointment/controller/apis/zoom_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/appointment/controller/apis/zoom_client.py b/backend/src/appointment/controller/apis/zoom_client.py index 5988b8e4f..f776d005f 100644 --- a/backend/src/appointment/controller/apis/zoom_client.py +++ b/backend/src/appointment/controller/apis/zoom_client.py @@ -40,8 +40,8 @@ def scopes(self): return self.SCOPES def check_expiry(self, token: dict | None, threshold: float): - """Checks expires_at and if expired or within the given threshold - sets expires_in to a negative number to trigger refresh + """Checks expires_at and sets expires_in to a negative number to trigger refresh + if already expired or within the given renewal threshold """ if not token: return token From 5fa4335fff1a741c1ad43ce9890f35d26db6180c Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Fri, 1 May 2026 11:24:24 -0600 Subject: [PATCH 07/19] Add external connection repo function to update status --- .../database/repo/external_connection.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/backend/src/appointment/database/repo/external_connection.py b/backend/src/appointment/database/repo/external_connection.py index 4477927be..c06024274 100644 --- a/backend/src/appointment/database/repo/external_connection.py +++ b/backend/src/appointment/database/repo/external_connection.py @@ -5,6 +5,7 @@ import logging import os +from datetime import UTC, datetime import sentry_sdk from sqlalchemy.orm import Session from .. import models @@ -144,6 +145,25 @@ def update_name(db: Session, db_external_connection: models.ExternalConnections, return db_external_connection +def update_status( + db: Session, + subscriber_id: int, + type: models.ExternalConnectionType, + status: models.ExternalConnectionStatus, + type_id: str | None = None, +): + db_results = get_by_type(db, subscriber_id, type, type_id) + if db_results is None or len(db_results) == 0: + return None + + db_external_connection = db_results[0] + db_external_connection.status = status + db_external_connection.status_checked_at = datetime.now(UTC) + db.commit() + db.refresh(db_external_connection) + return db_external_connection + + def get_subscriber_without_oidc_by_email(db: Session, email: str): """Return a subscriber by the OIDC recovery email address without an OIDC connection""" # Subquery to check if a subscriber has an OIDC external connection From 1b257c4ed9353f2ad8b110425b0bf87dc8f9876b Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Fri, 1 May 2026 11:25:03 -0600 Subject: [PATCH 08/19] Force OAuth session attempt to refresh Zoom token and mark ec as error if failure --- .../commands/refresh_zoom_tokens.py | 73 ++++++++++++------- 1 file changed, 48 insertions(+), 25 deletions(-) diff --git a/backend/src/appointment/commands/refresh_zoom_tokens.py b/backend/src/appointment/commands/refresh_zoom_tokens.py index c413a94d8..d57fa0d01 100644 --- a/backend/src/appointment/commands/refresh_zoom_tokens.py +++ b/backend/src/appointment/commands/refresh_zoom_tokens.py @@ -4,10 +4,10 @@ Zoom tokens typically last ~90 days, so weekly renewal keeps a buffer. """ +import json import logging -from datetime import datetime, timedelta, timezone -from ..database import repo +from ..database import repo, models from ..dependencies.database import get_engine_and_session from ..dependencies.zoom import get_zoom_client from ..main import _common_setup @@ -18,31 +18,54 @@ def run(): _, SessionLocal = get_engine_and_session() db = SessionLocal() + zoom_connections = [] + refreshed = 0 + failed = 0 - # Renew tokens that expire within the next 7 days - threshold = datetime.now(tz=timezone.utc) + timedelta(days=7) - zoom_connections = repo.external_connection.get_zoom(db) + try: + zoom_connections = repo.external_connection.get_zoom(db) - checked = 0 - failed = 0 + for connection in zoom_connections: + if not connection or not connection.token: + continue + + owner_id = connection.owner_id + try: + subscriber = repo.subscriber.get(db, owner_id) + if not subscriber: + raise ValueError('No subscriber found for external connection') + + zoom_client = get_zoom_client(subscriber) + token = json.loads(connection.token) + + # Force the OAuth session to attempt a refresh by marking the token as expired. + token['expires_in'] = -100 + token['expires_at'] = 0 + zoom_client.setup(subscriber.id, token) + zoom_client.get_me() + + new_token = zoom_client.client.token + repo.external_connection.update_token( + db, json.dumps(new_token), subscriber.id, models.ExternalConnectionType.zoom + ) + refreshed += 1 + except Exception: + # Mark the external connection as error so the user can see + # it in the Settings UI and fix it manually when they log in + repo.external_connection.update_status( + db, + owner_id, + models.ExternalConnectionType.zoom, + models.ExternalConnectionStatus.error, + ) + + # TODO: Send an email to the Subscriber + # https://github.com/thunderbird/appointment/issues/1662 + failed += 1 + finally: + db.close() - for connection in zoom_connections: - if not connection or not connection.token: - continue - - # Setup a new Zoom client to trigger token renewal if expiry date is in the past - # or within the given time threshold - try: - subscriber = repo.subscriber.get(db, connection.owner_id) - zoom_client = get_zoom_client(subscriber) - zoom_client.setup(subscriber.id, connection.token, threshold.timestamp()) - checked += 1 - except Exception as e: - logging.error(f'[refresh_zoom_tokens] Failed to renew Zoom token for subscriber {subscriber.id}: {e}') - failed += 1 - - db.close() logging.info( - f'[refresh_zoom_tokens] Zoom token checks complete: ' - f'{checked} checked, {failed} failed, {len(zoom_connections)} total processed' + f'[refresh_zoom_tokens] Zoom token refresh complete: ' + f'{refreshed} refreshed, {failed} failed, {len(zoom_connections)} total processed' ) From 3a0928a5995a994f95156c5d34b297bc7df400c8 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Fri, 1 May 2026 11:25:16 -0600 Subject: [PATCH 09/19] Add tests --- backend/test/unit/test_commands.py | 180 +++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) diff --git a/backend/test/unit/test_commands.py b/backend/test/unit/test_commands.py index 0343c910c..7ca1b7be3 100644 --- a/backend/test/unit/test_commands.py +++ b/backend/test/unit/test_commands.py @@ -1,5 +1,6 @@ import json import os +import time import pytest from datetime import datetime, timedelta, timezone @@ -389,3 +390,182 @@ def test_stop_channel_is_async_and_does_not_block_renewal( assert updated.channel_id == 'new-channel-id' assert updated.state is not None assert updated.state != 'old-state' + +class TestRefreshZoomTokens: + """Tests that the refresh command correctly renews Zoom OAuth tokens.""" + + MODULE = 'appointment.commands.refresh_zoom_tokens' + + @staticmethod + def _make_zoom_token(**overrides): + token = { + 'access_token': 'old-access-token', + 'refresh_token': 'old-refresh-token', + 'token_type': 'bearer', + 'expires_in': 3600, + 'scope': 'meeting:read meeting:write user:read', + } + token.update(overrides) + return json.dumps(token) + + def _run_refresh(self, with_db, mock_zoom_client): + with patch(f'{self.MODULE}._common_setup'): + with patch(f'{self.MODULE}.get_zoom_client', return_value=mock_zoom_client): + with patch(f'{self.MODULE}.get_engine_and_session', return_value=(None, with_db)): + from appointment.commands.refresh_zoom_tokens import run + + run() + + def test_token_is_refreshed(self, with_db, make_external_connections): + """Running the command should trigger a token refresh and persist the new token in the DB.""" + old_token = self._make_zoom_token() + make_external_connections( + subscriber_id=1, + type=models.ExternalConnectionType.zoom, + token=old_token, + ) + + refreshed_token = { + 'access_token': 'new-access-token', + 'refresh_token': 'new-refresh-token', + 'token_type': 'bearer', + 'expires_in': 3600, + 'expires_at': time.time() + 3600, + 'scope': 'meeting:read meeting:write user:read', + } + + mock_zoom_client = Mock() + + def fake_setup(subscriber_id, token, threshold=0.0): + mock_zoom_client.client = Mock() + mock_zoom_client.client.token = refreshed_token + + mock_zoom_client.setup.side_effect = fake_setup + mock_zoom_client.get_me.return_value = {'id': 'user123'} + + self._run_refresh(with_db, mock_zoom_client) + + mock_zoom_client.get_me.assert_called_once() + setup_subscriber_id, setup_token = mock_zoom_client.setup.call_args.args + assert setup_subscriber_id == 1 + assert setup_token['expires_in'] == -100 + assert setup_token['expires_at'] == 0 + + with with_db() as db: + connections = repo.external_connection.get_by_type( + db, 1, models.ExternalConnectionType.zoom + ) + assert len(connections) == 1 + stored_token = json.loads(connections[0].token) + assert stored_token['access_token'] == 'new-access-token' + assert stored_token['refresh_token'] == 'new-refresh-token' + assert 'expires_at' in stored_token + + def test_skips_connection_without_token(self, with_db, make_external_connections): + """Connections with no token should be skipped.""" + make_external_connections( + subscriber_id=1, + type=models.ExternalConnectionType.zoom, + token='', + ) + + mock_zoom_client = Mock() + self._run_refresh(with_db, mock_zoom_client) + + mock_zoom_client.setup.assert_not_called() + + def test_failed_refresh_does_not_stop_others(self, with_db, make_external_connections, make_pro_subscriber): + """If one token fails to refresh, the command should continue with the rest.""" + make_external_connections( + subscriber_id=1, + type=models.ExternalConnectionType.zoom, + token=self._make_zoom_token(), + ) + + subscriber_2 = make_pro_subscriber() + make_external_connections( + subscriber_id=subscriber_2.id, + type=models.ExternalConnectionType.zoom, + token=self._make_zoom_token(), + ) + + call_count = 0 + + def fail_first_setup(subscriber_id, token, threshold=0.0): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise Exception('Zoom API error') + + mock_zoom_client = Mock() + mock_zoom_client.setup.side_effect = fail_first_setup + + self._run_refresh(with_db, mock_zoom_client) + + assert mock_zoom_client.setup.call_count == 2 + + def test_failed_refresh_marks_external_connection_error(self, with_db, make_external_connections): + """If token refresh fails, external connection status should be marked as error.""" + make_external_connections( + subscriber_id=1, + type=models.ExternalConnectionType.zoom, + token=self._make_zoom_token(), + ) + + mock_zoom_client = Mock() + mock_zoom_client.setup.side_effect = Exception('Zoom API error') + + self._run_refresh(with_db, mock_zoom_client) + + with with_db() as db: + connection = repo.external_connection.get_by_type(db, 1, models.ExternalConnectionType.zoom)[0] + assert connection.status == models.ExternalConnectionStatus.error + assert connection.status_checked_at is not None + + def test_invalid_token_payload_does_not_stop_others( + self, with_db, make_external_connections, make_pro_subscriber + ): + """If one stored token is invalid JSON, the command should still refresh other users.""" + make_external_connections( + subscriber_id=1, + type=models.ExternalConnectionType.zoom, + token='not-json', + ) + + subscriber_2 = make_pro_subscriber() + make_external_connections( + subscriber_id=subscriber_2.id, + type=models.ExternalConnectionType.zoom, + token=self._make_zoom_token(), + ) + + refreshed_token = { + 'access_token': 'new-access-token', + 'refresh_token': 'new-refresh-token', + 'token_type': 'bearer', + 'expires_in': 3600, + 'expires_at': time.time() + 3600, + 'scope': 'meeting:read meeting:write user:read', + } + + mock_zoom_client = Mock() + + def fake_setup(subscriber_id, token, threshold=0.0): + assert subscriber_id == subscriber_2.id + mock_zoom_client.client = Mock() + mock_zoom_client.client.token = refreshed_token + + mock_zoom_client.setup.side_effect = fake_setup + mock_zoom_client.get_me.return_value = {'id': 'user456'} + + self._run_refresh(with_db, mock_zoom_client) + + mock_zoom_client.setup.assert_called_once() + mock_zoom_client.get_me.assert_called_once() + + with with_db() as db: + valid_connection = repo.external_connection.get_by_type( + db, subscriber_2.id, models.ExternalConnectionType.zoom + )[0] + assert json.loads(valid_connection.token)['refresh_token'] == 'new-refresh-token' + From 0b8ab558228e10d66ae1a9d71061f4d1856f11b2 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Fri, 1 May 2026 11:57:39 -0600 Subject: [PATCH 10/19] Update external_connection repo functions to avoid unecessary extra queries --- .../database/repo/external_connection.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/backend/src/appointment/database/repo/external_connection.py b/backend/src/appointment/database/repo/external_connection.py index c06024274..bc9ac8ba2 100644 --- a/backend/src/appointment/database/repo/external_connection.py +++ b/backend/src/appointment/database/repo/external_connection.py @@ -37,7 +37,14 @@ def update_token( if db_results is None or len(db_results) == 0: return None - db_external_connection = db_results[0] + return update_token_by_connection(db, token, db_results[0]) + + +def update_token_by_connection( + db: Session, + token: str, + db_external_connection: models.ExternalConnections, +): db_external_connection.token = token db.commit() db.refresh(db_external_connection) @@ -147,16 +154,9 @@ def update_name(db: Session, db_external_connection: models.ExternalConnections, def update_status( db: Session, - subscriber_id: int, - type: models.ExternalConnectionType, + db_external_connection: models.ExternalConnections, status: models.ExternalConnectionStatus, - type_id: str | None = None, ): - db_results = get_by_type(db, subscriber_id, type, type_id) - if db_results is None or len(db_results) == 0: - return None - - db_external_connection = db_results[0] db_external_connection.status = status db_external_connection.status_checked_at = datetime.now(UTC) db.commit() From 1b1756c12892296d884e77da5a2455380f729a6d Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Fri, 1 May 2026 11:57:57 -0600 Subject: [PATCH 11/19] Mark the external connection as ok if the refresh went well --- .../commands/refresh_zoom_tokens.py | 14 +++++-- backend/test/unit/test_commands.py | 41 +++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/backend/src/appointment/commands/refresh_zoom_tokens.py b/backend/src/appointment/commands/refresh_zoom_tokens.py index d57fa0d01..76c63cd66 100644 --- a/backend/src/appointment/commands/refresh_zoom_tokens.py +++ b/backend/src/appointment/commands/refresh_zoom_tokens.py @@ -45,8 +45,15 @@ def run(): zoom_client.get_me() new_token = zoom_client.client.token - repo.external_connection.update_token( - db, json.dumps(new_token), subscriber.id, models.ExternalConnectionType.zoom + repo.external_connection.update_token_by_connection( + db, + json.dumps(new_token), + connection, + ) + repo.external_connection.update_status( + db, + connection, + models.ExternalConnectionStatus.ok, ) refreshed += 1 except Exception: @@ -54,8 +61,7 @@ def run(): # it in the Settings UI and fix it manually when they log in repo.external_connection.update_status( db, - owner_id, - models.ExternalConnectionType.zoom, + connection, models.ExternalConnectionStatus.error, ) diff --git a/backend/test/unit/test_commands.py b/backend/test/unit/test_commands.py index 7ca1b7be3..a7a20c5ac 100644 --- a/backend/test/unit/test_commands.py +++ b/backend/test/unit/test_commands.py @@ -522,6 +522,47 @@ def test_failed_refresh_marks_external_connection_error(self, with_db, make_exte assert connection.status == models.ExternalConnectionStatus.error assert connection.status_checked_at is not None + def test_successful_refresh_resets_external_connection_status(self, with_db, make_external_connections): + """A successful refresh should mark previously-failed connections back to ok.""" + make_external_connections( + subscriber_id=1, + type=models.ExternalConnectionType.zoom, + token=self._make_zoom_token(), + ) + + with with_db() as db: + connection = repo.external_connection.get_by_type(db, 1, models.ExternalConnectionType.zoom)[0] + repo.external_connection.update_status( + db, + connection, + models.ExternalConnectionStatus.error, + ) + + refreshed_token = { + 'access_token': 'new-access-token', + 'refresh_token': 'new-refresh-token', + 'token_type': 'bearer', + 'expires_in': 3600, + 'expires_at': time.time() + 3600, + 'scope': 'meeting:read meeting:write user:read', + } + + mock_zoom_client = Mock() + + def fake_setup(subscriber_id, token, threshold=0.0): + mock_zoom_client.client = Mock() + mock_zoom_client.client.token = refreshed_token + + mock_zoom_client.setup.side_effect = fake_setup + mock_zoom_client.get_me.return_value = {'id': 'user123'} + + self._run_refresh(with_db, mock_zoom_client) + + with with_db() as db: + connection = repo.external_connection.get_by_type(db, 1, models.ExternalConnectionType.zoom)[0] + assert connection.status == models.ExternalConnectionStatus.ok + assert connection.status_checked_at is not None + def test_invalid_token_payload_does_not_stop_others( self, with_db, make_external_connections, make_pro_subscriber ): From 07b420586ef3a49a3616fd0ec244df606bcdcc11 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Fri, 1 May 2026 13:21:31 -0600 Subject: [PATCH 12/19] Add generic acquire and release task lock --- backend/src/appointment/tasks/locks.py | 31 ++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 backend/src/appointment/tasks/locks.py diff --git a/backend/src/appointment/tasks/locks.py b/backend/src/appointment/tasks/locks.py new file mode 100644 index 000000000..70d9ccb0d --- /dev/null +++ b/backend/src/appointment/tasks/locks.py @@ -0,0 +1,31 @@ +import uuid + + +DEFAULT_LOCK_TTL_SECONDS = 60 * 60 + + +def _task_lock_key(task_name: str) -> str: + return f'lock:task:{task_name}' + + +def acquire_task_lock(redis_instance, task_name: str, ttl_seconds: int = DEFAULT_LOCK_TTL_SECONDS) -> str | None: + lock_key = _task_lock_key(task_name) + lock_token = str(uuid.uuid4()) + lock_acquired = bool(redis_instance.set(lock_key, lock_token, nx=True, ex=ttl_seconds)) + if not lock_acquired: + return None + return lock_token + + +def release_task_lock(redis_instance, task_name: str, lock_token: str): + lock_key = _task_lock_key(task_name) + + # Only delete lock if still owned by this task instance. + release_script = """ +if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) +else + return 0 +end +""" + redis_instance.eval(release_script, 1, lock_key, lock_token) From 67225ba6a197d1aa6984e112fdd9b5de9eed2ace Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Fri, 1 May 2026 13:21:54 -0600 Subject: [PATCH 13/19] Run refresh_zoom_tokens with delay with guard locks --- backend/src/appointment/routes/commands.py | 10 ++++---- backend/src/appointment/tasks/zoom.py | 28 ++++++++++++++++++++++ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/backend/src/appointment/routes/commands.py b/backend/src/appointment/routes/commands.py index ed53b08d0..3b33c81c7 100644 --- a/backend/src/appointment/routes/commands.py +++ b/backend/src/appointment/routes/commands.py @@ -11,7 +11,6 @@ generate_documentation_pages, renew_google_channels, backfill_google_channels, - refresh_zoom_tokens, ) router = typer.Typer() @@ -73,8 +72,7 @@ def backfill_channels(): @router.command('refresh-zoom-tokens') def refresh_tokens(): - try: - with cron_lock('refresh_zoom_tokens'): - refresh_zoom_tokens.run() - except FileExistsError: - print('refresh-zoom-tokens is already running, skipping.') + from ..tasks.zoom import refresh_zoom_tokens as refresh_zoom_tokens_task + + refresh_zoom_tokens_task.delay() + print('refresh-zoom-tokens task queued.') diff --git a/backend/src/appointment/tasks/zoom.py b/backend/src/appointment/tasks/zoom.py index 1914916f3..c88be05ad 100644 --- a/backend/src/appointment/tasks/zoom.py +++ b/backend/src/appointment/tasks/zoom.py @@ -1,8 +1,15 @@ import logging +import os import sentry_sdk from appointment.celery_app import celery +from appointment.dependencies.database import get_redis +from appointment.tasks.locks import ( + DEFAULT_LOCK_TTL_SECONDS, + acquire_task_lock, + release_task_lock, +) log = logging.getLogger(__name__) @@ -11,11 +18,32 @@ def refresh_zoom_tokens(): from appointment.commands.refresh_zoom_tokens import run + task_name = refresh_zoom_tokens.__name__ + redis_instance = get_redis(os.getenv('REDIS_CELERY_DB')) + lock_token = None + + if redis_instance is not None: + lock_token = acquire_task_lock(redis_instance, task_name, DEFAULT_LOCK_TTL_SECONDS) + if lock_token is None: + log.info('Zoom token check is already running, skipping.') + return + else: + log.warning('Redis unavailable; running Zoom token check without distributed lock.') + log.info('Starting Zoom token check') + try: run() except Exception as e: log.error(f'Zoom token check failed: {e}') sentry_sdk.capture_exception(e) raise + finally: + if lock_token is not None: + try: + release_task_lock(redis_instance, task_name, lock_token) + except Exception as e: + log.error(f'Failed to release Zoom token check lock: {e}') + sentry_sdk.capture_exception(e) + log.info('Zoom token check complete') From 890c63c2422de46a4894fb5eb41ed8634f14c4b7 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Fri, 1 May 2026 13:25:58 -0600 Subject: [PATCH 14/19] Add tests for acquire and release lock --- backend/test/unit/test_commands.py | 100 ++++++++++++++++++++++++++++- 1 file changed, 99 insertions(+), 1 deletion(-) diff --git a/backend/test/unit/test_commands.py b/backend/test/unit/test_commands.py index a7a20c5ac..ff32fb27e 100644 --- a/backend/test/unit/test_commands.py +++ b/backend/test/unit/test_commands.py @@ -7,7 +7,7 @@ from unittest.mock import Mock, patch from appointment.database import models, repo -from appointment.routes.commands import cron_lock +from appointment.routes.commands import cron_lock, refresh_tokens def test_cron_lock(): @@ -36,6 +36,104 @@ def test_cron_lock(): os.remove(test_lock_file_name) +def test_refresh_zoom_tokens_command_queues_celery_task(): + """CLI refresh command should enqueue task instead of running inline.""" + with patch('appointment.tasks.zoom.refresh_zoom_tokens.delay') as mock_delay: + refresh_tokens() + + mock_delay.assert_called_once() + + +def test_acquire_task_lock_returns_token_when_acquired(): + """Lock helper should return a lock token when Redis acquires lock.""" + from appointment.tasks.locks import acquire_task_lock + + mock_redis = Mock() + mock_redis.set.return_value = True + + with patch('appointment.tasks.locks.uuid.uuid4', return_value='abc-123'): + lock_token = acquire_task_lock(mock_redis, 'refresh_zoom_tokens', ttl_seconds=123) + + assert lock_token == 'abc-123' + mock_redis.set.assert_called_once_with( + 'lock:task:refresh_zoom_tokens', + 'abc-123', + nx=True, + ex=123, + ) + + +def test_acquire_task_lock_returns_none_when_not_acquired(): + """Lock helper should return None when lock is already held.""" + from appointment.tasks.locks import acquire_task_lock + + mock_redis = Mock() + mock_redis.set.return_value = False + + lock_token = acquire_task_lock(mock_redis, 'refresh_zoom_tokens') + + assert lock_token is None + + +def test_release_task_lock_uses_task_scoped_key(): + """Lock helper should release using task-scoped key and token check.""" + from appointment.tasks.locks import release_task_lock + + mock_redis = Mock() + release_task_lock(mock_redis, 'refresh_zoom_tokens', 'abc-123') + + mock_redis.eval.assert_called_once() + _, key_count, lock_key, lock_token = mock_redis.eval.call_args.args + assert key_count == 1 + assert lock_key == 'lock:task:refresh_zoom_tokens' + assert lock_token == 'abc-123' + + +def test_refresh_zoom_tokens_task_skips_when_lock_is_held(): + """Celery task should skip execution when lock cannot be acquired.""" + from appointment.tasks.zoom import refresh_zoom_tokens as refresh_zoom_tokens_task + + mock_redis = Mock() + + with patch('appointment.tasks.zoom.get_redis', return_value=mock_redis): + with patch('appointment.tasks.zoom.acquire_task_lock', return_value=None): + with patch('appointment.tasks.zoom.release_task_lock') as mock_release_lock: + with patch('appointment.commands.refresh_zoom_tokens.run') as mock_run: + refresh_zoom_tokens_task() + + mock_run.assert_not_called() + mock_release_lock.assert_not_called() + + +def test_refresh_zoom_tokens_task_releases_lock_after_run(): + """Celery task should release lock after successful execution.""" + from appointment.tasks.zoom import refresh_zoom_tokens as refresh_zoom_tokens_task + + mock_redis = Mock() + + with patch('appointment.tasks.zoom.get_redis', return_value=mock_redis): + with patch('appointment.tasks.zoom.acquire_task_lock', return_value='lock-123') as mock_acquire_lock: + with patch('appointment.tasks.zoom.release_task_lock') as mock_release_lock: + with patch('appointment.commands.refresh_zoom_tokens.run') as mock_run: + refresh_zoom_tokens_task() + + mock_acquire_lock.assert_called_once() + mock_run.assert_called_once() + mock_release_lock.assert_called_once_with(mock_redis, 'refresh_zoom_tokens', 'lock-123') + + +def test_refresh_zoom_tokens_task_uses_function_name_for_lock(): + """Task should use its own function name as lock key scope.""" + from appointment.tasks.zoom import refresh_zoom_tokens as refresh_zoom_tokens_task + + mock_redis = Mock() + with patch('appointment.tasks.zoom.get_redis', return_value=mock_redis): + with patch('appointment.tasks.zoom.acquire_task_lock', return_value=None) as mock_acquire_lock: + refresh_zoom_tokens_task() + + assert mock_acquire_lock.call_args.args[1] == 'refresh_zoom_tokens' + + def _make_google_token(): return json.dumps( { From 064949f9be3a25cdc419dc4594672d17a9797edd Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Fri, 1 May 2026 13:33:19 -0600 Subject: [PATCH 15/19] Remove orphaned external connections if any --- .../appointment/commands/refresh_zoom_tokens.py | 8 ++++++-- .../database/repo/external_connection.py | 6 ++++++ backend/test/unit/test_commands.py | 16 ++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/backend/src/appointment/commands/refresh_zoom_tokens.py b/backend/src/appointment/commands/refresh_zoom_tokens.py index 76c63cd66..09533f935 100644 --- a/backend/src/appointment/commands/refresh_zoom_tokens.py +++ b/backend/src/appointment/commands/refresh_zoom_tokens.py @@ -21,6 +21,7 @@ def run(): zoom_connections = [] refreshed = 0 failed = 0 + cleaned = 0 try: zoom_connections = repo.external_connection.get_zoom(db) @@ -33,7 +34,10 @@ def run(): try: subscriber = repo.subscriber.get(db, owner_id) if not subscriber: - raise ValueError('No subscriber found for external connection') + # Orphaned external connection so let's just remove it. + repo.external_connection.delete(db, connection) + cleaned += 1 + continue zoom_client = get_zoom_client(subscriber) token = json.loads(connection.token) @@ -73,5 +77,5 @@ def run(): logging.info( f'[refresh_zoom_tokens] Zoom token refresh complete: ' - f'{refreshed} refreshed, {failed} failed, {len(zoom_connections)} total processed' + f'{refreshed} refreshed, {failed} failed, {cleaned} cleaned, {len(zoom_connections)} total processed' ) diff --git a/backend/src/appointment/database/repo/external_connection.py b/backend/src/appointment/database/repo/external_connection.py index bc9ac8ba2..adc9e22c3 100644 --- a/backend/src/appointment/database/repo/external_connection.py +++ b/backend/src/appointment/database/repo/external_connection.py @@ -62,6 +62,12 @@ def delete_by_type(db: Session, subscriber_id: int, type: models.ExternalConnect return True +def delete(db: Session, db_external_connection: models.ExternalConnections): + db.delete(db_external_connection) + db.commit() + return True + + def get_by_type( db: Session, subscriber_id: int, type: models.ExternalConnectionType, type_id: str | None = None ) -> list[models.ExternalConnections] | None: diff --git a/backend/test/unit/test_commands.py b/backend/test/unit/test_commands.py index ff32fb27e..b8e365d4e 100644 --- a/backend/test/unit/test_commands.py +++ b/backend/test/unit/test_commands.py @@ -661,6 +661,22 @@ def fake_setup(subscriber_id, token, threshold=0.0): assert connection.status == models.ExternalConnectionStatus.ok assert connection.status_checked_at is not None + def test_orphaned_zoom_connection_is_cleaned_up(self, with_db, make_external_connections): + """If an external connection has no subscriber, it should be deleted as orphaned.""" + make_external_connections( + subscriber_id=99999, + type=models.ExternalConnectionType.zoom, + token=self._make_zoom_token(), + ) + + mock_zoom_client = Mock() + self._run_refresh(with_db, mock_zoom_client) + + mock_zoom_client.setup.assert_not_called() + + with with_db() as db: + assert repo.external_connection.get_zoom(db) == [] + def test_invalid_token_payload_does_not_stop_others( self, with_db, make_external_connections, make_pro_subscriber ): From 002ae95c2fa3152798cbb7dee2a10e517055fd65 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Mon, 25 May 2026 13:35:36 -0600 Subject: [PATCH 16/19] Add context manager for ease to use on task locks --- backend/src/appointment/tasks/locks.py | 39 ++++++++++++++++++++++- backend/src/appointment/tasks/zoom.py | 43 +++++--------------------- 2 files changed, 46 insertions(+), 36 deletions(-) diff --git a/backend/src/appointment/tasks/locks.py b/backend/src/appointment/tasks/locks.py index 70d9ccb0d..8d1b5ddb5 100644 --- a/backend/src/appointment/tasks/locks.py +++ b/backend/src/appointment/tasks/locks.py @@ -1,9 +1,16 @@ +import logging +import os import uuid - +from contextlib import contextmanager +from appointment.dependencies.database import get_redis DEFAULT_LOCK_TTL_SECONDS = 60 * 60 +class TaskLockFailed(Exception): + """Raised when a task lock cannot be acquired.""" + + def _task_lock_key(task_name: str) -> str: return f'lock:task:{task_name}' @@ -29,3 +36,33 @@ def release_task_lock(redis_instance, task_name: str, lock_token: str): end """ redis_instance.eval(release_script, 1, lock_key, lock_token) + + +@contextmanager +def task_lock(task_name: str, ttl_seconds: int = DEFAULT_LOCK_TTL_SECONDS): + """Context manager that acquires and releases a distributed task lock. + + Raises TaskLockFailed if Redis is available but the lock is already held. + When Redis is unavailable, execution proceeds without a lock. + """ + import sentry_sdk + + redis_instance = get_redis(os.getenv('REDIS_CELERY_DB')) + lock_token = None + + if redis_instance is not None: + lock_token = acquire_task_lock(redis_instance, task_name, ttl_seconds) + if lock_token is None: + raise TaskLockFailed(f'Failed to acquire lock for {task_name}') + else: + logging.warning(f'Redis unavailable; running {task_name} without distributed lock.') + + try: + yield + finally: + if lock_token is not None: + try: + release_task_lock(redis_instance, task_name, lock_token) + except Exception as e: + logging.error(f'Failed to release {task_name} lock: {e}') + sentry_sdk.capture_exception(e) diff --git a/backend/src/appointment/tasks/zoom.py b/backend/src/appointment/tasks/zoom.py index c88be05ad..2fa19118b 100644 --- a/backend/src/appointment/tasks/zoom.py +++ b/backend/src/appointment/tasks/zoom.py @@ -1,49 +1,22 @@ import logging -import os - import sentry_sdk from appointment.celery_app import celery -from appointment.dependencies.database import get_redis -from appointment.tasks.locks import ( - DEFAULT_LOCK_TTL_SECONDS, - acquire_task_lock, - release_task_lock, -) - -log = logging.getLogger(__name__) +from appointment.tasks.locks import TaskLockFailed, task_lock @celery.task def refresh_zoom_tokens(): from appointment.commands.refresh_zoom_tokens import run - task_name = refresh_zoom_tokens.__name__ - redis_instance = get_redis(os.getenv('REDIS_CELERY_DB')) - lock_token = None - - if redis_instance is not None: - lock_token = acquire_task_lock(redis_instance, task_name, DEFAULT_LOCK_TTL_SECONDS) - if lock_token is None: - log.info('Zoom token check is already running, skipping.') - return - else: - log.warning('Redis unavailable; running Zoom token check without distributed lock.') - - log.info('Starting Zoom token check') - try: - run() + with task_lock(refresh_zoom_tokens.__name__): + logging.info('Starting Zoom token check') + run() + logging.info('Zoom token check complete') + except TaskLockFailed: + logging.info('Zoom token check is already running, skipping.') except Exception as e: - log.error(f'Zoom token check failed: {e}') + logging.error(f'Zoom token check failed: {e}') sentry_sdk.capture_exception(e) raise - finally: - if lock_token is not None: - try: - release_task_lock(redis_instance, task_name, lock_token) - except Exception as e: - log.error(f'Failed to release Zoom token check lock: {e}') - sentry_sdk.capture_exception(e) - - log.info('Zoom token check complete') From 45633ed60eced43b6f88f800ba3e18348e5f0da7 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Mon, 25 May 2026 13:38:01 -0600 Subject: [PATCH 17/19] Add tests for the context manager --- backend/test/unit/test_commands.py | 117 ++++++++++++++++++++++------- 1 file changed, 89 insertions(+), 28 deletions(-) diff --git a/backend/test/unit/test_commands.py b/backend/test/unit/test_commands.py index b8e365d4e..b6d5574df 100644 --- a/backend/test/unit/test_commands.py +++ b/backend/test/unit/test_commands.py @@ -3,11 +3,14 @@ import time import pytest +from contextlib import contextmanager from datetime import datetime, timedelta, timezone from unittest.mock import Mock, patch from appointment.database import models, repo from appointment.routes.commands import cron_lock, refresh_tokens +from appointment.tasks.locks import acquire_task_lock, release_task_lock, task_lock, TaskLockFailed +from appointment.tasks.zoom import refresh_zoom_tokens as refresh_zoom_tokens_task def test_cron_lock(): @@ -46,7 +49,6 @@ def test_refresh_zoom_tokens_command_queues_celery_task(): def test_acquire_task_lock_returns_token_when_acquired(): """Lock helper should return a lock token when Redis acquires lock.""" - from appointment.tasks.locks import acquire_task_lock mock_redis = Mock() mock_redis.set.return_value = True @@ -65,7 +67,6 @@ def test_acquire_task_lock_returns_token_when_acquired(): def test_acquire_task_lock_returns_none_when_not_acquired(): """Lock helper should return None when lock is already held.""" - from appointment.tasks.locks import acquire_task_lock mock_redis = Mock() mock_redis.set.return_value = False @@ -77,7 +78,6 @@ def test_acquire_task_lock_returns_none_when_not_acquired(): def test_release_task_lock_uses_task_scoped_key(): """Lock helper should release using task-scoped key and token check.""" - from appointment.tasks.locks import release_task_lock mock_redis = Mock() release_task_lock(mock_redis, 'refresh_zoom_tokens', 'abc-123') @@ -89,49 +89,110 @@ def test_release_task_lock_uses_task_scoped_key(): assert lock_token == 'abc-123' +def test_task_lock_context_manager_acquires_and_releases(): + """Context manager should acquire lock on entry and release on exit.""" + + mock_redis = Mock() + mock_redis.set.return_value = True + + with patch('appointment.tasks.locks.get_redis', return_value=mock_redis): + with patch('appointment.tasks.locks.uuid.uuid4', return_value='ctx-token'): + with task_lock('my_task'): + mock_redis.set.assert_called_once() + + mock_redis.eval.assert_called_once() + _, _, _, lock_token = mock_redis.eval.call_args.args + assert lock_token == 'ctx-token' + + +def test_task_lock_context_manager_raises_when_lock_held(): + """Context manager should raise TaskLockFailed when lock is already held.""" + + mock_redis = Mock() + mock_redis.set.return_value = False + + with patch('appointment.tasks.locks.get_redis', return_value=mock_redis): + with pytest.raises(TaskLockFailed): + with task_lock('my_task'): + pass + + +def test_task_lock_context_manager_releases_on_exception(): + """Context manager should still release the lock when the body raises.""" + + mock_redis = Mock() + mock_redis.set.return_value = True + + with patch('appointment.tasks.locks.get_redis', return_value=mock_redis): + with patch('appointment.tasks.locks.uuid.uuid4', return_value='err-token'): + with pytest.raises(RuntimeError): + with task_lock('my_task'): + raise RuntimeError('boom') + + mock_redis.eval.assert_called_once() + _, _, _, lock_token = mock_redis.eval.call_args.args + assert lock_token == 'err-token' + + +def test_task_lock_context_manager_proceeds_without_redis(): + """Context manager should proceed without locking when Redis is unavailable.""" + + ran = False + with patch('appointment.tasks.locks.get_redis', return_value=None): + with task_lock('my_task'): + ran = True + + assert ran + + def test_refresh_zoom_tokens_task_skips_when_lock_is_held(): """Celery task should skip execution when lock cannot be acquired.""" - from appointment.tasks.zoom import refresh_zoom_tokens as refresh_zoom_tokens_task - mock_redis = Mock() + @contextmanager + def _failing_lock(task_name, ttl_seconds=None): + raise TaskLockFailed('already locked') - with patch('appointment.tasks.zoom.get_redis', return_value=mock_redis): - with patch('appointment.tasks.zoom.acquire_task_lock', return_value=None): - with patch('appointment.tasks.zoom.release_task_lock') as mock_release_lock: - with patch('appointment.commands.refresh_zoom_tokens.run') as mock_run: - refresh_zoom_tokens_task() + with patch('appointment.tasks.zoom.task_lock', side_effect=_failing_lock): + with patch('appointment.commands.refresh_zoom_tokens.run') as mock_run: + refresh_zoom_tokens_task() mock_run.assert_not_called() - mock_release_lock.assert_not_called() -def test_refresh_zoom_tokens_task_releases_lock_after_run(): - """Celery task should release lock after successful execution.""" - from appointment.tasks.zoom import refresh_zoom_tokens as refresh_zoom_tokens_task +def test_refresh_zoom_tokens_task_acquires_lock_for_run(): + """Celery task should run inside the task_lock context manager.""" - mock_redis = Mock() + lock_entered = False - with patch('appointment.tasks.zoom.get_redis', return_value=mock_redis): - with patch('appointment.tasks.zoom.acquire_task_lock', return_value='lock-123') as mock_acquire_lock: - with patch('appointment.tasks.zoom.release_task_lock') as mock_release_lock: - with patch('appointment.commands.refresh_zoom_tokens.run') as mock_run: - refresh_zoom_tokens_task() + @contextmanager + def _tracking_lock(task_name, ttl_seconds=None): + nonlocal lock_entered + lock_entered = True + yield + + with patch('appointment.tasks.zoom.task_lock', side_effect=_tracking_lock): + with patch('appointment.commands.refresh_zoom_tokens.run') as mock_run: + refresh_zoom_tokens_task() - mock_acquire_lock.assert_called_once() + assert lock_entered mock_run.assert_called_once() - mock_release_lock.assert_called_once_with(mock_redis, 'refresh_zoom_tokens', 'lock-123') def test_refresh_zoom_tokens_task_uses_function_name_for_lock(): """Task should use its own function name as lock key scope.""" - from appointment.tasks.zoom import refresh_zoom_tokens as refresh_zoom_tokens_task - mock_redis = Mock() - with patch('appointment.tasks.zoom.get_redis', return_value=mock_redis): - with patch('appointment.tasks.zoom.acquire_task_lock', return_value=None) as mock_acquire_lock: - refresh_zoom_tokens_task() + captured_name = None + + @contextmanager + def _capture_lock(task_name, ttl_seconds=None): + nonlocal captured_name + captured_name = task_name + raise TaskLockFailed('test') + + with patch('appointment.tasks.zoom.task_lock', side_effect=_capture_lock): + refresh_zoom_tokens_task() - assert mock_acquire_lock.call_args.args[1] == 'refresh_zoom_tokens' + assert captured_name == 'refresh_zoom_tokens' def _make_google_token(): From 694b96f43d031e2c6888ea9192d1852ca77edf2b Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Mon, 25 May 2026 13:46:15 -0600 Subject: [PATCH 18/19] Use watch/multi instead of lua eval --- backend/src/appointment/tasks/locks.py | 18 ++++---- backend/test/unit/test_commands.py | 57 ++++++++++++++++++-------- 2 files changed, 48 insertions(+), 27 deletions(-) diff --git a/backend/src/appointment/tasks/locks.py b/backend/src/appointment/tasks/locks.py index 8d1b5ddb5..8c529db9a 100644 --- a/backend/src/appointment/tasks/locks.py +++ b/backend/src/appointment/tasks/locks.py @@ -25,17 +25,17 @@ def acquire_task_lock(redis_instance, task_name: str, ttl_seconds: int = DEFAULT def release_task_lock(redis_instance, task_name: str, lock_token: str): + """Only delete the lock if still owned by this task instance.""" lock_key = _task_lock_key(task_name) - # Only delete lock if still owned by this task instance. - release_script = """ -if redis.call("get", KEYS[1]) == ARGV[1] then - return redis.call("del", KEYS[1]) -else - return 0 -end -""" - redis_instance.eval(release_script, 1, lock_key, lock_token) + with redis_instance.pipeline() as pipe: + pipe.watch(lock_key) + if pipe.get(lock_key) == lock_token: + pipe.multi() + pipe.delete(lock_key) + pipe.execute() + else: + pipe.unwatch() @contextmanager diff --git a/backend/test/unit/test_commands.py b/backend/test/unit/test_commands.py index b6d5574df..3aa7adfa7 100644 --- a/backend/test/unit/test_commands.py +++ b/backend/test/unit/test_commands.py @@ -76,17 +76,40 @@ def test_acquire_task_lock_returns_none_when_not_acquired(): assert lock_token is None -def test_release_task_lock_uses_task_scoped_key(): - """Lock helper should release using task-scoped key and token check.""" +def test_release_task_lock_deletes_when_token_matches(): + """Lock helper should delete the key when the stored token matches.""" + mock_pipe = Mock() + mock_pipe.get.return_value = 'abc-123' + mock_pipe.__enter__ = Mock(return_value=mock_pipe) + mock_pipe.__exit__ = Mock(return_value=False) mock_redis = Mock() + mock_redis.pipeline.return_value = mock_pipe + release_task_lock(mock_redis, 'refresh_zoom_tokens', 'abc-123') - mock_redis.eval.assert_called_once() - _, key_count, lock_key, lock_token = mock_redis.eval.call_args.args - assert key_count == 1 - assert lock_key == 'lock:task:refresh_zoom_tokens' - assert lock_token == 'abc-123' + mock_pipe.watch.assert_called_once_with('lock:task:refresh_zoom_tokens') + mock_pipe.multi.assert_called_once() + mock_pipe.delete.assert_called_once_with('lock:task:refresh_zoom_tokens') + mock_pipe.execute.assert_called_once() + + +def test_release_task_lock_skips_when_token_differs(): + """Lock helper should not delete the key when a different token owns it.""" + mock_pipe = Mock() + mock_pipe.get.return_value = 'other-token' + mock_pipe.__enter__ = Mock(return_value=mock_pipe) + mock_pipe.__exit__ = Mock(return_value=False) + + mock_redis = Mock() + mock_redis.pipeline.return_value = mock_pipe + + release_task_lock(mock_redis, 'refresh_zoom_tokens', 'abc-123') + + mock_pipe.watch.assert_called_once_with('lock:task:refresh_zoom_tokens') + mock_pipe.multi.assert_not_called() + mock_pipe.delete.assert_not_called() + mock_pipe.unwatch.assert_called_once() def test_task_lock_context_manager_acquires_and_releases(): @@ -97,12 +120,11 @@ def test_task_lock_context_manager_acquires_and_releases(): with patch('appointment.tasks.locks.get_redis', return_value=mock_redis): with patch('appointment.tasks.locks.uuid.uuid4', return_value='ctx-token'): - with task_lock('my_task'): - mock_redis.set.assert_called_once() + with patch('appointment.tasks.locks.release_task_lock') as mock_release: + with task_lock('my_task'): + mock_redis.set.assert_called_once() - mock_redis.eval.assert_called_once() - _, _, _, lock_token = mock_redis.eval.call_args.args - assert lock_token == 'ctx-token' + mock_release.assert_called_once_with(mock_redis, 'my_task', 'ctx-token') def test_task_lock_context_manager_raises_when_lock_held(): @@ -125,13 +147,12 @@ def test_task_lock_context_manager_releases_on_exception(): with patch('appointment.tasks.locks.get_redis', return_value=mock_redis): with patch('appointment.tasks.locks.uuid.uuid4', return_value='err-token'): - with pytest.raises(RuntimeError): - with task_lock('my_task'): - raise RuntimeError('boom') + with patch('appointment.tasks.locks.release_task_lock') as mock_release: + with pytest.raises(RuntimeError): + with task_lock('my_task'): + raise RuntimeError('boom') - mock_redis.eval.assert_called_once() - _, _, _, lock_token = mock_redis.eval.call_args.args - assert lock_token == 'err-token' + mock_release.assert_called_once_with(mock_redis, 'my_task', 'err-token') def test_task_lock_context_manager_proceeds_without_redis(): From 5be2b180c0564f44ce2c0416ffac59d8d89ad79e Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Tue, 26 May 2026 08:24:38 -0600 Subject: [PATCH 19/19] Better exception catching / handling during locks --- backend/src/appointment/tasks/locks.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/src/appointment/tasks/locks.py b/backend/src/appointment/tasks/locks.py index 8c529db9a..3cb714d8f 100644 --- a/backend/src/appointment/tasks/locks.py +++ b/backend/src/appointment/tasks/locks.py @@ -59,10 +59,14 @@ def task_lock(task_name: str, ttl_seconds: int = DEFAULT_LOCK_TTL_SECONDS): try: yield + except Exception as e: + logging.warning(f'{task_name} failed: {e}') + sentry_sdk.capture_exception(e) + raise finally: if lock_token is not None: try: release_task_lock(redis_instance, task_name, lock_token) except Exception as e: - logging.error(f'Failed to release {task_name} lock: {e}') + logging.warning(f'Failed to release {task_name} lock: {e}') sentry_sdk.capture_exception(e)