Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6c6273c
feat: support Python 3.15 (#757)
vishal-bala Oct 16, 2026
f63a161
fix: close Python 3.15 gaps found in pre-PR review
vishal-bala Oct 16, 2026
ff863e2
docs(contributing): state the supported Python range as 3.10 through …
vishal-bala Oct 16, 2026
28b0e5c
feat: install every extra on Python 3.15
vishal-bala Oct 30, 2026
fdf0b57
refactor(python): drop Python 3.10 support
vishal-bala Oct 30, 2026
874f34d
docs(readme): state Python 3.11 as the minimum
vishal-bala Oct 30, 2026
61effbf
refactor(typing): use Self for the remaining methods that return thei…
vishal-bala Oct 30, 2026
e31bacb
refactor(enums): make RedisVL's string enums StrEnum subclasses
vishal-bala Oct 30, 2026
f9658e8
test(enums): drop StrEnum pins that could not fail
vishal-bala Oct 30, 2026
355dcbb
fix(mcp): share one startup deadline across connect and INFO
vishal-bala Oct 30, 2026
45ae85a
fix(migration): cancel sibling quantize workers when one fails
vishal-bala Oct 30, 2026
116b681
docs(mcp,migration): narrow the timeout and cancellation claims
vishal-bala Oct 30, 2026
626fe56
test(migration): capture worker-failure logs under --log-level=CRITICAL
vishal-bala Oct 30, 2026
ad90047
ci: use Python 3.12 as the default for single-version jobs
vishal-bala Nov 2, 2026
d3e7a02
refactor(enums): make RedisVL's string enums StrEnum subclasses
vishal-bala Oct 30, 2026
cb070cc
test(enums): drop StrEnum pins that could not fail
vishal-bala Oct 30, 2026
a7d4c71
fix(schema): enforce INT8/UINT8 range check on list vectors
vishal-bala Oct 30, 2026
7da0918
test(enums): drop the INT8 range pin now covered by test_validation.py
vishal-bala Nov 6, 2026
bd2f3b0
fix(mcp): share one startup deadline across connect and INFO
vishal-bala Oct 30, 2026
7db990b
fix(migration): cancel sibling quantize workers when one fails
vishal-bala Oct 30, 2026
c9ab20a
docs(mcp,migration): narrow the timeout and cancellation claims
vishal-bala Oct 30, 2026
41d6767
test(migration): capture worker-failure logs under --log-level=CRITICAL
vishal-bala Oct 30, 2026
bff83c4
Merge refactor/asyncio-timeout-taskgroup into ci/default-python-3.12
vishal-bala Nov 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/auto-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ concurrency:
cancel-in-progress: false

env:
PYTHON_VERSION: "3.11"
PYTHON_VERSION: "3.12"
UV_VERSION: "0.13.1"
AUTO_VERSION: "11.3.6"
RELEASE_BOT_NAME: "applied-ai-releases[bot]"
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test-fork-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ permissions:
pull-requests: read

env:
PYTHON_VERSION: "3.11"
PYTHON_VERSION: "3.12"
UV_VERSION: "0.13.1"

jobs:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ on:
workflow_dispatch:

env:
PYTHON_VERSION: "3.11"
PYTHON_VERSION: "3.12"
UV_VERSION: "0.13.1"

jobs:
Expand Down
2 changes: 1 addition & 1 deletion .readthedocs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ version: 2
build:
os: ubuntu-24.04
tools:
python: "3.11"
python: "3.12"

jobs:
# 1️⃣ Install uv once before we create the virtual-env
Expand Down
1 change: 1 addition & 0 deletions docs/user_guide/how_to_guides/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ indexes:
- `runtime.default_embed_text_field` is only required when the server should generate embeddings during upsert
- `vectorizer` is required for query embedding and server-side embedding, but optional for fulltext-only configs
- `runtime.max_result_window` caps deep paging by limiting the maximum `offset + limit`
- `runtime.startup_timeout_seconds` bounds three startup steps for the binding: connecting to Redis together with the first `INFO`, which share one deadline, then reading the index, then building the vectorizer
- `schema_overrides` is only for patching incomplete field attrs discovered from Redis

### Fulltext-Only Config
Expand Down
5 changes: 4 additions & 1 deletion docs/user_guide/how_to_guides/migrate-indexes.md
Original file line number Diff line number Diff line change
Expand Up @@ -887,7 +887,10 @@ backup is written *before* the index is dropped, so a crash at any point
leaves a complete backup on disk. In multi-worker mode, dump and quantize
are fused (each worker reads, backs up, and converts its shard in one pass
*after* the index drop). A crash during this fused phase may leave partial
backup shards. Re-running detects and resumes from partial state.
backup shards. Re-running detects and resumes from partial state. With
`--async` (`AsyncMigrationExecutor`), the first worker to fail cancels the
others; the sync executor lets them finish their shards. Either way, no shard
is still being written when the migration reports the failure.
```

### Rollback
Expand Down
4 changes: 2 additions & 2 deletions redisvl/extensions/message_history/schema.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import warnings
from enum import Enum
from enum import StrEnum
from typing import Any
from uuid import uuid4

Expand All @@ -20,7 +20,7 @@
from redisvl.utils.utils import current_timestamp


class ChatRole(str, Enum):
class ChatRole(StrEnum):
"""Enumeration of valid roles for a chat message."""

USER = "user"
Expand Down
4 changes: 2 additions & 2 deletions redisvl/mcp/errors.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import asyncio
from enum import Enum
from enum import StrEnum
from typing import Any

from pydantic import ValidationError
Expand All @@ -8,7 +8,7 @@
from redisvl.exceptions import RedisSearchError


class MCPErrorCode(str, Enum):
class MCPErrorCode(StrEnum):
"""Stable internal error codes exposed by the MCP framework."""

INVALID_REQUEST = "invalid_request"
Expand Down
21 changes: 14 additions & 7 deletions redisvl/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -979,13 +979,20 @@ async def _connect_redis_client(self, timeout: int) -> Any:
if self.config is None:
raise RuntimeError("MCP server config not loaded")

client = await asyncio.wait_for(
RedisConnectionFactory._get_aredis_connection(
redis_url=self.config.server.redis_url
),
timeout=timeout,
)
await asyncio.wait_for(client.info("server"), timeout=timeout)
# Connecting and the first INFO share one deadline, so a slow server
# fails startup within ``timeout`` rather than up to twice that.
client: Any = None
try:
async with asyncio.timeout(timeout):
client = await RedisConnectionFactory._get_aredis_connection(
redis_url=self.config.server.redis_url
)
await client.info("server")
except BaseException:
# The caller only takes ownership of a client that answered INFO.
if client is not None:
await client.aclose()
raise
return client

async def _load_effective_schema(
Expand Down
2 changes: 1 addition & 1 deletion redisvl/mcp/tools/list_indexes.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def _binding_fields(binding_runtime: BindingRuntime) -> list[dict[str, str]]:
embed_source = binding_runtime.binding.runtime.default_embed_text_field
fields: list[dict[str, str]] = []
for field in binding_runtime.schema.fields.values():
field_type = str(getattr(field.type, "value", field.type))
field_type = str(field.type)
if field_type.lower() == "vector":
continue
if field.name == embed_source:
Expand Down
2 changes: 1 addition & 1 deletion redisvl/mcp/tools/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def _build_filter_hint(
that the model never sees the knob.
"""
filter_fields = [
f"{field.name}({getattr(field.type, 'value', field.type)})"
f"{field.name}({field.type})"
for field in schema.fields.values()
if field.type in _DSL_FILTER_FIELD_TYPES and field.name not in exclude
]
Expand Down
3 changes: 1 addition & 2 deletions redisvl/mcp/tools/upsert.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,7 @@ async def _embed_many(vectorizer: Any, contents: list[str]) -> list[list[float]]
def _vector_dtype(rt: Any) -> str:
"""Resolve the binding's vector field datatype as a lowercase string."""
field = rt.binding.get_vector_field(rt.schema)
datatype = getattr(field.attrs.datatype, "value", field.attrs.datatype)
return str(datatype).lower()
return str(field.attrs.datatype).lower()


def _validation_schema_for_record(
Expand Down
5 changes: 3 additions & 2 deletions redisvl/migration/async_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -564,8 +564,9 @@ async def apply(

Async counterpart of :meth:`MigrationExecutor.apply`. Uses
``await`` for Redis I/O so the event loop remains responsive during
large quantization jobs. Multi-worker quantization uses
``asyncio.gather`` with independent connections.
large quantization jobs. Multi-worker quantization runs in an
``asyncio.TaskGroup`` with independent connections, so one worker's
failure cancels the rest.

Args:
plan: The migration plan to apply (from
Expand Down
58 changes: 40 additions & 18 deletions redisvl/migration/quantize.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
the per-key HGET loop with batched pipeline operations.

Also provides multi-worker orchestration for parallel quantization
using ThreadPoolExecutor (sync) or asyncio.gather (async).
using ThreadPoolExecutor (sync) or asyncio.TaskGroup (async).
"""

import hashlib
Expand Down Expand Up @@ -538,9 +538,19 @@ async def async_multi_worker_quantize(
progress_callback: Optional[Callable[[str, int, int], None]] = None,
worker_backup_paths: Optional[List[str]] = None,
) -> MultiWorkerResult:
"""Orchestrate async multi-worker quantization via asyncio.gather.
"""Orchestrate async multi-worker quantization via asyncio.TaskGroup.

Each worker gets its own async Redis connection and backup file shard.
If a worker fails, its siblings are cancelled, and each closes its
connection, before the failure is raised, so no worker is still writing
once the caller sees the error. A cancelled worker's shard records only
whole batches, so a later run resumes it like any interrupted shard.

Raises:
Exception: The failing worker's own exception, not an
``ExceptionGroup``. If several workers fail before the
cancellation reaches them, the first is raised and the rest
are logged.
"""
import asyncio

Expand All @@ -562,22 +572,34 @@ async def async_multi_worker_quantize(
f"({len(worker_backup_paths)} != {actual_workers})"
)

coroutines = [
_async_worker_quantize(
worker_id=i,
redis_url=redis_url,
keys=slices[i],
datatype_changes=datatype_changes,
backup_path=worker_backup_paths[i],
index_name=index_name,
batch_size=batch_size,
progress_callback=progress_callback,
)
for i in range(actual_workers)
]

results = await asyncio.gather(*coroutines)
worker_results = list(results)
failure: Exception | None = None
try:
async with asyncio.TaskGroup() as group:
tasks = [
group.create_task(
_async_worker_quantize(
worker_id=i,
redis_url=redis_url,
keys=slices[i],
datatype_changes=datatype_changes,
backup_path=worker_backup_paths[i],
index_name=index_name,
batch_size=batch_size,
progress_callback=progress_callback,
)
)
for i in range(actual_workers)
]
except ExceptionGroup as group_error:
failure = group_error.exceptions[0]
for other in group_error.exceptions[1:]:
logger.error("Another quantization worker also failed", exc_info=other)
if failure is not None:
# Raised outside the except block so the worker's traceback and cause
# reach the caller unchanged, as they did under asyncio.gather.
raise failure

worker_results = [task.result() for task in tasks]
total_docs = sum(r["docs"] for r in worker_results)

return MultiWorkerResult(
Expand Down
4 changes: 2 additions & 2 deletions redisvl/query/filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import math
import numbers
import re
from enum import Enum
from enum import Enum, StrEnum
from functools import wraps
from typing import Any, Callable

Expand All @@ -12,7 +12,7 @@
# mypy: disable-error-code="override"


class Inclusive(str, Enum):
class Inclusive(StrEnum):
"""Enum for valid inclusive options"""

BOTH = "both"
Expand Down
6 changes: 3 additions & 3 deletions redisvl/query/query.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from enum import Enum
from enum import StrEnum
from typing import Any, Self

from redis.commands.search.query import Query as RedisQuery
Expand Down Expand Up @@ -429,7 +429,7 @@ class BaseVectorQuery:
_normalize_vector_distance: bool = False


class HybridPolicy(str, Enum):
class HybridPolicy(StrEnum):
"""Enum for valid hybrid policy options in vector queries."""

BATCHES = "BATCHES"
Expand Down Expand Up @@ -592,7 +592,7 @@ def _build_query_string(self) -> str:

# Add hybrid policy parameters if specified
if self._hybrid_policy:
knn_query += f" HYBRID_POLICY {self._hybrid_policy.value}"
knn_query += f" HYBRID_POLICY {self._hybrid_policy}"

# Add batch size if specified and using BATCHES policy
if self._hybrid_policy == HybridPolicy.BATCHES and self._batch_size:
Expand Down
14 changes: 7 additions & 7 deletions redisvl/schema/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
- Vector Search: https://redis.io/docs/interact/search-and-query/advanced-concepts/vectors/
"""

from enum import Enum
from enum import StrEnum
from typing import Any, Literal

from pydantic import BaseModel, Field, field_validator, model_validator
Expand All @@ -57,21 +57,21 @@
}


class FieldTypes(str, Enum):
class FieldTypes(StrEnum):
TAG = "tag"
TEXT = "text"
NUMERIC = "numeric"
GEO = "geo"
VECTOR = "vector"


class VectorDistanceMetric(str, Enum):
class VectorDistanceMetric(StrEnum):
COSINE = "COSINE"
L2 = "L2"
IP = "IP"


class VectorDataType(str, Enum):
class VectorDataType(StrEnum):
BFLOAT16 = "BFLOAT16"
FLOAT16 = "FLOAT16"
FLOAT32 = "FLOAT32"
Expand All @@ -80,13 +80,13 @@ class VectorDataType(str, Enum):
UINT8 = "UINT8"


class VectorIndexAlgorithm(str, Enum):
class VectorIndexAlgorithm(StrEnum):
FLAT = "FLAT"
HNSW = "HNSW"
SVS_VAMANA = "SVS-VAMANA"


class CompressionType(str, Enum):
class CompressionType(StrEnum):
"""Vector compression types for SVS-VAMANA algorithm"""

LVQ4 = "LVQ4"
Expand Down Expand Up @@ -328,7 +328,7 @@ def validate_svs_params(self):
if not self.compression.value.startswith("LeanVec"):
raise ValueError(
f"reduce parameter is only supported with LeanVec compression types. "
f"Got compression={self.compression.value}. "
f"Got compression={self.compression}. "
f"Either use LeanVec4x8/LeanVec8x8 or remove the reduce parameter."
)

Expand Down
28 changes: 12 additions & 16 deletions redisvl/schema/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,22 +174,18 @@ def _validate_vector(cls, value):
raise ValueError(
f"Vector field '{fname}' must have {dims} dimensions, got {len(value)}"
)
# Validate data types
datatype_str = str(datatype).upper()
# Integer-based datatypes
if datatype_str in ("INT8", "UINT8"):
# Check range for INT8
if datatype_str == "INT8":
if any(v < -128 or v > 127 for v in value):
raise ValueError(
f"Vector field '{fname}' contains values outside the INT8 range (-128 to 127)"
)
# Check range for UINT8
elif datatype_str == "UINT8":
if any(v < 0 or v > 255 for v in value):
raise ValueError(
f"Vector field '{fname}' contains values outside the UINT8 range (0 to 255)"
)
# Check range for INT8
if datatype == VectorDataType.INT8:
if any(v < -128 or v > 127 for v in value):
raise ValueError(
f"Vector field '{fname}' contains values outside the INT8 range (-128 to 127)"
)
# Check range for UINT8
elif datatype == VectorDataType.UINT8:
if any(v < 0 or v > 255 for v in value):
raise ValueError(
f"Vector field '{fname}' contains values outside the UINT8 range (0 to 255)"
)
return value

return _validate_vector
Expand Down
5 changes: 1 addition & 4 deletions tests/integration/test_index_migrator_e2e_recovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -536,9 +536,6 @@ def test_json_same_width_datatype_change_is_schema_only_end_to_end(
live_index = SearchIndex.from_existing(index_name, redis_url=redis_url)
vector_field = live_index.schema.fields.get("embedding")
assert vector_field is not None
datatype = getattr(
vector_field.attrs.datatype, "value", vector_field.attrs.datatype
)
assert str(datatype).lower() == "bfloat16"
assert str(vector_field.attrs.datatype).lower() == "bfloat16"
finally:
_cleanup(redis_url, client, index_name, prefix)
Loading
Loading