Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
39 changes: 36 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ SYNC_NAME := redis_om
INSTALL_STAMP := .install.stamp
UV := $(shell command -v uv 2> /dev/null)
REDIS_OM_URL ?= redis://localhost:6380?decode_responses=True
DOCKER_COMPOSE := docker compose
CLUSTER_COMPOSE := $(DOCKER_COMPOSE) -f docker-compose.cluster.yml

.DEFAULT_GOAL := help

Expand Down Expand Up @@ -40,7 +42,8 @@ clean:
rm -rf redis_om
rm -rf tests_sync
rm -rf .venv
-docker-compose down
-$(DOCKER_COMPOSE) down
-$(CLUSTER_COMPOSE) down


.PHONY: dist
Expand Down Expand Up @@ -68,7 +71,7 @@ format: $(INSTALL_STAMP) sync
.PHONY: test
test: $(INSTALL_STAMP) sync redis
REDIS_OM_URL=$(REDIS_OM_URL) $(UV) run pytest -n auto -vv ./tests/ ./tests_sync/ --cov-report term-missing --cov $(NAME) $(SYNC_NAME)
docker-compose down
$(DOCKER_COMPOSE) down

.PHONY: test_oss
test_oss: $(INSTALL_STAMP) sync redis
Expand All @@ -84,7 +87,37 @@ shell: $(INSTALL_STAMP)

.PHONY: redis
redis:
docker-compose up -d
$(DOCKER_COMPOSE) up -d

.PHONY: redis_cluster
redis_cluster:
$(CLUSTER_COMPOSE) up -d
@echo "Waiting for Redis Cluster nodes to start..."
@sleep 5
@cluster_init_container=$$($(CLUSTER_COMPOSE) ps -q redis-cluster-7001); \
if ! docker exec $$cluster_init_container redis-cli -p 7001 cluster info 2>/dev/null | grep -q "cluster_state:ok"; then \
docker exec $$cluster_init_container redis-cli --cluster create \
127.0.0.1:7001 127.0.0.1:7002 127.0.0.1:7003 \
127.0.0.1:7004 127.0.0.1:7005 127.0.0.1:7006 \
--cluster-replicas 1 --cluster-yes; \
fi
@echo "Waiting for Redis Cluster to become healthy..."
@cluster_init_container=$$($(CLUSTER_COMPOSE) ps -q redis-cluster-7001); \
for attempt in 1 2 3 4 5 6 7 8 9 10; do \
if docker exec $$cluster_init_container redis-cli -p 7001 cluster info 2>/dev/null | grep -q "cluster_state:ok"; then \
exit 0; \
fi; \
sleep 2; \
done; \
echo "Redis Cluster did not become healthy in time" >&2; \
exit 1

.PHONY: test_cluster
test_cluster: $(INSTALL_STAMP) sync redis redis_cluster
REDIS_OM_URL=$(REDIS_OM_URL) $(UV) run pytest -vv ./tests/test_cluster_operations.py
REDIS_OM_URL=$(REDIS_OM_URL) $(UV) run pytest -vv ./tests_sync/test_cluster_operations.py
$(CLUSTER_COMPOSE) down
$(DOCKER_COMPOSE) down

.PHONY: upload
upload: dist
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,19 @@ Cluster support includes:
- RediSearch-backed queries, including embedded JSON and GEO lookups
- migrator support for creating search indexes on cluster deployments

For local cluster validation, this repository includes a dedicated 6-node Redis
Cluster test setup on ports `7001-7006`:

```sh
make redis
make redis_cluster
make test_cluster
```

`get_redis_connection()` accepts either `cluster=True` or `cluster=true` in the
URL and strips the query flag before handing the URL to redis-py, so other URL
parameters such as `decode_responses=True` continue to work unchanged.

## 📇 Modeling Your Data

Redis OM contains powerful declarative models that give you data validation, serialization, and persistence to Redis.
Expand Down Expand Up @@ -798,6 +811,10 @@ We'd love your contributions!

You can also **contribute documentation** -- or just let us know if something needs more detail. [Open an issue on GitHub](https://github.com/XChikuX/redis-om-python/issues/new) to get started.

Current local coverage baseline: **88% overall** across `aredis_om/` and the
generated `redis_om/` mirror, with **1168 passing tests** plus the cluster test
suite.

## 📝 License

Redis OM uses the [MIT license][license-url].
Expand Down
9 changes: 6 additions & 3 deletions aredis_om/connections.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,13 @@ def get_redis_connection(**kwargs) -> Union[redis.Redis, redis.RedisCluster]:

def _strip_cluster_param(url: str) -> str:
"""Remove 'cluster=true' from URL query parameters."""
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse

parsed = urlparse(url)
params = parse_qs(parsed.query, keep_blank_values=True)
params.pop("cluster", None)
params = [
(key, value)
for key, value in parse_qsl(parsed.query, keep_blank_values=True)
if key.lower() != "cluster"
]
new_query = urlencode(params, doseq=True)
return urlunparse(parsed._replace(query=new_query))
32 changes: 24 additions & 8 deletions aredis_om/model/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@
Union,
)
from typing import get_args as typing_get_args
from typing import no_type_check
from typing import (
no_type_check,
)

from more_itertools import ichunked
from pydantic import ConfigDict
Expand Down Expand Up @@ -182,7 +184,19 @@ def validate_model_fields(model: Type["RedisModel"], field_values: Dict[str, Any
obj = model
for sub_field in field_name.split("__"):
if not isinstance(obj, ModelMeta) and hasattr(obj, "field"):
obj = getattr(obj, "field").annotation
annotation = getattr(obj, "field").annotation
# Unwrap Optional[X] (i.e. Union[X, None]) so that we can
# traverse into the inner model's fields.
if get_origin(annotation) is Union:
annotation = next(
(
a
for a in typing_get_args(annotation)
if a is not type(None)
),
annotation,
)
obj = annotation

if not hasattr(obj, sub_field):
raise QuerySyntaxError(
Expand Down Expand Up @@ -3054,21 +3068,23 @@ def schema_for_type(
"In this Preview release, list and tuple fields can only "
f"contain strings. Problem field: {name}. See docs: TODO"
)
if full_text_search is True:
raise RedisModelError(
"List and tuple fields cannot be indexed for full-text "
f"search. Problem field: {name}. See docs: TODO"
)
if sortable is True:
raise RedisModelError(
"In this Preview release, list and tuple fields cannot be "
f"marked as sortable. Problem field: {name}. See docs: TODO"
)
if case_sensitive is True and full_text_search is True:
raise RedisModelError(
f"List field '{name}' cannot be both case-sensitive and "
"full-text searchable."
)
separator = getattr(
field_info, "separator", SINGLE_VALUE_TAG_FIELD_SEPARATOR
)
schema = f"{path} AS {index_field_name} TAG SEPARATOR {separator}"
if case_sensitive is True:
if full_text_search is True:
schema += f" {path} AS {index_field_name}_fts TEXT"
elif case_sensitive is True:
schema += " CASESENSITIVE"
elif typ is bool:
schema = f"{path} AS {index_field_name} TAG"
Expand Down
108 changes: 108 additions & 0 deletions docker-compose.cluster.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
services:
redis-cluster-7001:
image: "redis:8-alpine"
restart: always
network_mode: host
command: >
redis-server
--port 7001
--dir /data
--save ""
--appendonly no
--protected-mode no
--cluster-enabled yes
--cluster-config-file nodes-7001.conf
--cluster-node-timeout 5000
--cluster-announce-ip 127.0.0.1
--cluster-announce-port 7001
--cluster-announce-bus-port 17001

redis-cluster-7002:
image: "redis:8-alpine"
restart: always
network_mode: host
command: >
redis-server
--port 7002
--dir /data
--save ""
--appendonly no
--protected-mode no
--cluster-enabled yes
--cluster-config-file nodes-7002.conf
--cluster-node-timeout 5000
--cluster-announce-ip 127.0.0.1
--cluster-announce-port 7002
--cluster-announce-bus-port 17002

redis-cluster-7003:
image: "redis:8-alpine"
restart: always
network_mode: host
command: >
redis-server
--port 7003
--dir /data
--save ""
--appendonly no
--protected-mode no
--cluster-enabled yes
--cluster-config-file nodes-7003.conf
--cluster-node-timeout 5000
--cluster-announce-ip 127.0.0.1
--cluster-announce-port 7003
--cluster-announce-bus-port 17003

redis-cluster-7004:
image: "redis:8-alpine"
restart: always
network_mode: host
command: >
redis-server
--port 7004
--dir /data
--save ""
--appendonly no
--protected-mode no
--cluster-enabled yes
--cluster-config-file nodes-7004.conf
--cluster-node-timeout 5000
--cluster-announce-ip 127.0.0.1
--cluster-announce-port 7004
--cluster-announce-bus-port 17004

redis-cluster-7005:
image: "redis:8-alpine"
restart: always
network_mode: host
command: >
redis-server
--port 7005
--dir /data
--save ""
--appendonly no
--protected-mode no
--cluster-enabled yes
--cluster-config-file nodes-7005.conf
--cluster-node-timeout 5000
--cluster-announce-ip 127.0.0.1
--cluster-announce-port 7005
--cluster-announce-bus-port 17005

redis-cluster-7006:
image: "redis:8-alpine"
restart: always
network_mode: host
command: >
redis-server
--port 7006
--dir /data
--save ""
--appendonly no
--protected-mode no
--cluster-enabled yes
--cluster-config-file nodes-7006.conf
--cluster-node-timeout 5000
--cluster-announce-ip 127.0.0.1
--cluster-announce-port 7006
--cluster-announce-bus-port 17006
33 changes: 32 additions & 1 deletion make_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,45 @@
ADDITIONAL_REPLACEMENTS = {
"aredis_om": "redis_om",
"async_redis": "sync_redis",
"redis.asyncio as aioredis": "redis as aioredis",
":tests.": ":tests_sync.",
"pytest_asyncio": "pytest",
"py_test_mark_asyncio": "py_test_mark_sync",
"pytest.mark.asyncio(f)": "f",
"pytest.mark.asyncio": "py_test_mark_sync",
".aclose()": ".close()",
}


POST_SYNC_FIXES = {
"tests_sync/test_cluster_operations.py": {
"import redis.asyncio as aioredis": "import redis as aioredis",
"conn.aclose()": "conn.close()",
# In the generated sync mirror these call sites already contain eager
# return values, not coroutines, so the async gather wrapper must be
# removed.
"asyncio.gather(*tasks)": "tasks",
}
}


def apply_post_sync_fixes(repo_root: Path):
for relative_path, replacements in POST_SYNC_FIXES.items():
file_path = repo_root / relative_path
if not file_path.exists():
continue

content = file_path.read_text()
updated = content
for old, new in replacements.items():
updated = updated.replace(old, new)

if updated != content:
file_path.write_text(updated)


def main():
repo_root = Path(__file__).absolute().parent
rules = [
unasync.Rule(
fromdir="/aredis_om/",
Expand All @@ -28,7 +58,7 @@ def main():
),
]
filepaths = []
for root, _, filenames in os.walk(Path(__file__).absolute().parent):
for root, _, filenames in os.walk(repo_root):
for filename in filenames:
if filename.rpartition(".")[-1] in (
"py",
Expand All @@ -37,6 +67,7 @@ def main():
filepaths.append(os.path.join(root, filename))

unasync.unasync_files(filepaths, rules)
apply_post_sync_fixes(repo_root)


if __name__ == "__main__":
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "pyredis-om"
version = "0.6.0"
version = "0.6.1"
description = "A drop-in replacement for `redis-om`, built out of frustration."
authors = [
{ name = "Redis OSS", email = "oss@redis.com" },
Expand Down
4 changes: 2 additions & 2 deletions tests/test_cluster_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
- GEO operations (save, search, GeoFilter) on cluster
- Full-text search on cluster
- Complex queries (AND, OR, NOT, IN, range) on cluster
- Index creation / migration on cluster (FT.CREATE via target_nodes=PRIMARIES)
- Index creation / migration on cluster (FT.CREATE via target_nodes=RANDOM)
- Pipeline and batch operations on cluster
- Performance comparison vs single-instance (pass/fail based on slowdown factor)
- Direct Redis verification before redis-om layer queries
Expand Down Expand Up @@ -1421,7 +1421,7 @@ async def test_cluster_pipeline_mixed_ops(cluster_json_models, cluster_hash_mode

@py_test_mark_asyncio
async def test_cluster_migration_creates_indexes(cluster_conn):
"""Cluster: Migrator creates indexes on cluster primaries."""
"""Cluster: Migrator creates indexes on cluster."""
model_registry.clear()

class MigrTestJson(JsonModel):
Expand Down
Loading
Loading