- Add remote translator worker (Docker) that connects via WebSocket - Add backend Go handlers for remote worker CRUD (REST + WebSocket) - Add frontend Admin panel for managing remote workers - Add ProtectedRoute component to secure admin routes - Add PostgreSQL schema for remote_workers table - Fix duplicate Load function in config.go - Fix frontend duplicate fetchStatus function - Fix Dockerfiles missing go.sum (use go mod download + tidy) Features: - Workers connect to server via WebSocket (no ports needed) - API key authentication per worker - Job assigner automatically distributes translation jobs - Worker status tracking (online/offline/disabled) - Timeout handling for stuck jobs
22 KiB
Plan: Sistema de Workers Remotos para Traducción
Visión General
Sistema que permite conectar workers de traducción desde equipos externos (con GPU) al servidor central mediante WebSockets, eliminando la necesidad de exponer el servidor a internet y permitiendo procesamiento distribuido.
Arquitectura del Sistema
┌─────────────────────────────────────────────────────────────────────────────┐
│ SERVidor VPS │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
│ │ API REST │ │ WebSocket │ │ Scheduler │ │ PostgreSQL │ │
│ │ (Go) │◄─┤ Server │ │ (Go) │ │ (Cola Jobs) │ │
│ │ │ │ (Go) │ │ │ │ │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────────┘ │
│ │ │ │ │
│ └────────────────┼────────────────┘ │
│ │ │
│ PUERTO 80/443 (ya expuesto) │
└─────────────────────────────────────────────────────────────────────────────┘
▲
│ WebSocket (conexión saliente)
│ API Key autenticación
│
┌─────────────────────────────────────────────────────────────────────────────┐
│ EQUIPO LOCAL CON GPU │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Worker Client (Go) │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │ │
│ │ │ WS Client │ │ Lógica │ │ CTranslate2 (subproc) │ │ │
│ │ │ (Go) │ │ (Go) │ │ + Modelo NLLB-1.3B │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ Conexión saliente (no requiere puertos abiertos) │
└─────────────────────────────────────────────────────────────────────────────┘
Flujo de Datos
- Scheduler (servidor) crea jobs en
traduccionescon statuspending - Worker remoto se conecta por WS, envía capabilities (
gpu/cpu) - Servidor asigna job disponible que coincida con capabilities → status
assigned - Worker recibe job → traduce con NLLB-1.3B → envía resultado por WS
- Servidor actualiza
traduccionescon resultado → statuscompleted
Análisis de la Aplicación Actual
Componentes Relevantes Identificados
| Componente | Ubicación | Función |
|---|---|---|
| Handlers admin | backend/internal/handlers/admin.go |
API workers locales (Docker) |
| Modelos | backend/internal/models/models.go |
Estructuras de datos |
| Tabla traducciones | init-db/00-complete-schema.sql |
Cola de traducciones |
| Worker Python | workers/ctranslator_worker.py |
Traducción actual |
| Scheduler Python | workers/translation_scheduler.py |
Creador de jobs |
| Frontend Admin | frontend/src/pages/AdminWorkers.tsx |
Panel de control |
Estados Actuales de traducción
La tabla traducciones tiene:
pending- disponibledone- completadoerror- fallido
Problema identificado: No hay estado assigned → varios workers pueden tomar el mismo job.
Query Actual del Worker
# workers/ctranslator_worker.py:361-378
SELECT ... FROM traducciones t
WHERE t.lang_to = %s
AND (t.titulo_trad IS NULL OR t.resumen_trad IS NULL)
AND (t.locked_at IS NULL OR t.locked_at < NOW() - INTERVAL '10 minutes')
ORDER BY n.fecha DESC
LIMIT %s
FOR UPDATE SKIP LOCKED
Usa locked_at como mecanismo de locking, pero no actualiza un status.
Decisiones de Diseño
1. Conexión: WebSocket
- Bidireccional, tiempo real
- El worker solo necesita conexión saliente (como navegación web)
- No requiere exponer puertos adicionales en el VPS
2. Autenticación: API Key
- El servidor genera una API key única por worker
- El worker la envía en cada conexión WS o como header
- Más simple que usuario/password
3. Cola de Trabajos: PostgreSQL
- Persistencia estable (no Redis, para simplificar)
- El servidor escribe resultados (más control que acceso directo del worker)
- Si el worker falla, el servidor puede reasignar el job
4. ML en Worker: CTranslate2
- El worker llama a CTranslate2 como subproceso
- Mismo mecanismo que el worker actual (Docker) pero standalone
- No requiere bindings Go nativos
Problemas Potenciales y Soluciones
| Problema | Severity | Solución |
|---|---|---|
| Worker sin internet | N/A | No es problema: el worker inicia conexión saliente |
| Worker se desconecta durante procesamiento | Alta | Timeout en servidor → job vuelve a pending |
| Job asignado a dos workers | Alta | Nuevo estado assigned + columna worker_id |
| Worker escribe directamente a BD | Media | Enviar resultados por WS → servidor controla escritura |
| Modelo NLLB no disponible localmente | Baja | Descargar modelo convertido previamente |
Plan de Implementación
Fase 1: Base de Datos
Archivo: init-db/35-remote-workers.sql
-- Tabla de workers remotos
CREATE TABLE IF NOT EXISTS remote_workers (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
api_key VARCHAR(64) UNIQUE NOT NULL,
capabilities VARCHAR(50) DEFAULT 'cpu', -- 'cpu' o 'gpu'
status VARCHAR(20) DEFAULT 'offline', -- 'online', 'offline', 'disabled'
last_seen TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
-- Añadir columnas a traducciones para workers remotos
ALTER TABLE traducciones ADD COLUMN IF NOT EXISTS worker_id INTEGER REFERENCES remote_workers(id);
ALTER TABLE traducciones ADD COLUMN IF NOT EXISTS assigned_at TIMESTAMP;
-- Nuevo índice para buscar jobs disponibles por capabilities
CREATE INDEX IF NOT EXISTS idx_traducciones_pending_worker
ON traducciones(lang_to, status, worker_id)
WHERE status = 'pending' AND worker_id IS NULL;
-- Índice para jobs asignados (para cleanup de timeout)
CREATE INDEX IF NOT EXISTS idx_traducciones_assigned_timeout
ON traducciones(assigned_at)
WHERE status = 'assigned' AND assigned_at IS NOT NULL;
Fase 2: Modelos (Backend Go)
Nuevo archivo: backend/internal/models/worker.go
package models
import "time"
// Worker remoto registrado
type RemoteWorker struct {
ID int `json:"id"`
Name string `json:"name"`
APIKey string `json:"api_key,omitempty"` // Solo al crear
Capabilities string `json:"capabilities"` // 'cpu' o 'gpu'
Status string `json:"status"` // 'online', 'offline', 'disabled'
LastSeen time.Time `json:"last_seen"`
CreatedAt time.Time `json:"created_at"`
}
// Job de traducción para worker remoto
type TranslationJob struct {
ID int64 `json:"id"`
NewsID int64 `json:"noticia_id"`
LangFrom string `json:"lang_from"`
LangTo string `json:"lang_to"`
Title string `json:"title"`
Summary string `json:"summary"`
}
// Resultado de traducción enviado por worker
type TranslationResult struct {
JobID int64 `json:"job_id"`
TitleTr string `json:"title_trad"`
SummaryTr string `json:"resumen_trad"`
Error string `json:"error,omitempty"`
}
// Mensaje WebSocket: cliente → servidor
type WSClientMessage struct {
Type string `json:"type"` // "register", "heartbeat", "result"
Capabilities string `json:"capabilities,omitempty"`
APIKey string `json:"api_key,omitempty"`
WorkerID int `json:"worker_id,omitempty"`
Result *TranslationResult `json:"result,omitempty"`
}
// Mensaje WebSocket: servidor → cliente
type WSServerMessage struct {
Type string `json:"type"` // "job", "ack", "error"
Job *TranslationJob `json:"job,omitempty"`
}
Fase 3: Handlers REST (Backend Go)
Nuevo archivo: backend/internal/handlers/remote_worker.go
Endpoints
| Método | Endpoint | Descripción |
|---|---|---|
| POST | /api/admin/workers/remote |
Crear worker (recibe nombre, capabilities) |
| GET | /api/admin/workers/remote |
Listar todos los workers |
| GET | /api/admin/workers/remote/:id |
Ver detalle de un worker |
| DELETE | /api/admin/workers/remote/:id |
Eliminar worker |
| PATCH | /api/admin/workers/remote/:id/toggle |
Habilitar/deshabilitar |
Funciones principales
- CreateRemoteWorker - Genera API key aleatoria (64 chars), guarda en BD
- ListRemoteWorkers - Retorna todos con jobs counts
- GetRemoteWorker - Retorna detalle + estadísticas
- DeleteRemoteWorker - Soft delete (marcar como disabled)
- ToggleRemoteWorker - Enable/disable sin borrar
Fase 4: WebSocket Handler (Backend Go)
Nuevo archivo: backend/internal/handlers/worker_ws.go
// Estructuras requeridas
type WSWorker struct {
conn *websocket.Conn
workerID int
capabilities string
lastHeartbeat time.Time
}
// Conexiónmap[int]*WSWorker // workerID → conexión
var workers = make(map[int]*WSWorker)
var workersByConn = make(map[*websocket.Conn]*WSWorker)
// Handler principal
func HandleWorkerWS(c *gin.Context) {
// 1. Verificar API key en query string o header
apiKey := c.Query("api_key")
if apiKey == "" {
apiKey = c.GetHeader("X-API-Key")
}
// 2. Buscar worker por API key
workerID, err := validateWorkerAPIKey(apiKey)
if err != nil {
c.JSON(401, gin.H{"error": "Invalid API key"})
return
}
// 3. Upgrade a WebSocket
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
// 4. Registrar worker como online
wsWorker := &WSWorker{
conn: conn,
workerID: workerID,
capabilities: getWorkerCapabilities(workerID),
lastHeartbeat: time.Now(),
}
workers[workerID] = wsWorker
workersByConn[conn] = wsWorker
// 5. Update status en BD
updateWorkerStatus(workerID, "online")
// 6. Loop de lectura
go handleWSRead(wsWorker)
}
// Loop de lectura de mensajes del worker
func handleWSRead(wsWorker *WSWorker) {
for {
var msg WSClientMessage
err := wsWorker.conn.ReadJSON(&msg)
if err != nil {
// Worker desconectado
cleanupWorker(wsWorker)
return
}
switch msg.Type {
case "register":
// Registro inicial (capabilities)
wsWorker.capabilities = msg.Capabilities
sendAck(wsWorker, "registered")
case "heartbeat":
wsWorker.lastHeartbeat = time.Now()
sendAck(wsWorker, "ok")
case "result":
// Worker completó un job
handleTranslationResult(wsWorker.workerID, msg.Result)
sendAck(wsWorker, "received")
}
}
}
// Asignar job disponible al worker
func assignJobToWorker(workerID int) *TranslationJob {
caps := getWorkerCapabilities(workerID)
// Buscar job pending que coincida con capabilities del worker
// Por ahora: cualquier job pending funciona (el worker decide qué procesar)
job := getNextPendingJob(caps)
if job == nil {
return nil
}
// Asignar: UPDATE status='assigned', worker_id=workerID, assigned_at=NOW()
assignJob(job.ID, workerID)
return job
}
Fase 5: Scheduler (Migración Python → Go)
Nuevo archivo: backend/internal/workers/translation_scheduler.go
package workers
func StartTranslationScheduler() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
scheduleTranslations()
}
}
}
func scheduleTranslations() {
// Para cada idioma destino (es)
targetLangs := []string{"es"}
for _, lang := range targetLangs {
// INSERT jobs para noticias sin traducción
_, err := db.Exec(`
INSERT INTO traducciones (noticia_id, lang_from, lang_to, status, created_at)
SELECT n.id, n.lang, $1, 'pending', NOW()
FROM noticias n
WHERE n.lang IS NOT NULL
AND TRIM(n.lang) != ''
AND n.lang != $1
AND NOT EXISTS (
SELECT 1 FROM traducciones t
WHERE t.noticia_id = n.id AND t.lang_to = $1
)
ORDER BY n.fecha DESC
LIMIT $2
ON CONFLICT (noticia_id, lang_to) DO NOTHING
`, lang, 2000)
}
}
// Cleanup de jobs huérfanos (assigned hace demasiado tiempo)
func cleanupStaleAssignments() {
db.Exec(`
UPDATE traducciones
SET status = 'pending', worker_id = NULL, assigned_at = NULL
WHERE status = 'assigned'
AND assigned_at < NOW() - INTERVAL '5 minutes'
`)
}
Fase 6: Cliente Worker Remoto (Go)
Nuevo directorio: worker-client/
worker-client/
├── cmd/
│ └── main.go # Entry point
├── internal/
│ ├── config/
│ │ └── config.go # Flags y configuración
│ ├── ws/
│ │ └── client.go # WebSocket client
│ ├── translator/
│ │ └── ctranslate2.go # Llamada a CTranslate2
│ └── store/
│ └── local.go # Persistencia local (SQLite)
├── go.mod
└── README.md
cmd/main.go
func main() {
// Flags
serverURL := flag.String("server", "ws://localhost:8080/ws/worker", "WebSocket server URL")
apiKey := flag.String("api-key", "", "API key for authentication")
device := flag.String("device", "cuda", "Device: cuda or cpu")
modelPath := flag.String("model-path", "./models/nllb-ct2", "CTranslate2 model path")
batchSize := flag.Int("batch", 8, "Translation batch size")
flag.Parse()
// Validar
if *apiKey == "" {
log.Fatal("API key required")
}
// Inicializar
cfg := config.Config{
ServerURL: *serverURL,
APIKey: *apiKey,
Device: *device,
ModelPath: *modelPath,
BatchSize: *batchSize,
}
// Conectar y loop
client := ws.NewClient(&cfg)
client.ConnectAndRun()
}
internal/ws/client.go
func (c *Client) ConnectAndRun() {
for {
err := c.connect()
if err != nil {
log.Printf("Connection failed: %v", err)
time.Sleep(5 * time.Second)
continue
}
// Loop de mensajes
c.readLoop()
}
}
func (c *Client) handleMessage(msg []byte) {
var serverMsg WSServerMessage
if err := json.Unmarshal(msg, &serverMsg); err != nil {
return
}
switch serverMsg.Type {
case "job":
c.processJob(serverMsg.Job)
case "ping":
c.sendHeartbeat()
}
}
func (c *Client) processJob(job *TranslationJob) {
// 1. Traducir título
titleTr := translate(job.Title, job.LangFrom, job.LangTo)
// 2. Traducir resumen (chunks si es largo)
summaryTr := translateLongText(job.Summary, job.LangFrom, job.LangTo)
// 3. Enviar resultado
c.SendResult(job.ID, titleTr, summaryTr, "")
}
internal/translator/ctranslate2.go
func Translate(text, srcLang, tgtLang string) (string, error) {
// Mapear códigos de idioma
srcCode := langMap[srcLang]
tgtCode := langMap[tgtLang]
// Construir comando
cmd := exec.Command(
"ct2-transformers-converter", // O el binario de ctranslate2
"--source", text,
"--src_lang", srcCode,
"--tgt_lang", tgtCode,
)
output, err := cmd.CombinedOutput()
if err != nil {
return "", err
}
return string(output), nil
}
Nota: La forma más estable es usar el binario ctranslate2 directamente como wrapper del modelo convertido. Ver documentación de CTranslate2 para la interfaz CLI.
Fase 7: Frontend (Panel Admin)
Modificar: frontend/src/pages/AdminWorkers.tsx
Añadir nueva sección "Workers Remotos":
// Estados adicionales
const [remoteWorkers, setRemoteWorkers] = useState<RemoteWorker[]>([])
const [showAddForm, setShowAddForm] = useState(false)
// Fetch
const fetchRemoteWorkers = async () => {
const res = await api.get('/admin/workers/remote')
setRemoteWorkers(res.data)
}
// UI: Añadir después de la sección de workers locales
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Wifi className="h-5 w-5" />
Workers Remotos
</h2>
{/* Botón añadir */}
<button onClick={() => setShowAddForm(true)} className="btn-primary mb-4">
Añadir Worker
</button>
{/* Lista de workers */}
<div className="space-y-3">
{remoteWorkers.map(worker => (
<div key={worker.id} className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div>
<span className="font-medium">{worker.name}</span>
<span className={`ml-2 px-2 py-0.5 text-xs rounded ${
worker.status === 'online' ? 'bg-green-100' : 'bg-gray-100'
}`}>
{worker.status}
</span>
</div>
<div className="flex gap-2">
<button className="btn-secondary">Ver detalles</button>
<button className="btn-danger">Eliminar</button>
</div>
</div>
))}
</div>
</div>
Archivos a Crear/Modificar
Backend (Go)
| Archivo | Acción | Descripción |
|---|---|---|
init-db/35-remote-workers.sql |
Crear | Tabla remote_workers + columnas |
backend/internal/models/worker.go |
Crear | Modelos RemoteWorker, TranslationJob |
backend/internal/handlers/remote_worker.go |
Crear | CRUD REST API |
backend/internal/handlers/worker_ws.go |
Crear | WebSocket handler |
backend/internal/workers/translation_scheduler.go |
Crear | Scheduler migrado de Python |
backend/cmd/server/main.go |
Modificar | Añadir rutas + iniciar scheduler |
backend/go.mod |
Modificar | Añadir dependencia github.com/gorilla/websocket |
Frontend (React)
| Archivo | Acción | Descripción |
|---|---|---|
frontend/src/pages/AdminWorkers.tsx |
Modificar | Añadir sección workers remotos |
Cliente Worker
| Archivo | Acción | Descripción |
|---|---|---|
worker-client/cmd/main.go |
Crear | CLI entry point |
worker-client/internal/config/config.go |
Crear | Configuración |
worker-client/internal/ws/client.go |
Crear | WebSocket client |
worker-client/internal/translator/ctranslate2.go |
Crear | Wrapper CTranslate2 |
worker-client/go.mod |
Crear | Módulos Go |
worker-client/README.md |
Crear | Instrucciones de uso |
Consideraciones de Seguridad
- API Key: Generar con
crypto/rand- 64 caracteres hex - Rate limiting: En endpoints de creación de workers
- Validación: Verificar que el worker tiene capabilities válidas
- Timeout: Jobs asignados sin respuesta en 5 min vuelven a cola
- Conexión: El servidor debe verificar origen del WebSocket
Testing Plan
- Unidad: Tests de handlers (mock de BD)
- Integración WS: Probar conexión, registro, heartbeat, resultado
- E2E:
- Iniciar cliente worker local
- Crear job pending manualmente
- Verificar que worker recibe job
- Verificar resultado en BD
Notas de Implementación
- Mantener compatibilidad con workers Docker existentes (CPU/GPU locales)
- Los workers remotos serán una opción adicional, no reemplazo
- El scheduler puede coexistir (Go + Python) durante transición
- Considerar métricas: jobs procesadors por worker, tiempo promedio