join: exit nonzero when the left file cannot be read (#2169) (#2171)

The join verb's left-file ingest silently swallowed read errors: a
nonexistent or malformed left file produced empty (or partial) output
with exit code 0.

Two defects, one per ingest path:

* Unsorted (half-streaming) path, ingestLeftFile in
  pkg/transformers/join.go: errors from the record-reader's error
  channel were dropped ("TODO: propagate error to caller"), as were
  record-reader construction errors. All left-file read failures were
  silent.

* Sorted (-s) path, readRecord in
  pkg/transformers/utils/join_bucket_keeper.go: the error channel was
  consulted, but the record-reader goroutine sends the error and the
  end-of-stream marker on separate channels, so the select could see
  end-of-stream first and drop the error. A nonexistent left file with
  -s exited 0 about half the time (racy).

Both paths now print "mlr: <error>" to stderr and exit 1, matching how
read errors on regular (right) input files are reported. On receipt of
the end-of-stream marker, both consumers now do a final non-blocking
drain of the error channel, which is deterministic since the reader
sends any error before the marker.

Discovered while verifying #377.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
John Kerl 2026-07-06 17:34:33 -04:00 committed by GitHub
parent e291280fde
commit 5f152d7669
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 37 additions and 5 deletions

View file

@ -491,8 +491,8 @@ func (tr *TransformerJoin) ingestLeftFile() {
// TODO: perhaps increase recordsPerBatch, and/or refactor
recordReader, err := input.Create(readerOpts, 1)
if err != nil {
// TODO: propagate error to caller
return
fmt.Fprintf(os.Stderr, "mlr: %v\n", err)
os.Exit(1)
}
// Set the initial context for the left-file.
@ -521,9 +521,8 @@ func (tr *TransformerJoin) ingestLeftFile() {
select {
case err := <-errorChannel:
// TODO: propagate error to caller
_ = err
return
fmt.Fprintf(os.Stderr, "mlr: %v\n", err)
os.Exit(1)
case leftrecsAndContexts := <-readerChannel:
// TODO: temp for batch-reader refactor
@ -532,6 +531,17 @@ func (tr *TransformerJoin) ingestLeftFile() {
leftrecAndContext.Record = utils.KeepLeftFieldNames(leftrecAndContext.Record, tr.leftKeepFieldNameSet)
if leftrecAndContext.EndOfStream {
// The record-reader may have sent an error (e.g. the left file
// is missing or unreadable) immediately before its end-of-stream
// marker. Since those are separate channels, the select can see
// the end-of-stream marker first -- so, check the error channel
// before declaring the ingest complete.
select {
case err := <-errorChannel:
fmt.Fprintf(os.Stderr, "mlr: %v\n", err)
os.Exit(1)
default:
}
done = true
break // breaks the switch, not the for, in Golang
}

View file

@ -553,6 +553,17 @@ func (keeper *JoinBucketKeeper) readRecord() *types.RecordAndContext {
leftrecAndContext := leftrecsAndContexts[0]
leftrecAndContext.Record = KeepLeftFieldNames(leftrecAndContext.Record, keeper.leftKeepFieldNameSet)
if leftrecAndContext.EndOfStream { // end-of-stream marker
// The record-reader may have sent an error (e.g. the left file is
// missing or unreadable) immediately before its end-of-stream
// marker. Since those are separate channels, the select can see
// the end-of-stream marker first -- so, check the error channel
// before declaring the left-file read complete.
select {
case err := <-keeper.errorChannel:
fmt.Fprintf(os.Stderr, "mlr: %v\n", err)
os.Exit(1)
default:
}
keeper.recordReaderDone = true
return nil
}

View file

@ -0,0 +1 @@
mlr --icsv --opprint join -s -j a -f test/input/join-left-ragged.csv test/input/abixy.csv

View file

@ -0,0 +1 @@
mlr: CSV header/data length mismatch 2 != 4 at filename test/input/join-left-ragged.csv row 3

View file

@ -0,0 +1 @@
mlr --icsv --opprint join -j a -f test/input/join-left-ragged.csv test/input/abixy.csv

View file

@ -0,0 +1 @@
mlr: CSV header/data length mismatch 2 != 4 at filename test/input/join-left-ragged.csv row 3

View file

@ -0,0 +1 @@
mlr --icsv --opprint join -s -j a -f /nonesuch/nope/never test/input/abixy.csv

View file

@ -0,0 +1 @@
mlr: open /nonesuch/nope/never: no such file or directory

View file

@ -0,0 +1 @@
mlr --icsv --opprint join -j a -f /nonesuch/nope/never test/input/abixy.csv

View file

@ -0,0 +1 @@
mlr: open /nonesuch/nope/never: no such file or directory

View file

@ -0,0 +1,3 @@
a,l
pan,one
eks,two,three,four
1 a,l
2 pan,one
3 eks,two,three,four