- Backend usa DATABASE_URL (resolvePgConnInfo) en vez de DB_* que no existían en el contenedor API, arreglando el fallo de pg_dump - Dockerfile backend: alpine 3.23 para pg_dump 18.4 (compatible con PostgreSQL 18 del servidor) - BackupDatabase ya no usa docker exec (no disponible en la imagen) - Nuevo endpoint POST /api/admin/restore para restaurar .sql o .zip vía psql - UI de Configuración: sección Restaurar Base de Datos - Regenerado go.sum para el build
344 lines
No EOL
7.5 KiB
Go
344 lines
No EOL
7.5 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/gorilla/websocket"
|
|
"github.com/rss2/backend/internal/db"
|
|
"github.com/rss2/backend/internal/models"
|
|
)
|
|
|
|
var upgrader = websocket.Upgrader{
|
|
CheckOrigin: func(r *http.Request) bool { return true },
|
|
}
|
|
|
|
type WSWorker struct {
|
|
conn *websocket.Conn
|
|
workerID int
|
|
capabilities string
|
|
workerName string
|
|
lastHeartbeat time.Time
|
|
mu sync.Mutex
|
|
}
|
|
|
|
var (
|
|
workers = make(map[int]*WSWorker)
|
|
workersByAPI = make(map[string]*WSWorker)
|
|
workersMu sync.RWMutex
|
|
)
|
|
|
|
func HandleWorkerWS(c *gin.Context) {
|
|
apiKey := c.Query("api_key")
|
|
if apiKey == "" {
|
|
apiKey = c.GetHeader("X-API-Key")
|
|
}
|
|
|
|
if apiKey == "" {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "API key required"})
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
var workerID int
|
|
var capabilities string
|
|
err := db.GetPool().QueryRow(ctx, `
|
|
SELECT id, capabilities FROM remote_workers
|
|
WHERE api_key = $1 AND status != 'disabled'
|
|
`, apiKey).Scan(&workerID, &capabilities)
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"})
|
|
return
|
|
}
|
|
|
|
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
|
if err != nil {
|
|
log.Printf("WebSocket upgrade error: %v", err)
|
|
return
|
|
}
|
|
|
|
wsWorker := &WSWorker{
|
|
conn: conn,
|
|
workerID: workerID,
|
|
capabilities: capabilities,
|
|
lastHeartbeat: time.Now(),
|
|
}
|
|
|
|
workersMu.Lock()
|
|
workers[workerID] = wsWorker
|
|
workersByAPI[apiKey] = wsWorker
|
|
workersMu.Unlock()
|
|
|
|
db.GetPool().Exec(ctx, `
|
|
UPDATE remote_workers SET status = 'online', last_seen = NOW() WHERE id = $1
|
|
`, workerID)
|
|
|
|
go heartbeatLoop(wsWorker)
|
|
go writeLoop(wsWorker)
|
|
readLoop(wsWorker, apiKey)
|
|
}
|
|
|
|
func readLoop(wsWorker *WSWorker, apiKey string) {
|
|
defer func() {
|
|
cleanupWorker(wsWorker, apiKey)
|
|
}()
|
|
|
|
for {
|
|
var msg models.WSClientMessage
|
|
err := wsWorker.conn.ReadJSON(&msg)
|
|
if err != nil {
|
|
log.Printf("Worker %d read error: %v", wsWorker.workerID, err)
|
|
break
|
|
}
|
|
|
|
switch msg.Type {
|
|
case "register":
|
|
wsWorker.mu.Lock()
|
|
if msg.WorkerName != "" {
|
|
wsWorker.workerName = msg.WorkerName
|
|
}
|
|
if msg.Capabilities != "" {
|
|
wsWorker.capabilities = msg.Capabilities
|
|
}
|
|
wsWorker.mu.Unlock()
|
|
|
|
sendWS(wsWorker, models.WSServerMessage{Type: "ack", Job: nil})
|
|
|
|
case "heartbeat":
|
|
wsWorker.mu.Lock()
|
|
wsWorker.lastHeartbeat = time.Now()
|
|
wsWorker.mu.Unlock()
|
|
sendWS(wsWorker, models.WSServerMessage{Type: "ack", Job: nil})
|
|
|
|
case "result":
|
|
if msg.Result != nil {
|
|
handleTranslationResult(wsWorker.workerID, msg.Result)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func writeLoop(wsWorker *WSWorker) {
|
|
ticker := time.NewTicker(30 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
wsWorker.mu.Lock()
|
|
err := wsWorker.conn.WriteMessage(websocket.PingMessage, nil)
|
|
wsWorker.mu.Unlock()
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func heartbeatLoop(wsWorker *WSWorker) {
|
|
ticker := time.NewTicker(10 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
wsWorker.mu.Lock()
|
|
elapsed := time.Since(wsWorker.lastHeartbeat)
|
|
wsWorker.mu.Unlock()
|
|
|
|
if elapsed > 60*time.Second {
|
|
log.Printf("Worker %d heartbeat timeout", wsWorker.workerID)
|
|
wsWorker.conn.Close()
|
|
return
|
|
}
|
|
|
|
sendWS(wsWorker, models.WSServerMessage{Type: "ping", Job: nil})
|
|
}
|
|
}
|
|
}
|
|
|
|
func sendWS(wsWorker *WSWorker, msg models.WSServerMessage) {
|
|
data, _ := json.Marshal(msg)
|
|
wsWorker.mu.Lock()
|
|
wsWorker.conn.WriteMessage(websocket.TextMessage, data)
|
|
wsWorker.mu.Unlock()
|
|
}
|
|
|
|
func cleanupWorker(wsWorker *WSWorker, apiKey string) {
|
|
workersMu.Lock()
|
|
delete(workers, wsWorker.workerID)
|
|
delete(workersByAPI, apiKey)
|
|
workersMu.Unlock()
|
|
|
|
ctx := context.Background()
|
|
db.GetPool().Exec(ctx, `
|
|
UPDATE remote_workers SET status = 'offline', last_seen = NOW() WHERE id = $1
|
|
`, wsWorker.workerID)
|
|
|
|
wsWorker.conn.Close()
|
|
|
|
db.GetPool().Exec(ctx, `
|
|
UPDATE traducciones
|
|
SET status = 'pending', worker_id = NULL, assigned_at = NULL
|
|
WHERE worker_id = $1 AND status = 'assigned'
|
|
`, wsWorker.workerID)
|
|
|
|
log.Printf("Worker %d disconnected", wsWorker.workerID)
|
|
}
|
|
|
|
func handleTranslationResult(workerID int, result *models.TranslationResult) {
|
|
ctx := context.Background()
|
|
|
|
if result.Error != "" {
|
|
db.GetPool().Exec(ctx, `
|
|
UPDATE traducciones
|
|
SET status = 'error', worker_id = NULL, assigned_at = NULL
|
|
WHERE id = $1
|
|
`, result.JobID)
|
|
log.Printf("Job %d failed: %s", result.JobID, result.Error)
|
|
return
|
|
}
|
|
|
|
_, err := db.GetPool().Exec(ctx, `
|
|
UPDATE traducciones
|
|
SET titulo_trad = $1, resumen_trad = $2, status = 'done',
|
|
worker_id = $3, assigned_at = NULL
|
|
WHERE id = $4
|
|
`, result.TitleTr, result.SummaryTr, workerID, result.JobID)
|
|
|
|
if err != nil {
|
|
log.Printf("Error updating translation result: %v", err)
|
|
}
|
|
|
|
db.GetPool().Exec(ctx, `
|
|
UPDATE remote_workers SET last_seen = NOW() WHERE id = $1
|
|
`, workerID)
|
|
|
|
log.Printf("Job %d completed by worker %d", result.JobID, workerID)
|
|
}
|
|
|
|
func AssignJobToWorker(workerID int) *models.TranslationJob {
|
|
ctx := context.Background()
|
|
|
|
workersMu.RLock()
|
|
wsWorker, exists := workers[workerID]
|
|
workersMu.RUnlock()
|
|
|
|
if !exists {
|
|
return nil
|
|
}
|
|
|
|
wsWorker.mu.Lock()
|
|
workerCapabilities := wsWorker.capabilities
|
|
wsWorker.mu.Unlock()
|
|
|
|
_ = workerCapabilities
|
|
|
|
var job models.TranslationJob
|
|
err := db.GetPool().QueryRow(ctx, `
|
|
SELECT t.id, t.noticia_id, t.lang_from, t.lang_to, n.titulo, n.resumen
|
|
FROM traducciones t
|
|
JOIN noticias n ON n.id = t.noticia_id
|
|
WHERE t.status = 'pending'
|
|
AND (t.worker_id IS NULL OR t.status = 'assigned' AND t.assigned_at < NOW() - INTERVAL '5 minutes')
|
|
AND t.lang_to = 'es'
|
|
AND (t.titulo_trad IS NULL OR t.resumen_trad IS NULL)
|
|
ORDER BY n.fecha DESC
|
|
LIMIT 1
|
|
FOR UPDATE SKIP LOCKED
|
|
`).Scan(&job.ID, &job.NewsID, &job.LangFrom, &job.LangTo, &job.Title, &job.Summary)
|
|
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
db.GetPool().Exec(ctx, `
|
|
UPDATE traducciones
|
|
SET status = 'assigned', worker_id = $1, assigned_at = NOW()
|
|
WHERE id = $2
|
|
`, workerID, job.ID)
|
|
|
|
sendWS(wsWorker, models.WSServerMessage{
|
|
Type: "job",
|
|
Job: &job,
|
|
})
|
|
|
|
return &job
|
|
}
|
|
|
|
func StartJobAssigner() {
|
|
go func() {
|
|
ticker := time.NewTicker(5 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
workersMu.RLock()
|
|
for workerID := range workers {
|
|
workersMu.RUnlock()
|
|
AssignJobToWorker(workerID)
|
|
workersMu.RLock()
|
|
}
|
|
workersMu.RUnlock()
|
|
|
|
ctx := context.Background()
|
|
db.GetPool().Exec(ctx, `
|
|
UPDATE traducciones
|
|
SET status = 'pending', worker_id = NULL, assigned_at = NULL
|
|
WHERE status = 'assigned'
|
|
AND assigned_at < NOW() - INTERVAL '10 minutes'
|
|
`)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
func GetModelDownloadURL(c *gin.Context) {
|
|
apiKey := c.Query("api_key")
|
|
if apiKey == "" {
|
|
apiKey = c.GetHeader("X-API-Key")
|
|
}
|
|
|
|
if apiKey == "" {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "API key required"})
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
var workerID int
|
|
err := db.GetPool().QueryRow(ctx, `
|
|
SELECT id FROM remote_workers WHERE api_key = $1
|
|
`, apiKey).Scan(&workerID)
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"})
|
|
return
|
|
}
|
|
|
|
var modelURL, modelPath string
|
|
db.GetPool().QueryRow(ctx, `
|
|
SELECT value FROM config WHERE key = 'remote_worker_model_url'
|
|
`).Scan(&modelURL)
|
|
|
|
db.GetPool().QueryRow(ctx, `
|
|
SELECT value FROM config WHERE key = 'remote_worker_model_path'
|
|
`).Scan(&modelPath)
|
|
|
|
if modelPath == "" {
|
|
modelPath = "/app/models/nllb-ct2"
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"model_download_url": modelURL,
|
|
"model_path": modelPath,
|
|
})
|
|
} |