🧱 refactor(bootstrap): separar init_db do startup

Extrai o bootstrap de banco e seed para uma rotina dedicada, mantendo init_db apenas como alias legado de compatibilidade para evitar quebra nos fluxos existentes.

Remove o bootstrap automatico do startup do app HTTP e do container principal, deixando o processo de atendimento responsavel apenas por subir a aplicacao e nao por preparar schema ou popular dados.

Alinha compose, exemplos de systemd, documentacao e testes para o novo fluxo explicito de bootstrap, com a suite completa validada em 211 testes.
main
parent a5b28182d9
commit be4992f9c6

@ -96,18 +96,24 @@ Antes de ativar o servico, rode uma inicializacao manual para validar banco e se
```bash ```bash
cd /opt/orquestrador cd /opt/orquestrador
source venv/bin/activate source venv/bin/activate
python -m app.db.init_db python -m app.db.bootstrap
``` ```
## 7) Configurar `systemd` ## 7) Configurar `systemd`
Copie o template: Copie o template principal:
```bash ```bash
sudo cp deploy/systemd/orquestrador.service.example /etc/systemd/system/orquestrador.service sudo cp deploy/systemd/orquestrador.service.example /etc/systemd/system/orquestrador.service
sudo nano /etc/systemd/system/orquestrador.service sudo nano /etc/systemd/system/orquestrador.service
``` ```
Se quiser um atalho explicito para bootstrap manual via `systemd`, existe tambem o template:
```bash
sudo cp deploy/systemd/orquestrador-bootstrap.service.example /etc/systemd/system/orquestrador-bootstrap.service
```
Depois recarregue e inicie: Depois recarregue e inicie:
```bash ```bash
@ -143,6 +149,9 @@ cd /opt/orquestrador
git pull origin main git pull origin main
source venv/bin/activate source venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
# rode o bootstrap apenas quando houver mudanca de schema/seed
python -m app.db.bootstrap
sudo systemctl restart orquestrador sudo systemctl restart orquestrador
sudo systemctl status orquestrador sudo systemctl status orquestrador
``` ```

@ -29,5 +29,5 @@ COPY app /app/app
ENV PATH=/root/.local/bin:$PATH ENV PATH=/root/.local/bin:$PATH
# Sobe o bootstrap de banco e inicia o satelite do Telegram. # Inicia apenas o servico principal; bootstrap de banco e seed sao rotinas explicitas.
CMD ["sh", "-c", "python -m app.db.init_db && python -m app.integrations.telegram_satellite_service"] CMD ["python", "-m", "app.integrations.telegram_satellite_service"]

@ -156,17 +156,27 @@ O projeto usa duas conexoes MySQL:
- banco de tools - banco de tools
- banco mock de negocio - banco mock de negocio
O bootstrap atual cria tabelas e executa seed por meio de: O bootstrap agora e uma rotina dedicada e explicita:
- [app/db/init_db.py](/d:/vitor/Pessoal/PJ/Orquestrador/app/db/init_db.py) - [app/db/bootstrap.py](/d:/vitor/Pessoal/PJ/Orquestrador/app/db/bootstrap.py)
- [app/db/init_db.py](/d:/vitor/Pessoal/PJ/Orquestrador/app/db/init_db.py) como alias legado de compatibilidade
Esse bootstrap e usado no container e pode ser executado manualmente antes do servico principal. Importante:
- o container principal nao executa bootstrap automaticamente;
- o app HTTP legado nao executa bootstrap no startup;
- a preparacao de schema e seed deve ser rodada de forma explicita antes do servico principal quando necessario.
## Execucao Local ## Execucao Local
### Sem Docker ### Sem Docker
1. Configure as variaveis de ambiente com base em `.env.example`. 1. Configure as variaveis de ambiente com base em `.env.example`.
2. Inicialize banco e seed: 2. Inicialize banco e seed com a rotina dedicada:
```bash
python -m app.db.bootstrap
```
Alias legado ainda aceito:
```bash ```bash
python -m app.db.init_db python -m app.db.init_db
@ -184,8 +194,15 @@ O compose atual sobe:
- `mysql` - `mysql`
- `redis` - `redis`
- `telegram` - `telegram`
- `bootstrap` como rotina opcional e dedicada via profile
Preparar banco e seed:
```bash
docker compose --profile bootstrap run --rm bootstrap
```
Subida completa: Subida completa do atendimento:
```bash ```bash
docker compose up --build docker compose up --build
@ -253,13 +270,19 @@ Observacao:
## Docker ## Docker
O [Dockerfile](/d:/vitor/Pessoal/PJ/Orquestrador/Dockerfile) hoje sobe o servico principal do projeto: O [Dockerfile](/d:/vitor/Pessoal/PJ/Orquestrador/Dockerfile) agora sobe apenas o servico principal do projeto:
```bash
python -m app.integrations.telegram_satellite_service
```
O bootstrap fica separado e pode ser executado quando necessario com:
```bash ```bash
python -m app.db.init_db && python -m app.integrations.telegram_satellite_service python -m app.db.bootstrap
``` ```
Isso deixa o container alinhado com o uso atual do sistema, sem assumir FastAPI como interface principal. Isso evita que um restart do container recrie schema ou rode seed de forma implicita.
## Testes ## Testes
@ -285,7 +308,9 @@ DEBUG=false python -m unittest discover -s tests -v
Os proximos ganhos mais valiosos para o projeto sao: Os proximos ganhos mais valiosos para o projeto sao:
- persistir trilha de conversa e decisoes - persistir trilha de conversa e decisoes
- desacoplar bootstrap de banco do startup da aplicacao - consolidar observabilidade por turno e por tool com baixo acoplamento operacional
- aumentar observabilidade por turno e por tool - aumentar observabilidade por turno e por tool
- reduzir o tamanho do `OrquestradorService` - reduzir o tamanho do `OrquestradorService`
- consolidar documentacao operacional Telegram-first - consolidar documentacao operacional Telegram-first

@ -0,0 +1,81 @@
"""
Rotina dedicada de bootstrap de banco de dados.
Cria tabelas e executa seed inicial de forma explicita, fora do startup do app.
"""
from app.core.settings import settings
from app.db.database import Base, engine
from app.db.mock_database import MockBase, mock_engine
from app.db.models import Tool
from app.db.mock_models import (
ConversationTurn,
Customer,
Order,
RentalContract,
RentalFine,
RentalPayment,
RentalVehicle,
ReviewSchedule,
Vehicle,
)
from app.db.mock_seed import seed_mock_data
from app.db.tool_seed import seed_tools
def bootstrap_databases(
*,
run_tools_seed: bool | None = None,
run_mock_seed: bool | None = None,
) -> None:
"""Cria tabelas e executa seed inicial em ambos os bancos."""
print("Inicializando bancos...")
failures: list[str] = []
should_seed_tools = settings.auto_seed_tools if run_tools_seed is None else bool(run_tools_seed)
should_seed_mock = (
settings.auto_seed_mock and settings.mock_seed_enabled
if run_mock_seed is None
else bool(run_mock_seed)
)
try:
print("Criando tabelas MySQL (tools)...")
Base.metadata.create_all(bind=engine)
if should_seed_tools:
print("Populando tools iniciais...")
seed_tools()
else:
print("Seed de tools desabilitada por configuracao.")
print("MySQL tools OK.")
except Exception as exc:
print(f"Aviso: falha no MySQL (tools): {exc}")
failures.append(f"tools={exc}")
try:
print("Criando tabelas MySQL (dados ficticios)...")
MockBase.metadata.create_all(bind=mock_engine)
if should_seed_mock:
print("Populando dados ficticios iniciais...")
seed_mock_data()
else:
print("Seed mock desabilitada por configuracao.")
print("MySQL mock OK.")
except Exception as exc:
print(f"Aviso: falha no MySQL mock: {exc}")
failures.append(f"mock={exc}")
if failures:
raise RuntimeError(
"Falha ao inicializar bancos do orquestrador: " + " | ".join(failures)
)
print("Bancos inicializados com sucesso!")
def main() -> None:
"""Executa o bootstrap dedicado quando chamado via modulo."""
bootstrap_databases()
if __name__ == "__main__":
main()

@ -1,64 +1,14 @@
""" """
Inicializacao de banco de dados. Compatibilidade para bootstrap legado.
Cria tabelas e executa seed inicial em ambos os bancos. Mantem o comando historico `python -m app.db.init_db` delegando para a rotina dedicada.
""" """
from app.core.settings import settings from app.db.bootstrap import bootstrap_databases
from app.db.database import Base, engine
from app.db.mock_database import MockBase, mock_engine
from app.db.models import Tool
from app.db.mock_models import (
ConversationTurn,
Customer,
Order,
RentalContract,
RentalFine,
RentalPayment,
RentalVehicle,
ReviewSchedule,
Vehicle,
)
from app.db.mock_seed import seed_mock_data
from app.db.tool_seed import seed_tools
def init_db(): def init_db() -> None:
"""Cria tabelas e executa seed inicial em ambos os bancos.""" """Mantem compatibilidade com o nome legado do bootstrap."""
print("Inicializando bancos...") bootstrap_databases()
failures: list[str] = []
try:
print("Criando tabelas MySQL (tools)...")
Base.metadata.create_all(bind=engine)
if settings.auto_seed_tools:
print("Populando tools iniciais...")
seed_tools()
else:
print("Seed de tools desabilitada por configuracao.")
print("MySQL tools OK.")
except Exception as exc:
print(f"Aviso: falha no MySQL (tools): {exc}")
failures.append(f"tools={exc}")
try:
print("Criando tabelas MySQL (dados ficticios)...")
MockBase.metadata.create_all(bind=mock_engine)
if settings.auto_seed_mock and settings.mock_seed_enabled:
print("Populando dados ficticios iniciais...")
seed_mock_data()
else:
print("Seed mock desabilitada por configuracao.")
print("MySQL mock OK.")
except Exception as exc:
print(f"Aviso: falha no MySQL mock: {exc}")
failures.append(f"mock={exc}")
if failures:
raise RuntimeError(
"Falha ao inicializar bancos do orquestrador: " + " | ".join(failures)
)
print("Bancos inicializados com sucesso!")
if __name__ == "__main__": if __name__ == "__main__":

@ -1,6 +1,5 @@
from fastapi import FastAPI from fastapi import FastAPI
from app.db.init_db import init_db
from app.services.ai.llm_service import LLMService from app.services.ai.llm_service import LLMService
app = FastAPI(title="AI Orquestrador") app = FastAPI(title="AI Orquestrador")
@ -9,15 +8,13 @@ app = FastAPI(title="AI Orquestrador")
@app.on_event("startup") @app.on_event("startup")
async def startup_event(): async def startup_event():
""" """
Inicializa o banco de dados e executa seeds automaticamente. Realiza apenas inicializacao leve do app HTTP legado.
Bootstrap de banco e seed agora sao operacoes explicitas e separadas.
""" """
print("[Startup] Iniciando bootstrap legado do app HTTP...")
init_db()
try: try:
await LLMService().warmup() await LLMService().warmup()
print("[Startup] LLM warmup concluido.") print("[Startup] LLM warmup concluido.")
except Exception as e: except Exception as e:
print(f"[Startup] Aviso: falha no warmup do LLM: {e}") print(f"[Startup] Aviso: falha no warmup do LLM: {e}")
print("[Startup] App HTTP legado inicializado.") print("[Startup] App HTTP legado inicializado sem bootstrap automatico.")

@ -0,0 +1,15 @@
[Unit]
Description=AI Orquestrador Database Bootstrap
After=network.target
[Service]
Type=oneshot
User=vitor
Group=vitor
WorkingDirectory=/opt/orquestrador
EnvironmentFile=/opt/orquestrador/.env.prod
Environment=PATH=/opt/orquestrador/venv/bin
ExecStart=/opt/orquestrador/venv/bin/python -m app.db.bootstrap
[Install]
WantedBy=multi-user.target

@ -9,7 +9,7 @@ Group=vitor
WorkingDirectory=/opt/orquestrador WorkingDirectory=/opt/orquestrador
EnvironmentFile=/opt/orquestrador/.env.prod EnvironmentFile=/opt/orquestrador/.env.prod
Environment=PATH=/opt/orquestrador/venv/bin Environment=PATH=/opt/orquestrador/venv/bin
ExecStart=/bin/sh -c '/opt/orquestrador/venv/bin/python -m app.db.init_db && /opt/orquestrador/venv/bin/python -m app.integrations.telegram_satellite_service' ExecStart=/opt/orquestrador/venv/bin/python -m app.integrations.telegram_satellite_service
Restart=always Restart=always
RestartSec=5 RestartSec=5

@ -1,3 +1,31 @@
x-orquestrador-env: &orquestrador-env
GOOGLE_PROJECT_ID: ${GOOGLE_PROJECT_ID:-local-dev}
GOOGLE_LOCATION: ${GOOGLE_LOCATION:-us-central1}
VERTEX_MODEL_NAME: ${VERTEX_MODEL_NAME:-gemini-2.5-pro}
ENVIRONMENT: ${ENVIRONMENT:-development}
DEBUG: ${ORQUESTRADOR_DEBUG:-false}
DB_HOST: mysql
DB_PORT: 3306
DB_USER: root
DB_PASSWORD: root
DB_NAME: orquestrador_mock
MOCK_DB_HOST: mysql
MOCK_DB_PORT: 3306
MOCK_DB_USER: root
MOCK_DB_PASSWORD: root
MOCK_DB_NAME: orquestrador_mock
AUTO_SEED_TOOLS: ${AUTO_SEED_TOOLS:-true}
AUTO_SEED_MOCK: ${AUTO_SEED_MOCK:-true}
MOCK_SEED_ENABLED: ${MOCK_SEED_ENABLED:-true}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_POLLING_TIMEOUT: ${TELEGRAM_POLLING_TIMEOUT:-30}
TELEGRAM_REQUEST_TIMEOUT: ${TELEGRAM_REQUEST_TIMEOUT:-45}
CONVERSATION_STATE_BACKEND: ${CONVERSATION_STATE_BACKEND:-redis}
CONVERSATION_STATE_TTL_MINUTES: ${CONVERSATION_STATE_TTL_MINUTES:-60}
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
REDIS_KEY_PREFIX: ${REDIS_KEY_PREFIX:-orquestrador}
REDIS_SOCKET_TIMEOUT_SECONDS: ${REDIS_SOCKET_TIMEOUT_SECONDS:-5}
services: services:
mysql: mysql:
image: mysql:8.4 image: mysql:8.4
@ -30,33 +58,7 @@ services:
telegram: telegram:
build: . build: .
container_name: orquestrador_telegram container_name: orquestrador_telegram
environment: environment: *orquestrador-env
GOOGLE_PROJECT_ID: ${GOOGLE_PROJECT_ID:-local-dev}
GOOGLE_LOCATION: ${GOOGLE_LOCATION:-us-central1}
VERTEX_MODEL_NAME: ${VERTEX_MODEL_NAME:-gemini-2.5-pro}
ENVIRONMENT: ${ENVIRONMENT:-development}
DEBUG: ${ORQUESTRADOR_DEBUG:-false}
DB_HOST: mysql
DB_PORT: 3306
DB_USER: root
DB_PASSWORD: root
DB_NAME: orquestrador_mock
MOCK_DB_HOST: mysql
MOCK_DB_PORT: 3306
MOCK_DB_USER: root
MOCK_DB_PASSWORD: root
MOCK_DB_NAME: orquestrador_mock
AUTO_SEED_TOOLS: ${AUTO_SEED_TOOLS:-true}
AUTO_SEED_MOCK: ${AUTO_SEED_MOCK:-true}
MOCK_SEED_ENABLED: ${MOCK_SEED_ENABLED:-true}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_POLLING_TIMEOUT: ${TELEGRAM_POLLING_TIMEOUT:-30}
TELEGRAM_REQUEST_TIMEOUT: ${TELEGRAM_REQUEST_TIMEOUT:-45}
CONVERSATION_STATE_BACKEND: ${CONVERSATION_STATE_BACKEND:-redis}
CONVERSATION_STATE_TTL_MINUTES: ${CONVERSATION_STATE_TTL_MINUTES:-60}
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
REDIS_KEY_PREFIX: ${REDIS_KEY_PREFIX:-orquestrador}
REDIS_SOCKET_TIMEOUT_SECONDS: ${REDIS_SOCKET_TIMEOUT_SECONDS:-5}
depends_on: depends_on:
mysql: mysql:
condition: service_healthy condition: service_healthy
@ -64,5 +66,15 @@ services:
condition: service_healthy condition: service_healthy
restart: unless-stopped restart: unless-stopped
bootstrap:
build: .
profiles: ["bootstrap"]
environment: *orquestrador-env
command: ["python", "-m", "app.db.bootstrap"]
depends_on:
mysql:
condition: service_healthy
restart: "no"
volumes: volumes:
mysql_data: mysql_data:

@ -1,7 +1,9 @@
import unittest import unittest
from unittest.mock import patch from unittest.mock import AsyncMock, patch
from app import main as main_module
from app.core.settings import Settings from app.core.settings import Settings
from app.db import bootstrap as bootstrap_module
from app.db import init_db as init_db_module from app.db import init_db as init_db_module
@ -25,35 +27,35 @@ class SettingsParsingTests(unittest.TestCase):
self.assertEqual(settings.conversation_state_backend, "redis") self.assertEqual(settings.conversation_state_backend, "redis")
class InitDbBootstrapTests(unittest.TestCase): class BootstrapRuntimeTests(unittest.TestCase):
@patch.object(init_db_module, "seed_tools") @patch.object(bootstrap_module, "seed_tools")
@patch.object(init_db_module, "seed_mock_data") @patch.object(bootstrap_module, "seed_mock_data")
@patch.object(init_db_module.MockBase.metadata, "create_all") @patch.object(bootstrap_module.MockBase.metadata, "create_all")
@patch.object(init_db_module.Base.metadata, "create_all") @patch.object(bootstrap_module.Base.metadata, "create_all")
def test_init_db_respects_seed_flags( def test_bootstrap_databases_respects_seed_flags(
self, self,
tools_create_all, tools_create_all,
mock_create_all, mock_create_all,
seed_mock_data, seed_mock_data,
seed_tools, seed_tools,
): ):
with patch.object(init_db_module.settings, "auto_seed_tools", False), patch.object( with patch.object(bootstrap_module.settings, "auto_seed_tools", False), patch.object(
init_db_module.settings, bootstrap_module.settings,
"auto_seed_mock", "auto_seed_mock",
False, False,
), patch.object(init_db_module.settings, "mock_seed_enabled", True): ), patch.object(bootstrap_module.settings, "mock_seed_enabled", True):
init_db_module.init_db() bootstrap_module.bootstrap_databases()
tools_create_all.assert_called_once() tools_create_all.assert_called_once()
mock_create_all.assert_called_once() mock_create_all.assert_called_once()
seed_tools.assert_not_called() seed_tools.assert_not_called()
seed_mock_data.assert_not_called() seed_mock_data.assert_not_called()
@patch.object(init_db_module, "seed_tools") @patch.object(bootstrap_module, "seed_tools")
@patch.object(init_db_module, "seed_mock_data") @patch.object(bootstrap_module, "seed_mock_data")
@patch.object(init_db_module.MockBase.metadata, "create_all") @patch.object(bootstrap_module.MockBase.metadata, "create_all")
@patch.object(init_db_module.Base.metadata, "create_all", side_effect=RuntimeError("tools db down")) @patch.object(bootstrap_module.Base.metadata, "create_all", side_effect=RuntimeError("tools db down"))
def test_init_db_raises_when_any_backend_fails( def test_bootstrap_databases_raises_when_any_backend_fails(
self, self,
tools_create_all, tools_create_all,
mock_create_all, mock_create_all,
@ -61,13 +63,31 @@ class InitDbBootstrapTests(unittest.TestCase):
seed_tools, seed_tools,
): ):
with self.assertRaisesRegex(RuntimeError, "tools=tools db down"): with self.assertRaisesRegex(RuntimeError, "tools=tools db down"):
init_db_module.init_db() bootstrap_module.bootstrap_databases()
tools_create_all.assert_called_once() tools_create_all.assert_called_once()
mock_create_all.assert_called_once() mock_create_all.assert_called_once()
seed_mock_data.assert_called_once() seed_mock_data.assert_called_once()
seed_tools.assert_not_called() seed_tools.assert_not_called()
@patch.object(init_db_module, "bootstrap_databases")
def test_init_db_wrapper_delegates_to_bootstrap_databases(self, bootstrap_databases):
init_db_module.init_db()
bootstrap_databases.assert_called_once_with()
class HttpStartupTests(unittest.IsolatedAsyncioTestCase):
async def test_startup_event_warms_llm_without_running_bootstrap(self):
with patch("app.main.LLMService") as llm_cls, patch(
"app.db.bootstrap.bootstrap_databases"
) as bootstrap_databases:
llm_cls.return_value.warmup = AsyncMock()
await main_module.startup_event()
llm_cls.return_value.warmup.assert_awaited_once()
bootstrap_databases.assert_not_called()
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

Loading…
Cancel
Save