Fix Redis flush and wait_for_redis in modular mode

- Move modular Redis wait from uWSGI exec-pre to entrypoint (exec-pre runs under 'su -' which strips Docker env vars, so DISPATCHARR_ENV and REDIS_HOST were never available)
- Selective flush in modular mode: clears stale app state (stream locks, proxy metadata) while preserving Celery broker/result keys
- AIO mode unchanged: full flushdb via uWSGI exec-pre
- Update unit tests for both flush paths
This commit is contained in:
None 2026-03-06 15:17:32 -06:00
parent 6ff81e6287
commit 4f41c287ac
4 changed files with 66 additions and 24 deletions

View file

@ -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)

View file

@ -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

View file

@ -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:

View file

@ -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):