diff --git a/backend/api/models.py b/backend/api/models.py index 03c3b42c6..32eca05da 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -11,7 +11,7 @@ from django.utils import timezone from django.conf import settings from api.services import Providers, ServiceConfig -from api.tasks.syncing import trigger_sync_tasks, trigger_syncs_for_referencing_envs +from api.tasks.syncing import trigger_sync_tasks, detect_and_trigger_referencing_syncs from backend.quotas import ( can_add_account, can_add_app, @@ -467,10 +467,9 @@ class Environment(models.Model): objects = EnvironmentManager() def save(self, *args, **kwargs): - # Call the "real" save() method to save the Secret super().save(*args, **kwargs) - # Trigger all sync jobs associated with this environment + # Own syncs: synchronous, so queued status shows immediately. [ trigger_sync_tasks(env_sync) for env_sync in EnvironmentSync.objects.filter( @@ -479,9 +478,8 @@ def save(self, *args, **kwargs): if env_sync.is_active ] - # Trigger syncs for other environments whose secrets - # reference this one (cross-env and cross-app references) - trigger_syncs_for_referencing_envs(self) + # Referencing envs: deferred to a worker (detection decrypts + walks the org graph). + detect_and_trigger_referencing_syncs.delay(str(self.id)) class EnvironmentKey(models.Model): diff --git a/backend/api/tasks/syncing.py b/backend/api/tasks/syncing.py index 5f6604062..12737b57f 100644 --- a/backend/api/tasks/syncing.py +++ b/backend/api/tasks/syncing.py @@ -515,8 +515,11 @@ def trigger_syncs_for_referencing_envs(changed_env): this we build the org's reference graph and follow it from each candidate environment until we reach the changed environment (cycle-safe). - Called synchronously from Environment.save() so the pending sync status is - reflected in the UI right away; the actual sync work is dispatched async. + Runs off the request path, dispatched from Environment.save() via the + detect_and_trigger_referencing_syncs RQ job — so referencing envs' syncs are + queued shortly after the write, not within it. (The changed environment's own + syncs are still triggered synchronously in Environment.save for immediate + status.) The actual provider sync work is dispatched async by trigger_sync_tasks. """ from api.utils.secrets import get_referenced_environment_ids @@ -602,3 +605,24 @@ def references_changed_env(start_env_id): logger.warning( f"Failed to check references for environment {env_id}: {e}" ) + + +@job("default", timeout=DEFAULT_TIMEOUT) +def detect_and_trigger_referencing_syncs(changed_env_id): + """ + Async entrypoint for trigger_syncs_for_referencing_envs. + + Dispatched from Environment.save() so the reference-graph detection — which + decrypts and scans every synced/intermediate environment in the org — runs + off the request path instead of blocking the secret write. The changed + environment's own syncs are still triggered synchronously by Environment.save + for immediate status feedback; only the cross-reference fan-out is deferred. + """ + Environment = apps.get_model("api", "Environment") + try: + changed_env = Environment.objects.select_related( + "app", "app__organisation" + ).get(id=changed_env_id) + except Environment.DoesNotExist: + return + trigger_syncs_for_referencing_envs(changed_env) diff --git a/backend/tests/tasks/test_syncing.py b/backend/tests/tasks/test_syncing.py index fd4a93936..1b59a4856 100644 --- a/backend/tests/tasks/test_syncing.py +++ b/backend/tests/tasks/test_syncing.py @@ -1,5 +1,42 @@ from unittest.mock import patch, MagicMock -from api.tasks.syncing import trigger_syncs_for_referencing_envs +from api.tasks.syncing import ( + trigger_syncs_for_referencing_envs, + detect_and_trigger_referencing_syncs, +) + + +# --- detect_and_trigger_referencing_syncs (async wrapper) tests --- + + +@patch("api.tasks.syncing.trigger_syncs_for_referencing_envs") +@patch("api.tasks.syncing.apps.get_model") +def test_detect_and_trigger_refetches_env_and_delegates(mock_get_model, mock_trigger): + """The async job re-fetches the env by id and runs the detection.""" + env = MagicMock() + MockEnvironment = MagicMock() + MockEnvironment.objects.select_related.return_value.get.return_value = env + MockEnvironment.DoesNotExist = type("DoesNotExist", (Exception,), {}) + mock_get_model.return_value = MockEnvironment + + detect_and_trigger_referencing_syncs("env-id") + + mock_trigger.assert_called_once_with(env) + + +@patch("api.tasks.syncing.trigger_syncs_for_referencing_envs") +@patch("api.tasks.syncing.apps.get_model") +def test_detect_and_trigger_missing_env_is_noop(mock_get_model, mock_trigger): + """If the env was deleted before the job runs, it returns without error.""" + MockEnvironment = MagicMock() + MockEnvironment.DoesNotExist = type("DoesNotExist", (Exception,), {}) + MockEnvironment.objects.select_related.return_value.get.side_effect = ( + MockEnvironment.DoesNotExist() + ) + mock_get_model.return_value = MockEnvironment + + detect_and_trigger_referencing_syncs("missing-env") + + mock_trigger.assert_not_called() # --- trigger_syncs_for_referencing_envs tests ---