package runner import ( "context" "fmt" "time" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "github.com/libnovel/backend/internal/domain" ) // runTranslationTask executes one machine-translation task end-to-end and // reports the result back to PocketBase. func (r *Runner) runTranslationTask(ctx context.Context, task domain.TranslationTask) { ctx, span := otel.Tracer("runner").Start(ctx, "runner.translation_task") defer span.End() span.SetAttributes( attribute.String("task.id", task.ID), attribute.String("book.slug", task.Slug), attribute.Int("chapter.number", task.Chapter), attribute.String("translation.lang", task.Lang), ) log := r.deps.Log.With("task_id", task.ID, "slug", task.Slug, "chapter", task.Chapter, "lang", task.Lang) log.Info("runner: translation task starting") // Heartbeat goroutine — keeps the task alive while translation runs. hbCtx, hbCancel := context.WithCancel(ctx) defer hbCancel() go func() { tick := time.NewTicker(r.cfg.HeartbeatInterval) defer tick.Stop() for { select { case <-hbCtx.Done(): return case <-tick.C: if err := r.deps.Consumer.HeartbeatTask(ctx, task.ID); err != nil { log.Warn("runner: heartbeat failed", "err", err) } } } }() fail := func(msg string) { log.Error("runner: translation task failed", "reason", msg) r.tasksFailed.Add(1) span.SetStatus(codes.Error, msg) result := domain.TranslationResult{ErrorMessage: msg} if err := r.deps.Consumer.FinishTranslationTask(ctx, task.ID, result); err != nil { log.Error("runner: FinishTranslationTask failed", "err", err) } if r.deps.Notifier != nil { _ = r.deps.Notifier.CreateNotification(ctx, "admin", "Translation Failed", fmt.Sprintf("Ch.%d of %s (%s): %s", task.Chapter, task.Slug, task.Lang, msg), fmt.Sprintf("/books/%s", task.Slug)) } } // Guard: LibreTranslate must be configured. if r.deps.LibreTranslate == nil { fail("libretranslate client not configured (LIBRETRANSLATE_URL is empty)") return } // 1. Read raw markdown chapter. raw, err := r.deps.BookReader.ReadChapter(ctx, task.Slug, task.Chapter) if err != nil { fail(fmt.Sprintf("read chapter: %v", err)) return } if raw == "" { fail("chapter text is empty") return } // 2. Translate (chunked, concurrent). translated, err := r.deps.LibreTranslate.Translate(ctx, raw, "en", task.Lang) if err != nil { fail(fmt.Sprintf("translate: %v", err)) return } // 3. Store translated markdown in MinIO. key := r.deps.TranslationStore.TranslationObjectKey(task.Lang, task.Slug, task.Chapter) if err := r.deps.TranslationStore.PutTranslation(ctx, key, []byte(translated)); err != nil { fail(fmt.Sprintf("put translation: %v", err)) return } // 4. Report success. r.tasksCompleted.Add(1) span.SetStatus(codes.Ok, "") result := domain.TranslationResult{ObjectKey: key} if err := r.deps.Consumer.FinishTranslationTask(ctx, task.ID, result); err != nil { log.Error("runner: FinishTranslationTask failed", "err", err) } if r.deps.Notifier != nil { _ = r.deps.Notifier.CreateNotification(ctx, "admin", "Translation Ready", fmt.Sprintf("Ch.%d of %s translated to %s", task.Chapter, task.Slug, task.Lang), fmt.Sprintf("/books/%s", task.Slug)) } log.Info("runner: translation task finished", "key", key) }