go icon indicating copy to clipboard operation
go copied to clipboard

proposal: x/time/rate: add Sometimes type

Open gaal opened this issue 1 year ago • 3 comments

This proposal makes acting on simple rate throttlers more ergonomic. It adds an API that we use internally (I'm not the author). Adding it will ease open-sourcing a project I'm involved in.

package rate

import "time"

// Sometimes performs an action occasionally.
// The public fields govern the behavior of Do, which performs the action.
// A zero Sometimes performs the action exactly once.
//
// C++ users familiar with the glog package can use this mechanism instead
// of LOG_FIRST_N, LOG_EVERY_N, LOG_EVERY_N_SEC:
//
//	var sometimes = rate.Sometimes{First: 3, Interval: 10*time.Second}
//	func Spammy() {
//	  sometimes.Do(func() { log.Info("here I am!") })
//	}
type Sometimes struct {
	First    int           // if non-zero, the first N calls to Do will run f.
	Every    int           // if non-zero, every Nth call to Do will run f.
	Interval time.Duration // if non-zero and Interval has elapsed since f's last run, Do will run f.
}

// Do runs f, as governed by First, Every, and Interval.
//
// The model is is a union of filters. The first call to Do
// always runs f. Subsequent calls run f if allowed by any
// one of the Sometimes fields.
//
// If Do is called multiple times simultaneously, calls will block
// and run serially. It is therefore intended for lightweight operations.
//
// Because a call to Do may block until f returns, if f causes Do
// to be called, it will deadlock.
func (s *Sometimes) Do(f func())

gaal avatar Aug 03 '22 16:08 gaal

I'm the original author of this type inside Google. It's simple, and the API has been stable for many years. If it's useful, I'm fine seeing this added to the existing rate package.

Sajmani avatar Aug 03 '22 16:08 Sajmani

This proposal has been added to the active column of the proposals project and will now be reviewed at the weekly proposal review meetings. — rsc for the proposal review group

rsc avatar Aug 03 '22 18:08 rsc

Change https://go.dev/cl/421915 mentions this issue: x/time/rate: add Sometimes, which runs a function occasionally.

gopherbot avatar Aug 07 '22 12:08 gopherbot

Does anyone object to accepting this proposal?

rsc avatar Aug 10 '22 17:08 rsc

I have 2 things.

One: given the code comment specifically mentions it being implemented as a union of the filters, it would be more flexible as an array of filters/filter functions which are then unioned. This implementation would come at the cost of complexity, where I acknowledge this is explicitly trying to be simple. This more of a personal preference but I think worth discussing.

Two: I continue to be of the opinion that before we expand rate any further, there should be a common rate limiter interface defined that different rate limiters can then implement.

deefdragon avatar Aug 14 '22 04:08 deefdragon

One: given the code comment specifically mentions it being implemented as a union of the filters, it would be more flexible as an array of filters/filter functions which are then unioned. This implementation would come at the cost of complexity, where I acknowledge this is explicitly trying to be simple. This more of a personal preference but I think worth discussing.

Two: I continue to be of the opinion that before we expand rate any further, there should be a common rate limiter interface defined that different rate limiters can then implement.

There are many existing callers to this in the Google codebase, overwhelmingly rate-limited log statements. In those callsites, the simplicity of the API (as documented, modeled after sync.Once) is a big win. It decouples the particular log library from the Sometimes policy. And to date these have been the kinds of throttles that we've needed for these operations: I don't think adding abstraction would help. Adding flexibility might have allowed additional kinds of callers, but at the cost of complicating the setup, possibly significantly. I think it's worth the 20 LOC to be added as-is.

gaal avatar Aug 14 '22 12:08 gaal

I don't think adding Checks [] func() bool to the struct would make setup that much more complicated as it could just be ignored if nil, but in retrospect, I also can not come up with much of a reasonable use of a Checks array where you wouldn't be better off just using some other mechanism. Also, adding the above can be done later if shown useful, so I withdraw my first remark.

I still believe in my second point that a common interface should be described before more changes to the rate package are added, but I can see this easily being made interface compatible to almost anything if one is ever introduced, so that's a bit of a consolation.

I have one final extra point. Is there similar code or similar acting helpers that are known elsewhere outside of google? I can see this being useful, but I want to make sure it meets the "address an important issue for many people" standard.

deefdragon avatar Aug 15 '22 05:08 deefdragon

How often is this used for something other than a rate-limiting log statement? I'm curious whether it might belong nearer to logging if it's really log-specific.

rsc avatar Aug 17 '22 17:08 rsc

The vast majority of Google callsites use this for logging. How would putting it closer to logging look like, though? Just copy the current type into packages log (and glog)? Does that need another proposal?

(BTW, while gathering this data, regexp lookaround assertions would have come in handy, though not strictly necessary.)

gaal avatar Aug 18 '22 15:08 gaal

I forgot to mention, though, that it's also used to throttle t.Log and ctxlog. Those have similar requirements, but it would be odd to add Sometimes to packages testing and again to ctxlog for this; and it would also be odd to add Sometimes only to stdlib's log and have (say) tests do

var sometimes = log.Sometimes{...}
// ...
sometimes.Do(func() { t.Log(...) })

So personally I'm in favor of adding it to package rate.

gaal avatar Aug 18 '22 15:08 gaal

Another option (which may also address @deefdragon 's concern) is to put the Sometimes type in a new package under x/time. Perhaps package some, type Times ;-)

Sajmani avatar Aug 18 '22 17:08 Sajmani

I checked to see whether that would clash with any identifiers named some, and the answer is not really (there are a tiny number of cases with variables named some but not where the throttler is needed). So I think that could work.

package some // import "x/time/some"

type Times ...
func (t *Times) Do(...)

gaal avatar Aug 18 '22 18:08 gaal

I've updated the CL and description based on the feedback above. PTAL and let me know if there's anything left to do in order to proceed with the proposal. Thanks.

gaal avatar Aug 23 '22 15:08 gaal

"some.Times" is a very weird name. There's only one possible symbol you can put in "package some". It's cute but not functional. It seems like we should go back to rate.Sometimes.

rsc avatar Aug 31 '22 17:08 rsc

Updated back.

Personally I prefer rate.Sometimes, as it would make my life easier.

gaal avatar Aug 31 '22 18:08 gaal

OK it sounds like we are back to

type Sometimes struct {
	First    int           // if non-zero, the first N calls to Do will run f.
	Every    int           // if non-zero, every Nth call to Do will run f.
	Interval time.Duration // if non-zero and Interval has elapsed since f's last run, Do will run f.
}

What are the semantics when combinations of these are set? For example if all three are set, or if First+Every are set, or if Every+Interval are set, or if First+Interval are set?

rsc avatar Sep 07 '22 17:09 rsc

The action is allowed if any one of the Sometimes conditions is met.

gaal avatar Sep 07 '22 17:09 gaal

For clarity, here is the implementation of Do from inside Google, which makes the semantics a bit clearer:

// Do runs the function f as allowed by First, Every, and Interval.
//
// The model is a union (not intersection) of filters.  The first call to Do
// always runs f.  Subsequent calls to Do run f if allowed by First or Every or
// Interval.
//
// A non-zero First:N causes the first N Do(f) calls to run f.
//
// A non-zero Every:M causes every Mth Do(f) call, starting with the first, to
// run f.
//
// A non-zero Interval causes Do(f) to run f if Interval has elapsed since
// Do last ran f.
//
// Specifying multiple filters produces the union of these execution streams.
// For example, specifying both First:N and Every:M causes the first N Do(f)
// calls and every Mth Do(f) call, starting with the first, to run f.  See
// Examples for more.
//
// If Do is called multiple times simultaneously, the calls will block and run
// serially.  Therefore, Do is intended for lightweight operations.
//
// Because a call to Do may block until f returns, if f causes Do to be called,
// it will deadlock.
func (s *Sometimes) Do(f func()) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.count == 0 ||
		(s.First != 0 && s.count < s.First) ||
		(s.Every != 0 && s.count%s.Every == 0) ||
		(s.Interval != 0 && !s.last.IsZero() && time.Since(s.last) >= s.Interval) {
		f()
		s.last = time.Now()
	}
	s.count++
}

Does anyone object to adding this API?

rsc avatar Sep 21 '22 17:09 rsc

Based on the discussion above, this proposal seems like a likely accept. — rsc for the proposal review group

rsc avatar Sep 28 '22 20:09 rsc

No change in consensus, so accepted. 🎉 This issue now tracks the work of implementing the proposal. — rsc for the proposal review group

rsc avatar Oct 06 '22 01:10 rsc