From 1aae6d36a524d69b2dc1bcddf42e26705cc54421 Mon Sep 17 00:00:00 2001 From: Lukas Wuttke Date: Tue, 2 Jun 2026 10:25:41 +0200 Subject: [PATCH] fix(dataset push): make live ingestion work end-to-end The first real-cluster run of `dataset push` surfaced three issues that the mock-only test suite couldn't catch. All three are now validated end-to-end against a live tracebloc/client release (image_classification, 6/6 records, 100% success, rows confirmed in MySQL). 1. Staging collided with the ingestor's DEST_PATH (the blocker). The CLI staged source files into /data/shared/, which is exactly the ingestor's DEST_PATH (data-ingestors config.DEST_PATH = STORAGE_PATH/TABLE_NAME). Its DuplicateValidator rejects a pre-existing, non-empty destination, so the CLI's own staging tripped the check on every run. StagedPrefix now stages under /data/shared/.tracebloc-staging/
, leaving the destination fresh. Added FinalDestPrefix for the real destination shown in pre-flight. 2. Image target_size had no override. image_classification defaults to 512x512 and the ingestor VALIDATES the resolution (it does not resize), so any other size hard-failed the Image Resolution Validator. Added --target-size WxH, plus auto-detect from the first image when the flag is omitted, emitted as spec.file_options.target_size. 3. Watch treated a broken log stream as fatal. A Pod replaced/restarted/ deleted mid-follow (e.g. a backoffLimit retry) returned exit 9 even when the Job ultimately succeeded. The Job's terminal status is now the source of truth: a non-ctx stream error is only fatal if the Job outcome can't be determined. Tests: new push/detect_test.go (ParseTargetSize, DetectImageSize); spec_test.go (staging != dest regression, target_size passes schema); watch_test.go (Job status wins on stream break); stream_test.go updated to derive paths from StagedPrefix. go build / vet / test all green. Co-Authored-By: Claude Opus 4.8 --- internal/cli/dataset.go | 67 +++++++++++++++++------ internal/push/detect.go | 70 ++++++++++++++++++++++++ internal/push/detect_test.go | 88 ++++++++++++++++++++++++++++++ internal/push/spec.go | 100 ++++++++++++++++++++++++++++------ internal/push/spec_test.go | 94 +++++++++++++++++++++++++++++++- internal/push/stage.go | 8 +-- internal/push/stream_test.go | 43 ++++++++++----- internal/submit/watch.go | 72 ++++++++++++++++-------- internal/submit/watch_test.go | 56 +++++++++++++++++++ 9 files changed, 520 insertions(+), 78 deletions(-) create mode 100644 internal/push/detect.go create mode 100644 internal/push/detect_test.go diff --git a/internal/cli/dataset.go b/internal/cli/dataset.go index 5e76e0f9..eb97ea23 100644 --- a/internal/cli/dataset.go +++ b/internal/cli/dataset.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "path/filepath" "github.com/spf13/cobra" "gopkg.in/yaml.v3" @@ -74,6 +75,7 @@ func newDatasetPushCmd() *cobra.Command { category string intent string labelColumn string + targetSize string // Operations flags. dryRun bool @@ -145,6 +147,7 @@ Exit codes: Context: contextOverride, Namespace: nsOverride, Spec: push.SpecArgs{Table: table, Category: category, Intent: intent, LabelColumn: labelColumn}, + TargetSizeFlag: targetSize, DryRun: dryRun, IngestorSAName: ingestorSAName, StagePodImage: stagePodImage, @@ -175,6 +178,9 @@ Exit codes: "intent: train|test") cmd.Flags().StringVar(&labelColumn, "label-column", "", "column name in labels.csv that holds the label") + cmd.Flags().StringVar(&targetSize, "target-size", "", + "image resolution as WxH (e.g. 512x512). Default: auto-detected from the first image. "+ + "All images must share this resolution — the ingestor validates it, it does not resize.") cmd.Flags().BoolVar(&dryRun, "dry-run", false, "validate + discover + walk, but don't create any cluster resources") @@ -209,6 +215,7 @@ type runDatasetPushArgs struct { Context string Namespace string Spec push.SpecArgs + TargetSizeFlag string // raw --target-size; resolved after Discover DryRun bool IngestorSAName string StagePodImage string @@ -258,11 +265,48 @@ func runDatasetPush(ctx context.Context, out, errOut io.Writer, a runDatasetPush "tracebloc/client#147 non-goals.", a.Spec.Category)} } - // 3. Synthesize the spec from flags + validate against schema. + // 3. Walk the local directory FIRST. Enforces layout + size caps, + // and gives us the image list the target-size auto-detect below + // needs. Both this and the schema check are local "fail fast" + // steps; doing the walk first lets the synthesized spec carry + // the resolved target_size. + layout, err := push.Discover(a.LocalPath) + if err != nil { + return &exitError{code: 3, err: err} + } + + // 3a. Resolve the image target resolution. The ingestor's + // image_classification default is 512x512 and it VALIDATES + // (it does not resize), so a mismatch hard-fails the run with + // an "incorrect resolution" error. Honour an explicit + // --target-size; otherwise auto-detect from the first image so + // the common "all my images are NxN" case just works without + // the customer needing to know the knob exists. + if a.TargetSizeFlag != "" { + w, h, perr := push.ParseTargetSize(a.TargetSizeFlag) + if perr != nil { + return &exitError{code: 2, err: perr} + } + a.Spec.TargetSize = []int{w, h} + } else if len(layout.Images) > 0 { + if w, h, derr := push.DetectImageSize(layout.Images[0]); derr == nil { + a.Spec.TargetSize = []int{w, h} + _, _ = fmt.Fprintf(out, + "Auto-detected image target size %dx%d from %s (override with --target-size).\n", + w, h, filepath.Base(layout.Images[0])) + } else { + _, _ = fmt.Fprintf(errOut, + "Note: couldn't auto-detect image size (%v); using the ingestor "+ + "default. Pass --target-size WxH if ingestion reports a "+ + "resolution mismatch.\n", derr) + } + } + + // 4. Synthesize the spec from flags + validate against schema. // Catches "bad category", "missing intent" etc. BEFORE we - // touch the filesystem or the cluster. The error formatter - // is the same one ingest validate uses, so a customer who - // YAML'd manually first sees identical wording. + // touch the cluster. The error formatter is the same one + // ingest validate uses, so a customer who YAML'd manually + // first sees identical wording. spec := a.Spec.Build() specBytes, err := yaml.Marshal(spec) if err != nil { @@ -295,14 +339,6 @@ func runDatasetPush(ctx context.Context, out, errOut io.Writer, a runDatasetPush return &exitError{code: 2, err: errors.New("synthesized spec failed schema validation; check the flag values above")} } - // 4. Walk the local directory. Enforces layout + size caps; - // customer sees a clear pointer to expected layout if they - // pass the wrong directory. - layout, err := push.Discover(a.LocalPath) - if err != nil { - return &exitError{code: 3, err: err} - } - // 5. Cluster discovery — same kubeconfig path as `cluster info`. // Errors mirror that command's exit-code contract (3 for // kubeconfig, 4 for missing release) so behaviour is @@ -527,13 +563,12 @@ func printPushPreflight( _, _ = fmt.Fprintf(out, " category: %s\n", spec["category"]) _, _ = fmt.Fprintf(out, " intent: %s\n", spec["intent"]) _, _ = fmt.Fprintf(out, " label column: %s\n", spec["label"]) - _, _ = fmt.Fprintf(out, " destination: %s\n", push.StagedPrefix(spec["table"].(string))) + _, _ = fmt.Fprintf(out, " destination: %s\n", push.FinalDestPrefix(spec["table"].(string))) _, _ = fmt.Fprintln(out) if !dryRun { - _, _ = fmt.Fprintf(out, "Next: stage %d files (%s) → %s\n", - 1+len(layout.Images), push.HumanBytes(layout.TotalBytes), - push.StagedPrefix(spec["table"].(string))) + _, _ = fmt.Fprintf(out, "Next: stage %d files (%s) for table %q\n", + 1+len(layout.Images), push.HumanBytes(layout.TotalBytes), spec["table"]) _, _ = fmt.Fprintln(out) } } diff --git a/internal/push/detect.go b/internal/push/detect.go new file mode 100644 index 00000000..9a2b838e --- /dev/null +++ b/internal/push/detect.go @@ -0,0 +1,70 @@ +package push + +import ( + "fmt" + "image" + "os" + "strconv" + "strings" + + // Register the stdlib image decoders so image.DecodeConfig can + // read the headers of the formats the image_classification layout + // accepts. webp is NOT in the stdlib — DetectImageSize returns an + // error for it and the caller falls back to requiring + // --target-size. (.jpg/.jpeg both decode via image/jpeg.) + _ "image/gif" + _ "image/jpeg" + _ "image/png" +) + +// DetectImageSize returns the pixel width and height of the image at +// path by decoding only its header (image.DecodeConfig — it does not +// read the pixel data, so it's cheap even for large images). +// +// Supports the stdlib-registered formats (jpeg, png, gif). Returns an +// error for formats without a registered decoder (notably webp); the +// caller treats that as "couldn't auto-detect" and falls back to the +// ingestor default, advising --target-size. +func DetectImageSize(path string) (width, height int, err error) { + f, err := os.Open(path) + if err != nil { + return 0, 0, err + } + defer func() { _ = f.Close() }() + + cfg, _, err := image.DecodeConfig(f) + if err != nil { + return 0, 0, fmt.Errorf("decoding image header %q: %w", path, err) + } + return cfg.Width, cfg.Height, nil +} + +// ParseTargetSize parses a --target-size flag value into [width, +// height]. Accepts "WxH" (the documented form, e.g. "512x512") and +// "W,H" as a convenience. Both dimensions must be positive integers. +func ParseTargetSize(s string) (width, height int, err error) { + sep := "x" + if strings.Contains(s, ",") { + sep = "," + } + parts := strings.Split(s, sep) + if len(parts) != 2 { + return 0, 0, fmt.Errorf( + "target size %q must be WxH (e.g. 512x512)", s) + } + width, err = strconv.Atoi(strings.TrimSpace(parts[0])) + if err != nil { + return 0, 0, fmt.Errorf( + "target size %q: width is not an integer: %w", s, err) + } + height, err = strconv.Atoi(strings.TrimSpace(parts[1])) + if err != nil { + return 0, 0, fmt.Errorf( + "target size %q: height is not an integer: %w", s, err) + } + if width <= 0 || height <= 0 { + return 0, 0, fmt.Errorf( + "target size %q: width and height must both be positive", s) + } + return width, height, nil +} diff --git a/internal/push/detect_test.go b/internal/push/detect_test.go new file mode 100644 index 00000000..14348bf3 --- /dev/null +++ b/internal/push/detect_test.go @@ -0,0 +1,88 @@ +package push + +import ( + "image" + "image/png" + "os" + "path/filepath" + "testing" +) + +// TestParseTargetSize covers the --target-size flag parser: the +// documented WxH form, the W,H convenience form, and the rejection +// cases (missing dimension, non-integer, non-positive, wrong arity). +func TestParseTargetSize(t *testing.T) { + cases := []struct { + in string + w, h int + wantErr bool + }{ + {"512x512", 512, 512, false}, + {"640x480", 640, 480, false}, + {"512,512", 512, 512, false}, + {"1x1", 1, 1, false}, + {"512", 0, 0, true}, + {"512x", 0, 0, true}, + {"x512", 0, 0, true}, + {"0x512", 0, 0, true}, + {"-4x512", 0, 0, true}, + {"512x512x512", 0, 0, true}, + {"abcxdef", 0, 0, true}, + {"", 0, 0, true}, + } + for _, c := range cases { + w, h, err := ParseTargetSize(c.in) + if c.wantErr { + if err == nil { + t.Errorf("ParseTargetSize(%q) = (%d,%d,nil), want error", c.in, w, h) + } + continue + } + if err != nil { + t.Errorf("ParseTargetSize(%q) unexpected error: %v", c.in, err) + continue + } + if w != c.w || h != c.h { + t.Errorf("ParseTargetSize(%q) = (%d,%d), want (%d,%d)", c.in, w, h, c.w, c.h) + } + } +} + +// TestDetectImageSize_PNG: a real (generated) PNG's header is decoded +// to its true dimensions. Pins the auto-detect path used when the +// customer doesn't pass --target-size. +func TestDetectImageSize_PNG(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "img.png") + f, err := os.Create(p) + if err != nil { + t.Fatal(err) + } + if err := png.Encode(f, image.NewRGBA(image.Rect(0, 0, 320, 200))); err != nil { + t.Fatal(err) + } + _ = f.Close() + + w, h, err := DetectImageSize(p) + if err != nil { + t.Fatalf("DetectImageSize: %v", err) + } + if w != 320 || h != 200 { + t.Errorf("DetectImageSize = (%d,%d), want (320,200)", w, h) + } +} + +// TestDetectImageSize_Unsupported: a non-image (or unregistered +// format) returns an error so the caller falls back to the ingestor +// default + advises --target-size, rather than silently using a +// bogus size. +func TestDetectImageSize_Unsupported(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "note.txt") + if err := os.WriteFile(p, []byte("not an image"), 0o644); err != nil { + t.Fatal(err) + } + if _, _, err := DetectImageSize(p); err == nil { + t.Error("DetectImageSize on non-image returned nil error; want a decode error") + } +} diff --git a/internal/push/spec.go b/internal/push/spec.go index 8aabb0a3..ebb7d984 100644 --- a/internal/push/spec.go +++ b/internal/push/spec.go @@ -133,6 +133,16 @@ type SpecArgs struct { // shorthand because passthrough is the only policy // image_classification cares about. LabelColumn string + + // TargetSize, when len==2, pins the image resolution as [W, H]. + // The ingestor's image_classification default is 512x512 and it + // VALIDATES (it does not resize), so a dataset whose images don't + // match the default hard-fails. Setting this emits + // spec.file_options.target_size so the customer's actual + // resolution wins. Empty (len 0) ⇒ omit and let the ingestor + // default apply. Populated by the CLI from --target-size or by + // auto-detecting the first image. + TargetSize []int } // Build produces the ingest.v1.json-conforming spec map. The @@ -157,7 +167,7 @@ type SpecArgs struct { // calls StagedPrefix, which panics on an unsafe name. func (a SpecArgs) Build() map[string]any { prefix := StagedPrefix(a.Table) - return map[string]any{ + spec := map[string]any{ "apiVersion": "tracebloc.io/v1", "kind": "IngestConfig", "category": a.Category, @@ -170,40 +180,94 @@ func (a SpecArgs) Build() map[string]any { "images": path.Join(prefix, "images") + "/", "label": a.LabelColumn, } + // Emit the image resolution under spec.file_options.target_size — + // the same override key the helm flow + data-ingestors' + // conventions.resolve honour (it merges spec.file_options over the + // per-category default). Without this, image_classification + // defaults to 512x512 and the ingestor's Image Resolution + // Validator rejects any other size. + if len(a.TargetSize) == 2 { + spec["spec"] = map[string]any{ + "file_options": map[string]any{ + "target_size": []int{a.TargetSize[0], a.TargetSize[1]}, + }, + } + } + return spec } -// StagedPrefix returns the in-cluster destination directory the CLI -// writes files into for a given table. Used in two places that -// MUST agree: +// SharedRoot is the in-cluster mount path of the chart's shared PVC +// (cluster.SharedPVCMountPath). Both the ephemeral stage Pod and the +// ingestor Job mount client-pvc here, so any path under it is visible +// to both — which is why the CLI's staging area lives under it. +const SharedRoot = "/data/shared" + +// stagingDirName is the hidden directory under SharedRoot where the +// CLI lands a run's SOURCE files. It is deliberately SEPARATE from +// the ingestor's destination (SharedRoot/
): +// +// data-ingestors computes DEST_PATH = STORAGE_PATH/TABLE_NAME = +// SharedRoot/
, and its DuplicateValidator FAILS if that path +// already exists non-empty. If the CLI staged straight into +// SharedRoot/
(as it did originally), its own staging would +// create exactly the non-empty destination the validator rejects — +// so every push failed the duplicate check. Staging under +// SharedRoot/.tracebloc-staging/
keeps the destination fresh +// while remaining on the same PVC the ingestor reads. +const stagingDirName = ".tracebloc-staging" + +// StagedPrefix returns the in-cluster directory the CLI streams a +// dataset's SOURCE files into for a given table. The synthesized +// spec's csv/images point here; the ingestor reads from here and +// writes the processed table to FinalDestPrefix(table). +// +// Two call sites MUST agree on this value: // -// 1. Phase 3 (this PR + PR-b): the path the ephemeral stage Pod -// creates and tars files into. +// 1. The ephemeral stage Pod's tar target (StreamLayout). // 2. The csv/images fields in Build() above, which jobs-manager -// reads to know where the ingestor Job will find them. +// hands to the ingestor Job so it reads what we just staged. // -// Exported because Phase 3's PR-b (stage Pod construction) needs -// it from the same place, and Phase 4 (submit) might want to print -// it as part of "what we pushed." +// It is intentionally NOT SharedRoot/
: that is the ingestor's +// DEST_PATH, whose DuplicateValidator rejects a pre-existing, +// non-empty directory. See stagingDirName. // // PRECONDITION: table must already have passed ValidateTableName. // This function panics on an unsafe name rather than returning an -// escape path — a name that escapes /data/shared is a caller bug -// (validation was skipped), and a panic surfaces it loudly in -// tests instead of silently letting PR-b's stage Pod write to, -// say, /etc. Every production call path runs ValidateTableName -// first (see cli.runDatasetPush), so the panic is unreachable in -// correct code. +// escape path — a name that escapes SharedRoot is a caller bug +// (validation was skipped), and a panic surfaces it loudly in tests +// instead of silently letting the stage Pod write to, say, /etc. +// Every production call path runs ValidateTableName first (see +// cli.runDatasetPush), so the panic is unreachable in correct code. func StagedPrefix(table string) string { // Deliberately NOT path.Join here: path.Join cleans ".." // segments, which is exactly the silent traversal we're // guarding against. Plain concatenation keeps the name as a // literal segment so the assertion below can detect a bad one. - prefix := "/data/shared/" + table if !tableNamePattern.MatchString(table) { panic(fmt.Sprintf( "push.StagedPrefix: unsafe table name %q — caller must "+ "ValidateTableName before constructing a PVC path", table)) } - return prefix + return SharedRoot + "/" + stagingDirName + "/" + table +} + +// FinalDestPrefix returns where the ingestor writes the processed +// table: SharedRoot/
, matching data-ingestors' config.DEST_PATH +// (STORAGE_PATH/TABLE_NAME). This is what the training side reads and +// what the CLI shows the customer as the destination. The CLI never +// writes here directly — doing so would trip the ingestor's +// DuplicateValidator; it stages to StagedPrefix(table) and the +// ingestor produces this path. +// +// PRECONDITION: table must already have passed ValidateTableName. +// Panics on an unsafe name, same rationale as StagedPrefix. +func FinalDestPrefix(table string) string { + if !tableNamePattern.MatchString(table) { + panic(fmt.Sprintf( + "push.FinalDestPrefix: unsafe table name %q — caller must "+ + "ValidateTableName before constructing a PVC path", + table)) + } + return SharedRoot + "/" + table } diff --git a/internal/push/spec_test.go b/internal/push/spec_test.go index 05c79614..a6301cbf 100644 --- a/internal/push/spec_test.go +++ b/internal/push/spec_test.go @@ -102,8 +102,98 @@ func TestStagedPrefix_PerTableIsolation(t *testing.T) { if a, b := StagedPrefix("cats"), StagedPrefix("dogs"); a == b { t.Errorf("StagedPrefix(%q) == StagedPrefix(%q) = %q, want distinct", "cats", "dogs", a) } - if got := StagedPrefix("table_a"); got != "/data/shared/table_a" { - t.Errorf("StagedPrefix(%q) = %q, want /data/shared/table_a", "table_a", got) + if got := StagedPrefix("table_a"); got != "/data/shared/.tracebloc-staging/table_a" { + t.Errorf("StagedPrefix(%q) = %q, want /data/shared/.tracebloc-staging/table_a", "table_a", got) + } +} + +// TestStagedPrefix_DoesNotCollideWithDest is the regression pin for +// the live-discovered blocker (#26): the CLI must stage SOURCE files +// somewhere the ingestor's DEST_PATH (= FinalDestPrefix = /data/ +// shared/
) does NOT contain. If staging ever lands at or under +// the destination again, the ingestor's DuplicateValidator will +// reject the CLI's own staging as a pre-existing non-empty +// destination, and every push fails the duplicate check. +func TestStagedPrefix_DoesNotCollideWithDest(t *testing.T) { + const table = "cats_dogs_train" + staged := StagedPrefix(table) + dest := FinalDestPrefix(table) + + if staged == dest { + t.Fatalf("StagedPrefix == FinalDestPrefix == %q; the CLI would stage "+ + "into the ingestor's DEST_PATH and trip DuplicateValidator", staged) + } + // The destination must not contain the staging dir either — + // otherwise DEST is non-empty (it holds the staging subtree) and + // the validator still fails. + if strings.HasPrefix(staged, dest+"/") { + t.Errorf("StagedPrefix %q is under FinalDestPrefix %q; DEST would be "+ + "non-empty at ingest time", staged, dest) + } + if got := FinalDestPrefix(table); got != "/data/shared/"+table { + t.Errorf("FinalDestPrefix(%q) = %q, want /data/shared/%s", table, got, table) + } +} + +// TestBuild_WithTargetSize_PassesSchema pins the #27 plumbing: when +// the CLI resolves an image resolution (via --target-size or +// auto-detect), Build emits spec.file_options.target_size and the +// result still validates against the embedded v1 schema. A drift here +// would make every push that sets a target size fail validation. +func TestBuild_WithTargetSize_PassesSchema(t *testing.T) { + spec := SpecArgs{ + Table: "cats_dogs_train", + Category: "image_classification", + Intent: "train", + LabelColumn: "label", + TargetSize: []int{256, 256}, + }.Build() + + // The nested override must be present and well-shaped. + specBlock, ok := spec["spec"].(map[string]any) + if !ok { + t.Fatalf("Build() with TargetSize didn't emit a spec block: %#v", spec["spec"]) + } + fo, ok := specBlock["file_options"].(map[string]any) + if !ok { + t.Fatalf("spec.file_options missing/wrong type: %#v", specBlock["file_options"]) + } + ts, ok := fo["target_size"].([]int) + if !ok || len(ts) != 2 || ts[0] != 256 || ts[1] != 256 { + t.Fatalf("spec.file_options.target_size = %#v, want [256 256]", fo["target_size"]) + } + + specBytes, err := yaml.Marshal(spec) + if err != nil { + t.Fatalf("yaml.Marshal: %v", err) + } + v, err := schema.NewV1Validator() + if err != nil { + t.Fatalf("NewV1Validator: %v", err) + } + _, errs, parseErr := v.ValidateYAML(specBytes) + if parseErr != nil { + t.Fatalf("ValidateYAML parse error on our own output: %v\n%s", parseErr, specBytes) + } + if len(errs) != 0 { + t.Fatalf("spec with target_size failed schema validation: %s\nspec:\n%s", + schema.FormatErrors(errs), specBytes) + } +} + +// TestBuild_NoTargetSize_OmitsSpecBlock: when no resolution is set, +// Build must NOT emit a spec block (the ingestor's per-category +// default applies). Asserting the omission keeps the minimal-spec +// contract that the original schema test relies on. +func TestBuild_NoTargetSize_OmitsSpecBlock(t *testing.T) { + spec := SpecArgs{ + Table: "t", + Category: "image_classification", + Intent: "train", + LabelColumn: "label", + }.Build() + if _, present := spec["spec"]; present { + t.Errorf("Build() with no TargetSize emitted a spec block; want omitted") } } diff --git a/internal/push/stage.go b/internal/push/stage.go index f8e94725..ec396073 100644 --- a/internal/push/stage.go +++ b/internal/push/stage.go @@ -137,8 +137,8 @@ func Stage(ctx context.Context, opts StageOptions) error { // 5. Stream the tar. This is where actual bytes flow. The // progress bar (if TTY) renders during this call. - _, _ = fmt.Fprintf(opts.Out, "Streaming %d files (%s) to %s...\n", - 1+len(opts.Layout.Images), HumanBytes(opts.Layout.TotalBytes), StagedPrefix(opts.Table)) + _, _ = fmt.Fprintf(opts.Out, "Streaming %d files (%s) for table %q...\n", + 1+len(opts.Layout.Images), HumanBytes(opts.Layout.TotalBytes), opts.Table) if err := StreamLayout(ctx, opts.Executor, opts.Namespace, podName, "stage", @@ -147,7 +147,7 @@ func Stage(ctx context.Context, opts StageOptions) error { } // 6. Print "done" message. The deferred cleanup runs after this. - _, _ = fmt.Fprintf(opts.Out, "Staged %d files to %s\n", - 1+len(opts.Layout.Images), StagedPrefix(opts.Table)) + _, _ = fmt.Fprintf(opts.Out, "Staged %d files for table %q\n", + 1+len(opts.Layout.Images), opts.Table) return nil } diff --git a/internal/push/stream_test.go b/internal/push/stream_test.go index 62bde455..ea0e0553 100644 --- a/internal/push/stream_test.go +++ b/internal/push/stream_test.go @@ -187,12 +187,19 @@ func TestStreamLayout_RemoteCommand(t *testing.T) { // - tar BEFORE any rm of $DEST (preserves on tar failure) // - mv AFTER tar succeeds (atomic-ish swap) // + // `dest` is StagedPrefix(table) — the CLI's SOURCE staging dir, + // which (since #26) lives under SharedRoot/.tracebloc-staging/ so + // it never collides with the ingestor's DEST_PATH. Derive the + // expected paths from it so this test tracks StagedPrefix rather + // than hardcoding the prefix. + dest := StagedPrefix("my_table") + // Extract the staging path with a regex so the random hex // suffix doesn't pin us to a specific invocation's bytes. - stagingRE := regexp.MustCompile(`/data/shared/my_table\.staging-[0-9a-f]{8}`) + stagingRE := regexp.MustCompile(regexp.QuoteMeta(dest) + `\.staging-[0-9a-f]{8}`) stagingPaths := stagingRE.FindAllString(script, -1) if len(stagingPaths) == 0 { - t.Fatalf("remote script has no /data/shared/my_table.staging-<8hex> path (race-safety regression):\n%s", script) + t.Fatalf("remote script has no %s.staging-<8hex> path (race-safety regression):\n%s", dest, script) } // All staging mentions must refer to the SAME suffix in a single // invocation. If we see two distinct suffixes that's a bug: @@ -209,7 +216,7 @@ func TestStreamLayout_RemoteCommand(t *testing.T) { for _, want := range []string{ `mkdir -p "` + staging + `"`, `tar -xf - -C "` + staging + `"`, - `mv "` + staging + `" "/data/shared/my_table"`, + `mv "` + staging + `" "` + dest + `"`, } { if !strings.Contains(script, want) { t.Errorf("remote script missing %q: %s", want, script) @@ -221,18 +228,24 @@ func TestStreamLayout_RemoteCommand(t *testing.T) { // (backup-and-swap, so a mv failure can be rolled back). // The contract is the same: tar runs while $DEST is intact. tarIdx := strings.Index(script, `tar -xf - -C "`+staging+`"`) - destBackupMvIdx := strings.Index(script, `mv "/data/shared/my_table" "`) + destBackupMvIdx := strings.Index(script, `mv "`+dest+`" "`) if tarIdx < 0 || destBackupMvIdx < 0 { t.Fatalf("remote script missing tar or destination-backup mv: %s", script) } if tarIdx >= destBackupMvIdx { t.Errorf("remote script touches $DEST BEFORE tar succeeds — partial-transfer could destroy previous data:\n%s", script) } - // Single-segment guarantee: both rm targets must end with - // /my_table or /my_table.staging-* — never just /data/shared. - if strings.Contains(script, `rm -rf "/data/shared"`) || - strings.Contains(script, `rm -rf "/data/shared/"`) { - t.Errorf("remote script rm-rfs the parent /data/shared (would nuke sibling tables):\n%s", script) + // Single-segment guarantee: rm targets must never be the shared + // root or the staging parent themselves (that would nuke sibling + // tables / every in-flight push). + for _, forbidden := range []string{ + `rm -rf "/data/shared"`, + `rm -rf "/data/shared/"`, + `rm -rf "` + SharedRoot + "/" + stagingDirName + `"`, + } { + if strings.Contains(script, forbidden) { + t.Errorf("remote script contains dangerous %q (would nuke sibling tables/pushes):\n%s", forbidden, script) + } } // Bugbot r9 + r10: orphan cleanup for previously-failed pushes @@ -253,7 +266,7 @@ func TestStreamLayout_RemoteCommand(t *testing.T) { // must be backed up to .old- BEFORE the new dataset // arrives, and restored if the main mv fails. Pin the key // shape pieces — backup mv, primary mv, rollback mv, cleanup. - backupRE := regexp.MustCompile(`/data/shared/my_table\.old-[0-9a-f]{8}`) + backupRE := regexp.MustCompile(regexp.QuoteMeta(dest) + `\.old-[0-9a-f]{8}`) backupPaths := backupRE.FindAllString(script, -1) if len(backupPaths) == 0 { t.Fatalf("remote script has no .old- backup path (r10 rollback regression):\n%s", script) @@ -272,15 +285,15 @@ func TestStreamLayout_RemoteCommand(t *testing.T) { // suffixes would defeat the "find -name ...staging-* ...old-*" // orphan-cleanup symmetry, AND would risk collision with a // concurrent push's .old-. - if strings.TrimPrefix(backup, "/data/shared/my_table.old-") != - strings.TrimPrefix(staging, "/data/shared/my_table.staging-") { + if strings.TrimPrefix(backup, dest+".old-") != + strings.TrimPrefix(staging, dest+".staging-") { t.Errorf("backup and staging suffixes diverge: %q vs %q", backup, staging) } // Backup mv (DEST → .old) must appear BEFORE primary mv // (.staging → DEST), or rollback wouldn't have anything to // restore. - backupMvIdx := strings.Index(script, `mv "/data/shared/my_table" "`+backup+`"`) - primaryMvIdx := strings.Index(script, `mv "`+staging+`" "/data/shared/my_table"`) + backupMvIdx := strings.Index(script, `mv "`+dest+`" "`+backup+`"`) + primaryMvIdx := strings.Index(script, `mv "`+staging+`" "`+dest+`"`) if backupMvIdx < 0 || primaryMvIdx < 0 { t.Fatalf("remote script missing backup or primary mv:\n%s", script) } @@ -299,7 +312,7 @@ func TestStreamLayout_StagingSuffixIsUniquePerInvocation(t *testing.T) { if err != nil { t.Fatalf("Discover: %v", err) } - stagingRE := regexp.MustCompile(`/data/shared/t\.staging-[0-9a-f]{8}`) + stagingRE := regexp.MustCompile(regexp.QuoteMeta(StagedPrefix("t")) + `\.staging-[0-9a-f]{8}`) collect := func() string { fe := &fakeExecutor{} diff --git a/internal/submit/watch.go b/internal/submit/watch.go index 3280e813..3ef8d477 100644 --- a/internal/submit/watch.go +++ b/internal/submit/watch.go @@ -220,18 +220,10 @@ func WatchJob( // a structured representation of the banner without // requiring a second log fetch post-completion. summary, logErr := streamPodLogsAndParse(watchCtx, cs, namespace, podName, out) - // Filter out the two ctx-flavored errors — both are "observation - // gave up early," not "stream failed." They get classified below - // into Detached (customer SIGINT, JobWatchTimeout expiry). Any - // other error is a real streaming failure (network mid-stream, - // API server tantrum) and bubbles up as a watch error. - if logErr != nil && - !errors.Is(logErr, context.Canceled) && - !errors.Is(logErr, context.DeadlineExceeded) { - return nil, fmt.Errorf("streaming logs from Pod %s/%s: %w", namespace, podName, logErr) - } - // 3. Detach branches: + // 3. Detach branches — checked FIRST, since the customer's SIGINT + // or the watch-cap expiry is the operative intent and takes + // precedence over any stream error: // - customerCtx canceled = SIGINT // - watchCtx expired (DeadlineExceeded) = JobWatchTimeout cap // hit during streaming (1-hour observation window exceeded) @@ -265,20 +257,54 @@ func WatchJob( // inheriting watchCtx's depleted budget caused successful // slow ingestions to misreport as Unknown. // - // The fresh ctx still propagates SIGINT (parent is - // customerCtx, which carries signal.NotifyContext's - // cancel). If the customer Ctrl-C's during this 30s - // window, we fall into the detach branch below — same - // contract as during the log stream. + // The Job — not the log stream — is the source of truth for + // success/failure, so we ALWAYS consult it here, INCLUDING when + // the log stream broke for a non-ctx reason (#28: the watched + // Pod was replaced / restarted / deleted mid-follow, e.g. a + // backoffLimit retry). A broken stream is only fatal if we also + // can't determine the Job's outcome. + // + // The fresh ctx still propagates SIGINT (parent is customerCtx, + // which carries signal.NotifyContext's cancel); a Ctrl-C in this + // window falls into the detach branches below. finalCtx, finalCancel := context.WithTimeout(customerCtx, 30*time.Second) defer finalCancel() - outcome, err := finalJobStatus(finalCtx, cs, namespace, jobName) - if err != nil { + outcome, statusErr := finalJobStatus(finalCtx, cs, namespace, jobName) + + // A non-ctx log-stream error is incidental if the Job still + // reached a terminal state. Previously ANY such error (e.g. + // "container is terminated" once the Pod was replaced by a retry) + // returned exit 9 even when the Job ultimately succeeded. #28. + streamFailed := logErr != nil && + !errors.Is(logErr, context.Canceled) && + !errors.Is(logErr, context.DeadlineExceeded) + if streamFailed { + // SIGINT during the final-status poll → graceful detach. + if errors.Is(customerCtx.Err(), context.Canceled) { + return &WatchResult{ + Outcome: JobOutcomeDetached, + PodName: podName, + Summary: summary, + DetachReason: DetachReasonSignal, + }, nil + } + // Job reached a terminal state → the stream error was + // incidental (Pod replaced/restarted). Report the real + // outcome the customer cares about. + if statusErr == nil && (outcome == JobOutcomeSucceeded || outcome == JobOutcomeFailed) { + return &WatchResult{Outcome: outcome, PodName: podName, Summary: summary}, nil + } + // Couldn't confirm a terminal Job state → the stream failure + // is the actionable signal; surface it. + return nil, fmt.Errorf("streaming logs from Pod %s/%s: %w", namespace, podName, logErr) + } + + // 5. Clean-stream path: classify on the Job status alone. + if statusErr != nil { // Treat SIGINT during finalJobStatus as graceful detach - // (same as during the log stream — jobs-manager already - // accepted the run, the customer is just stopping the - // observation). Bugbot PR #10 r2 flagged the "exit 9 on - // post-stream SIGINT" inconsistency. + // (jobs-manager already accepted the run; the customer is + // just stopping the observation). Bugbot PR #10 r2 flagged + // the "exit 9 on post-stream SIGINT" inconsistency. if errors.Is(customerCtx.Err(), context.Canceled) { return &WatchResult{ Outcome: JobOutcomeDetached, @@ -287,7 +313,7 @@ func WatchJob( DetachReason: DetachReasonSignal, }, nil } - return nil, fmt.Errorf("reading final Job status for %s/%s: %w", namespace, jobName, err) + return nil, fmt.Errorf("reading final Job status for %s/%s: %w", namespace, jobName, statusErr) } return &WatchResult{ Outcome: outcome, diff --git a/internal/submit/watch_test.go b/internal/submit/watch_test.go index 2be8b052..4da6fb9a 100644 --- a/internal/submit/watch_test.go +++ b/internal/submit/watch_test.go @@ -263,6 +263,62 @@ func TestWatchJob_PodWaitTimeoutMapsToDetach(t *testing.T) { } } +// TestWatchJob_TerminalJobStatusWins is the #28 regression pin: the +// Job — not the log stream — is the source of truth for the outcome. +// With a Running Pod and a Job already reporting Complete, WatchJob +// must return Succeeded. This holds whether the (fake) log stream +// yields data or breaks: if the stream errored, the new +// "streamFailed but Job terminal → report outcome" branch still +// resolves to the Job's verdict instead of bubbling exit-9. Before +// the fix, a broken stream (e.g. a Pod replaced by a retry, or +// deleted mid-follow) returned an error even on a successful Job. +func TestWatchJob_TerminalJobStatusWins(t *testing.T) { + cs := fake.NewClientset( + jobPod("ingestor-xyz", "ingestor", corev1.PodRunning), + jobWithCondition("ingestor", batchv1.JobComplete), + ) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var out bytes.Buffer + wr, err := WatchJob(ctx, cs, "tracebloc", "ingestor", &out) + if err != nil { + t.Fatalf("WatchJob returned error; want nil + Succeeded: %v", err) + } + if wr == nil { + t.Fatal("WatchJob returned nil result") + } + if wr.Outcome != JobOutcomeSucceeded { + t.Fatalf("Outcome = %v, want Succeeded (Job condition is the source of truth)", wr.Outcome) + } +} + +// TestWatchJob_TerminalFailedJobReported: the mirror of the above for +// a Failed Job — the watch reports Failed (→ exit 9), not a generic +// watch error, even if the stream broke. +func TestWatchJob_TerminalFailedJobReported(t *testing.T) { + cs := fake.NewClientset( + jobPod("ingestor-xyz", "ingestor", corev1.PodRunning), + jobWithCondition("ingestor", batchv1.JobFailed), + ) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var out bytes.Buffer + wr, err := WatchJob(ctx, cs, "tracebloc", "ingestor", &out) + if err != nil { + t.Fatalf("WatchJob returned error; want nil + Failed: %v", err) + } + if wr == nil { + t.Fatal("WatchJob returned nil result") + } + if wr.Outcome != JobOutcomeFailed { + t.Fatalf("Outcome = %v, want Failed", wr.Outcome) + } +} + // TestJobOutcome_String: stringer pin so diagnostic output stays // stable. func TestJobOutcome_String(t *testing.T) {