Skip to content

proposal: x/sync: once functions that can be retried on error #22098

Closed
@skabbes

Description

@skabbes

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

  1. 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
}
  1. Dynamically generating a lookup table on demand the first time
  2. A cache library which only allocates "room" on the storage server (storage namespace / table / etc) the first time it is used.

Workaround

  1. 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.
  2. Use sync.Do but on failure, swap the sync.Once with a new sync.Once so it can retry.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions