rss2/remote-worker.md
lordpietre 8093cc1e40 feat: add remote worker system for distributed translation
- 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
2026-04-01 03:29:09 +00:00

635 lines
No EOL
22 KiB
Markdown

# 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
1. **Scheduler (servidor)** crea jobs en `traducciones` con status `pending`
2. **Worker remoto** se conecta por WS, envía capabilities (`gpu`/`cpu`)
3. **Servidor** asigna job disponible que coincida con capabilities → status `assigned`
4. **Worker** recibe job → traduce con NLLB-1.3B → envía resultado por WS
5. **Servidor** actualiza `traducciones` con resultado → status `completed`
---
## 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` - disponible
- `done` - completado
- `error` - fallido
**Problema identificado:** No hay estado `assigned` → varios workers pueden tomar el mismo job.
### Query Actual del Worker
```python
# 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`
```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`
```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
1. **CreateRemoteWorker** - Genera API key aleatoria (64 chars), guarda en BD
2. **ListRemoteWorkers** - Retorna todos con jobs counts
3. **GetRemoteWorker** - Retorna detalle + estadísticas
4. **DeleteRemoteWorker** - Soft delete (marcar como disabled)
5. **ToggleRemoteWorker** - Enable/disable sin borrar
### Fase 4: WebSocket Handler (Backend Go)
**Nuevo archivo:** `backend/internal/handlers/worker_ws.go`
```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`
```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
```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
```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
```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":
```tsx
// 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
1. **API Key**: Generar con `crypto/rand` - 64 caracteres hex
2. **Rate limiting**: En endpoints de creación de workers
3. **Validación**: Verificar que el worker tiene capabilities válidas
4. **Timeout**: Jobs asignados sin respuesta en 5 min vuelven a cola
5. **Conexión**: El servidor debe verificar origen del WebSocket
---
## Testing Plan
1. **Unidad**: Tests de handlers (mock de BD)
2. **Integración WS**: Probar conexión, registro, heartbeat, resultado
3. **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