Skip to content

Commit d8adf62

Browse files
feat: enhance ScyllaDB adapter with Docker support and update dependencies
- Add address translator for Docker/NAT environments - Configure connection pool limits per host - Update dependency versions to match uv.lock - Update test containers and config template
1 parent ab7a4e4 commit d8adf62

7 files changed

Lines changed: 269 additions & 168 deletions

File tree

.env.test

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ REDIS__DATABASE=0
3232
REDIS__PASSWORD=test_password
3333

3434
# PostgreSQL Configuration
35-
POSTGRES__IMAGE=postgres:18.1-alpine
35+
POSTGRES__IMAGE=postgres:18.2-alpine
3636
POSTGRES_SQLALCHEMY__HOST=localhost
3737
POSTGRES_SQLALCHEMY__PORT=5432
3838
POSTGRES_SQLALCHEMY__DATABASE=test_db
@@ -67,7 +67,7 @@ ELASTIC__HTTP_USER_NAME=elastic
6767
ELASTIC__HTTP_PASSWORD=test_password
6868

6969
# Kafka Configuration
70-
KAFKA__IMAGE=confluentinc/cp-kafka:7.9.3
70+
KAFKA__IMAGE=confluentinc/cp-kafka:8.0.3
7171
KAFKA__BROKERS_LIST=["localhost:9092"]
7272

7373
# MinIO Configuration
@@ -77,9 +77,10 @@ MINIO__ACCESS_KEY=test_access_key
7777
MINIO__SECRET_KEY=test_secret_key
7878

7979
# ScyllaDB Configuration
80-
SCYLLADB__IMAGE=scylladb/scylla:2025.3
80+
SCYLLADB__IMAGE=scylladb/scylla:2025.4
8181
SCYLLADB__CONTACT_POINTS=["localhost"]
8282
SCYLLADB__PORT=9042
8383
SCYLLADB__PROTOCOL_VERSION=4
8484
SCYLLADB__COMPRESSION=true
8585
SCYLLADB__DISABLE_SHARD_AWARENESS=true
86+
SCYLLADB__ADDRESS_TRANSLATION_ENABLED=true

archipy/adapters/scylladb/adapters.py

Lines changed: 75 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
from cassandra.auth import PlainTextAuthProvider
1515
from cassandra.cluster import Cluster
1616
from cassandra.policies import (
17+
AddressTranslator,
18+
DCAwareRoundRobinPolicy,
1719
ExponentialBackoffRetryPolicy,
1820
FallthroughRetryPolicy,
1921
RoundRobinPolicy,
@@ -40,6 +42,19 @@
4042
logger = logging.getLogger(__name__)
4143

4244

45+
class _FixedAddressTranslator(AddressTranslator):
46+
"""Translates all discovered node addresses to a fixed host address.
47+
48+
Used in Docker/NAT environments where gossip-discovered internal IPs are unreachable.
49+
"""
50+
51+
def __init__(self, address: str) -> None:
52+
self._address = address
53+
54+
def translate(self, addr: str) -> str: # noqa: ARG002
55+
return self._address
56+
57+
4358
class ScyllaDBExceptionHandlerMixin:
4459
"""Mixin class to handle ScyllaDB/Cassandra exceptions in a consistent way."""
4560

@@ -182,13 +197,10 @@ def _create_cluster(self) -> Any:
182197

183198
# Configure load balancing policy with optional datacenter awareness
184199
if self.config.LOCAL_DC:
185-
from cassandra.policies import DCAwareRoundRobinPolicy
186-
187200
base_policy = DCAwareRoundRobinPolicy(local_dc=self.config.LOCAL_DC)
201+
load_balancing_policy = TokenAwarePolicy(base_policy)
188202
else:
189-
base_policy = RoundRobinPolicy()
190-
191-
load_balancing_policy = TokenAwarePolicy(base_policy)
203+
load_balancing_policy = TokenAwarePolicy(RoundRobinPolicy())
192204

193205
if self.config.RETRY_POLICY == "FALLTHROUGH":
194206
retry_policy = FallthroughRetryPolicy()
@@ -203,6 +215,12 @@ def _create_cluster(self) -> Any:
203215
if self.config.DISABLE_SHARD_AWARENESS:
204216
shard_aware_options = {"disable": True}
205217

218+
# Address translation for Docker/NAT environments where gossip-discovered
219+
# internal container IPs are unreachable from the host
220+
address_translator = None
221+
if self.config.ADDRESS_TRANSLATION_ENABLED:
222+
address_translator = _FixedAddressTranslator(self.config.CONTACT_POINTS[0])
223+
206224
# Cluster is from cassandra.cluster, properly typed
207225
cluster = Cluster(
208226
contact_points=self.config.CONTACT_POINTS,
@@ -214,12 +232,17 @@ def _create_cluster(self) -> Any:
214232
load_balancing_policy=load_balancing_policy,
215233
default_retry_policy=retry_policy,
216234
shard_aware_options=shard_aware_options,
235+
address_translator=address_translator,
217236
)
218237

219238
# Configure connection pool settings
220239
if cluster.profile_manager is not None:
221240
profile = cluster.profile_manager.default
222241
profile.request_timeout = self.config.REQUEST_TIMEOUT
242+
# Configure connection pool limits per host
243+
profile.max_connections_per_host = self.config.MAX_CONNECTIONS_PER_HOST
244+
profile.min_connections_per_host = self.config.MIN_CONNECTIONS_PER_HOST
245+
profile.core_connections_per_host = self.config.CORE_CONNECTIONS_PER_HOST
223246

224247
# Set pool configuration
225248
cluster.connection_class.max_requests_per_connection = self.config.MAX_REQUESTS_PER_CONNECTION
@@ -328,10 +351,23 @@ def create_keyspace(self, keyspace: str, replication_factor: int = 1) -> None:
328351
keyspace (str): The name of the keyspace to create.
329352
replication_factor (int): The replication factor. Defaults to 1.
330353
"""
331-
query = f"""
332-
CREATE KEYSPACE IF NOT EXISTS {keyspace}
333-
WITH replication = {{'class': 'SimpleStrategy', 'replication_factor': {replication_factor}}}
334-
"""
354+
# Use configured replication strategy
355+
if self.config.REPLICATION_STRATEGY == "NetworkTopologyStrategy" and self.config.REPLICATION_CONFIG:
356+
# Build replication config for NetworkTopologyStrategy
357+
replication_parts = ["'class': 'NetworkTopologyStrategy'"]
358+
for dc, rf in self.config.REPLICATION_CONFIG.items():
359+
replication_parts.append(f"'{dc}': {rf}")
360+
replication_str = ", ".join(replication_parts)
361+
query = f"""
362+
CREATE KEYSPACE IF NOT EXISTS {keyspace}
363+
WITH replication = {{{replication_str}}}
364+
"""
365+
else:
366+
# Use SimpleStrategy (default)
367+
query = f"""
368+
CREATE KEYSPACE IF NOT EXISTS {keyspace}
369+
WITH replication = {{'class': 'SimpleStrategy', 'replication_factor': {replication_factor}}}
370+
"""
335371
try:
336372
self.execute(query)
337373
except Exception as e:
@@ -765,13 +801,10 @@ def _create_cluster(self) -> Any:
765801

766802
# Configure load balancing policy with optional datacenter awareness
767803
if self.config.LOCAL_DC:
768-
from cassandra.policies import DCAwareRoundRobinPolicy
769-
770804
base_policy = DCAwareRoundRobinPolicy(local_dc=self.config.LOCAL_DC)
805+
load_balancing_policy = TokenAwarePolicy(base_policy)
771806
else:
772-
base_policy = RoundRobinPolicy()
773-
774-
load_balancing_policy = TokenAwarePolicy(base_policy)
807+
load_balancing_policy = TokenAwarePolicy(RoundRobinPolicy())
775808

776809
if self.config.RETRY_POLICY == "FALLTHROUGH":
777810
retry_policy = FallthroughRetryPolicy()
@@ -786,6 +819,12 @@ def _create_cluster(self) -> Any:
786819
if self.config.DISABLE_SHARD_AWARENESS:
787820
shard_aware_options = {"disable": True}
788821

822+
# Address translation for Docker/NAT environments where gossip-discovered
823+
# internal container IPs are unreachable from the host
824+
address_translator = None
825+
if self.config.ADDRESS_TRANSLATION_ENABLED:
826+
address_translator = _FixedAddressTranslator(self.config.CONTACT_POINTS[0])
827+
789828
# Cluster is from cassandra.cluster, properly typed
790829
cluster = Cluster(
791830
contact_points=self.config.CONTACT_POINTS,
@@ -797,12 +836,17 @@ def _create_cluster(self) -> Any:
797836
load_balancing_policy=load_balancing_policy,
798837
default_retry_policy=retry_policy,
799838
shard_aware_options=shard_aware_options,
839+
address_translator=address_translator,
800840
)
801841

802842
# Configure connection pool settings
803843
if cluster.profile_manager is not None:
804844
profile = cluster.profile_manager.default
805845
profile.request_timeout = self.config.REQUEST_TIMEOUT
846+
# Configure connection pool limits per host
847+
profile.max_connections_per_host = self.config.MAX_CONNECTIONS_PER_HOST
848+
profile.min_connections_per_host = self.config.MIN_CONNECTIONS_PER_HOST
849+
profile.core_connections_per_host = self.config.CORE_CONNECTIONS_PER_HOST
806850

807851
# Set pool configuration
808852
cluster.connection_class.max_requests_per_connection = self.config.MAX_REQUESTS_PER_CONNECTION
@@ -925,10 +969,23 @@ async def create_keyspace(self, keyspace: str, replication_factor: int = 1) -> N
925969
keyspace (str): The name of the keyspace to create.
926970
replication_factor (int): The replication factor. Defaults to 1.
927971
"""
928-
query = f"""
929-
CREATE KEYSPACE IF NOT EXISTS {keyspace}
930-
WITH replication = {{'class': 'SimpleStrategy', 'replication_factor': {replication_factor}}}
931-
"""
972+
# Use configured replication strategy
973+
if self.config.REPLICATION_STRATEGY == "NetworkTopologyStrategy" and self.config.REPLICATION_CONFIG:
974+
# Build replication config for NetworkTopologyStrategy
975+
replication_parts = ["'class': 'NetworkTopologyStrategy'"]
976+
for dc, rf in self.config.REPLICATION_CONFIG.items():
977+
replication_parts.append(f"'{dc}': {rf}")
978+
replication_str = ", ".join(replication_parts)
979+
query = f"""
980+
CREATE KEYSPACE IF NOT EXISTS {keyspace}
981+
WITH replication = {{{replication_str}}}
982+
"""
983+
else:
984+
# Use SimpleStrategy (default)
985+
query = f"""
986+
CREATE KEYSPACE IF NOT EXISTS {keyspace}
987+
WITH replication = {{'class': 'SimpleStrategy', 'replication_factor': {replication_factor}}}
988+
"""
932989
try:
933990
await self.execute(query)
934991
except Exception as e:

archipy/configs/config_template.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1002,6 +1002,13 @@ class ScyllaDBConfig(BaseModel):
10021002
default=False,
10031003
description="Disable shard awareness (useful for Docker/Testcontainer/NAT environments)",
10041004
)
1005+
ADDRESS_TRANSLATION_ENABLED: bool = Field(
1006+
default=False,
1007+
description="Enable address translation to redirect all discovered node connections to the first contact point. "
1008+
"In Docker/Testcontainer/NAT environments, ScyllaDB nodes advertise their internal container IPs "
1009+
"via gossip, which are unreachable from the host. When enabled, the driver translates all discovered "
1010+
"addresses to the first configured contact point, allowing connections through Docker's port mapping.",
1011+
)
10051012
RETRY_POLICY: Literal["EXPONENTIAL_BACKOFF", "FALLTHROUGH"] = Field(
10061013
default="EXPONENTIAL_BACKOFF",
10071014
description="Retry policy type (uses native driver RetryPolicy). "

features/steps/kafka_adapter_steps.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ def step_topic_list_includes(context, topic_name):
232232
def step_consumer_receive(context, expected_message, topic_name, group_id):
233233
adapter = get_kafka_consumer_adapter(context, topic_name, group_id)
234234
try:
235-
messages = adapter.batch_consume(messages_number=1, timeout=2)
235+
messages = adapter.batch_consume(messages_number=1, timeout=10)
236236
assert len(messages) > 0, "No messages received"
237237
received_message = messages[0].value().decode("utf-8")
238238
assert received_message == expected_message, f"Expected '{expected_message}', got '{received_message}'"

features/test_containers.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Container manager for test containers"""
22

33
import logging
4+
from pathlib import Path
45

56
from testcontainers.core.container import DockerContainer
67
from testcontainers.core.waiting_utils import wait_for_logs
@@ -452,8 +453,8 @@ def __init__(self, config: KafkaConfig | None = None, image: str | None = None)
452453
self.port: int | None = None
453454
self.bootstrap_servers: str | None = None
454455

455-
# Set up the container
456-
self._container = KafkaContainer(image=self.image)
456+
# Set up the container with KRaft mode (required for cp-kafka 7.4+, mandatory for 8.0+)
457+
self._container = KafkaContainer(image=self.image).with_kraft()
457458

458459
def start(self) -> KafkaContainer:
459460
"""Start the Kafka container."""
@@ -566,6 +567,9 @@ def stop(self) -> None:
566567
class ScyllaDBTestContainer(metaclass=Singleton, thread_safe=True):
567568
"""Test container for ScyllaDB."""
568569

570+
# Minimum aio-max-nr required by ScyllaDB's Seastar framework
571+
MIN_AIO_MAX_NR = 131072
572+
569573
def __init__(self, config: ScyllaDBConfig | None = None, image: str | None = None) -> None:
570574
"""Initialize ScyllaDB test container.
571575
@@ -597,15 +601,47 @@ def __init__(self, config: ScyllaDBConfig | None = None, image: str | None = Non
597601
# Add environment variables for single-node configuration
598602
self._container.with_env("SCYLLA_ARGS", "--smp 1 --memory 750M")
599603

604+
@staticmethod
605+
def _check_aio_max_nr() -> None:
606+
"""Check that the host system's fs.aio-max-nr is sufficient for ScyllaDB.
607+
608+
ScyllaDB's Seastar framework requires a minimum number of AIO slots.
609+
If the system value is too low, ScyllaDB will crash on startup.
610+
611+
Raises:
612+
RuntimeError: If aio-max-nr is below the required minimum.
613+
"""
614+
aio_path = Path("/proc/sys/fs/aio-max-nr")
615+
if not aio_path.exists():
616+
logger.warning("Cannot check aio-max-nr: %s not found (non-Linux system?)", aio_path)
617+
return
618+
619+
current_value = int(aio_path.read_text().strip())
620+
if current_value < ScyllaDBTestContainer.MIN_AIO_MAX_NR:
621+
msg = (
622+
f"ScyllaDB requires fs.aio-max-nr >= {ScyllaDBTestContainer.MIN_AIO_MAX_NR}, "
623+
f"but the current value is {current_value}. "
624+
f"Fix this by running: sudo sysctl -w fs.aio-max-nr={ScyllaDBTestContainer.MIN_AIO_MAX_NR} "
625+
f"To make it permanent, add 'fs.aio-max-nr = {ScyllaDBTestContainer.MIN_AIO_MAX_NR}' "
626+
f"to /etc/sysctl.conf and run 'sudo sysctl -p'."
627+
)
628+
raise RuntimeError(msg)
629+
600630
def start(self) -> DockerContainer:
601631
"""Start the ScyllaDB container.
602632
603633
Returns:
604634
DockerContainer: The running container instance.
635+
636+
Raises:
637+
RuntimeError: If the host system's aio-max-nr is too low for ScyllaDB.
605638
"""
606639
if self._is_running:
607640
return self._container
608641

642+
# Pre-flight check: verify host AIO configuration
643+
self._check_aio_max_nr()
644+
609645
# Start the container
610646
self._container.start()
611647

pyproject.toml

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,25 +20,25 @@ license = { file = "LICENSE" }
2020
# Core optional dependencies
2121
aiosqlite = ["aiosqlite>=0.22.1"]
2222
behave = ["behave>=1.3.3"]
23-
cache = ["cachetools>=7.0.0", "async-lru>=2.1.0"]
23+
cache = ["cachetools>=7.0.1", "async-lru>=2.1.0"]
2424
dependency-injection = ["dependency-injector>=4.48.3"]
2525
elastic-apm = ["elastic-apm>=6.25.0"]
2626
elasticsearch = ["elasticsearch>=9.3.0"]
2727
elasticsearch-async = ["elasticsearch[async]>=9.3.0"]
2828
fakeredis = ["fakeredis>=2.33.0"]
29-
fastapi = ["fastapi[all]>=0.128.5"]
29+
fastapi = ["fastapi[all]>=0.129.0"]
3030
grpc = ["grpcio>=1.78.0", "grpcio-health-checking>=1.78.0", "protobuf>=6.33.5"]
3131
jwt = ["pyjwt>=2.11.0"]
3232
kafka = ["confluent-kafka>=2.13.0"]
3333
kavenegar = ["kavenegar>=1.1.2"]
34-
keycloak = ["python-keycloak>=7.0.3", "cachetools>=7.0.0", "async-lru>=2.1.0"]
35-
minio = ["minio>=7.2.20", "cachetools>=7.0.0", "async-lru>=2.1.0"]
34+
keycloak = ["python-keycloak>=7.1.1", "cachetools>=7.0.1", "async-lru>=2.1.0"]
35+
minio = ["minio>=7.2.20", "cachetools>=7.0.1", "async-lru>=2.1.0"]
3636
parsian-ipg = ["zeep>=4.3.2", "requests[socks]>=2.32.5"]
3737
postgres = ["psycopg[binary,pool]>=3.3.2"]
3838
prometheus = ["prometheus-client>=0.24.1"]
39-
redis = ["redis[hiredis]>=7.1.0"]
39+
redis = ["redis[hiredis]>=7.1.1"]
4040
scheduler = ["apscheduler>=3.11.2"]
41-
scylladb = ["scylla-driver>=3.29.7", "lz4>=4.4.5", "cachetools>=7.0.0", "async-lru>=2.1.0"]
41+
scylladb = ["scylla-driver>=3.29.8", "lz4>=4.4.5", "cachetools>=7.0.1", "async-lru>=2.1.0"]
4242
sentry = ["sentry-sdk>=2.52.0"]
4343
sqlalchemy = ["sqlalchemy>=2.0.46"]
4444
sqlalchemy-async = ["sqlalchemy[asyncio]>=2.0.46"]
@@ -68,10 +68,10 @@ dev = [
6868
"add-trailing-comma>=4.0.0",
6969
"bandit>=1.9.3",
7070
"codespell>=2.4.1",
71-
"ty>=0.0.15",
71+
"ty>=0.0.17",
7272
"pre-commit-hooks>=6.0.0",
7373
"pre-commit>=4.5.1",
74-
"ruff>=0.15.0",
74+
"ruff>=0.15.1",
7575
"types-cachetools>=6.2.0.20251022",
7676
"types-grpcio>=1.0.0.20251009",
7777
"types-protobuf>=6.32.1.20251210",

0 commit comments

Comments
 (0)