statuses update
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
|
||||
"github.com/postmet/watcher/internal/config"
|
||||
"github.com/postmet/watcher/internal/pipelinestatus"
|
||||
"github.com/postmet/watcher/internal/publisher"
|
||||
"github.com/postmet/watcher/internal/scanner"
|
||||
)
|
||||
@@ -42,6 +43,10 @@ func main() {
|
||||
slog.Error("publisher init failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := pipelinestatus.DeclareQueue(ch, cfg.StatusQueue); err != nil {
|
||||
slog.Error("declare status queue failed", "queue", cfg.StatusQueue, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
@@ -51,6 +56,7 @@ func main() {
|
||||
"poll_interval", cfg.PollInterval.String(),
|
||||
"exchange", cfg.Exchange,
|
||||
"routing_key", cfg.RoutingKey,
|
||||
"status_queue", cfg.StatusQueue,
|
||||
)
|
||||
|
||||
ticker := time.NewTicker(cfg.PollInterval)
|
||||
@@ -85,6 +91,14 @@ func main() {
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := pipelinestatus.Publish(pubCtx, ch, cfg.StatusQueue, pipelinestatus.Event{
|
||||
TaskID: cf.TaskID,
|
||||
Filename: cf.Filename,
|
||||
Status: pipelinestatus.StatusPending,
|
||||
Stage: pipelinestatus.StageQueued,
|
||||
}); err != nil {
|
||||
slog.Warn("status publish failed", "task_id", cf.TaskID, "error", err)
|
||||
}
|
||||
slog.Info("task published", "task_id", cf.TaskID, "filename", cf.Filename)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ type Config struct {
|
||||
RabbitURL string
|
||||
Exchange string
|
||||
RoutingKey string
|
||||
StatusQueue string
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
@@ -31,6 +32,7 @@ func Load() Config {
|
||||
RabbitURL: getEnv("RABBITMQ_URL", "amqp://guest:guest@localhost:5672/"),
|
||||
Exchange: getEnv("RABBITMQ_EXCHANGE", "audio_pipeline"),
|
||||
RoutingKey: getEnv("RABBITMQ_ROUTING_KEY", "audio.new"),
|
||||
StatusQueue: getEnv("STATUS_QUEUE", "pipeline.status"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
47
watcher/internal/config/config_test.go
Normal file
47
watcher/internal/config/config_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadDefaults(t *testing.T) {
|
||||
t.Setenv("STORAGE_ROOT", "")
|
||||
t.Setenv("RABBITMQ_URL", "")
|
||||
t.Setenv("STATUS_QUEUE", "")
|
||||
|
||||
cfg := Load()
|
||||
if cfg.StorageRoot != "/data/storage" {
|
||||
t.Fatalf("StorageRoot: got %q", cfg.StorageRoot)
|
||||
}
|
||||
if cfg.StatusQueue != "pipeline.status" {
|
||||
t.Fatalf("StatusQueue: got %q", cfg.StatusQueue)
|
||||
}
|
||||
if cfg.PollInterval != 5*time.Second {
|
||||
t.Fatalf("PollInterval: got %v", cfg.PollInterval)
|
||||
}
|
||||
if cfg.StableChecks != 3 {
|
||||
t.Fatalf("StableChecks: got %d", cfg.StableChecks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromEnv(t *testing.T) {
|
||||
t.Setenv("STORAGE_ROOT", "/tmp/storage")
|
||||
t.Setenv("POLL_INTERVAL", "10s")
|
||||
t.Setenv("STABLE_CHECKS", "5")
|
||||
t.Setenv("STATUS_QUEUE", "custom.status")
|
||||
|
||||
cfg := Load()
|
||||
if cfg.StorageRoot != "/tmp/storage" {
|
||||
t.Fatalf("StorageRoot: got %q", cfg.StorageRoot)
|
||||
}
|
||||
if cfg.PollInterval != 10*time.Second {
|
||||
t.Fatalf("PollInterval: got %v", cfg.PollInterval)
|
||||
}
|
||||
if cfg.StableChecks != 5 {
|
||||
t.Fatalf("StableChecks: got %d", cfg.StableChecks)
|
||||
}
|
||||
if cfg.StatusQueue != "custom.status" {
|
||||
t.Fatalf("StatusQueue: got %q", cfg.StatusQueue)
|
||||
}
|
||||
}
|
||||
57
watcher/internal/pipelinestatus/status.go
Normal file
57
watcher/internal/pipelinestatus/status.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package pipelinestatus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
const (
|
||||
StatusPending = "pending"
|
||||
StatusInProgress = "in_progress"
|
||||
StatusDone = "done"
|
||||
StatusError = "error"
|
||||
)
|
||||
|
||||
const (
|
||||
StageQueued = "queued"
|
||||
StageTranscribing = "transcribing"
|
||||
StageAnalysing = "analysing"
|
||||
StageTagging = "tagging"
|
||||
StageCompleted = "completed"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Stage string `json:"stage"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
func DeclareQueue(ch *amqp.Channel, queue string) error {
|
||||
_, err := ch.QueueDeclare(queue, true, false, false, false, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func Publish(ctx context.Context, ch *amqp.Channel, queue string, ev Event) error {
|
||||
if ev.Timestamp == 0 {
|
||||
ev.Timestamp = time.Now().Unix()
|
||||
}
|
||||
body, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ch.PublishWithContext(ctx, "", queue, false, false, amqp.Publishing{
|
||||
ContentType: "application/json",
|
||||
Body: body,
|
||||
DeliveryMode: amqp.Persistent,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("publish status: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
65
watcher/internal/pipelinestatus/status_test.go
Normal file
65
watcher/internal/pipelinestatus/status_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package pipelinestatus
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEventJSON(t *testing.T) {
|
||||
ev := Event{
|
||||
TaskID: "01HXABCDEF",
|
||||
Filename: "call.wav",
|
||||
Status: StatusPending,
|
||||
Stage: StageQueued,
|
||||
Timestamp: 1717843200,
|
||||
}
|
||||
body, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var got Event
|
||||
if err := json.Unmarshal(body, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.TaskID != ev.TaskID || got.Status != StatusPending || got.Stage != StageQueued {
|
||||
t.Fatalf("unexpected event: %+v", got)
|
||||
}
|
||||
if got.Error != "" {
|
||||
t.Fatalf("expected empty error, got %q", got.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventJSONWithError(t *testing.T) {
|
||||
ev := Event{
|
||||
TaskID: "01HX",
|
||||
Status: StatusError,
|
||||
Stage: StageTranscribing,
|
||||
Error: "nexara timeout",
|
||||
}
|
||||
body, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !json.Valid(body) {
|
||||
t.Fatal("invalid json")
|
||||
}
|
||||
var got Event
|
||||
if err := json.Unmarshal(body, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Error != "nexara timeout" {
|
||||
t.Fatalf("error field: got %q", got.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusConstants(t *testing.T) {
|
||||
statuses := []string{StatusPending, StatusInProgress, StatusDone, StatusError}
|
||||
seen := make(map[string]bool)
|
||||
for _, s := range statuses {
|
||||
if seen[s] {
|
||||
t.Fatalf("duplicate status %q", s)
|
||||
}
|
||||
seen[s] = true
|
||||
}
|
||||
}
|
||||
43
watcher/internal/publisher/publisher_test.go
Normal file
43
watcher/internal/publisher/publisher_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package publisher
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAudioTaskJSON(t *testing.T) {
|
||||
task := AudioTask{
|
||||
TaskID: "01HXABCDEF",
|
||||
FilePath: "/data/storage/processing/01HX.wav",
|
||||
Filename: "call.wav",
|
||||
Size: 1024,
|
||||
CreatedAt: 1717843200,
|
||||
}
|
||||
body, err := json.Marshal(task)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var got AudioTask
|
||||
if err := json.Unmarshal(body, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.TaskID != task.TaskID || got.FilePath != task.FilePath || got.Size != 1024 {
|
||||
t.Fatalf("unexpected task: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudioTaskCreatedAtDefault(t *testing.T) {
|
||||
task := AudioTask{TaskID: "x", Filename: "a.wav"}
|
||||
if task.CreatedAt != 0 {
|
||||
t.Fatal("zero value should have CreatedAt=0 before publish")
|
||||
}
|
||||
before := time.Now().Unix()
|
||||
if task.CreatedAt == 0 {
|
||||
task.CreatedAt = time.Now().Unix()
|
||||
}
|
||||
if task.CreatedAt < before {
|
||||
t.Fatal("timestamp should be set")
|
||||
}
|
||||
}
|
||||
162
watcher/internal/scanner/scanner_test.go
Normal file
162
watcher/internal/scanner/scanner_test.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testScanner(t *testing.T) (*Scanner, string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
s := New(Config{
|
||||
StorageRoot: root,
|
||||
IncomingDir: "incoming",
|
||||
ProcessingDir: "processing",
|
||||
FailedDir: "failed",
|
||||
StableWindow: time.Millisecond,
|
||||
StableChecks: 1,
|
||||
})
|
||||
if err := s.EnsureDirs(); err != nil {
|
||||
t.Fatalf("EnsureDirs: %v", err)
|
||||
}
|
||||
return s, root
|
||||
}
|
||||
|
||||
func TestEnsureDirs(t *testing.T) {
|
||||
_, root := testScanner(t)
|
||||
for _, dir := range []string{"incoming", "processing", "failed"} {
|
||||
p := filepath.Join(root, dir)
|
||||
if st, err := os.Stat(p); err != nil || !st.IsDir() {
|
||||
t.Fatalf("dir %s missing: %v", dir, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanOnceClaimsStableAudio(t *testing.T) {
|
||||
s, root := testScanner(t)
|
||||
incoming := filepath.Join(root, "incoming")
|
||||
if err := os.WriteFile(filepath.Join(incoming, "call.wav"), []byte("audio"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
claimed, err := s.ScanOnce()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(claimed) != 1 {
|
||||
t.Fatalf("claimed %d files, want 1", len(claimed))
|
||||
}
|
||||
cf := claimed[0]
|
||||
if cf.Filename != "call.wav" {
|
||||
t.Fatalf("filename: got %q", cf.Filename)
|
||||
}
|
||||
if cf.TaskID == "" {
|
||||
t.Fatal("empty task_id")
|
||||
}
|
||||
if !strings.HasPrefix(cf.FilePath, filepath.Join(root, "processing")) {
|
||||
t.Fatalf("unexpected path: %s", cf.FilePath)
|
||||
}
|
||||
if _, err := os.Stat(cf.FilePath); err != nil {
|
||||
t.Fatalf("claimed file missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(incoming, "call.wav")); !os.IsNotExist(err) {
|
||||
t.Fatal("source file should be moved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanOnceSkipsUnsupportedAndHidden(t *testing.T) {
|
||||
s, root := testScanner(t)
|
||||
incoming := filepath.Join(root, "incoming")
|
||||
files := map[string][]byte{
|
||||
".hidden.wav": []byte("x"),
|
||||
"notes.txt": []byte("x"),
|
||||
"upload.tmp": []byte("x"),
|
||||
"valid.mp3": []byte("audio"),
|
||||
}
|
||||
for name, data := range files {
|
||||
if err := os.WriteFile(filepath.Join(incoming, name), data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
claimed, err := s.ScanOnce()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(claimed) != 1 {
|
||||
t.Fatalf("claimed %d files, want 1 (valid.mp3)", len(claimed))
|
||||
}
|
||||
if claimed[0].Filename != "valid.mp3" {
|
||||
t.Fatalf("filename: got %q", claimed[0].Filename)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanOnceEmptyIncoming(t *testing.T) {
|
||||
s, _ := testScanner(t)
|
||||
claimed, err := s.ScanOnce()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(claimed) != 0 {
|
||||
t.Fatalf("expected 0 claimed, got %d", len(claimed))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackToIncoming(t *testing.T) {
|
||||
s, root := testScanner(t)
|
||||
processing := filepath.Join(root, "processing")
|
||||
incoming := filepath.Join(root, "incoming")
|
||||
processingFile := filepath.Join(processing, "01TASK.wav")
|
||||
if err := os.WriteFile(processingFile, []byte("data"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := s.RollbackToIncoming(processingFile, "call.wav"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(incoming, "call.wav")); err != nil {
|
||||
t.Fatalf("file not restored to incoming: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(processingFile); !os.IsNotExist(err) {
|
||||
t.Fatal("processing file should be gone")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveToFailed(t *testing.T) {
|
||||
s, root := testScanner(t)
|
||||
processing := filepath.Join(root, "processing")
|
||||
processingFile := filepath.Join(processing, "broken.wav")
|
||||
if err := os.WriteFile(processingFile, []byte("data"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := s.MoveToFailed(processingFile, "broken.wav"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
failedPath := filepath.Join(root, "failed", "broken.wav")
|
||||
if _, err := os.Stat(failedPath); err != nil {
|
||||
t.Fatalf("file not in failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsStableStableFile(t *testing.T) {
|
||||
s, root := testScanner(t)
|
||||
path := filepath.Join(root, "incoming", "stable.wav")
|
||||
if err := os.WriteFile(path, []byte("fixed-content"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !s.isStable(path) {
|
||||
t.Fatal("expected stable file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsStableMissingFile(t *testing.T) {
|
||||
s, root := testScanner(t)
|
||||
path := filepath.Join(root, "incoming", "missing.wav")
|
||||
if s.isStable(path) {
|
||||
t.Fatal("missing file should not be stable")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user