mirror of
https://github.com/j3ssie/osmedeus.git
synced 2026-08-20 06:35:48 +02:00
- Add QueuePoller for dual-source (DB + Redis) task polling with concurrent execution - Add worker queue subcommands (list, new, run) for managing queued tasks - Add uninstall command to safely remove Osmedeus installation - Add input validation to prevent command injection via dangerous shell characters - Add queue run support via --queue and --queue-run flags in osmedeus run - Add queue polling to server with --no-queue-polling flag to disable it - Support queuing tasks from both CLI and API with deduplication across sources
644 lines
17 KiB
Go
644 lines
17 KiB
Go
package executor
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/j3ssie/osmedeus/v5/internal/core"
|
|
"github.com/j3ssie/osmedeus/v5/internal/functions"
|
|
"github.com/j3ssie/osmedeus/v5/internal/logger"
|
|
"github.com/j3ssie/osmedeus/v5/internal/template"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// ForeachExecutor executes foreach steps
|
|
type ForeachExecutor struct {
|
|
dispatcher *StepDispatcher
|
|
templateEngine template.TemplateEngine
|
|
functionRegistry *functions.Registry
|
|
}
|
|
|
|
// NewForeachExecutor creates a new foreach executor
|
|
func NewForeachExecutor(dispatcher *StepDispatcher, engine template.TemplateEngine, registry *functions.Registry) *ForeachExecutor {
|
|
return &ForeachExecutor{
|
|
dispatcher: dispatcher,
|
|
templateEngine: engine,
|
|
functionRegistry: registry,
|
|
}
|
|
}
|
|
|
|
// Name returns the executor name for logging/debugging
|
|
func (e *ForeachExecutor) Name() string {
|
|
return "foreach"
|
|
}
|
|
|
|
// StepTypes returns the step types this executor handles
|
|
func (e *ForeachExecutor) StepTypes() []core.StepType {
|
|
return []core.StepType{core.StepTypeForeach}
|
|
}
|
|
|
|
// Execute executes a foreach step
|
|
func (e *ForeachExecutor) Execute(ctx context.Context, step *core.Step, execCtx *core.ExecutionContext) (*core.StepResult, error) {
|
|
result := &core.StepResult{
|
|
StepName: step.Name,
|
|
Status: core.StepStatusRunning,
|
|
StartTime: time.Now(),
|
|
Exports: make(map[string]interface{}),
|
|
}
|
|
|
|
if step.Step == nil {
|
|
result.Status = core.StepStatusFailed
|
|
result.Error = fmt.Errorf("foreach step has no inner step")
|
|
result.EndTime = time.Now()
|
|
return result, result.Error
|
|
}
|
|
|
|
threads, err := step.Threads.Int()
|
|
if err != nil {
|
|
result.Status = core.StepStatusFailed
|
|
result.Error = err
|
|
result.EndTime = time.Now()
|
|
result.Duration = result.EndTime.Sub(result.StartTime)
|
|
return result, err
|
|
}
|
|
if threads <= 0 {
|
|
threads = 1
|
|
}
|
|
|
|
// Check for streaming output configuration via exports
|
|
var streamingOutput string
|
|
if step.Exports != nil {
|
|
if so, ok := step.Exports["streaming_output"]; ok {
|
|
// Render the template
|
|
rendered, err := e.templateEngine.Render(so, execCtx.GetVariables())
|
|
if err == nil {
|
|
streamingOutput = rendered
|
|
}
|
|
}
|
|
}
|
|
|
|
// Auto-enable streaming for large inputs (>1000 lines) to avoid OOM
|
|
autoStreaming := false
|
|
var autoStreamFile string
|
|
if streamingOutput == "" {
|
|
lineCount, countErr := countInputLines(step.Input)
|
|
if countErr == nil && lineCount > 1000 {
|
|
autoStreaming = true
|
|
tmpFile, tmpErr := os.CreateTemp("", "osmedeus-foreach-*.txt")
|
|
if tmpErr == nil {
|
|
autoStreamFile = tmpFile.Name()
|
|
streamingOutput = autoStreamFile
|
|
_ = tmpFile.Close()
|
|
}
|
|
}
|
|
}
|
|
// Clean up auto-stream temp file on exit
|
|
if autoStreamFile != "" {
|
|
defer func() { _ = os.Remove(autoStreamFile) }()
|
|
}
|
|
|
|
// Execute with streaming worker pool
|
|
outputs, err := e.executeWithWorkerPoolStreaming(ctx, step, step.Input, threads, execCtx, streamingOutput)
|
|
|
|
if autoStreaming && autoStreamFile != "" {
|
|
// Read back a truncated version of the auto-streamed output
|
|
result.Output = readTruncatedFile(autoStreamFile, 10*1024*1024) // 10MB max
|
|
} else if streamingOutput != "" {
|
|
// In streaming mode, output is written to file
|
|
result.Output = fmt.Sprintf("Results streamed to: %s", streamingOutput)
|
|
} else {
|
|
result.Output = strings.Join(outputs, "\n")
|
|
}
|
|
result.EndTime = time.Now()
|
|
result.Duration = result.EndTime.Sub(result.StartTime)
|
|
|
|
if err != nil {
|
|
result.Status = core.StepStatusFailed
|
|
result.Error = err
|
|
return result, err
|
|
}
|
|
|
|
result.Status = core.StepStatusSuccess
|
|
return result, nil
|
|
}
|
|
|
|
// LineIterator provides streaming access to lines in a file
|
|
type LineIterator struct {
|
|
file *os.File
|
|
scanner *bufio.Scanner
|
|
current string
|
|
err error
|
|
}
|
|
|
|
// NewLineIterator creates an iterator for reading lines from a file
|
|
func NewLineIterator(path string) (*LineIterator, error) {
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
scanner := bufio.NewScanner(file)
|
|
// Increase scanner buffer for long lines
|
|
scanner.Buffer(make([]byte, 64*1024), 10*1024*1024)
|
|
|
|
return &LineIterator{
|
|
file: file,
|
|
scanner: scanner,
|
|
}, nil
|
|
}
|
|
|
|
// Next advances to the next non-empty line, returns false when done
|
|
func (it *LineIterator) Next() bool {
|
|
for it.scanner.Scan() {
|
|
line := strings.TrimSpace(it.scanner.Text())
|
|
if line != "" {
|
|
it.current = line
|
|
return true
|
|
}
|
|
}
|
|
it.err = it.scanner.Err()
|
|
return false
|
|
}
|
|
|
|
// Value returns the current line
|
|
func (it *LineIterator) Value() string {
|
|
return it.current
|
|
}
|
|
|
|
// Err returns any error encountered during iteration
|
|
func (it *LineIterator) Err() error {
|
|
return it.err
|
|
}
|
|
|
|
// Close closes the underlying file
|
|
func (it *LineIterator) Close() error {
|
|
if it.file != nil {
|
|
return it.file.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// countInputLines counts non-empty lines in an input file (for result slice allocation)
|
|
func countInputLines(path string) (int, error) {
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer func() { _ = file.Close() }()
|
|
|
|
count := 0
|
|
scanner := bufio.NewScanner(file)
|
|
scanner.Buffer(make([]byte, 64*1024), 10*1024*1024)
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line != "" {
|
|
count++
|
|
}
|
|
}
|
|
return count, scanner.Err()
|
|
}
|
|
|
|
// renderSecondaryTemplates clones the step and renders [[ ]] templates with loop context
|
|
func (e *ForeachExecutor) renderSecondaryTemplates(step *core.Step, execCtx *core.ExecutionContext) *core.Step {
|
|
// Clone the step to avoid modifying the original
|
|
cloned := step.Clone()
|
|
ctx := execCtx.GetVariables()
|
|
|
|
// Render Command if it has secondary variables
|
|
if e.templateEngine.HasSecondaryVariable(cloned.Command) {
|
|
rendered, err := e.templateEngine.RenderSecondary(cloned.Command, ctx)
|
|
if err == nil {
|
|
cloned.Command = rendered
|
|
}
|
|
}
|
|
|
|
// Render Commands array
|
|
for i, cmd := range cloned.Commands {
|
|
if e.templateEngine.HasSecondaryVariable(cmd) {
|
|
rendered, err := e.templateEngine.RenderSecondary(cmd, ctx)
|
|
if err == nil {
|
|
cloned.Commands[i] = rendered
|
|
}
|
|
}
|
|
}
|
|
|
|
// Render Input (for nested foreach)
|
|
if e.templateEngine.HasSecondaryVariable(cloned.Input) {
|
|
rendered, err := e.templateEngine.RenderSecondary(cloned.Input, ctx)
|
|
if err == nil {
|
|
cloned.Input = rendered
|
|
}
|
|
}
|
|
|
|
return cloned
|
|
}
|
|
|
|
// ForeachIterationError wraps a step execution error with context about the
|
|
// failing foreach iteration (input value, rendered command, captured output).
|
|
type ForeachIterationError struct {
|
|
Inner error
|
|
InputValue string // Loop variable value that failed
|
|
RenderedCommand string // Fully rendered command
|
|
Output string // stdout+stderr from failed step
|
|
}
|
|
|
|
func (e *ForeachIterationError) Error() string { return e.Inner.Error() }
|
|
func (e *ForeachIterationError) Unwrap() error { return e.Inner }
|
|
|
|
// workItem represents a single item to process in the worker pool
|
|
type workItem struct {
|
|
index int
|
|
value string
|
|
}
|
|
|
|
// workResult represents the result of processing a work item
|
|
type workResult struct {
|
|
index int
|
|
output string
|
|
err error
|
|
}
|
|
|
|
// streamWriter handles concurrent writes to an output file
|
|
type streamWriter struct {
|
|
file *os.File
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func newStreamWriter(path string) (*streamWriter, error) {
|
|
f, err := os.Create(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &streamWriter{file: f}, nil
|
|
}
|
|
|
|
func (w *streamWriter) Write(line string) error {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
_, err := w.file.WriteString(line + "\n")
|
|
return err
|
|
}
|
|
|
|
func (w *streamWriter) Close() error {
|
|
if w.file != nil {
|
|
return w.file.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// executeWithWorkerPool executes the inner step using a streaming worker pool pattern
|
|
// This is memory-efficient: creates only 'threads' goroutines instead of N goroutines
|
|
// and streams input lines on-demand instead of loading all into memory
|
|
func (e *ForeachExecutor) executeWithWorkerPool(ctx context.Context, step *core.Step, inputPath string, threads int, execCtx *core.ExecutionContext) ([]string, error) {
|
|
// Count lines first for result slice allocation (fast, O(n) with minimal memory)
|
|
lineCount, err := countInputLines(inputPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to count input lines: %w", err)
|
|
}
|
|
|
|
if lineCount == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
// Create bounded work queue - buffer 2x thread count for smooth flow
|
|
workQueue := make(chan workItem, threads*2)
|
|
results := make(chan workResult, threads*2)
|
|
|
|
// Track completion
|
|
var workerWg sync.WaitGroup
|
|
var producerErr error
|
|
|
|
// Start fixed worker pool (only 'threads' goroutines, not N)
|
|
for i := 0; i < threads; i++ {
|
|
workerWg.Add(1)
|
|
go func() {
|
|
defer workerWg.Done()
|
|
for work := range workQueue {
|
|
// Check context cancellation
|
|
if ctx.Err() != nil {
|
|
results <- workResult{index: work.index, err: ctx.Err()}
|
|
continue
|
|
}
|
|
|
|
// Apply variable pre-processing if configured
|
|
loopValue := work.value
|
|
if step.VariablePreProcess != "" {
|
|
processedValue, err := e.preProcessVariable(step.VariablePreProcess, step.Variable, work.value, execCtx)
|
|
if err != nil {
|
|
// Log warning but continue with original value (fail-safe)
|
|
logger.Get().Warn("variable pre-process failed, using original value",
|
|
zap.String("expression", step.VariablePreProcess),
|
|
zap.String("original_value", work.value),
|
|
zap.Error(err))
|
|
} else {
|
|
loopValue = processedValue
|
|
}
|
|
}
|
|
|
|
// Create optimized child context with loop variables pre-set
|
|
childCtx := execCtx.CloneForLoop(step.Variable, loopValue, work.index+1)
|
|
|
|
// Clone inner step and render secondary templates [[ ]]
|
|
innerStep := e.renderSecondaryTemplates(step.Step, childCtx)
|
|
|
|
// Execute inner step
|
|
stepResult, err := e.dispatcher.Dispatch(ctx, innerStep, childCtx)
|
|
|
|
var output string
|
|
if stepResult != nil {
|
|
output = stepResult.Output
|
|
}
|
|
|
|
// Wrap error with foreach iteration context
|
|
if err != nil {
|
|
renderedCmd := getInnerStepCommand(innerStep)
|
|
if rendered, renderErr := e.templateEngine.Render(renderedCmd, childCtx.GetVariables()); renderErr == nil {
|
|
renderedCmd = rendered
|
|
}
|
|
err = &ForeachIterationError{
|
|
Inner: err,
|
|
InputValue: loopValue,
|
|
RenderedCommand: renderedCmd,
|
|
Output: output,
|
|
}
|
|
}
|
|
|
|
results <- workResult{index: work.index, output: output, err: err}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Producer: stream lines into work queue (separate goroutine)
|
|
go func() {
|
|
defer close(workQueue)
|
|
|
|
iter, err := NewLineIterator(inputPath)
|
|
if err != nil {
|
|
producerErr = err
|
|
return
|
|
}
|
|
defer func() { _ = iter.Close() }()
|
|
|
|
idx := 0
|
|
for iter.Next() {
|
|
select {
|
|
case workQueue <- workItem{index: idx, value: iter.Value()}:
|
|
idx++
|
|
case <-ctx.Done():
|
|
producerErr = ctx.Err()
|
|
return
|
|
}
|
|
}
|
|
|
|
if iter.Err() != nil {
|
|
producerErr = iter.Err()
|
|
}
|
|
}()
|
|
|
|
// Collector: close results when all workers done
|
|
go func() {
|
|
workerWg.Wait()
|
|
close(results)
|
|
}()
|
|
|
|
// Collect results in order
|
|
outputs := make([]string, lineCount)
|
|
var firstError error
|
|
|
|
for r := range results {
|
|
if r.index < len(outputs) {
|
|
outputs[r.index] = r.output
|
|
}
|
|
if r.err != nil && firstError == nil {
|
|
firstError = r.err
|
|
}
|
|
}
|
|
|
|
// Check for producer error
|
|
if producerErr != nil && firstError == nil {
|
|
firstError = producerErr
|
|
}
|
|
|
|
return outputs, firstError
|
|
}
|
|
|
|
// executeWithWorkerPoolStreaming is like executeWithWorkerPool but supports streaming output to file.
|
|
// When streamingOutput is set, results are written directly to the file instead of being collected in memory.
|
|
// This enables O(1) memory usage for million-line inputs.
|
|
func (e *ForeachExecutor) executeWithWorkerPoolStreaming(ctx context.Context, step *core.Step, inputPath string, threads int, execCtx *core.ExecutionContext, streamingOutput string) ([]string, error) {
|
|
// If no streaming, delegate to original implementation
|
|
if streamingOutput == "" {
|
|
return e.executeWithWorkerPool(ctx, step, inputPath, threads, execCtx)
|
|
}
|
|
|
|
log := logger.Get()
|
|
log.Debug("Foreach streaming mode enabled",
|
|
zap.String("input", inputPath),
|
|
zap.String("output", streamingOutput),
|
|
zap.Int("threads", threads),
|
|
)
|
|
|
|
// Create streaming writer
|
|
writer, err := newStreamWriter(streamingOutput)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create streaming output file: %w", err)
|
|
}
|
|
defer func() { _ = writer.Close() }()
|
|
|
|
// Create bounded work queue
|
|
workQueue := make(chan workItem, threads*2)
|
|
done := make(chan struct{})
|
|
|
|
// Track completion
|
|
var workerWg sync.WaitGroup
|
|
var producerErr error
|
|
var writeErr error
|
|
var writeErrMu sync.Mutex
|
|
var firstErr error
|
|
var firstErrMu sync.Mutex
|
|
|
|
// Start fixed worker pool
|
|
for i := 0; i < threads; i++ {
|
|
workerWg.Add(1)
|
|
go func() {
|
|
defer workerWg.Done()
|
|
for work := range workQueue {
|
|
// Check context cancellation
|
|
if ctx.Err() != nil {
|
|
continue
|
|
}
|
|
|
|
// Apply variable pre-processing if configured
|
|
loopValue := work.value
|
|
if step.VariablePreProcess != "" {
|
|
processedValue, err := e.preProcessVariable(step.VariablePreProcess, step.Variable, work.value, execCtx)
|
|
if err != nil {
|
|
logger.Get().Warn("variable pre-process failed, using original value",
|
|
zap.String("expression", step.VariablePreProcess),
|
|
zap.String("original_value", work.value),
|
|
zap.Error(err))
|
|
} else {
|
|
loopValue = processedValue
|
|
}
|
|
}
|
|
|
|
// Create optimized child context with loop variables pre-set
|
|
childCtx := execCtx.CloneForLoop(step.Variable, loopValue, work.index+1)
|
|
|
|
// Clone inner step and render secondary templates [[ ]]
|
|
innerStep := e.renderSecondaryTemplates(step.Step, childCtx)
|
|
|
|
// Execute inner step
|
|
stepResult, dispatchErr := e.dispatcher.Dispatch(ctx, innerStep, childCtx)
|
|
|
|
// Track first error with foreach iteration context
|
|
if dispatchErr != nil {
|
|
firstErrMu.Lock()
|
|
if firstErr == nil {
|
|
renderedCmd := getInnerStepCommand(innerStep)
|
|
if rendered, renderErr := e.templateEngine.Render(renderedCmd, childCtx.GetVariables()); renderErr == nil {
|
|
renderedCmd = rendered
|
|
}
|
|
var output string
|
|
if stepResult != nil {
|
|
output = stepResult.Output
|
|
}
|
|
firstErr = &ForeachIterationError{
|
|
Inner: dispatchErr,
|
|
InputValue: loopValue,
|
|
RenderedCommand: renderedCmd,
|
|
Output: output,
|
|
}
|
|
}
|
|
firstErrMu.Unlock()
|
|
}
|
|
|
|
// Stream output directly to file (no memory collection)
|
|
if stepResult != nil && stepResult.Output != "" {
|
|
if err := writer.Write(stepResult.Output); err != nil {
|
|
writeErrMu.Lock()
|
|
if writeErr == nil {
|
|
writeErr = err
|
|
}
|
|
writeErrMu.Unlock()
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Producer: stream lines into work queue
|
|
go func() {
|
|
defer close(workQueue)
|
|
|
|
iter, err := NewLineIterator(inputPath)
|
|
if err != nil {
|
|
producerErr = err
|
|
return
|
|
}
|
|
defer func() { _ = iter.Close() }()
|
|
|
|
idx := 0
|
|
for iter.Next() {
|
|
select {
|
|
case workQueue <- workItem{index: idx, value: iter.Value()}:
|
|
idx++
|
|
case <-ctx.Done():
|
|
producerErr = ctx.Err()
|
|
return
|
|
}
|
|
}
|
|
|
|
if iter.Err() != nil {
|
|
producerErr = iter.Err()
|
|
}
|
|
}()
|
|
|
|
// Wait for all workers to complete
|
|
go func() {
|
|
workerWg.Wait()
|
|
close(done)
|
|
}()
|
|
|
|
<-done
|
|
|
|
// Check for errors (priority: producer > write > dispatch)
|
|
if producerErr != nil {
|
|
return nil, producerErr
|
|
}
|
|
if writeErr != nil {
|
|
return nil, fmt.Errorf("streaming write error: %w", writeErr)
|
|
}
|
|
if firstErr != nil {
|
|
return nil, firstErr
|
|
}
|
|
|
|
// Return empty slice since results were streamed
|
|
return nil, nil
|
|
}
|
|
|
|
// CanHandle returns true if this executor can handle the given step type
|
|
func (e *ForeachExecutor) CanHandle(stepType core.StepType) bool {
|
|
return stepType == core.StepTypeForeach
|
|
}
|
|
|
|
// autoQuoteForJS wraps string values in single quotes for JS function calls,
|
|
// escaping any internal single quotes.
|
|
func autoQuoteForJS(value string) string {
|
|
// Escape single quotes: ' -> \'
|
|
escaped := strings.ReplaceAll(value, "'", "\\'")
|
|
return "'" + escaped + "'"
|
|
}
|
|
|
|
// preProcessVariable evaluates the pre-process expression with the loop variable set.
|
|
// The expression can use [[variable]] syntax to reference the current value.
|
|
// For example: "get_parent_url([[url]])" with url="http://example.com/path"
|
|
// becomes: "get_parent_url('http://example.com/path')"
|
|
func (e *ForeachExecutor) preProcessVariable(expr, varName, varValue string, execCtx *core.ExecutionContext) (string, error) {
|
|
// Build context with parent variables
|
|
ctx := execCtx.GetVariables()
|
|
|
|
// For pre-process expressions, auto-quote the loop variable value
|
|
// This allows clean syntax: get_parent_url([[url]]) instead of get_parent_url('[[url]]')
|
|
ctx[varName] = autoQuoteForJS(varValue)
|
|
|
|
// Render secondary templates [[var]] -> 'quoted_value'
|
|
renderedExpr, err := e.templateEngine.RenderSecondary(expr, ctx)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to render pre-process expression: %w", err)
|
|
}
|
|
|
|
// Execute the function expression
|
|
result, err := e.functionRegistry.Execute(renderedExpr, ctx)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to execute pre-process function: %w", err)
|
|
}
|
|
|
|
// Convert result to string
|
|
return fmt.Sprintf("%v", result), nil
|
|
}
|
|
|
|
// readTruncatedFile reads up to maxBytes from a file, appending "[output truncated]" if the file is larger.
|
|
func readTruncatedFile(path string, maxBytes int) string {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
|
|
buf := make([]byte, maxBytes+1) // read 1 extra byte to detect truncation
|
|
n, _ := f.Read(buf)
|
|
if n == 0 {
|
|
return ""
|
|
}
|
|
if n > maxBytes {
|
|
return string(buf[:maxBytes]) + "\n[output truncated]"
|
|
}
|
|
return string(buf[:n])
|
|
}
|