Repository navigation
feat: 6 critical Kafka→DB projection nodes [OMN-7610] #4
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
18eda14
b05d30e
a0547f1
a556b9d
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 |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| FROM python:3.12-slim | ||
|
|
||
| WORKDIR /app | ||
|
|
||
| # Install uv for fast dependency resolution | ||
| RUN pip install --no-cache-dir uv | ||
|
|
||
| # Copy dependency file first for layer caching | ||
| COPY pyproject.toml ./ | ||
| COPY src/ ./src/ | ||
|
|
||
| # Install the package with projection dependencies | ||
| RUN uv pip install --system --no-cache ".[projection]" 2>/dev/null || \ | ||
| uv pip install --system --no-cache aiokafka asyncpg python-dateutil && \ | ||
| uv pip install --system --no-cache -e . | ||
|
|
||
| # Default: overridden by docker-compose command | ||
| CMD ["python", "-m", "omnimarket"] | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,62 @@ | ||||||||||||||||||||||
| ############################################################################### | ||||||||||||||||||||||
| # docker-compose.projection.yml — OMN-7610 projection nodes | ||||||||||||||||||||||
| # | ||||||||||||||||||||||
| # Deploys all 6 critical Kafka→DB projection consumers as independent | ||||||||||||||||||||||
| # Docker services on .201 server. Each service is a standalone Python | ||||||||||||||||||||||
| # process consuming from its Kafka topic(s) and projecting into the | ||||||||||||||||||||||
| # omnidash_analytics PostgreSQL database. | ||||||||||||||||||||||
| # | ||||||||||||||||||||||
| # Usage: | ||||||||||||||||||||||
| # docker compose -f docker-compose.projection.yml up -d | ||||||||||||||||||||||
| # docker compose -f docker-compose.projection.yml logs -f | ||||||||||||||||||||||
| # docker compose -f docker-compose.projection.yml down | ||||||||||||||||||||||
| ############################################################################### | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| x-projection-defaults: &projection-defaults | ||||||||||||||||||||||
| build: | ||||||||||||||||||||||
| context: . | ||||||||||||||||||||||
| dockerfile: Dockerfile.projection | ||||||||||||||||||||||
| restart: unless-stopped | ||||||||||||||||||||||
| network_mode: host | ||||||||||||||||||||||
| env_file: .env | ||||||||||||||||||||||
| environment: | ||||||||||||||||||||||
| KAFKA_BROKERS: ${KAFKA_BROKERS:-192.168.86.201:19092} | ||||||||||||||||||||||
| OMNIDASH_ANALYTICS_DB_URL: ${OMNIDASH_ANALYTICS_DB_URL} | ||||||||||||||||||||||
| KAFKA_CONSUMER_GROUP: ${KAFKA_CONSUMER_GROUP:-omnimarket-projections-v1} | ||||||||||||||||||||||
| PYTHONUNBUFFERED: "1" | ||||||||||||||||||||||
|
Comment on lines
+22
to
+26
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.
Unlike 🛠️ Consider adding a placeholder or documentationEither add a default that fails clearly: - OMNIDASH_ANALYTICS_DB_URL: ${OMNIDASH_ANALYTICS_DB_URL}
+ OMNIDASH_ANALYTICS_DB_URL: ${OMNIDASH_ANALYTICS_DB_URL:?OMNIDASH_ANALYTICS_DB_URL must be set in .env}Or document the required variable in the file header comments. 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
| logging: | ||||||||||||||||||||||
| driver: json-file | ||||||||||||||||||||||
| options: | ||||||||||||||||||||||
| max-size: "10m" | ||||||||||||||||||||||
| max-file: "3" | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| services: | ||||||||||||||||||||||
| projection-session-outcome: | ||||||||||||||||||||||
| <<: *projection-defaults | ||||||||||||||||||||||
| container_name: omnimarket-projection-session-outcome | ||||||||||||||||||||||
| command: ["python", "-m", "omnimarket.nodes.node_projection_session_outcome.handlers.handler_session_outcome"] | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| projection-llm-cost: | ||||||||||||||||||||||
| <<: *projection-defaults | ||||||||||||||||||||||
| container_name: omnimarket-projection-llm-cost | ||||||||||||||||||||||
| command: ["python", "-m", "omnimarket.nodes.node_projection_llm_cost.handlers.handler_llm_cost"] | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| projection-savings: | ||||||||||||||||||||||
| <<: *projection-defaults | ||||||||||||||||||||||
| container_name: omnimarket-projection-savings | ||||||||||||||||||||||
| command: ["python", "-m", "omnimarket.nodes.node_projection_savings.handlers.handler_savings"] | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| projection-delegation: | ||||||||||||||||||||||
| <<: *projection-defaults | ||||||||||||||||||||||
| container_name: omnimarket-projection-delegation | ||||||||||||||||||||||
| command: ["python", "-m", "omnimarket.nodes.node_projection_delegation.handlers.handler_delegation"] | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| projection-baselines: | ||||||||||||||||||||||
| <<: *projection-defaults | ||||||||||||||||||||||
| container_name: omnimarket-projection-baselines | ||||||||||||||||||||||
| command: ["python", "-m", "omnimarket.nodes.node_projection_baselines.handlers.handler_baselines"] | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| projection-registration: | ||||||||||||||||||||||
| <<: *projection-defaults | ||||||||||||||||||||||
| container_name: omnimarket-projection-registration | ||||||||||||||||||||||
| command: ["python", "-m", "omnimarket.nodes.node_projection_registration.handlers.handler_registration"] | ||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| """Asyncpg implementation of DatabaseAdapter.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import logging | ||
| import os | ||
| from typing import Any | ||
|
|
||
| import asyncpg # type: ignore[import-untyped] | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| DB_URL_ENV = "OMNIDASH_ANALYTICS_DB_URL" | ||
|
|
||
|
|
||
| class AsyncpgAdapter: | ||
| """DatabaseAdapter backed by asyncpg connection pool.""" | ||
|
|
||
| def __init__( | ||
| self, dsn: str | None = None, min_size: int = 2, max_size: int = 10 | ||
| ) -> None: | ||
| self._dsn = dsn or os.environ.get(DB_URL_ENV, "") | ||
| self._min_size = min_size | ||
| self._max_size = max_size | ||
| self._pool: asyncpg.Pool | None = None | ||
|
|
||
| async def connect(self) -> None: | ||
| if not self._dsn: | ||
| raise RuntimeError(f"{DB_URL_ENV} not set and no DSN provided") | ||
| self._pool = await asyncpg.create_pool( | ||
| self._dsn, | ||
| min_size=self._min_size, | ||
| max_size=self._max_size, | ||
| command_timeout=30, | ||
| ) | ||
| logger.info( | ||
| "asyncpg pool connected (min=%d, max=%d)", self._min_size, self._max_size | ||
| ) | ||
|
|
||
| async def execute(self, query: str, *params: Any) -> list[dict[str, Any]]: | ||
| assert self._pool is not None, "call connect() first" | ||
| async with self._pool.acquire() as conn: | ||
| rows = await conn.fetch(query, *params) | ||
|
Comment on lines
+40
to
+43
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. 🧩 Analysis chain🏁 Script executed: cat -n src/omnimarket/adapters/asyncpg_adapter.py | sed -n '30,65p'Repository: OmniNode-ai/omnimarket Length of output: 1883 Replace
Suggested fix async def execute(self, query: str, *params: Any) -> list[dict[str, Any]]:
- assert self._pool is not None, "call connect() first"
+ if self._pool is None:
+ raise RuntimeError("call connect() first")
async with self._pool.acquire() as conn:
rows = await conn.fetch(query, *params)
return [dict(r) for r in rows]
async def execute_many(self, query: str, params_list: list[tuple[Any, ...]]) -> None:
- assert self._pool is not None, "call connect() first"
+ if self._pool is None:
+ raise RuntimeError("call connect() first")
async with self._pool.acquire() as conn:
await conn.executemany(query, params_list)
async def fetchval(self, query: str, *params: Any) -> Any:
- assert self._pool is not None, "call connect() first"
+ if self._pool is None:
+ raise RuntimeError("call connect() first")
async with self._pool.acquire() as conn:
return await conn.fetchval(query, *params)
async def execute_in_transaction(self, queries: list[tuple[str, tuple[Any, ...]]]) -> None:
"""Execute multiple queries in a single transaction."""
- assert self._pool is not None, "call connect() first"
+ if self._pool is None:
+ raise RuntimeError("call connect() first")
async with self._pool.acquire() as conn:
async with conn.transaction():
for query, params in queries:
await conn.execute(query, *params)Also applies to: 42-45, 47-50, 52-58 🤖 Prompt for AI Agents |
||
| return [dict(r) for r in rows] | ||
|
|
||
| async def execute_many( | ||
| self, query: str, params_list: list[tuple[Any, ...]] | ||
| ) -> None: | ||
| assert self._pool is not None, "call connect() first" | ||
| async with self._pool.acquire() as conn: | ||
| await conn.executemany(query, params_list) | ||
|
|
||
| async def fetchval(self, query: str, *params: Any) -> Any: | ||
| assert self._pool is not None, "call connect() first" | ||
| async with self._pool.acquire() as conn: | ||
| return await conn.fetchval(query, *params) | ||
|
|
||
| async def execute_in_transaction( | ||
| self, queries: list[tuple[str, tuple[Any, ...]]] | ||
| ) -> None: | ||
| """Execute multiple queries in a single transaction.""" | ||
| assert self._pool is not None, "call connect() first" | ||
| async with self._pool.acquire() as conn, conn.transaction(): | ||
| for query, params in queries: | ||
| await conn.execute(query, *params) | ||
|
|
||
| async def close(self) -> None: | ||
| if self._pool: | ||
| await self._pool.close() | ||
| self._pool = None | ||
| logger.info("asyncpg pool closed") | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| """DatabaseAdapter protocol for projection nodes.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from typing import Any, Protocol | ||
|
|
||
|
|
||
| class DatabaseAdapter(Protocol): | ||
| """Async database adapter for projection handlers.""" | ||
|
|
||
| async def execute(self, query: str, *params: Any) -> list[dict[str, Any]]: | ||
| """Execute a parameterized query and return rows as dicts.""" | ||
| ... | ||
|
|
||
| async def execute_many( | ||
| self, query: str, params_list: list[tuple[Any, ...]] | ||
| ) -> None: | ||
| """Execute a parameterized query for each set of params.""" | ||
| ... | ||
|
|
||
| async def fetchval(self, query: str, *params: Any) -> Any: | ||
| """Execute a query and return a single value.""" | ||
| ... | ||
|
|
||
| async def close(self) -> None: | ||
| """Close the connection pool.""" | ||
| ... |
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.
The
.[projection]install will always fail — no such optional group exists.The
pyproject.tomldeclaresaiokafka,asyncpg, andpython-dateutilas main dependencies (not in an optional[project.optional-dependencies]group). This means:uv pip install '.[projection]'will always fail (silently, due to2>/dev/null)-e .(editable install) in a production image is unusual and increases image size🛠️ Suggested fix: Install directly without the phantom optional group
Alternatively, if you want a separate optional group for projection-only deployments, add to
pyproject.toml:Then remove these from main
dependenciesto keep the base package lighter.🤖 Prompt for AI Agents