From 3ef9bcc12d2068edd6665a37edbd9b9f8a0b8e1a Mon Sep 17 00:00:00 2001 From: Brad Moylan Date: Thu, 2 Nov 2023 09:16:32 -0700 Subject: [PATCH] refreshable v1: Add ToV2() and FromV2() conversion functions (#329) * refreshable v1: Add ToV2() conversion function * FromV2 --- refreshable/go.mod | 1 + refreshable/go.sum | 2 + refreshable/v2.go | 30 ++ refreshable/v2_test.go | 25 ++ .../palantir/pkg/refreshable/v2/LICENSE | 29 ++ .../palantir/pkg/refreshable/v2/async.go | 103 +++++++ .../palantir/pkg/refreshable/v2/godelw | 262 ++++++++++++++++++ .../palantir/pkg/refreshable/v2/main.go | 14 + .../pkg/refreshable/v2/refreshable.go | 95 +++++++ .../pkg/refreshable/v2/refreshable_default.go | 88 ++++++ .../refreshable/v2/refreshable_validating.go | 56 ++++ refreshable/vendor/modules.txt | 3 + 12 files changed, 708 insertions(+) create mode 100644 refreshable/v2.go create mode 100644 refreshable/v2_test.go create mode 100644 refreshable/vendor/github.com/palantir/pkg/refreshable/v2/LICENSE create mode 100644 refreshable/vendor/github.com/palantir/pkg/refreshable/v2/async.go create mode 100644 refreshable/vendor/github.com/palantir/pkg/refreshable/v2/godelw create mode 100644 refreshable/vendor/github.com/palantir/pkg/refreshable/v2/main.go create mode 100644 refreshable/vendor/github.com/palantir/pkg/refreshable/v2/refreshable.go create mode 100644 refreshable/vendor/github.com/palantir/pkg/refreshable/v2/refreshable_default.go create mode 100644 refreshable/vendor/github.com/palantir/pkg/refreshable/v2/refreshable_validating.go diff --git a/refreshable/go.mod b/refreshable/go.mod index 98d87a92..72085423 100644 --- a/refreshable/go.mod +++ b/refreshable/go.mod @@ -4,6 +4,7 @@ go 1.20 require ( github.com/palantir/pkg v1.1.0 + github.com/palantir/pkg/refreshable/v2 v2.0.0 github.com/stretchr/testify v1.8.4 ) diff --git a/refreshable/go.sum b/refreshable/go.sum index d6444b41..8d4c7627 100644 --- a/refreshable/go.sum +++ b/refreshable/go.sum @@ -2,6 +2,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/palantir/pkg v1.1.0 h1:0EhrSUP8oeeh3MUvk7V/UU7WmsN1UiJNTvNj0sN9Cpo= github.com/palantir/pkg v1.1.0/go.mod h1:KC9srP/9ssWRxBxFCIqhUGC4Jt7OJkWRz0Iqehup1/c= +github.com/palantir/pkg/refreshable/v2 v2.0.0 h1:9uxG2L5nqUOfLg4l9YWjmh2JVW38VNXu0t4/mLkC2is= +github.com/palantir/pkg/refreshable/v2 v2.0.0/go.mod h1:SfgGx/EeOCBJWfhDphHIu7nRl5TeXkKzvlfj8o7x9Mg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= diff --git a/refreshable/v2.go b/refreshable/v2.go new file mode 100644 index 00000000..572135b5 --- /dev/null +++ b/refreshable/v2.go @@ -0,0 +1,30 @@ +// Copyright (c) 2023 Palantir Technologies. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package refreshable + +import ( + refreshablev2 "github.com/palantir/pkg/refreshable/v2" +) + +// ToV2 converts from a v1 Refreshable created by this package to v2 supporting type safety via generics. +// If v1's value is not of type T, the function panics. +func ToV2[T any](v1 Refreshable) refreshablev2.Refreshable[T] { + v2 := refreshablev2.New[T](v1.Current().(T)) + v1.Subscribe(func(i interface{}) { + v2.Update(i.(T)) + }) + return v2 +} + +// FromV2 converts from a v1 Refreshable created by this package to v2 supporting type safety via generics. +func FromV2[T any](v2 refreshablev2.Refreshable[T]) Refreshable { + v1 := NewDefaultRefreshable(v2.Current()) + v2.Subscribe(func(i T) { + if err := v1.Update(i); err != nil { + panic(err) + } + }) + return v1 +} diff --git a/refreshable/v2_test.go b/refreshable/v2_test.go new file mode 100644 index 00000000..aeeb48b5 --- /dev/null +++ b/refreshable/v2_test.go @@ -0,0 +1,25 @@ +// Copyright (c) 2023 Palantir Technologies. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package refreshable + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestToFromV2(t *testing.T) { + base := NewDefaultRefreshable("original") + v2 := ToV2[string](base) + v1 := FromV2(v2) + assert.Equal(t, base.Current(), "original", "base missing original value") + assert.Equal(t, v2.Current(), "original", "v2 missing original value") + assert.Equal(t, v1.Current(), "original", "v1 missing original value") + + assert.NoError(t, base.Update("updated")) + assert.Equal(t, base.Current(), "updated", "base missing updated value") + assert.Equal(t, v2.Current(), "updated", "v2 missing updated value") + assert.Equal(t, v1.Current(), "updated", "v1 missing updated value") +} diff --git a/refreshable/vendor/github.com/palantir/pkg/refreshable/v2/LICENSE b/refreshable/vendor/github.com/palantir/pkg/refreshable/v2/LICENSE new file mode 100644 index 00000000..11ceccb7 --- /dev/null +++ b/refreshable/vendor/github.com/palantir/pkg/refreshable/v2/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2016, Palantir Technologies, Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/refreshable/vendor/github.com/palantir/pkg/refreshable/v2/async.go b/refreshable/vendor/github.com/palantir/pkg/refreshable/v2/async.go new file mode 100644 index 00000000..4ccd6f75 --- /dev/null +++ b/refreshable/vendor/github.com/palantir/pkg/refreshable/v2/async.go @@ -0,0 +1,103 @@ +// Copyright (c) 2022 Palantir Technologies. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package refreshable + +import ( + "context" + "time" +) + +// NewFromChannel populates an Updatable with the values channel. +// If an element is already available, the returned Value is guaranteed to be populated. +// The channel should be closed when no longer used to avoid leaking resources. +func NewFromChannel[T any](values <-chan T) Ready[T] { + out := newReady[T]() + select { + case initial, ok := <-values: + if !ok { + return out // channel already closed + } + out.Update(initial) + default: + } + go func() { + for value := range values { + out.Update(value) + } + }() + return out +} + +// NewFromTickerFunc returns a Ready Refreshable populated by the result of the provider called each interval. +// If the providers bool return is false, the value is ignored. +// The result's ReadyC channel is closed when a new value is populated. +// The refreshable will stop updating when the provided context is cancelled or the returned UnsubscribeFunc func is called. +func NewFromTickerFunc[T any](ctx context.Context, interval time.Duration, provider func(ctx context.Context) (T, bool)) (Ready[T], UnsubscribeFunc) { + out := newReady[T]() + ctx, cancel := context.WithCancel(ctx) + values := make(chan T) + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + defer close(values) + for { + if value, ok := provider(ctx); ok { + out.Update(value) + } + select { + case <-ticker.C: + continue + case <-ctx.Done(): + return + } + } + }() + return out, UnsubscribeFunc(cancel) +} + +// Wait waits until the Ready has a current value or the context expires. +func Wait[T any](ctx context.Context, ready Ready[T]) (T, bool) { + select { + case <-ready.ReadyC(): + return ready.Current(), true + case <-ctx.Done(): + var zero T + return zero, false + } +} + +// ready is an Updatable which exposes a channel that is closed when a value is first available. +// Current returns the zero value before Update is called, marking the value ready. +type ready[T any] struct { + in Updatable[T] + readyC <-chan struct{} + cancel context.CancelFunc +} + +func newReady[T any]() *ready[T] { + ctx, cancel := context.WithCancel(context.Background()) + return &ready[T]{ + in: newZero[T](), + readyC: ctx.Done(), + cancel: cancel, + } +} + +func (r *ready[T]) Current() T { + return r.in.Current() +} + +func (r *ready[T]) Subscribe(consumer func(T)) UnsubscribeFunc { + return r.in.Subscribe(consumer) +} + +func (r *ready[T]) ReadyC() <-chan struct{} { + return r.readyC +} + +func (r *ready[T]) Update(val T) { + r.in.Update(val) + r.cancel() +} diff --git a/refreshable/vendor/github.com/palantir/pkg/refreshable/v2/godelw b/refreshable/vendor/github.com/palantir/pkg/refreshable/v2/godelw new file mode 100644 index 00000000..961b7e72 --- /dev/null +++ b/refreshable/vendor/github.com/palantir/pkg/refreshable/v2/godelw @@ -0,0 +1,262 @@ +#!/bin/bash + +set -euo pipefail + +# Version and checksums for godel. Values are populated by the godel "dist" task. +VERSION=2.89.0 +DARWIN_AMD64_CHECKSUM=daadeb92fbb644f879a3926557541a1bc9fa524c7e38b2c54eadaa5788efe623 +DARWIN_ARM64_CHECKSUM=e9abb43512edab145061831d5df0e98d14658f62f67196b1950e9f63dd3f1e18 +LINUX_AMD64_CHECKSUM=c13865c1f4440211130fc7ef71d045afacc564ee8521bc3523372a8cac033345 +LINUX_ARM64_CHECKSUM=b40e104e5f4cfd408a975266426cd01772f1b38fcce9a9b260849f030c9b9f22 + +# Downloads file at URL to destination path using wget or curl. Prints an error and exits if wget or curl is not present. +function download { + local url=$1 + local dst=$2 + + # determine whether wget, curl or both are present + set +e + command -v wget >/dev/null 2>&1 + local wget_exists=$? + command -v curl >/dev/null 2>&1 + local curl_exists=$? + set -e + + # if one of wget or curl is not present, exit with error + if [ "$wget_exists" -ne 0 -a "$curl_exists" -ne 0 ]; then + echo "wget or curl must be present to download distribution. Install one of these programs and try again or install the distribution manually." + exit 1 + fi + + if [ "$wget_exists" -eq 0 ]; then + # attempt download using wget + echo "Downloading $url to $dst..." + local progress_opt="" + if wget --help | grep -q '\--show-progress'; then + progress_opt="-q --show-progress" + fi + set +e + wget -O "$dst" $progress_opt "$url" + rv=$? + set -e + if [ "$rv" -eq 0 ]; then + # success + return + fi + + echo "Download failed using command: wget -O $dst $progress_opt $url" + + # curl does not exist, so nothing more to try: exit + if [ "$curl_exists" -ne 0 ]; then + echo "Download failed using wget and curl was not found. Verify that the distribution URL is correct and try again or install the distribution manually." + exit 1 + fi + # curl exists, notify that download will be attempted using curl + echo "Attempting download using curl..." + fi + + # attempt download using curl + echo "Downloading $url to $dst..." + set +e + curl -f -L -o "$dst" "$url" + rv=$? + set -e + if [ "$rv" -ne 0 ]; then + echo "Download failed using command: curl -f -L -o $dst $url" + if [ "$wget_exists" -eq 0 ]; then + echo "Download failed using wget and curl. Verify that the distribution URL is correct and try again or install the distribution manually." + else + echo "Download failed using curl and wget was not found. Verify that the distribution URL is correct and try again or install the distribution manually." + fi + exit 1 + fi +} + +# verifies that the provided checksum matches the computed SHA-256 checksum of the specified file. If not, echoes an +# error and exits. +function verify_checksum { + local file=$1 + local expected_checksum=$2 + local computed_checksum=$(compute_sha256 $file) + if [ "$expected_checksum" != "$computed_checksum" ]; then + echo "SHA-256 checksum for $file did not match expected value." + echo "Expected: $expected_checksum" + echo "Actual: $computed_checksum" + exit 1 + fi +} + +# computes the SHA-256 hash of the provided file. Uses openssl, shasum or sha1sum program. +function compute_sha256 { + local file=$1 + if command -v openssl >/dev/null 2>&1; then + # print SHA-256 hash using openssl + openssl dgst -sha256 "$file" | sed -E 's/SHA(2-)?256\(.*\)= //' + elif command -v shasum >/dev/null 2>&1; then + # Darwin systems ship with "shasum" utility + shasum -a 256 "$file" | sed -E 's/[[:space:]]+.+//' + elif command -v sha256sum >/dev/null 2>&1; then + # Most Linux systems ship with sha256sum utility + sha256sum "$file" | sed -E 's/[[:space:]]+.+//' + else + echo "Could not find program to calculate SHA-256 checksum for file" + exit 1 + fi +} + +# Verifies that the tgz file at the provided path contains the paths/files that would be expected in a valid gödel +# distribution with the provided version. +function verify_dist_tgz_valid { + local tgz_path=$1 + local version=$2 + + local expected_paths=("godel-$version/" "godel-$version/bin/darwin-amd64/godel" "godel-$version/bin/darwin-arm64/godel" "godel-$version/bin/linux-amd64/godel" "godel-$version/bin/linux-arm64/godel" "godel-$version/wrapper/godelw" "godel-$version/wrapper/godel/config/") + local files=($(tar -tf "$tgz_path")) + + # this is a double-for loop, but fine since $expected_paths is small and bash doesn't have good primitives for set/map/list manipulation + for curr_line in "${files[@]}"; do + # if all expected paths have been found, terminate + if [[ ${#expected_paths[*]} == 0 ]]; then + break + fi + + # check for expected path and splice out if match is found + idx=0 + for curr_expected in "${expected_paths[@]}"; do + if [ "$curr_expected" = "$curr_line" ]; then + expected_paths=(${expected_paths[@]:0:idx} ${expected_paths[@]:$(($idx + 1))}) + break + fi + idx=$idx+1 + done + done + + # if any expected paths still remain, raise error and exit + if [[ ${#expected_paths[*]} > 0 ]]; then + echo "Required paths were not present in $tgz_path: ${expected_paths[@]}" + exit 1 + fi +} + +# Verifies that the gödel binary in the distribution reports the expected version when called with the "version" +# argument. Assumes that a valid gödel distribution directory for the given version exists in the provided directory. +function verify_godel_version { + local base_dir=$1 + local version=$2 + local os=$3 + local arch=$4 + + local expected_output="godel version $version" + local version_output=$($base_dir/godel-$version/bin/$os-$arch/godel version) + + if [ "$expected_output" != "$version_output" ]; then + echo "Version reported by godel executable did not match expected version: expected \"$expected_output\", was \"$version_output\"" + exit 1 + fi +} + +# directory of godelw script +SCRIPT_HOME=$(cd "$(dirname "$0")" && pwd) + +# use $GODEL_HOME or default value +GODEL_BASE_DIR=${GODEL_HOME:-$HOME/.godel} + +# determine OS +OS="" +EXPECTED_CHECKSUM="" +case "$(uname)-$(uname -m)" in + Darwin-x86_64) + OS=darwin + ARCH=amd64 + EXPECTED_CHECKSUM=$DARWIN_AMD64_CHECKSUM + ;; + Darwin-arm64) + OS=darwin + ARCH=arm64 + EXPECTED_CHECKSUM=$DARWIN_ARM64_CHECKSUM + ;; + Linux-x86_64) + OS=linux + ARCH=amd64 + EXPECTED_CHECKSUM=$LINUX_AMD64_CHECKSUM + ;; + Linux-aarch64) + OS=linux + ARCH=arm64 + EXPECTED_CHECKSUM=$LINUX_ARM64_CHECKSUM + ;; + *) + echo "Unsupported operating system-architecture: $(uname)-$(uname -m)" + exit 1 + ;; +esac + +# path to godel binary +CMD=$GODEL_BASE_DIR/dists/godel-$VERSION/bin/$OS-$ARCH/godel + +# godel binary is not present -- download distribution +if [ ! -f "$CMD" ]; then + # get download URL + PROPERTIES_FILE=$SCRIPT_HOME/godel/config/godel.properties + if [ ! -f "$PROPERTIES_FILE" ]; then + echo "Properties file must exist at $PROPERTIES_FILE" + exit 1 + fi + DOWNLOAD_URL=$(cat "$PROPERTIES_FILE" | sed -E -n "s/^distributionURL=//p") + if [ -z "$DOWNLOAD_URL" ]; then + echo "Value for property \"distributionURL\" was empty in $PROPERTIES_FILE" + exit 1 + fi + DOWNLOAD_CHECKSUM=$(cat "$PROPERTIES_FILE" | sed -E -n "s/^distributionSHA256=//p") + + # create downloads directory if it does not already exist + mkdir -p "$GODEL_BASE_DIR/downloads" + + # download tgz and verify its contents + # Download to unique location that includes PID ($$) and use trap ensure that temporary download file is cleaned up + # if script is terminated before the file is moved to its destination. + DOWNLOAD_DST=$GODEL_BASE_DIR/downloads/godel-$VERSION-$$.tgz + download "$DOWNLOAD_URL" "$DOWNLOAD_DST" + trap 'rm -rf "$DOWNLOAD_DST"' EXIT + if [ -n "$DOWNLOAD_CHECKSUM" ]; then + verify_checksum "$DOWNLOAD_DST" "$DOWNLOAD_CHECKSUM" + fi + verify_dist_tgz_valid "$DOWNLOAD_DST" "$VERSION" + + # create temporary directory for unarchiving, unarchive downloaded file and verify directory + TMP_DIST_DIR=$(mktemp -d "$GODEL_BASE_DIR/tmp_XXXXXX" 2>/dev/null || mktemp -d -t "$GODEL_BASE_DIR/tmp_XXXXXX") + trap 'rm -rf "$TMP_DIST_DIR"' EXIT + tar zxvf "$DOWNLOAD_DST" -C "$TMP_DIST_DIR" >/dev/null 2>&1 + verify_godel_version "$TMP_DIST_DIR" "$VERSION" "$OS" "$ARCH" + + # rename downloaded file to remove PID portion + mv "$DOWNLOAD_DST" "$GODEL_BASE_DIR/downloads/godel-$VERSION.tgz" + + # if destination directory for distribution already exists, remove it + if [ -d "$GODEL_BASE_DIR/dists/godel-$VERSION" ]; then + rm -rf "$GODEL_BASE_DIR/dists/godel-$VERSION" + fi + + # ensure that parent directory of destination exists + mkdir -p "$GODEL_BASE_DIR/dists" + + # move expanded distribution directory to destination location. The location of the unarchived directory is known to + # be in the same directory tree as the destination, so "mv" should always work. + mv "$TMP_DIST_DIR/godel-$VERSION" "$GODEL_BASE_DIR/dists/godel-$VERSION" + + # edge case cleanup: if the destination directory "$GODEL_BASE_DIR/dists/godel-$VERSION" was created prior to the + # "mv" operation above, then the move operation will move the source directory into the destination directory. In + # this case, remove the directory. It should always be safe to remove this directory because if the directory + # existed in the distribution and was non-empty, then the move operation would fail (because non-empty directories + # cannot be overwritten by mv). All distributions of a given version are also assumed to be identical. The only + # instance in which this would not work is if the distribution purposely contained an empty directory that matched + # the name "godel-$VERSION", and this is assumed to never be true. + if [ -d "$GODEL_BASE_DIR/dists/godel-$VERSION/godel-$VERSION" ]; then + rm -rf "$GODEL_BASE_DIR/dists/godel-$VERSION/godel-$VERSION" + fi +fi + +verify_checksum "$CMD" "$EXPECTED_CHECKSUM" + +# execute command +$CMD --wrapper "$SCRIPT_HOME/$(basename "$0")" "$@" diff --git a/refreshable/vendor/github.com/palantir/pkg/refreshable/v2/main.go b/refreshable/vendor/github.com/palantir/pkg/refreshable/v2/main.go new file mode 100644 index 00000000..fb5b2622 --- /dev/null +++ b/refreshable/vendor/github.com/palantir/pkg/refreshable/v2/main.go @@ -0,0 +1,14 @@ +// Copyright (c) 2021 Palantir Technologies. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build module +// +build module + +// This file exists only to smooth the transition for modules. Having this file makes it such that other modules that +// consume this module will not have import path conflicts caused by github.com/palantir/pkg. +package main + +import ( + _ "github.com/palantir/pkg" +) diff --git a/refreshable/vendor/github.com/palantir/pkg/refreshable/v2/refreshable.go b/refreshable/vendor/github.com/palantir/pkg/refreshable/v2/refreshable.go new file mode 100644 index 00000000..f5c8fa3e --- /dev/null +++ b/refreshable/vendor/github.com/palantir/pkg/refreshable/v2/refreshable.go @@ -0,0 +1,95 @@ +// Copyright (c) 2021 Palantir Technologies. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package refreshable + +import ( + "context" +) + +// A Refreshable is a generic container type for a volatile underlying value. +// It supports atomic access and user-provided callback "subscriptions" on updates. +type Refreshable[T any] interface { + // Current returns the most recent value of this Refreshable. + // If the value has not been initialized, returns T's zero value. + Current() T + + // Subscribe calls the consumer function when Value updates until stop is closed. + // The consumer must be relatively fast: Updatable.Set blocks until all subscribers have returned. + // Expensive or error-prone responses to refreshed values should be asynchronous. + // Updates considered no-ops by reflect.DeepEqual may be skipped. + // When called, consumer is executed with the Current value. + Subscribe(consumer func(T)) UnsubscribeFunc +} + +// A Updatable is a Refreshable which supports setting the value with a user-provided value. +// When a utility returns a (non-Updatable) Refreshable, it implies that value updates are handled internally. +type Updatable[T any] interface { + Refreshable[T] + // Update updates the Refreshable with a new T. + // It blocks until all subscribers have completed. + Update(T) +} + +// A Validated is a Refreshable capable of rejecting updates according to validation logic. +// Its Current method returns the most recent value to pass validation. +type Validated[T any] interface { + Refreshable[T] + // Validation returns the result of the most recent validation. + // If the last value was valid, Validation returns the same value as Current and a nil error. + // If the last value was invalid, it and the error are returned. Current returns the most recent valid value. + Validation() (T, error) +} + +// Ready extends Refreshable for asynchronous implementations which may not have a value when they are constructed. +// Callers should check that the Ready channel is closed before using the Current value. +type Ready[T any] interface { + Refreshable[T] + // ReadyC returns a channel which is closed after a value is successfully populated. + ReadyC() <-chan struct{} +} + +// UnsubscribeFunc removes a subscription from a refreshable's internal tracking and/or stops its update routine. +// It is safe to call multiple times. +type UnsubscribeFunc func() + +// New returns a new Updatable that begins with the given value. +func New[T any](val T) Updatable[T] { + return newDefault(val) +} + +// Map returns a new Refreshable based on the current one that handles updates based on the current Refreshable. +func Map[T any, M any](original Refreshable[T], mapFn func(T) M) (Refreshable[M], UnsubscribeFunc) { + out := newDefault(mapFn(original.Current())) + stop := original.Subscribe(func(v T) { + out.Update(mapFn(v)) + }) + return (*readOnlyRefreshable[M])(out), stop +} + +// MapContext is like Map but unsubscribes when the context is cancelled. +func MapContext[T any, M any](ctx context.Context, original Refreshable[T], mapFn func(T) M) Refreshable[M] { + out, stop := Map(original, mapFn) + go func() { + <-ctx.Done() + stop() + }() + return out +} + +// MapWithError is similar to Validate but allows for the function to return a mapping/mutation +// of the input object in addition to returning an error. The returned validRefreshable will contain the mapped value. +// An error is returned if the current original value fails to map. +func MapWithError[T any, M any](original Refreshable[T], mapFn func(T) (M, error)) (Validated[M], UnsubscribeFunc, error) { + v, stop := newValidRefreshable(original, mapFn) + _, err := v.Validation() + return v, stop, err +} + +// Validate returns a new Refreshable that returns the latest original value accepted by the validatingFn. +// If the upstream value results in an error, it is reported by Validation(). +// An error is returned if the current original value is invalid. +func Validate[T any](original Refreshable[T], validatingFn func(T) error) (Validated[T], UnsubscribeFunc, error) { + return MapWithError(original, identity(validatingFn)) +} diff --git a/refreshable/vendor/github.com/palantir/pkg/refreshable/v2/refreshable_default.go b/refreshable/vendor/github.com/palantir/pkg/refreshable/v2/refreshable_default.go new file mode 100644 index 00000000..22f9e8d0 --- /dev/null +++ b/refreshable/vendor/github.com/palantir/pkg/refreshable/v2/refreshable_default.go @@ -0,0 +1,88 @@ +// Copyright (c) 2021 Palantir Technologies. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package refreshable + +import ( + "reflect" + "sync" + "sync/atomic" +) + +type defaultRefreshable[T any] struct { + mux sync.Mutex + current atomic.Value + subscribers []*func(T) +} + +func newDefault[T any](val T) *defaultRefreshable[T] { + d := new(defaultRefreshable[T]) + d.current.Store(&val) + return d +} + +func newZero[T any]() *defaultRefreshable[T] { + d := new(defaultRefreshable[T]) + var zero T + d.current.Store(&zero) + return d +} + +// Update changes the value of the Refreshable, then blocks while subscribers are executed. +func (d *defaultRefreshable[T]) Update(val T) { + d.mux.Lock() + defer d.mux.Unlock() + old := d.current.Swap(&val) + if reflect.DeepEqual(*(old.(*T)), val) { + return + } + for _, sub := range d.subscribers { + (*sub)(val) + } +} + +func (d *defaultRefreshable[T]) Current() T { + return *(d.current.Load().(*T)) +} + +func (d *defaultRefreshable[T]) Subscribe(consumer func(T)) UnsubscribeFunc { + d.mux.Lock() + defer d.mux.Unlock() + + consumerFnPtr := &consumer + d.subscribers = append(d.subscribers, consumerFnPtr) + consumer(d.Current()) + return d.unsubscribe(consumerFnPtr) +} + +func (d *defaultRefreshable[T]) unsubscribe(consumerFnPtr *func(T)) UnsubscribeFunc { + return func() { + d.mux.Lock() + defer d.mux.Unlock() + + matchIdx := -1 + for idx, currSub := range d.subscribers { + if currSub == consumerFnPtr { + matchIdx = idx + break + } + } + if matchIdx != -1 { + d.subscribers = append(d.subscribers[:matchIdx], d.subscribers[matchIdx+1:]...) + } + } + +} + +// readOnlyRefreshable aliases defaultRefreshable but hides the Update method so the type +// does not implement Updatable. +type readOnlyRefreshable[T any] defaultRefreshable[T] + +func (d *readOnlyRefreshable[T]) Current() T { + return (*defaultRefreshable[T])(d).Current() +} + +func (d *readOnlyRefreshable[T]) Subscribe(consumer func(T)) UnsubscribeFunc { + return (*defaultRefreshable[T])(d).Subscribe(consumer) +} diff --git a/refreshable/vendor/github.com/palantir/pkg/refreshable/v2/refreshable_validating.go b/refreshable/vendor/github.com/palantir/pkg/refreshable/v2/refreshable_validating.go new file mode 100644 index 00000000..b6fa8adf --- /dev/null +++ b/refreshable/vendor/github.com/palantir/pkg/refreshable/v2/refreshable_validating.go @@ -0,0 +1,56 @@ +// Copyright (c) 2022 Palantir Technologies. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package refreshable + +type validRefreshable[T any] struct { + r Updatable[validRefreshableContainer[T]] +} + +type validRefreshableContainer[T any] struct { + validated T + unvalidated T + lastErr error +} + +func (v *validRefreshable[T]) Current() T { return v.r.Current().validated } + +func (v *validRefreshable[T]) Subscribe(consumer func(T)) UnsubscribeFunc { + return v.r.Subscribe(func(val validRefreshableContainer[T]) { + consumer(val.validated) + }) +} + +// Validation returns the most recent upstream Refreshable and its validation result. +// If nil, the validRefreshable is up-to-date with its original. +func (v *validRefreshable[T]) Validation() (T, error) { + c := v.r.Current() + return c.unvalidated, c.lastErr +} + +func newValidRefreshable[T any, M any](original Refreshable[T], mappingFn func(T) (M, error)) (*validRefreshable[M], UnsubscribeFunc) { + valid := &validRefreshable[M]{r: newDefault(validRefreshableContainer[M]{})} + stop := original.Subscribe(func(valueT T) { + updateValidRefreshable(valid, valueT, mappingFn) + }) + return valid, stop +} + +func updateValidRefreshable[T any, M any](valid *validRefreshable[M], value T, mapFn func(T) (M, error)) { + validated := valid.r.Current().validated + unvalidated, err := mapFn(value) + if err == nil { + validated = unvalidated + } + valid.r.Update(validRefreshableContainer[M]{ + validated: validated, + unvalidated: unvalidated, + lastErr: err, + }) +} + +// identity is a validating map function that returns its input argument type. +func identity[T any](validatingFn func(T) error) func(i T) (T, error) { + return func(i T) (T, error) { return i, validatingFn(i) } +} diff --git a/refreshable/vendor/modules.txt b/refreshable/vendor/modules.txt index fdd8df7d..3c275537 100644 --- a/refreshable/vendor/modules.txt +++ b/refreshable/vendor/modules.txt @@ -4,6 +4,9 @@ github.com/davecgh/go-spew/spew # github.com/palantir/pkg v1.1.0 ## explicit; go 1.19 github.com/palantir/pkg +# github.com/palantir/pkg/refreshable/v2 v2.0.0 +## explicit; go 1.20 +github.com/palantir/pkg/refreshable/v2 # github.com/pmezard/go-difflib v1.0.0 ## explicit github.com/pmezard/go-difflib/difflib