Handle corrupt GF3 archives in batch runs

This commit is contained in:
GF3 Pipeline Bot
2026-05-30 01:46:04 +08:00
parent 5c24c0e64c
commit ff41cf3ced
6 changed files with 125 additions and 9 deletions
+2
View File
@@ -40,6 +40,8 @@ Supported input forms:
The wrapper writes one output subdirectory per scene. On successful runs it saves defaults next to the executable in `gf3wrapper.json`, so double-click interactive mode can reuse the previous input/output/DEM/polarization settings. The wrapper writes one output subdirectory per scene. On successful runs it saves defaults next to the executable in `gf3wrapper.json`, so double-click interactive mode can reuse the previous input/output/DEM/polarization settings.
Batch directory runs continue after per-scene failures. If one archive is truncated, invalid, missing metadata, or fails during SARscape processing, the wrapper prints the failed scene and reason, skips that scene, and continues with the remaining scenes. At the end it prints a batch summary and returns a non-zero exit code when any scene failed.
More details: [docs/sarscape_go_wrapper.md](docs/sarscape_go_wrapper.md) More details: [docs/sarscape_go_wrapper.md](docs/sarscape_go_wrapper.md)
## Python Pipeline ## Python Pipeline
+2
View File
@@ -14,4 +14,6 @@ Run from the repository root:
The executable requires ENVI/IDL Runtime and SARscape on the target machine. The executable requires ENVI/IDL Runtime and SARscape on the target machine.
In directory mode, failed scenes are skipped and listed in a final batch summary. The wrapper continues with remaining scenes, but exits non-zero if any scene failed.
After successful runs, `gf3wrapper.json` is created in this directory to store defaults. That file is local runtime state and is ignored by Git. After successful runs, `gf3wrapper.json` is created in this directory to store defaults. That file is local runtime state and is ignored by Git.
BIN
View File
Binary file not shown.
+16
View File
@@ -40,6 +40,22 @@ The wrapper supports:
Directory mode recursively scans for `*.tar.gz`, `*.tgz`, and `*.meta.xml`. If both archive and metadata exist for the same scene name, the archive is preferred. Internal `.gf3_extract` folders are skipped. Directory mode recursively scans for `*.tar.gz`, `*.tgz`, and `*.meta.xml`. If both archive and metadata exist for the same scene name, the archive is preferred. Internal `.gf3_extract` folders are skipped.
## Batch Failure Handling
Directory runs are fault-tolerant at the scene level. If a single scene cannot be extracted or processed, the wrapper records that scene as failed, prints the input path and error reason, and continues with the next scene.
Archive read errors include the archive path, archive size when available, and the archive member being read when the failure happened. For example, a truncated `.tar.gz` reports that the archive appears incomplete and should be re-copied or re-downloaded.
At the end of the run, the wrapper prints a summary:
```text
Batch summary: 3 succeeded, 1 failed, 4 total
Failed scenes:
- GF3_... (<archive path>): <failure reason>
```
If any scene failed, the process exits with a non-zero status after the summary. This lets scheduled jobs detect that the batch needs attention while still preserving successful outputs from other scenes.
## Outputs ## Outputs
Each scene is processed into its own output directory: Each scene is processed into its own output directory:
+2
View File
@@ -39,6 +39,8 @@ Batch directory:
If `gf3wrapper.json` exists next to the executable, omitted `-input`, `-output`, `-dem`, and `-pol` values are loaded from that file. If `gf3wrapper.json` exists next to the executable, omitted `-input`, `-output`, `-dem`, and `-pol` values are loaded from that file.
In batch directory mode, a failed scene does not stop the whole run. Invalid or truncated archives, missing metadata, and SARscape failures are reported with the scene input path and reason, then the wrapper continues with the next scene. A final summary lists succeeded and failed scenes, and the process exits non-zero when any scene failed.
## Notes ## Notes
- The server still needs ENVI, IDL Runtime, and SARscape installed and licensed. - The server still needs ENVI, IDL Runtime, and SARscape installed and licensed.
+103 -9
View File
@@ -38,6 +38,20 @@ type scene struct {
name string name string
} }
type sceneFailure struct {
name string
input string
err error
}
type batchError struct {
failures []sceneFailure
}
func (e batchError) Error() string {
return fmt.Sprintf("batch completed with %d failed scene(s)", len(e.failures))
}
func main() { func main() {
cfg := parseFlags() cfg := parseFlags()
if len(os.Args) == 1 { if len(os.Args) == 1 {
@@ -251,34 +265,76 @@ func run(cfg config) error {
return fmt.Errorf("no *.meta.xml scenes found in %s", cfg.input) return fmt.Errorf("no *.meta.xml scenes found in %s", cfg.input)
} }
failures := make([]sceneFailure, 0)
succeeded := 0
for _, sc := range scenes { for _, sc := range scenes {
sceneOut := filepath.Join(cfg.output, sc.name) sceneOut := filepath.Join(cfg.output, sc.name)
if err := os.MkdirAll(sceneOut, 0o755); err != nil { if err := os.MkdirAll(sceneOut, 0o755); err != nil {
return err recordSceneFailure(&failures, sc, err)
continue
} }
if sc.archivePath != "" { if sc.archivePath != "" {
extractDir := filepath.Join(sceneOut, ".gf3_extract") extractDir := filepath.Join(sceneOut, ".gf3_extract")
fmt.Printf("Extracting %s\n", sc.archivePath) fmt.Printf("Extracting %s\n", sc.archivePath)
if err := extractTarGz(sc.archivePath, extractDir); err != nil { if err := extractTarGz(sc.archivePath, extractDir); err != nil {
return err recordSceneFailure(&failures, sc, fmt.Errorf("failed to extract archive before processing scene %s: %w", sc.archivePath, err))
continue
} }
extractedScenes, err := findMetaXML(extractDir) extractedScenes, err := findMetaXML(extractDir)
if err != nil { if err != nil {
return err recordSceneFailure(&failures, sc, err)
continue
} }
if len(extractedScenes) == 0 { if len(extractedScenes) == 0 {
return fmt.Errorf("no *.meta.xml found in archive: %s", sc.archivePath) recordSceneFailure(&failures, sc, fmt.Errorf("no *.meta.xml found in archive: %s", sc.archivePath))
continue
} }
sc.metaPath = extractedScenes[0].metaPath sc.metaPath = extractedScenes[0].metaPath
sc.name = sceneName(sc.metaPath) sc.name = sceneName(sc.metaPath)
} }
if err := runScene(cfg, savPath, sc, sceneOut); err != nil { if err := runScene(cfg, savPath, sc, sceneOut); err != nil {
return err recordSceneFailure(&failures, sc, err)
continue
} }
succeeded++
}
printBatchSummary(len(scenes), succeeded, failures)
if len(failures) > 0 {
return batchError{failures: failures}
} }
return nil return nil
} }
func recordSceneFailure(failures *[]sceneFailure, sc scene, err error) {
input := sc.metaPath
if sc.archivePath != "" {
input = sc.archivePath
}
if input == "" {
input = sc.name
}
failure := sceneFailure{name: sc.name, input: input, err: err}
*failures = append(*failures, failure)
fmt.Printf("FAILED %s\n", sc.name)
fmt.Printf("Input %s\n", input)
fmt.Printf("Reason: %v\n", err)
fmt.Println("Continuing with next scene.")
}
func printBatchSummary(total, succeeded int, failures []sceneFailure) {
fmt.Println()
fmt.Printf("Batch summary: %d succeeded, %d failed, %d total\n", succeeded, len(failures), total)
if len(failures) == 0 {
return
}
fmt.Println("Failed scenes:")
for _, failure := range failures {
fmt.Printf("- %s (%s): %v\n", failure.name, failure.input, failure.err)
}
}
func materializeSAV(outputDir string) (string, error) { func materializeSAV(outputDir string) (string, error) {
data, err := embedded.ReadFile("assets/gf3_sarscape_cli.sav") data, err := embedded.ReadFile("assets/gf3_sarscape_cli.sav")
if err != nil { if err != nil {
@@ -508,9 +564,31 @@ func matched(pattern string) bool {
} }
func extractTarGz(src, dst string) error { func extractTarGz(src, dst string) error {
if err := os.MkdirAll(dst, 0o755); err != nil { dst = filepath.Clean(dst)
tmpDst := dst + ".tmp"
if err := os.RemoveAll(tmpDst); err != nil {
return fmt.Errorf("remove stale temporary extraction directory %s: %w", tmpDst, err)
}
if err := os.MkdirAll(tmpDst, 0o755); err != nil {
return err return err
} }
if err := extractTarGzToDir(src, tmpDst); err != nil {
_ = os.RemoveAll(tmpDst)
return err
}
if err := os.RemoveAll(dst); err != nil {
_ = os.RemoveAll(tmpDst)
return fmt.Errorf("replace extraction directory %s: %w", dst, err)
}
if err := os.Rename(tmpDst, dst); err != nil {
_ = os.RemoveAll(tmpDst)
return fmt.Errorf("move completed extraction into %s: %w", dst, err)
}
return nil
}
func extractTarGzToDir(src, dst string) error {
f, err := os.Open(src) f, err := os.Open(src)
if err != nil { if err != nil {
return err return err
@@ -519,19 +597,21 @@ func extractTarGz(src, dst string) error {
gz, err := gzip.NewReader(f) gz, err := gzip.NewReader(f)
if err != nil { if err != nil {
return err return archiveReadError(src, "", err)
} }
defer gz.Close() defer gz.Close()
tr := tar.NewReader(gz) tr := tar.NewReader(gz)
var currentEntry string
for { for {
hdr, err := tr.Next() hdr, err := tr.Next()
if errors.Is(err, io.EOF) { if errors.Is(err, io.EOF) {
break break
} }
if err != nil { if err != nil {
return err return archiveReadError(src, currentEntry, err)
} }
currentEntry = hdr.Name
target := filepath.Join(dst, filepath.Clean(hdr.Name)) target := filepath.Join(dst, filepath.Clean(hdr.Name))
if !strings.HasPrefix(target, filepath.Clean(dst)+string(os.PathSeparator)) && filepath.Clean(target) != filepath.Clean(dst) { if !strings.HasPrefix(target, filepath.Clean(dst)+string(os.PathSeparator)) && filepath.Clean(target) != filepath.Clean(dst) {
return fmt.Errorf("archive entry escapes destination: %s", hdr.Name) return fmt.Errorf("archive entry escapes destination: %s", hdr.Name)
@@ -552,7 +632,7 @@ func extractTarGz(src, dst string) error {
_, copyErr := io.Copy(out, tr) _, copyErr := io.Copy(out, tr)
closeErr := out.Close() closeErr := out.Close()
if copyErr != nil { if copyErr != nil {
return copyErr return archiveReadError(src, hdr.Name, copyErr)
} }
if closeErr != nil { if closeErr != nil {
return closeErr return closeErr
@@ -562,6 +642,20 @@ func extractTarGz(src, dst string) error {
return nil return nil
} }
func archiveReadError(src, entry string, err error) error {
detail := src
if info, statErr := os.Stat(src); statErr == nil {
detail = fmt.Sprintf("%s (%d bytes)", src, info.Size())
}
if entry != "" {
detail = fmt.Sprintf("%s while reading %s", detail, entry)
}
if errors.Is(err, io.ErrUnexpectedEOF) || strings.Contains(strings.ToLower(err.Error()), "unexpected eof") {
return fmt.Errorf("archive appears truncated or incomplete: %s: %w; re-copy or re-download the .tar.gz", detail, err)
}
return fmt.Errorf("read archive %s: %w", detail, err)
}
func sceneName(metaPath string) string { func sceneName(metaPath string) string {
base := filepath.Base(metaPath) base := filepath.Base(metaPath)
base = strings.TrimSuffix(base, ".meta.xml") base = strings.TrimSuffix(base, ".meta.xml")