diff: implement --stop feature (#405)

* sq diff --stop
This commit is contained in:
Neil O'Toole 2024-02-29 11:48:35 -07:00 committed by GitHub
parent 78c3cfc77d
commit 2cca8a51b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 250 additions and 80 deletions

View File

@ -9,8 +9,34 @@ Breaking changes are annotated with ☢️, and alpha/beta features with 🐥.
## Upcoming
This release features significant improvements to [`sq diff`](https://sq.io/docs/diff).
## Added
- Previously `sq diff --data` diffed every row, which could get crazy
with a large table. Now the command stops after N differences, where N is controlled by
the `--stop` flag, or the new config option [`diff.stop`](https://sq.io/docs/config#diffstop).
The default stop-after value is `3`; set to `0` to show all differences.
```shell
# Stop on first difference
$ sq diff @prod.actor @staging.actor --data --stop 1
# Stop after 5 differences, using the -n shorthand flag
$ sq diff @prod.actor @staging.actor --data -n 5
```
- [#353]: The performance of `sq diff` has been significantly improved. There's still more to do.
## Changed
- ☢️ Previously, `sq diff` only exited non-zero on an error. Now, `sq diff` exits `0` when no differences,
exits `1` if differences are found, and exits `2` on any error.
This aligns with the behavior of [GNU diff](https://www.gnu.org/software/diffutils/manual/):
```text
Exit status is 0 if inputs are the same, 1 if different, 2 if trouble.
```
- Minor fiddling with the color scheme for some command output.
@ -1131,6 +1157,7 @@ make working with lots of sources much easier.
[#335]: https://github.com/neilotoole/sq/issues/335
[#338]: https://github.com/neilotoole/sq/issues/338
[#340]: https://github.com/neilotoole/sq/pull/340
[#353]: https://github.com/neilotoole/sq/pull/353
[v0.15.2]: https://github.com/neilotoole/sq/releases/tag/v0.15.2

View File

@ -20,7 +20,6 @@ package cli
import (
"context"
"errors"
"fmt"
"io"
"os"
@ -56,13 +55,6 @@ const (
msgSrcEmptyTableName = "source has empty table name"
)
// errNoMsg is a sentinel error indicating that a command
// has failed (and thus the program should exit with a non-zero
// code), but no error message should be printed.
// This is useful in the case where any error information may
// already have been printed as part of the command output.
var errNoMsg = errors.New("")
// Execute builds a Run using ctx and default
// settings, and invokes ExecuteWith.
func Execute(ctx context.Context, stdin *os.File, stdout, stderr io.Writer, args []string) error {

View File

@ -27,6 +27,15 @@ var OptDiffNumLines = options.NewInt(
options.TagOutput,
)
var OptDiffStopAfter = options.NewInt(
"diff.stop",
&options.Flag{Name: "stop", Short: 'n'},
3,
"Stop after <n> differences",
`Stop after <n> differences are found. If n <= 0, no limit is applied.`,
options.TagOutput,
)
var OptDiffHunkMaxSize = options.NewInt(
"diff.hunk.max-size",
nil,
@ -98,8 +107,7 @@ source overview, schema, and table row counts. Table row data is not compared.
When comparing tables ("table diff"), the default is to diff table schema and
row counts. Table row data is not compared.
Use flags to specify the elements you want to compare. The available
elements are:
Use flags to specify the modes you want to compare. The available modes are:
--overview source metadata, without schema (source diff only)
--dbprops database/server properties (source diff only)
@ -108,13 +116,14 @@ elements are:
--data row data values
--all all of the above
Flag --data diffs the values of each row in the compared tables. Use with
caution with large tables.
Flag --data diffs the values of each row in the compared tables, until the stop
limit is reached. Use the --stop (-n) flag or the diff.stop config option to
specify the stop limit. The default is 3.
Use --format with --data to specify the format to render the diff records.
Line-based formats (e.g. "text" or "jsonl") are often the most ergonomic,
although "yaml" may be preferable for comparing column values. The
available formats are:
although "yaml" may be preferable for comparing column values. The available
formats are:
text, csv, tsv,
json, jsona, jsonl,
@ -132,15 +141,16 @@ Note that --overview and --dbprops only apply to source diffs, not table diffs.
Flag --unified (-U) controls the number of lines to show surrounding a diff.
The default (3) can be changed via:
$ sq config set diff.lines N`,
$ sq config set diff.lines N
Exit status is 0 if inputs are the same, 1 if different, 2 on any error.`,
Args: cobra.ExactArgs(2),
ValidArgsFunction: (&handleTableCompleter{
handleRequired: true,
max: 2,
}).complete,
RunE: execDiff,
Example: `
Metadata diff
Example: ` Metadata diff
-------------
# Diff sources (compare default elements).
@ -173,17 +183,18 @@ The default (3) can be changed via:
Row data diff
-------------
# Compare data in the actor tables.
$ sq diff @prod/sakila.actor @staging/sakila.actor --data
# Compare data in the actor tables, stopping at the first difference.
$ sq diff @prod/sakila.actor @staging/sakila.actor --data --stop 1
# Compare data in the actor tables, but output in JSONL.
$ sq diff @prod/sakila.actor @staging/sakila.actor --data --format jsonl
# Compare data in all tables and views. Caution: may be slow.
$ sq diff @prod/sakila @staging/sakila --data`,
$ sq diff @prod/sakila @staging/sakila --data --stop 0`,
}
addOptionFlag(cmd.Flags(), OptDiffNumLines)
addOptionFlag(cmd.Flags(), OptDiffStopAfter)
addOptionFlag(cmd.Flags(), OptDiffDataFormat)
cmd.Flags().BoolP(flag.DiffOverview, flag.DiffOverviewShort, false, flag.DiffOverviewUsage)
@ -209,10 +220,23 @@ The default (3) can be changed via:
}
// execDiff compares sources or tables.
func execDiff(cmd *cobra.Command, args []string) error {
func execDiff(cmd *cobra.Command, args []string) (err error) {
ctx := cmd.Context()
ru := run.FromContext(ctx)
var foundDiffs bool
defer func() {
// From GNU diff help:
// > Exit status is 0 if inputs are the same, 1 if different, 2 if trouble.
switch {
case err != nil:
err = errz.WithExitCode(err, 2)
case foundDiffs:
// We want to exit 1 if diffs were found.
err = errz.WithExitCode(errz.ErrNoMsg, 1)
}
}()
handle1, table1, err := source.ParseTableHandle(args[0])
if err != nil {
return errz.Wrapf(err, "invalid input (1st arg): %s", args[0])
@ -248,6 +272,7 @@ func execDiff(cmd *cobra.Command, args []string) error {
diffCfg := &diff.Config{
Run: ru,
Lines: OptDiffNumLines.Get(o),
StopAfter: OptDiffStopAfter.Get(o),
HunkMaxSize: OptDiffHunkMaxSize.Get(o),
Printing: ru.Writers.OutPrinting.Clone(),
Colors: ru.Writers.OutPrinting.Diff.Clone(),
@ -261,20 +286,22 @@ func execDiff(cmd *cobra.Command, args []string) error {
switch {
case table1 == "" && table2 == "":
diffCfg.Elements = getDiffSourceElements(cmd)
return diff.ExecSourceDiff(ctx, diffCfg, src1, src2)
diffCfg.Modes = getDiffSourceElements(cmd)
foundDiffs, err = diff.ExecSourceDiff(ctx, diffCfg, src1, src2)
case table1 == "" || table2 == "":
return errz.Errorf("invalid args: both must be either @HANDLE or @HANDLE.TABLE")
default:
diffCfg.Elements = getDiffTableElements(cmd)
return diff.ExecTableDiff(ctx, diffCfg, src1, table1, src2, table2)
diffCfg.Modes = getDiffTableElements(cmd)
foundDiffs, err = diff.ExecTableDiff(ctx, diffCfg, src1, table1, src2, table2)
}
return err
}
func getDiffSourceElements(cmd *cobra.Command) *diff.Elements {
func getDiffSourceElements(cmd *cobra.Command) *diff.Modes {
if !isAnyDiffElementsFlagChanged(cmd) {
// Default
return &diff.Elements{
return &diff.Modes{
Overview: true,
DBProperties: false,
Schema: true,
@ -284,7 +311,7 @@ func getDiffSourceElements(cmd *cobra.Command) *diff.Elements {
}
if cmdFlagChanged(cmd, flag.DiffAll) {
return &diff.Elements{
return &diff.Modes{
Overview: true,
DBProperties: true,
Schema: true,
@ -293,7 +320,7 @@ func getDiffSourceElements(cmd *cobra.Command) *diff.Elements {
}
}
return &diff.Elements{
return &diff.Modes{
Overview: cmdFlagIsSetTrue(cmd, flag.DiffOverview),
DBProperties: cmdFlagIsSetTrue(cmd, flag.DiffDBProps),
Schema: cmdFlagIsSetTrue(cmd, flag.DiffSchema),
@ -302,24 +329,24 @@ func getDiffSourceElements(cmd *cobra.Command) *diff.Elements {
}
}
func getDiffTableElements(cmd *cobra.Command) *diff.Elements {
func getDiffTableElements(cmd *cobra.Command) *diff.Modes {
if !isAnyDiffElementsFlagChanged(cmd) {
// Default
return &diff.Elements{
return &diff.Modes{
Schema: true,
RowCount: true,
}
}
if cmdFlagChanged(cmd, flag.DiffAll) {
return &diff.Elements{
return &diff.Modes{
Schema: true,
RowCount: true,
Data: true,
}
}
return &diff.Elements{
return &diff.Modes{
Schema: cmdFlagIsSetTrue(cmd, flag.DiffSchema),
RowCount: cmdFlagIsSetTrue(cmd, flag.DiffRowCount),
Data: cmdFlagIsSetTrue(cmd, flag.DiffData),

View File

@ -125,7 +125,7 @@ func execPing(cmd *cobra.Command, args []string) error {
err = pingSources(cmd.Context(), ru.DriverRegistry, srcs, ru.Writers.Ping, timeout)
if errors.Is(err, context.Canceled) {
// It's common to cancel "sq ping". We don't want to print the cancel message.
return errNoMsg
return errz.ErrNoMsg
}
return err
@ -133,7 +133,7 @@ func execPing(cmd *cobra.Command, args []string) error {
// pingSources pings each of the sources in srcs, and prints results
// to w. If any error occurs pinging any of srcs, that error is printed
// inline as part of the ping results, and an errNoMsg is returned.
// inline as part of the ping results, and an ErrNoMsg is returned.
//
// NOTE: This ping code has an ancient lineage, in that it was
// originally laid down before context.Context was a thing. Thus,
@ -189,7 +189,7 @@ func pingSources(ctx context.Context, dp driver.Provider, srcs []*source.Source,
// an additional error message (as the error message will already have
// been printed by PingWriter).
if pingErrExists {
return errNoMsg
return errz.ErrNoMsg
}
return nil

View File

@ -9,8 +9,6 @@ import (
"os"
"time"
"github.com/neilotoole/sq/testh/proj"
"github.com/spf13/cobra"
"github.com/neilotoole/sq/cli/run"
@ -18,6 +16,7 @@ import (
"github.com/neilotoole/sq/libsq/core/lg/lga"
"github.com/neilotoole/sq/libsq/core/progress"
"github.com/neilotoole/sq/libsq/files"
"github.com/neilotoole/sq/testh/proj"
)
// newXCmd returns the "x" command, which is the container

View File

@ -27,11 +27,11 @@ func BenchmarkExecTableDiff(b *testing.B) {
require.NoError(b, ru.Config.Collection.Add(srcA))
require.NoError(b, ru.Config.Collection.Add(srcB))
elems := &diff.Elements{Data: true}
elems := &diff.Modes{Data: true}
cfg := &diff.Config{
Run: ru,
RecordWriterFn: tablew.NewRecordWriter,
Elements: elems,
Modes: elems,
Lines: 3,
Printing: output.NewPrinting(),
Colors: diffdoc.NewColors(),
@ -43,7 +43,7 @@ func BenchmarkExecTableDiff(b *testing.B) {
for i := 0; i < b.N; i++ {
buf := &bytes.Buffer{}
ru.Out = buf
err := diff.ExecTableDiff(th.Context, cfg, srcA, sakila.TblActor, srcB, sakila.TblActor)
_, err := diff.ExecTableDiff(th.Context, cfg, srcA, sakila.TblActor, srcB, sakila.TblActor)
require.NoError(b, err)
buf.Reset()
}

View File

@ -13,12 +13,11 @@ type Config struct {
// Run is the main program run.Run instance.
Run *run.Run
// Elements specifies what elements to diff.
Elements *Elements
// Modes specifies what diff modes to use.
Modes *Modes
// RecordWriterFn is a factory function that returns
// an output.RecordWriter used to generate diff text
// when comparing table data.
// RecordWriterFn is a factory function that returns an output.RecordWriter
// used to generate diff text when comparing table data.
RecordWriterFn output.NewRecordWriterFunc
// Printing is the output.Printing instance to use when generating diff text.
@ -39,10 +38,13 @@ type Config struct {
// Zero indicates sequential execution; a negative values indicates unbounded
// concurrency.
Concurrency int
// StopAfter specifies the number of diffs to execute before stopping.
StopAfter int
}
// Elements determines what source elements to compare.
type Elements struct {
// Modes determines what diff modes to execute.
type Modes struct {
// Overview compares a summary of the sources.
Overview bool

View File

@ -8,6 +8,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/neilotoole/sq/cli/testrun"
"github.com/neilotoole/sq/libsq/core/errz"
"github.com/neilotoole/sq/libsq/source"
"github.com/neilotoole/sq/libsq/source/drivertype"
"github.com/neilotoole/sq/testh"
@ -35,6 +36,7 @@ func TestSchemaDiff(t *testing.T) {
err := tr.Reset().Exec("diff", "@test_a", "@test_b", "--schema")
require.NoError(t, err)
require.Error(t, err)
require.Equal(t, 1, errz.ExitCode(err), "should be exit code 1 on differences")
fmt.Fprintln(os.Stdout, tr.Out.String())
}

View File

@ -8,13 +8,15 @@ import (
)
// ExecSourceDiff is the entrypoint to diff two sources, handle1 and handle2.
// If differences are found, hasDiffs returns true.
//
// Contrast with [ExecTableDiff], which diffs two tables.
func ExecSourceDiff(ctx context.Context, cfg *Config, src1, src2 *source.Source) error {
elems := cfg.Elements
func ExecSourceDiff(ctx context.Context, cfg *Config, src1, src2 *source.Source) (hasDiffs bool, err error) {
modes := cfg.Modes
var differs []*diffdoc.Differ
if elems.Overview {
if modes.Overview {
doc := diffdoc.NewUnifiedDoc(diffdoc.Titlef(cfg.Colors,
"sq diff --overview %s %s", src1.Handle, src2.Handle))
differs = append(differs, diffdoc.NewDiffer(doc, func(ctx context.Context, _ func(error)) {
@ -22,7 +24,7 @@ func ExecSourceDiff(ctx context.Context, cfg *Config, src1, src2 *source.Source)
}))
}
if elems.DBProperties {
if modes.DBProperties {
doc := diffdoc.NewUnifiedDoc(diffdoc.Titlef(cfg.Colors,
"sq diff --dbprops %s %s", src1.Handle, src2.Handle))
differs = append(differs, diffdoc.NewDiffer(doc, func(ctx context.Context, _ func(error)) {
@ -30,19 +32,19 @@ func ExecSourceDiff(ctx context.Context, cfg *Config, src1, src2 *source.Source)
}))
}
if elems.Schema {
schemaDiffers, err := differsForSchema(ctx, cfg, elems.RowCount, src1, src2)
if modes.Schema {
schemaDiffers, err := differsForSchema(ctx, cfg, modes.RowCount, src1, src2)
if err != nil {
return err
return hasDiffs, err
}
differs = append(differs, schemaDiffers...)
}
if elems.Data {
if modes.Data {
// We're going for it... diff all table data.
dataDiffers, err := differsForAllTableData(ctx, cfg, src1, src2)
if err != nil {
return err
return hasDiffs, err
}
differs = append(differs, dataDiffers...)
}

View File

@ -10,13 +10,15 @@ import (
// ExecTableDiff is the entrypoint to diff two tables, src1.table1 and
// src2.table2.
//
// If differences are found, hasDiffs returns true.
//
// Contrast with [ExecSourceDiff], which diffs two sources.
func ExecTableDiff(ctx context.Context, cfg *Config,
src1 *source.Source, table1 string, src2 *source.Source, table2 string,
) error {
func ExecTableDiff(ctx context.Context, cfg *Config, src1 *source.Source, table1 string,
src2 *source.Source, table2 string,
) (hasDiffs bool, err error) {
var (
ru = cfg.Run
elems = cfg.Elements
elems = cfg.Modes
td1 = source.Table{Handle: src1.Handle, Name: table1}
td2 = source.Table{Handle: src2.Handle, Name: table2}
differs []*diffdoc.Differ

View File

@ -3,6 +3,7 @@ package diff
import (
"bytes"
"context"
"errors"
"fmt"
"slices"
"strings"
@ -54,7 +55,11 @@ func differsForAllTableData(ctx context.Context, cfg *Config, src1, src2 *source
func differForTableData(cfg *Config, title bool, td1, td2 source.Table) *diffdoc.Differ {
var cmdTitle diffdoc.Title
if title {
cmdTitle = diffdoc.Titlef(cfg.Colors, "sq diff --data %s %s", td1, td2)
if cfg.StopAfter > 0 {
cmdTitle = diffdoc.Titlef(cfg.Colors, "sq diff --data --stop-after %d %s %s", cfg.StopAfter, td1, td2)
} else {
cmdTitle = diffdoc.Titlef(cfg.Colors, "sq diff --data %s %s", td1, td2)
}
}
doc := diffdoc.NewHunkDoc(
@ -76,7 +81,7 @@ func differForTableData(cfg *Config, title bool, td1, td2 source.Table) *diffdoc
// checked via [diffdoc.HunkDoc.Err]. Any error should also be propagated via
// cancelFn, to cancel any peer goroutines. Note that the returned doc's
// [diffdoc.Doc.Read] method blocks until the doc is completed (or errors out).
func diffTableData(ctx context.Context, cancelFn context.CancelCauseFunc,
func diffTableData(ctx context.Context, cancelFn context.CancelCauseFunc, //nolint:funlen,gocognit
cfg *Config, td1, td2 source.Table, doc *diffdoc.HunkDoc,
) {
log := lg.FromContext(ctx).With(lga.Left, td1.String(), lga.Right, td2.String())
@ -124,6 +129,18 @@ func diffTableData(ctx context.Context, cancelFn context.CancelCauseFunc,
return
}
if errors.Is(err, context.Canceled) {
log.Warn("Diff: cancelled err on errCh consumer")
// FIXME: docs
return
}
if errors.Is(err, errz.ErrStop) {
log.Warn("Diff: stop error on errCh consumer")
// FIXME: docs
return
}
log.Error("Error from record writer errCh", lga.Err, err)
cancelFn(err)
}
@ -135,13 +152,22 @@ func diffTableData(ctx context.Context, cancelFn context.CancelCauseFunc,
qc := run.NewQueryContext(cfg.Run, nil)
// We give the DB query goroutines their own context, dbCtx. This is so that
// we can explicitly stop the queries using dbCancel(errz.ErrStop) if we reach
// the diff stop-after limit.
dbCtx, dbCancel := context.WithCancelCause(ctx)
go func() {
query1 := td1.Handle + "." + stringz.DoubleQuote(td1.Name)
// Execute DB query1; records will be sent to recw1.recCh.
if err := libsq.ExecuteSLQ(ctx, qc, query1, recw1); err != nil {
if errz.Has[*driver.NotExistError](err) {
if err := libsq.ExecuteSLQ(dbCtx, qc, query1, recw1); err != nil {
switch {
case errz.Has[*driver.NotExistError](err):
// For diffing, it's totally ok if a table is not found.
log.Debug("Diff: table not found", lga.Table, td1.String())
log.Debug("Diff: table not found", lga.Table, td2.String())
return
case errors.Is(err, errz.ErrStop) || errz.IsContextStop(dbCtx):
// This means we explicitly stopped the query, probably due to reaching
// the diff stop-after limit.
return
}
@ -161,10 +187,17 @@ func diffTableData(ctx context.Context, cancelFn context.CancelCauseFunc,
go func() {
query2 := td2.Handle + "." + stringz.DoubleQuote(td2.Name)
// Execute DB query2; records will be sent to recw2.recCh.
if err := libsq.ExecuteSLQ(ctx, qc, query2, recw2); err != nil {
if errz.Has[*driver.NotExistError](err) {
if err := libsq.ExecuteSLQ(dbCtx, qc, query2, recw2); err != nil {
switch {
case errz.Has[*driver.NotExistError](err):
// For diffing, it's totally ok if a table is not found.
log.Debug("Diff: table not found", lga.Table, td2.String())
return
case errors.Is(err, errz.ErrStop) || errz.IsContextStop(dbCtx):
// This means we explicitly stopped the query, probably due to
// reaching the diff stop-after limit.
return
default:
}
cancelFn(err)
@ -182,6 +215,8 @@ func diffTableData(ctx context.Context, cancelFn context.CancelCauseFunc,
// by the diff exec goroutine further below.
go func() {
var rec1, rec2 record.Record
var diffCount int
stopAfter := cfg.StopAfter
for i := 0; ctx.Err() == nil; i++ {
select {
@ -202,7 +237,17 @@ func diffTableData(ctx context.Context, cancelFn context.CancelCauseFunc,
return
}
recPairsCh <- record.NewPair(i, rec1, rec2)
rp := record.NewPair(i, rec1, rec2)
if !rp.Equal() {
diffCount++
}
recPairsCh <- rp
if stopAfter > 0 && diffCount >= stopAfter {
dbCancel(errz.ErrStop) // Explicit stop
close(recPairsCh)
return
}
}
}()

View File

@ -40,7 +40,7 @@ func PrintError(ctx context.Context, ru *run.Run, err error) {
return
}
if errors.Is(err, errNoMsg) {
if errors.Is(err, errz.ErrNoMsg) {
// errNoMsg is a sentinel err that sq doesn't want to print
return
}

View File

@ -185,6 +185,7 @@ func RegisterDefaultOpts(reg *options.Registry) {
OptLogLevel,
OptLogFormat,
OptDiffNumLines,
OptDiffStopAfter,
OptDiffDataFormat,
OptDiffHunkMaxSize,
files.OptHTTPRequestTimeout,

View File

@ -16,7 +16,7 @@ func TestRegisterDefaultOpts(t *testing.T) {
lgt.New(t).Debug("options.Registry (after)", "reg", reg)
keys := reg.Keys()
require.Len(t, keys, 58)
require.Len(t, keys, 59)
for _, opt := range reg.Opts() {
opt := opt

View File

@ -50,9 +50,9 @@ func (d *Differ) execute(ctx context.Context, cancelFn func(error)) func() error
// Zero indicates sequential execution; a negative values indicates unbounded
// concurrency.
//
// The first error encountered is returned.
func Execute(ctx context.Context, w io.Writer, concurrency int, differs []*Differ) (err error) {
// REVISIT: should Execute accept <-chan *Differ instead of []*Differ?
// The first error encountered is returned; hasDiff returns true if differences
// were found, and false if no differences.
func Execute(ctx context.Context, w io.Writer, concurrency int, differs []*Differ) (hasDiffs bool, err error) {
defer func() {
for _, differ := range differs {
if differs == nil || differ.doc == nil {
@ -90,6 +90,7 @@ func Execute(ctx context.Context, w io.Writer, concurrency int, differs []*Diffe
rdrs = append(rdrs, differs[i].doc)
}
_, err = io.Copy(w, contextio.NewReader(ctx, io.MultiReader(rdrs...)))
return err
var n int64
n, err = io.Copy(w, contextio.NewReader(ctx, io.MultiReader(rdrs...)))
return n > 0, err
}

View File

@ -292,6 +292,37 @@ func ExitCode(err error) (code int) {
return -1
}
// WithExitCode returns an error that implements ExitCoder, if err is non-nil.
// If err is nil, WithExitCode returns nil. If err already implements ExitCoder
// and its exit code is the same as code, WithExitCode returns err unchanged.
func WithExitCode(err error, code int) error {
if err == nil {
return nil
}
if c := ExitCode(err); c == code {
return err
}
if ez, ok := err.(*errz); ok { //nolint:errorlint
return &exitCoder{errz: *ez, code: code}
}
return &exitCoder{errz: errz{stack: callers(0), error: err}, code: code}
}
var _ ExitCoder = (*exitCoder)(nil)
type exitCoder struct {
errz
code int
}
// ExitCode implements ExitCoder.
func (e *exitCoder) ExitCode() int {
return e.code
}
// Drain reads all currently available non-nil errors from errCh. If errCh is
// nil, or there are no errors to read, Drain returns nil. If there's only a
// single error, Drain returns it. If there are multiple errors, Drain returns
@ -361,3 +392,29 @@ func IsErrContext(err error) bool {
}
return errors.Is(err, context.DeadlineExceeded)
}
// ErrStop is a sentinel error a la [io.EOF] used to indicate that an explicit
// stop condition has been reached. The stop condition is typically not an
// indication of a failure state, but rather a signal to stop processing. It is
// usually used in conjunction with context.CancelCauseFunc.
//
// See: [IsContextStop].
var ErrStop = errors.New("explicit stop")
// IsContextStop returns true if ctx's cause error is [ErrStop].
func IsContextStop(ctx context.Context) bool {
if ctx == nil {
return false
}
err := context.Cause(ctx)
if err == nil {
return false
}
return errors.Is(err, ErrStop)
}
// ErrNoMsg is a sentinel error indicating that a command has failed (and thus
// the program should exit with a non-zero code), but no error message should be
// printed. This is useful in the case where any error information may already
// have been printed as part of the command output.
var ErrNoMsg = errors.New("")

View File

@ -243,3 +243,18 @@ func TestIsErrContext(t *testing.T) {
require.True(t, errz.IsErrContext(errz.Err(context.Canceled)))
require.True(t, errz.IsErrContext(fmt.Errorf("wrap: %w", context.Canceled)))
}
func TestWithExitCode(t *testing.T) {
got := errz.WithExitCode(nil, 1)
require.Nil(t, got)
err := errz.New("nope")
require.Equal(t, -1, errz.ExitCode(err))
err = errz.WithExitCode(err, 2)
require.Equal(t, 2, errz.ExitCode(err))
err = errz.WithExitCode(errz.ErrNoMsg, 3)
require.Equal(t, 3, errz.ExitCode(err))
require.True(t, errors.Is(err, errz.ErrNoMsg))
}

View File

@ -264,8 +264,6 @@ func getPair[K comparable, V any](ctx context.Context, c *oncecache.Cache[K, V],
val2, mdErr = c.Get(gCtx, key2)
return mdErr
})
if err = g.Wait(); err != nil {
return val1, val2, err
}
err = g.Wait()
return val1, val2, err
}