diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index b79af952..693c25d0 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -175,23 +175,17 @@ else check_external_postgres_version || exit 1 fi -# Wait for Redis to be ready (modular mode uses external Redis) +# Wait for Redis to be ready and flush stale state. +# In modular mode Redis is external — call wait_for_redis.py here +# because uWSGI's exec-pre runs under 'su -' which strips env vars +# (DISPATCHARR_ENV, REDIS_HOST, etc.). +# In AIO mode Redis is started by uWSGI (attach-daemon), so the +# exec-pre in uwsgi.ini handles the wait + flush there instead. if [[ "$DISPATCHARR_ENV" == "modular" ]]; then echo "🔗 Modular mode: Using external Redis at ${REDIS_HOST}:${REDIS_PORT}" - echo_with_timestamp "Waiting for external Redis to be ready..." - until python3 -c " -import socket, sys -try: - s = socket.create_connection(('${REDIS_HOST}', ${REDIS_PORT}), timeout=2) - s.close() - sys.exit(0) -except Exception: - sys.exit(1) -" 2>/dev/null; do - echo_with_timestamp "Waiting for Redis at ${REDIS_HOST}:${REDIS_PORT}..." - sleep 1 - done - echo "✅ External Redis is ready" + echo_with_timestamp "Waiting for Redis to be ready..." + python3 /app/scripts/wait_for_redis.py + echo "✅ Redis is ready" fi # Ensure database encoding is UTF8 (handles both internal and external databases) diff --git a/docker/uwsgi.modular.ini b/docker/uwsgi.modular.ini index 3220a8d8..57ecaea8 100644 --- a/docker/uwsgi.modular.ini +++ b/docker/uwsgi.modular.ini @@ -5,8 +5,8 @@ ; exec-pre = touch /data/logs/uwsgi.log ; exec-pre = chmod 666 /data/logs/uwsgi.log -; First run Redis availability check script once -exec-pre = python /app/scripts/wait_for_redis.py +; Redis wait + flush is handled by the entrypoint in modular mode +; (uWSGI exec-pre runs under 'su -' which strips Docker env vars) ; Start Daphne for WebSocket support (required for real-time features) ; Redis and Celery run in separate containers in modular mode diff --git a/scripts/wait_for_redis.py b/scripts/wait_for_redis.py index 41b66757..e1814411 100644 --- a/scripts/wait_for_redis.py +++ b/scripts/wait_for_redis.py @@ -12,6 +12,28 @@ import logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) +# Key prefixes used by Celery's broker (Kombu) and result backend. +# These must be preserved in modular mode where Celery runs independently. +_CELERY_KEY_PREFIXES = ('celery', '_kombu', 'unacked') + + +def _flush_non_celery_keys(client): + """Delete all Redis keys except those belonging to Celery.""" + cursor = '0' + deleted = 0 + while True: + cursor, keys = client.scan(cursor=cursor, count=500) + to_delete = [ + k for k in keys + if not k.decode('utf-8', errors='replace').startswith(_CELERY_KEY_PREFIXES) + ] + if to_delete: + deleted += client.delete(*to_delete) + if cursor == 0: + break + logger.info(f"Modular mode: selectively cleared {deleted} non-Celery Redis key(s)") + + def wait_for_redis(host='localhost', port=6379, db=0, password='', username='', max_retries=30, retry_interval=2): """Wait for Redis to become available""" redis_client = None @@ -31,6 +53,15 @@ def wait_for_redis(host='localhost', port=6379, db=0, password='', username='', socket_connect_timeout=2 ) redis_client.ping() + # Clear stale state on startup. In AIO mode, every service restarts + # together so a full flush is safe. In modular mode, Celery has its + # own lifecycle — preserve its broker/result keys and only wipe + # application state (stream locks, proxy metadata, etc.). + if os.environ.get('DISPATCHARR_ENV') == 'modular': + _flush_non_celery_keys(redis_client) + else: + redis_client.flushdb() + logger.info(f"Flushed Redis database") logger.info(f"✅ Redis at {host}:{port}/{db} is now available!") return True except (redis.exceptions.ConnectionError, redis.exceptions.TimeoutError) as e: diff --git a/tests/test_wait_for_redis.py b/tests/test_wait_for_redis.py index 55e623eb..0dd02ba1 100644 --- a/tests/test_wait_for_redis.py +++ b/tests/test_wait_for_redis.py @@ -21,23 +21,40 @@ class WaitForRedisTests(SimpleTestCase): """ Tests for scripts/wait_for_redis.py. - The critical regression test is test_successful_connection_does_not_call_flushdb - which ensures the removed flushdb() call is never re-added. + Verifies flush behaviour: full flushdb in AIO mode, selective + (non-Celery) key deletion in modular mode. """ @patch('wait_for_redis.redis.Redis') - def test_successful_connection_does_not_call_flushdb(self, mock_redis_cls): - """After connecting successfully, flushdb must NOT be called.""" + def test_aio_mode_calls_flushdb(self, mock_redis_cls): + """In AIO mode (default), flushdb is called after successful ping.""" mock_client = MagicMock() mock_client.ping.return_value = True mock_redis_cls.return_value = mock_client - wait_for_redis = _import_wait_for_redis() - result = wait_for_redis(max_retries=1, retry_interval=0) + with patch.dict(os.environ, {}, clear=False): + os.environ.pop('DISPATCHARR_ENV', None) + wait_for_redis = _import_wait_for_redis() + result = wait_for_redis(max_retries=1, retry_interval=0) + + self.assertTrue(result) + mock_client.flushdb.assert_called_once() + + @patch('wait_for_redis._flush_non_celery_keys') + @patch('wait_for_redis.redis.Redis') + def test_modular_mode_does_not_call_flushdb(self, mock_redis_cls, mock_selective): + """In modular mode, flushdb must NOT be called — selective flush instead.""" + mock_client = MagicMock() + mock_client.ping.return_value = True + mock_redis_cls.return_value = mock_client + + with patch.dict(os.environ, {'DISPATCHARR_ENV': 'modular'}): + wait_for_redis = _import_wait_for_redis() + result = wait_for_redis(max_retries=1, retry_interval=0) self.assertTrue(result) - mock_client.ping.assert_called_once() mock_client.flushdb.assert_not_called() + mock_selective.assert_called_once_with(mock_client) @patch('wait_for_redis.redis.Redis') def test_retries_on_connection_error(self, mock_redis_cls):