diff --git a/docs/concepts/queries.md b/docs/concepts/queries.md index 9f6354241..3034b5a90 100644 --- a/docs/concepts/queries.md +++ b/docs/concepts/queries.md @@ -354,7 +354,7 @@ query = HybridQuery( RedisVL returns query results as a list of dictionaries, one per matched document. On Redis 8.8+ a document can occasionally come back with its field payload missing: the RediSearch worker pool now defaults to a nonzero number of background worker threads, and if a document — or one of its indexed fields — expires (via TTL or `HPEXPIRE`) at the exact moment a background search is reading it, the server returns the matched id with an empty field payload instead of dropping it from the result set. This is most likely in workloads that combine search with short TTLs, such as a semantic cache or chat history. -RedisVL detects and **silently skips** such a document (logging at `WARNING` level) whenever the query type guarantees a field would be present on a healthy match: +RedisVL detects and **skips** such a document (logging at `WARNING` level) whenever the query type guarantees a field would be present on a healthy match: - **`VectorQuery` / `VectorRangeQuery`** with `return_score=True` (the default) — a healthy match always carries a `vector_distance`. - **`FilterQuery` on JSON storage** that returns the whole object — a healthy match always carries its JSON payload. diff --git a/redisvl/cli/index.py b/redisvl/cli/index.py index f4ce89710..888b35aa3 100644 --- a/redisvl/cli/index.py +++ b/redisvl/cli/index.py @@ -20,9 +20,6 @@ ) from redisvl.redis.utils import convert_bytes from redisvl.schema.schema import IndexSchema -from redisvl.utils.log import get_logger - -logger = get_logger("[RedisVL]") # Exceptions commonly raised when loading or validating a schema path (-s). SCHEMA_INPUT_ERRORS = ( @@ -83,7 +80,6 @@ def __init__(self): try: args.handler(args) except Exception as e: - logger.error(e, exc_info=True) print(str(e), file=sys.stderr) sys.exit(1) diff --git a/redisvl/cli/migrate.py b/redisvl/cli/migrate.py index 2e92c78dd..3906531ae 100644 --- a/redisvl/cli/migrate.py +++ b/redisvl/cli/migrate.py @@ -74,7 +74,6 @@ def __init__(self): getattr(self, command)() except Exception as e: print(f"Error: {e}", file=sys.stderr) - logger.error(e) sys.exit(1) def helper(self): diff --git a/redisvl/cli/stats.py b/redisvl/cli/stats.py index 2614bfae1..aa27b85e1 100644 --- a/redisvl/cli/stats.py +++ b/redisvl/cli/stats.py @@ -14,9 +14,6 @@ from redisvl.exceptions import RedisSearchError from redisvl.index import SearchIndex from redisvl.schema.schema import IndexSchema -from redisvl.utils.log import get_logger - -logger = get_logger("[RedisVL]") # Exceptions commonly raised when loading or validating a schema path (-s). SCHEMA_INPUT_ERRORS = ( @@ -113,7 +110,6 @@ def __init__(self): try: self.stats(args) except Exception as e: - logger.error(e, exc_info=True) print(str(e), file=sys.stderr) sys.exit(1) diff --git a/redisvl/utils/log.py b/redisvl/utils/log.py index b78c20949..8a4792c15 100644 --- a/redisvl/utils/log.py +++ b/redisvl/utils/log.py @@ -7,9 +7,4 @@ def get_logger(name, log_level="info", fmt=None): # Use file name if logger is in debug mode name = "RedisVL" if log_level == "debug" else name - logger = logging.getLogger(name) - - # Add a NullHandler to loggers to avoid "no handler found" warnings - if not logger.handlers: - logger.addHandler(logging.NullHandler()) - return logger + return logging.getLogger(name) diff --git a/tests/unit/test_cli_index.py b/tests/unit/test_cli_index.py index 5833bd7ad..b38d7b39a 100644 --- a/tests/unit/test_cli_index.py +++ b/tests/unit/test_cli_index.py @@ -2,6 +2,7 @@ import re import subprocess import sys +import textwrap from types import SimpleNamespace from unittest.mock import MagicMock @@ -459,3 +460,33 @@ def test_index_runtime_errors( assert exc_info.value.code == 1 assert captured.out == "" assert captured.err != "" + + +def test_index_failure_stderr_subprocess_has_no_traceback(): + """Unconfigured logging: ``rvl index`` failures print one line, no traceback.""" + result = subprocess.run( + [ + sys.executable, + "-c", + textwrap.dedent(""" + import sys + from unittest.mock import MagicMock, patch + + fake_index = MagicMock() + fake_index.info.side_effect = RuntimeError("boom") + with patch("redisvl.cli.index.SearchIndex", return_value=fake_index): + sys.argv = ["rvl", "index", "info", "-i", "test-idx"] + try: + from redisvl.cli.index import Index + Index() + except SystemExit as exc: + sys.exit(exc.code) + """), + ], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 1, result.stderr + assert "boom" in result.stderr + assert "Traceback" not in result.stderr diff --git a/tests/unit/test_log.py b/tests/unit/test_log.py new file mode 100644 index 000000000..837d064f0 --- /dev/null +++ b/tests/unit/test_log.py @@ -0,0 +1,55 @@ +"""Logging reaches stderr when the application has not configured logging. + +pytest's ``caplog`` fixture installs its own handler, so a test that uses it +passes even when ``get_logger`` swallows the record. These tests run in a +fresh interpreter and read stderr instead. +""" + +import subprocess +import sys +import textwrap + + +def _run(script: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, "-c", textwrap.dedent(script)], + capture_output=True, + text=True, + check=False, + ) + + +def test_warning_reaches_stderr_when_logging_is_unconfigured(): + result = _run(""" + import logging + from redisvl.utils.log import get_logger + + get_logger("redisvl.demo").info("redisvl info hidden") + get_logger("redisvl.demo").warning("from redisvl logger") + logging.getLogger("plain.demo").warning("from plain logger") + """) + assert result.returncode == 0, result.stderr + assert "redisvl info hidden" not in result.stderr + assert "from redisvl logger" in result.stderr + assert "from plain logger" in result.stderr + + +def test_configured_logging_receives_warning_exactly_once(): + result = _run(""" + import logging + import sys + + logging.basicConfig( + stream=sys.stdout, + format="APP %(levelname)s %(name)s %(message)s", + level=logging.WARNING, + ) + from redisvl.utils.log import get_logger + + get_logger("redisvl.demo").warning("configured redisvl warning") + """) + assert result.returncode == 0, result.stderr + assert result.stdout.splitlines() == [ + "APP WARNING redisvl.demo configured redisvl warning" + ] + assert "configured redisvl warning" not in result.stderr