Add shared bounded pipeline plans
This commit is contained in:
@@ -145,6 +145,8 @@ fixed dependency relation required by the feature roadmap.
|
|||||||
|
|
||||||
## Stage 2 — Shared Bounded Plan Selection
|
## Stage 2 — Shared Bounded Plan Selection
|
||||||
|
|
||||||
|
**Status: Completed**
|
||||||
|
|
||||||
### Goal
|
### Goal
|
||||||
|
|
||||||
Give the application one validated representation of a contiguous canonical
|
Give the application one validated representation of a contiguous canonical
|
||||||
|
|||||||
@@ -2,13 +2,134 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// BoundedPlan is one validated inclusive range of the canonical pipeline.
|
||||||
|
// It owns effective endpoints and membership so command, runner, and
|
||||||
|
// composition callers do not independently interpret range bounds.
|
||||||
|
type BoundedPlan struct {
|
||||||
|
stages []stage.Stage
|
||||||
|
canonicalNames []string
|
||||||
|
startIndex int
|
||||||
|
endIndex int
|
||||||
|
explicitFrom bool
|
||||||
|
explicitThrough bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildBoundedPlan selects an inclusive contiguous range of the canonical
|
||||||
|
// pipeline. Empty endpoints default to the beginning or end respectively.
|
||||||
|
func BuildBoundedPlan(from, through string) (BoundedPlan, error) {
|
||||||
|
registry := stage.All()
|
||||||
|
names := make([]string, len(registry))
|
||||||
|
indices := make(map[string]int, len(registry))
|
||||||
|
for index, candidate := range registry {
|
||||||
|
if candidate == nil {
|
||||||
|
return BoundedPlan{}, fmt.Errorf("build bounded plan: canonical stage %d is nil", index)
|
||||||
|
}
|
||||||
|
name := candidate.Name()
|
||||||
|
if _, duplicate := indices[name]; duplicate {
|
||||||
|
return BoundedPlan{}, fmt.Errorf("build bounded plan: duplicate canonical stage %q", name)
|
||||||
|
}
|
||||||
|
names[index] = name
|
||||||
|
indices[name] = index
|
||||||
|
}
|
||||||
|
if len(registry) == 0 {
|
||||||
|
return BoundedPlan{}, fmt.Errorf("build bounded plan: canonical stage registry is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
start := 0
|
||||||
|
if from != "" {
|
||||||
|
var ok bool
|
||||||
|
start, ok = indices[from]
|
||||||
|
if !ok {
|
||||||
|
return BoundedPlan{}, fmt.Errorf("build bounded plan: unknown from stage %q; valid stages: %s", from, strings.Join(names, ", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end := len(registry) - 1
|
||||||
|
if through != "" {
|
||||||
|
var ok bool
|
||||||
|
end, ok = indices[through]
|
||||||
|
if !ok {
|
||||||
|
return BoundedPlan{}, fmt.Errorf("build bounded plan: unknown through stage %q; valid stages: %s", through, strings.Join(names, ", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if start > end {
|
||||||
|
return BoundedPlan{}, fmt.Errorf("build bounded plan: from stage %q occurs after through stage %q; valid stages: %s", from, through, strings.Join(names, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
return BoundedPlan{
|
||||||
|
stages: append([]stage.Stage(nil), registry[start:end+1]...),
|
||||||
|
canonicalNames: append([]string(nil), names...),
|
||||||
|
startIndex: start,
|
||||||
|
endIndex: end,
|
||||||
|
explicitFrom: from != "",
|
||||||
|
explicitThrough: through != "",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stages returns a copy of the selected canonical stages.
|
||||||
|
func (p BoundedPlan) Stages() []stage.Stage {
|
||||||
|
return append([]stage.Stage(nil), p.stages...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Names returns selected stage names in canonical order.
|
||||||
|
func (p BoundedPlan) Names() []string {
|
||||||
|
out := make([]string, 0, len(p.stages))
|
||||||
|
for _, candidate := range p.stages {
|
||||||
|
out = append(out, candidate.Name())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// From returns the effective inclusive start stage.
|
||||||
|
func (p BoundedPlan) From() string {
|
||||||
|
if len(p.canonicalNames) == 0 || p.startIndex < 0 || p.startIndex >= len(p.canonicalNames) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return p.canonicalNames[p.startIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Through returns the effective inclusive end stage.
|
||||||
|
func (p BoundedPlan) Through() string {
|
||||||
|
if len(p.canonicalNames) == 0 || p.endIndex < 0 || p.endIndex >= len(p.canonicalNames) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return p.canonicalNames[p.endIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contains reports whether a canonical stage is selected by the range.
|
||||||
|
func (p BoundedPlan) Contains(name string) bool {
|
||||||
|
for _, candidate := range p.stages {
|
||||||
|
if candidate.Name() == name {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrefixNames returns canonical stages excluded before the selected start.
|
||||||
|
func (p BoundedPlan) PrefixNames() []string {
|
||||||
|
if p.startIndex <= 0 || p.startIndex > len(p.canonicalNames) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return append([]string(nil), p.canonicalNames[:p.startIndex]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasExplicitBounds reports whether either endpoint was supplied by the caller.
|
||||||
|
func (p BoundedPlan) HasExplicitBounds() bool {
|
||||||
|
return p.explicitFrom || p.explicitThrough
|
||||||
|
}
|
||||||
|
|
||||||
// BuildFullPlan returns the canonical full stage list in deterministic order.
|
// BuildFullPlan returns the canonical full stage list in deterministic order.
|
||||||
func BuildFullPlan() []stage.Stage {
|
func BuildFullPlan() []stage.Stage {
|
||||||
return stage.All()
|
plan, err := BuildBoundedPlan("", "")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return plan.Stages()
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildSingleStagePlan returns a one-stage plan for an exact stage name.
|
// BuildSingleStagePlan returns a one-stage plan for an exact stage name.
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||||
|
)
|
||||||
|
|
||||||
func TestBuildFullPlanOrder(t *testing.T) {
|
func TestBuildFullPlanOrder(t *testing.T) {
|
||||||
got := BuildFullPlan()
|
got := BuildFullPlan()
|
||||||
@@ -34,3 +40,113 @@ func TestBuildSingleStagePlanUnknown(t *testing.T) {
|
|||||||
t.Fatal("expected error for unknown stage, got nil")
|
t.Fatal("expected error for unknown stage, got nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildBoundedPlanEndpoints(t *testing.T) {
|
||||||
|
canonical := stageNames(BuildFullPlan())
|
||||||
|
for index, name := range canonical {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
one, err := BuildBoundedPlan(name, name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildBoundedPlan(%q, %q) error = %v", name, name, err)
|
||||||
|
}
|
||||||
|
if got := one.Names(); !reflect.DeepEqual(got, []string{name}) {
|
||||||
|
t.Fatalf("one-stage names = %#v, want %q", got, name)
|
||||||
|
}
|
||||||
|
if one.From() != name || one.Through() != name || !one.Contains(name) || !one.HasExplicitBounds() {
|
||||||
|
t.Fatalf("one-stage plan endpoints or membership = %#v", one)
|
||||||
|
}
|
||||||
|
|
||||||
|
from, err := BuildBoundedPlan(name, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildBoundedPlan(%q, empty) error = %v", name, err)
|
||||||
|
}
|
||||||
|
if got := from.Names(); !reflect.DeepEqual(got, canonical[index:]) {
|
||||||
|
t.Fatalf("from names = %#v, want %#v", got, canonical[index:])
|
||||||
|
}
|
||||||
|
|
||||||
|
through, err := BuildBoundedPlan("", name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildBoundedPlan(empty, %q) error = %v", name, err)
|
||||||
|
}
|
||||||
|
if got := through.Names(); !reflect.DeepEqual(got, canonical[:index+1]) {
|
||||||
|
t.Fatalf("through names = %#v, want %#v", got, canonical[:index+1])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildBoundedPlanDefaultsToFullCanonicalPlan(t *testing.T) {
|
||||||
|
plan, err := BuildBoundedPlan("", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildBoundedPlan() error = %v", err)
|
||||||
|
}
|
||||||
|
want := stageNames(BuildFullPlan())
|
||||||
|
if got := plan.Names(); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("bounded names = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
if plan.From() != want[0] || plan.Through() != want[len(want)-1] || plan.HasExplicitBounds() {
|
||||||
|
t.Fatalf("default endpoints = %q through %q explicit=%t", plan.From(), plan.Through(), plan.HasExplicitBounds())
|
||||||
|
}
|
||||||
|
if got := plan.PrefixNames(); len(got) != 0 {
|
||||||
|
t.Fatalf("default prefix = %#v, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildBoundedPlanRejectsInvalidBounds(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
from string
|
||||||
|
through string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{name: "unknown from", from: "missing", through: "analyze", want: []string{"unknown from stage", "missing", "prepare", "notify"}},
|
||||||
|
{name: "unknown through", from: "extract", through: "missing", want: []string{"unknown through stage", "missing", "prepare", "notify"}},
|
||||||
|
{name: "reversed", from: "publish", through: "render", want: []string{"publish", "occurs after", "render", "prepare", "notify"}},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, err := BuildBoundedPlan(test.from, test.through)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("BuildBoundedPlan() error = nil")
|
||||||
|
}
|
||||||
|
for _, fragment := range test.want {
|
||||||
|
if !strings.Contains(err.Error(), fragment) {
|
||||||
|
t.Fatalf("error = %q, want fragment %q", err, fragment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoundedPlanIsContiguousAndCannotMutateRegistry(t *testing.T) {
|
||||||
|
before := stageNames(BuildFullPlan())
|
||||||
|
plan, err := BuildBoundedPlan("trim", "analyze")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildBoundedPlan() error = %v", err)
|
||||||
|
}
|
||||||
|
want := []string{"trim", "render", "extract", "analyze"}
|
||||||
|
if got := plan.Names(); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("names = %#v, want contiguous %#v", got, want)
|
||||||
|
}
|
||||||
|
if got := plan.PrefixNames(); !reflect.DeepEqual(got, before[:5]) {
|
||||||
|
t.Fatalf("prefix = %#v, want %#v", got, before[:5])
|
||||||
|
}
|
||||||
|
stages := plan.Stages()
|
||||||
|
stages[0] = nil
|
||||||
|
names := plan.Names()
|
||||||
|
names[0] = "changed"
|
||||||
|
if got := plan.Names(); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("mutated plan names = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
if got := stageNames(BuildFullPlan()); !reflect.DeepEqual(got, before) {
|
||||||
|
t.Fatalf("canonical registry changed = %#v, want %#v", got, before)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stageNames(stages []stage.Stage) []string {
|
||||||
|
names := make([]string, 0, len(stages))
|
||||||
|
for _, candidate := range stages {
|
||||||
|
names = append(names, candidate.Name())
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user