From bcc34017290fad114cbffff9d7dcceb86abb35fa Mon Sep 17 00:00:00 2001 From: yashnevatia Date: Wed, 16 Sep 2026 10:00:17 +0100 Subject: [PATCH 1/4] Add method to calculate average unknownTTL --- chain_capabilities/common/ttl.go | 56 +++++++++ chain_capabilities/common/ttl_test.go | 164 ++++++++++++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 chain_capabilities/common/ttl.go create mode 100644 chain_capabilities/common/ttl_test.go diff --git a/chain_capabilities/common/ttl.go b/chain_capabilities/common/ttl.go new file mode 100644 index 000000000..46fb03523 --- /dev/null +++ b/chain_capabilities/common/ttl.go @@ -0,0 +1,56 @@ +package capcommon + +import ( + "context" + "time" + + "github.com/smartcontractkit/chainlink-common/pkg/capabilities" + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/pkg/types/core" +) + +// AverageRequestTimeout returns the average RequestTimeout configured across +// capabilityID's CapabilityMethodConfig entries for donID. Method configs for +// WriteReport and LogTrigger methods are excluded — their timeout semantics +// differ from regular executable methods. If the config can't be fetched or no +// eligible RemoteExecutableConfig.RequestTimeout values are found, it returns +// fallback. +func AverageRequestTimeout(ctx context.Context, registry core.CapabilitiesRegistry, capabilityID string, donID uint32, fallback time.Duration, lggr logger.Logger) time.Duration { + if registry == nil { + return fallback + } + + cfg, err := WithPollingRetry(ctx, lggr, func(ctx context.Context) (capabilities.CapabilityConfiguration, error) { + return registry.ConfigForCapability(ctx, capabilityID, donID) + }) + if err != nil { + lggr.Errorw("failed getting config for capability", "capabilityID", capabilityID, "error", err) + return fallback + } + + var total time.Duration + var count int + for method, methodCfg := range cfg.CapabilityMethodConfig { + if isNonReadMethod(method) { + continue + } + if methodCfg.RemoteExecutableConfig == nil || methodCfg.RemoteExecutableConfig.RequestTimeout == 0 { + continue + } + total += methodCfg.RemoteExecutableConfig.RequestTimeout + count++ + } + if count == 0 { + return fallback + } + return total / time.Duration(count) +} + +func isNonReadMethod(method string) bool { + switch method { + case "WriteReport", "LogTrigger": + return true + default: + return false + } +} diff --git a/chain_capabilities/common/ttl_test.go b/chain_capabilities/common/ttl_test.go new file mode 100644 index 000000000..8054c1c43 --- /dev/null +++ b/chain_capabilities/common/ttl_test.go @@ -0,0 +1,164 @@ +package capcommon + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/capabilities" + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/pkg/types/core/mocks" +) + +func TestAverageRequestTimeout(t *testing.T) { + t.Parallel() + + const capID = "evm:ChainSelector:42@1.0.0" + const donID = uint32(10) + const fallback = 5 * time.Second + lggr := logger.Test(t) + + // withTimeout returns a RemoteExecutableConfig with the given RequestTimeout. + withTimeout := func(d time.Duration) *capabilities.RemoteExecutableConfig { + return &capabilities.RemoteExecutableConfig{RequestTimeout: d} + } + + // configWith builds a CapabilityConfiguration with one method config per + // RemoteExecutableConfig, keyed by the given method names. Nil entries yield + // method configs without a RemoteExecutableConfig. + configWith := func(recs ...*capabilities.RemoteExecutableConfig) capabilities.CapabilityConfiguration { + methodCfgs := make(map[string]capabilities.CapabilityMethodConfig, len(recs)) + for i, rec := range recs { + methodCfgs[fmt.Sprintf("method-%d", i)] = capabilities.CapabilityMethodConfig{RemoteExecutableConfig: rec} + } + return capabilities.CapabilityConfiguration{CapabilityMethodConfig: methodCfgs} + } + + // configWithMethods is like configWith but takes explicit method names. + configWithMethods := func(methodCfgs map[string]capabilities.CapabilityMethodConfig) capabilities.CapabilityConfiguration { + return capabilities.CapabilityConfiguration{CapabilityMethodConfig: methodCfgs} + } + + t.Run("returns fallback when registry is nil", func(t *testing.T) { + t.Parallel() + + got := AverageRequestTimeout(context.Background(), nil, capID, donID, fallback, lggr) + require.Equal(t, fallback, got) + }) + + t.Run("averages RequestTimeout across method configs", func(t *testing.T) { + t.Parallel() + + reg := mocks.NewCapabilitiesRegistry(t) + reg.EXPECT().ConfigForCapability(mock.Anything, capID, donID).Return(configWith( + withTimeout(1*time.Second), + withTimeout(2*time.Second), + withTimeout(3*time.Second), + ), nil) + + got := AverageRequestTimeout(context.Background(), reg, capID, donID, fallback, lggr) + require.Equal(t, 2*time.Second, got) + }) + + t.Run("skips method configs without RemoteExecutableConfig or zero RequestTimeout", func(t *testing.T) { + t.Parallel() + + reg := mocks.NewCapabilitiesRegistry(t) + reg.EXPECT().ConfigForCapability(mock.Anything, capID, donID).Return(configWith( + nil, + withTimeout(0), + withTimeout(4*time.Second), + withTimeout(8*time.Second), + ), nil) + + got := AverageRequestTimeout(context.Background(), reg, capID, donID, fallback, lggr) + require.Equal(t, 6*time.Second, got) + }) + + t.Run("returns fallback when no method config has a RequestTimeout", func(t *testing.T) { + t.Parallel() + + reg := mocks.NewCapabilitiesRegistry(t) + reg.EXPECT().ConfigForCapability(mock.Anything, capID, donID).Return(configWith( + nil, + withTimeout(0), + ), nil) + + got := AverageRequestTimeout(context.Background(), reg, capID, donID, fallback, lggr) + require.Equal(t, fallback, got) + }) + + t.Run("returns fallback when config is empty", func(t *testing.T) { + t.Parallel() + + reg := mocks.NewCapabilitiesRegistry(t) + reg.EXPECT().ConfigForCapability(mock.Anything, capID, donID).Return(configWith(), nil) + + got := AverageRequestTimeout(context.Background(), reg, capID, donID, fallback, lggr) + require.Equal(t, fallback, got) + }) + + t.Run("excludes WriteReport and LogTrigger methods from the average", func(t *testing.T) { + t.Parallel() + + reg := mocks.NewCapabilitiesRegistry(t) + reg.EXPECT().ConfigForCapability(mock.Anything, capID, donID).Return(configWithMethods(map[string]capabilities.CapabilityMethodConfig{ + "CallContract": {RemoteExecutableConfig: withTimeout(2 * time.Second)}, + "FilterLogs": {RemoteExecutableConfig: withTimeout(4 * time.Second)}, + "WriteReport": {RemoteExecutableConfig: withTimeout(100 * time.Second)}, + "LogTrigger": {RemoteExecutableConfig: withTimeout(200 * time.Second)}, + }), nil) + + got := AverageRequestTimeout(context.Background(), reg, capID, donID, fallback, lggr) + require.Equal(t, 3*time.Second, got) + }) + + t.Run("returns fallback when only WriteReport and LogTrigger have RequestTimeout", func(t *testing.T) { + t.Parallel() + + reg := mocks.NewCapabilitiesRegistry(t) + reg.EXPECT().ConfigForCapability(mock.Anything, capID, donID).Return(configWithMethods(map[string]capabilities.CapabilityMethodConfig{ + "WriteReport": {RemoteExecutableConfig: withTimeout(100 * time.Second)}, + "LogTrigger": {RemoteExecutableConfig: withTimeout(200 * time.Second)}, + }), nil) + + got := AverageRequestTimeout(context.Background(), reg, capID, donID, fallback, lggr) + require.Equal(t, fallback, got) + }) + + t.Run("returns fallback when config fetch fails", func(t *testing.T) { + t.Parallel() + + // Cancelled ctx makes WithPollingRetry give up after the first failure + // instead of retrying for up to 60s. + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + reg := mocks.NewCapabilitiesRegistry(t) + reg.EXPECT().ConfigForCapability(mock.Anything, capID, donID). + Return(capabilities.CapabilityConfiguration{}, errors.New("boom")) + + got := AverageRequestTimeout(ctx, reg, capID, donID, fallback, lggr) + require.Equal(t, fallback, got) + }) + + t.Run("retries after a transient error and returns average", func(t *testing.T) { + t.Parallel() + + reg := mocks.NewCapabilitiesRegistry(t) + reg.EXPECT().ConfigForCapability(mock.Anything, capID, donID). + Return(capabilities.CapabilityConfiguration{}, errors.New("metadataRegistry information not available")).Once() + reg.EXPECT().ConfigForCapability(mock.Anything, capID, donID).Return(configWith( + withTimeout(1*time.Second), + withTimeout(3*time.Second), + ), nil) + + got := AverageRequestTimeout(context.Background(), reg, capID, donID, fallback, lggr) + require.Equal(t, 2*time.Second, got) + }) +} From 1864522308eeaebbfbcbb763ac42a80071117de9 Mon Sep 17 00:00:00 2001 From: yashnevatia Date: Wed, 16 Sep 2026 10:01:00 +0100 Subject: [PATCH 2/4] rename file --- chain_capabilities/common/{ttl.go => unknown_ttl.go} | 0 chain_capabilities/common/{ttl_test.go => unknown_ttl_test.go} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename chain_capabilities/common/{ttl.go => unknown_ttl.go} (100%) rename chain_capabilities/common/{ttl_test.go => unknown_ttl_test.go} (100%) diff --git a/chain_capabilities/common/ttl.go b/chain_capabilities/common/unknown_ttl.go similarity index 100% rename from chain_capabilities/common/ttl.go rename to chain_capabilities/common/unknown_ttl.go diff --git a/chain_capabilities/common/ttl_test.go b/chain_capabilities/common/unknown_ttl_test.go similarity index 100% rename from chain_capabilities/common/ttl_test.go rename to chain_capabilities/common/unknown_ttl_test.go From 6ef77b3d48eb7117e42c45540e2c4cb15d177a0d Mon Sep 17 00:00:00 2001 From: yashnevatia Date: Wed, 16 Sep 2026 10:03:04 +0100 Subject: [PATCH 3/4] move to helpers --- chain_capabilities/common/helpers.go | 47 ++++++++++++++++ .../{unknown_ttl_test.go => helpers_test.go} | 0 chain_capabilities/common/unknown_ttl.go | 56 ------------------- 3 files changed, 47 insertions(+), 56 deletions(-) rename chain_capabilities/common/{unknown_ttl_test.go => helpers_test.go} (100%) delete mode 100644 chain_capabilities/common/unknown_ttl.go diff --git a/chain_capabilities/common/helpers.go b/chain_capabilities/common/helpers.go index a4a6bd22a..05fcfde75 100644 --- a/chain_capabilities/common/helpers.go +++ b/chain_capabilities/common/helpers.go @@ -15,6 +15,7 @@ import ( caperrors "github.com/smartcontractkit/chainlink-common/pkg/capabilities/errors" "github.com/smartcontractkit/chainlink-common/pkg/logger" "github.com/smartcontractkit/chainlink-common/pkg/settings/limits" + "github.com/smartcontractkit/chainlink-common/pkg/types/core" "github.com/smartcontractkit/chainlink-common/pkg/utils/retry" commonmon "github.com/smartcontractkit/capabilities/libs/monitoring" @@ -172,3 +173,49 @@ func WithRetry[T any](ctx context.Context, lggr logger.Logger, fn func(context.C } return result, nil } + +// AverageRequestTimeout returns the average RequestTimeout configured across +// capabilityID's CapabilityMethodConfig entries for donID. Method configs for +// WriteReport and LogTrigger methods are excluded — their timeout semantics +// differ from regular executable methods. If the config can't be fetched or no +// eligible RemoteExecutableConfig.RequestTimeout values are found, it returns +// fallback. +func AverageRequestTimeout(ctx context.Context, registry core.CapabilitiesRegistry, capabilityID string, donID uint32, fallback time.Duration, lggr logger.Logger) time.Duration { + if registry == nil { + return fallback + } + + cfg, err := WithPollingRetry(ctx, lggr, func(ctx context.Context) (capabilities.CapabilityConfiguration, error) { + return registry.ConfigForCapability(ctx, capabilityID, donID) + }) + if err != nil { + lggr.Errorw("failed getting config for capability", "capabilityID", capabilityID, "error", err) + return fallback + } + + var total time.Duration + var count int + for method, methodCfg := range cfg.CapabilityMethodConfig { + if isNonReadMethod(method) { + continue + } + if methodCfg.RemoteExecutableConfig == nil || methodCfg.RemoteExecutableConfig.RequestTimeout == 0 { + continue + } + total += methodCfg.RemoteExecutableConfig.RequestTimeout + count++ + } + if count == 0 { + return fallback + } + return total / time.Duration(count) +} + +func isNonReadMethod(method string) bool { + switch method { + case "WriteReport", "LogTrigger": + return true + default: + return false + } +} diff --git a/chain_capabilities/common/unknown_ttl_test.go b/chain_capabilities/common/helpers_test.go similarity index 100% rename from chain_capabilities/common/unknown_ttl_test.go rename to chain_capabilities/common/helpers_test.go diff --git a/chain_capabilities/common/unknown_ttl.go b/chain_capabilities/common/unknown_ttl.go deleted file mode 100644 index 46fb03523..000000000 --- a/chain_capabilities/common/unknown_ttl.go +++ /dev/null @@ -1,56 +0,0 @@ -package capcommon - -import ( - "context" - "time" - - "github.com/smartcontractkit/chainlink-common/pkg/capabilities" - "github.com/smartcontractkit/chainlink-common/pkg/logger" - "github.com/smartcontractkit/chainlink-common/pkg/types/core" -) - -// AverageRequestTimeout returns the average RequestTimeout configured across -// capabilityID's CapabilityMethodConfig entries for donID. Method configs for -// WriteReport and LogTrigger methods are excluded — their timeout semantics -// differ from regular executable methods. If the config can't be fetched or no -// eligible RemoteExecutableConfig.RequestTimeout values are found, it returns -// fallback. -func AverageRequestTimeout(ctx context.Context, registry core.CapabilitiesRegistry, capabilityID string, donID uint32, fallback time.Duration, lggr logger.Logger) time.Duration { - if registry == nil { - return fallback - } - - cfg, err := WithPollingRetry(ctx, lggr, func(ctx context.Context) (capabilities.CapabilityConfiguration, error) { - return registry.ConfigForCapability(ctx, capabilityID, donID) - }) - if err != nil { - lggr.Errorw("failed getting config for capability", "capabilityID", capabilityID, "error", err) - return fallback - } - - var total time.Duration - var count int - for method, methodCfg := range cfg.CapabilityMethodConfig { - if isNonReadMethod(method) { - continue - } - if methodCfg.RemoteExecutableConfig == nil || methodCfg.RemoteExecutableConfig.RequestTimeout == 0 { - continue - } - total += methodCfg.RemoteExecutableConfig.RequestTimeout - count++ - } - if count == 0 { - return fallback - } - return total / time.Duration(count) -} - -func isNonReadMethod(method string) bool { - switch method { - case "WriteReport", "LogTrigger": - return true - default: - return false - } -} From be01d522420a89bb9f496ea7fe5294f47c61d7a2 Mon Sep 17 00:00:00 2001 From: yashnevatia Date: Mon, 21 Sep 2026 12:23:06 +0100 Subject: [PATCH 4/4] avg to max --- chain_capabilities/common/helpers.go | 12 +++++---- chain_capabilities/common/helpers_test.go | 32 +++++++++++------------ 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/chain_capabilities/common/helpers.go b/chain_capabilities/common/helpers.go index 05fcfde75..2bc88d00f 100644 --- a/chain_capabilities/common/helpers.go +++ b/chain_capabilities/common/helpers.go @@ -174,13 +174,13 @@ func WithRetry[T any](ctx context.Context, lggr logger.Logger, fn func(context.C return result, nil } -// AverageRequestTimeout returns the average RequestTimeout configured across +// MaxRequestTimeout returns the maximum RequestTimeout configured across // capabilityID's CapabilityMethodConfig entries for donID. Method configs for // WriteReport and LogTrigger methods are excluded — their timeout semantics // differ from regular executable methods. If the config can't be fetched or no // eligible RemoteExecutableConfig.RequestTimeout values are found, it returns // fallback. -func AverageRequestTimeout(ctx context.Context, registry core.CapabilitiesRegistry, capabilityID string, donID uint32, fallback time.Duration, lggr logger.Logger) time.Duration { +func MaxRequestTimeout(ctx context.Context, registry core.CapabilitiesRegistry, capabilityID string, donID uint32, fallback time.Duration, lggr logger.Logger) time.Duration { if registry == nil { return fallback } @@ -193,7 +193,7 @@ func AverageRequestTimeout(ctx context.Context, registry core.CapabilitiesRegist return fallback } - var total time.Duration + var maxTimeout time.Duration var count int for method, methodCfg := range cfg.CapabilityMethodConfig { if isNonReadMethod(method) { @@ -202,13 +202,15 @@ func AverageRequestTimeout(ctx context.Context, registry core.CapabilitiesRegist if methodCfg.RemoteExecutableConfig == nil || methodCfg.RemoteExecutableConfig.RequestTimeout == 0 { continue } - total += methodCfg.RemoteExecutableConfig.RequestTimeout + if methodCfg.RemoteExecutableConfig.RequestTimeout > maxTimeout { + maxTimeout = methodCfg.RemoteExecutableConfig.RequestTimeout + } count++ } if count == 0 { return fallback } - return total / time.Duration(count) + return maxTimeout } func isNonReadMethod(method string) bool { diff --git a/chain_capabilities/common/helpers_test.go b/chain_capabilities/common/helpers_test.go index 8054c1c43..ad40ebf7c 100644 --- a/chain_capabilities/common/helpers_test.go +++ b/chain_capabilities/common/helpers_test.go @@ -47,11 +47,11 @@ func TestAverageRequestTimeout(t *testing.T) { t.Run("returns fallback when registry is nil", func(t *testing.T) { t.Parallel() - got := AverageRequestTimeout(context.Background(), nil, capID, donID, fallback, lggr) + got := MaxRequestTimeout(context.Background(), nil, capID, donID, fallback, lggr) require.Equal(t, fallback, got) }) - t.Run("averages RequestTimeout across method configs", func(t *testing.T) { + t.Run("returns max RequestTimeout across method configs", func(t *testing.T) { t.Parallel() reg := mocks.NewCapabilitiesRegistry(t) @@ -61,8 +61,8 @@ func TestAverageRequestTimeout(t *testing.T) { withTimeout(3*time.Second), ), nil) - got := AverageRequestTimeout(context.Background(), reg, capID, donID, fallback, lggr) - require.Equal(t, 2*time.Second, got) + got := MaxRequestTimeout(context.Background(), reg, capID, donID, fallback, lggr) + require.Equal(t, 3*time.Second, got) }) t.Run("skips method configs without RemoteExecutableConfig or zero RequestTimeout", func(t *testing.T) { @@ -76,8 +76,8 @@ func TestAverageRequestTimeout(t *testing.T) { withTimeout(8*time.Second), ), nil) - got := AverageRequestTimeout(context.Background(), reg, capID, donID, fallback, lggr) - require.Equal(t, 6*time.Second, got) + got := MaxRequestTimeout(context.Background(), reg, capID, donID, fallback, lggr) + require.Equal(t, 8*time.Second, got) }) t.Run("returns fallback when no method config has a RequestTimeout", func(t *testing.T) { @@ -89,7 +89,7 @@ func TestAverageRequestTimeout(t *testing.T) { withTimeout(0), ), nil) - got := AverageRequestTimeout(context.Background(), reg, capID, donID, fallback, lggr) + got := MaxRequestTimeout(context.Background(), reg, capID, donID, fallback, lggr) require.Equal(t, fallback, got) }) @@ -99,11 +99,11 @@ func TestAverageRequestTimeout(t *testing.T) { reg := mocks.NewCapabilitiesRegistry(t) reg.EXPECT().ConfigForCapability(mock.Anything, capID, donID).Return(configWith(), nil) - got := AverageRequestTimeout(context.Background(), reg, capID, donID, fallback, lggr) + got := MaxRequestTimeout(context.Background(), reg, capID, donID, fallback, lggr) require.Equal(t, fallback, got) }) - t.Run("excludes WriteReport and LogTrigger methods from the average", func(t *testing.T) { + t.Run("excludes WriteReport and LogTrigger methods from the max", func(t *testing.T) { t.Parallel() reg := mocks.NewCapabilitiesRegistry(t) @@ -114,8 +114,8 @@ func TestAverageRequestTimeout(t *testing.T) { "LogTrigger": {RemoteExecutableConfig: withTimeout(200 * time.Second)}, }), nil) - got := AverageRequestTimeout(context.Background(), reg, capID, donID, fallback, lggr) - require.Equal(t, 3*time.Second, got) + got := MaxRequestTimeout(context.Background(), reg, capID, donID, fallback, lggr) + require.Equal(t, 4*time.Second, got) }) t.Run("returns fallback when only WriteReport and LogTrigger have RequestTimeout", func(t *testing.T) { @@ -127,7 +127,7 @@ func TestAverageRequestTimeout(t *testing.T) { "LogTrigger": {RemoteExecutableConfig: withTimeout(200 * time.Second)}, }), nil) - got := AverageRequestTimeout(context.Background(), reg, capID, donID, fallback, lggr) + got := MaxRequestTimeout(context.Background(), reg, capID, donID, fallback, lggr) require.Equal(t, fallback, got) }) @@ -143,11 +143,11 @@ func TestAverageRequestTimeout(t *testing.T) { reg.EXPECT().ConfigForCapability(mock.Anything, capID, donID). Return(capabilities.CapabilityConfiguration{}, errors.New("boom")) - got := AverageRequestTimeout(ctx, reg, capID, donID, fallback, lggr) + got := MaxRequestTimeout(ctx, reg, capID, donID, fallback, lggr) require.Equal(t, fallback, got) }) - t.Run("retries after a transient error and returns average", func(t *testing.T) { + t.Run("retries after a transient error and returns max", func(t *testing.T) { t.Parallel() reg := mocks.NewCapabilitiesRegistry(t) @@ -158,7 +158,7 @@ func TestAverageRequestTimeout(t *testing.T) { withTimeout(3*time.Second), ), nil) - got := AverageRequestTimeout(context.Background(), reg, capID, donID, fallback, lggr) - require.Equal(t, 2*time.Second, got) + got := MaxRequestTimeout(context.Background(), reg, capID, donID, fallback, lggr) + require.Equal(t, 3*time.Second, got) }) }