go.temporal.io/server/common/dynamicconfig/collection.go
779 LOC · 404 covered · 375 uncovered · 123 ranges · 22919 concepts · 81 introducers · 11231 tests
File neighbourhood
The centred file is linked to every concept that introduces one of its ranges, every test that runs code from the file, and the gray connector concepts standing between those tests and the file's own introducer concepts. Undirected links join concepts to every file where they introduce source and concepts to the tests they introduce; arrows show specialization between the displayed concepts and bridge only concepts omitted from this view. Concept colors match the source ranges below; connector concepts have no source color and are shown in gray.
Focused file, its introducer and connector concepts, their introduced files, and tests that run code from the file
In the embedded map, ordinary wheel input scrolls the page; use the visible controls to zoom and drag to pan. Open the full-screen map for canvas navigation: wheel pans, Ctrl/Command plus wheel zooms, and arrow keys pan when this region is focused. On touch screens, open the full-screen map to pan or pinch. If JavaScript or WebGL is unavailable, use the related-file, concept, and source links on this page.
Graph controls are ready.
Interactive rendering requires JavaScript and WebGL. Use the related-file, concept, and source links on this page while the interactive map is unavailable.
package dynamicconfig
import (
"context"
"errors"
"fmt"
"math"
"reflect"
"runtime"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"weak"
"github.com/mitchellh/mapstructure"
"go.temporal.io/server/common/goro"
"go.temporal.io/server/common/log"
"go.temporal.io/server/common/log/tag"
"go.temporal.io/server/common/pingable"
"go.temporal.io/server/common/primitives/timestamp"
"go.temporal.io/server/common/util"
"google.golang.org/protobuf/reflect/protoreflect"
)
type (
// Collection implements lookup and constraint logic on top of a Client.
// The rest of the server code should use Collection as the interface to dynamic config,
// instead of the low-level Client.
Collection struct {
client Client
logger log.Logger
errCount int64
cancelClientSubscription func()
subscriptionLock sync.Mutex // protects subscriptions and subscriptionIdx
subscriptions map[Key]map[int]any // final "any" is *subscription[T]
subscriptionIdx int
poller goro.Group
// cache converted values. use weak pointers to avoid holding on to values in the cache
// that are no longer in use. this must be a pointer since the cleanup closures need to
// reference this without referencing Collection.
convertCache *sync.Map // map[weak.Pointer[ConstrainedValue]]any
// index by constraints
indexCache *sync.Map // map[weak.Pointer[ConstrainedValue]]map[Constraints]int32
}
subscription[T any] struct {
// constant:
prec []Constraints
f func(T)
def T
cdef []TypedConstrainedValue[T] // nil for regular settings, populated for constrained default settings
// protected by subscriptionLock in Collection:
raw any // raw value that last sent value was converted from
}
subscriptionCallbackSettings struct {
MinWorkers int
MaxWorkers int
TargetDelay time.Duration
ShrinkFactor float64
}
// sentinel type that doesn't compare equal to anything else
defaultValue struct{}
// These function types follow a similar pattern:
// {X}PropertyFn - returns a value of type X that is global (no filters)
// {X}PropertyFnWith{Y}Filter - returns a value of type X with the given filters
// Available value types:
// Bool: bool
// Duration: time.Duration
// Float: float64
// Int: int
// Map: map[string]any
// String: string
// Available filters:
// Namespace func(namespace string)
// NamespaceID func(namespaceID string)
// TaskQueue func(namespace string, taskQueue string, taskType enumspb.TaskQueueType) (matching task queue)
// TaskType func(taskType enumspsb.TaskType) (history task type)
// ShardID func(shardID int32)
)
const (
errCountLogThreshold = 1000
// After this many constraints, switch to a cached lookup. This value was determined
// empirically on my machine using BenchmarkCollectionIndexed.
constraintsCacheThreshold = 32
)
var (
errKeyNotPresent = errors.New("key not present")
errNoMatchingConstraint = errors.New("no matching constraint in key")
protoEnumType = reflect.TypeFor[protoreflect.Enum]()
errorType = reflect.TypeFor[error]()
durationType = reflect.TypeFor[time.Duration]()
timeType = reflect.TypeFor[time.Time]()
stringType = reflect.TypeFor[string]()
usingDefaultValue any = defaultValue{}
)
// NewCollection creates a new collection. For subscriptions to work, you must call Start/Stop.
// Get will work without Start/Stop.
// Do this at the first convenient place we have a logger:
logSharedStructureWarnings(logger)
return &Collection{
client: client,
logger: logger,
errCount: -1,
subscriptions: make(map[Key]map[int]any),
convertCache: new(sync.Map),
indexCache: new(sync.Map),
}
}
c.subscriptionLock.Lock()
defer c.subscriptionLock.Unlock()
if notifyingClient, ok := c.client.(NotifyingClient); ok {
}
}
c.poller.Cancel()
c.poller.Wait()
if c.cancelClientSubscription != nil {
}
}
// Implement pingable.Pingable
return []pingable.Check{
{
Name: "dynamic config callbacks",
Timeout: 5 * time.Second,
Ping: func() []pingable.Pingable {
c.subscriptionLock.Lock()
//nolint:staticcheck // SA2001 just checking if we can acquire the lock
c.subscriptionLock.Unlock()
return nil
},
},
}
}
interval := DynamicConfigSubscriptionPollInterval.Get(c)
for ctx.Err() == nil {
util.InterruptibleSleep(ctx, interval())
c.pollOnce()
}
}
c.subscriptionLock.Lock()
defer c.subscriptionLock.Unlock()
for key, subs := range c.subscriptions {
setting := queryRegistry(key)
if setting == nil {
continue
}
cvs := c.client.GetValue(key)
setting.dispatchUpdate(c, sub, cvs)
}
}
}
c.subscriptionLock.Lock()
defer c.subscriptionLock.Unlock()
for key, cvs := range changed {
setting := queryRegistry(key)
if setting == nil {
continue
}
// use setting.Key instead of key to avoid changing case again
}
}
}
// TODO: This is a lot of unnecessary contention with little benefit. Consider using
// https://github.com/cespare/percpu here.
errCount := atomic.AddInt64(&c.errCount, 1)
// log only the first x errors and then one every x after that to reduce log noise
return errCount < errCountLogThreshold || errCount%errCountLogThreshold == 0
}
func findMatch(
cache *sync.Map,
cvs []ConstrainedValue,
precedence []Constraints,
if len(cvs) == 0 {
} else if len(cvs) > constraintsCacheThreshold && len(cvs) <= math.MaxInt32 {
collection.go ×2
}
for idx, cv := range cvs {
if m == cv.Constraints {
// Note: cvs here is the slice returned by Client.GetValue. We want to return a
// pointer into that slice so that the converted value is cached as long as the
// Client keeps the []ConstrainedValue alive. See the comment on
// Client.GetValue.
return &cvs[idx], nil
}
}
}
// key is present but no constraint section matches
}
func findMatchWithCache(
cache *sync.Map,
cvs []ConstrainedValue,
precedence []Constraints,
var cached map[Constraints]int32
weakcvp := weak.Make(&cvs[0])
if v, ok := cache.Load(weakcvp); ok {
cached = v.(map[Constraints]int32) // nolint:revive // unchecked-type-assertion
} else {
cached = make(map[Constraints]int32, len(cvs))
for i := range cvs {
// pick first one to match behavior if multiple match
if _, ok := cached[cvs[i].Constraints]; !ok {
cached[cvs[i].Constraints] = int32(i)
}
}
runtime.AddCleanup(&cvs[0], func(w weak.Pointer[ConstrainedValue]) {
cache.Delete(w)
}, weakcvp)
}
}
if i, ok := cached[m]; ok {
// Note: cvs here is the slice returned by Client.GetValue. We want to return a
// pointer into that slice so that the converted value is cached as long as the
// Client keeps the []ConstrainedValue alive. See the comment on
// Client.GetValue.
return &cvs[i], nil
}
}
// key is present but no constraint section matches
}
// matchAndConvert can't be a method of Collection because methods can't be generic, but we can
// take a *Collection as an argument.
func matchAndConvert[T any](
c *Collection,
key Key,
def T,
convert func(value any) (T, error),
precedence []Constraints,
cvs := c.client.GetValue(key)
v, _ := matchAndConvertCvs(c, key, def, convert, precedence, cvs)
return v
}
func matchAndConvertCvs[T any](
c *Collection,
key Key,
def T,
convert func(value any) (T, error),
precedence []Constraints,
cvs []ConstrainedValue,
cvp, err := findMatch(c.indexCache, cvs, precedence)
if err != nil {
return def, usingDefaultValue
}
if err != nil {
if c.throttleLog() {
c.logger.Warn("Failed to convert value, using default", tag.Key(key.String()), tag.IgnoredValue(cvp), tag.Error(err))
}
return def, usingDefaultValue
}
}
// Returns matched value out of cvs, matched default out of defaultCVs, and also the priorities
// of each of the matches (lower matched first). For no match, order will be 0.
func findMatchWithConstrainedDefaults[T any](cvs []ConstrainedValue, defaultCVs []TypedConstrainedValue[T], precedence []Constraints) (
matchedValue *ConstrainedValue,
matchedDefault T,
valueOrder int,
defaultOrder int,
order := 0
for _, m := range precedence {
for idx, cv := range cvs {
if m == cv.Constraints {
if valueOrder == 0 {
valueOrder = order
// Note: cvs here is the slice returned by Client.GetValue. We want to
// return a pointer into that slice instead of copying the ConstrainedValue.
// See findMatch.
matchedValue = &cvs[idx]
}
}
}
order++
if m == cv.Constraints {
if defaultOrder == 0 {
defaultOrder = order
matchedDefault = cv.Value
}
}
}
}
}
func findAndResolveWithConstrainedDefaults[T any](
c *Collection,
key Key,
convert func(value any) (T, error),
cvs []ConstrainedValue,
defaultCVs []TypedConstrainedValue[T],
precedence []Constraints,
cvp, defVal, valOrder, defOrder := findMatchWithConstrainedDefaults(cvs, defaultCVs, precedence)
if defOrder == 0 {
// This is a server bug: all precedence lists must end with no-constraints, and all
// constrained defaults must have a no-constraints value, so we should have gotten a match.
c.logger.Warn("Constrained defaults had no match (this is a bug; fix server code)", tag.Key(key.String()))
// leave value as the zero value, that's the best we can do
return value, usingDefaultValue
return defVal, usingDefaultValue // use sentinel since we're using default
}
if err != nil {
// We failed to convert the value to the desired type. Use the default.
if c.throttleLog() {
c.logger.Warn("Failed to convert value, using default", tag.Key(key.String()), tag.IgnoredValue(cvp), tag.Error(err))
}
return defVal, usingDefaultValue
}
}
func matchAndConvertWithConstrainedDefault[T any](
c *Collection,
key Key,
cdef []TypedConstrainedValue[T],
convert func(value any) (T, error),
precedence []Constraints,
cvs := c.client.GetValue(key)
value, _ := findAndResolveWithConstrainedDefaults(c, key, convert, cvs, cdef, precedence)
return value
}
func subscribe[T any](
c *Collection,
key Key,
def T,
convert func(value any) (T, error),
prec []Constraints,
callback func(T),
c.subscriptionLock.Lock()
defer c.subscriptionLock.Unlock()
// get one value immediately (note that subscriptionLock is held here so we can't race with
// an update)
cvs := c.client.GetValue(key)
init, raw := matchAndConvertCvs(c, key, def, convert, prec, cvs)
// As a convenience (and for efficiency), you can pass in a nil callback; we just return the
// current value and skip the subscription. The cancellation func returned is also nil.
if callback == nil {
}
id := c.subscriptionIdx
if c.subscriptions[key] == nil {
c.subscriptions[key] = make(map[int]any)
}
prec: prec,
f: callback,
def: def,
raw: raw,
}
return init, func() {
defer c.subscriptionLock.Unlock()
delete(c.subscriptions[key], id)
}
}
func subscribeWithConstrainedDefault[T any](
c *Collection,
key Key,
cdef []TypedConstrainedValue[T],
convert func(value any) (T, error),
prec []Constraints,
callback func(T),
c.subscriptionLock.Lock()
defer c.subscriptionLock.Unlock()
// get one value immediately (note that subscriptionLock is held here so we can't race with
// an update)
cvs := c.client.GetValue(key)
init, raw := findAndResolveWithConstrainedDefaults(c, key, convert, cvs, cdef, prec)
// As a convenience (and for efficiency), you can pass in a nil callback; we just return the
// current value and skip the subscription. The cancellation func returned is also nil.
if callback == nil {
return init, nil
}
id := c.subscriptionIdx
if c.subscriptions[key] == nil {
c.subscriptions[key] = make(map[int]any)
}
prec: prec,
f: callback,
cdef: cdef,
raw: raw,
}
return init, func() {
defer c.subscriptionLock.Unlock()
delete(c.subscriptions[key], id)
}
}
// called with subscriptionLock
func dispatchUpdate[T any](
c *Collection,
key Key,
convert func(value any) (T, error),
sub *subscription[T],
cvs []ConstrainedValue,
var raw any
cvp, err := findMatch(c.indexCache, cvs, sub.prec)
if err != nil {
}
// compare raw (pre-conversion) values, if unchanged, skip this update. note that
// `usingDefaultValue` is equal to itself but nothing else.
// make raw field point to new one, not old one, so that old loaded files can get
collection.go ×1
// garbage collected.
sub.raw = raw
return
}
// raw value changed, need to dispatch default or converted value
if cvp == nil {
if err != nil {
// We failed to convert the value to the desired type. Use the default.
if c.throttleLog() {
c.logger.Warn("Failed to convert value, using default", tag.Key(key.String()), tag.IgnoredValue(cvp), tag.Error(err))
}
newVal, raw = sub.def, usingDefaultValue
}
}
go sub.f(newVal)
}
// called with subscriptionLock
func dispatchUpdateWithConstrainedDefault[T any](
c *Collection,
key Key,
convert func(value any) (T, error),
sub *subscription[T],
cvs []ConstrainedValue,
// Note: This performs the conversion even if the raw value is unchanged. This isn't ideal,
// but so far constrained default settings are only used for primitive values so it's okay.
// If we have a constrained default value with a complex conversion function, this could be
// optimized to delay conversion until after we check DeepEqual.
newVal, raw := findAndResolveWithConstrainedDefaults(c, key, convert, cvs, sub.cdef, sub.prec)
// compare raw (pre-conversion) values, if unchanged, skip this update. note that
// `usingDefaultValue` is equal to itself but nothing else.
if reflect.DeepEqual(sub.raw, raw) {
// make raw field point to new one, not old one, so that old loaded files can get
// garbage collected.
sub.raw = raw
return
}
go sub.f(newVal)
}
func convertWithCache[T any](c *Collection, key Key, convert func(any) (T, error), cvp *ConstrainedValue) (T, error) {
collection.go ×2
weakcvp := weak.Make(cvp)
if converted, ok := c.convertCache.Load(weakcvp); ok {
return t, nil
}
// Each key can only be used with a single type, so this shouldn't happen
c.logger.Warn("Cached converted value has wrong type", tag.Key(key.String()))
// Fall through to regular conversion
}
if err != nil {
return zero, err
}
cc := c.convertCache // capture only this pointer, not the whole Collection
runtime.AddCleanup(cvp, func(w weak.Pointer[ConstrainedValue]) {
}, weakcvp)
}
}
switch val := val.(type) {
return int(val), nil
case int8:
return int(val), nil
return int(val), nil
case int32:
return int(val), nil
return int(val), nil
case uint:
return int(val), nil
case uint8:
return int(val), nil
case uint16:
return int(val), nil
return int(val), nil
case uint64:
return int(val), nil
case uintptr:
return int(val), nil
return 0, errors.New("value type is not int")
}
}
switch val := val.(type) {
return float64(val), nil
return float64(val), nil
}
}
}
switch v := val.(type) {
return v, nil
d, err := timestamp.ParseDurationDefaultSeconds(v)
if err != nil {
}
}
// treat numeric values as seconds
}
}
if stringVal, ok := val.(string); ok {
return stringVal, nil
}
return "", errors.New("value type is not string")
}
switch v := val.(type) {
return v, nil
return strconv.ParseBool(v)
return false, errors.New("value type is not bool")
}
}
if mapVal, ok := val.(map[string]any); ok {
}
}
// ConvertStructure can be used as a conversion function for New*TypedSettingWithConverter.
// The value from dynamic config will be converted to T, on top of the given default.
//
// Note that any failure in conversion of _any_ field will result in the overall default being used,
// ignoring the fields that successfully converted.
//
// Note that the default value will be deep-copied and then passed to mapstructure with the
// ZeroFields setting false, so the config value will be _merged_ on top of it. Be very careful
// when using non-empty maps or slices, the result may not be what you want.
//
// To avoid confusion, the default passed to ConvertStructure should be either the same as the
// overall default for the setting (if you want any value set to be merged over the default, i.e.
// treat the fields independently), or the zero value of its type (if you want to treat the fields
// as a group and default unset fields to zero).
return func(v any) (T, error) {
// if we already have the right type, no conversion is necessary
if typedV, ok := v.(T); ok {
}
// Deep-copy the default and decode over it. This allows using e.g. a struct with some
// default fields filled in and a config that only set some fields.
dec, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
Result: &out,
DecodeHook: mapstructure.ComposeDecodeHookFunc(
mapstructureHookDuration,
mapstructureHookTimestamp,
mapstructureHookProtoEnum,
mapstructureHookGeneric,
),
})
if err != nil {
return out, err
}
return out, err
}
}
// Parses string into time.Duration. mapstructure has an implementation of this already but it
// calls time.ParseDuration and we want to use our own method.
if t != durationType {
return data, nil
}
}
// Parses string or int into time.Time.
if t != timeType {
return data, nil
}
case time.Time:
return v, nil
ts, err := time.Parse(time.RFC3339, v)
if err != nil {
return time.Time{}, fmt.Errorf("failed to parse time: %v", err)
}
}
// treat numeric values as seconds
if ival, err := convertInt(data); err == nil {
return time.Unix(int64(ival), 0), nil
} else if fval, err := convertFloat(data); err == nil {
ipart, fpart := math.Modf(fval)
return time.Unix(int64(ipart), int64(fpart*float64(time.Second))), nil
}
return time.Time{}, errors.New("value not convertible to Time")
}
// Parses proto enum values from strings.
if f != stringType || !t.Implements(protoEnumType) {
}
str := strings.ToLower(data.(string)) // we checked f above so this can't fail
for i := 0; i < vals.Len(); i++ {
val := vals.Get(i)
if str == strings.ToLower(string(val.Name())) {
}
}
}
// Parses generic values. See GenericParseHook.
if mth, ok := t.MethodByName("DynamicConfigParseHook"); ok &&
mth.Func.IsValid() &&
mth.Type != nil &&
mth.Type.NumIn() == 2 &&
mth.Type.In(1) == f &&
mth.Type.NumOut() == 2 &&
mth.Type.Out(0) == t &&
mth.Type.Out(1) == errorType {
out := mth.Func.Call([]reflect.Value{reflect.Zero(t), reflect.ValueOf(data)})
if !out[1].IsNil() {
return nil, err
}
return nil, errors.New("failed to convert DynamicConfigParseHook error")
}
}
// pass through
}