Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

util/must: add Handle for convenient failure handling #107790

Closed
wants to merge 3 commits into from

Conversation

erikgrinaker
Copy link
Contributor

@erikgrinaker erikgrinaker commented Jul 28, 2023

util/must: add Handle for convenient failure handling

This patch adds Handle(), a convenience function that avoids returning assertion errors when running many assertions. Instead, non-fatal assertion failures throw panics that are caught by Handle() and converted to errors. Example usage:

// nolint:errcheck
err := must.Handle(ctx, func(ctx context.Context) {
  must.True(ctx, true, "not true")
  must.Equal(ctx, 1, 1, "not equal")
})

Inside a Handle closure, must assertions never return errors, so they can be ignored. However, the errcheck linter will complain about this and must be disabled.

Initially, an attempt was made to pass in a struct with infallible assertion methods, for a safer API. Unfortunately, methods can't use generics, nor can function types, so this appears to be one of the only ways to achieve this.

Resolves #107418.

util/must: add DisableFatalAssertions for tests

storage: use must for MVCC iterator assertions

Epic: none
Release note: None

@erikgrinaker erikgrinaker requested a review from knz July 28, 2023 14:15
@erikgrinaker erikgrinaker self-assigned this Jul 28, 2023
@cockroach-teamcity
Copy link
Member

This change is Reviewable

Copy link
Contributor

@knz knz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed 4 of 4 files at r1, 1 of 1 files at r2, 31 of 31 files at r3, 8 of 8 files at r4, 2 of 2 files at r5, 31 of 33 files at r8, 8 of 8 files at r9, 2 of 2 files at r10, 2 of 2 files at r11, all commit messages.
Reviewable status: :shipit: complete! 0 of 0 LGTMs obtained (waiting on @erikgrinaker)


pkg/util/must/must.go line 271 at r5 (raw file):

// handlePanic is thrown and recovered via Handle().
type handlePanic struct{ err error }

Better yet: make this an error, (with func Unwrap() error { return h.err })
Then use errors.As() in your recover handler above, like this:

if r := recover(); r != nil {
   e, ok := r.(error)
   if !ok { panic(r) }
   if unused := handlePanic{}; !errors.As(e, &unused) { panic(r) }
   err = e
}

Why this makes sense:

  • the code under Handle can be complex and wrap its errors using errors.Wrap or other things.
  • you don't want to discard these layers in the return value of Handle.

Copy link
Contributor Author

@erikgrinaker erikgrinaker left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewable status: :shipit: complete! 0 of 0 LGTMs obtained (waiting on @knz)


pkg/util/must/must.go line 271 at r5 (raw file):

Previously, knz (Raphael 'kena' Poss) wrote…

Better yet: make this an error, (with func Unwrap() error { return h.err })
Then use errors.As() in your recover handler above, like this:

if r := recover(); r != nil {
   e, ok := r.(error)
   if !ok { panic(r) }
   if unused := handlePanic{}; !errors.As(e, &unused) { panic(r) }
   err = e
}

Why this makes sense:

  • the code under Handle can be complex and wrap its errors using errors.Wrap or other things.
  • you don't want to discard these layers in the return value of Handle.

Sure, if it fits in the mid-stack inlining budget.

Does the overall approach seem reasonable though? I'm a bit lukewarm on the whole nolint:errcheck thing and lack of API safety, but seems like the lesser of evils.

@knz
Copy link
Contributor

knz commented Jul 28, 2023

IMHO the approach is sound. Also there's precedentr: that's what testing.T does.

@erikgrinaker
Copy link
Contributor Author

Ok thanks, I'll wrap this up then.

@erikgrinaker erikgrinaker force-pushed the must-with branch 2 times, most recently from 226b112 to eb17c25 Compare July 29, 2023 09:38
@erikgrinaker erikgrinaker marked this pull request as ready for review July 29, 2023 09:41
@erikgrinaker erikgrinaker requested a review from a team as a code owner July 29, 2023 09:41
@erikgrinaker erikgrinaker requested a review from itsbilal July 29, 2023 09:41
Copy link
Member

@RaduBerinde RaduBerinde left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do the must.XX APIs return errors in the first place? I don't see much of a usecase for that..

E.g. not much benefit to do

if err := must.True(x, "msg"); err != nil {
  return err
}

instead of

if !x {
  return errors.AssertionFailedf("msg")
}

Reviewable status: :shipit: complete! 0 of 0 LGTMs obtained (waiting on @itsbilal and @knz)

Copy link
Member

@RaduBerinde RaduBerinde left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewable status: :shipit: complete! 0 of 0 LGTMs obtained (waiting on @erikgrinaker, @itsbilal, and @knz)


pkg/storage/intent_interleaving_iter.go line 1348 at r14 (raw file):

		// At least one of the iterators must be valid. The iterator's validity state
		// should match i.iterValid.
		must.True(ctx, iterValid || intentValid, "i.valid=%t but both iterators are invalid", i.valid)

This code looks much cleaner now, nice!

One thing to keep in mind though is that now all the formatting arguments get boxed into interface{} in all cases, so we will allocate a lot more - we don't want that in hot paths.

Copy link
Contributor Author

@erikgrinaker erikgrinaker left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do the must.XX APIs return errors in the first place? I don't see much of a usecase for that..

A couple of reasons:

  • We want to fatal on assertion failures in non-release builds, to make sure we notice them. We want to return assertion errors in release builds, to avoid outages over trigger-happy assertions, but still want them to notify Sentry and log an error with a stack trace at the failure site.

  • For other assertions, e.g. must.Equal(a, b), we can automatically include the values in the failure messages without the caller having to manually pass them in the format string, and can generate better output like diffs in the case of large values (e.g. large structs). We can also have less trivial assertions like must.Contains(), although probably nothing too complex since we want them to be reasonably cheap. This is all purely for convenience though, and we can drop it if that's the consensus, but I find myself preferring Testify over testing.T and figured the same would hold here.

Reviewable status: :shipit: complete! 0 of 0 LGTMs obtained (waiting on @itsbilal, @knz, and @RaduBerinde)


pkg/storage/intent_interleaving_iter.go line 1348 at r14 (raw file):

One thing to keep in mind though is that now all the formatting arguments get boxed into interface{} in all cases, so we will allocate a lot more - we don't want that in hot paths.

Oof, yeah, good point. Not a big deal here specifically, because these are all gated on must.Expensive and compiled out in most builds, but could be a problem in general.

Given the assertions all inline, I was hoping the compiler would be clever enough to avoid the heap escape on the happy path, or that we could avoid it with something like the below, but alas.

func True(ctx context.Context, v bool, format string, args ...interface{}) error {
	if v {
		return nil
	}
	f, a := format, args
	return failDepth(ctx, 1, f, a...)
}

That said, the current approach for assertions also has this problem, so I guess it isn't strictly worse than today, but it would be nice to avoid it. Consider e.g.:

foo := "bar"
if foo != "bar" {
	log.Fatalf(ctx, "%s != bar", foo) // foo escapes
}

Both must or log.Fatalf can avoid it with something like:

foo := "bar"
if foo != "bar" {
	foo := foo // escapes on unhappy path
	must.Fail(ctx, "%s != bar", foo)
}

But I agree that this is an unfortunate API footgun. Ideas for how to improve this?

Copy link
Contributor Author

@erikgrinaker erikgrinaker left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewable status: :shipit: complete! 0 of 0 LGTMs obtained (waiting on @itsbilal, @knz, and @RaduBerinde)


pkg/storage/intent_interleaving_iter.go line 1348 at r14 (raw file):

Previously, erikgrinaker (Erik Grinaker) wrote…

One thing to keep in mind though is that now all the formatting arguments get boxed into interface{} in all cases, so we will allocate a lot more - we don't want that in hot paths.

Oof, yeah, good point. Not a big deal here specifically, because these are all gated on must.Expensive and compiled out in most builds, but could be a problem in general.

Given the assertions all inline, I was hoping the compiler would be clever enough to avoid the heap escape on the happy path, or that we could avoid it with something like the below, but alas.

func True(ctx context.Context, v bool, format string, args ...interface{}) error {
	if v {
		return nil
	}
	f, a := format, args
	return failDepth(ctx, 1, f, a...)
}

That said, the current approach for assertions also has this problem, so I guess it isn't strictly worse than today, but it would be nice to avoid it. Consider e.g.:

foo := "bar"
if foo != "bar" {
	log.Fatalf(ctx, "%s != bar", foo) // foo escapes
}

Both must or log.Fatalf can avoid it with something like:

foo := "bar"
if foo != "bar" {
	foo := foo // escapes on unhappy path
	must.Fail(ctx, "%s != bar", foo)
}

But I agree that this is an unfortunate API footgun. Ideas for how to improve this?

This is only the case for format args though, with the typed convenience API we copy these internally on the unhappy path, e.g.:

foo := "bar"
err := must.Equal(ctx, foo, "bar", "failed") // does not escape
if err != nil {
	return err
}

This patch adds `Handle()`, a convenience function that avoids returning
assertion errors when running many assertions. Instead, non-fatal
assertion failures throw panics that are caught by `Handle()` and
converted to errors. Example usage:

```go
// nolint:errcheck
err := must.Handle(ctx, func(ctx context.Context) {
  must.True(ctx, true, "not true")
  must.Equal(ctx, 1, 1, "not equal")
})
```

Inside a `Handle` closure, must assertions never return errors, so they
can be ignored. However, the errcheck linter will complain about this
and must be disabled.

Initially, an attempt was made to pass in a struct with infallible
assertion methods, for a safer API. Unfortunately, methods can't use
generics, nor can function types, so this appears to be one of the only
ways to achieve this.

Epic: none
Release note: None
Epic: none
Release note: None
@RaduBerinde
Copy link
Member

pkg/storage/intent_interleaving_iter.go line 1348 at r14 (raw file):

Previously, erikgrinaker (Erik Grinaker) wrote…

This is only the case for format args though, with the typed convenience API we copy these internally on the unhappy path, e.g.:

foo := "bar"
err := Equal(ctx, foo, "bar", "failed") // does not escape
if err != nil {
	return err
}
foo := "bar"
if foo != "bar" {
	log.Fatalf(ctx, "%s != bar", foo) // foo escapes
}

The code above does not allocate unless we get into the branch with Fatalf. There's a difference between escaping (which applies to pointers, which foo here is not) and boxing, which means allocating something just so that an interface{} can refer to it (in the string case runtime.convTstring).

https://godbolt.org/z/999bGEq5E

Copy link
Member

@RaduBerinde RaduBerinde left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • We want to fatal on assertion failures in non-release builds, to make sure we notice them. We want to return assertion errors in release builds, to avoid outages over trigger-happy assertions, but still want them to notify Sentry and log an error with a stack trace at the failure site.

Can't we just make sure that AssertionFailedf does these things? At least the SQL layer reports assertion errors to Sentry (via sqltelemetry.RecordError).

Reviewable status: :shipit: complete! 0 of 0 LGTMs obtained (waiting on @erikgrinaker, @itsbilal, and @knz)

@RaduBerinde
Copy link
Member

pkg/storage/intent_interleaving_iter.go line 1348 at r14 (raw file):

Previously, RaduBerinde wrote…
foo := "bar"
if foo != "bar" {
	log.Fatalf(ctx, "%s != bar", foo) // foo escapes
}

The code above does not allocate unless we get into the branch with Fatalf. There's a difference between escaping (which applies to pointers, which foo here is not) and boxing, which means allocating something just so that an interface{} can refer to it (in the string case runtime.convTstring).

https://godbolt.org/z/999bGEq5E

Not "applies to pointers", I should have said "applies when passing a pointer to an object".

Copy link
Contributor Author

@erikgrinaker erikgrinaker left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewable status: :shipit: complete! 0 of 0 LGTMs obtained (waiting on @itsbilal, @knz, and @RaduBerinde)


pkg/storage/intent_interleaving_iter.go line 1348 at r14 (raw file):

Previously, RaduBerinde wrote…

Not "applies to pointers", I should have said "applies when passing a pointer to an object".

Right, that makes sense, thanks for catching this. I'll have a closer look at this when I can, but I agree, this seems like a showstopper so we may have to do something like what you're suggesting.

Copy link
Contributor

@knz knz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't we just make sure that AssertionFailedf does these things? At least the SQL layer reports assertion errors to Sentry (via sqltelemetry.RecordError).

We could but it's not exactly trivial. The errors constructors do not have access to context.Context, so it's hard to include meaningful detail if we fail there. Access to the ctx is what I particularly like about the must API.

Reviewable status: :shipit: complete! 0 of 0 LGTMs obtained (waiting on @itsbilal, @miretskiy, and @RaduBerinde)

Copy link
Contributor

@knz knz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't we just make sure that AssertionFailedf does these things?

An alternative could be to replace all calls to errors.AssertionFailedf and errors.AssertionErrorWithWrappedErrf to some other API that includes the ctx as argument. Then lint against use of the raw API.

Thoughts?

Reviewable status: :shipit: complete! 0 of 0 LGTMs obtained (waiting on @itsbilal, @miretskiy, and @RaduBerinde)

Copy link
Contributor Author

@erikgrinaker erikgrinaker left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it'd be a very small helper API, basically what must.Fail() does.

Reviewable status: :shipit: complete! 0 of 0 LGTMs obtained (waiting on @itsbilal, @knz, @miretskiy, and @RaduBerinde)

craig bot pushed a commit that referenced this pull request Aug 11, 2023
108272: util/must: revert API r=erikgrinaker a=erikgrinaker

This patch reverts #106508, since `@RaduBerinde` [pointed out](#107790 (review)) a performance flaw where it will often incur an allocation on the happy path due to interface boxing of the format args. Other options are considered in #108169.

We'll revisit runtime assertions with a different API that avoids this cost on the happy path.

Co-authored-by: Erik Grinaker <[email protected]>
@erikgrinaker
Copy link
Contributor Author

Closing, as must was reverted in #108272.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

util/must: convenience wrapper for making many assertions
5 participants