nomadcode/services/core/internal/workflow/service.go
toki bbb1179194 feat(work-item-pipeline): 작업자 외부 참조 중복 처리 및 고유 인덱스 추가
Tasks 테이블에 external_provider, external_id 기반 UPSERT 로직을 추가하고
GetTaskByExternalRef 쿼리를 신설하여 중복 작업을 효율적으로 처리한다.

외부 참조 고유 인덱스 마이그레이션(00008)을 추가한다.

Workspace slots 쿼리에서 alias를 명시하여 FOR UPDATE SKIP LOCKED 동시성 처리를 개선한다.

Storage, workflow, workitempipeline의 중복 처리 로직을 일관되게 적용하고
Plane 웹훅 테스트를 보강한다.
2026-06-15 14:58:59 +09:00

184 lines
4.7 KiB
Go

package workflow
import (
"context"
"encoding/json"
"errors"
"log/slog"
"strings"
"github.com/nomadcode/nomadcode-core/internal/storage"
)
var (
ErrInvalidTaskInput = errors.New("invalid task input")
ErrTaskCannotBeEnqueued = errors.New("task cannot be enqueued in current status")
ErrInvalidTaskTransition = errors.New("invalid task transition")
)
type TaskEnqueuer interface {
EnqueueTask(ctx context.Context, taskID string) error
}
type Service struct {
store *storage.Store
enqueuer TaskEnqueuer
logger *slog.Logger
lifecycle *Lifecycle
}
func NewService(store *storage.Store, enqueuer TaskEnqueuer, logger *slog.Logger) *Service {
return &Service{
store: store,
enqueuer: enqueuer,
logger: logger,
lifecycle: NewLifecycle(store, logger),
}
}
func (s *Service) CreateTask(ctx context.Context, input CreateTaskInput) (storage.Task, error) {
title := strings.TrimSpace(input.Title)
source := strings.TrimSpace(input.Source)
if title == "" || source == "" {
return storage.Task{}, ErrInvalidTaskInput
}
payload := input.Payload
if len(payload) == 0 || string(payload) == "null" {
payload = json.RawMessage(`{}`)
}
if !json.Valid(payload) {
return storage.Task{}, ErrInvalidTaskInput
}
metadata, err := NormalizeTaskMetadata(input.Metadata)
if err != nil {
return storage.Task{}, err
}
external, err := NormalizeExternalRef(input.External)
if err != nil {
return storage.Task{}, err
}
return s.store.CreateTask(ctx, storage.CreateTaskInput{
Title: title,
Source: source,
Payload: payload,
Metadata: metadata,
ExternalProvider: external.Provider,
ExternalID: external.ID,
ExternalURL: external.URL,
ExternalMetadata: external.Metadata,
})
}
func (s *Service) GetTask(ctx context.Context, id string) (storage.Task, error) {
return s.store.GetTask(ctx, id)
}
func (s *Service) GetTaskByExternalRef(ctx context.Context, provider, id string) (storage.Task, error) {
provider = strings.TrimSpace(provider)
id = strings.TrimSpace(id)
if provider == "" || id == "" {
return storage.Task{}, ErrInvalidTaskInput
}
return s.store.GetTaskByExternalRef(ctx, provider, id)
}
type NormalizedExternalRef struct {
Provider *string
ID *string
URL *string
Metadata json.RawMessage
}
func NormalizeExternalRef(input *ExternalRefInput) (NormalizedExternalRef, error) {
if input == nil {
return NormalizedExternalRef{Metadata: json.RawMessage(`{}`)}, nil
}
provider := strings.TrimSpace(input.Provider)
id := strings.TrimSpace(input.ID)
externalURL := strings.TrimSpace(input.URL)
metadata := input.Metadata
if len(metadata) == 0 || string(metadata) == "null" {
metadata = json.RawMessage(`{}`)
}
if !json.Valid(metadata) {
return NormalizedExternalRef{}, ErrInvalidTaskInput
}
if provider == "" {
return NormalizedExternalRef{}, ErrInvalidTaskInput
}
return NormalizedExternalRef{
Provider: optionalString(provider),
ID: optionalString(id),
URL: optionalString(externalURL),
Metadata: metadata,
}, nil
}
func optionalString(value string) *string {
if value == "" {
return nil
}
return &value
}
func NormalizeTaskMetadata(metadata json.RawMessage) (json.RawMessage, error) {
if len(metadata) == 0 || string(metadata) == "null" {
return json.RawMessage(`{}`), nil
}
if !json.Valid(metadata) {
return nil, ErrInvalidTaskInput
}
return metadata, nil
}
func (s *Service) ListTasks(ctx context.Context, limit int32) ([]storage.Task, error) {
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
return s.store.ListTasks(ctx, limit)
}
func (s *Service) UpdateTaskMetadata(ctx context.Context, id string, metadata json.RawMessage) (storage.Task, error) {
normalized, err := NormalizeTaskMetadata(metadata)
if err != nil {
return storage.Task{}, err
}
return s.store.UpdateMetadata(ctx, id, normalized)
}
func (s *Service) EnqueueTask(ctx context.Context, id string) (storage.Task, error) {
queuedTask, err := s.lifecycle.QueueTask(ctx, id)
if err != nil {
return storage.Task{}, err
}
if s.enqueuer == nil {
err := errors.New("task enqueuer is not configured")
if _, failErr := s.lifecycle.FailTask(ctx, id, err.Error()); failErr != nil && s.logger != nil {
s.logger.Error("failed to mark task enqueue error", "task_id", id, "error", failErr)
}
return queuedTask, err
}
if err := s.enqueuer.EnqueueTask(ctx, id); err != nil {
if _, failErr := s.lifecycle.FailTask(ctx, id, err.Error()); failErr != nil && s.logger != nil {
s.logger.Error("failed to mark task enqueue error", "task_id", id, "error", failErr)
}
return queuedTask, err
}
return queuedTask, nil
}
func canEnqueue(status string) bool {
return canEnqueueStatus(TaskStatus(status))
}