From 3232b531aa5e31a71cb2a7eea94bb3aff2c75c22 Mon Sep 17 00:00:00 2001 From: Pavel Tisnovsky Date: Fri, 19 Sep 2025 11:22:17 +0200 Subject: [PATCH 1/2] LCORE-298: configuration for conversation cache --- src/configuration.py | 8 +++++++ src/constants.py | 5 +++++ src/models/config.py | 50 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+) diff --git a/src/configuration.py b/src/configuration.py index 8aa50a0a..c6193ab6 100644 --- a/src/configuration.py +++ b/src/configuration.py @@ -19,6 +19,7 @@ AuthenticationConfiguration, InferenceConfiguration, DatabaseConfiguration, + ConversationCache, ) @@ -124,6 +125,13 @@ def inference(self) -> InferenceConfiguration: raise LogicError("logic error: configuration is not loaded") return self._configuration.inference + @property + def conversation_cache(self) -> ConversationCache: + """Return conversation cache configuration.""" + if self._configuration is None: + raise LogicError("logic error: configuration is not loaded") + return self._configuration.conversation_cache + @property def database_configuration(self) -> DatabaseConfiguration: """Return database configuration.""" diff --git a/src/constants.py b/src/constants.py index e19b31e0..c37b98be 100644 --- a/src/constants.py +++ b/src/constants.py @@ -58,3 +58,8 @@ POSTGRES_DEFAULT_SSL_MODE = "prefer" # See: https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE POSTGRES_DEFAULT_GSS_ENCMODE = "prefer" + +# cache constants +CACHE_TYPE_MEMORY = "memory" +CACHE_TYPE_SQLITE = "sqlite" +CACHE_TYPE_POSTGRES = "postgres" diff --git a/src/models/config.py b/src/models/config.py index bc7538e9..c842744b 100644 --- a/src/models/config.py +++ b/src/models/config.py @@ -76,6 +76,12 @@ class SQLiteDatabaseConfiguration(ConfigurationBase): db_path: str +class InMemoryCacheConfig(ConfigurationBase): + """In-memory cache configuration.""" + + max_entries: PositiveInt + + class PostgreSQLDatabaseConfiguration(ConfigurationBase): """PostgreSQL database configuration.""" @@ -480,6 +486,49 @@ def check_default_model_and_provider(self) -> Self: return self +class ConversationCache(ConfigurationBase): + """Conversation cache configuration.""" + + type: Literal["memory", "sqlite", "postgres"] | None = None + memory: Optional[InMemoryCacheConfig] = None + sqlite: Optional[SQLiteDatabaseConfiguration] = None + postgres: Optional[PostgreSQLDatabaseConfiguration] = None + + @model_validator(mode="after") + def check_cache_configuration(self) -> Self: + """Check conversation cache configuration.""" + # if any backend config is provided, type must be explicitly selected + if self.type is None: + if any([self.memory, self.sqlite, self.postgres]): + raise ValueError( + "Conversation cache type must be set when backend configuration is provided" + ) + # no type selected + no configuration is expected and fully supported + return self + match self.type: + case constants.CACHE_TYPE_MEMORY: + if self.memory is None: + raise ValueError("Memory cache is selected, but not configured") + # no other DBs configuration allowed + if any([self.sqlite, self.postgres]): + raise ValueError("Only memory cache config must be provided") + case constants.CACHE_TYPE_SQLITE: + if self.sqlite is None: + raise ValueError("SQLite cache is selected, but not configured") + # no other DBs configuration allowed + if any([self.memory, self.postgres]): + raise ValueError("Only SQLite cache config must be provided") + case constants.CACHE_TYPE_POSTGRES: + if self.postgres is None: + raise ValueError("PostgreSQL cache is selected, but not configured") + # no other DBs configuration allowed + if any([self.memory, self.sqlite]): + raise ValueError("Only PostgreSQL cache config must be provided") + case _: + raise ValueError("Invalid conversation cache type selected") + return self + + class Configuration(ConfigurationBase): """Global service configuration.""" @@ -495,6 +544,7 @@ class Configuration(ConfigurationBase): authorization: Optional[AuthorizationConfiguration] = None customization: Optional[Customization] = None inference: InferenceConfiguration = Field(default_factory=InferenceConfiguration) + conversation_cache: ConversationCache = Field(default_factory=ConversationCache) def dump(self, filename: str = "configuration.json") -> None: """Dump actual configuration into JSON file.""" From ae7886b6fa940e1dbc7b250836e52902401e1483 Mon Sep 17 00:00:00 2001 From: Pavel Tisnovsky Date: Fri, 19 Sep 2025 11:59:56 +0200 Subject: [PATCH 2/2] Added new unit tests, updated existing ones --- src/models/config.py | 2 - .../models/config/test_conversation_cache.py | 200 ++++++++++++++++++ .../models/config/test_dump_configuration.py | 6 + tests/unit/test_configuration.py | 7 + 4 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 tests/unit/models/config/test_conversation_cache.py diff --git a/src/models/config.py b/src/models/config.py index c842744b..9260a078 100644 --- a/src/models/config.py +++ b/src/models/config.py @@ -524,8 +524,6 @@ def check_cache_configuration(self) -> Self: # no other DBs configuration allowed if any([self.memory, self.sqlite]): raise ValueError("Only PostgreSQL cache config must be provided") - case _: - raise ValueError("Invalid conversation cache type selected") return self diff --git a/tests/unit/models/config/test_conversation_cache.py b/tests/unit/models/config/test_conversation_cache.py new file mode 100644 index 00000000..b410506d --- /dev/null +++ b/tests/unit/models/config/test_conversation_cache.py @@ -0,0 +1,200 @@ +"""Unit tests for ConversationCache model.""" + +from pathlib import Path + +import pytest + +from pydantic import ValidationError + +import constants +from models.config import ( + ConversationCache, + InMemoryCacheConfig, + SQLiteDatabaseConfiguration, + PostgreSQLDatabaseConfiguration, +) + + +def test_conversation_cache_no_type_specified() -> None: + """Check the test for type as optional attribute.""" + c = ConversationCache() + assert c.type is None + + +def test_conversation_cache_unknown_type() -> None: + """Check the test for cache type.""" + with pytest.raises( + ValidationError, match="Input should be 'memory', 'sqlite' or 'postgres'" + ): + _ = ConversationCache(type="foo") + + +def test_conversation_cache_correct_type_but_not_configured(subtests) -> None: + """Check the test for cache type.""" + with subtests.test(msg="Memory cache"): + with pytest.raises( + ValidationError, match="Memory cache is selected, but not configured" + ): + _ = ConversationCache(type=constants.CACHE_TYPE_MEMORY) + + with subtests.test(msg="SQLite cache"): + with pytest.raises( + ValidationError, match="SQLite cache is selected, but not configured" + ): + _ = ConversationCache(type=constants.CACHE_TYPE_SQLITE) + + with subtests.test(msg="SQLite cache"): + with pytest.raises( + ValidationError, match="PostgreSQL cache is selected, but not configured" + ): + _ = ConversationCache(type=constants.CACHE_TYPE_POSTGRES) + + +def test_conversation_cache_no_type_but_configured(subtests) -> None: + """Check the test for cache type.""" + m = "Conversation cache type must be set when backend configuration is provided" + + with subtests.test(msg="Memory cache"): + with pytest.raises(ValidationError, match=m): + _ = ConversationCache(memory=InMemoryCacheConfig(max_entries=100)) + + with subtests.test(msg="SQLite cache"): + with pytest.raises(ValidationError, match=m): + _ = ConversationCache(sqlite=SQLiteDatabaseConfiguration(db_path="path")) + + with subtests.test(msg="PostgreSQL cache"): + d = PostgreSQLDatabaseConfiguration( + db="db", + user="user", + password="password", + port=1234, + ca_cert_path=Path("tests/configuration/server.crt"), + ) + with pytest.raises(ValidationError, match=m): + _ = ConversationCache(postgres=d) + + +def test_conversation_cache_multiple_configurations(subtests) -> None: + """Test how multiple configurations are handled.""" + d = PostgreSQLDatabaseConfiguration( + db="db", + user="user", + password="password", + port=1234, + ca_cert_path=Path("tests/configuration/server.crt"), + ) + + with subtests.test(msg="Memory cache"): + with pytest.raises( + ValidationError, match="Only memory cache config must be provided" + ): + _ = ConversationCache( + type=constants.CACHE_TYPE_MEMORY, + memory=InMemoryCacheConfig(max_entries=100), + sqlite=SQLiteDatabaseConfiguration(db_path="path"), + postgres=d, + ) + + with subtests.test(msg="SQLite cache"): + with pytest.raises( + ValidationError, match="Only SQLite cache config must be provided" + ): + _ = ConversationCache( + type=constants.CACHE_TYPE_SQLITE, + memory=InMemoryCacheConfig(max_entries=100), + sqlite=SQLiteDatabaseConfiguration(db_path="path"), + postgres=d, + ) + + with subtests.test(msg="PostgreSQL cache"): + with pytest.raises( + ValidationError, match="Only PostgreSQL cache config must be provided" + ): + _ = ConversationCache( + type=constants.CACHE_TYPE_POSTGRES, + memory=InMemoryCacheConfig(max_entries=100), + sqlite=SQLiteDatabaseConfiguration(db_path="path"), + postgres=d, + ) + + +def test_conversation_type_memory() -> None: + """Test the memory conversation cache configuration.""" + c = ConversationCache( + type=constants.CACHE_TYPE_MEMORY, memory=InMemoryCacheConfig(max_entries=100) + ) + assert c.type == constants.CACHE_TYPE_MEMORY + assert c.memory is not None + assert c.sqlite is None + assert c.postgres is None + assert c.memory.max_entries == 100 + + +def test_conversation_type_memory_wrong_config() -> None: + """Test the memory conversation cache configuration.""" + with pytest.raises(ValidationError, match="Field required"): + _ = ConversationCache( + type=constants.CACHE_TYPE_MEMORY, + memory=InMemoryCacheConfig(), + ) + + with pytest.raises(ValidationError, match="Input should be greater than 0"): + _ = ConversationCache( + type=constants.CACHE_TYPE_MEMORY, + memory=InMemoryCacheConfig(max_entries=-100), + ) + + +def test_conversation_type_sqlite() -> None: + """Test the SQLite conversation cache configuration.""" + c = ConversationCache( + type=constants.CACHE_TYPE_SQLITE, + sqlite=SQLiteDatabaseConfiguration(db_path="path"), + ) + assert c.type == constants.CACHE_TYPE_SQLITE + assert c.memory is None + assert c.sqlite is not None + assert c.postgres is None + assert c.sqlite.db_path == "path" + + +def test_conversation_type_sqlite_wrong_config() -> None: + """Test the SQLite conversation cache configuration.""" + with pytest.raises(ValidationError, match="Field required"): + _ = ConversationCache( + type=constants.CACHE_TYPE_SQLITE, + memory=SQLiteDatabaseConfiguration(), + ) + + +def test_conversation_type_postgres() -> None: + """Test the PostgreSQL conversation cache configuration.""" + d = PostgreSQLDatabaseConfiguration( + db="db", + user="user", + password="password", + port=1234, + ca_cert_path=Path("tests/configuration/server.crt"), + ) + + c = ConversationCache( + type=constants.CACHE_TYPE_POSTGRES, + postgres=d, + ) + assert c.type == constants.CACHE_TYPE_POSTGRES + assert c.memory is None + assert c.sqlite is None + assert c.postgres is not None + assert c.postgres.host == "localhost" + assert c.postgres.port == 1234 + assert c.postgres.db == "db" + assert c.postgres.user == "user" + + +def test_conversation_type_postgres_wrong_config() -> None: + """Test the SQLite conversation cache configuration.""" + with pytest.raises(ValidationError, match="Field required"): + _ = ConversationCache( + type=constants.CACHE_TYPE_POSTGRES, + postgres=PostgreSQLDatabaseConfiguration(), + ) diff --git a/tests/unit/models/config/test_dump_configuration.py b/tests/unit/models/config/test_dump_configuration.py index 303b1799..03990b01 100644 --- a/tests/unit/models/config/test_dump_configuration.py +++ b/tests/unit/models/config/test_dump_configuration.py @@ -163,6 +163,12 @@ def test_dump_configuration(tmp_path) -> None: }, }, "authorization": None, + "conversation_cache": { + "memory": None, + "postgres": None, + "sqlite": None, + "type": None, + }, } diff --git a/tests/unit/test_configuration.py b/tests/unit/test_configuration.py index 8ed2b2d2..11a90165 100644 --- a/tests/unit/test_configuration.py +++ b/tests/unit/test_configuration.py @@ -69,6 +69,10 @@ def test_default_configuration() -> None: # try to read property _ = cfg.database_configuration # pylint: disable=pointless-statement + with pytest.raises(Exception, match="logic error: configuration is not loaded"): + # try to read property + _ = cfg.conversation_cache # pylint: disable=pointless-statement + def test_configuration_is_singleton() -> None: """Test that configuration is singleton.""" @@ -144,6 +148,9 @@ def test_init_from_dict() -> None: # check inference configuration assert cfg.inference is not None + # check conversation cache + assert cfg.conversation_cache is not None + def test_init_from_dict_with_mcp_servers() -> None: """Test initialization with MCP servers configuration."""