Description
sync.Once
provides a great tool for initializing singletons, or performing initialization lazily. However, it is completely unusable for initializing resource who's initialization can fail (and that failure is temporary).
I am proposing a type and/or method to be added to the sync package that can handle initialization functions that can fail, to simply retry them later - the next time they are needed.
// TryDo calls `f` successively (serialized) until f first returns without error
// If `f` panics, the initialization is treated as if an error occurred
// and `TryDo` will be called again on the next invocation.
func (x *XX) TryDo(f func () error) error {
// implementation details
return nil
}
Simple Use cases
- An http.Handler which lazily reads a template from over the network once
var once sync.XX
var tmpl []byte
handler := func (w http.ResponseWriter, r* http.Request) {
err := once.TryDo(func () error {
tmpl, err = readTemplateFromFS()
return err
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
// do something with tmpl
}
- Dynamically generating a lookup table on demand the first time
- A cache library which only allocates "room" on the storage server (storage namespace / table / etc) the first time it is used.
Workaround
- Instead of returning an error the once function would infinitely retry with a backoff policy. This allowed erroring function to fit into the
sync.Once
. If retries are exhausted, this is not a suitable workaround. - Use
sync.Do
but on failure, swap thesync.Once
with a newsync.Once
so it can retry. - Write your own
TryDo
, requiring a lock, atomic variable (to make it low overhead) and a clever defer function to deal with panics. A hand rolled version will likely not handle panics, or atomic var optimization.
Advanced use case
You are writing a library API which requires lazy initialization of some expensive reusable resource. The API looks like this.
// Search will search for matches of `q` inside book. If `ctx` is canceled, all
// search operations with Search will return with `ErrCanceled`
func (b *Book) Search(ctx context.Context, query string) ([]SearchResult, error)
However, to perform a search the first time, we would like to lazily build a search index of that book, an expensive operation, which can fail. You would like this index build operation to backoff and retry up until the limits of the context passed.
func (b *Book) Search(ctx context.Context, query string) ([]SearchResult, error) {
err := b.once.TryDo(func () error {
// build the search index respects the deadlines of ctx and will return an error otherwise
return retryTilDealineOrSuccess(ctx, func () error {
return b.buildSearchIndex(ctx)
})
})
if err != nil {
return nil, err
}
// ... snip ...
}
As a final comment, TryDo
is more generic than Do
, you can trivially build Do
with TryDo
but not vice versa. I would like to solicit feedback on this API addition, and if agreed I can build a design doc or submit the proposed code to implement this.