-
Notifications
You must be signed in to change notification settings - Fork 51
feat(states): add storage-phase config and task-state repository selector #380
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
857150f
c08fca8
7435671
07433d0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,16 +1,76 @@ | ||
| from typing import Annotated | ||
| import builtins | ||
| from typing import Annotated, Any, Protocol | ||
|
|
||
| import pymongo | ||
| from fastapi import Depends | ||
| from src.adapters.crud_store.adapter_mongodb import MongoDBCRUDRepository | ||
| from src.config.dependencies import DMongoDBDatabase | ||
| from src.config.dependencies import DMongoDBDatabase, GlobalDependencies | ||
| from src.config.environment_variables import EnvironmentVariables, StoragePhase | ||
| from src.domain.entities.states import StateEntity | ||
| from src.utils.logging import make_logger | ||
|
|
||
| logger = make_logger(__name__) | ||
|
|
||
|
|
||
| class TaskStateRepository(MongoDBCRUDRepository[StateEntity]): | ||
| class TaskStateRepositoryProtocol(Protocol): | ||
| """Contract every task-state storage backend must satisfy. | ||
|
|
||
| Covers everything callers actually invoke through the DTaskStateRepository | ||
| seam: the states use case and authorization shortcuts (create / get / | ||
| update / delete / list), the retention service (find_by_field / | ||
| delete_by_field / batch_create), and get_by_task_and_agent. | ||
|
|
||
| Behavioral requirements beyond the signatures: `.id` is presented as a | ||
| string; `create` honors caller-supplied created_at/updated_at and only | ||
| falls back to server time when absent; missing rows raise ItemDoesNotExist | ||
| and duplicates raise DuplicateItemError; and `list` accepts the | ||
| Mongo-shaped filter dict the states use case builds (plain equality plus | ||
| `{"$in": [...]}` for the authorized-task allow-list). | ||
| """ | ||
|
|
||
| async def create(self, item: StateEntity) -> StateEntity: ... | ||
|
|
||
| async def batch_create( | ||
| self, items: builtins.list[StateEntity] | ||
| ) -> builtins.list[StateEntity]: ... | ||
|
|
||
| async def get( | ||
| self, id: str | None = None, name: str | None = None | ||
| ) -> StateEntity | None: ... | ||
|
|
||
| async def update(self, item: StateEntity) -> StateEntity: ... | ||
|
|
||
| async def delete(self, id: str | None = None, name: str | None = None) -> None: ... | ||
|
|
||
| async def list( | ||
| self, | ||
| filters: dict[str, Any] | None = None, | ||
| limit: int | None = None, | ||
| page_number: int | None = None, | ||
| order_by: str | None = None, | ||
| order_direction: str | None = None, | ||
| ) -> builtins.list[StateEntity]: ... | ||
|
|
||
| async def find_by_field( | ||
| self, | ||
| field_name: str, | ||
| field_value: Any, | ||
| limit: int | None = None, | ||
| page_number: int | None = None, | ||
| sort_by: dict[str, int] | None = None, | ||
| filters: dict[str, Any] | None = None, | ||
| ) -> builtins.list[StateEntity]: ... | ||
|
|
||
| async def delete_by_field(self, field_name: str, field_value: Any) -> int: ... | ||
|
|
||
| async def get_by_task_and_agent( | ||
| self, task_id: str, agent_id: str | ||
| ) -> StateEntity | None: ... | ||
|
|
||
|
|
||
| class TaskStateRepository( | ||
| MongoDBCRUDRepository[StateEntity], TaskStateRepositoryProtocol | ||
| ): | ||
| """Repository for managing task states in MongoDB.""" | ||
|
|
||
| COLLECTION_NAME = "task_states" | ||
|
|
@@ -47,4 +107,29 @@ async def get_by_task_and_agent( | |
| return self._deserialize(doc) if doc else None | ||
|
|
||
|
|
||
| DTaskStateRepository = Annotated[TaskStateRepository, Depends(TaskStateRepository)] | ||
| def get_task_state_repository() -> TaskStateRepositoryProtocol: | ||
| """Select the task-state repository for the configured storage phase. | ||
|
|
||
| This is the single construction point for task-state repositories: the | ||
| FastAPI seam below resolves through it, and so must every site that builds | ||
| the repository by hand outside Depends (the Temporal factories), so that a | ||
| phase switch applies to request handlers and workers alike. | ||
|
|
||
| Each branch constructs its repository lazily — the Postgres phase must | ||
| never touch a Mongo handle, which is what allows the MongoDB connection to | ||
| be absent entirely once no store needs it. | ||
| """ | ||
| phase = EnvironmentVariables.refresh().TASK_STATE_STORAGE_PHASE | ||
| if phase == StoragePhase.MONGODB: | ||
| return TaskStateRepository(GlobalDependencies().mongodb_database) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. mongo db class should implement
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You're right, I just added it. The repo now declares the Protocol as a base, and a test enforces that every protocol method has a real implementation, so any drift fails CI at the class instead of at some call site. The follow-up's Postgres repo gets the same base and test, plus the shared test suite running against both backends, which is what keeps the two aligned. |
||
| # Unreachable through env config (refresh() rejects unimplemented phases | ||
| # at startup); defense in depth for configs constructed another way. | ||
| raise NotImplementedError( | ||
| f"TASK_STATE_STORAGE_PHASE={phase.value!r} is not implemented yet; " | ||
| "only 'mongodb' is currently supported." | ||
| ) | ||
|
|
||
|
greptile-apps[bot] marked this conversation as resolved.
|
||
|
|
||
| DTaskStateRepository = Annotated[ | ||
| TaskStateRepositoryProtocol, Depends(get_task_state_repository) | ||
| ] | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,7 +18,7 @@ | |
| from src.domain.repositories.event_repository import EventRepository | ||
| from src.domain.repositories.task_message_repository import TaskMessageRepository | ||
| from src.domain.repositories.task_repository import TaskRepository | ||
| from src.domain.repositories.task_state_repository import TaskStateRepository | ||
| from src.domain.repositories.task_state_repository import get_task_state_repository | ||
| from src.domain.services.task_message_service import TaskMessageService | ||
| from src.domain.services.task_retention_service import TaskRetentionService | ||
| from src.domain.use_cases.task_retention_use_case import TaskRetentionUseCase | ||
|
|
@@ -41,7 +41,9 @@ def build_task_retention_use_case( | |
| task_message_repository = TaskMessageRepository( | ||
| global_dependencies.mongodb_database | ||
| ) | ||
| task_state_repository = TaskStateRepository(global_dependencies.mongodb_database) | ||
| # Resolved through the shared selector (not constructed by hand) so the | ||
| # worker honors TASK_STATE_STORAGE_PHASE the same way the API does. | ||
| task_state_repository = get_task_state_repository() | ||
| task_message_service = TaskMessageService( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why not do the same for messages too since youre introducing that env var in this pr?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The env var and the selector are separate pieces here. The selector only exists where there are two backends to select between. Messages get theirs in M2 (Milestone 2), alongside the Postgres message repo, same as state got its selector in this PR. The env var can't wait for M2 though: mongodb_required is derived from both phases, so both settings need to exist for it to have its final shape. Until M2 consumes it, startup validation keeps it locked to mongodb. |
||
| message_repository=task_message_repository | ||
| ) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| import pytest | ||
| from pydantic import ValidationError | ||
| from src.config.environment_variables import EnvironmentVariables, StoragePhase | ||
|
|
||
|
|
||
| @pytest.fixture(autouse=True) | ||
| def _reset_env_cache(): | ||
| """Drop the forced-refresh cache so later tests re-read a clean environment.""" | ||
| yield | ||
| EnvironmentVariables.clear_cache() | ||
|
|
||
|
|
||
| @pytest.mark.unit | ||
| def test_storage_phases_default_to_mongodb(monkeypatch): | ||
| monkeypatch.delenv("TASK_STATE_STORAGE_PHASE", raising=False) | ||
| monkeypatch.delenv("TASK_MESSAGE_STORAGE_PHASE", raising=False) | ||
|
|
||
| env = EnvironmentVariables.refresh(force_refresh=True) | ||
|
|
||
| assert env.TASK_STATE_STORAGE_PHASE == StoragePhase.MONGODB | ||
| assert env.TASK_MESSAGE_STORAGE_PHASE == StoragePhase.MONGODB | ||
| assert env.mongodb_required is True | ||
|
|
||
|
|
||
| @pytest.mark.unit | ||
| def test_storage_phases_parse_from_environment(monkeypatch): | ||
| monkeypatch.setenv("TASK_STATE_STORAGE_PHASE", "mongodb") | ||
| monkeypatch.setenv("TASK_MESSAGE_STORAGE_PHASE", "mongodb") | ||
|
|
||
| env = EnvironmentVariables.refresh(force_refresh=True) | ||
|
|
||
| assert env.TASK_STATE_STORAGE_PHASE == StoragePhase.MONGODB | ||
| assert env.TASK_MESSAGE_STORAGE_PHASE == StoragePhase.MONGODB | ||
|
|
||
|
|
||
| @pytest.mark.unit | ||
| @pytest.mark.parametrize( | ||
| "raw", ["mongo", "POSTGRES", "true", "", "dual_write", "dual_read"] | ||
| ) | ||
| def test_storage_phase_rejects_unknown_values(monkeypatch, raw): | ||
| # Fail loud on typos rather than silently falling back to a backend the | ||
| # operator did not choose. dual_write/dual_read are deliberately not | ||
| # defined: a data-migration effort would add its own phases. | ||
| monkeypatch.setenv("TASK_STATE_STORAGE_PHASE", raw) | ||
|
|
||
| with pytest.raises(ValidationError): | ||
| EnvironmentVariables.refresh(force_refresh=True) | ||
|
|
||
|
|
||
| @pytest.mark.unit | ||
| @pytest.mark.parametrize( | ||
| "key", ["TASK_STATE_STORAGE_PHASE", "TASK_MESSAGE_STORAGE_PHASE"] | ||
| ) | ||
| def test_unimplemented_phase_is_rejected_at_refresh(monkeypatch, key): | ||
| """postgres is a valid phase value but has no repository yet: the config | ||
| refresh (process startup, API and Temporal workers alike) must fail with | ||
| the variable named — not the first state request or worker wiring.""" | ||
| monkeypatch.setenv(key, "postgres") | ||
|
|
||
| with pytest.raises(ValueError, match=key): | ||
| EnvironmentVariables.refresh(force_refresh=True) | ||
|
|
||
|
|
||
| @pytest.mark.unit | ||
| @pytest.mark.parametrize( | ||
| ("state_phase", "message_phase", "expected"), | ||
| [ | ||
| ("mongodb", "mongodb", True), | ||
| ("postgres", "mongodb", True), | ||
| ("mongodb", "postgres", True), | ||
| ("postgres", "postgres", False), | ||
| ], | ||
| ) | ||
| def test_mongodb_required_unless_every_phase_is_postgres( | ||
| monkeypatch, state_phase, message_phase, expected | ||
| ): | ||
| monkeypatch.delenv("TASK_STATE_STORAGE_PHASE", raising=False) | ||
| monkeypatch.delenv("TASK_MESSAGE_STORAGE_PHASE", raising=False) | ||
| env = EnvironmentVariables.refresh(force_refresh=True) | ||
|
|
||
| # Unimplemented phases cannot come from the environment (refresh rejects | ||
| # them at startup), so pin the derived property on updated copies. | ||
| patched = env.model_copy( | ||
| update={ | ||
| "TASK_STATE_STORAGE_PHASE": StoragePhase(state_phase), | ||
| "TASK_MESSAGE_STORAGE_PHASE": StoragePhase(message_phase), | ||
| } | ||
| ) | ||
|
|
||
| assert patched.mongodb_required is expected |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why not just enum? Why is string needed
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Matches the other enums in this file (Environment, EnvVarKeys). Existing code compares raw env strings to those members directly, which only works when members are strings. Also keeps it log/JSON-friendly without .value everywhere. Pydantic would parse either way, so it's more for consistency