Skip to content

Commit d28a45c

Browse files
feat(states): add storage-phase config and task-state repository selector
Introduce TASK_STATE_STORAGE_PHASE and TASK_MESSAGE_STORAGE_PHASE settings (mongodb|postgres, default mongodb) with a derived mongodb_required property, a TaskStateRepositoryProtocol capturing the contract callers rely on through the DTaskStateRepository seam, and a get_task_state_repository() selector that resolves the backend by phase. The DI seam and both Temporal factories (task retention, scheduled agent runs) now construct the repository through the selector, so a phase switch applies to API handlers and workers alike. The postgres phase raises NotImplementedError until the Postgres repository lands, and the mongodb default keeps existing deployments unchanged.
1 parent 32a9acc commit d28a45c

8 files changed

Lines changed: 304 additions & 7 deletions

File tree

agentex/docker-compose.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,9 @@ services:
164164
- REDIS_URL=redis://agentex-redis:6379
165165
- MONGODB_URI=mongodb://agentex-mongodb:27017
166166
- MONGODB_DATABASE_NAME=agentex
167+
# Storage backend per document store (mongodb|postgres), e.g. `TASK_STATE_STORAGE_PHASE=postgres ./dev.sh`.
168+
- TASK_STATE_STORAGE_PHASE=${TASK_STATE_STORAGE_PHASE:-mongodb}
169+
- TASK_MESSAGE_STORAGE_PHASE=${TASK_MESSAGE_STORAGE_PHASE:-mongodb}
167170
- WATCHFILES_FORCE_POLLING=true
168171
- ENABLE_HEALTH_CHECK_WORKFLOW=true
169172
# Disabled by default; enable when testing, e.g. `ENABLE_AGENT_RUN_SCHEDULES=true ./dev.sh`.
@@ -238,6 +241,8 @@ services:
238241
- REDIS_URL=redis://agentex-redis:6379
239242
- MONGODB_URI=mongodb://agentex-mongodb:27017
240243
- MONGODB_DATABASE_NAME=agentex
244+
- TASK_STATE_STORAGE_PHASE=${TASK_STATE_STORAGE_PHASE:-mongodb}
245+
- TASK_MESSAGE_STORAGE_PHASE=${TASK_MESSAGE_STORAGE_PHASE:-mongodb}
241246
- AGENTEX_SERVER_TASK_QUEUE=agentex-server
242247
- RETENTION_CLEANUP_ENABLED=${RETENTION_CLEANUP_ENABLED:-false}
243248
- RETENTION_CLEANUP_AGENT_ALLOWLIST=${RETENTION_CLEANUP_AGENT_ALLOWLIST:-}

agentex/src/config/environment_variables.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ class EnvVarKeys(str, Enum):
6767
RETENTION_CLEANUP_MAX_IN_FLIGHT = "RETENTION_CLEANUP_MAX_IN_FLIGHT"
6868
RETENTION_CLEANUP_DRY_RUN = "RETENTION_CLEANUP_DRY_RUN"
6969
RETENTION_CLEANUP_STALE_RUNNING_DAYS = "RETENTION_CLEANUP_STALE_RUNNING_DAYS"
70+
TASK_STATE_STORAGE_PHASE = "TASK_STATE_STORAGE_PHASE"
71+
TASK_MESSAGE_STORAGE_PHASE = "TASK_MESSAGE_STORAGE_PHASE"
7072

7173

7274
class Environment(str, Enum):
@@ -75,6 +77,17 @@ class Environment(str, Enum):
7577
PROD = "production"
7678

7779

80+
class StoragePhase(str, Enum):
81+
"""Which backend serves a document store (task state, task messages).
82+
83+
A future data-migration effort would define its own additional phases;
84+
new values are additive, not breaking.
85+
"""
86+
87+
MONGODB = "mongodb"
88+
POSTGRES = "postgres"
89+
90+
7891
refreshed_environment_variables = None
7992

8093

@@ -166,6 +179,19 @@ class EnvironmentVariables(BaseModel):
166179
# are treated as abandoned and become eligible for cleanup. 0 disables the
167180
# override (RUNNING tasks are never cleaned), preserving prior behavior.
168181
RETENTION_CLEANUP_STALE_RUNNING_DAYS: int = 0
182+
# Storage backend per document store. The mongodb default keeps existing
183+
# deployments unchanged; postgres serves that store from the relational
184+
# database instead.
185+
TASK_STATE_STORAGE_PHASE: StoragePhase = StoragePhase.MONGODB
186+
TASK_MESSAGE_STORAGE_PHASE: StoragePhase = StoragePhase.MONGODB
187+
188+
@property
189+
def mongodb_required(self) -> bool:
190+
"""True while any document store still needs a MongoDB connection."""
191+
return not (
192+
self.TASK_STATE_STORAGE_PHASE == StoragePhase.POSTGRES
193+
and self.TASK_MESSAGE_STORAGE_PHASE == StoragePhase.POSTGRES
194+
)
169195

170196
@classmethod
171197
def refresh(cls, force_refresh: bool = False) -> EnvironmentVariables | None:
@@ -285,6 +311,12 @@ def refresh(cls, force_refresh: bool = False) -> EnvironmentVariables | None:
285311
RETENTION_CLEANUP_STALE_RUNNING_DAYS=int(
286312
os.environ.get(EnvVarKeys.RETENTION_CLEANUP_STALE_RUNNING_DAYS, "0")
287313
),
314+
TASK_STATE_STORAGE_PHASE=os.environ.get(
315+
EnvVarKeys.TASK_STATE_STORAGE_PHASE, StoragePhase.MONGODB
316+
),
317+
TASK_MESSAGE_STORAGE_PHASE=os.environ.get(
318+
EnvVarKeys.TASK_MESSAGE_STORAGE_PHASE, StoragePhase.MONGODB
319+
),
288320
)
289321
refreshed_environment_variables = environment_variables
290322
return refreshed_environment_variables

agentex/src/domain/repositories/task_state_repository.py

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,73 @@
1-
from typing import Annotated
1+
import builtins
2+
from typing import Annotated, Any, Protocol
23

34
import pymongo
45
from fastapi import Depends
56
from src.adapters.crud_store.adapter_mongodb import MongoDBCRUDRepository
6-
from src.config.dependencies import DMongoDBDatabase
7+
from src.config.dependencies import DMongoDBDatabase, GlobalDependencies
8+
from src.config.environment_variables import EnvironmentVariables, StoragePhase
79
from src.domain.entities.states import StateEntity
810
from src.utils.logging import make_logger
911

1012
logger = make_logger(__name__)
1113

1214

15+
class TaskStateRepositoryProtocol(Protocol):
16+
"""Contract every task-state storage backend must satisfy.
17+
18+
Covers everything callers actually invoke through the DTaskStateRepository
19+
seam: the states use case and authorization shortcuts (create / get /
20+
update / delete / list), the retention service (find_by_field /
21+
delete_by_field / batch_create), and get_by_task_and_agent.
22+
23+
Behavioral requirements beyond the signatures: `.id` is presented as a
24+
string; `create` honors caller-supplied created_at/updated_at and only
25+
falls back to server time when absent; missing rows raise ItemDoesNotExist
26+
and duplicates raise DuplicateItemError; and `list` accepts the
27+
Mongo-shaped filter dict the states use case builds (plain equality plus
28+
`{"$in": [...]}` for the authorized-task allow-list).
29+
"""
30+
31+
async def create(self, item: StateEntity) -> StateEntity: ...
32+
33+
async def batch_create(
34+
self, items: builtins.list[StateEntity]
35+
) -> builtins.list[StateEntity]: ...
36+
37+
async def get(
38+
self, id: str | None = None, name: str | None = None
39+
) -> StateEntity | None: ...
40+
41+
async def update(self, item: StateEntity) -> StateEntity: ...
42+
43+
async def delete(self, id: str | None = None, name: str | None = None) -> None: ...
44+
45+
async def list(
46+
self,
47+
filters: dict[str, Any] | None = None,
48+
limit: int | None = None,
49+
page_number: int | None = None,
50+
order_by: str | None = None,
51+
order_direction: str | None = None,
52+
) -> builtins.list[StateEntity]: ...
53+
54+
async def find_by_field(
55+
self,
56+
field_name: str,
57+
field_value: Any,
58+
limit: int | None = None,
59+
page_number: int | None = None,
60+
sort_by: dict[str, int] | None = None,
61+
filters: dict[str, Any] | None = None,
62+
) -> builtins.list[StateEntity]: ...
63+
64+
async def delete_by_field(self, field_name: str, field_value: Any) -> int: ...
65+
66+
async def get_by_task_and_agent(
67+
self, task_id: str, agent_id: str
68+
) -> StateEntity | None: ...
69+
70+
1371
class TaskStateRepository(MongoDBCRUDRepository[StateEntity]):
1472
"""Repository for managing task states in MongoDB."""
1573

@@ -47,4 +105,27 @@ async def get_by_task_and_agent(
47105
return self._deserialize(doc) if doc else None
48106

49107

50-
DTaskStateRepository = Annotated[TaskStateRepository, Depends(TaskStateRepository)]
108+
def get_task_state_repository() -> TaskStateRepositoryProtocol:
109+
"""Select the task-state repository for the configured storage phase.
110+
111+
This is the single construction point for task-state repositories: the
112+
FastAPI seam below resolves through it, and so must every site that builds
113+
the repository by hand outside Depends (the Temporal factories), so that a
114+
phase switch applies to request handlers and workers alike.
115+
116+
Each branch constructs its repository lazily — the Postgres phase must
117+
never touch a Mongo handle, which is what allows the MongoDB connection to
118+
be absent entirely once no store needs it.
119+
"""
120+
phase = EnvironmentVariables.refresh().TASK_STATE_STORAGE_PHASE
121+
if phase == StoragePhase.MONGODB:
122+
return TaskStateRepository(GlobalDependencies().mongodb_database)
123+
raise NotImplementedError(
124+
f"TASK_STATE_STORAGE_PHASE={phase.value!r} is not implemented yet; "
125+
"only 'mongodb' is currently supported."
126+
)
127+
128+
129+
DTaskStateRepository = Annotated[
130+
TaskStateRepositoryProtocol, Depends(get_task_state_repository)
131+
]

agentex/src/temporal/scheduled_agent_run_factory.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
from src.domain.repositories.event_repository import EventRepository
3838
from src.domain.repositories.task_message_repository import TaskMessageRepository
3939
from src.domain.repositories.task_repository import TaskRepository
40-
from src.domain.repositories.task_state_repository import TaskStateRepository
40+
from src.domain.repositories.task_state_repository import get_task_state_repository
4141
from src.domain.services.agent_acp_service import AgentACPService
4242
from src.domain.services.authorization_service import AuthorizationService
4343
from src.domain.services.task_message_service import TaskMessageService
@@ -98,7 +98,9 @@ def build_acp_use_case_for_principal(
9898
task_repository = TaskRepository(rw_session_maker, ro_session_maker)
9999
event_repository = EventRepository(rw_session_maker, ro_session_maker)
100100

101-
task_state_repository = TaskStateRepository(global_dependencies.mongodb_database)
101+
# Resolved through the shared selector (not constructed by hand) so the
102+
# worker honors TASK_STATE_STORAGE_PHASE the same way the API does.
103+
task_state_repository = get_task_state_repository()
102104
task_message_repository = TaskMessageRepository(
103105
global_dependencies.mongodb_database
104106
)

agentex/src/temporal/task_retention_factory.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from src.domain.repositories.event_repository import EventRepository
1919
from src.domain.repositories.task_message_repository import TaskMessageRepository
2020
from src.domain.repositories.task_repository import TaskRepository
21-
from src.domain.repositories.task_state_repository import TaskStateRepository
21+
from src.domain.repositories.task_state_repository import get_task_state_repository
2222
from src.domain.services.task_message_service import TaskMessageService
2323
from src.domain.services.task_retention_service import TaskRetentionService
2424
from src.domain.use_cases.task_retention_use_case import TaskRetentionUseCase
@@ -41,7 +41,9 @@ def build_task_retention_use_case(
4141
task_message_repository = TaskMessageRepository(
4242
global_dependencies.mongodb_database
4343
)
44-
task_state_repository = TaskStateRepository(global_dependencies.mongodb_database)
44+
# Resolved through the shared selector (not constructed by hand) so the
45+
# worker honors TASK_STATE_STORAGE_PHASE the same way the API does.
46+
task_state_repository = get_task_state_repository()
4547
task_message_service = TaskMessageService(
4648
message_repository=task_message_repository
4749
)
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import pytest
2+
from pydantic import ValidationError
3+
from src.config.environment_variables import EnvironmentVariables, StoragePhase
4+
5+
6+
@pytest.fixture(autouse=True)
7+
def _reset_env_cache():
8+
"""Drop the forced-refresh cache so later tests re-read a clean environment."""
9+
yield
10+
EnvironmentVariables.clear_cache()
11+
12+
13+
@pytest.mark.unit
14+
def test_storage_phases_default_to_mongodb(monkeypatch):
15+
monkeypatch.delenv("TASK_STATE_STORAGE_PHASE", raising=False)
16+
monkeypatch.delenv("TASK_MESSAGE_STORAGE_PHASE", raising=False)
17+
18+
env = EnvironmentVariables.refresh(force_refresh=True)
19+
20+
assert env.TASK_STATE_STORAGE_PHASE == StoragePhase.MONGODB
21+
assert env.TASK_MESSAGE_STORAGE_PHASE == StoragePhase.MONGODB
22+
assert env.mongodb_required is True
23+
24+
25+
@pytest.mark.unit
26+
def test_storage_phases_parse_from_environment(monkeypatch):
27+
monkeypatch.setenv("TASK_STATE_STORAGE_PHASE", "postgres")
28+
monkeypatch.setenv("TASK_MESSAGE_STORAGE_PHASE", "mongodb")
29+
30+
env = EnvironmentVariables.refresh(force_refresh=True)
31+
32+
assert env.TASK_STATE_STORAGE_PHASE == StoragePhase.POSTGRES
33+
assert env.TASK_MESSAGE_STORAGE_PHASE == StoragePhase.MONGODB
34+
35+
36+
@pytest.mark.unit
37+
@pytest.mark.parametrize(
38+
"raw", ["mongo", "POSTGRES", "true", "", "dual_write", "dual_read"]
39+
)
40+
def test_storage_phase_rejects_unknown_values(monkeypatch, raw):
41+
# Fail loud on typos rather than silently falling back to a backend the
42+
# operator did not choose. dual_write/dual_read are deliberately not
43+
# defined: a data-migration effort would add its own phases.
44+
monkeypatch.setenv("TASK_STATE_STORAGE_PHASE", raw)
45+
46+
with pytest.raises(ValidationError):
47+
EnvironmentVariables.refresh(force_refresh=True)
48+
49+
50+
@pytest.mark.unit
51+
@pytest.mark.parametrize(
52+
("state_phase", "message_phase", "expected"),
53+
[
54+
("mongodb", "mongodb", True),
55+
("postgres", "mongodb", True),
56+
("mongodb", "postgres", True),
57+
("postgres", "postgres", False),
58+
],
59+
)
60+
def test_mongodb_required_unless_every_phase_is_postgres(
61+
monkeypatch, state_phase, message_phase, expected
62+
):
63+
monkeypatch.setenv("TASK_STATE_STORAGE_PHASE", state_phase)
64+
monkeypatch.setenv("TASK_MESSAGE_STORAGE_PHASE", message_phase)
65+
66+
env = EnvironmentVariables.refresh(force_refresh=True)
67+
68+
assert env.mongodb_required is expected
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
from types import SimpleNamespace
2+
from typing import get_args
3+
from unittest.mock import MagicMock
4+
5+
import pytest
6+
from src.config.environment_variables import EnvironmentVariables
7+
from src.domain.repositories import task_state_repository as selector_module
8+
from src.domain.repositories.task_state_repository import (
9+
DTaskStateRepository,
10+
TaskStateRepository,
11+
get_task_state_repository,
12+
)
13+
14+
15+
@pytest.fixture(autouse=True)
16+
def _reset_env_cache():
17+
"""Drop the forced-refresh cache so later tests re-read a clean environment."""
18+
yield
19+
EnvironmentVariables.clear_cache()
20+
21+
22+
def _set_phase(monkeypatch, phase: str | None):
23+
if phase is None:
24+
monkeypatch.delenv("TASK_STATE_STORAGE_PHASE", raising=False)
25+
else:
26+
monkeypatch.setenv("TASK_STATE_STORAGE_PHASE", phase)
27+
EnvironmentVariables.refresh(force_refresh=True)
28+
29+
30+
@pytest.mark.unit
31+
@pytest.mark.parametrize("phase", [None, "mongodb"])
32+
def test_selector_returns_mongo_repository_for_mongodb_phase(monkeypatch, phase):
33+
_set_phase(monkeypatch, phase)
34+
mongo_db = MagicMock()
35+
monkeypatch.setattr(
36+
selector_module,
37+
"GlobalDependencies",
38+
lambda: SimpleNamespace(mongodb_database=mongo_db),
39+
)
40+
41+
repository = get_task_state_repository()
42+
43+
assert isinstance(repository, TaskStateRepository)
44+
assert repository.db is mongo_db
45+
46+
47+
@pytest.mark.unit
48+
def test_selector_rejects_unimplemented_phases_lazily(monkeypatch):
49+
"""Unimplemented phases fail loud, and without touching any Mongo wiring:
50+
the mongodb branch must be the only one that needs a Mongo handle."""
51+
phase = "postgres"
52+
_set_phase(monkeypatch, phase)
53+
global_dependencies = MagicMock(
54+
side_effect=AssertionError("selector must not touch Mongo wiring")
55+
)
56+
monkeypatch.setattr(selector_module, "GlobalDependencies", global_dependencies)
57+
58+
with pytest.raises(NotImplementedError, match=phase):
59+
get_task_state_repository()
60+
61+
global_dependencies.assert_not_called()
62+
63+
64+
@pytest.mark.unit
65+
def test_di_seam_resolves_through_selector():
66+
"""DTaskStateRepository is the seam every consumer (use case, authorization
67+
shortcuts, services) resolves; it must point at the phase selector."""
68+
depends_marker = get_args(DTaskStateRepository)[1]
69+
assert depends_marker.dependency is get_task_state_repository

0 commit comments

Comments
 (0)