From d8f3ade5b4e860cc3889d7e0145f059789c6d731 Mon Sep 17 00:00:00 2001 From: "denis.chernov" Date: Fri, 10 Jul 2026 11:36:36 -0700 Subject: [PATCH 01/16] added UseConnectionManager bool --- core/bridges/bridge_type.go | 5 +++++ core/bridges/orm.go | 8 ++++---- .../0301_add_use_connection_manager_to_bridge_types.sql | 4 ++++ core/web/presenters/bridges.go | 2 ++ core/web/presenters/bridges_test.go | 3 +++ core/web/resolver/bridge.go | 5 +++++ core/web/resolver/mutation.go | 4 ++++ core/web/schema/type/bridge.graphql | 3 +++ 8 files changed, 30 insertions(+), 4 deletions(-) create mode 100644 core/store/migrate/migrations/0301_add_use_connection_manager_to_bridge_types.sql diff --git a/core/bridges/bridge_type.go b/core/bridges/bridge_type.go index b935cfe8318..46ec666e217 100644 --- a/core/bridges/bridge_type.go +++ b/core/bridges/bridge_type.go @@ -21,6 +21,7 @@ type BridgeTypeRequest struct { URL models.WebURL `json:"url"` Confirmations uint32 `json:"confirmations"` MinimumContractPayment *assets.Link `json:"minimumContractPayment"` + UseConnectionManager bool `json:"useConnectionManager"` } // GetID returns the ID of this structure for jsonapi serialization. @@ -48,6 +49,7 @@ type BridgeTypeAuthentication struct { IncomingToken string OutgoingToken string MinimumContractPayment *assets.Link + UseConnectionManager bool `json:"useConnectionManager"` } // BridgeType is used for external adapters and has fields for @@ -62,6 +64,7 @@ type BridgeType struct { MinimumContractPayment *assets.Link CreatedAt time.Time UpdatedAt time.Time + UseConnectionManager bool `json:"useConnectionManager"` } // NewBridgeType returns a bridge type authentication (with plaintext @@ -84,6 +87,7 @@ func NewBridgeType(btr *BridgeTypeRequest) (*BridgeTypeAuthentication, IncomingToken: incomingToken, OutgoingToken: outgoingToken, MinimumContractPayment: btr.MinimumContractPayment, + UseConnectionManager: btr.UseConnectionManager, }, &BridgeType{ Name: btr.Name, URL: btr.URL, @@ -92,6 +96,7 @@ func NewBridgeType(btr *BridgeTypeRequest) (*BridgeTypeAuthentication, Salt: salt, OutgoingToken: outgoingToken, MinimumContractPayment: btr.MinimumContractPayment, + UseConnectionManager: btr.UseConnectionManager, }, nil } diff --git a/core/bridges/orm.go b/core/bridges/orm.go index b20c95f161d..e315c0bdc64 100644 --- a/core/bridges/orm.go +++ b/core/bridges/orm.go @@ -124,8 +124,8 @@ func (o *orm) BridgeTypes(ctx context.Context, offset int, limit int) (bridges [ // CreateBridgeType saves the bridge type. func (o *orm) CreateBridgeType(ctx context.Context, bt *BridgeType) error { - stmt := `INSERT INTO bridge_types (name, url, confirmations, incoming_token_hash, salt, outgoing_token, minimum_contract_payment, created_at, updated_at) - VALUES (:name, :url, :confirmations, :incoming_token_hash, :salt, :outgoing_token, :minimum_contract_payment, now(), now()) + stmt := `INSERT INTO bridge_types (name, url, confirmations, incoming_token_hash, salt, outgoing_token, minimum_contract_payment, use_connection_manager, created_at, updated_at) + VALUES (:name, :url, :confirmations, :incoming_token_hash, :salt, :outgoing_token, :minimum_contract_payment, :use_connection_manager, now(), now()) RETURNING *;` err := o.transact(ctx, false, func(tx *orm) error { stmt, err := tx.ds.PrepareNamedContext(ctx, stmt) @@ -141,8 +141,8 @@ func (o *orm) CreateBridgeType(ctx context.Context, bt *BridgeType) error { // UpdateBridgeType updates the bridge type. func (o *orm) UpdateBridgeType(ctx context.Context, bt *BridgeType, btr *BridgeTypeRequest) error { - stmt := "UPDATE bridge_types SET url = $1, confirmations = $2, minimum_contract_payment = $3 WHERE name = $4 RETURNING *" - err := o.ds.GetContext(ctx, bt, stmt, btr.URL, btr.Confirmations, btr.MinimumContractPayment, bt.Name) + stmt := "UPDATE bridge_types SET url = $1, confirmations = $2, minimum_contract_payment = $3, use_connection_manager = $4 WHERE name = $5 RETURNING *" + err := o.ds.GetContext(ctx, bt, stmt, btr.URL, btr.Confirmations, btr.MinimumContractPayment, btr.UseConnectionManager, bt.Name) return err } diff --git a/core/store/migrate/migrations/0301_add_use_connection_manager_to_bridge_types.sql b/core/store/migrate/migrations/0301_add_use_connection_manager_to_bridge_types.sql new file mode 100644 index 00000000000..f2e235d8985 --- /dev/null +++ b/core/store/migrate/migrations/0301_add_use_connection_manager_to_bridge_types.sql @@ -0,0 +1,4 @@ +-- +goose Up +ALTER TABLE bridge_types ADD COLUMN use_connection_manager boolean NOT NULL DEFAULT false; +-- +goose Down +ALTER TABLE bridge_types DROP COLUMN use_connection_manager; diff --git a/core/web/presenters/bridges.go b/core/web/presenters/bridges.go index 82f4a961c94..f56e87a76de 100644 --- a/core/web/presenters/bridges.go +++ b/core/web/presenters/bridges.go @@ -17,6 +17,7 @@ type BridgeResource struct { IncomingToken string `json:"incomingToken,omitempty"` OutgoingToken string `json:"outgoingToken"` MinimumContractPayment *assets.Link `json:"minimumContractPayment"` + UseConnectionManager bool `json:"useConnectionManager"` CreatedAt time.Time `json:"createdAt"` } @@ -35,6 +36,7 @@ func NewBridgeResource(b bridges.BridgeType) *BridgeResource { Confirmations: b.Confirmations, OutgoingToken: b.OutgoingToken, MinimumContractPayment: b.MinimumContractPayment, + UseConnectionManager: b.UseConnectionManager, CreatedAt: b.CreatedAt, } } diff --git a/core/web/presenters/bridges_test.go b/core/web/presenters/bridges_test.go index 746d361958a..e8f12c9b09f 100644 --- a/core/web/presenters/bridges_test.go +++ b/core/web/presenters/bridges_test.go @@ -27,6 +27,7 @@ func TestBridgeResource(t *testing.T) { Confirmations: 1, OutgoingToken: "vjNL7X8Ea6GFJoa6PBsvK2ECzNK3b8IZ", MinimumContractPayment: assets.NewLinkFromJuels(1), + UseConnectionManager: true, CreatedAt: timestamp, } @@ -46,6 +47,7 @@ func TestBridgeResource(t *testing.T) { "confirmations":1, "outgoingToken":"vjNL7X8Ea6GFJoa6PBsvK2ECzNK3b8IZ", "minimumContractPayment":"1", + "useConnectionManager":true, "createdAt":"2000-01-01T00:00:00Z" } } @@ -71,6 +73,7 @@ func TestBridgeResource(t *testing.T) { "incomingToken": "cd+OfGXy3UHEDAlD0y27F6/rJE14X1UI", "outgoingToken":"vjNL7X8Ea6GFJoa6PBsvK2ECzNK3b8IZ", "minimumContractPayment":"1", + "useConnectionManager":true, "createdAt":"2000-01-01T00:00:00Z" } } diff --git a/core/web/resolver/bridge.go b/core/web/resolver/bridge.go index 9a3cc53f952..92f4ff6dd4c 100644 --- a/core/web/resolver/bridge.go +++ b/core/web/resolver/bridge.go @@ -54,6 +54,11 @@ func (r *BridgeResolver) MinimumContractPayment() string { return r.bridge.MinimumContractPayment.String() } +// UseConnectionManager resolves the usage of connection manager for the bridge. +func (r *BridgeResolver) UseConnectionManager() bool { + return r.bridge.UseConnectionManager +} + // CreatedAt resolves the bridge's created at field. func (r *BridgeResolver) CreatedAt() graphql.Time { return graphql.Time{Time: r.bridge.CreatedAt} diff --git a/core/web/resolver/mutation.go b/core/web/resolver/mutation.go index c8026419af8..8c8be7f2fbf 100644 --- a/core/web/resolver/mutation.go +++ b/core/web/resolver/mutation.go @@ -58,6 +58,7 @@ type createBridgeInput struct { URL string Confirmations int32 MinimumContractPayment string + UseConnectionManager *bool } // CreateBridge creates a new bridge. @@ -84,6 +85,7 @@ func (r *Resolver) CreateBridge(ctx context.Context, args struct{ Input createBr URL: webURL, Confirmations: uint32(args.Input.Confirmations), MinimumContractPayment: minContractPayment, + UseConnectionManager: args.Input.UseConnectionManager != nil && *args.Input.UseConnectionManager, } bta, bt, err := bridges.NewBridgeType(btr) @@ -451,6 +453,7 @@ type updateBridgeInput struct { URL string Confirmations int32 MinimumContractPayment string + UseConnectionManager *bool } func (r *Resolver) UpdateBridge(ctx context.Context, args struct { @@ -479,6 +482,7 @@ func (r *Resolver) UpdateBridge(ctx context.Context, args struct { URL: webURL, Confirmations: uint32(args.Input.Confirmations), MinimumContractPayment: minContractPayment, + UseConnectionManager: args.Input.UseConnectionManager != nil && *args.Input.UseConnectionManager, } taskType, err := bridges.ParseBridgeName(string(args.ID)) diff --git a/core/web/schema/type/bridge.graphql b/core/web/schema/type/bridge.graphql index e24616f3262..f74e0f71155 100644 --- a/core/web/schema/type/bridge.graphql +++ b/core/web/schema/type/bridge.graphql @@ -5,6 +5,7 @@ type Bridge { confirmations: Int! outgoingToken: String! minimumContractPayment: String! + useConnectionManager: Boolean! createdAt: Time! } @@ -23,6 +24,7 @@ input CreateBridgeInput { url: String! confirmations: Int! minimumContractPayment: String! + useConnectionManager: Boolean } # CreateBridgeSuccess defines the success response when creating a bridge @@ -40,6 +42,7 @@ input UpdateBridgeInput { url: String! confirmations: Int! minimumContractPayment: String! + useConnectionManager: Boolean } # UpdateBridgeSuccess defines the success response when updating a bridge From 0b02e0ba209a21bf057aca0c8fa7bd5da8a2c2c6 Mon Sep 17 00:00:00 2001 From: "denis.chernov" Date: Sun, 12 Jul 2026 20:22:01 -0700 Subject: [PATCH 02/16] BridgeConnManager --- core/internal/cltest/factories.go | 7 +- core/services/pipeline/bridge_conn_manager.go | 24 ++++ core/services/pipeline/helpers_test.go | 6 +- core/services/pipeline/runner.go | 3 + core/services/pipeline/task.bridge.go | 66 +++++++++-- core/services/pipeline/task.bridge_test.go | 104 ++++++++++++++++++ 6 files changed, 197 insertions(+), 13 deletions(-) create mode 100644 core/services/pipeline/bridge_conn_manager.go diff --git a/core/internal/cltest/factories.go b/core/internal/cltest/factories.go index 8644ac0e308..390044ee970 100644 --- a/core/internal/cltest/factories.go +++ b/core/internal/cltest/factories.go @@ -50,8 +50,9 @@ func NewPeerID() (id ragep2ptypes.PeerID) { } type BridgeOpts struct { - Name string - URL string + Name string + URL string + UseConnectionManager bool } // NewBridgeType create new bridge type given info slice @@ -72,6 +73,7 @@ func NewBridgeType(t testing.TB, opts BridgeOpts) (*bridges.BridgeTypeAuthentica } else { btr.URL = WebURL(t, "https://bridge.example.com/api?"+rnd) } + btr.UseConnectionManager = opts.UseConnectionManager bta, bt, err := bridges.NewBridgeType(btr) require.NoError(t, err) @@ -197,7 +199,6 @@ NOW(),NOW(),$1,'{}',false,$2,$3,0,0,0,0,0,0,0,0,0 return spec } - func MustInsertExternalInitiator(t *testing.T, orm bridges.ORM) (ei bridges.ExternalInitiator) { return MustInsertExternalInitiatorWithOpts(t, orm, ExternalInitiatorOpts{}) } diff --git a/core/services/pipeline/bridge_conn_manager.go b/core/services/pipeline/bridge_conn_manager.go new file mode 100644 index 00000000000..3fd20592164 --- /dev/null +++ b/core/services/pipeline/bridge_conn_manager.go @@ -0,0 +1,24 @@ +package pipeline + +import ( + "context" + + "github.com/pkg/errors" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink/v2/core/bridges" +) + +type BridgeConnManager interface { + GetObservation(ctx context.Context, lggr logger.Logger, bridge bridges.BridgeType, requestData MapParam) ([]byte, error) +} + +type dummyBridgeConnManager struct{} + +func NewBridgeConnManager() BridgeConnManager { + return &dummyBridgeConnManager{} +} + +func (m *dummyBridgeConnManager) GetObservation(ctx context.Context, lggr logger.Logger, bridge bridges.BridgeType, requestData MapParam) ([]byte, error) { + return nil, errors.Errorf("bridge connection manager transport is not implemented for bridge %q", bridge.Name.String()) +} diff --git a/core/services/pipeline/helpers_test.go b/core/services/pipeline/helpers_test.go index cef56bf733e..407a08b8e76 100644 --- a/core/services/pipeline/helpers_test.go +++ b/core/services/pipeline/helpers_test.go @@ -36,13 +36,17 @@ func (t *BridgeTask) HelperSetDependencies( orm bridges.ORM, specId int32, id uuid.UUID, - httpClient *http.Client) { + httpClient *http.Client, + bridgeConnManager ...BridgeConnManager) { t.config = config t.bridgeConfig = bridgeConfig t.orm = orm t.uuid = id t.httpClient = httpClient t.specId = specId + if len(bridgeConnManager) > 0 { + t.bridgeConnManager = bridgeConnManager[0] + } } func (t *HTTPTask) HelperSetDependencies(config Config, restrictedHTTPClient, unrestrictedHTTPClient *http.Client) { diff --git a/core/services/pipeline/runner.go b/core/services/pipeline/runner.go index 35d675f7e58..584e38aa363 100644 --- a/core/services/pipeline/runner.go +++ b/core/services/pipeline/runner.go @@ -69,6 +69,7 @@ type runner struct { lggr logger.Logger httpClient *http.Client unrestrictedHTTPClient *http.Client + bridgeConnManager BridgeConnManager // test helper runFinished func(*Run) @@ -134,6 +135,7 @@ func NewRunner( lggr: lggr, httpClient: httpClient, unrestrictedHTTPClient: unrestrictedHTTPClient, + bridgeConnManager: NewBridgeConnManager(), } r.runReaperWorker = commonutils.NewSleeperTask( @@ -349,6 +351,7 @@ func (r *runner) InitializePipeline(spec Spec) (pipeline *Pipeline, err error) { // must use the unrestrictedHTTPClient because some node operators // may run external adapters on their own hardware bt.httpClient = r.unrestrictedHTTPClient + bt.bridgeConnManager = r.bridgeConnManager bt.requiredJSONPaths = bt.getRequiredJSONPaths() case TaskTypeETHCall: task.(*ETHCallTask).legacyChains = r.legacyEVMChains diff --git a/core/services/pipeline/task.bridge.go b/core/services/pipeline/task.bridge.go index d19e0993a9d..3a0e397274c 100644 --- a/core/services/pipeline/task.bridge.go +++ b/core/services/pipeline/task.bridge.go @@ -85,11 +85,12 @@ type BridgeTask struct { // requiredJSONPaths). When empty or "false", that check is skipped. CheckRequired string `json:"checkRequired"` - specId int32 - orm bridges.ORM - config Config - bridgeConfig BridgeConfig - httpClient *http.Client + specId int32 + orm bridges.ORM + config Config + bridgeConfig BridgeConfig + httpClient *http.Client + bridgeConnManager BridgeConnManager // requiredJSONPaths is populated in runner.InitializePipeline from strict // downstream jsonparse tasks. When CheckRequired is true and cacheTTL is set, @@ -162,10 +163,11 @@ func (t *BridgeTask) Run(ctx context.Context, lggr logger.Logger, vars Vars, inp overtimeCtx, cancel := overtimeContext(ctx) defer cancel() - url, err := t.getBridgeURLFromName(overtimeCtx, name) + bridge, err := t.getBridgeFromName(overtimeCtx, name) if err != nil { return Result{Error: err}, runInfo } + url := URLParam(bridge.URL) requestDataJSON, err := t.finalizeAndMarshalBridgeRequestData(lggr, vars, inputValues, &requestData, includeInputAtKey) if err != nil { @@ -178,6 +180,17 @@ func (t *BridgeTask) Run(ctx context.Context, lggr logger.Logger, vars Vars, inp requestCtx, cancel := httpRequestCtx(ctx, t, t.config) defer cancel() + if bridge.UseConnectionManager { + bridgeConnManager := t.bridgeConnManager + if bridgeConnManager == nil { + bridgeConnManager = NewBridgeConnManager() + } + responseBytes, err := bridgeConnManager.GetObservation(ctx, lggr, bridge, requestData) + if err != nil { + return t.resolveBridgeConnManagerFailureOrCache(overtimeCtx, lggr, url, responseBytes, err, cacheTTL) + } + return Result{Value: string(responseBytes)}, runInfo + } var cachedResponse bool responseBytes, statusCode, headers, start, finish, err := makeHTTPRequest(requestCtx, lggr, "POST", url, reqHeaders, requestData, t.httpClient, t.config.DefaultHTTPLimit()) @@ -392,6 +405,41 @@ func (t *BridgeTask) resolveFailureOrCache( return out, nil, nil } +func (t *BridgeTask) resolveBridgeConnManagerFailureOrCache( + ctx context.Context, + lggr logger.Logger, + url URLParam, + responseBytes []byte, + err error, + cacheTTL Uint64Param, +) (Result, RunInfo) { + if cacheTTL == 0 { + lggr.Debugw("Bridge task: connection manager request failed", + "response", string(responseBytes), + "url", url.String(), + "error", err, + ) + return Result{Error: err}, RunInfo{IsRetryable: err != nil} + } + + //nolint:gosec // disable G115 + cachedBytes, cacheErr := t.orm.GetCachedResponse(ctx, t.dotID, t.specId, time.Duration(cacheTTL)*time.Second) + if cacheErr != nil { + if !errors.Is(cacheErr, sql.ErrNoRows) { + lggr.Warnw("Bridge task: connection manager cache fallback failed", + "err", cacheErr.Error(), + "url", url.String(), + ) + } + return Result{Error: err}, RunInfo{IsRetryable: err != nil} + } + lggr.Debugw("Bridge task: connection manager request failed, falling back to cache", + "response", string(cachedBytes), + "url", url.String(), + ) + return Result{Value: string(cachedBytes)}, RunInfo{} +} + func (bt *BridgeTelemetry) resolveStreamID(t *BridgeTask, vars Vars, lggr logger.Logger) { if t.StreamID.Valid { bt.StreamID = &t.StreamID.Uint32 @@ -408,12 +456,12 @@ func (bt *BridgeTelemetry) resolveStreamID(t *BridgeTask, vars Vars, lggr logger } } -func (t *BridgeTask) getBridgeURLFromName(ctx context.Context, name StringParam) (URLParam, error) { +func (t *BridgeTask) getBridgeFromName(ctx context.Context, name StringParam) (bridges.BridgeType, error) { bt, err := t.orm.FindBridge(ctx, bridges.BridgeName(name)) if err != nil { - return URLParam{}, errors.Wrapf(err, "could not find bridge with name '%s'", name) + return bridges.BridgeType{}, errors.Wrapf(err, "could not find bridge with name '%s'", name) } - return URLParam(bt.URL), nil + return bt, nil } func withRunInfo(request MapParam, meta MapParam) MapParam { diff --git a/core/services/pipeline/task.bridge_test.go b/core/services/pipeline/task.bridge_test.go index 0557d0ee48c..1e304faa07b 100644 --- a/core/services/pipeline/task.bridge_test.go +++ b/core/services/pipeline/task.bridge_test.go @@ -22,6 +22,7 @@ import ( "github.com/stretchr/testify/require" commonconfig "github.com/smartcontractkit/chainlink-common/pkg/config" + commonlogger "github.com/smartcontractkit/chainlink-common/pkg/logger" "github.com/smartcontractkit/chainlink-common/pkg/services/servicetest" "github.com/smartcontractkit/chainlink-common/pkg/sqlutil" @@ -59,6 +60,17 @@ type adapterResponseData struct { Result *decimal.Decimal `json:"result"` } +type fakeBridgeConnManager struct { + callCount atomic.Int32 + response []byte + err error +} + +func (f *fakeBridgeConnManager) GetObservation(ctx context.Context, lggr commonlogger.Logger, bridge bridges.BridgeType, requestData pipeline.MapParam) ([]byte, error) { + f.callCount.Add(1) + return f.response, f.err +} + // adapterResponse is the HTTP response as defined by the external adapter: // https://github.com/smartcontractkit/bnc-adapter type adapterResponse struct { @@ -269,6 +281,98 @@ func TestBridgeTask_Happy(t *testing.T) { assert.NotEqual(t, uuid.Nil, btelem.DotID) } +func TestBridgeTask_UsesBridgeConnManagerWhenEnabled(t *testing.T) { + t.Parallel() + + db := pgtest.NewSqlxDB(t) + cfg := configtest.NewTestGeneralConfig(t) + + var httpCalls atomic.Int32 + s1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + httpCalls.Add(1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer s1.Close() + + feedURL, err := url.ParseRequestURI(s1.URL) + require.NoError(t, err) + + orm := bridges.NewORM(db) + _, bridge := cltest.MustCreateBridge(t, db, cltest.BridgeOpts{ + URL: feedURL.String(), + UseConnectionManager: true, + }) + + task := pipeline.BridgeTask{ + BaseTask: pipeline.NewBaseTask(0, "bridge", nil, nil, 0), + Name: bridge.Name.String(), + RequestData: btcUSDPairing, + } + manager := &fakeBridgeConnManager{ + response: []byte(`{"data":{"result":"9700"}}`), + } + c := clhttptest.NewTestLocalOnlyHTTPClient() + trORM := pipeline.NewORM(db, logger.TestLogger(t), cfg.JobPipeline().MaxSuccessfulRuns()) + specID, err := trORM.CreateSpec(testutils.Context(t), pipeline.Pipeline{}, *sqlutil.NewInterval(5 * time.Minute)) + require.NoError(t, err) + task.HelperSetDependencies(cfg.JobPipeline(), cfg.WebServer(), orm, specID, uuid.UUID{}, c, manager) + + result, runInfo := task.Run(testutils.Context(t), logger.TestLogger(t), pipeline.NewVarsFrom(nil), nil) + + assert.False(t, runInfo.IsPending) + assert.False(t, runInfo.IsRetryable) + require.NoError(t, result.Error) + assert.Equal(t, `{"data":{"result":"9700"}}`, result.Value) + assert.Equal(t, int32(1), manager.callCount.Load()) + assert.Equal(t, int32(0), httpCalls.Load()) +} + +func TestBridgeTask_UsesBridgeConnManagerCacheFallback(t *testing.T) { + t.Parallel() + + db := pgtest.NewSqlxDB(t) + cfg := configtest.NewTestGeneralConfig(t) + + var httpCalls atomic.Int32 + s1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + httpCalls.Add(1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer s1.Close() + + feedURL, err := url.ParseRequestURI(s1.URL) + require.NoError(t, err) + + orm := bridges.NewORM(db) + _, bridge := cltest.MustCreateBridge(t, db, cltest.BridgeOpts{ + URL: feedURL.String(), + UseConnectionManager: true, + }) + + task := pipeline.BridgeTask{ + BaseTask: pipeline.NewBaseTask(0, "bridge", nil, nil, 0), + Name: bridge.Name.String(), + RequestData: btcUSDPairing, + CacheTTL: "10s", + } + manager := &fakeBridgeConnManager{err: errors.New("connection manager failed")} + c := clhttptest.NewTestLocalOnlyHTTPClient() + trORM := pipeline.NewORM(db, logger.TestLogger(t), cfg.JobPipeline().MaxSuccessfulRuns()) + specID, err := trORM.CreateSpec(testutils.Context(t), pipeline.Pipeline{}, *sqlutil.NewInterval(5 * time.Minute)) + require.NoError(t, err) + task.HelperSetDependencies(cfg.JobPipeline(), cfg.WebServer(), orm, specID, uuid.UUID{}, c, manager) + require.NoError(t, orm.UpsertBridgeResponse(testutils.Context(t), task.DotID(), specID, []byte(`{"data":{"result":"9700"}}`))) + + result, runInfo := task.Run(testutils.Context(t), logger.TestLogger(t), pipeline.NewVarsFrom(nil), nil) + + assert.False(t, runInfo.IsPending) + assert.False(t, runInfo.IsRetryable) + require.NoError(t, result.Error) + assert.Equal(t, `{"data":{"result":"9700"}}`, result.Value) + assert.Equal(t, int32(1), manager.callCount.Load()) + assert.Equal(t, int32(0), httpCalls.Load()) +} + func TestBridgeTask_HandlesIntermittentFailure(t *testing.T) { t.Parallel() From 0c906402b54e7e815f1f9210028be8759e254242 Mon Sep 17 00:00:00 2001 From: "denis.chernov" Date: Wed, 15 Jul 2026 11:56:40 -0700 Subject: [PATCH 03/16] bridgeConnManager cache added --- core/services/pipeline/bridge_conn_manager.go | 66 ++++++++++++++--- core/services/pipeline/helpers_test.go | 10 +-- core/services/pipeline/task.bridge.go | 64 +++++------------ core/services/pipeline/task.bridge_test.go | 71 +++---------------- 4 files changed, 89 insertions(+), 122 deletions(-) diff --git a/core/services/pipeline/bridge_conn_manager.go b/core/services/pipeline/bridge_conn_manager.go index 3fd20592164..5ddeb6ba023 100644 --- a/core/services/pipeline/bridge_conn_manager.go +++ b/core/services/pipeline/bridge_conn_manager.go @@ -1,24 +1,74 @@ package pipeline import ( - "context" + "crypto/sha256" + stdErrors "errors" + "fmt" + "sync" - "github.com/pkg/errors" + "github.com/goccy/go-json" - "github.com/smartcontractkit/chainlink-common/pkg/logger" "github.com/smartcontractkit/chainlink/v2/core/bridges" ) type BridgeConnManager interface { - GetObservation(ctx context.Context, lggr logger.Logger, bridge bridges.BridgeType, requestData MapParam) ([]byte, error) + GetObservation(bridge bridges.BridgeType, requestData MapParam) ([]byte, error) } -type dummyBridgeConnManager struct{} +var ( + ErrBridgeObservationNotFound = stdErrors.New("bridge observation not found") +) + +type bridgeConnManager struct { + mu sync.RWMutex + cache map[[32]byte][]byte +} + +var defaultBridgeConnManager BridgeConnManager = &bridgeConnManager{ + cache: make(map[[32]byte][]byte), +} func NewBridgeConnManager() BridgeConnManager { - return &dummyBridgeConnManager{} + return defaultBridgeConnManager +} + +// PutObservation writes an observation to the in-memory cache. +func (m *bridgeConnManager) PutObservation(bridge bridges.BridgeType, requestData MapParam, observation []byte) error { + key, err := bridgeObservationCacheKey(bridge, requestData) + if err != nil { + return err + } + payload := make([]byte, len(observation)) + copy(payload, observation) + m.mu.Lock() + defer m.mu.Unlock() + m.cache[key] = payload + return nil +} + +func (m *bridgeConnManager) GetObservation(bridge bridges.BridgeType, requestData MapParam) ([]byte, error) { + key, err := bridgeObservationCacheKey(bridge, requestData) + if err != nil { + return nil, err + } + m.mu.RLock() + entry, ok := m.cache[key] + m.mu.RUnlock() + if !ok { + return nil, fmt.Errorf("%w for bridge %q", ErrBridgeObservationNotFound, bridge.Name.String()) + } + payload := make([]byte, len(entry)) + copy(payload, entry) + return payload, nil } -func (m *dummyBridgeConnManager) GetObservation(ctx context.Context, lggr logger.Logger, bridge bridges.BridgeType, requestData MapParam) ([]byte, error) { - return nil, errors.Errorf("bridge connection manager transport is not implemented for bridge %q", bridge.Name.String()) +func bridgeObservationCacheKey(bridge bridges.BridgeType, requestData MapParam) ([32]byte, error) { + lookupBytes, err := json.Marshal(requestData) + if err != nil { + return [32]byte{}, fmt.Errorf("failed to marshal bridge lookup payload: %w", err) + } + b := make([]byte, 0, len(bridge.Name.String())+len(lookupBytes)) + b = append(b, bridge.Name.String()...) + b = append(b, lookupBytes...) + return sha256.Sum256(b), nil } diff --git a/core/services/pipeline/helpers_test.go b/core/services/pipeline/helpers_test.go index 407a08b8e76..4517d8c6c9a 100644 --- a/core/services/pipeline/helpers_test.go +++ b/core/services/pipeline/helpers_test.go @@ -36,17 +36,17 @@ func (t *BridgeTask) HelperSetDependencies( orm bridges.ORM, specId int32, id uuid.UUID, - httpClient *http.Client, - bridgeConnManager ...BridgeConnManager) { + httpClient *http.Client) { t.config = config t.bridgeConfig = bridgeConfig t.orm = orm t.uuid = id t.httpClient = httpClient t.specId = specId - if len(bridgeConnManager) > 0 { - t.bridgeConnManager = bridgeConnManager[0] - } +} + +func (t *BridgeTask) HelperSetBridgeConnManager(bridgeConnManager BridgeConnManager) { + t.bridgeConnManager = bridgeConnManager } func (t *HTTPTask) HelperSetDependencies(config Config, restrictedHTTPClient, unrestrictedHTTPClient *http.Client) { diff --git a/core/services/pipeline/task.bridge.go b/core/services/pipeline/task.bridge.go index 3a0e397274c..fb77baa2012 100644 --- a/core/services/pipeline/task.bridge.go +++ b/core/services/pipeline/task.bridge.go @@ -168,15 +168,8 @@ func (t *BridgeTask) Run(ctx context.Context, lggr logger.Logger, vars Vars, inp return Result{Error: err}, runInfo } url := URLParam(bridge.URL) - - requestDataJSON, err := t.finalizeAndMarshalBridgeRequestData(lggr, vars, inputValues, &requestData, includeInputAtKey) - if err != nil { - return Result{Error: err}, runInfo - } - logger.Sugared(lggr).Tracew("Bridge task: sending request", - "requestData", string(requestDataJSON), - "url", url.String(), - ) + lookupPayload := make(MapParam) + maps.Copy(lookupPayload, requestData) requestCtx, cancel := httpRequestCtx(ctx, t, t.config) defer cancel() @@ -185,13 +178,27 @@ func (t *BridgeTask) Run(ctx context.Context, lggr logger.Logger, vars Vars, inp if bridgeConnManager == nil { bridgeConnManager = NewBridgeConnManager() } - responseBytes, err := bridgeConnManager.GetObservation(ctx, lggr, bridge, requestData) + responseBytes, err := bridgeConnManager.GetObservation(bridge, lookupPayload) if err != nil { - return t.resolveBridgeConnManagerFailureOrCache(overtimeCtx, lggr, url, responseBytes, err, cacheTTL) + lggr.Debugw("Bridge task: connection manager request failed", + "response", string(responseBytes), + "url", url.String(), + "error", err, + ) + return Result{Error: err}, RunInfo{IsRetryable: true} } return Result{Value: string(responseBytes)}, runInfo } + requestDataJSON, err := t.finalizeAndMarshalBridgeRequestData(lggr, vars, inputValues, &requestData, includeInputAtKey) + if err != nil { + return Result{Error: err}, runInfo + } + logger.Sugared(lggr).Tracew("Bridge task: sending request", + "requestData", string(requestDataJSON), + "url", url.String(), + ) + var cachedResponse bool responseBytes, statusCode, headers, start, finish, err := makeHTTPRequest(requestCtx, lggr, "POST", url, reqHeaders, requestData, t.httpClient, t.config.DefaultHTTPLimit()) elapsed := finish.Sub(start) @@ -405,41 +412,6 @@ func (t *BridgeTask) resolveFailureOrCache( return out, nil, nil } -func (t *BridgeTask) resolveBridgeConnManagerFailureOrCache( - ctx context.Context, - lggr logger.Logger, - url URLParam, - responseBytes []byte, - err error, - cacheTTL Uint64Param, -) (Result, RunInfo) { - if cacheTTL == 0 { - lggr.Debugw("Bridge task: connection manager request failed", - "response", string(responseBytes), - "url", url.String(), - "error", err, - ) - return Result{Error: err}, RunInfo{IsRetryable: err != nil} - } - - //nolint:gosec // disable G115 - cachedBytes, cacheErr := t.orm.GetCachedResponse(ctx, t.dotID, t.specId, time.Duration(cacheTTL)*time.Second) - if cacheErr != nil { - if !errors.Is(cacheErr, sql.ErrNoRows) { - lggr.Warnw("Bridge task: connection manager cache fallback failed", - "err", cacheErr.Error(), - "url", url.String(), - ) - } - return Result{Error: err}, RunInfo{IsRetryable: err != nil} - } - lggr.Debugw("Bridge task: connection manager request failed, falling back to cache", - "response", string(cachedBytes), - "url", url.String(), - ) - return Result{Value: string(cachedBytes)}, RunInfo{} -} - func (bt *BridgeTelemetry) resolveStreamID(t *BridgeTask, vars Vars, lggr logger.Logger) { if t.StreamID.Valid { bt.StreamID = &t.StreamID.Uint32 diff --git a/core/services/pipeline/task.bridge_test.go b/core/services/pipeline/task.bridge_test.go index 1e304faa07b..557346cc7f3 100644 --- a/core/services/pipeline/task.bridge_test.go +++ b/core/services/pipeline/task.bridge_test.go @@ -22,7 +22,6 @@ import ( "github.com/stretchr/testify/require" commonconfig "github.com/smartcontractkit/chainlink-common/pkg/config" - commonlogger "github.com/smartcontractkit/chainlink-common/pkg/logger" "github.com/smartcontractkit/chainlink-common/pkg/services/servicetest" "github.com/smartcontractkit/chainlink-common/pkg/sqlutil" @@ -60,17 +59,6 @@ type adapterResponseData struct { Result *decimal.Decimal `json:"result"` } -type fakeBridgeConnManager struct { - callCount atomic.Int32 - response []byte - err error -} - -func (f *fakeBridgeConnManager) GetObservation(ctx context.Context, lggr commonlogger.Logger, bridge bridges.BridgeType, requestData pipeline.MapParam) ([]byte, error) { - f.callCount.Add(1) - return f.response, f.err -} - // adapterResponse is the HTTP response as defined by the external adapter: // https://github.com/smartcontractkit/bnc-adapter type adapterResponse struct { @@ -281,7 +269,7 @@ func TestBridgeTask_Happy(t *testing.T) { assert.NotEqual(t, uuid.Nil, btelem.DotID) } -func TestBridgeTask_UsesBridgeConnManagerWhenEnabled(t *testing.T) { +func TestBridgeTask_UsesBridgeConnManagerHappyPath(t *testing.T) { t.Parallel() db := pgtest.NewSqlxDB(t) @@ -303,65 +291,24 @@ func TestBridgeTask_UsesBridgeConnManagerWhenEnabled(t *testing.T) { UseConnectionManager: true, }) - task := pipeline.BridgeTask{ - BaseTask: pipeline.NewBaseTask(0, "bridge", nil, nil, 0), - Name: bridge.Name.String(), - RequestData: btcUSDPairing, - } - manager := &fakeBridgeConnManager{ - response: []byte(`{"data":{"result":"9700"}}`), - } - c := clhttptest.NewTestLocalOnlyHTTPClient() - trORM := pipeline.NewORM(db, logger.TestLogger(t), cfg.JobPipeline().MaxSuccessfulRuns()) - specID, err := trORM.CreateSpec(testutils.Context(t), pipeline.Pipeline{}, *sqlutil.NewInterval(5 * time.Minute)) - require.NoError(t, err) - task.HelperSetDependencies(cfg.JobPipeline(), cfg.WebServer(), orm, specID, uuid.UUID{}, c, manager) - - result, runInfo := task.Run(testutils.Context(t), logger.TestLogger(t), pipeline.NewVarsFrom(nil), nil) - - assert.False(t, runInfo.IsPending) - assert.False(t, runInfo.IsRetryable) - require.NoError(t, result.Error) - assert.Equal(t, `{"data":{"result":"9700"}}`, result.Value) - assert.Equal(t, int32(1), manager.callCount.Load()) - assert.Equal(t, int32(0), httpCalls.Load()) -} - -func TestBridgeTask_UsesBridgeConnManagerCacheFallback(t *testing.T) { - t.Parallel() - - db := pgtest.NewSqlxDB(t) - cfg := configtest.NewTestGeneralConfig(t) - - var httpCalls atomic.Int32 - s1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - httpCalls.Add(1) - w.WriteHeader(http.StatusInternalServerError) - })) - defer s1.Close() - - feedURL, err := url.ParseRequestURI(s1.URL) - require.NoError(t, err) - - orm := bridges.NewORM(db) - _, bridge := cltest.MustCreateBridge(t, db, cltest.BridgeOpts{ - URL: feedURL.String(), - UseConnectionManager: true, + manager := pipeline.NewBridgeConnManager() + seedable, ok := manager.(interface { + PutObservation(bridge bridges.BridgeType, requestData pipeline.MapParam, observation []byte) error }) + require.True(t, ok) + require.NoError(t, seedable.PutObservation(*bridge, pipeline.MapParam(utils.MustUnmarshalToMap(btcUSDPairing)), []byte(`{"data":{"result":"9700"}}`))) task := pipeline.BridgeTask{ BaseTask: pipeline.NewBaseTask(0, "bridge", nil, nil, 0), Name: bridge.Name.String(), RequestData: btcUSDPairing, - CacheTTL: "10s", } - manager := &fakeBridgeConnManager{err: errors.New("connection manager failed")} c := clhttptest.NewTestLocalOnlyHTTPClient() trORM := pipeline.NewORM(db, logger.TestLogger(t), cfg.JobPipeline().MaxSuccessfulRuns()) specID, err := trORM.CreateSpec(testutils.Context(t), pipeline.Pipeline{}, *sqlutil.NewInterval(5 * time.Minute)) require.NoError(t, err) - task.HelperSetDependencies(cfg.JobPipeline(), cfg.WebServer(), orm, specID, uuid.UUID{}, c, manager) - require.NoError(t, orm.UpsertBridgeResponse(testutils.Context(t), task.DotID(), specID, []byte(`{"data":{"result":"9700"}}`))) + task.HelperSetDependencies(cfg.JobPipeline(), cfg.WebServer(), orm, specID, uuid.UUID{}, c) + task.HelperSetBridgeConnManager(manager) result, runInfo := task.Run(testutils.Context(t), logger.TestLogger(t), pipeline.NewVarsFrom(nil), nil) @@ -369,7 +316,6 @@ func TestBridgeTask_UsesBridgeConnManagerCacheFallback(t *testing.T) { assert.False(t, runInfo.IsRetryable) require.NoError(t, result.Error) assert.Equal(t, `{"data":{"result":"9700"}}`, result.Value) - assert.Equal(t, int32(1), manager.callCount.Load()) assert.Equal(t, int32(0), httpCalls.Load()) } @@ -392,7 +338,6 @@ func TestBridgeTask_HandlesIntermittentFailure(t *testing.T) { BaseTask: pipeline.NewBaseTask(0, "bridge", nil, nil, 0), Name: bridge.Name.String(), RequestData: btcUSDPairing, - CacheTTL: "30s", // standard duration string format } c := clhttptest.NewTestLocalOnlyHTTPClient() trORM := pipeline.NewORM(db, logger.TestLogger(t), cfg.JobPipeline().MaxSuccessfulRuns()) From f516da78237de6e0efc890b0bc25a7ed8cb275ca Mon Sep 17 00:00:00 2001 From: "denis.chernov" Date: Mon, 20 Jul 2026 21:22:42 -0700 Subject: [PATCH 04/16] eaconn --- core/services/pipeline/bridge_conn_manager.go | 119 +++++++-- .../pipeline/bridge_conn_manager_test.go | 217 ++++++++++++++++ core/services/pipeline/eaconn.go | 228 ++++++++++++++++ core/services/pipeline/grpc_stream_client.go | 45 ++++ core/services/pipeline/runner.go | 2 +- core/services/pipeline/streamspb/generate.go | 6 + .../services/pipeline/streamspb/streams.pb.go | 243 ++++++++++++++++++ .../services/pipeline/streamspb/streams.proto | 26 ++ .../pipeline/streamspb/streams_grpc.pb.go | 117 +++++++++ core/services/pipeline/task.bridge_test.go | 5 + 10 files changed, 987 insertions(+), 21 deletions(-) create mode 100644 core/services/pipeline/bridge_conn_manager_test.go create mode 100644 core/services/pipeline/eaconn.go create mode 100644 core/services/pipeline/grpc_stream_client.go create mode 100644 core/services/pipeline/streamspb/generate.go create mode 100644 core/services/pipeline/streamspb/streams.pb.go create mode 100644 core/services/pipeline/streamspb/streams.proto create mode 100644 core/services/pipeline/streamspb/streams_grpc.pb.go diff --git a/core/services/pipeline/bridge_conn_manager.go b/core/services/pipeline/bridge_conn_manager.go index 5ddeb6ba023..4731f12b3e6 100644 --- a/core/services/pipeline/bridge_conn_manager.go +++ b/core/services/pipeline/bridge_conn_manager.go @@ -1,56 +1,79 @@ package pipeline import ( + "context" "crypto/sha256" stdErrors "errors" "fmt" "sync" "github.com/goccy/go-json" + "google.golang.org/protobuf/types/known/structpb" + "github.com/smartcontractkit/chainlink-common/pkg/logger" "github.com/smartcontractkit/chainlink/v2/core/bridges" ) type BridgeConnManager interface { GetObservation(bridge bridges.BridgeType, requestData MapParam) ([]byte, error) + PutObservation(key [32]byte, observation []byte) } var ( ErrBridgeObservationNotFound = stdErrors.New("bridge observation not found") ) +// bridgeConnManager is a package-level singleton: one observation cache plus one +// EAConn registry shared by every pipeline run in the process. It self-initializes +// lazily as bridges are first used; there is no explicit start/close lifecycle. type bridgeConnManager struct { mu sync.RWMutex cache map[[32]byte][]byte + + connsMu sync.Mutex + conns map[string]*eaConn // bridge name -> EAConn + lggr logger.Logger // guarded by connsMu; set at most once, from NewBridgeConnManager + + dial eaStreamDialer } var defaultBridgeConnManager BridgeConnManager = &bridgeConnManager{ cache: make(map[[32]byte][]byte), + conns: make(map[string]*eaConn), + lggr: logger.Nop(), + dial: dialGRPCStream, } -func NewBridgeConnManager() BridgeConnManager { - return defaultBridgeConnManager -} - -// PutObservation writes an observation to the in-memory cache. -func (m *bridgeConnManager) PutObservation(bridge bridges.BridgeType, requestData MapParam, observation []byte) error { - key, err := bridgeObservationCacheKey(bridge, requestData) - if err != nil { - return err +// NewBridgeConnManager returns the package-level singleton. Passing a logger sets +// it on the singleton for use by lazily-created EAConns; it's expected to be +// called once, from PipelineRunner startup, with all other call sites (fallback +// construction, tests) using the zero-arg form and getting whatever logger (or +// the Nop default) is already set. +func NewBridgeConnManager(lggr ...logger.Logger) BridgeConnManager { + m := defaultBridgeConnManager.(*bridgeConnManager) + if len(lggr) > 0 && lggr[0] != nil { + m.connsMu.Lock() + m.lggr = lggr[0] + m.connsMu.Unlock() } - payload := make([]byte, len(observation)) - copy(payload, observation) - m.mu.Lock() - defer m.mu.Unlock() - m.cache[key] = payload - return nil + return m } func (m *bridgeConnManager) GetObservation(bridge bridges.BridgeType, requestData MapParam) ([]byte, error) { - key, err := bridgeObservationCacheKey(bridge, requestData) + data, err := subscriptionData(requestData) + if err != nil { + return nil, fmt.Errorf("bridge %q: %w", bridge.Name.String(), err) + } + key, err := bridgeObservationCacheKey(bridge, data) if err != nil { return nil, err } + subscription, err := structpb.NewStruct(data) + if err != nil { + return nil, fmt.Errorf("failed to build subscription payload for bridge %q: %w", bridge.Name.String(), err) + } + m.getOrCreateConn(bridge).registerAsset(key, subscription) + m.mu.RLock() entry, ok := m.cache[key] m.mu.RUnlock() @@ -62,13 +85,69 @@ func (m *bridgeConnManager) GetObservation(bridge bridges.BridgeType, requestDat return payload, nil } -func bridgeObservationCacheKey(bridge bridges.BridgeType, requestData MapParam) ([32]byte, error) { - lookupBytes, err := json.Marshal(requestData) +// putObservation is called by an EAConn's receiver loop when it accepts a +// streamed observation for a currently registered asset. +func (m *bridgeConnManager) PutObservation(key [32]byte, observation []byte) { + payload := make([]byte, len(observation)) + copy(payload, observation) + m.mu.Lock() + defer m.mu.Unlock() + m.cache[key] = payload +} + +// getOrCreateConn returns the bridge's persistent EAConn, lazily creating and +// starting it on first use. +func (m *bridgeConnManager) getOrCreateConn(bridge bridges.BridgeType) *eaConn { + name := bridge.Name.String() + + m.connsMu.Lock() + defer m.connsMu.Unlock() + if conn, ok := m.conns[name]; ok { + return conn + } + + conn := newEAConn(bridge, m) + m.conns[name] = conn + conn.start() + return conn +} + +var errStreamDialingDisabledForTest = stdErrors.New("EAConn stream dialing disabled for test") + +// DisableEAConnDialingForTest replaces the manager's stream dialer with one that +// fails immediately without any network I/O, for tests that seed the observation +// cache directly and must not depend on a real streams-adapter connection. It +// mutates the shared package-level singleton and is intended for test setup only. +func (m *bridgeConnManager) DisableEAConnDialingForTest() { + m.connsMu.Lock() + defer m.connsMu.Unlock() + m.dial = func(_ context.Context, _ string) (eaStreamClient, error) { + return nil, errStreamDialingDisabledForTest + } +} + +// subscriptionData extracts the inner "data" object from a bridge task's request +// payload — the only part sent as Subscription.Data and the only part the adapter +// hashes (see ObservationPayloadHash on the streams-adapter side). +func subscriptionData(requestData MapParam) (map[string]any, error) { + data, ok := requestData["data"].(map[string]any) + if !ok || len(data) == 0 { + return nil, fmt.Errorf("request data is missing a non-empty \"data\" field required for subscription") + } + return data, nil +} + +// bridgeObservationCacheKey mirrors the streams-adapter's own ObservationPayloadHash. +// The adapter is configured with its own adapterName equal to this bridge's name, +// so payload_hash on an accepted observation equals this same key. +func bridgeObservationCacheKey(bridge bridges.BridgeType, data map[string]any) ([32]byte, error) { + lookupBytes, err := json.Marshal(data) if err != nil { return [32]byte{}, fmt.Errorf("failed to marshal bridge lookup payload: %w", err) } - b := make([]byte, 0, len(bridge.Name.String())+len(lookupBytes)) - b = append(b, bridge.Name.String()...) + name := bridge.Name.String() + b := make([]byte, 0, len(name)+len(lookupBytes)) + b = append(b, name...) b = append(b, lookupBytes...) return sha256.Sum256(b), nil } diff --git a/core/services/pipeline/bridge_conn_manager_test.go b/core/services/pipeline/bridge_conn_manager_test.go new file mode 100644 index 00000000000..1c86a8acf47 --- /dev/null +++ b/core/services/pipeline/bridge_conn_manager_test.go @@ -0,0 +1,217 @@ +package pipeline + +import ( + "context" + "encoding/hex" + "net/url" + "sync" + "testing" + "time" + + "github.com/jonboulle/clockwork" + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink/v2/core/bridges" + "github.com/smartcontractkit/chainlink/v2/core/services/pipeline/streamspb" + "github.com/smartcontractkit/chainlink/v2/core/store/models" +) + +func testBridge(t *testing.T, name string) bridges.BridgeType { + t.Helper() + u, err := url.Parse("http://" + name + ".example.invalid:8080") + require.NoError(t, err) + return bridges.BridgeType{ + Name: bridges.BridgeName(name), + URL: models.WebURL(*u), + UseConnectionManager: true, + } +} + +func newTestManager() *bridgeConnManager { + return &bridgeConnManager{ + cache: make(map[[32]byte][]byte), + conns: make(map[string]*eaConn), + lggr: logger.Nop(), + dial: func(_ context.Context, _ string) (eaStreamClient, error) { + return nil, errStreamDialingDisabledForTest + }, + } +} + +func TestBridgeConnManager_GetOrCreateConn_OnePerBridge(t *testing.T) { + t.Parallel() + m := newTestManager() + bridgeA := testBridge(t, "bridgea") + bridgeB := testBridge(t, "bridgeb") + + var wg sync.WaitGroup + conns := make([]*eaConn, 20) + for idx := range 10 { + wg.Add(2) + go func() { + defer wg.Done() + conns[idx] = m.getOrCreateConn(bridgeA) + }() + go func() { + defer wg.Done() + conns[idx+10] = m.getOrCreateConn(bridgeB) + }() + } + wg.Wait() + + for i := 1; i < 10; i++ { + assert.Same(t, conns[0], conns[i], "all concurrent lookups for bridgeA must return the same EAConn") + } + for i := 11; i < 20; i++ { + assert.Same(t, conns[10], conns[i], "all concurrent lookups for bridgeB must return the same EAConn") + } + assert.NotSame(t, conns[0], conns[10], "separate bridges must get separate EAConns") + + m.connsMu.Lock() + assert.Len(t, m.conns, 2) + m.connsMu.Unlock() +} + +func TestEAConn_RegisterAsset_RefreshAndIdlePrune(t *testing.T) { + t.Parallel() + m := newTestManager() + bridge := testBridge(t, "idlebridge") + conn := newEAConn(bridge, m) + clock := clockwork.NewFakeClock() + conn.clock = clock + + payload, err := structpb.NewStruct(map[string]any{"endpoint": "crypto"}) + require.NoError(t, err) + + var keyA, keyB [32]byte + keyA[0] = 1 + keyB[0] = 2 + conn.registerAsset(keyA, payload) + conn.registerAsset(keyB, payload) + + // Neither asset is idle yet: both survive a snapshot. + active, req := conn.pruneAndSnapshot() + assert.Len(t, active, 2) + assert.Len(t, req.Subscriptions, 2) + + // Refresh keyA only, then advance past the idle timeout. + clock.Advance(assetIdleTimeout / 2) + conn.registerAsset(keyA, payload) + clock.Advance(assetIdleTimeout/2 + time.Millisecond) + + active, req = conn.pruneAndSnapshot() + assert.Len(t, active, 1) + assert.Len(t, req.Subscriptions, 1) + _, aStillActive := active[keyA] + assert.True(t, aStillActive, "refreshed asset must survive the idle prune") + _, bStillActive := active[keyB] + assert.False(t, bStillActive, "unrefreshed asset must be pruned (indirect unsubscribe)") +} + +func TestEAConn_HandleObservation(t *testing.T) { + t.Parallel() + m := newTestManager() + bridge := testBridge(t, "obsbridge") + conn := newEAConn(bridge, m) + + payload, err := structpb.NewStruct(map[string]any{"endpoint": "crypto"}) + require.NoError(t, err) + var key [32]byte + key[0] = 42 + conn.registerAsset(key, payload) + + assetKey := hex.EncodeToString(key[:]) + observationsMetric := func() float64 { + return testutil.ToFloat64(promEAConnObservationsTotal.WithLabelValues(conn.bridgeName, assetKey)) + } + + // Registered key: cached, and the per-asset counter is incremented. + conn.handleObservation(&streamspb.SubscribeResponse{ + PayloadHash: key[:], + ObservationJson: []byte(`{"result":"1"}`), + }) + m.mu.RLock() + cached, ok := m.cache[key] + m.mu.RUnlock() + require.True(t, ok) + assert.Equal(t, `{"result":"1"}`, string(cached)) + assert.Equal(t, float64(1), observationsMetric()) + + // Unregistered key: discarded, counter unchanged. + var unknownKey [32]byte + unknownKey[0] = 99 + conn.handleObservation(&streamspb.SubscribeResponse{ + PayloadHash: unknownKey[:], + ObservationJson: []byte(`{"result":"2"}`), + }) + m.mu.RLock() + _, unknownCached := m.cache[unknownKey] + m.mu.RUnlock() + assert.False(t, unknownCached, "observation for an unregistered key must not be cached") + assert.Equal(t, float64(1), observationsMetric(), "counter must not increment for an unregistered key") + + // Malformed (wrong-length) payload_hash: discarded, no panic, counter unchanged. + conn.handleObservation(&streamspb.SubscribeResponse{ + PayloadHash: []byte{1, 2, 3}, + ObservationJson: []byte(`{"result":"3"}`), + }) + assert.Equal(t, float64(1), observationsMetric(), "counter must not increment for a malformed payload_hash") +} + +func TestNextBackoff(t *testing.T) { + t.Parallel() + d := reconnectBackoffInitial + seen := []time.Duration{d} + for range 10 { + d = nextBackoff(d) + seen = append(seen, d) + } + for i := 1; i < len(seen); i++ { + assert.LessOrEqual(t, seen[i-1], seen[i], "backoff must never decrease") + assert.LessOrEqual(t, seen[i], reconnectBackoffMax, "backoff must never exceed the configured max") + } + assert.Equal(t, reconnectBackoffMax, seen[len(seen)-1], "backoff must saturate at the max") +} + +func TestBridgeConnManager_GetObservation_CacheHitAndMiss(t *testing.T) { + t.Parallel() + m := newTestManager() + bridge := testBridge(t, "cachebridge") + requestData := MapParam{"data": map[string]any{"endpoint": "crypto"}} + + _, err := m.GetObservation(bridge, requestData) + require.ErrorIs(t, err, ErrBridgeObservationNotFound) + + key, err := bridgeObservationCacheKey(bridge, requestData["data"].(map[string]any)) + require.NoError(t, err) + + m.PutObservation(key, []byte(`{"result":"9700"}`)) + got, err := m.GetObservation(bridge, requestData) + require.NoError(t, err) + assert.Equal(t, `{"result":"9700"}`, string(got)) + + // GetObservation must still have registered the asset with the bridge's EAConn. + m.connsMu.Lock() + conn, ok := m.conns[bridge.Name.String()] + m.connsMu.Unlock() + require.True(t, ok) + conn.mu.Lock() + _, registered := conn.assets[key] + conn.mu.Unlock() + assert.True(t, registered) +} +func TestBridgeConnManager_GetObservation_MissingDataField(t *testing.T) { + t.Parallel() + m := newTestManager() + bridge := testBridge(t, "nodatabridge") + + _, err := m.GetObservation(bridge, MapParam{"foo": "bar"}) + assert.Error(t, err, "requestData without a \"data\" field must be rejected, not silently subscribed") + + _, err = m.GetObservation(bridge, MapParam{"data": map[string]any{}}) + assert.Error(t, err, "an empty \"data\" field must be rejected") +} diff --git a/core/services/pipeline/eaconn.go b/core/services/pipeline/eaconn.go new file mode 100644 index 00000000000..342c0851873 --- /dev/null +++ b/core/services/pipeline/eaconn.go @@ -0,0 +1,228 @@ +package pipeline + +import ( + "context" + "encoding/hex" + "net/url" + "sync" + "time" + + "github.com/jonboulle/clockwork" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "google.golang.org/protobuf/types/known/structpb" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink/v2/core/bridges" + "github.com/smartcontractkit/chainlink/v2/core/services/pipeline/streamspb" +) + +// Operational timing and reconnect behavior are hardcoded: this manager has no +// configuration section and EAConn takes no configuration interface. +const ( + subscriptionInterval = 10 * time.Second + assetIdleTimeout = 30 * time.Second + reconnectBackoffInitial = time.Second + reconnectBackoffMultiplier = 2.0 + reconnectBackoffMax = time.Minute +) + +// promEAConnObservationsTotal counts accepted gRPC observation messages per bridge +// and asset pair (the hex-encoded bridgeObservationCacheKey). Rate per second is +// derived externally, e.g. via PromQL rate(bridge_eaconn_observations_total[1m]). +var promEAConnObservationsTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "bridge_eaconn_observations_total", + Help: "Count of gRPC observation messages accepted per bridge and asset pair", +}, []string{"bridgeName", "assetKey"}) + +// eaStreamClient is the protobuf-independent contract EAConn depends on for its +// bidirectional observation stream, implemented by grpcStreamClient in production +// and by fakes in tests. +type eaStreamClient interface { + Send(*streamspb.SubscribeRequest) error + Recv() (*streamspb.SubscribeResponse, error) + Close() error +} + +// eaStreamDialer opens a new stream to target (host:port derived from the bridge's +// URL authority). All connections are plaintext gRPC regardless of bridge URL scheme. +type eaStreamDialer func(ctx context.Context, target string) (eaStreamClient, error) + +// eaAsset is a bridge's registered asset: an immutable subscription payload plus the +// timestamp it was last requested through GetObservation. +type eaAsset struct { + payload *structpb.Struct + lastUsed time.Time +} + +// eaConn is the single persistent connection owned by BridgeConnManager for one +// bridge name. It sends complete active-asset snapshots on a fixed interval, +// applies indirect unsubscribe by omitting idle assets from the next snapshot, and +// reconnects with fixed exponential backoff on any dial/send/receive failure. +type eaConn struct { + bridgeName string + target string + dial eaStreamDialer + lggr logger.Logger + manager *bridgeConnManager + clock clockwork.Clock + + mu sync.Mutex + assets map[[32]byte]*eaAsset + + startOnce sync.Once +} + +// newEAConn is called with manager.connsMu already held, so reading manager.lggr +// here is safe without a separate lock. +func newEAConn(bridge bridges.BridgeType, manager *bridgeConnManager) *eaConn { + name := bridge.Name.String() + return &eaConn{ + bridgeName: name, + target: url.URL(bridge.URL).Host, + dial: manager.dial, + lggr: logger.With(logger.Named(manager.lggr, "EAConn"), "bridgeName", name), + manager: manager, + clock: clockwork.NewRealClock(), + assets: make(map[[32]byte]*eaAsset), + } +} + +func (c *eaConn) start() { + c.startOnce.Do(func() { + go c.run(context.Background()) + }) +} + +// registerAsset records or refreshes the lease for key, lazily adding it to the next +// snapshot. Asset changes never trigger an immediate send. +func (c *eaConn) registerAsset(key [32]byte, payload *structpb.Struct) { + c.mu.Lock() + defer c.mu.Unlock() + if asset, ok := c.assets[key]; ok { + asset.lastUsed = c.clock.Now() + return + } + c.assets[key] = &eaAsset{payload: payload, lastUsed: c.clock.Now()} + c.lggr.Debugw("EAConn: registered asset", "key", hex.EncodeToString(key[:])) +} + +// pruneAndSnapshot removes assets idle for at least assetIdleTimeout (indirect +// unsubscribe by omission) and returns a full-snapshot request for what remains. +func (c *eaConn) pruneAndSnapshot() (map[[32]byte]struct{}, *streamspb.SubscribeRequest) { + now := c.clock.Now() + c.mu.Lock() + defer c.mu.Unlock() + for key, asset := range c.assets { + if now.Sub(asset.lastUsed) >= assetIdleTimeout { + delete(c.assets, key) + } + } + keys := make(map[[32]byte]struct{}, len(c.assets)) + req := &streamspb.SubscribeRequest{Subscriptions: make([]*streamspb.Subscription, 0, len(c.assets))} + for key, asset := range c.assets { + keys[key] = struct{}{} + req.Subscriptions = append(req.Subscriptions, &streamspb.Subscription{Data: asset.payload}) + } + return keys, req +} + +// run owns the connect/send/receive/reconnect lifecycle for the process lifetime; +// there is no explicit stop, since this manager has no runner-owned lifecycle. +func (c *eaConn) run(ctx context.Context) { + backoff := reconnectBackoffInitial + for { + stream, err := c.dial(ctx, c.target) + if err != nil { + c.lggr.Errorw("EAConn: dial failed", "target", c.target, "err", err) + backoff = c.sleepBackoff(backoff) + continue + } + c.lggr.Infow("EAConn: stream opened", "target", c.target) + backoff = reconnectBackoffInitial + + streamErr := c.serve(stream) + _ = stream.Close() + c.lggr.Warnw("EAConn: stream closed, reconnecting", "target", c.target, "err", streamErr) + backoff = c.sleepBackoff(backoff) + } +} + +// serve runs the sender ticker and receiver loop against one open stream until +// either fails, returning the failure. +func (c *eaConn) serve(stream eaStreamClient) error { + errCh := make(chan error, 2) + done := make(chan struct{}) + defer close(done) + + go func() { + errCh <- c.recvLoop(stream, done) + }() + go func() { + errCh <- c.sendLoop(stream, done) + }() + return <-errCh +} + +func (c *eaConn) sendLoop(stream eaStreamClient, done <-chan struct{}) error { + ticker := c.clock.NewTicker(subscriptionInterval) + defer ticker.Stop() + for { + select { + case <-done: + return nil + case <-ticker.Chan(): + _, req := c.pruneAndSnapshot() + if err := stream.Send(req); err != nil { + return err + } + } + } +} + +func (c *eaConn) recvLoop(stream eaStreamClient, done <-chan struct{}) error { + for { + resp, err := stream.Recv() + if err != nil { + return err + } + c.handleObservation(resp) + select { + case <-done: + return nil + default: + } + } +} + +// handleObservation accepts an observation only for a key currently registered to +// this EAConn (payload_hash is expected to equal bridgeObservationCacheKey of the +// subscription payload we sent); unregistered or malformed keys are discarded. +func (c *eaConn) handleObservation(resp *streamspb.SubscribeResponse) { + if len(resp.PayloadHash) != 32 { + c.lggr.Warnw("EAConn: discarding observation with malformed payload_hash", "len", len(resp.PayloadHash)) + return + } + var key [32]byte + copy(key[:], resp.PayloadHash) + + c.mu.Lock() + _, registered := c.assets[key] + c.mu.Unlock() + if !registered { + c.lggr.Debugw("EAConn: discarding observation for unregistered key", "payloadHash", hex.EncodeToString(key[:])) + return + } + promEAConnObservationsTotal.WithLabelValues(c.bridgeName, hex.EncodeToString(key[:])).Inc() + c.manager.PutObservation(key, resp.ObservationJson) +} + +func (c *eaConn) sleepBackoff(current time.Duration) time.Duration { + c.clock.Sleep(current) + return nextBackoff(current) +} + +func nextBackoff(current time.Duration) time.Duration { + next := time.Duration(float64(current) * reconnectBackoffMultiplier) + return min(next, reconnectBackoffMax) +} diff --git a/core/services/pipeline/grpc_stream_client.go b/core/services/pipeline/grpc_stream_client.go new file mode 100644 index 00000000000..d841de531a7 --- /dev/null +++ b/core/services/pipeline/grpc_stream_client.go @@ -0,0 +1,45 @@ +package pipeline + +import ( + "context" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + "github.com/smartcontractkit/chainlink/v2/core/services/pipeline/streamspb" +) + +// grpcStreamClient adapts streamspb's generated Subscribe stream to eaStreamClient. +type grpcStreamClient struct { + conn *grpc.ClientConn + stream streamspb.StreamService_SubscribeClient +} + +// dialGRPCStream opens a plaintext gRPC connection to target and starts the +// bidirectional Subscribe stream. All EAConn connections are plaintext regardless +// of the bridge URL scheme: no TLS, no application tokens, no client certificates. +func dialGRPCStream(ctx context.Context, target string) (eaStreamClient, error) { + conn, err := grpc.NewClient(target, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return nil, err + } + stream, err := streamspb.NewStreamServiceClient(conn).Subscribe(ctx) + if err != nil { + _ = conn.Close() + return nil, err + } + return &grpcStreamClient{conn: conn, stream: stream}, nil +} + +func (c *grpcStreamClient) Send(req *streamspb.SubscribeRequest) error { + return c.stream.Send(req) +} + +func (c *grpcStreamClient) Recv() (*streamspb.SubscribeResponse, error) { + return c.stream.Recv() +} + +func (c *grpcStreamClient) Close() error { + _ = c.stream.CloseSend() + return c.conn.Close() +} diff --git a/core/services/pipeline/runner.go b/core/services/pipeline/runner.go index 584e38aa363..f25712cbb86 100644 --- a/core/services/pipeline/runner.go +++ b/core/services/pipeline/runner.go @@ -135,7 +135,7 @@ func NewRunner( lggr: lggr, httpClient: httpClient, unrestrictedHTTPClient: unrestrictedHTTPClient, - bridgeConnManager: NewBridgeConnManager(), + bridgeConnManager: NewBridgeConnManager(lggr), } r.runReaperWorker = commonutils.NewSleeperTask( diff --git a/core/services/pipeline/streamspb/generate.go b/core/services/pipeline/streamspb/generate.go new file mode 100644 index 00000000000..18a73a20300 --- /dev/null +++ b/core/services/pipeline/streamspb/generate.go @@ -0,0 +1,6 @@ +// Package streamspb holds the generated types for streams.proto (the streams-adapter +// wire contract). +package streamspb + +//go:generate protoc --go_out=. --go_opt=paths=source_relative streams.proto +//go:generate protoc --go-grpc_out=. --go-grpc_opt=paths=source_relative streams.proto diff --git a/core/services/pipeline/streamspb/streams.pb.go b/core/services/pipeline/streamspb/streams.pb.go new file mode 100644 index 00000000000..5a349cd3a85 --- /dev/null +++ b/core/services/pipeline/streamspb/streams.pb.go @@ -0,0 +1,243 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v5.29.3 +// source: streams.proto + +package streamspb + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + structpb "google.golang.org/protobuf/types/known/structpb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type SubscribeRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Subscriptions []*Subscription `protobuf:"bytes,1,rep,name=subscriptions,proto3" json:"subscriptions,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubscribeRequest) Reset() { + *x = SubscribeRequest{} + mi := &file_streams_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SubscribeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubscribeRequest) ProtoMessage() {} + +func (x *SubscribeRequest) ProtoReflect() protoreflect.Message { + mi := &file_streams_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubscribeRequest.ProtoReflect.Descriptor instead. +func (*SubscribeRequest) Descriptor() ([]byte, []int) { + return file_streams_proto_rawDescGZIP(), []int{0} +} + +func (x *SubscribeRequest) GetSubscriptions() []*Subscription { + if x != nil { + return x.Subscriptions + } + return nil +} + +type Subscription struct { + state protoimpl.MessageState `protogen:"open.v1"` + Data *structpb.Struct `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Subscription) Reset() { + *x = Subscription{} + mi := &file_streams_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Subscription) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Subscription) ProtoMessage() {} + +func (x *Subscription) ProtoReflect() protoreflect.Message { + mi := &file_streams_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Subscription.ProtoReflect.Descriptor instead. +func (*Subscription) Descriptor() ([]byte, []int) { + return file_streams_proto_rawDescGZIP(), []int{1} +} + +func (x *Subscription) GetData() *structpb.Struct { + if x != nil { + return x.Data + } + return nil +} + +type SubscribeResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Timestamp string `protobuf:"bytes,2,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + ObservationJson []byte `protobuf:"bytes,3,opt,name=observation_json,json=observationJson,proto3" json:"observation_json,omitempty"` + PayloadHash []byte `protobuf:"bytes,4,opt,name=payload_hash,json=payloadHash,proto3" json:"payload_hash,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubscribeResponse) Reset() { + *x = SubscribeResponse{} + mi := &file_streams_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SubscribeResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubscribeResponse) ProtoMessage() {} + +func (x *SubscribeResponse) ProtoReflect() protoreflect.Message { + mi := &file_streams_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubscribeResponse.ProtoReflect.Descriptor instead. +func (*SubscribeResponse) Descriptor() ([]byte, []int) { + return file_streams_proto_rawDescGZIP(), []int{2} +} + +func (x *SubscribeResponse) GetTimestamp() string { + if x != nil { + return x.Timestamp + } + return "" +} + +func (x *SubscribeResponse) GetObservationJson() []byte { + if x != nil { + return x.ObservationJson + } + return nil +} + +func (x *SubscribeResponse) GetPayloadHash() []byte { + if x != nil { + return x.PayloadHash + } + return nil +} + +var File_streams_proto protoreflect.FileDescriptor + +const file_streams_proto_rawDesc = "" + + "\n" + + "\rstreams.proto\x12\n" + + "streams.v1\x1a\x1cgoogle/protobuf/struct.proto\"R\n" + + "\x10SubscribeRequest\x12>\n" + + "\rsubscriptions\x18\x01 \x03(\v2\x18.streams.v1.SubscriptionR\rsubscriptions\";\n" + + "\fSubscription\x12+\n" + + "\x04data\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x04data\"\x7f\n" + + "\x11SubscribeResponse\x12\x1c\n" + + "\ttimestamp\x18\x02 \x01(\tR\ttimestamp\x12)\n" + + "\x10observation_json\x18\x03 \x01(\fR\x0fobservationJson\x12!\n" + + "\fpayload_hash\x18\x04 \x01(\fR\vpayloadHash2_\n" + + "\rStreamService\x12N\n" + + "\tSubscribe\x12\x1c.streams.v1.SubscribeRequest\x1a\x1d.streams.v1.SubscribeResponse\"\x00(\x010\x01BKZIgithub.com/smartcontractkit/chainlink/v2/core/services/pipeline/streamspbb\x06proto3" + +var ( + file_streams_proto_rawDescOnce sync.Once + file_streams_proto_rawDescData []byte +) + +func file_streams_proto_rawDescGZIP() []byte { + file_streams_proto_rawDescOnce.Do(func() { + file_streams_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_streams_proto_rawDesc), len(file_streams_proto_rawDesc))) + }) + return file_streams_proto_rawDescData +} + +var file_streams_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_streams_proto_goTypes = []any{ + (*SubscribeRequest)(nil), // 0: streams.v1.SubscribeRequest + (*Subscription)(nil), // 1: streams.v1.Subscription + (*SubscribeResponse)(nil), // 2: streams.v1.SubscribeResponse + (*structpb.Struct)(nil), // 3: google.protobuf.Struct +} +var file_streams_proto_depIdxs = []int32{ + 1, // 0: streams.v1.SubscribeRequest.subscriptions:type_name -> streams.v1.Subscription + 3, // 1: streams.v1.Subscription.data:type_name -> google.protobuf.Struct + 0, // 2: streams.v1.StreamService.Subscribe:input_type -> streams.v1.SubscribeRequest + 2, // 3: streams.v1.StreamService.Subscribe:output_type -> streams.v1.SubscribeResponse + 3, // [3:4] is the sub-list for method output_type + 2, // [2:3] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_streams_proto_init() } +func file_streams_proto_init() { + if File_streams_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_streams_proto_rawDesc), len(file_streams_proto_rawDesc)), + NumEnums: 0, + NumMessages: 3, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_streams_proto_goTypes, + DependencyIndexes: file_streams_proto_depIdxs, + MessageInfos: file_streams_proto_msgTypes, + }.Build() + File_streams_proto = out.File + file_streams_proto_goTypes = nil + file_streams_proto_depIdxs = nil +} diff --git a/core/services/pipeline/streamspb/streams.proto b/core/services/pipeline/streamspb/streams.proto new file mode 100644 index 00000000000..fde33fff6eb --- /dev/null +++ b/core/services/pipeline/streamspb/streams.proto @@ -0,0 +1,26 @@ +syntax = "proto3"; + +package streams.v1; + +import "google/protobuf/struct.proto"; + +option go_package = "github.com/smartcontractkit/chainlink/v2/core/services/pipeline/streamspb"; + +service StreamService { + // Each client request replaces the complete subscription set. + rpc Subscribe(stream SubscribeRequest) returns (stream SubscribeResponse) {} +} + +message SubscribeRequest { + repeated Subscription subscriptions = 1; +} + +message Subscription { + google.protobuf.Struct data = 1; +} + +message SubscribeResponse { + string timestamp = 2; + bytes observation_json = 3; + bytes payload_hash = 4; +} diff --git a/core/services/pipeline/streamspb/streams_grpc.pb.go b/core/services/pipeline/streamspb/streams_grpc.pb.go new file mode 100644 index 00000000000..3c1a3706ef1 --- /dev/null +++ b/core/services/pipeline/streamspb/streams_grpc.pb.go @@ -0,0 +1,117 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.2 +// - protoc v5.29.3 +// source: streams.proto + +package streamspb + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + StreamService_Subscribe_FullMethodName = "/streams.v1.StreamService/Subscribe" +) + +// StreamServiceClient is the client API for StreamService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type StreamServiceClient interface { + // Each client request replaces the complete subscription set. + Subscribe(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[SubscribeRequest, SubscribeResponse], error) +} + +type streamServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewStreamServiceClient(cc grpc.ClientConnInterface) StreamServiceClient { + return &streamServiceClient{cc} +} + +func (c *streamServiceClient) Subscribe(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[SubscribeRequest, SubscribeResponse], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &StreamService_ServiceDesc.Streams[0], StreamService_Subscribe_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[SubscribeRequest, SubscribeResponse]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StreamService_SubscribeClient = grpc.BidiStreamingClient[SubscribeRequest, SubscribeResponse] + +// StreamServiceServer is the server API for StreamService service. +// All implementations must embed UnimplementedStreamServiceServer +// for forward compatibility. +type StreamServiceServer interface { + // Each client request replaces the complete subscription set. + Subscribe(grpc.BidiStreamingServer[SubscribeRequest, SubscribeResponse]) error + mustEmbedUnimplementedStreamServiceServer() +} + +// UnimplementedStreamServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedStreamServiceServer struct{} + +func (UnimplementedStreamServiceServer) Subscribe(grpc.BidiStreamingServer[SubscribeRequest, SubscribeResponse]) error { + return status.Error(codes.Unimplemented, "method Subscribe not implemented") +} +func (UnimplementedStreamServiceServer) mustEmbedUnimplementedStreamServiceServer() {} +func (UnimplementedStreamServiceServer) testEmbeddedByValue() {} + +// UnsafeStreamServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to StreamServiceServer will +// result in compilation errors. +type UnsafeStreamServiceServer interface { + mustEmbedUnimplementedStreamServiceServer() +} + +func RegisterStreamServiceServer(s grpc.ServiceRegistrar, srv StreamServiceServer) { + // If the following call panics, it indicates UnimplementedStreamServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&StreamService_ServiceDesc, srv) +} + +func _StreamService_Subscribe_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(StreamServiceServer).Subscribe(&grpc.GenericServerStream[SubscribeRequest, SubscribeResponse]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StreamService_SubscribeServer = grpc.BidiStreamingServer[SubscribeRequest, SubscribeResponse] + +// StreamService_ServiceDesc is the grpc.ServiceDesc for StreamService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var StreamService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "streams.v1.StreamService", + HandlerType: (*StreamServiceServer)(nil), + Methods: []grpc.MethodDesc{}, + Streams: []grpc.StreamDesc{ + { + StreamName: "Subscribe", + Handler: _StreamService_Subscribe_Handler, + ServerStreams: true, + ClientStreams: true, + }, + }, + Metadata: "streams.proto", +} diff --git a/core/services/pipeline/task.bridge_test.go b/core/services/pipeline/task.bridge_test.go index 557346cc7f3..462a1460b2b 100644 --- a/core/services/pipeline/task.bridge_test.go +++ b/core/services/pipeline/task.bridge_test.go @@ -294,8 +294,13 @@ func TestBridgeTask_UsesBridgeConnManagerHappyPath(t *testing.T) { manager := pipeline.NewBridgeConnManager() seedable, ok := manager.(interface { PutObservation(bridge bridges.BridgeType, requestData pipeline.MapParam, observation []byte) error + DisableEAConnDialingForTest() }) require.True(t, ok) + // This test seeds the cache directly and asserts no HTTP calls are made; the + // bridge URL points at a plain httptest server, not a streams-adapter, so real + // EAConn dialing must be disabled to avoid flaky cross-protocol traffic. + seedable.DisableEAConnDialingForTest() require.NoError(t, seedable.PutObservation(*bridge, pipeline.MapParam(utils.MustUnmarshalToMap(btcUSDPairing)), []byte(`{"data":{"result":"9700"}}`))) task := pipeline.BridgeTask{ From e729a5956418c7fd65bb81010ed4641b804bb765 Mon Sep 17 00:00:00 2001 From: Denis CHernov Date: Thu, 23 Jul 2026 23:04:43 -0700 Subject: [PATCH 05/16] removes bridge- prefix from adapter name --- core/services/pipeline/bridge_conn_manager.go | 30 ++++++++++--------- .../pipeline/bridge_conn_manager_test.go | 10 +++---- core/services/pipeline/eaconn.go | 11 ++++--- 3 files changed, 26 insertions(+), 25 deletions(-) diff --git a/core/services/pipeline/bridge_conn_manager.go b/core/services/pipeline/bridge_conn_manager.go index 4731f12b3e6..c505bf87f7a 100644 --- a/core/services/pipeline/bridge_conn_manager.go +++ b/core/services/pipeline/bridge_conn_manager.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" stdErrors "errors" "fmt" + "strings" "sync" "github.com/goccy/go-json" @@ -12,6 +13,7 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/logger" "github.com/smartcontractkit/chainlink/v2/core/bridges" + "github.com/smartcontractkit/chainlink/v2/core/store/models" ) type BridgeConnManager interface { @@ -60,25 +62,27 @@ func NewBridgeConnManager(lggr ...logger.Logger) BridgeConnManager { } func (m *bridgeConnManager) GetObservation(bridge bridges.BridgeType, requestData MapParam) ([]byte, error) { + bridgeName := strings.TrimPrefix(bridge.Name.String(), "bridge-") data, err := subscriptionData(requestData) if err != nil { - return nil, fmt.Errorf("bridge %q: %w", bridge.Name.String(), err) + return nil, fmt.Errorf("bridge %q: %w", bridgeName, err) } - key, err := bridgeObservationCacheKey(bridge, data) + key, err := bridgeObservationCacheKey(bridgeName, data) if err != nil { return nil, err } + m.lggr.Debugw("cache key generated", "key", fmt.Sprintf("%x", key), "bridge", bridgeName, "data", data) subscription, err := structpb.NewStruct(data) if err != nil { - return nil, fmt.Errorf("failed to build subscription payload for bridge %q: %w", bridge.Name.String(), err) + return nil, fmt.Errorf("failed to build subscription payload for bridge %q: %w", bridgeName, err) } - m.getOrCreateConn(bridge).registerAsset(key, subscription) + m.getOrCreateConn(bridgeName, bridge.URL).registerAsset(key, subscription) m.mu.RLock() entry, ok := m.cache[key] m.mu.RUnlock() if !ok { - return nil, fmt.Errorf("%w for bridge %q", ErrBridgeObservationNotFound, bridge.Name.String()) + return nil, fmt.Errorf("%w for bridge %q", ErrBridgeObservationNotFound, bridgeName) } payload := make([]byte, len(entry)) copy(payload, entry) @@ -97,17 +101,16 @@ func (m *bridgeConnManager) PutObservation(key [32]byte, observation []byte) { // getOrCreateConn returns the bridge's persistent EAConn, lazily creating and // starting it on first use. -func (m *bridgeConnManager) getOrCreateConn(bridge bridges.BridgeType) *eaConn { - name := bridge.Name.String() +func (m *bridgeConnManager) getOrCreateConn(bridgeName string, bridgeURL models.WebURL) *eaConn { m.connsMu.Lock() defer m.connsMu.Unlock() - if conn, ok := m.conns[name]; ok { + if conn, ok := m.conns[bridgeName]; ok { return conn } - conn := newEAConn(bridge, m) - m.conns[name] = conn + conn := newEAConn(bridgeName, bridgeURL, m) + m.conns[bridgeName] = conn conn.start() return conn } @@ -140,14 +143,13 @@ func subscriptionData(requestData MapParam) (map[string]any, error) { // bridgeObservationCacheKey mirrors the streams-adapter's own ObservationPayloadHash. // The adapter is configured with its own adapterName equal to this bridge's name, // so payload_hash on an accepted observation equals this same key. -func bridgeObservationCacheKey(bridge bridges.BridgeType, data map[string]any) ([32]byte, error) { +func bridgeObservationCacheKey(bridgeName string, data map[string]any) ([32]byte, error) { lookupBytes, err := json.Marshal(data) if err != nil { return [32]byte{}, fmt.Errorf("failed to marshal bridge lookup payload: %w", err) } - name := bridge.Name.String() - b := make([]byte, 0, len(name)+len(lookupBytes)) - b = append(b, name...) + b := make([]byte, 0, len(bridgeName)+len(lookupBytes)) + b = append(b, bridgeName...) b = append(b, lookupBytes...) return sha256.Sum256(b), nil } diff --git a/core/services/pipeline/bridge_conn_manager_test.go b/core/services/pipeline/bridge_conn_manager_test.go index 1c86a8acf47..09bb069d1b8 100644 --- a/core/services/pipeline/bridge_conn_manager_test.go +++ b/core/services/pipeline/bridge_conn_manager_test.go @@ -54,11 +54,11 @@ func TestBridgeConnManager_GetOrCreateConn_OnePerBridge(t *testing.T) { wg.Add(2) go func() { defer wg.Done() - conns[idx] = m.getOrCreateConn(bridgeA) + conns[idx] = m.getOrCreateConn(bridgeA.Name.String(), bridgeA.URL) }() go func() { defer wg.Done() - conns[idx+10] = m.getOrCreateConn(bridgeB) + conns[idx+10] = m.getOrCreateConn(bridgeB.Name.String(), bridgeB.URL) }() } wg.Wait() @@ -80,7 +80,7 @@ func TestEAConn_RegisterAsset_RefreshAndIdlePrune(t *testing.T) { t.Parallel() m := newTestManager() bridge := testBridge(t, "idlebridge") - conn := newEAConn(bridge, m) + conn := newEAConn(bridge.Name.String(), bridge.URL, m) clock := clockwork.NewFakeClock() conn.clock = clock @@ -116,7 +116,7 @@ func TestEAConn_HandleObservation(t *testing.T) { t.Parallel() m := newTestManager() bridge := testBridge(t, "obsbridge") - conn := newEAConn(bridge, m) + conn := newEAConn(bridge.Name.String(), bridge.URL, m) payload, err := structpb.NewStruct(map[string]any{"endpoint": "crypto"}) require.NoError(t, err) @@ -186,7 +186,7 @@ func TestBridgeConnManager_GetObservation_CacheHitAndMiss(t *testing.T) { _, err := m.GetObservation(bridge, requestData) require.ErrorIs(t, err, ErrBridgeObservationNotFound) - key, err := bridgeObservationCacheKey(bridge, requestData["data"].(map[string]any)) + key, err := bridgeObservationCacheKey(bridge.Name.String(), requestData["data"].(map[string]any)) require.NoError(t, err) m.PutObservation(key, []byte(`{"result":"9700"}`)) diff --git a/core/services/pipeline/eaconn.go b/core/services/pipeline/eaconn.go index 342c0851873..47da60bd066 100644 --- a/core/services/pipeline/eaconn.go +++ b/core/services/pipeline/eaconn.go @@ -13,8 +13,8 @@ import ( "google.golang.org/protobuf/types/known/structpb" "github.com/smartcontractkit/chainlink-common/pkg/logger" - "github.com/smartcontractkit/chainlink/v2/core/bridges" "github.com/smartcontractkit/chainlink/v2/core/services/pipeline/streamspb" + "github.com/smartcontractkit/chainlink/v2/core/store/models" ) // Operational timing and reconnect behavior are hardcoded: this manager has no @@ -75,13 +75,12 @@ type eaConn struct { // newEAConn is called with manager.connsMu already held, so reading manager.lggr // here is safe without a separate lock. -func newEAConn(bridge bridges.BridgeType, manager *bridgeConnManager) *eaConn { - name := bridge.Name.String() +func newEAConn(bridgeName string, bridgeUrl models.WebURL, manager *bridgeConnManager) *eaConn { return &eaConn{ - bridgeName: name, - target: url.URL(bridge.URL).Host, + bridgeName: bridgeName, + target: url.URL(bridgeUrl).Host, dial: manager.dial, - lggr: logger.With(logger.Named(manager.lggr, "EAConn"), "bridgeName", name), + lggr: logger.With(logger.Named(manager.lggr, "EAConn"), "bridgeName", bridgeName), manager: manager, clock: clockwork.NewRealClock(), assets: make(map[[32]byte]*eaAsset), From af02729276e2520a5d20a3dfbffa6b15f236ee7e Mon Sep 17 00:00:00 2001 From: Denis CHernov Date: Fri, 24 Jul 2026 15:30:12 -0700 Subject: [PATCH 06/16] move changes into bridgeconn package --- .../{ => bridgeconn}/bridge_conn_manager.go | 30 ++++++++++++++----- .../bridge_conn_manager_test.go | 11 +++---- .../pipeline/{ => bridgeconn}/eaconn.go | 4 +-- .../{ => bridgeconn}/grpc_stream_client.go | 4 +-- .../{ => bridgeconn}/streamspb/generate.go | 0 .../{ => bridgeconn}/streamspb/streams.pb.go | 4 +-- .../{ => bridgeconn}/streamspb/streams.proto | 2 +- .../streamspb/streams_grpc.pb.go | 2 +- core/services/pipeline/helpers_test.go | 3 +- core/services/pipeline/runner.go | 5 ++-- core/services/pipeline/task.bridge.go | 7 +++-- core/services/pipeline/task.bridge_test.go | 7 +++-- 12 files changed, 49 insertions(+), 30 deletions(-) rename core/services/pipeline/{ => bridgeconn}/bridge_conn_manager.go (83%) rename core/services/pipeline/{ => bridgeconn}/bridge_conn_manager_test.go (96%) rename core/services/pipeline/{ => bridgeconn}/eaconn.go (99%) rename core/services/pipeline/{ => bridgeconn}/grpc_stream_client.go (96%) rename core/services/pipeline/{ => bridgeconn}/streamspb/generate.go (100%) rename core/services/pipeline/{ => bridgeconn}/streamspb/streams.pb.go (97%) rename core/services/pipeline/{ => bridgeconn}/streamspb/streams.proto (92%) rename core/services/pipeline/{ => bridgeconn}/streamspb/streams_grpc.pb.go (99%) diff --git a/core/services/pipeline/bridge_conn_manager.go b/core/services/pipeline/bridgeconn/bridge_conn_manager.go similarity index 83% rename from core/services/pipeline/bridge_conn_manager.go rename to core/services/pipeline/bridgeconn/bridge_conn_manager.go index c505bf87f7a..fcd3ab5025e 100644 --- a/core/services/pipeline/bridge_conn_manager.go +++ b/core/services/pipeline/bridgeconn/bridge_conn_manager.go @@ -1,4 +1,4 @@ -package pipeline +package bridgeconn import ( "context" @@ -17,8 +17,7 @@ import ( ) type BridgeConnManager interface { - GetObservation(bridge bridges.BridgeType, requestData MapParam) ([]byte, error) - PutObservation(key [32]byte, observation []byte) + GetObservation(bridge bridges.BridgeType, requestData map[string]any) ([]byte, error) } var ( @@ -61,7 +60,7 @@ func NewBridgeConnManager(lggr ...logger.Logger) BridgeConnManager { return m } -func (m *bridgeConnManager) GetObservation(bridge bridges.BridgeType, requestData MapParam) ([]byte, error) { +func (m *bridgeConnManager) GetObservation(bridge bridges.BridgeType, requestData map[string]any) ([]byte, error) { bridgeName := strings.TrimPrefix(bridge.Name.String(), "bridge-") data, err := subscriptionData(requestData) if err != nil { @@ -89,8 +88,8 @@ func (m *bridgeConnManager) GetObservation(bridge bridges.BridgeType, requestDat return payload, nil } -// putObservation is called by an EAConn's receiver loop when it accepts a -// streamed observation for a currently registered asset. +// PutObservation stores observation bytes under the given payload hash key. +// It is used by EAConn receiver loops and white-box tests in this package. func (m *bridgeConnManager) PutObservation(key [32]byte, observation []byte) { payload := make([]byte, len(observation)) copy(payload, observation) @@ -99,6 +98,21 @@ func (m *bridgeConnManager) PutObservation(key [32]byte, observation []byte) { m.cache[key] = payload } +// SeedObservation computes the bridge observation key from request data and +// stores a cache entry. This is intended for tests. +func (m *bridgeConnManager) SeedObservation(bridge bridges.BridgeType, requestData map[string]any, observation []byte) error { + data, err := subscriptionData(requestData) + if err != nil { + return err + } + key, err := bridgeObservationCacheKey(strings.TrimPrefix(bridge.Name.String(), "bridge-"), data) + if err != nil { + return err + } + m.PutObservation(key, observation) + return nil +} + // getOrCreateConn returns the bridge's persistent EAConn, lazily creating and // starting it on first use. func (m *bridgeConnManager) getOrCreateConn(bridgeName string, bridgeURL models.WebURL) *eaConn { @@ -130,9 +144,9 @@ func (m *bridgeConnManager) DisableEAConnDialingForTest() { } // subscriptionData extracts the inner "data" object from a bridge task's request -// payload — the only part sent as Subscription.Data and the only part the adapter +// payload: the only part sent as Subscription.Data and the only part the adapter // hashes (see ObservationPayloadHash on the streams-adapter side). -func subscriptionData(requestData MapParam) (map[string]any, error) { +func subscriptionData(requestData map[string]any) (map[string]any, error) { data, ok := requestData["data"].(map[string]any) if !ok || len(data) == 0 { return nil, fmt.Errorf("request data is missing a non-empty \"data\" field required for subscription") diff --git a/core/services/pipeline/bridge_conn_manager_test.go b/core/services/pipeline/bridgeconn/bridge_conn_manager_test.go similarity index 96% rename from core/services/pipeline/bridge_conn_manager_test.go rename to core/services/pipeline/bridgeconn/bridge_conn_manager_test.go index 09bb069d1b8..916e4493dd4 100644 --- a/core/services/pipeline/bridge_conn_manager_test.go +++ b/core/services/pipeline/bridgeconn/bridge_conn_manager_test.go @@ -1,4 +1,4 @@ -package pipeline +package bridgeconn import ( "context" @@ -16,7 +16,7 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/logger" "github.com/smartcontractkit/chainlink/v2/core/bridges" - "github.com/smartcontractkit/chainlink/v2/core/services/pipeline/streamspb" + "github.com/smartcontractkit/chainlink/v2/core/services/pipeline/bridgeconn/streamspb" "github.com/smartcontractkit/chainlink/v2/core/store/models" ) @@ -181,7 +181,7 @@ func TestBridgeConnManager_GetObservation_CacheHitAndMiss(t *testing.T) { t.Parallel() m := newTestManager() bridge := testBridge(t, "cachebridge") - requestData := MapParam{"data": map[string]any{"endpoint": "crypto"}} + requestData := map[string]any{"data": map[string]any{"endpoint": "crypto"}} _, err := m.GetObservation(bridge, requestData) require.ErrorIs(t, err, ErrBridgeObservationNotFound) @@ -204,14 +204,15 @@ func TestBridgeConnManager_GetObservation_CacheHitAndMiss(t *testing.T) { conn.mu.Unlock() assert.True(t, registered) } + func TestBridgeConnManager_GetObservation_MissingDataField(t *testing.T) { t.Parallel() m := newTestManager() bridge := testBridge(t, "nodatabridge") - _, err := m.GetObservation(bridge, MapParam{"foo": "bar"}) + _, err := m.GetObservation(bridge, map[string]any{"foo": "bar"}) assert.Error(t, err, "requestData without a \"data\" field must be rejected, not silently subscribed") - _, err = m.GetObservation(bridge, MapParam{"data": map[string]any{}}) + _, err = m.GetObservation(bridge, map[string]any{"data": map[string]any{}}) assert.Error(t, err, "an empty \"data\" field must be rejected") } diff --git a/core/services/pipeline/eaconn.go b/core/services/pipeline/bridgeconn/eaconn.go similarity index 99% rename from core/services/pipeline/eaconn.go rename to core/services/pipeline/bridgeconn/eaconn.go index 47da60bd066..b28f448a9f7 100644 --- a/core/services/pipeline/eaconn.go +++ b/core/services/pipeline/bridgeconn/eaconn.go @@ -1,4 +1,4 @@ -package pipeline +package bridgeconn import ( "context" @@ -13,7 +13,7 @@ import ( "google.golang.org/protobuf/types/known/structpb" "github.com/smartcontractkit/chainlink-common/pkg/logger" - "github.com/smartcontractkit/chainlink/v2/core/services/pipeline/streamspb" + "github.com/smartcontractkit/chainlink/v2/core/services/pipeline/bridgeconn/streamspb" "github.com/smartcontractkit/chainlink/v2/core/store/models" ) diff --git a/core/services/pipeline/grpc_stream_client.go b/core/services/pipeline/bridgeconn/grpc_stream_client.go similarity index 96% rename from core/services/pipeline/grpc_stream_client.go rename to core/services/pipeline/bridgeconn/grpc_stream_client.go index d841de531a7..7fbdbf6687e 100644 --- a/core/services/pipeline/grpc_stream_client.go +++ b/core/services/pipeline/bridgeconn/grpc_stream_client.go @@ -1,4 +1,4 @@ -package pipeline +package bridgeconn import ( "context" @@ -6,7 +6,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" - "github.com/smartcontractkit/chainlink/v2/core/services/pipeline/streamspb" + "github.com/smartcontractkit/chainlink/v2/core/services/pipeline/bridgeconn/streamspb" ) // grpcStreamClient adapts streamspb's generated Subscribe stream to eaStreamClient. diff --git a/core/services/pipeline/streamspb/generate.go b/core/services/pipeline/bridgeconn/streamspb/generate.go similarity index 100% rename from core/services/pipeline/streamspb/generate.go rename to core/services/pipeline/bridgeconn/streamspb/generate.go diff --git a/core/services/pipeline/streamspb/streams.pb.go b/core/services/pipeline/bridgeconn/streamspb/streams.pb.go similarity index 97% rename from core/services/pipeline/streamspb/streams.pb.go rename to core/services/pipeline/bridgeconn/streamspb/streams.pb.go index 5a349cd3a85..ae3599d2951 100644 --- a/core/services/pipeline/streamspb/streams.pb.go +++ b/core/services/pipeline/bridgeconn/streamspb/streams.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v5.29.3 +// protoc v7.35.1 // source: streams.proto package streamspb @@ -185,7 +185,7 @@ const file_streams_proto_rawDesc = "" + "\x10observation_json\x18\x03 \x01(\fR\x0fobservationJson\x12!\n" + "\fpayload_hash\x18\x04 \x01(\fR\vpayloadHash2_\n" + "\rStreamService\x12N\n" + - "\tSubscribe\x12\x1c.streams.v1.SubscribeRequest\x1a\x1d.streams.v1.SubscribeResponse\"\x00(\x010\x01BKZIgithub.com/smartcontractkit/chainlink/v2/core/services/pipeline/streamspbb\x06proto3" + "\tSubscribe\x12\x1c.streams.v1.SubscribeRequest\x1a\x1d.streams.v1.SubscribeResponse\"\x00(\x010\x01BVZTgithub.com/smartcontractkit/chainlink/v2/core/services/pipeline/bridgeconn/streamspbb\x06proto3" var ( file_streams_proto_rawDescOnce sync.Once diff --git a/core/services/pipeline/streamspb/streams.proto b/core/services/pipeline/bridgeconn/streamspb/streams.proto similarity index 92% rename from core/services/pipeline/streamspb/streams.proto rename to core/services/pipeline/bridgeconn/streamspb/streams.proto index fde33fff6eb..d25683b9988 100644 --- a/core/services/pipeline/streamspb/streams.proto +++ b/core/services/pipeline/bridgeconn/streamspb/streams.proto @@ -4,7 +4,7 @@ package streams.v1; import "google/protobuf/struct.proto"; -option go_package = "github.com/smartcontractkit/chainlink/v2/core/services/pipeline/streamspb"; +option go_package = "github.com/smartcontractkit/chainlink/v2/core/services/pipeline/bridgeconn/streamspb"; service StreamService { // Each client request replaces the complete subscription set. diff --git a/core/services/pipeline/streamspb/streams_grpc.pb.go b/core/services/pipeline/bridgeconn/streamspb/streams_grpc.pb.go similarity index 99% rename from core/services/pipeline/streamspb/streams_grpc.pb.go rename to core/services/pipeline/bridgeconn/streamspb/streams_grpc.pb.go index 3c1a3706ef1..3528b732fc5 100644 --- a/core/services/pipeline/streamspb/streams_grpc.pb.go +++ b/core/services/pipeline/bridgeconn/streamspb/streams_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.2 -// - protoc v5.29.3 +// - protoc v7.35.1 // source: streams.proto package streamspb diff --git a/core/services/pipeline/helpers_test.go b/core/services/pipeline/helpers_test.go index 4517d8c6c9a..c80b33135a4 100644 --- a/core/services/pipeline/helpers_test.go +++ b/core/services/pipeline/helpers_test.go @@ -8,6 +8,7 @@ import ( "github.com/smartcontractkit/chainlink-evm/pkg/chains/legacyevm" "github.com/smartcontractkit/chainlink/v2/core/bridges" + "github.com/smartcontractkit/chainlink/v2/core/services/pipeline/bridgeconn" ) const ( @@ -45,7 +46,7 @@ func (t *BridgeTask) HelperSetDependencies( t.specId = specId } -func (t *BridgeTask) HelperSetBridgeConnManager(bridgeConnManager BridgeConnManager) { +func (t *BridgeTask) HelperSetBridgeConnManager(bridgeConnManager bridgeconn.BridgeConnManager) { t.bridgeConnManager = bridgeConnManager } diff --git a/core/services/pipeline/runner.go b/core/services/pipeline/runner.go index f25712cbb86..243696230a1 100644 --- a/core/services/pipeline/runner.go +++ b/core/services/pipeline/runner.go @@ -27,6 +27,7 @@ import ( "github.com/smartcontractkit/chainlink/v2/core/config/env" "github.com/smartcontractkit/chainlink/v2/core/logger" "github.com/smartcontractkit/chainlink/v2/core/recovery" + "github.com/smartcontractkit/chainlink/v2/core/services/pipeline/bridgeconn" ) type Runner interface { @@ -69,7 +70,7 @@ type runner struct { lggr logger.Logger httpClient *http.Client unrestrictedHTTPClient *http.Client - bridgeConnManager BridgeConnManager + bridgeConnManager bridgeconn.BridgeConnManager // test helper runFinished func(*Run) @@ -135,7 +136,7 @@ func NewRunner( lggr: lggr, httpClient: httpClient, unrestrictedHTTPClient: unrestrictedHTTPClient, - bridgeConnManager: NewBridgeConnManager(lggr), + bridgeConnManager: bridgeconn.NewBridgeConnManager(lggr), } r.runReaperWorker = commonutils.NewSleeperTask( diff --git a/core/services/pipeline/task.bridge.go b/core/services/pipeline/task.bridge.go index fb77baa2012..3f794456b15 100644 --- a/core/services/pipeline/task.bridge.go +++ b/core/services/pipeline/task.bridge.go @@ -24,6 +24,7 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/logger" "github.com/smartcontractkit/chainlink/v2/core/bridges" + "github.com/smartcontractkit/chainlink/v2/core/services/pipeline/bridgeconn" "github.com/smartcontractkit/chainlink/v2/core/services/pipeline/eautils" ) @@ -90,7 +91,7 @@ type BridgeTask struct { config Config bridgeConfig BridgeConfig httpClient *http.Client - bridgeConnManager BridgeConnManager + bridgeConnManager bridgeconn.BridgeConnManager // requiredJSONPaths is populated in runner.InitializePipeline from strict // downstream jsonparse tasks. When CheckRequired is true and cacheTTL is set, @@ -176,9 +177,9 @@ func (t *BridgeTask) Run(ctx context.Context, lggr logger.Logger, vars Vars, inp if bridge.UseConnectionManager { bridgeConnManager := t.bridgeConnManager if bridgeConnManager == nil { - bridgeConnManager = NewBridgeConnManager() + bridgeConnManager = bridgeconn.NewBridgeConnManager() } - responseBytes, err := bridgeConnManager.GetObservation(bridge, lookupPayload) + responseBytes, err := bridgeConnManager.GetObservation(bridge, map[string]any(lookupPayload)) if err != nil { lggr.Debugw("Bridge task: connection manager request failed", "response", string(responseBytes), diff --git a/core/services/pipeline/task.bridge_test.go b/core/services/pipeline/task.bridge_test.go index 462a1460b2b..1960391348f 100644 --- a/core/services/pipeline/task.bridge_test.go +++ b/core/services/pipeline/task.bridge_test.go @@ -34,6 +34,7 @@ import ( "github.com/smartcontractkit/chainlink/v2/core/logger" "github.com/smartcontractkit/chainlink/v2/core/services/chainlink" "github.com/smartcontractkit/chainlink/v2/core/services/pipeline" + "github.com/smartcontractkit/chainlink/v2/core/services/pipeline/bridgeconn" "github.com/smartcontractkit/chainlink/v2/core/services/pipeline/eautils" "github.com/smartcontractkit/chainlink/v2/core/store/models" "github.com/smartcontractkit/chainlink/v2/core/utils" @@ -291,9 +292,9 @@ func TestBridgeTask_UsesBridgeConnManagerHappyPath(t *testing.T) { UseConnectionManager: true, }) - manager := pipeline.NewBridgeConnManager() + manager := bridgeconn.NewBridgeConnManager() seedable, ok := manager.(interface { - PutObservation(bridge bridges.BridgeType, requestData pipeline.MapParam, observation []byte) error + SeedObservation(bridge bridges.BridgeType, requestData map[string]any, observation []byte) error DisableEAConnDialingForTest() }) require.True(t, ok) @@ -301,7 +302,7 @@ func TestBridgeTask_UsesBridgeConnManagerHappyPath(t *testing.T) { // bridge URL points at a plain httptest server, not a streams-adapter, so real // EAConn dialing must be disabled to avoid flaky cross-protocol traffic. seedable.DisableEAConnDialingForTest() - require.NoError(t, seedable.PutObservation(*bridge, pipeline.MapParam(utils.MustUnmarshalToMap(btcUSDPairing)), []byte(`{"data":{"result":"9700"}}`))) + require.NoError(t, seedable.SeedObservation(*bridge, utils.MustUnmarshalToMap(btcUSDPairing), []byte(`{"data":{"result":"9700"}}`))) task := pipeline.BridgeTask{ BaseTask: pipeline.NewBaseTask(0, "bridge", nil, nil, 0), From 790817e710244c90a6a8611e136133b8ea331581 Mon Sep 17 00:00:00 2001 From: Denis CHernov Date: Sat, 25 Jul 2026 20:32:32 -0700 Subject: [PATCH 07/16] TLS support for gRPC client --- .../pipeline/bridgeconn/bridge_conn_manager.go | 2 +- .../bridgeconn/bridge_conn_manager_test.go | 2 +- core/services/pipeline/bridgeconn/eaconn.go | 12 ++++++++---- .../pipeline/bridgeconn/grpc_stream_client.go | 16 +++++++++++----- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/core/services/pipeline/bridgeconn/bridge_conn_manager.go b/core/services/pipeline/bridgeconn/bridge_conn_manager.go index fcd3ab5025e..05705804a3b 100644 --- a/core/services/pipeline/bridgeconn/bridge_conn_manager.go +++ b/core/services/pipeline/bridgeconn/bridge_conn_manager.go @@ -138,7 +138,7 @@ var errStreamDialingDisabledForTest = stdErrors.New("EAConn stream dialing disab func (m *bridgeConnManager) DisableEAConnDialingForTest() { m.connsMu.Lock() defer m.connsMu.Unlock() - m.dial = func(_ context.Context, _ string) (eaStreamClient, error) { + m.dial = func(_ context.Context, _ string, _ bool) (eaStreamClient, error) { return nil, errStreamDialingDisabledForTest } } diff --git a/core/services/pipeline/bridgeconn/bridge_conn_manager_test.go b/core/services/pipeline/bridgeconn/bridge_conn_manager_test.go index 916e4493dd4..c7a4e7aff22 100644 --- a/core/services/pipeline/bridgeconn/bridge_conn_manager_test.go +++ b/core/services/pipeline/bridgeconn/bridge_conn_manager_test.go @@ -36,7 +36,7 @@ func newTestManager() *bridgeConnManager { cache: make(map[[32]byte][]byte), conns: make(map[string]*eaConn), lggr: logger.Nop(), - dial: func(_ context.Context, _ string) (eaStreamClient, error) { + dial: func(_ context.Context, _ string, _ bool) (eaStreamClient, error) { return nil, errStreamDialingDisabledForTest }, } diff --git a/core/services/pipeline/bridgeconn/eaconn.go b/core/services/pipeline/bridgeconn/eaconn.go index b28f448a9f7..38dea00a72b 100644 --- a/core/services/pipeline/bridgeconn/eaconn.go +++ b/core/services/pipeline/bridgeconn/eaconn.go @@ -45,8 +45,9 @@ type eaStreamClient interface { } // eaStreamDialer opens a new stream to target (host:port derived from the bridge's -// URL authority). All connections are plaintext gRPC regardless of bridge URL scheme. -type eaStreamDialer func(ctx context.Context, target string) (eaStreamClient, error) +// URL authority). useTLS selects TLS (bridge URL scheme https) versus plaintext gRPC +// (http). +type eaStreamDialer func(ctx context.Context, target string, useTLS bool) (eaStreamClient, error) // eaAsset is a bridge's registered asset: an immutable subscription payload plus the // timestamp it was last requested through GetObservation. @@ -62,6 +63,7 @@ type eaAsset struct { type eaConn struct { bridgeName string target string + useTLS bool dial eaStreamDialer lggr logger.Logger manager *bridgeConnManager @@ -76,9 +78,11 @@ type eaConn struct { // newEAConn is called with manager.connsMu already held, so reading manager.lggr // here is safe without a separate lock. func newEAConn(bridgeName string, bridgeUrl models.WebURL, manager *bridgeConnManager) *eaConn { + u := url.URL(bridgeUrl) return &eaConn{ bridgeName: bridgeName, - target: url.URL(bridgeUrl).Host, + target: u.Host, + useTLS: u.Scheme == "https", dial: manager.dial, lggr: logger.With(logger.Named(manager.lggr, "EAConn"), "bridgeName", bridgeName), manager: manager, @@ -131,7 +135,7 @@ func (c *eaConn) pruneAndSnapshot() (map[[32]byte]struct{}, *streamspb.Subscribe func (c *eaConn) run(ctx context.Context) { backoff := reconnectBackoffInitial for { - stream, err := c.dial(ctx, c.target) + stream, err := c.dial(ctx, c.target, c.useTLS) if err != nil { c.lggr.Errorw("EAConn: dial failed", "target", c.target, "err", err) backoff = c.sleepBackoff(backoff) diff --git a/core/services/pipeline/bridgeconn/grpc_stream_client.go b/core/services/pipeline/bridgeconn/grpc_stream_client.go index 7fbdbf6687e..f3aed78a4c7 100644 --- a/core/services/pipeline/bridgeconn/grpc_stream_client.go +++ b/core/services/pipeline/bridgeconn/grpc_stream_client.go @@ -4,6 +4,7 @@ import ( "context" "google.golang.org/grpc" + "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" "github.com/smartcontractkit/chainlink/v2/core/services/pipeline/bridgeconn/streamspb" @@ -15,11 +16,16 @@ type grpcStreamClient struct { stream streamspb.StreamService_SubscribeClient } -// dialGRPCStream opens a plaintext gRPC connection to target and starts the -// bidirectional Subscribe stream. All EAConn connections are plaintext regardless -// of the bridge URL scheme: no TLS, no application tokens, no client certificates. -func dialGRPCStream(ctx context.Context, target string) (eaStreamClient, error) { - conn, err := grpc.NewClient(target, grpc.WithTransportCredentials(insecure.NewCredentials())) +// dialGRPCStream opens a gRPC connection to target and starts the bidirectional +// Subscribe stream. When useTLS is true (bridge URL scheme is https) the connection +// uses TLS with the host's system root CAs; otherwise it is plaintext. Neither mode +// uses application tokens or client certificates. +func dialGRPCStream(ctx context.Context, target string, useTLS bool) (eaStreamClient, error) { + creds := insecure.NewCredentials() + if useTLS { + creds = credentials.NewClientTLSFromCert(nil, "") + } + conn, err := grpc.NewClient(target, grpc.WithTransportCredentials(creds)) if err != nil { return nil, err } From 1576020d15728a8d1633fbea2b08bedfa8345544 Mon Sep 17 00:00:00 2001 From: Denis CHernov Date: Sat, 25 Jul 2026 20:41:58 -0700 Subject: [PATCH 08/16] fixed migrations number collision --- ...es.sql => 0302_add_use_connection_manager_to_bridge_types.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename core/store/migrate/migrations/{0301_add_use_connection_manager_to_bridge_types.sql => 0302_add_use_connection_manager_to_bridge_types.sql} (100%) diff --git a/core/store/migrate/migrations/0301_add_use_connection_manager_to_bridge_types.sql b/core/store/migrate/migrations/0302_add_use_connection_manager_to_bridge_types.sql similarity index 100% rename from core/store/migrate/migrations/0301_add_use_connection_manager_to_bridge_types.sql rename to core/store/migrate/migrations/0302_add_use_connection_manager_to_bridge_types.sql From ef75ca5d1711743719315c07cb77c434d5e82c82 Mon Sep 17 00:00:00 2001 From: Denis CHernov Date: Sat, 25 Jul 2026 21:19:47 -0700 Subject: [PATCH 09/16] install protoc-gen-go-grpc in protoc make target --- GNUmakefile | 1 + 1 file changed, 1 insertion(+) diff --git a/GNUmakefile b/GNUmakefile index a847688f7f8..d81ba831df7 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -234,6 +234,7 @@ codecgen: $(codecgen) ## Install codecgen protoc: ## Install protoc core/scripts/install-protoc.sh 29.3 / go install google.golang.org/protobuf/cmd/protoc-gen-go@`go list -m -json google.golang.org/protobuf | jq -r .Version` + go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.2 go install github.com/smartcontractkit/wsrpc/cmd/protoc-gen-go-wsrpc@`go list -m -json github.com/smartcontractkit/wsrpc | jq -r .Version` .PHONY: telemetry-protobuf From 4b565bdf3398653a6b51e7072a2f9f2a30fa9249 Mon Sep 17 00:00:00 2001 From: Denis CHernov Date: Sat, 25 Jul 2026 21:45:55 -0700 Subject: [PATCH 10/16] regenerate correct streams.pb.go and streams_grpc.pb.go files --- .../pipeline/bridgeconn/bridge_conn_manager.go | 8 +++++--- .../bridgeconn/bridge_conn_manager_test.go | 16 +++++++++------- core/services/pipeline/bridgeconn/eaconn.go | 5 +++-- .../pipeline/bridgeconn/streamspb/streams.pb.go | 2 +- .../bridgeconn/streamspb/streams_grpc.pb.go | 2 +- core/services/pipeline/helpers_test.go | 2 +- core/services/pipeline/runner.go | 2 +- core/services/pipeline/task.bridge.go | 16 ++++++++-------- core/services/pipeline/task.bridge_test.go | 7 ++++--- 9 files changed, 33 insertions(+), 27 deletions(-) diff --git a/core/services/pipeline/bridgeconn/bridge_conn_manager.go b/core/services/pipeline/bridgeconn/bridge_conn_manager.go index 05705804a3b..393e6416174 100644 --- a/core/services/pipeline/bridgeconn/bridge_conn_manager.go +++ b/core/services/pipeline/bridgeconn/bridge_conn_manager.go @@ -3,6 +3,7 @@ package bridgeconn import ( "context" "crypto/sha256" + "encoding/hex" stdErrors "errors" "fmt" "strings" @@ -12,10 +13,12 @@ import ( "google.golang.org/protobuf/types/known/structpb" "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink/v2/core/bridges" "github.com/smartcontractkit/chainlink/v2/core/store/models" ) +//nolint:revive // Interface name matches existing project convention. type BridgeConnManager interface { GetObservation(bridge bridges.BridgeType, requestData map[string]any) ([]byte, error) } @@ -70,7 +73,7 @@ func (m *bridgeConnManager) GetObservation(bridge bridges.BridgeType, requestDat if err != nil { return nil, err } - m.lggr.Debugw("cache key generated", "key", fmt.Sprintf("%x", key), "bridge", bridgeName, "data", data) + m.lggr.Debugw("cache key generated", "key", hex.EncodeToString(key[:]), "bridge", bridgeName, "data", data) subscription, err := structpb.NewStruct(data) if err != nil { return nil, fmt.Errorf("failed to build subscription payload for bridge %q: %w", bridgeName, err) @@ -116,7 +119,6 @@ func (m *bridgeConnManager) SeedObservation(bridge bridges.BridgeType, requestDa // getOrCreateConn returns the bridge's persistent EAConn, lazily creating and // starting it on first use. func (m *bridgeConnManager) getOrCreateConn(bridgeName string, bridgeURL models.WebURL) *eaConn { - m.connsMu.Lock() defer m.connsMu.Unlock() if conn, ok := m.conns[bridgeName]; ok { @@ -149,7 +151,7 @@ func (m *bridgeConnManager) DisableEAConnDialingForTest() { func subscriptionData(requestData map[string]any) (map[string]any, error) { data, ok := requestData["data"].(map[string]any) if !ok || len(data) == 0 { - return nil, fmt.Errorf("request data is missing a non-empty \"data\" field required for subscription") + return nil, stdErrors.New("request data is missing a non-empty \"data\" field required for subscription") } return data, nil } diff --git a/core/services/pipeline/bridgeconn/bridge_conn_manager_test.go b/core/services/pipeline/bridgeconn/bridge_conn_manager_test.go index c7a4e7aff22..b52a072b7c4 100644 --- a/core/services/pipeline/bridgeconn/bridge_conn_manager_test.go +++ b/core/services/pipeline/bridgeconn/bridge_conn_manager_test.go @@ -15,6 +15,7 @@ import ( "google.golang.org/protobuf/types/known/structpb" "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink/v2/core/bridges" "github.com/smartcontractkit/chainlink/v2/core/services/pipeline/bridgeconn/streamspb" "github.com/smartcontractkit/chainlink/v2/core/store/models" @@ -138,8 +139,8 @@ func TestEAConn_HandleObservation(t *testing.T) { cached, ok := m.cache[key] m.mu.RUnlock() require.True(t, ok) - assert.Equal(t, `{"result":"1"}`, string(cached)) - assert.Equal(t, float64(1), observationsMetric()) + assert.JSONEq(t, `{"result":"1"}`, string(cached)) + assert.InEpsilon(t, float64(1), observationsMetric(), 0) // Unregistered key: discarded, counter unchanged. var unknownKey [32]byte @@ -152,20 +153,21 @@ func TestEAConn_HandleObservation(t *testing.T) { _, unknownCached := m.cache[unknownKey] m.mu.RUnlock() assert.False(t, unknownCached, "observation for an unregistered key must not be cached") - assert.Equal(t, float64(1), observationsMetric(), "counter must not increment for an unregistered key") + assert.InEpsilon(t, float64(1), observationsMetric(), 0, "counter must not increment for an unregistered key") // Malformed (wrong-length) payload_hash: discarded, no panic, counter unchanged. conn.handleObservation(&streamspb.SubscribeResponse{ PayloadHash: []byte{1, 2, 3}, ObservationJson: []byte(`{"result":"3"}`), }) - assert.Equal(t, float64(1), observationsMetric(), "counter must not increment for a malformed payload_hash") + assert.InEpsilon(t, float64(1), observationsMetric(), 0, "counter must not increment for a malformed payload_hash") } func TestNextBackoff(t *testing.T) { t.Parallel() d := reconnectBackoffInitial - seen := []time.Duration{d} + seen := make([]time.Duration, 0, 11) + seen = append(seen, d) for range 10 { d = nextBackoff(d) seen = append(seen, d) @@ -192,7 +194,7 @@ func TestBridgeConnManager_GetObservation_CacheHitAndMiss(t *testing.T) { m.PutObservation(key, []byte(`{"result":"9700"}`)) got, err := m.GetObservation(bridge, requestData) require.NoError(t, err) - assert.Equal(t, `{"result":"9700"}`, string(got)) + assert.JSONEq(t, `{"result":"9700"}`, string(got)) // GetObservation must still have registered the asset with the bridge's EAConn. m.connsMu.Lock() @@ -211,7 +213,7 @@ func TestBridgeConnManager_GetObservation_MissingDataField(t *testing.T) { bridge := testBridge(t, "nodatabridge") _, err := m.GetObservation(bridge, map[string]any{"foo": "bar"}) - assert.Error(t, err, "requestData without a \"data\" field must be rejected, not silently subscribed") + require.Error(t, err, "requestData without a \"data\" field must be rejected, not silently subscribed") _, err = m.GetObservation(bridge, map[string]any{"data": map[string]any{}}) assert.Error(t, err, "an empty \"data\" field must be rejected") diff --git a/core/services/pipeline/bridgeconn/eaconn.go b/core/services/pipeline/bridgeconn/eaconn.go index 38dea00a72b..1cdc26a169f 100644 --- a/core/services/pipeline/bridgeconn/eaconn.go +++ b/core/services/pipeline/bridgeconn/eaconn.go @@ -13,6 +13,7 @@ import ( "google.golang.org/protobuf/types/known/structpb" "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink/v2/core/services/pipeline/bridgeconn/streamspb" "github.com/smartcontractkit/chainlink/v2/core/store/models" ) @@ -77,8 +78,8 @@ type eaConn struct { // newEAConn is called with manager.connsMu already held, so reading manager.lggr // here is safe without a separate lock. -func newEAConn(bridgeName string, bridgeUrl models.WebURL, manager *bridgeConnManager) *eaConn { - u := url.URL(bridgeUrl) +func newEAConn(bridgeName string, bridgeURL models.WebURL, manager *bridgeConnManager) *eaConn { + u := url.URL(bridgeURL) return &eaConn{ bridgeName: bridgeName, target: u.Host, diff --git a/core/services/pipeline/bridgeconn/streamspb/streams.pb.go b/core/services/pipeline/bridgeconn/streamspb/streams.pb.go index ae3599d2951..df9dcd7055a 100644 --- a/core/services/pipeline/bridgeconn/streamspb/streams.pb.go +++ b/core/services/pipeline/bridgeconn/streamspb/streams.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v7.35.1 +// protoc v5.29.3 // source: streams.proto package streamspb diff --git a/core/services/pipeline/bridgeconn/streamspb/streams_grpc.pb.go b/core/services/pipeline/bridgeconn/streamspb/streams_grpc.pb.go index 3528b732fc5..3c1a3706ef1 100644 --- a/core/services/pipeline/bridgeconn/streamspb/streams_grpc.pb.go +++ b/core/services/pipeline/bridgeconn/streamspb/streams_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.2 -// - protoc v7.35.1 +// - protoc v5.29.3 // source: streams.proto package streamspb diff --git a/core/services/pipeline/helpers_test.go b/core/services/pipeline/helpers_test.go index c80b33135a4..99f3958076e 100644 --- a/core/services/pipeline/helpers_test.go +++ b/core/services/pipeline/helpers_test.go @@ -43,7 +43,7 @@ func (t *BridgeTask) HelperSetDependencies( t.orm = orm t.uuid = id t.httpClient = httpClient - t.specId = specId + t.specID = specId } func (t *BridgeTask) HelperSetBridgeConnManager(bridgeConnManager bridgeconn.BridgeConnManager) { diff --git a/core/services/pipeline/runner.go b/core/services/pipeline/runner.go index 0f4216b60e9..c93a17dfc0b 100644 --- a/core/services/pipeline/runner.go +++ b/core/services/pipeline/runner.go @@ -347,7 +347,7 @@ func (r *runner) InitializePipeline(spec Spec) (pipeline *Pipeline, err error) { bt.bridgeConfig = r.bridgeConfig // orm added to BridgeTask bt.orm = r.btORM - bt.specId = spec.ID + bt.specID = spec.ID // URL is "safe" because it comes from the node's own database. We // must use the unrestrictedHTTPClient because some node operators // may run external adapters on their own hardware diff --git a/core/services/pipeline/task.bridge.go b/core/services/pipeline/task.bridge.go index 3f794456b15..4dd3bd88d2e 100644 --- a/core/services/pipeline/task.bridge.go +++ b/core/services/pipeline/task.bridge.go @@ -86,7 +86,7 @@ type BridgeTask struct { // requiredJSONPaths). When empty or "false", that check is skipped. CheckRequired string `json:"checkRequired"` - specId int32 + specID int32 orm bridges.ORM config Config bridgeConfig BridgeConfig @@ -179,14 +179,14 @@ func (t *BridgeTask) Run(ctx context.Context, lggr logger.Logger, vars Vars, inp if bridgeConnManager == nil { bridgeConnManager = bridgeconn.NewBridgeConnManager() } - responseBytes, err := bridgeConnManager.GetObservation(bridge, map[string]any(lookupPayload)) - if err != nil { + responseBytes, obsErr := bridgeConnManager.GetObservation(bridge, map[string]any(lookupPayload)) + if obsErr != nil { lggr.Debugw("Bridge task: connection manager request failed", "response", string(responseBytes), "url", url.String(), - "error", err, + "error", obsErr, ) - return Result{Error: err}, RunInfo{IsRetryable: true} + return Result{Error: obsErr}, RunInfo{IsRetryable: true} } return Result{Value: string(responseBytes)}, runInfo } @@ -226,7 +226,7 @@ func (t *BridgeTask) Run(ctx context.Context, lggr logger.Logger, vars Vars, inp RequestStartTimestamp: start, RequestFinishTimestamp: finish, LocalCacheHit: cachedResponse, - SpecID: t.specId, + SpecID: t.specID, DotID: t.DotID(), } if err != nil { @@ -273,7 +273,7 @@ func (t *BridgeTask) Run(ctx context.Context, lggr logger.Logger, vars Vars, inp } if !cachedResponse && cacheTTL > 0 { - err := t.orm.UpsertBridgeResponse(overtimeCtx, t.dotID, t.specId, responseBytes) + err := t.orm.UpsertBridgeResponse(overtimeCtx, t.dotID, t.specID, responseBytes) if err != nil { lggr.Errorw("Bridge task: failed to upsert response in bridge cache", "err", err) } @@ -391,7 +391,7 @@ func (t *BridgeTask) resolveFailureOrCache( } //nolint:gosec // disable G115 - cachedBytes, cacheErr := t.orm.GetCachedResponse(ctx, t.dotID, t.specId, time.Duration(cacheTTL)*time.Second) + cachedBytes, cacheErr := t.orm.GetCachedResponse(ctx, t.dotID, t.specID, time.Duration(cacheTTL)*time.Second) if cacheErr != nil { promBridgeCacheErrors.WithLabelValues(t.Name).Inc() if !errors.Is(cacheErr, sql.ErrNoRows) { diff --git a/core/services/pipeline/task.bridge_test.go b/core/services/pipeline/task.bridge_test.go index 1960391348f..b19b191562d 100644 --- a/core/services/pipeline/task.bridge_test.go +++ b/core/services/pipeline/task.bridge_test.go @@ -311,17 +311,17 @@ func TestBridgeTask_UsesBridgeConnManagerHappyPath(t *testing.T) { } c := clhttptest.NewTestLocalOnlyHTTPClient() trORM := pipeline.NewORM(db, logger.TestLogger(t), cfg.JobPipeline().MaxSuccessfulRuns()) - specID, err := trORM.CreateSpec(testutils.Context(t), pipeline.Pipeline{}, *sqlutil.NewInterval(5 * time.Minute)) + specID, err := trORM.CreateSpec(t.Context(), pipeline.Pipeline{}, *sqlutil.NewInterval(5 * time.Minute)) require.NoError(t, err) task.HelperSetDependencies(cfg.JobPipeline(), cfg.WebServer(), orm, specID, uuid.UUID{}, c) task.HelperSetBridgeConnManager(manager) - result, runInfo := task.Run(testutils.Context(t), logger.TestLogger(t), pipeline.NewVarsFrom(nil), nil) + result, runInfo := task.Run(t.Context(), logger.TestLogger(t), pipeline.NewVarsFrom(nil), nil) assert.False(t, runInfo.IsPending) assert.False(t, runInfo.IsRetryable) require.NoError(t, result.Error) - assert.Equal(t, `{"data":{"result":"9700"}}`, result.Value) + assert.JSONEq(t, `{"data":{"result":"9700"}}`, result.Value.(string)) assert.Equal(t, int32(0), httpCalls.Load()) } @@ -344,6 +344,7 @@ func TestBridgeTask_HandlesIntermittentFailure(t *testing.T) { BaseTask: pipeline.NewBaseTask(0, "bridge", nil, nil, 0), Name: bridge.Name.String(), RequestData: btcUSDPairing, + CacheTTL: "30s", // standard duration string format } c := clhttptest.NewTestLocalOnlyHTTPClient() trORM := pipeline.NewORM(db, logger.TestLogger(t), cfg.JobPipeline().MaxSuccessfulRuns()) From 5844bb2b1338d670d48810e54a387a67ca780eff Mon Sep 17 00:00:00 2001 From: Denis Chernov Date: Tue, 28 Jul 2026 22:31:06 -0700 Subject: [PATCH 11/16] promEAConnTransmitDuration metric --- core/services/pipeline/bridgeconn/eaconn.go | 70 +++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/core/services/pipeline/bridgeconn/eaconn.go b/core/services/pipeline/bridgeconn/eaconn.go index 1cdc26a169f..cc4a9dac88b 100644 --- a/core/services/pipeline/bridgeconn/eaconn.go +++ b/core/services/pipeline/bridgeconn/eaconn.go @@ -7,6 +7,7 @@ import ( "sync" "time" + "github.com/goccy/go-json" "github.com/jonboulle/clockwork" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" @@ -36,6 +37,52 @@ var promEAConnObservationsTotal = promauto.NewCounterVec(prometheus.CounterOpts{ Help: "Count of gRPC observation messages accepted per bridge and asset pair", }, []string{"bridgeName", "assetKey"}) +// promEAConnTransmitDuration measures the delay between the external adapter receiving +// data from its upstream provider (timestamps.providerDataReceivedUnixMs in the +// observation payload) and this node handling the gRPC message. +var promEAConnTransmitDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Name: "bridge_eaconn_transmit_duration_seconds", + Help: "Delay from the adapter receiving provider data to the node handling the gRPC observation", + Buckets: []float64{ + 0.001, // 1 ms + 0.002, // 2 ms + 0.005, // 5 ms + 0.010, // 10 ms + 0.015, // 15 ms + 0.020, // 20 ms + 0.030, // 30 ms + 0.040, // 40 ms + 0.050, // 50 ms + 0.075, // 75 ms + 0.100, // 100 ms + 0.150, // 150 ms + 0.200, // 200 ms + 0.500, // 500 ms + }, +}, []string{"bridgeName"}) + +// promEAConnTransmitSkewTotal counts observations excluded from the transmit-duration +// histogram because now minus providerDataReceivedUnixMs came out negative, meaning the +// adapter's clock runs ahead of this node's. Skew of tens of milliseconds is normal +// between unsynchronized hosts and exceeds the latency the histogram tries to measure, +// so such samples are dropped rather than folded into the lowest bucket. Compare this +// against bridge_eaconn_observations_total to judge how much of a bridge's traffic the +// histogram actually covers: if this tracks the observation rate, the histogram is empty +// for that bridge and its clocks need synchronizing before the latency data means +// anything. +var promEAConnTransmitSkewTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "bridge_eaconn_transmit_skew_total", + Help: "Count of observations excluded from the transmit-duration histogram due to adapter clock skew", +}, []string{"bridgeName"}) + +// observationTimestamps is the subset of an observation payload EAConn reads for the +// transmit-duration metric; all other payload fields are ignored. +type observationTimestamps struct { + Timestamps struct { + ProviderDataReceivedUnixMs *int64 `json:"providerDataReceivedUnixMs"` + } `json:"timestamps"` +} + // eaStreamClient is the protobuf-independent contract EAConn depends on for its // bidirectional observation stream, implemented by grpcStreamClient in production // and by fakes in tests. @@ -218,9 +265,32 @@ func (c *eaConn) handleObservation(resp *streamspb.SubscribeResponse) { return } promEAConnObservationsTotal.WithLabelValues(c.bridgeName, hex.EncodeToString(key[:])).Inc() + c.observeTransmitDuration(resp.ObservationJson) c.manager.PutObservation(key, resp.ObservationJson) } +// observeTransmitDuration records now minus providerDataReceivedUnixMs. Payloads that +// omit the timestamp are skipped silently; negative durations are counted in +// promEAConnTransmitSkewTotal instead, since they reflect adapter clock skew rather than +// transmission time. Both branches are per-message hot paths at a few thousand +// observations per second, so neither logs: the counters carry the signal. +func (c *eaConn) observeTransmitDuration(observationJSON []byte) { + var obs observationTimestamps + if err := json.Unmarshal(observationJSON, &obs); err != nil { + return + } + if obs.Timestamps.ProviderDataReceivedUnixMs == nil { + return + } + received := time.UnixMilli(*obs.Timestamps.ProviderDataReceivedUnixMs) + d := c.clock.Now().Sub(received) + if d < 0 { + promEAConnTransmitSkewTotal.WithLabelValues(c.bridgeName).Inc() + return + } + promEAConnTransmitDuration.WithLabelValues(c.bridgeName).Observe(d.Seconds()) +} + func (c *eaConn) sleepBackoff(current time.Duration) time.Duration { c.clock.Sleep(current) return nextBackoff(current) From d8e8e4df30d4a5827e547e625ff1b03cc84714aa Mon Sep 17 00:00:00 2001 From: Denis Chernov Date: Thu, 6 Aug 2026 13:27:47 -0700 Subject: [PATCH 12/16] bridge telemetry for UseConnectionManager case, TTL 5 sec --- .../bridgeconn/bridge_conn_manager.go | 31 +++++++++++++--- .../bridgeconn/bridge_conn_manager_test.go | 28 +++++++++++++- core/services/pipeline/task.bridge.go | 37 +++++++++++++++++++ 3 files changed, 88 insertions(+), 8 deletions(-) diff --git a/core/services/pipeline/bridgeconn/bridge_conn_manager.go b/core/services/pipeline/bridgeconn/bridge_conn_manager.go index 393e6416174..5d7f922199b 100644 --- a/core/services/pipeline/bridgeconn/bridge_conn_manager.go +++ b/core/services/pipeline/bridgeconn/bridge_conn_manager.go @@ -8,8 +8,10 @@ import ( "fmt" "strings" "sync" + "time" "github.com/goccy/go-json" + "github.com/jonboulle/clockwork" "google.golang.org/protobuf/types/known/structpb" "github.com/smartcontractkit/chainlink-common/pkg/logger" @@ -25,27 +27,41 @@ type BridgeConnManager interface { var ( ErrBridgeObservationNotFound = stdErrors.New("bridge observation not found") + ErrBridgeObservationExpired = stdErrors.New("bridge observation expired") ) +// observationTTL bounds how long a cached observation may be served before it is +// treated as stale. Hardcoded for now; may become configurable later. +const observationTTL = 5 * time.Second + +// cacheEntry pairs a cached observation with the time it was stored, so +// GetObservation can reject entries older than observationTTL. +type cacheEntry struct { + payload []byte + storedAt time.Time +} + // bridgeConnManager is a package-level singleton: one observation cache plus one // EAConn registry shared by every pipeline run in the process. It self-initializes // lazily as bridges are first used; there is no explicit start/close lifecycle. type bridgeConnManager struct { mu sync.RWMutex - cache map[[32]byte][]byte + cache map[[32]byte]cacheEntry connsMu sync.Mutex conns map[string]*eaConn // bridge name -> EAConn lggr logger.Logger // guarded by connsMu; set at most once, from NewBridgeConnManager - dial eaStreamDialer + dial eaStreamDialer + clock clockwork.Clock } var defaultBridgeConnManager BridgeConnManager = &bridgeConnManager{ - cache: make(map[[32]byte][]byte), + cache: make(map[[32]byte]cacheEntry), conns: make(map[string]*eaConn), lggr: logger.Nop(), dial: dialGRPCStream, + clock: clockwork.NewRealClock(), } // NewBridgeConnManager returns the package-level singleton. Passing a logger sets @@ -86,8 +102,11 @@ func (m *bridgeConnManager) GetObservation(bridge bridges.BridgeType, requestDat if !ok { return nil, fmt.Errorf("%w for bridge %q", ErrBridgeObservationNotFound, bridgeName) } - payload := make([]byte, len(entry)) - copy(payload, entry) + if m.clock.Now().Sub(entry.storedAt) > observationTTL { + return nil, fmt.Errorf("%w for bridge %q", ErrBridgeObservationExpired, bridgeName) + } + payload := make([]byte, len(entry.payload)) + copy(payload, entry.payload) return payload, nil } @@ -98,7 +117,7 @@ func (m *bridgeConnManager) PutObservation(key [32]byte, observation []byte) { copy(payload, observation) m.mu.Lock() defer m.mu.Unlock() - m.cache[key] = payload + m.cache[key] = cacheEntry{payload: payload, storedAt: m.clock.Now()} } // SeedObservation computes the bridge observation key from request data and diff --git a/core/services/pipeline/bridgeconn/bridge_conn_manager_test.go b/core/services/pipeline/bridgeconn/bridge_conn_manager_test.go index b52a072b7c4..c25cf2fa3fd 100644 --- a/core/services/pipeline/bridgeconn/bridge_conn_manager_test.go +++ b/core/services/pipeline/bridgeconn/bridge_conn_manager_test.go @@ -34,12 +34,13 @@ func testBridge(t *testing.T, name string) bridges.BridgeType { func newTestManager() *bridgeConnManager { return &bridgeConnManager{ - cache: make(map[[32]byte][]byte), + cache: make(map[[32]byte]cacheEntry), conns: make(map[string]*eaConn), lggr: logger.Nop(), dial: func(_ context.Context, _ string, _ bool) (eaStreamClient, error) { return nil, errStreamDialingDisabledForTest }, + clock: clockwork.NewRealClock(), } } @@ -139,7 +140,7 @@ func TestEAConn_HandleObservation(t *testing.T) { cached, ok := m.cache[key] m.mu.RUnlock() require.True(t, ok) - assert.JSONEq(t, `{"result":"1"}`, string(cached)) + assert.JSONEq(t, `{"result":"1"}`, string(cached.payload)) assert.InEpsilon(t, float64(1), observationsMetric(), 0) // Unregistered key: discarded, counter unchanged. @@ -207,6 +208,29 @@ func TestBridgeConnManager_GetObservation_CacheHitAndMiss(t *testing.T) { assert.True(t, registered) } +func TestBridgeConnManager_GetObservation_ExpiresAfterTTL(t *testing.T) { + t.Parallel() + m := newTestManager() + clock := clockwork.NewFakeClock() + m.clock = clock + bridge := testBridge(t, "ttlbridge") + requestData := map[string]any{"data": map[string]any{"endpoint": "crypto"}} + + key, err := bridgeObservationCacheKey(bridge.Name.String(), requestData["data"].(map[string]any)) + require.NoError(t, err) + + m.PutObservation(key, []byte(`{"result":"9700"}`)) + + clock.Advance(observationTTL) + got, err := m.GetObservation(bridge, requestData) + require.NoError(t, err, "an entry at exactly the TTL boundary must not be treated as expired") + assert.JSONEq(t, `{"result":"9700"}`, string(got)) + + clock.Advance(time.Nanosecond) + _, err = m.GetObservation(bridge, requestData) + require.ErrorIs(t, err, ErrBridgeObservationExpired) +} + func TestBridgeConnManager_GetObservation_MissingDataField(t *testing.T) { t.Parallel() m := newTestManager() diff --git a/core/services/pipeline/task.bridge.go b/core/services/pipeline/task.bridge.go index 4dd3bd88d2e..74c0a30ea77 100644 --- a/core/services/pipeline/task.bridge.go +++ b/core/services/pipeline/task.bridge.go @@ -179,7 +179,44 @@ func (t *BridgeTask) Run(ctx context.Context, lggr logger.Logger, vars Vars, inp if bridgeConnManager == nil { bridgeConnManager = bridgeconn.NewBridgeConnManager() } + start := time.Now() responseBytes, obsErr := bridgeConnManager.GetObservation(bridge, map[string]any(lookupPayload)) + finish := time.Now() + + statusCode := http.StatusOK + if obsErr != nil { + statusCode = http.StatusGatewayTimeout + } + + if telemetryCh := GetTelemetryCh(ctx); telemetryCh != nil { + requestDataJSON, jsonErr := json.Marshal(lookupPayload) + if jsonErr != nil { + lggr.Warnw("Bridge task: failed to marshal request data for telemetry", "err", jsonErr) + } + bt := &BridgeTelemetry{ + Name: t.Name, + RequestData: requestDataJSON, + ResponseData: responseBytes, + ResponseStatusCode: statusCode, + RequestStartTimestamp: start, + RequestFinishTimestamp: finish, + SpecID: t.specID, + DotID: t.DotID(), + } + if obsErr != nil { + bt.ResponseError = new(string) + *bt.ResponseError = obsErr.Error() + } + + bt.resolveStreamID(t, vars, lggr) + + select { + case telemetryCh <- bt: + default: + lggr.Warn("bridge task: telemetry channel is full, dropping telemetry") + } + } + if obsErr != nil { lggr.Debugw("Bridge task: connection manager request failed", "response", string(responseBytes), From 6396bc61a40e19c38ae57cfaf92401a5541bcc60 Mon Sep 17 00:00:00 2001 From: Denis Chernov Date: Thu, 6 Aug 2026 16:54:23 -0700 Subject: [PATCH 13/16] Retrigger CI From a6536f49a51290ea4f33419881c3a6ad155a2ac7 Mon Sep 17 00:00:00 2001 From: Denis Chernov Date: Tue, 11 Aug 2026 13:00:57 -0700 Subject: [PATCH 14/16] set observationTTL to 60 seconds --- core/services/pipeline/bridgeconn/bridge_conn_manager.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/services/pipeline/bridgeconn/bridge_conn_manager.go b/core/services/pipeline/bridgeconn/bridge_conn_manager.go index 5d7f922199b..ec611effdfc 100644 --- a/core/services/pipeline/bridgeconn/bridge_conn_manager.go +++ b/core/services/pipeline/bridgeconn/bridge_conn_manager.go @@ -32,7 +32,7 @@ var ( // observationTTL bounds how long a cached observation may be served before it is // treated as stale. Hardcoded for now; may become configurable later. -const observationTTL = 5 * time.Second +const observationTTL = 60 * time.Second // cacheEntry pairs a cached observation with the time it was stored, so // GetObservation can reject entries older than observationTTL. From f7bb371b54b30418e5eea547f8b4da7836cca6fd Mon Sep 17 00:00:00 2001 From: Denis Chernov Date: Mon, 17 Aug 2026 15:39:39 -0700 Subject: [PATCH 15/16] integration test --- .../services/ocr2/plugins/llo/helpers_test.go | 79 +++++++++++ .../ocr2/plugins/llo/integration_test.go | 126 ++++++++++++++++++ core/services/pipeline/task.bridge.go | 5 + 3 files changed, 210 insertions(+) diff --git a/core/services/ocr2/plugins/llo/helpers_test.go b/core/services/ocr2/plugins/llo/helpers_test.go index 683a0a39e56..72fcfbede91 100644 --- a/core/services/ocr2/plugins/llo/helpers_test.go +++ b/core/services/ocr2/plugins/llo/helpers_test.go @@ -4,6 +4,7 @@ import ( "context" "crypto" "crypto/ed25519" + "crypto/sha256" "encoding/json" "errors" "fmt" @@ -46,6 +47,7 @@ import ( "github.com/smartcontractkit/chainlink/v2/core/services/chainlink" "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/validate" "github.com/smartcontractkit/chainlink/v2/core/services/ocrbootstrap" + "github.com/smartcontractkit/chainlink/v2/core/services/pipeline/bridgeconn/streamspb" "github.com/smartcontractkit/chainlink/v2/core/services/streams" "github.com/smartcontractkit/chainlink/v2/core/store/models" "github.com/smartcontractkit/chainlink/v2/core/utils/testutils/heavyweight" @@ -457,6 +459,83 @@ func createSingleDecimalCountingBridge(t *testing.T, name string, i int, p decim return bridgeName } +// grpcStreamBridgeServer is a minimal stand-in for a streams-adapter EA: it +// implements the streamspb.StreamServiceServer side of BridgeConnManager's +// protocol and, for every subscription it receives, immediately replies with a +// fixed price under the payload hash BridgeConnManager expects to find in its +// observation cache (see bridgeConnManager.bridgeObservationCacheKey). +type grpcStreamBridgeServer struct { + streamspb.UnimplementedStreamServiceServer + bridgeName string // trimmed of the "bridge-" prefix, matching bridgeConnManager's own trimming + price decimal.Decimal +} + +func (s *grpcStreamBridgeServer) Subscribe(stream streamspb.StreamService_SubscribeServer) error { + for { + req, err := stream.Recv() + if err != nil { + return nil //nolint:nilerr // client closing the stream ends the test happy-path; no error to report + } + for _, sub := range req.GetSubscriptions() { + hash, err := grpcStreamBridgeObservationHash(s.bridgeName, sub.GetData().AsMap()) + if err != nil { + return err + } + resp := &streamspb.SubscribeResponse{ + Timestamp: time.Now().UTC().Format(time.RFC3339), + ObservationJson: fmt.Appendf(nil, `{"result": %s}`, s.price.String()), + PayloadHash: hash[:], + } + if err := stream.Send(resp); err != nil { + return err + } + } + } +} + +// grpcStreamBridgeObservationHash mirrors bridgeConnManager's +// bridgeObservationCacheKey so this fake EA's responses land in the real cache. +func grpcStreamBridgeObservationHash(bridgeName string, data map[string]any) ([32]byte, error) { + lookupBytes, err := json.Marshal(data) + if err != nil { + return [32]byte{}, err + } + b := make([]byte, 0, len(bridgeName)+len(lookupBytes)) + b = append(b, bridgeName...) + b = append(b, lookupBytes...) + return sha256.Sum256(b), nil +} + +// createGRPCStreamBridge starts a plaintext gRPC server implementing the +// streams-adapter side of BridgeConnManager's protocol and registers a bridge +// with UseConnectionManager=true pointing at it, so BridgeTask.Run reads +// observations from BridgeConnManager's cache instead of making an HTTP call. +func createGRPCStreamBridge(t *testing.T, name string, i int, p decimal.Decimal, borm bridges.ORM) (bridgeName string) { + ctx := t.Context() + trimmedName := fmt.Sprintf("%s-%d", name, i) + bridgeName = "bridge-" + trimmedName + + var lc net.ListenConfig + lis, err := lc.Listen(ctx, "tcp", "127.0.0.1:0") + require.NoError(t, err) + + s := grpc.NewServer() + streamspb.RegisterStreamServiceServer(s, &grpcStreamBridgeServer{bridgeName: trimmedName, price: p}) + go func() { + _ = s.Serve(lis) //nolint:errcheck // don't care about this error in tests + }() + t.Cleanup(s.Stop) + + u, err := url.Parse("http://" + lis.Addr().String()) + require.NoError(t, err) + require.NoError(t, borm.CreateBridgeType(ctx, &bridges.BridgeType{ + Name: bridges.BridgeName(bridgeName), + URL: models.WebURL(*u), + UseConnectionManager: true, + })) + return bridgeName +} + func createBridge(t *testing.T, bridgeName string, responseJSON string, borm bridges.ORM) { ctx := t.Context() bridge := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { diff --git a/core/services/ocr2/plugins/llo/integration_test.go b/core/services/ocr2/plugins/llo/integration_test.go index 0068209c5c6..f0c1f9f892b 100644 --- a/core/services/ocr2/plugins/llo/integration_test.go +++ b/core/services/ocr2/plugins/llo/integration_test.go @@ -2886,6 +2886,132 @@ channelDefinitionsContractFromBlock = %d`, serverURL, serverPubKey, donID, confi "tombstoned channel's stream should not be observed (no additional bridge calls)") } +// TestIntegration_LLO_bridgeConnManagerHappyPath exercises the BridgeConnManager +// flow end to end: BridgeTask.Run subscribes to a new asset pair on a bridge with +// UseConnectionManager=true, the fake EA (a real gRPC server implementing +// streamspb.StreamServiceServer) sends back an observation, and BridgeConnManager +// caches it for BridgeTask.Run to serve on subsequent pipeline runs, ultimately +// producing an LLO report carrying that value. Happy path only. +func TestIntegration_LLO_bridgeConnManagerHappyPath(t *testing.T) { + t.Parallel() + + const ( + salt = 600 + donID = uint32(848484) + streamID = uint32(290) + ) + + offchainConfig := llocommon.OffchainConfig{ + ProtocolVersion: 1, + DefaultMinReportIntervalNanoseconds: uint64(1 * time.Second), + } + + clientCSAKeys := make([]csakey.KeyV2, nNodes) + clientPubKeys := make([]ed25519.PublicKey, nNodes) + for i := range nNodes { + k := big.NewInt(int64(salt + i)) + key := csakey.MustNewV2XXXTestingOnly(k) + clientCSAKeys[i] = key + clientPubKeys[i] = key.PublicKey + } + + steve, backend, configurator, configuratorAddress, _, _, _, _, configStore, configStoreAddress, _, _, _, _ := setupBlockchain(t) + fromBlock := 1 + + bootstrapCSAKey := csakey.MustNewV2XXXTestingOnly(big.NewInt(salt - 1)) + bootstrapNodePort := freeport.GetOne(t) + appBootstrap, bootstrapPeerID, _, bootstrapKb, _ := setupNode(t, bootstrapNodePort, "bootstrap_llo_bridgeconnmanager", backend, bootstrapCSAKey, nil) + bootstrapNode := Node{App: appBootstrap, KeyBundle: bootstrapKb} + + packetCh := make(chan *packet, 100000) + serverKey := csakey.MustNewV2XXXTestingOnly(big.NewInt(salt - 2)) + serverPubKey := serverKey.PublicKey + srv := NewMercuryServer(t, serverKey, packetCh) + serverURL := startMercuryServer(t, srv, clientPubKeys) + + oracles, nodes := setupNodes(t, nNodes, backend, clientCSAKeys, func(c *chainlink.Config) { + c.Mercury.Transmitter.Protocol = new(mercurytransmitter.MercuryTransmitterProtocolGRPC) + }) + + chainID := testutils.SimulatedChainID + relayType := "evm" + relayConfig := fmt.Sprintf(` +chainID = "%s" +fromBlock = %d +lloDonID = %d +lloConfigMode = "bluegreen" +`, chainID, fromBlock, donID) + addBootstrapJob(t, bootstrapNode, configuratorAddress, "job-bridgeconnmanager", relayType, relayConfig) + + pluginConfig := fmt.Sprintf(`servers = { "%s" = "%x" } +donID = %d +channelDefinitionsContractAddress = "0x%x" +channelDefinitionsContractFromBlock = %d`, serverURL, serverPubKey, donID, configStoreAddress, fromBlock) + + price := decimal.NewFromFloat(1234.5) + for i, node := range nodes { + bridgeName := createGRPCStreamBridge(t, "connmanager", i, price, node.App.BridgeORM()) + addSingleDecimalStreamJob(t, node, streamID, bridgeName) + addLLOJob( + t, + node, + configuratorAddress, + bootstrapPeerID, + bootstrapNodePort, + clientPubKeys[i], + "bridgeconnmanager-stream-test", + pluginConfig, + relayType, + relayConfig, + ) + } + + channelDefinitions := llotypes.ChannelDefinitions{ + 1: { + ReportFormat: llotypes.ReportFormatJSON, + Streams: []llotypes.Stream{ + {StreamID: streamID, Aggregator: llotypes.AggregatorMedian}, + }, + }, + } + url, sha := newChannelDefinitionsServer(t, channelDefinitions) + _, err := configStore.SetChannelDefinitions(steve, donID, url, sha) + require.NoError(t, err) + backend.Commit() + + setProductionConfig( + t, donID, steve, backend, configurator, configuratorAddress, nodes, + WithOracles(oracles), WithOffchainConfig(offchainConfig), + ) + + // The fake EA only starts sending observations once BridgeConnManager's + // EAConn sends its first subscription snapshot (on a fixed 10s interval), so + // this loop tolerates reports that predate the first cached observation and + // keeps polling until one carries the expected price. + require.Eventually(t, func() bool { + pckt, errReceive := receiveWithTimeout(t, packetCh, 2*time.Second) + if errReceive != nil { + return false + } + req := pckt.req + if req.ReportFormat != uint32(llotypes.ReportFormatJSON) { + return false + } + _, _, r, _, errDecode := (llocommon.JSONReportCodec{}).UnpackDecode(req.Payload) + if errDecode != nil || r.ChannelID != 1 { + return false + } + if len(r.Values) != 1 { + return false + } + dv, ok := r.Values[0].(*llocommon.Decimal) + if !ok { + return false + } + return dv.Decimal().Equal(price) + }, reportTimeout, 100*time.Millisecond, "expected a report sourced from the bridge connection manager cache") +} + func setupNodes(t *testing.T, nNodes int, backend evmtypes.Backend, clientCSAKeys []csakey.KeyV2, f func(*chainlink.Config)) (oracles []confighelper.OracleIdentityExtra, nodes []Node) { ports := freeport.GetN(t, nNodes) for i := range nNodes { diff --git a/core/services/pipeline/task.bridge.go b/core/services/pipeline/task.bridge.go index 74c0a30ea77..a897cb6d175 100644 --- a/core/services/pipeline/task.bridge.go +++ b/core/services/pipeline/task.bridge.go @@ -23,6 +23,7 @@ import ( "github.com/shopspring/decimal" "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink/v2/core/bridges" "github.com/smartcontractkit/chainlink/v2/core/services/pipeline/bridgeconn" "github.com/smartcontractkit/chainlink/v2/core/services/pipeline/eautils" @@ -188,6 +189,10 @@ func (t *BridgeTask) Run(ctx context.Context, lggr logger.Logger, vars Vars, inp statusCode = http.StatusGatewayTimeout } + elapsed := finish.Sub(start) + promBridgeLatency.WithLabelValues(t.Name, statusCodeGroup(statusCode)).Set(elapsed.Seconds()) + promBridgeLatencyHist.WithLabelValues(t.Name, statusCodeGroup(statusCode)).Observe(float64(elapsed.Milliseconds())) + if telemetryCh := GetTelemetryCh(ctx); telemetryCh != nil { requestDataJSON, jsonErr := json.Marshal(lookupPayload) if jsonErr != nil { From d3a0a9fe7f133a85f1355c6063bc7ae4c8b9a6e1 Mon Sep 17 00:00:00 2001 From: Denis Chernov Date: Tue, 18 Aug 2026 17:37:16 -0700 Subject: [PATCH 16/16] review fixes --- .../bridgeconn/bridge_conn_manager.go | 43 ++++++++++--------- .../bridgeconn/bridge_conn_manager_test.go | 4 +- core/services/pipeline/bridgeconn/eaconn.go | 4 +- core/services/pipeline/task.bridge.go | 3 -- core/services/pipeline/task.bridge_test.go | 2 +- 5 files changed, 26 insertions(+), 30 deletions(-) diff --git a/core/services/pipeline/bridgeconn/bridge_conn_manager.go b/core/services/pipeline/bridgeconn/bridge_conn_manager.go index 804566ece3e..e00680b379e 100644 --- a/core/services/pipeline/bridgeconn/bridge_conn_manager.go +++ b/core/services/pipeline/bridgeconn/bridge_conn_manager.go @@ -49,33 +49,34 @@ type bridgeConnManager struct { connsMu sync.Mutex conns map[string]*eaConn // bridge name -> EAConn - lggr logger.Logger // guarded by connsMu; set at most once, from NewBridgeConnManager + lggr logger.Logger // immutable after singleton creation dial eaStreamDialer clock clockwork.Clock } -var defaultBridgeConnManager BridgeConnManager = &bridgeConnManager{ - cache: make(map[[32]byte]cacheEntry), - conns: make(map[string]*eaConn), - lggr: logger.Nop(), - dial: dialGRPCStream, - clock: clockwork.NewRealClock(), -} +var ( + defaultBridgeConnManager BridgeConnManager + defaultBridgeConnManagerOnce sync.Once +) -// NewBridgeConnManager returns the package-level singleton. Passing a logger sets -// it on the singleton for use by lazily-created EAConns; it's expected to be -// called once, from PipelineRunner startup, with all other call sites (fallback -// construction, tests) using the zero-arg form and getting whatever logger (or -// the Nop default) is already set. -func NewBridgeConnManager(lggr ...logger.Logger) BridgeConnManager { - m := defaultBridgeConnManager.(*bridgeConnManager) - if len(lggr) > 0 && lggr[0] != nil { - m.connsMu.Lock() - m.lggr = lggr[0] - m.connsMu.Unlock() - } - return m +// NewBridgeConnManager returns the package-level singleton, creating it on the +// first call with the supplied logger. Subsequent calls return the same manager +// and ignore the logger. The logger is immutable after creation. +func NewBridgeConnManager(lggr logger.Logger) BridgeConnManager { + defaultBridgeConnManagerOnce.Do(func() { + if lggr == nil { + lggr = logger.Nop() + } + defaultBridgeConnManager = &bridgeConnManager{ + cache: make(map[[32]byte]cacheEntry), + conns: make(map[string]*eaConn), + lggr: lggr, + dial: dialGRPCStream, + clock: clockwork.NewRealClock(), + } + }) + return defaultBridgeConnManager } func (m *bridgeConnManager) GetObservation(bridge bridges.BridgeType, requestData map[string]any) ([]byte, error) { diff --git a/core/services/pipeline/bridgeconn/bridge_conn_manager_test.go b/core/services/pipeline/bridgeconn/bridge_conn_manager_test.go index f7f08e6009e..3488928f4fb 100644 --- a/core/services/pipeline/bridgeconn/bridge_conn_manager_test.go +++ b/core/services/pipeline/bridgeconn/bridge_conn_manager_test.go @@ -81,9 +81,9 @@ func TestEAConn_RegisterAsset_RefreshAndIdlePrune(t *testing.T) { t.Parallel() m := newTestManager() bridge := testBridge(t, "idlebridge") - conn := newEAConn(bridge.Name.String(), bridge.URL, m) clock := clockwork.NewFakeClock() - conn.clock = clock + m.clock = clock + conn := newEAConn(bridge.Name.String(), bridge.URL, m) payload, err := structpb.NewStruct(map[string]any{"endpoint": "crypto"}) require.NoError(t, err) diff --git a/core/services/pipeline/bridgeconn/eaconn.go b/core/services/pipeline/bridgeconn/eaconn.go index 16a9b0518c9..18ba878e70b 100644 --- a/core/services/pipeline/bridgeconn/eaconn.go +++ b/core/services/pipeline/bridgeconn/eaconn.go @@ -122,8 +122,6 @@ type eaConn struct { startOnce sync.Once } -// newEAConn is called with manager.connsMu already held, so reading manager.lggr -// here is safe without a separate lock. func newEAConn(bridgeName string, bridgeURL models.WebURL, manager *bridgeConnManager) *eaConn { u := url.URL(bridgeURL) return &eaConn{ @@ -133,7 +131,7 @@ func newEAConn(bridgeName string, bridgeURL models.WebURL, manager *bridgeConnMa dial: manager.dial, lggr: logger.With(logger.Named(manager.lggr, "EAConn"), "bridgeName", bridgeName), manager: manager, - clock: clockwork.NewRealClock(), + clock: manager.clock, assets: make(map[[32]byte]*eaAsset), } } diff --git a/core/services/pipeline/task.bridge.go b/core/services/pipeline/task.bridge.go index c08f21252cb..137fc065a71 100644 --- a/core/services/pipeline/task.bridge.go +++ b/core/services/pipeline/task.bridge.go @@ -176,9 +176,6 @@ func (t *BridgeTask) Run(ctx context.Context, lggr logger.Logger, vars Vars, inp defer cancel() if bridge.UseConnectionManager { bridgeConnManager := t.bridgeConnManager - if bridgeConnManager == nil { - bridgeConnManager = bridgeconn.NewBridgeConnManager() - } start := time.Now() responseBytes, obsErr := bridgeConnManager.GetObservation(bridge, map[string]any(lookupPayload)) finish := time.Now() diff --git a/core/services/pipeline/task.bridge_test.go b/core/services/pipeline/task.bridge_test.go index b4a3971314e..282c930b77d 100644 --- a/core/services/pipeline/task.bridge_test.go +++ b/core/services/pipeline/task.bridge_test.go @@ -290,7 +290,7 @@ func TestBridgeTask_UsesBridgeConnManagerHappyPath(t *testing.T) { UseConnectionManager: true, }) - manager := bridgeconn.NewBridgeConnManager() + manager := bridgeconn.NewBridgeConnManager(logger.TestLogger(t)) seedable, ok := manager.(interface { SeedObservation(bridge bridges.BridgeType, requestData map[string]any, observation []byte) error DisableEAConnDialingForTest()