|
8 | 8 | "fmt" |
9 | 9 | "os" |
10 | 10 | "strings" |
| 11 | + "sync" |
11 | 12 | "time" |
12 | 13 |
|
13 | 14 | "github.com/autonoco/buttons/internal/battery" |
@@ -410,13 +411,12 @@ func (e *Executor) runDrawerStep(ctx context.Context, step *Step, ctxMap Context |
410 | 411 | // its own child context with the outer context plus the loop |
411 | 412 | // variable (step.As) bound to the current item. |
412 | 413 | // |
413 | | -// v1 is serial (no parallelism). Per-item failures stop the loop by |
414 | | -// default; OnItemFailure="continue" records the error and moves on. |
415 | | -// Output is {results: [...]} — one entry per iteration, each a map |
416 | | -// of {step_id: step.Output} for the nested steps. Downstream refs |
417 | | -// walk this via ${<for_each_id>.output.results.0.step_id.field} |
418 | | -// (indexed access) but will typically be consumed by an aggregator |
419 | | -// step in later stages. |
| 414 | +// Concurrency: Parallelism <= 1 runs serially (deterministic order, |
| 415 | +// fail-fast). Parallelism > 1 spawns a worker pool. Results are |
| 416 | +// still written in input order regardless of completion order so |
| 417 | +// downstream refs like ${step.output.results.0.item} stay stable. |
| 418 | +// OnItemFailure="stop" in parallel mode cancels in-flight workers |
| 419 | +// via a sub-context the moment the first failure is observed. |
420 | 420 | func (e *Executor) runForEachStep(ctx context.Context, step *Step, ctxMap Context) (StepRun, error) { |
421 | 421 | sr := StepRun{ID: step.ID} |
422 | 422 |
|
@@ -462,65 +462,153 @@ func (e *Executor) runForEachStep(ctx context.Context, step *Step, ctxMap Contex |
462 | 462 | } |
463 | 463 |
|
464 | 464 | continueOnFail := step.OnItemFailure == "continue" |
465 | | - // []any (not []map[string]any) so downstream ref resolvers and |
466 | | - // aggregate steps can walk it without type gymnastics. |
467 | | - results := make([]any, 0, len(items)) |
| 465 | + parallelism := step.Parallelism |
| 466 | + if parallelism < 1 { |
| 467 | + parallelism = 1 |
| 468 | + } |
| 469 | + |
| 470 | + // Preallocated, index-addressable results so writers can drop |
| 471 | + // their output at position i without a mutex — each worker owns |
| 472 | + // exactly one index. |
| 473 | + results := make([]any, len(items)) |
| 474 | + errs := make([]error, len(items)) |
| 475 | + |
| 476 | + // Parallelism == 1 keeps the serial, fail-fast semantics that |
| 477 | + // stage 3 shipped. Inlined to avoid the channel/goroutine cost |
| 478 | + // for small loops where serial was already fine. |
| 479 | + if parallelism == 1 { |
| 480 | + for i, item := range items { |
| 481 | + results[i], errs[i] = e.runForEachIter(ctx, step, asName, item, i, ctxMap) |
| 482 | + if errs[i] != nil && !continueOnFail { |
| 483 | + sr.Status = "failed" |
| 484 | + sr.Output = map[string]any{ |
| 485 | + "results": trimNilTail(results, i+1), |
| 486 | + "completed": i + 1, |
| 487 | + "total": len(items), |
| 488 | + } |
| 489 | + sr.Error = &StepError{ |
| 490 | + Code: "FOREACH_ITEM_FAILED", |
| 491 | + Message: fmt.Sprintf("iteration %d failed: %v", i, errs[i]), |
| 492 | + Remediation: "fix the failing nested step or set on_item_failure: continue to tolerate per-item errors", |
| 493 | + } |
| 494 | + return sr, errs[i] |
| 495 | + } |
| 496 | + } |
| 497 | + sr.Status = "ok" |
| 498 | + sr.Output = map[string]any{"results": results, "total": len(items)} |
| 499 | + return sr, nil |
| 500 | + } |
| 501 | + |
| 502 | + // Parallel mode. workerCtx gets cancelled on first failure |
| 503 | + // when OnItemFailure != "continue" so in-flight iterations |
| 504 | + // abort promptly instead of finishing their work after we've |
| 505 | + // already decided to fail the step. |
| 506 | + workerCtx, cancel := context.WithCancel(ctx) |
| 507 | + defer cancel() |
| 508 | + |
| 509 | + // Bounded semaphore + sync primitives. A plain buffered chan |
| 510 | + // doubles as the semaphore (acquire by send, release by recv) |
| 511 | + // without pulling in x/sync. |
| 512 | + sem := make(chan struct{}, parallelism) |
| 513 | + var wg sync.WaitGroup |
| 514 | + var failMu sync.Mutex |
| 515 | + var firstFailIdx int = -1 |
| 516 | + var firstFailErr error |
468 | 517 |
|
469 | 518 | for i, item := range items { |
470 | | - // Per-iteration context: start from the outer ctxMap and |
471 | | - // overlay the loop variable so nested ${<as>.field} refs |
472 | | - // resolve. Don't mutate the outer map — each iter gets its |
473 | | - // own. |
474 | | - iterCtx := Context{} |
475 | | - for k, v := range ctxMap { |
476 | | - iterCtx[k] = v |
| 519 | + // Respect early-cancel decisions made by previous workers. |
| 520 | + if workerCtx.Err() != nil { |
| 521 | + break |
477 | 522 | } |
478 | | - iterCtx[asName] = item |
479 | | - |
480 | | - iterOut := map[string]any{} |
481 | | - var iterErr error |
482 | | - for _, nested := range step.Steps { |
483 | | - nestedStep := nested |
484 | | - stepRes, serr := e.runStep(ctx, nil, &nestedStep, iterCtx) |
485 | | - iterOut[nestedStep.ID] = map[string]any{ |
486 | | - "status": stepRes.Status, |
487 | | - "output": stepRes.Output, |
488 | | - "error": stepRes.Error, |
| 523 | + wg.Add(1) |
| 524 | + sem <- struct{}{} |
| 525 | + go func(idx int, it any) { |
| 526 | + defer wg.Done() |
| 527 | + defer func() { <-sem }() |
| 528 | + out, err := e.runForEachIter(workerCtx, step, asName, it, idx, ctxMap) |
| 529 | + results[idx] = out |
| 530 | + errs[idx] = err |
| 531 | + if err != nil && !continueOnFail { |
| 532 | + failMu.Lock() |
| 533 | + if firstFailIdx == -1 || idx < firstFailIdx { |
| 534 | + firstFailIdx = idx |
| 535 | + firstFailErr = err |
| 536 | + } |
| 537 | + failMu.Unlock() |
| 538 | + cancel() |
489 | 539 | } |
490 | | - // Expose the just-completed nested step's output so |
491 | | - // later nested steps in the same iteration can chain. |
492 | | - iterCtx[nestedStep.ID] = map[string]any{"output": stepRes.Output} |
493 | | - if serr != nil { |
494 | | - iterErr = serr |
495 | | - break |
| 540 | + }(i, item) |
| 541 | + } |
| 542 | + wg.Wait() |
| 543 | + |
| 544 | + if firstFailIdx != -1 { |
| 545 | + completed := 0 |
| 546 | + for i := range results { |
| 547 | + if results[i] != nil { |
| 548 | + completed++ |
496 | 549 | } |
497 | 550 | } |
| 551 | + sr.Status = "failed" |
| 552 | + sr.Output = map[string]any{ |
| 553 | + "results": results, |
| 554 | + "completed": completed, |
| 555 | + "total": len(items), |
| 556 | + } |
| 557 | + sr.Error = &StepError{ |
| 558 | + Code: "FOREACH_ITEM_FAILED", |
| 559 | + Message: fmt.Sprintf("iteration %d failed: %v", firstFailIdx, firstFailErr), |
| 560 | + Remediation: "fix the failing nested step or set on_item_failure: continue to tolerate per-item errors", |
| 561 | + } |
| 562 | + return sr, firstFailErr |
| 563 | + } |
498 | 564 |
|
499 | | - results = append(results, map[string]any{ |
500 | | - "index": i, |
501 | | - "item": item, |
502 | | - "steps": iterOut, |
503 | | - "failed": iterErr != nil, |
504 | | - }) |
| 565 | + sr.Status = "ok" |
| 566 | + sr.Output = map[string]any{"results": results, "total": len(items)} |
| 567 | + return sr, nil |
| 568 | +} |
505 | 569 |
|
506 | | - if iterErr != nil && !continueOnFail { |
507 | | - sr.Status = "failed" |
508 | | - sr.Output = map[string]any{"results": results, "completed": i + 1, "total": len(items)} |
509 | | - sr.Error = &StepError{ |
510 | | - Code: "FOREACH_ITEM_FAILED", |
511 | | - Message: fmt.Sprintf("iteration %d failed: %v", i, iterErr), |
512 | | - Remediation: "fix the failing nested step or set on_item_failure: continue to tolerate per-item errors", |
513 | | - } |
514 | | - return sr, iterErr |
| 570 | +// runForEachIter executes the nested Steps for one iteration against |
| 571 | +// a per-iteration context. Extracted so both the serial and parallel |
| 572 | +// paths share exactly one implementation of the per-iter semantics. |
| 573 | +func (e *Executor) runForEachIter(ctx context.Context, step *Step, asName string, item any, idx int, outerCtx Context) (map[string]any, error) { |
| 574 | + iterCtx := Context{} |
| 575 | + for k, v := range outerCtx { |
| 576 | + iterCtx[k] = v |
| 577 | + } |
| 578 | + iterCtx[asName] = item |
| 579 | + |
| 580 | + iterOut := map[string]any{} |
| 581 | + var iterErr error |
| 582 | + for _, nested := range step.Steps { |
| 583 | + nestedStep := nested |
| 584 | + stepRes, serr := e.runStep(ctx, nil, &nestedStep, iterCtx) |
| 585 | + iterOut[nestedStep.ID] = map[string]any{ |
| 586 | + "status": stepRes.Status, |
| 587 | + "output": stepRes.Output, |
| 588 | + "error": stepRes.Error, |
| 589 | + } |
| 590 | + iterCtx[nestedStep.ID] = map[string]any{"output": stepRes.Output} |
| 591 | + if serr != nil { |
| 592 | + iterErr = serr |
| 593 | + break |
515 | 594 | } |
516 | 595 | } |
| 596 | + return map[string]any{ |
| 597 | + "index": idx, |
| 598 | + "item": item, |
| 599 | + "steps": iterOut, |
| 600 | + "failed": iterErr != nil, |
| 601 | + }, iterErr |
| 602 | +} |
517 | 603 |
|
518 | | - sr.Status = "ok" |
519 | | - sr.Output = map[string]any{ |
520 | | - "results": results, |
521 | | - "total": len(items), |
| 604 | +// trimNilTail is used by the serial path to return a results slice |
| 605 | +// that only includes actually-completed iterations, keeping the |
| 606 | +// shape compatible with the pre-parallelism stage 3 behavior. |
| 607 | +func trimNilTail(xs []any, upTo int) []any { |
| 608 | + if upTo > len(xs) { |
| 609 | + upTo = len(xs) |
522 | 610 | } |
523 | | - return sr, nil |
| 611 | + return xs[:upTo] |
524 | 612 | } |
525 | 613 |
|
526 | 614 | // runSwitchStep handles kind=switch — an if/elif/else chain. Walks |
|
0 commit comments