miller/pkg/transformers/aaa_chain_transformer.go
John Kerl 5ede886106
Fix mid-stream error-loss race (flaky exit codes on transformer errors) (#2211)
When a transformer fails mid-stream (e.g. join -s with a malformed left
file), the error could be lost, yielding exit 0 with no stderr message:

- runSingleTransformerBatch forwarded the end-of-stream marker downstream
  before runSingleTransformer sent the error to
  dataProcessingErrorChannel, so the record-writer could finish and signal
  done-writing while the error was still unsent.
- Even with the error buffered, stream.Stream's select loop chooses among
  simultaneously-ready channels at random, and exiting on the done-writing
  signal dropped the buffered error.

Send the error before forwarding the end-of-stream marker (so it is
always buffered before the writer can finish), and drain the error
channels after the select loop exits.

Observed as a one-off Windows CI failure of
test/cases/verb-join/left-file-malformed-sorted, where the same case
passed on automatic rerun within the same job. Reproduced locally by
widening the deschedule window with a sleep between the end-of-stream
forward and the error send: 5/5 runs exited 0 with empty stderr; with
this fix, 200/200 runs exit 1 with the expected message even with
adversarial delays injected on both sides of the end-of-stream forward.

Follow-up to the os.Exit-removal refactor (plans/exit.md, #2204/#2205).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 18:26:56 -04:00

328 lines
12 KiB
Go

package transformers
import (
"fmt"
"os"
"github.com/johnkerl/miller/v6/pkg/cli"
"github.com/johnkerl/miller/v6/pkg/types"
)
// ChainTransformer is a refinement of Miller's high-level sketch in stream.go.
// As far as stream.go is concerned, the transformer-chain is a box which reads
// from an input-record channel (from the record-reader object) and writes to
// an output-record channel (to the record-writer object). Inside that box is a
// bit more complexity, including channels between transformers in the chain.
//
// Channel structure from outside the box:
// * readerInputRecordChannel "ichan" passes records from the record-reader
// to the transformer chain.
// * writerOutputRecordChannel "ochan" passes records from the transformer chain
// to the record-writer.
// * readerDownstreamDoneChannel "dchan" signifies back to the record-reader
// that it can stop reading files, since all downstream consumers aren't
// interested in any more data.
//
// Record-reader
// | ichan ^ dchan
// v |
// +---------------+
// | Transformer 0 |
// | Transformer 1 |
// | Transformer 2 |
// | Transformer 3 |
// +---------------+
// | ochan
// v
// Record-writer
//
// Channel structure from inside the box:
//
// Record-reader
// | ichan ^ dchan
// v |
// +---------------+
// | Transformer 0 |
// | | ^ |
// | v | |
// | Transformer 1 |
// | | ^ |
// | v | |
// | Transformer 2 |
// | | ^ |
// | v | |
// | Transformer 3 |
// +---------------+
// | ochan
// v
// Record-writer
//
// Each transformer has four channels from its point of view:
//
// | irchan odchan |
// | | ^ |
// | v | |
// | Transformer i |
// | | ^ |
// | v | |
// | orchan idchan |
//
// * inputRecordChannel "irchan" is where it reads records from.
// o If the transformer is the first in the chain, this is the
// readerInputRecordChannel passed into ChainTransformer.
// o Otherwise it's an intermediary channel from transformer i-1's output,
// created and connected by ChainTransformer.
//
// * outputRecordChannel "orchan" is where it writes records to.
// o If the transformer is the last in the chain, this is the
// writerOutputRecordChannel passed into ChainTransformer.
// o Otherwise it's an intermediary channel to transformer i+1's input,
// created and connected by ChainTransformer.
//
// * inputDownstreamDoneChannel "idchan" is where it reads a downstream-done flag from.
// o These are all created internally by ChainTransformer.
// o If the transform is the last in the chain, nothing writes to its idchan.
// o Otherwise transformer i's idchan is connected to transformer i+1's odchan,
// so transformer i can accept a downstream-done flag.
//
// * outputDownstreamDoneChannel "odchan" is where it writes a downstream-done flag to.
// o If the transformer is the first in the chain, this is the
// readerDownstreamDoneChannel passed into ChainTransformer.
// o Otherwise it's an intermediary channel connected to transformer i-1's idchan,
// so transformer i can produce a downstream-done flag.
//
// Handling in practice:
//
// * Most verbs pass a downstrem-done flag on their idchan upstream to their odchan.
// The HandleDefaultDownstreamDone function is for this purpose: most verbs use it.
//
// * The exceptional verbs are HEAD, TEE, and SEQGEN.
//
// * mlr head is the reason this exists. The problem to solve is that people
// want to do 'mlr head -n 10 myhugefile.dat' and have mlr exit as soon as those
// 10 records have been read.
//
// * However, if someone does 'mlr cut -f foo then tee bar.dat then head -n 10',
// they want bar.dat to have all the records seen by tee; head -n 10 should produce
// only the first 10 but bar.dat should have them all.
//
// * Likewise, 'mlr seqgen --stop 1000000000 then head -n 10' should result
// in seqgen breaking out of its data-production loop.
//
// * In head.go, tee.go, and seqgen.go you will see specific handling of
// reading idchan and writing odchan.
//
// TESTING
//
// * This is a bit awkward with regard to regression-test -- we don't want a
// multi-GB data file in our repo for the continuous integration job to check
// that the processing finishes quickly.
//
// * Nonetheless: all these should finish quickly/
//
// mlr head -n 10 ~/tmp/huge
// mlr cat then head -n 10 ~/tmp/huge
// mlr head -n 100 then tee foo.txt then head -n 10 ~/tmp/huge
// check `wc -l foo.txt` is 100
// mlr head -n 100 then tee foo.txt then head -n 10 then tee bar.txt ~/tmp/huge
// check `wc -l foo.txt` is 100 and `wc -l bar.txt` is 10
// mlr seqgen --stop 100000000 then head -n 10
//
// ChainTransformer is a refinement of Miller's high-level sketch in stream.go.
// While stream.go sees goroutines for record reader, transformer chain, and
// record writer, with input channel from record-reader to transformer chain
// and output channel from transformer chain to record-writer, ChainTransformer
// subdivides goroutines for each transformer in the chain, with intermediary
// channels between them.
func ChainTransformer(
readerRecordChannel <-chan []*types.RecordAndContext, // list of *types.RecordAndContext
readerDownstreamDoneChannel chan<- bool, // for mlr head -- see also stream.go
recordTransformers []RecordTransformer, // not *recordTransformer since this is an interface
writerRecordChannel chan<- []*types.RecordAndContext, // list of *types.RecordAndContext
dataProcessingErrorChannel chan<- error, // for mid-stream transformer errors -- see stream.go
options *cli.TOptions,
) {
i := 0
n := len(recordTransformers)
intermediateRecordChannels := make([]chan []*types.RecordAndContext, n-1) // list of *types.RecordAndContext
for i = 0; i < n-1; i++ {
intermediateRecordChannels[i] = make(chan []*types.RecordAndContext, 1) // list of *types.RecordAndContext
}
intermediateDownstreamDoneChannels := make([]chan bool, n)
for i = range n {
intermediateDownstreamDoneChannels[i] = make(chan bool, 1)
}
for i, recordTransformer := range recordTransformers {
// Downstream flow: channel a given transformer reads records from
irchan := readerRecordChannel
// Downstream flow: channel a given transformer writes transformed
// records to
orchan := writerRecordChannel
// Upstream signaling: channel a given transformer reads to see if
// downstream transformers are done (e.g. mlr head)
idchan := intermediateDownstreamDoneChannels[i]
// Upstream signaling: channel a given transformer (e.g. mlr head)
// writes to signal to upstream transformers that it will ignore
// further input.
odchan := readerDownstreamDoneChannel
if i > 0 {
irchan = intermediateRecordChannels[i-1]
odchan = intermediateDownstreamDoneChannels[i-1]
}
if i < n-1 {
orchan = intermediateRecordChannels[i]
}
go runSingleTransformer(
recordTransformer,
i == 0,
irchan,
orchan,
idchan,
odchan,
dataProcessingErrorChannel,
options,
)
}
}
func runSingleTransformer(
recordTransformer RecordTransformer,
isFirstInChain bool,
inputRecordChannel <-chan []*types.RecordAndContext, // list of *types.RecordAndContext
outputRecordChannel chan<- []*types.RecordAndContext, // list of *types.RecordAndContext
inputDownstreamDoneChannel <-chan bool,
outputDownstreamDoneChannel chan<- bool,
dataProcessingErrorChannel chan<- error,
options *cli.TOptions,
) {
done := false
for !done {
recordsAndContexts := <-inputRecordChannel
var err error
done, err = runSingleTransformerBatch(
recordsAndContexts,
recordTransformer,
isFirstInChain,
outputRecordChannel,
inputDownstreamDoneChannel,
outputDownstreamDoneChannel,
dataProcessingErrorChannel,
options,
)
if err != nil {
// runSingleTransformerBatch has already sent the error to
// dataProcessingErrorChannel and then forwarded an end-of-stream
// marker downstream, so the record-writer drains and finishes,
// upon which stream.Stream returns the error.
//
// Tell upstream (transformers and ultimately the record-reader,
// via the mlr-head mechanism) that we'll ignore further input.
select {
case outputDownstreamDoneChannel <- true:
default:
}
return
}
}
}
// runSingleTransformerBatch passes one batch of records through the
// transformer. The boolean return is true on end of record stream. A non-nil
// error is a mid-stream transformer failure: the error has already been sent
// to dataProcessingErrorChannel, and any output produced before the failure,
// plus an end-of-stream marker, has already been forwarded downstream so the
// rest of the chain and the record-writer can drain and finish cleanly.
func runSingleTransformerBatch(
inputRecordsAndContexts []*types.RecordAndContext, // list of types.RecordAndContext
recordTransformer RecordTransformer,
isFirstInChain bool,
outputRecordChannel chan<- []*types.RecordAndContext, // list of *types.RecordAndContext
inputDownstreamDoneChannel <-chan bool,
outputDownstreamDoneChannel chan<- bool,
dataProcessingErrorChannel chan<- error,
options *cli.TOptions,
) (bool, error) {
outputRecordsAndContexts := make([]*types.RecordAndContext, 0, len(inputRecordsAndContexts))
done := false
for _, inputRecordAndContext := range inputRecordsAndContexts {
// --nr-progress-mod
// TODO: function-pointer this away to reduce instruction count in the
// normal case which it isn't used at all. No need to test if {static thing} != 0
// on every record.
if options.NRProgressMod != 0 {
if isFirstInChain && inputRecordAndContext.Record != nil {
context := &inputRecordAndContext.Context
if context.NR%options.NRProgressMod == 0 {
fmt.Fprintf(os.Stderr, "NR=%d FNR=%d FILENAME=%s\n", context.NR, context.FNR, context.FILENAME)
}
}
}
// Three things can come through:
//
// * End-of-stream marker
// * Non-nil records to be printed
// * Strings to be printed from put/filter DSL print/dump/etc
// statements. They are handled here rather than fmt.Println directly
// in the put/filter handlers since we want all print statements and
// record-output to be in the same goroutine, for deterministic
// output ordering.
//
// The first two are passed to the transformer. The third we send along
// the output channel without involving the record-transformer, since
// there is no record to be transformed.
if inputRecordAndContext.EndOfStream || inputRecordAndContext.Record != nil {
err := recordTransformer.Transform(
inputRecordAndContext,
&outputRecordsAndContexts,
// TODO: maybe refactor these out of each transformer.
// And/or maybe poll them once per batch not once per record.
inputDownstreamDoneChannel,
outputDownstreamDoneChannel,
)
if err != nil {
// Surface the error to stream.Stream's select loop.
// Non-blocking send: if another goroutine errored first, that
// error wins. This must happen before the end-of-stream
// marker is forwarded below: the marker lets the
// record-writer finish and signal done-writing, and the error
// must already be buffered by then or stream.Stream could
// return nil, losing the nonzero exit code.
select {
case dataProcessingErrorChannel <- err:
default:
}
// Forward what was produced before the failure, plus an
// end-of-stream marker so downstream drains and finishes.
outputRecordsAndContexts = append(outputRecordsAndContexts,
types.NewEndOfStreamMarker(&inputRecordAndContext.Context))
outputRecordChannel <- outputRecordsAndContexts
return true, err
}
} else {
outputRecordsAndContexts = append(outputRecordsAndContexts, inputRecordAndContext)
}
if inputRecordAndContext.EndOfStream {
done = true
break
}
if done {
break
}
}
outputRecordChannel <- outputRecordsAndContexts
return done, nil
}