324 lines
10 KiB
Go
324 lines
10 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
|
)
|
|
|
|
func TestRecomputeStepRecoversThroughFilesystemCheckpoints(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
invalidateOutput bool
|
|
wantCode int
|
|
}{
|
|
{name: "accepted producer is hydrated", wantCode: 0},
|
|
{name: "invalid producer stops dependents", invalidateOutput: true, wantCode: 1},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
roots := newRecomputeTestRoots(t)
|
|
harness := newRecomputeTestHarness()
|
|
fresh := runRecomputeCommand(roots, harness.options(), false)
|
|
if fresh.code != 0 {
|
|
t.Fatalf("fresh run code=%d stderr=%q", fresh.code, fresh.stderr)
|
|
}
|
|
removeCheckpointLaneStage(t, roots.checkpoints, "extract", "first", "producer")
|
|
removeCheckpointLaneStage(t, roots.checkpoints, "merge", "first", "producer")
|
|
if tt.invalidateOutput {
|
|
path := findCheckpointFile(t, roots.checkpoints, "normalize", "first", "producer", "output.json")
|
|
if err := os.WriteFile(path, []byte("{"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
harness.resetCalls()
|
|
|
|
resumed := runRecomputeCommand(roots, harness.options(), true)
|
|
if resumed.code != tt.wantCode {
|
|
t.Fatalf("resumed code=%d stdout=%q stderr=%q", resumed.code, resumed.stdout, resumed.stderr)
|
|
}
|
|
events := readLatestCheckpointEvents(t, roots.debug)
|
|
if tt.invalidateOutput {
|
|
if harness.callsFor("test/extract/middle") != 0 || harness.callsFor("test/extract/dependent") != 0 {
|
|
t.Fatalf("dependent calls after invalid producer = %#v", harness.callsSnapshot())
|
|
}
|
|
if !strings.Contains(resumed.stderr, string(pipeline.CheckpointReasonDecodeFailed)) {
|
|
t.Fatalf("stderr=%q, want stable checkpoint reason", resumed.stderr)
|
|
}
|
|
assertNormalizeDecisionSequence(t, events, []checkpointDecisionExpectation{{"first", "producer", pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonDecodeFailed}})
|
|
return
|
|
}
|
|
|
|
if got := harness.callsSnapshot(); !reflect.DeepEqual(got, map[string]int{"test/extract/dependent": 1, "test/extract/middle": 1}) {
|
|
t.Fatalf("resumed extractor calls = %#v", got)
|
|
}
|
|
outputPath := filepath.Join(latestChildDir(t, roots.output), "result.json")
|
|
data, err := os.ReadFile(outputPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(data) != "[\"producer\",\"unrelated\",\"middle\",\"dependent\"]\n" {
|
|
t.Fatalf("ordered output = %q", data)
|
|
}
|
|
assertNormalizeDecisionSequence(t, events, []checkpointDecisionExpectation{
|
|
{"first", "producer", pipeline.CheckpointDecisionReused, pipeline.CheckpointReasonAcceptedArtifactReused},
|
|
{"first", "unrelated", pipeline.CheckpointDecisionReused, pipeline.CheckpointReasonReused},
|
|
{"second", "middle", pipeline.CheckpointDecisionForcedRecompute, pipeline.CheckpointReasonRecomputeStep},
|
|
{"third", "dependent", pipeline.CheckpointDecisionForcedRecompute, pipeline.CheckpointReasonRecomputeStep},
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
type checkpointDecisionExpectation struct {
|
|
step, lane string
|
|
action pipeline.CheckpointDecisionCategory
|
|
reason pipeline.CheckpointReasonCode
|
|
}
|
|
|
|
func assertNormalizeDecisionSequence(t *testing.T, events []pipeline.CheckpointEvent, want []checkpointDecisionExpectation) {
|
|
t.Helper()
|
|
var got []checkpointDecisionExpectation
|
|
for _, event := range events {
|
|
if event.Stage == string(pipeline.StageNormalize) {
|
|
got = append(got, checkpointDecisionExpectation{event.StepID, event.LaneID, event.Action, event.ReasonCode})
|
|
}
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("normalize decisions = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
type recomputeTestHarness struct {
|
|
base *stateTestHarness
|
|
mu sync.Mutex
|
|
calls map[string]int
|
|
}
|
|
|
|
func newRecomputeTestHarness() *recomputeTestHarness {
|
|
return &recomputeTestHarness{base: newStateTestHarness(), calls: make(map[string]int)}
|
|
}
|
|
|
|
func (h *recomputeTestHarness) options() Options {
|
|
opts := h.base.options()
|
|
for _, key := range []string{"test/extract/producer", "test/extract/unrelated", "test/extract/middle", "test/extract/dependent"} {
|
|
moduleKey := key
|
|
spec := pipeline.ModuleSpec{
|
|
Key: moduleKey, Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}, ArtifactKind: stateTestArtifactKind,
|
|
ReferenceSlots: []contracts.ReferenceSlot{{Name: "upstream", AcceptedMediaTypes: []string{"application/json"}, AcceptedArtifactKinds: []contracts.ArtifactKind{stateTestArtifactKind}}},
|
|
}
|
|
if err := pipeline.RegisterExtractor(opts.Registries.Extractors, spec, func() (contracts.Extractor[stateTestArtifact], error) {
|
|
return recomputeTestExtractor{key: moduleKey, harness: h}, nil
|
|
}); err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
if err := opts.Registries.Outputs.RegisterWithSpec(pipeline.ModuleSpec{Key: "test/recompute-output", Stage: pipeline.StageOutput, Requires: []string{"normalized"}, Provides: []string{"output"}}, func() (contracts.OutputEncoder, error) {
|
|
return recomputeTestOutput{}, nil
|
|
}); err != nil {
|
|
panic(err)
|
|
}
|
|
opts.Catalog = catalogFromRegistries(opts.Registries)
|
|
return opts
|
|
}
|
|
|
|
func (h *recomputeTestHarness) record(key string) {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
h.calls[key]++
|
|
}
|
|
|
|
func (h *recomputeTestHarness) resetCalls() {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
h.calls = make(map[string]int)
|
|
}
|
|
|
|
func (h *recomputeTestHarness) callsFor(key string) int {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
return h.calls[key]
|
|
}
|
|
|
|
func (h *recomputeTestHarness) callsSnapshot() map[string]int {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
result := make(map[string]int, len(h.calls))
|
|
for key, value := range h.calls {
|
|
result[key] = value
|
|
}
|
|
return result
|
|
}
|
|
|
|
type recomputeTestExtractor struct {
|
|
key string
|
|
harness *recomputeTestHarness
|
|
}
|
|
|
|
func (e recomputeTestExtractor) Key() string { return e.key }
|
|
func (e recomputeTestExtractor) ReferenceSlots() []contracts.ReferenceSlot {
|
|
return []contracts.ReferenceSlot{{Name: "upstream", AcceptedMediaTypes: []string{"application/json"}, AcceptedArtifactKinds: []contracts.ArtifactKind{stateTestArtifactKind}}}
|
|
}
|
|
func (e recomputeTestExtractor) Extract(context.Context, contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[stateTestArtifact], error) {
|
|
e.harness.record(e.key)
|
|
return contracts.TypedExtractionResult[stateTestArtifact]{Value: stateTestArtifact{Value: e.key}}, nil
|
|
}
|
|
|
|
type recomputeTestOutput struct{}
|
|
|
|
func (recomputeTestOutput) Key() string { return "test/recompute-output" }
|
|
func (recomputeTestOutput) Encode(_ context.Context, req contracts.OutputRequest) (contracts.OutputResult, error) {
|
|
lanes := make([]string, 0, len(req.NormalizeOutputs))
|
|
for _, output := range req.NormalizeOutputs {
|
|
lanes = append(lanes, output.LaneID)
|
|
}
|
|
data, err := json.Marshal(lanes)
|
|
if err != nil {
|
|
return contracts.OutputResult{}, err
|
|
}
|
|
return contracts.OutputResult{Files: []contracts.OutputFile{{Name: "result.json", Bytes: append(data, '\n')}}}, nil
|
|
}
|
|
|
|
func newRecomputeTestRoots(t *testing.T) stateTestRoots {
|
|
t.Helper()
|
|
roots := newStateTestRoots(t)
|
|
config := fmt.Sprintf(`version: 4
|
|
output:
|
|
directory: %q
|
|
cache:
|
|
chunk_plans:
|
|
directory: %q
|
|
mode: bypass
|
|
checkpoints:
|
|
enabled: true
|
|
directory: %q
|
|
debug:
|
|
directory: %q
|
|
pipelines:
|
|
sample:
|
|
input: test/input
|
|
chunk: test/chunk
|
|
steps:
|
|
- id: first
|
|
artifacts:
|
|
producer:
|
|
extract: test/extract/producer
|
|
merge: test/merge
|
|
normalize: test/normalize
|
|
unrelated:
|
|
extract: test/extract/unrelated
|
|
merge: test/merge
|
|
normalize: test/normalize
|
|
- id: second
|
|
references:
|
|
upstream:
|
|
artifact:
|
|
step: first
|
|
lane: producer
|
|
artifacts:
|
|
middle:
|
|
extract: test/extract/middle
|
|
merge: test/merge
|
|
normalize: test/normalize
|
|
- id: third
|
|
references:
|
|
upstream:
|
|
artifact:
|
|
step: second
|
|
lane: middle
|
|
artifacts:
|
|
dependent:
|
|
extract: test/extract/dependent
|
|
merge: test/merge
|
|
normalize: test/normalize
|
|
output: test/recompute-output
|
|
`, roots.output, roots.plans, roots.checkpoints, roots.debug)
|
|
if err := os.WriteFile(roots.config, []byte(config), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return roots
|
|
}
|
|
|
|
func runRecomputeCommand(roots stateTestRoots, opts Options, recompute bool) stateTestResult {
|
|
args := []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass"}
|
|
if recompute {
|
|
args = append(args, "--resume", "--recompute-step", "second", "--debug")
|
|
}
|
|
var stdout, stderr bytes.Buffer
|
|
return stateTestResult{code: RunWithOptions(args, &stdout, &stderr, opts), stdout: stdout.String(), stderr: stderr.String()}
|
|
}
|
|
|
|
func removeCheckpointLaneStage(t *testing.T, root, stage, step, lane string) {
|
|
t.Helper()
|
|
dir := filepath.Dir(findCheckpointFile(t, root, stage, step, lane, "manifest.json"))
|
|
if err := os.RemoveAll(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func findCheckpointFile(t *testing.T, root, stage, step, lane, name string) string {
|
|
t.Helper()
|
|
want := filepath.Join(stage, step, lane, name)
|
|
var matches []string
|
|
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !entry.IsDir() && strings.HasSuffix(path, want) {
|
|
matches = append(matches, path)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(matches) != 1 {
|
|
t.Fatalf("checkpoint files ending in %q = %v", want, matches)
|
|
}
|
|
return matches[0]
|
|
}
|
|
|
|
func latestChildDir(t *testing.T, root string) string {
|
|
t.Helper()
|
|
entries, err := os.ReadDir(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var dirs []string
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
dirs = append(dirs, filepath.Join(root, entry.Name()))
|
|
}
|
|
}
|
|
if len(dirs) == 0 {
|
|
t.Fatal("no child directory")
|
|
}
|
|
sort.Strings(dirs)
|
|
return dirs[len(dirs)-1]
|
|
}
|
|
|
|
func readLatestCheckpointEvents(t *testing.T, root string) []pipeline.CheckpointEvent {
|
|
t.Helper()
|
|
var events []pipeline.CheckpointEvent
|
|
data, err := os.ReadFile(filepath.Join(latestChildDir(t, root), "summary", "checkpoint-events.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := json.Unmarshal(data, &events); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return events
|
|
}
|