From 92bd015b96fda08f2c78289e344d0fac6229306b Mon Sep 17 00:00:00 2001 From: Matthew Pendrey Date: Mon, 20 Oct 2025 18:31:07 +0100 Subject: [PATCH 1/4] consensus request fast failure with informative failure messages and observation error handling --- consensus/action/capability.go | 2 +- consensus/go.mod | 2 +- consensus/oracle/consensus_execution.go | 56 +- consensus/oracle/consensus_execution_test.go | 27 +- .../oracle/identical_observations_test.go | 4 +- .../oracle/plugin/{ => batching}/batching.go | 45 +- .../plugin/batching/observation_batch.go | 78 +++ .../plugin/batching/observation_batch_test.go | 83 +++ .../oracle/plugin/batching/outcome_batch.go | 196 ++++++ .../plugin/batching/outcome_batch_test.go | 118 ++++ .../oracle/plugin/batching/query_batch.go | 70 ++ .../plugin/batching/query_batch_test.go | 87 +++ consensus/oracle/plugin/batching_test.go | 596 ------------------ .../oracle/plugin/duplicate_outcomes_test.go | 100 ++- .../oracle/plugin/errors_consensus_test.go | 76 +++ consensus/oracle/plugin/factory.go | 30 +- consensus/oracle/plugin/plugin.go | 27 +- consensus/oracle/plugin/plugin_observation.go | 213 +------ consensus/oracle/plugin/plugin_outcome.go | 293 ++++----- consensus/oracle/plugin/plugin_query.go | 86 +-- consensus/oracle/plugin/plugin_reports.go | 147 +++-- .../oracle/plugin/report_generation_test.go | 18 +- .../oracle/plugin/value_consensus_test.go | 362 +++++++---- consensus/oracle/transmitter/transmitter.go | 42 +- .../oracle/transmitter/transmitter_test.go | 46 ++ consensus/oracle/types/generate.go | 3 +- consensus/oracle/types/generate/main.go | 23 + .../oracle/types/value_consensus_types.pb.go | 566 ++++++++++------- .../oracle/types/value_consensus_types.proto | 56 +- 29 files changed, 1796 insertions(+), 1656 deletions(-) rename consensus/oracle/plugin/{ => batching}/batching.go (53%) create mode 100644 consensus/oracle/plugin/batching/observation_batch.go create mode 100644 consensus/oracle/plugin/batching/observation_batch_test.go create mode 100644 consensus/oracle/plugin/batching/outcome_batch.go create mode 100644 consensus/oracle/plugin/batching/outcome_batch_test.go create mode 100644 consensus/oracle/plugin/batching/query_batch.go create mode 100644 consensus/oracle/plugin/batching/query_batch_test.go delete mode 100644 consensus/oracle/plugin/batching_test.go create mode 100644 consensus/oracle/plugin/errors_consensus_test.go create mode 100644 consensus/oracle/types/generate/main.go diff --git a/consensus/action/capability.go b/consensus/action/capability.go index 19c8f9e9e..c5ccccfee 100644 --- a/consensus/action/capability.go +++ b/consensus/action/capability.go @@ -121,7 +121,7 @@ func (c *consensusCapability) Initialise(ctx context.Context, dependencies core. } reportingPlugin, err := plugin.NewReportingPluginFactory(c.lggr, c.metrics, c.reqStore, c.SetRequestTimeout, - c.requestBatchSize) + c.requestBatchSize, defaultKeyBundleIDForValueConsensus) if err != nil { return fmt.Errorf("error when creating reporting plugin factory: %w", err) } diff --git a/consensus/go.mod b/consensus/go.mod index 2c12e3bc4..9a4ebf396 100644 --- a/consensus/go.mod +++ b/consensus/go.mod @@ -3,6 +3,7 @@ module github.com/smartcontractkit/capabilities/consensus go 1.25.3 require ( + github.com/google/uuid v1.6.0 github.com/jonboulle/clockwork v0.5.0 github.com/shopspring/decimal v1.4.0 github.com/smartcontractkit/capabilities/libs v0.0.0-20250930133443-a868d4d9dee8 @@ -41,7 +42,6 @@ require ( github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1 // indirect github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect diff --git a/consensus/oracle/consensus_execution.go b/consensus/oracle/consensus_execution.go index 294584010..1ea24bb21 100644 --- a/consensus/oracle/consensus_execution.go +++ b/consensus/oracle/consensus_execution.go @@ -42,22 +42,6 @@ func CalculateOutcomeForObservations( observations []*valuespb.Value, consensusDescriptor *sdk.ConsensusDescriptor, defaultValue *valuespb.Value, - minObservations int, - f int, -) (*valuespb.Value, error) { - filtered, _, err := filterObservations(observations, minObservations) - if err != nil { - return nil, err - } - - return handleDescriptor(lggr, consensusDescriptor, filtered, defaultValue, f) -} - -func handleDescriptor( - lggr logger.Logger, - consensusDescriptor *sdk.ConsensusDescriptor, - filtered []*valuespb.Value, - defaultValue *valuespb.Value, f int, ) (*valuespb.Value, error) { switch desc := consensusDescriptor.GetDescriptor_().(type) { @@ -65,18 +49,18 @@ func handleDescriptor( aggregation := consensusDescriptor.GetAggregation() switch aggregation { case sdk.AggregationType_AGGREGATION_TYPE_IDENTICAL: - return handleIdenticalAggregation(lggr, filtered, f) + return handleIdenticalAggregation(lggr, observations, f) case sdk.AggregationType_AGGREGATION_TYPE_MEDIAN: - return handleMedianAggregation(lggr, filtered, f) + return handleMedianAggregation(lggr, observations, f) case sdk.AggregationType_AGGREGATION_TYPE_COMMON_PREFIX: - return handleCommonPrefixAggregation(lggr, filtered, f) + return handleCommonPrefixAggregation(lggr, observations, f) case sdk.AggregationType_AGGREGATION_TYPE_COMMON_SUFFIX: - return handleCommonSuffixAggregation(lggr, filtered, f) + return handleCommonSuffixAggregation(lggr, observations, f) default: return nil, fmt.Errorf("unknown aggregation type: %s", aggregation) } case *sdk.ConsensusDescriptor_FieldsMap: - return handleFieldsMapAggregation(lggr, filtered, desc.FieldsMap.GetFields(), defaultValue, f) + return handleFieldsMapAggregation(lggr, observations, desc.FieldsMap.GetFields(), defaultValue, f) default: return nil, fmt.Errorf("unknown consensus descriptor type: %T", desc) } @@ -126,7 +110,7 @@ func handleFieldsMapAggregation( } } - aggregated, err = handleDescriptor(lggr, d, obsForKey, defaultForKey, f) + aggregated, err = CalculateOutcomeForObservations(lggr, obsForKey, d, defaultForKey, f) if err == nil { result[key] = aggregated continue @@ -456,12 +440,7 @@ func filterObservations(observationProtos []*valuespb.Value, minObservations int return nil, nil, fmt.Errorf("insufficient observations (%d) to meet minimum (%d)", len(observationProtos), minObservations) } - var dominantType reflect.Type - var highestCount int - var highestCountEqual bool - observationsByType := map[reflect.Type][]*valuespb.Value{} - for _, observation := range observationProtos { if observation.Value == nil { continue @@ -469,22 +448,21 @@ func filterObservations(observationProtos []*valuespb.Value, minObservations int tpe := reflect.TypeOf(observation.Value) observationsByType[tpe] = append(observationsByType[tpe], observation) - count := len(observationsByType[tpe]) - if count > highestCount { - highestCount = count - dominantType = tpe - highestCountEqual = false - } else if count == highestCount { - highestCountEqual = true - } } - if highestCount < minObservations { - return nil, nil, fmt.Errorf("no single type met the minimum observation threshold of %d", minObservations) + var dominantType reflect.Type + for tpe, obsOfType := range observationsByType { + if len(obsOfType) >= minObservations { + if dominantType != nil { + // More than one type meets the threshold + return nil, nil, ErrMultipleValuesMetThreshold + } + dominantType = tpe + } } - if highestCountEqual { - return nil, nil, ErrMultipleValuesMetThreshold + if dominantType == nil { + return nil, nil, ErrNoValuesMetThreshold } return observationsByType[dominantType], dominantType, nil diff --git a/consensus/oracle/consensus_execution_test.go b/consensus/oracle/consensus_execution_test.go index 7191b415f..91505509f 100644 --- a/consensus/oracle/consensus_execution_test.go +++ b/consensus/oracle/consensus_execution_test.go @@ -25,7 +25,6 @@ func Test_CalculateOutcomeForObservations(t *testing.T) { observations []*valuespb.Value descriptor *sdk.ConsensusDescriptor defaultValue *valuespb.Value - minObs int f int expectedOutcome *valuespb.Value expectedError error @@ -43,7 +42,7 @@ func Test_CalculateOutcomeForObservations(t *testing.T) { Aggregation: sdk.AggregationType_AGGREGATION_TYPE_MEDIAN, }, }, - minObs: 3, + f: 2, expectedOutcome: nil, expectedError: errors.New("insufficient observations"), }, @@ -61,7 +60,7 @@ func Test_CalculateOutcomeForObservations(t *testing.T) { Aggregation: sdk.AggregationType_AGGREGATION_TYPE_MEDIAN, }, }, - minObs: 5, + f: 4, expectedOutcome: values.Proto(values.NewInt64(30)), expectedError: nil, }, @@ -80,13 +79,28 @@ func Test_CalculateOutcomeForObservations(t *testing.T) { Aggregation: sdk.AggregationType_AGGREGATION_TYPE_IDENTICAL, }, }, - minObs: 5, f: 3, expectedOutcome: values.Proto(values.NewInt64(42)), expectedError: nil, }, { - name: "median: mixed types, one dominant (int64) - handled by filtering", + name: "median: mixed types, two eligible types (int64, float64) - error returned", + f: 1, + observations: []*valuespb.Value{ + values.Proto(values.NewInt64(10)), values.Proto(values.NewFloat64(1.0)), + values.Proto(values.NewInt64(20)), values.Proto(values.NewFloat64(2.0)), + values.Proto(values.NewInt64(30)), values.Proto(values.NewInt64(40)), + }, + descriptor: &sdk.ConsensusDescriptor{ + Descriptor_: &sdk.ConsensusDescriptor_Aggregation{ + Aggregation: sdk.AggregationType_AGGREGATION_TYPE_MEDIAN, + }, + }, + expectedError: ErrMultipleValuesMetThreshold, + }, + { + name: "median: mixed types, one eligible type (int64) - handled by filtering", + f: 2, observations: []*valuespb.Value{ values.Proto(values.NewInt64(10)), values.Proto(values.NewFloat64(1.0)), values.Proto(values.NewInt64(20)), values.Proto(values.NewFloat64(2.0)), @@ -155,7 +169,6 @@ func Test_CalculateOutcomeForObservations(t *testing.T) { mustWrap(s{Val: 50, OtherField: "common", PrefixSlice: []int64{1, 2, 6}, Nest: s1{Val: 102}, SuffixSlice: []int64{42, 2, 3}}), }, descriptor: cre.ConsensusAggregationFromTags[s]().Descriptor(), - minObs: 5, f: 2, expectedOutcome: mustWrap(s{ Val: 30, @@ -201,7 +214,6 @@ func Test_CalculateOutcomeForObservations(t *testing.T) { Aggregation: sdk.AggregationType_AGGREGATION_TYPE_UNSPECIFIED, }, }, - minObs: 1, expectedOutcome: nil, expectedError: errors.New("unknown aggregation type"), }, @@ -214,7 +226,6 @@ func Test_CalculateOutcomeForObservations(t *testing.T) { tc.observations, tc.descriptor, tc.defaultValue, - tc.minObs, tc.f, ) diff --git a/consensus/oracle/identical_observations_test.go b/consensus/oracle/identical_observations_test.go index 61b9af3ab..eb76fea36 100644 --- a/consensus/oracle/identical_observations_test.go +++ b/consensus/oracle/identical_observations_test.go @@ -254,7 +254,7 @@ func Test_filterObservations(t *testing.T) { minObservations: 3, expectedObservations: nil, expectedType: nil, - expectedError: errors.New("no single type met the minimum observation threshold of 3"), + expectedError: errors.New("no values met f+1 threshold"), }, { name: "dominant type is TypeNil", @@ -266,7 +266,7 @@ func Test_filterObservations(t *testing.T) { minObservations: 2, expectedObservations: nil, expectedType: nil, - expectedError: errors.New("no single type met the minimum observation threshold of 2"), + expectedError: errors.New("no values met f+1 threshold"), }, { name: "all observations are of dominant type", diff --git a/consensus/oracle/plugin/batching.go b/consensus/oracle/plugin/batching/batching.go similarity index 53% rename from consensus/oracle/plugin/batching.go rename to consensus/oracle/plugin/batching/batching.go index 1252d0765..881a677e5 100644 --- a/consensus/oracle/plugin/batching.go +++ b/consensus/oracle/plugin/batching/batching.go @@ -1,23 +1,14 @@ -package plugin +package batching import ( - "google.golang.org/protobuf/proto" + "context" - "github.com/smartcontractkit/capabilities/consensus/oracle" + "google.golang.org/protobuf/proto" ) -// IDKey represents a unique identifier for a ConsensusRequest used for deduplication -type IDKey struct { - workflowExecutionID string - referenceID string -} - -// GetIDKey creates a unique identifier from a ConsensusRequest for deduplication -func GetIDKey(rq *oracle.ConsensusRequest) IDKey { - return IDKey{ - workflowExecutionID: rq.Metadata.WorkflowExecutionID, - referenceID: rq.Metadata.ReferenceID, - } +type metrics interface { + IncBatchCapacityExceeded(ctx context.Context, step string) + IncBatchRequestsTotal(ctx context.Context, step string) } // varintSize calculates the size of a varint encoding @@ -46,24 +37,23 @@ func varintSize(x uint64) int { } } -// CalculateMessageSize calculates the marshalled size of any proto message -func CalculateMessageSize(message proto.Message) int { - if message == nil { - return 0 - } - +// calculateMessageSize calculates the marshalled size of any proto message +func calculateMessageSize(message proto.Message) int { // Use proto.Size which gives us the exact marshalled size return proto.Size(message) } -// BatchHasCapacity checks if adding a new proto message would exceed the size limit -func BatchHasCapacity(cachedSize int, message proto.Message, maxSizeBytes int, incBatchRequestsMetric func(), - incBatchSizeExceededMetric func()) (bool, int) { - incBatchRequestsMetric() +func batchHasCapacityForMessageOnSlice(cachedSize int, message proto.Message, maxSizeBytes int) (bool, int) { + numBytes := proto.Size(message) + return batchHasCapacityForSliceBytes(cachedSize, numBytes, maxSizeBytes) +} - // Calculate size if we add one more message - newMessageSize := proto.Size(message) +func batchHasCapacityForStringOnSlice(cachedSize int, message string, maxSizeBytes int) (bool, int) { + numBytes := len(message) + return batchHasCapacityForSliceBytes(cachedSize, numBytes, maxSizeBytes) +} +func batchHasCapacityForSliceBytes(cachedSize int, newMessageSize int, maxSizeBytes int) (bool, int) { // Add protobuf field overhead: tag (field number + wire type) + length prefix // For repeated fields in protobuf, each element gets: // - Tag: field number (1 for the repeated field) << 3 | wire type (2 for length-delimited) @@ -78,7 +68,6 @@ func BatchHasCapacity(cachedSize int, message proto.Message, maxSizeBytes int, i // Check against config if totalSizeWithNewMessage > maxSizeBytes { - incBatchSizeExceededMetric() // Stop adding more messages return false, cachedSize } diff --git a/consensus/oracle/plugin/batching/observation_batch.go b/consensus/oracle/plugin/batching/observation_batch.go new file mode 100644 index 000000000..9229590cd --- /dev/null +++ b/consensus/oracle/plugin/batching/observation_batch.go @@ -0,0 +1,78 @@ +package batching + +import ( + "context" + "fmt" + + "google.golang.org/protobuf/proto" + + oracletypes "github.com/smartcontractkit/capabilities/consensus/oracle/types" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" +) + +type ObservationBatch struct { + oracletypes.Observation + + lggr logger.Logger + currentSerialisedBatchSize int + metrics metrics + maxObservationLengthBytes int +} + +func NewObservationBatch(ctx context.Context, lggr logger.Logger, maxObservationLengthBytes int, metrics metrics) *ObservationBatch { + metrics.IncBatchRequestsTotal(ctx, "observation") + observations := make(map[string]*oracletypes.RequestObservation) + obs := &oracletypes.Observation{Observations: observations} + messageSize := calculateMessageSize(obs) + + return &ObservationBatch{ + Observation: oracletypes.Observation{Observations: observations}, + lggr: lggr, + currentSerialisedBatchSize: messageSize, + maxObservationLengthBytes: maxObservationLengthBytes, + metrics: metrics, + } +} + +func (ob *ObservationBatch) AddObservation(ctx context.Context, reqObs *oracletypes.RequestObservation) bool { + // Adding an entry to a map is the same as adding a key-value pair to a slice for size calculation purposes + mapEntry := oracletypes.ObservationMapEntry{ + Key: reqObs.Metadata.RequestId, + Value: reqObs, + } + + mapEntrySize := proto.Size(&mapEntry) + ok, newSize := batchHasCapacityForSliceBytes(ob.currentSerialisedBatchSize, mapEntrySize, ob.maxObservationLengthBytes) + + if !ok { + ob.metrics.IncBatchCapacityExceeded(ctx, "observation") + return false + } + + ob.currentSerialisedBatchSize = newSize + ob.Observations[reqObs.Metadata.RequestId] = reqObs + + return true +} + +func (ob *ObservationBatch) CurrentSerialisedBatchSize() int { + return ob.currentSerialisedBatchSize +} + +func (ob *ObservationBatch) SerialiseObservationBatch() ([]byte, error) { + serialisedBatch, err := proto.MarshalOptions{Deterministic: true}.Marshal(&ob.Observation) + if err != nil { + return nil, fmt.Errorf("failed to serialise batch of observations: %w", err) + } + + ob.lggr.Debugw("serialised observation batch", "numObservations", len(ob.Observations), "actualSizeBytes", len(serialisedBatch), + "calculatedSizeBytes", ob.currentSerialisedBatchSize, + "maxObservationLengthBytes", ob.maxObservationLengthBytes) + + return serialisedBatch, nil +} + +func (ob *ObservationBatch) NumObservationsInBatch() int { + return len(ob.Observations) +} diff --git a/consensus/oracle/plugin/batching/observation_batch_test.go b/consensus/oracle/plugin/batching/observation_batch_test.go new file mode 100644 index 000000000..60084f028 --- /dev/null +++ b/consensus/oracle/plugin/batching/observation_batch_test.go @@ -0,0 +1,83 @@ +package batching_test + +import ( + "math/rand" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/capabilities/consensus/oracle/plugin/batching" + oracletypes "github.com/smartcontractkit/capabilities/consensus/oracle/types" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" +) + +func TestObservationBatchCapacityCalculation(t *testing.T) { + testLogger := logger.Test(t) + ctx := t.Context() + + testMetrics := newTestMetrics(t, "observation") + observationBatch := batching.NewObservationBatch(ctx, testLogger, 100_000_000, testMetrics) + + for i := 0; i < 1000; i++ { + added := observationBatch.AddObservation(ctx, &oracletypes.RequestObservation{ + Metadata: &oracletypes.RequestMetaData{ + RequestId: uuid.NewString(), + WorkflowExecutionId: generateRandomStringBetweenBounds(1, 10000), + }, + Input: nil, + ReceivedAt: nil, + }) + + require.True(t, added) + + serialisedBatch, err := observationBatch.SerialiseObservationBatch() + require.NoError(t, err) + + require.Equal(t, observationBatch.CurrentSerialisedBatchSize(), len(serialisedBatch)) + } + + require.Equal(t, 1, testMetrics.batchRequestsTotal) + require.Equal(t, 0, testMetrics.batchCapacityExceeded) +} + +func generateRandomStringBetweenBounds(lowerBound int, upperBound int) string { + n := rand.Intn(upperBound-lowerBound) + lowerBound + runes := make([]rune, n) + for i := range runes { + runes[i] = 'A' + rune(rand.Intn(25)) + } + return string(runes) +} + +func TestObservationBatchCapacityExceeded(t *testing.T) { + testLogger := logger.Test(t) + ctx := t.Context() + + testMetrics := newTestMetrics(t, "observation") + observationBatch := batching.NewObservationBatch(ctx, testLogger, 100, testMetrics) + + addedAtLeastOnce := false + for i := 0; i < 1000; i++ { + added := observationBatch.AddObservation(ctx, &oracletypes.RequestObservation{ + Metadata: &oracletypes.RequestMetaData{ + RequestId: uuid.NewString(), + WorkflowExecutionId: "exec-1", + }, + Input: nil, + ReceivedAt: nil, + }) + + if !added { + require.Equal(t, 1, testMetrics.batchRequestsTotal) + require.Equal(t, 1, testMetrics.batchCapacityExceeded) + return + } + + addedAtLeastOnce = true + } + + require.True(t, addedAtLeastOnce) + t.Fatal("expected batch capacity to be exceeded") +} diff --git a/consensus/oracle/plugin/batching/outcome_batch.go b/consensus/oracle/plugin/batching/outcome_batch.go new file mode 100644 index 000000000..9aee1df60 --- /dev/null +++ b/consensus/oracle/plugin/batching/outcome_batch.go @@ -0,0 +1,196 @@ +package batching + +import ( + "context" + "fmt" + + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" + + oracletypes "github.com/smartcontractkit/capabilities/consensus/oracle/types" + + "github.com/smartcontractkit/chainlink-protos/cre/go/values" + + valuespb "github.com/smartcontractkit/chainlink-protos/cre/go/values/pb" + + "github.com/smartcontractkit/libocr/offchainreporting2plus/ocr3types" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" +) + +type OutcomeBatch struct { + oracletypes.Outcome + + lggr logger.Logger + + metrics metrics + + outctx ocr3types.OutcomeContext + + maxOutcomeLengthBytes int + + currentSerialisedBatchSize int + + keybundleIDForConsensusFailure string +} + +func NewOutcomeBatch(ctx context.Context, lggr logger.Logger, outctx ocr3types.OutcomeContext, outcomeExpirySeqNrSpan uint64, maxOutcomeLengthBytes int, + keybundleIDForConsensusFailure string, metrics metrics) (*OutcomeBatch, error) { + metrics.IncBatchRequestsTotal(ctx, "outcome") + historicalOutcomes, err := getNonExpiredHistoricalRequestOutcomes(lggr, outctx, outcomeExpirySeqNrSpan) + if err != nil { + return nil, fmt.Errorf("failed to get previous outcomes: %w", err) + } + + batchSize := calculateMessageSize(&oracletypes.Outcome{HistoricalOutcomes: historicalOutcomes}) + + return &OutcomeBatch{ + Outcome: oracletypes.Outcome{ + HistoricalOutcomes: historicalOutcomes, + }, + lggr: lggr, + outctx: outctx, + currentSerialisedBatchSize: batchSize, + keybundleIDForConsensusFailure: keybundleIDForConsensusFailure, + metrics: metrics, + maxOutcomeLengthBytes: maxOutcomeLengthBytes, + }, nil +} + +func (o *OutcomeBatch) CurrentSerialisedBatchSize() int { + return o.currentSerialisedBatchSize +} + +// AddSuccessfulConsensusRequestOutcomeToBatch adds a successful consensus request outcome to the outcome batch. Returns false if batch does not have capacity to add the outcome. +func (o *OutcomeBatch) AddSuccessfulConsensusRequestOutcomeToBatch(ctx context.Context, metadata *oracletypes.RequestMetaData, value *valuespb.Value, timestamp *timestamppb.Timestamp) (bool, error) { + requestID := metadata.RequestId + + serialisedValue, err := proto.MarshalOptions{Deterministic: true}.Marshal(value) + if err != nil { + return false, fmt.Errorf("failed to marshal successful consensus outcome value when adding request %s to batch: %w", requestID, err) + } + + requestOutcome := &oracletypes.ConsensusOutcome{ + Outcome: &oracletypes.ConsensusOutcome_Success{ + Success: &oracletypes.ConsensusSuccessOutcome{ + Metadata: metadata, + Outcome: serialisedValue, + Timestamp: timestamp, + }, + }, + } + + hasCapacity := o.checkOutcomeBatchHasCapacity(ctx, requestID, requestOutcome, o.outctx.SeqNr) + if !hasCapacity { + o.metrics.IncBatchCapacityExceeded(ctx, "outcome") + return false, nil + } + + o.Outcomes = append(o.Outcomes, requestOutcome) + o.HistoricalOutcomes[requestID] = o.outctx.SeqNr + + return true, nil +} + +// AddFailedConsensusRequestOutcomeToBatch adds a failed consensus request outcome to the outcome batch. Returns false if batch does not have capacity to add the outcome. +func (o *OutcomeBatch) AddFailedConsensusRequestOutcomeToBatch(ctx context.Context, requestID, failureMessage string) (bool, error) { + requestOutcome := &oracletypes.ConsensusOutcome{ + Outcome: &oracletypes.ConsensusOutcome_Failure{ + Failure: &oracletypes.ConsensusFailedOutcome{ + RequestID: requestID, + KeyBundleId: o.keybundleIDForConsensusFailure, + FailureMessage: failureMessage, + }, + }, + } + + hasCapacity := o.checkOutcomeBatchHasCapacity(ctx, requestID, requestOutcome, o.outctx.SeqNr) + if !hasCapacity { + o.metrics.IncBatchCapacityExceeded(ctx, "outcome") + return false, nil + } + + o.Outcomes = append(o.Outcomes, requestOutcome) + o.HistoricalOutcomes[requestID] = o.outctx.SeqNr + + return true, nil +} + +// FailConsensusWithDefaultCheck handles a consensus failure by checking if a default value is available to use. +// If a default value is available, it adds a successful consensus outcome with the default value to the batch. +// If no default value is available, it adds a failed consensus outcome to the batch. +func (o *OutcomeBatch) FailConsensusWithDefaultCheck(ctx context.Context, lggr logger.Logger, requestID string, consensusFailedMsg string, consensusMDD *oracletypes.RequestObservation, timestamp *timestamppb.Timestamp) (bool, error) { + lggr.Debug(consensusFailedMsg) + + defaultVal, err := values.FromProto(consensusMDD.Input.Default) + if err != nil { + errMsg := fmt.Sprintf("could not convert default value from proto for request %s: %v", requestID, err) + lggr.Error(errMsg) + return o.AddFailedConsensusRequestOutcomeToBatch(ctx, requestID, errMsg) + } + + if defaultVal != nil { + lggr.Debugw("using default value for request", "requestID", requestID, "defaultValue", defaultVal) + return o.AddSuccessfulConsensusRequestOutcomeToBatch(ctx, consensusMDD.Metadata, consensusMDD.Input.Default, timestamp) + } + + return o.AddFailedConsensusRequestOutcomeToBatch(ctx, requestID, consensusFailedMsg) +} + +func (o *OutcomeBatch) SerialiseOutcomeBatch() ([]byte, error) { + serialisedBatch, err := proto.MarshalOptions{Deterministic: true}.Marshal(&o.Outcome) + if err != nil { + return nil, fmt.Errorf("failed to serialise batch of outcomes: %w", err) + } + + o.lggr.Debugw("serialised outcome batch", "numOutcomes", len(o.Outcomes), + "actualSizeBytes", len(serialisedBatch), "calculatedSizeBytes", o.currentSerialisedBatchSize, + "maxOutcomeLengthBytes", o.maxOutcomeLengthBytes) + + return serialisedBatch, nil +} + +func (o *OutcomeBatch) checkOutcomeBatchHasCapacity(ctx context.Context, requestID string, requestOutcome proto.Message, + historicalSeqNr uint64) bool { + ok, newSize := batchHasCapacityForMessageOnSlice(o.currentSerialisedBatchSize, requestOutcome, o.maxOutcomeLengthBytes) + + if !ok { + return false + } + + // Adding an entry to a map is the same as adding a key-value pair to a slice for size calculation purposes + mapEntry := oracletypes.HistoricalOutcomeMapEntry{ + Key: requestID, + Value: historicalSeqNr, + } + mapEntrySize := proto.Size(&mapEntry) + + ok, newSize = batchHasCapacityForSliceBytes(newSize, mapEntrySize, o.maxOutcomeLengthBytes) + + if !ok { + return false + } + + o.currentSerialisedBatchSize = newSize + return true +} + +func getNonExpiredHistoricalRequestOutcomes(lggr logger.Logger, outctx ocr3types.OutcomeContext, outcomeExpirySeqNrSpan uint64) (map[string]uint64, error) { + nonExpiredHistoricalOutcomes := map[string]uint64{} + if outctx.PreviousOutcome != nil { + prevOutcome := &oracletypes.Outcome{} + err := proto.Unmarshal(outctx.PreviousOutcome, prevOutcome) + if err != nil { + lggr.Errorw("could not unmarshal previous outcome", "error", err) + return nil, err + } + + for requestID, outcomeSeqNr := range prevOutcome.HistoricalOutcomes { + if outctx.SeqNr-outcomeSeqNr <= outcomeExpirySeqNrSpan { + nonExpiredHistoricalOutcomes[requestID] = outcomeSeqNr + } + } + } + + return nonExpiredHistoricalOutcomes, nil +} diff --git a/consensus/oracle/plugin/batching/outcome_batch_test.go b/consensus/oracle/plugin/batching/outcome_batch_test.go new file mode 100644 index 000000000..cf0718034 --- /dev/null +++ b/consensus/oracle/plugin/batching/outcome_batch_test.go @@ -0,0 +1,118 @@ +package batching_test + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-protos/cre/go/values" + "github.com/smartcontractkit/libocr/offchainreporting2plus/ocr3types" + + "github.com/smartcontractkit/capabilities/consensus/oracle/plugin/batching" + oracletypes "github.com/smartcontractkit/capabilities/consensus/oracle/types" +) + +func TestOutcomeBatchCapacityCalculation(t *testing.T) { + testLogger := logger.Test(t) + ctx := t.Context() + + prevOutcome := &oracletypes.Outcome{ + HistoricalOutcomes: map[string]uint64{ + "req-1": 10, + "req-2": 20, + "req-3": 30, + }, + } + + serialisedPrevOutcome, err := proto.MarshalOptions{Deterministic: true}.Marshal(prevOutcome) + require.NoError(t, err) + + testMetrics := newTestMetrics(t, "outcome") + outcome, err := batching.NewOutcomeBatch(ctx, testLogger, ocr3types.OutcomeContext{ + PreviousOutcome: serialisedPrevOutcome, + SeqNr: 1000, + }, 1000, + 100_000_000, "evm", testMetrics) + + require.NoError(t, err) + + for i := 0; i < 1000; i++ { + added, err := outcome.AddSuccessfulConsensusRequestOutcomeToBatch(ctx, &oracletypes.RequestMetaData{ + RequestId: uuid.NewString(), + WorkflowExecutionId: generateRandomStringBetweenBounds(1, 10000), + }, values.Proto(values.NewString("test-outcome-data-1")), ×tamppb.Timestamp{}) + + require.True(t, added) + require.NoError(t, err) + + added, err = outcome.AddFailedConsensusRequestOutcomeToBatch(ctx, uuid.NewString(), generateRandomStringBetweenBounds(1, 10000)) + + require.True(t, added) + require.NoError(t, err) + + serialisedBatch, err := outcome.SerialiseOutcomeBatch() + require.NoError(t, err) + + require.Equal(t, outcome.CurrentSerialisedBatchSize(), len(serialisedBatch)) + } + + require.Equal(t, 1, testMetrics.batchRequestsTotal) + require.Equal(t, 0, testMetrics.batchCapacityExceeded) +} + +func TestOutcomeBatchCapacityExceeded(t *testing.T) { + testLogger := logger.Test(t) + ctx := t.Context() + + prevOutcome := &oracletypes.Outcome{ + HistoricalOutcomes: map[string]uint64{ + "req-1": 10, + "req-2": 20, + "req-3": 30, + }, + } + + serialisedPrevOutcome, err := proto.MarshalOptions{Deterministic: true}.Marshal(prevOutcome) + require.NoError(t, err) + + testMetrics := newTestMetrics(t, "outcome") + outcome, err := batching.NewOutcomeBatch(ctx, testLogger, ocr3types.OutcomeContext{ + PreviousOutcome: serialisedPrevOutcome, + SeqNr: 1000, + }, 1000, + 100, "evm", testMetrics) + + require.NoError(t, err) + + for i := 0; i < 1000; i++ { + added, err := outcome.AddSuccessfulConsensusRequestOutcomeToBatch(ctx, &oracletypes.RequestMetaData{ + RequestId: uuid.NewString(), + WorkflowExecutionId: "exec-1", + }, values.Proto(values.NewString("test-outcome-data-1")), ×tamppb.Timestamp{}) + + if !added { + require.Equal(t, 1, testMetrics.batchRequestsTotal) + require.Equal(t, 1, testMetrics.batchCapacityExceeded) + return + } + require.NoError(t, err) + + added, err = outcome.AddFailedConsensusRequestOutcomeToBatch(ctx, uuid.NewString(), "failed") + + if !added { + return + } + require.NoError(t, err) + + serialisedBatch, err := outcome.SerialiseOutcomeBatch() + require.NoError(t, err) + + require.Equal(t, outcome.CurrentSerialisedBatchSize(), len(serialisedBatch)) + } + + t.Fatal("expected batch capacity to be exceeded") +} diff --git a/consensus/oracle/plugin/batching/query_batch.go b/consensus/oracle/plugin/batching/query_batch.go new file mode 100644 index 000000000..13f7c828c --- /dev/null +++ b/consensus/oracle/plugin/batching/query_batch.go @@ -0,0 +1,70 @@ +package batching + +import ( + "context" + "fmt" + + "google.golang.org/protobuf/proto" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + + oracletypes "github.com/smartcontractkit/capabilities/consensus/oracle/types" +) + +type QueryBatch struct { + oracletypes.Query + + lggr logger.Logger + currentSerialisedBatchSize int + metrics metrics + maxQueryLengthBytes int +} + +func NewQueryBatch(ctx context.Context, lggr logger.Logger, maxQueryLengthBytes int, metrics metrics) *QueryBatch { + metrics.IncBatchRequestsTotal(ctx, "query") + obs := &oracletypes.Query{RequestIDs: nil} + messageSize := calculateMessageSize(obs) + + return &QueryBatch{ + Query: oracletypes.Query{RequestIDs: nil}, + lggr: lggr, + currentSerialisedBatchSize: messageSize, + maxQueryLengthBytes: maxQueryLengthBytes, + metrics: metrics, + } +} + +func (qb *QueryBatch) AddRequestID(ctx context.Context, requestID string) bool { + hasCapacity, newSize := batchHasCapacityForStringOnSlice(qb.currentSerialisedBatchSize, requestID, qb.maxQueryLengthBytes) + + if !hasCapacity { + qb.metrics.IncBatchCapacityExceeded(ctx, "query") + return false + } + + qb.RequestIDs = append(qb.RequestIDs, requestID) + qb.currentSerialisedBatchSize = newSize + + return true +} + +func (qb *QueryBatch) CurrentSerialisedBatchSize() int { + return qb.currentSerialisedBatchSize +} + +func (qb *QueryBatch) SerialiseQueryBatch() ([]byte, error) { + serialisedBatch, err := proto.MarshalOptions{Deterministic: true}.Marshal(&qb.Query) + if err != nil { + return nil, fmt.Errorf("failed to serialise batch of request ids: %w", err) + } + + qb.lggr.Debugw("serialised batch of request ids", "numRequests", len(qb.RequestIDs), + "actualSizeBytes", len(serialisedBatch), "calculatedSizeBytes", qb.currentSerialisedBatchSize, + "maxQueryLengthBytes", qb.maxQueryLengthBytes) + + return serialisedBatch, nil +} + +func (qb *QueryBatch) NumberOfRequestIDs() int { + return len(qb.RequestIDs) +} diff --git a/consensus/oracle/plugin/batching/query_batch_test.go b/consensus/oracle/plugin/batching/query_batch_test.go new file mode 100644 index 000000000..068024a71 --- /dev/null +++ b/consensus/oracle/plugin/batching/query_batch_test.go @@ -0,0 +1,87 @@ +package batching_test + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + + "github.com/smartcontractkit/capabilities/consensus/oracle/plugin/batching" +) + +func TestQueryBatchCapacityCalculation(t *testing.T) { + testLogger := logger.Test(t) + ctx := t.Context() + + testMetrics := newTestMetrics(t, "query") + queryBatch := batching.NewQueryBatch(ctx, testLogger, 100000000, testMetrics) + + for i := 0; i < 1000; i++ { + added := queryBatch.AddRequestID(ctx, uuid.NewString()) + + require.True(t, added) + + serialisedBatch, err := queryBatch.SerialiseQueryBatch() + require.NoError(t, err) + + require.Equal(t, queryBatch.CurrentSerialisedBatchSize(), len(serialisedBatch)) + } + + require.Equal(t, 1, testMetrics.batchRequestsTotal) + require.Equal(t, 0, testMetrics.batchCapacityExceeded) +} + +func TestQueryBatchCapacityExceeded(t *testing.T) { + testLogger := logger.Test(t) + ctx := t.Context() + + testMetrics := newTestMetrics(t, "query") + + queryBatch := batching.NewQueryBatch(ctx, testLogger, 100, testMetrics) + + for i := 0; i < 1000; i++ { + added := queryBatch.AddRequestID(ctx, uuid.NewString()) + + if !added { + require.Equal(t, 1, testMetrics.batchRequestsTotal) + require.Equal(t, 1, testMetrics.batchCapacityExceeded) + return + } + + require.True(t, added) + + serialisedBatch, err := queryBatch.SerialiseQueryBatch() + require.NoError(t, err) + + require.Equal(t, queryBatch.CurrentSerialisedBatchSize(), len(serialisedBatch)) + } + + t.Fatal("expected batch capacity to be exceeded") +} + +type testMetrics struct { + t *testing.T + stepName string + batchRequestsTotal int + batchCapacityExceeded int +} + +func newTestMetrics(t *testing.T, stepName string) *testMetrics { + return &testMetrics{ + t: t, + stepName: stepName, + } +} + +func (tm *testMetrics) IncBatchRequestsTotal(_ context.Context, stepName string) { + require.Equal(tm.t, tm.stepName, stepName) + tm.batchRequestsTotal++ +} + +func (tm *testMetrics) IncBatchCapacityExceeded(_ context.Context, stepName string) { + require.Equal(tm.t, tm.stepName, stepName) + tm.batchCapacityExceeded++ +} diff --git a/consensus/oracle/plugin/batching_test.go b/consensus/oracle/plugin/batching_test.go deleted file mode 100644 index f8ffae12c..000000000 --- a/consensus/oracle/plugin/batching_test.go +++ /dev/null @@ -1,596 +0,0 @@ -package plugin - -import ( - "testing" - - "github.com/stretchr/testify/require" - "google.golang.org/protobuf/proto" - - "github.com/smartcontractkit/chainlink-common/pkg/capabilities" - "github.com/smartcontractkit/chainlink-protos/cre/go/sdk" - "github.com/smartcontractkit/chainlink-protos/cre/go/values" - - "github.com/smartcontractkit/capabilities/consensus/oracle" - oracletypes "github.com/smartcontractkit/capabilities/consensus/oracle/types" -) - -func TestQueryBatchHasCapacity_SizeEstimation(t *testing.T) { - t.Run("size estimation accuracy", func(t *testing.T) { - // Create test requests with varying sizes - testCases := []struct { - name string - request *oracletypes.Request - }{ - { - name: "minimal request", - request: &oracletypes.Request{ - Metadata: &oracletypes.RequestMetaData{ - RequestId: "test-1", - }, - RequestConsensusDescriptor: []byte("small"), - }, - }, - { - name: "medium request", - request: &oracletypes.Request{ - Metadata: &oracletypes.RequestMetaData{ - RequestId: "test-request-with-longer-id", - WorkflowExecutionId: "workflow-exec-123", - WorkflowStepReference: "step-ref-456", - WorkflowId: "workflow-789", - WorkflowOwner: "owner@example.com", - WorkflowName: "test-workflow", - WorkflowDonId: 12345, - WorkflowDonConfigVersion: 1, - ReportId: "report-abc", - KeyBundleId: "key-bundle-def", - RequestType: oracletypes.RequestType_VALUE_CONSENSUS, - }, - RequestConsensusDescriptor: []byte("medium-sized-consensus-descriptor-data"), - }, - }, - { - name: "large request", - request: &oracletypes.Request{ - Metadata: &oracletypes.RequestMetaData{ - RequestId: "very-long-request-id-with-lots-of-characters-to-make-it-large", - WorkflowExecutionId: "very-long-workflow-execution-id-with-many-characters", - WorkflowStepReference: "very-long-workflow-step-reference-with-many-characters", - WorkflowId: "very-long-workflow-id-with-many-characters", - WorkflowOwner: "very-long-workflow-owner-email@example.com", - WorkflowName: "very-long-workflow-name-with-many-characters", - WorkflowDonId: 4294967295, // max uint32 - WorkflowDonConfigVersion: 4294967295, // max uint32 - ReportId: "very-long-report-id-with-many-characters", - KeyBundleId: "very-long-key-bundle-id-with-many-characters", - RequestType: oracletypes.RequestType_REPORT_GENERATION, - }, - RequestConsensusDescriptor: make([]byte, 1000), // 1KB of data - }, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - // Test starting from empty query - initialSize := 0 - maxSize := 10000 // 10KB limit - - // Get estimated size from our function - hasCapacity, estimatedTotalSize := BatchHasCapacity(initialSize, tc.request, maxSize, func() {}, func() {}) - require.True(t, hasCapacity, "Should have capacity for single request") - - // Create actual query with the request and measure real marshalled bytes length - query := &oracletypes.Query{ - Requests: []*oracletypes.Request{tc.request}, - } - marshalledBytes, err := proto.MarshalOptions{Deterministic: true}.Marshal(query) - require.NoError(t, err, "Failed to marshal query") - actualMarshalledSize := len(marshalledBytes) - - t.Logf("Initial size: %d, Estimated total size: %d, Actual marshalled bytes length: %d", - initialSize, estimatedTotalSize, actualMarshalledSize) - - // The estimation should be exactly equal to actual marshalled bytes length - require.Equal(t, actualMarshalledSize, estimatedTotalSize, - "Size estimation should be exactly equal to actual marshalled bytes length. Estimated: %d, Actual: %d", - estimatedTotalSize, actualMarshalledSize) - }) - } - }) - - t.Run("multiple requests deterministic size calculation", func(t *testing.T) { - // Start with empty query - initialSize := 0 - - // Create multiple test requests with different sizes - requests := []*oracletypes.Request{ - { - Metadata: &oracletypes.RequestMetaData{ - RequestId: "req-1", - }, - RequestConsensusDescriptor: []byte("small"), - }, - { - Metadata: &oracletypes.RequestMetaData{ - RequestId: "req-2-with-longer-id", - WorkflowExecutionId: "workflow-exec-123", - WorkflowStepReference: "step-ref-456", - WorkflowId: "workflow-789", - WorkflowOwner: "owner@example.com", - WorkflowName: "test-workflow", - WorkflowDonId: 12345, - WorkflowDonConfigVersion: 1, - }, - RequestConsensusDescriptor: []byte("medium-sized-consensus-descriptor"), - }, - { - Metadata: &oracletypes.RequestMetaData{ - RequestId: "req-3-large", - }, - RequestConsensusDescriptor: make([]byte, 500), // Large descriptor - }, - } - - var actualRequests []*oracletypes.Request - currentSize := initialSize - - // Add requests one by one and verify size calculation at each step - for i, newReq := range requests { - // Calculate estimated size after adding this request - maxSize := 10000 // Large enough limit - hasCapacity, estimatedSize := BatchHasCapacity(currentSize, newReq, maxSize, func() {}, func() {}) - require.True(t, hasCapacity, "Should have capacity for request %d", i) - - // Actually add the request and calculate real marshalled bytes length - actualRequests = append(actualRequests, newReq) - queryWithAllData := &oracletypes.Query{Requests: actualRequests} - marshalledBytes, err := proto.MarshalOptions{Deterministic: true}.Marshal(queryWithAllData) - require.NoError(t, err, "Failed to marshal query at step %d", i+1) - actualSize := len(marshalledBytes) - - t.Logf("Step %d: Estimated size: %d, Actual marshalled bytes length: %d", i+1, estimatedSize, actualSize) - - // Verify exact match - require.Equal(t, actualSize, estimatedSize, - "Size estimation should be exactly equal at step %d. Estimated: %d, Actual: %d", - i+1, estimatedSize, actualSize) - - // Update current size for next iteration - currentSize = estimatedSize - } - }) - - t.Run("capacity config respected", func(t *testing.T) { - request := &oracletypes.Request{ - Metadata: &oracletypes.RequestMetaData{ - RequestId: "test", - }, - RequestConsensusDescriptor: make([]byte, 100), - } - - // First, calculate the actual size of the request to set realistic config - actualRequestSize := CalculateMessageSize(request) - t.Logf("Actual request size: %d bytes", actualRequestSize) - - // Test with limit smaller than request size - smallLimit := actualRequestSize - 1 - hasCapacity, _ := BatchHasCapacity(0, request, smallLimit, func() {}, func() {}) - require.False(t, hasCapacity, "Should not have capacity when request exceeds limit") - - // Test with adequate limit - largeLimit := actualRequestSize + 100 - hasCapacity, _ = BatchHasCapacity(0, request, largeLimit, func() {}, func() {}) - require.True(t, hasCapacity, "Should have capacity when request is within limit") - - // Test cumulative size checking - use current size that would cause overflow - currentSize := largeLimit - actualRequestSize + 1 - hasCapacity, _ = BatchHasCapacity(currentSize, request, largeLimit, func() {}, func() {}) - require.False(t, hasCapacity, "Should not have capacity when cumulative size would exceed limit") - }) -} - -func TestGetIDKey_DuplicateRecognition(t *testing.T) { - t.Run("identical requests produce same key", func(t *testing.T) { - metadata := oracle.ConsensusRequestMetadata{ - RequestMetadata: capabilities.RequestMetadata{ - WorkflowExecutionID: "exec-123", - ReferenceID: "ref-456", - WorkflowID: "workflow-789", - WorkflowOwner: "owner@example.com", - }, - } - - value1Pb := values.Proto(values.NewString("value1")) - value2Pb := values.Proto(values.NewString("value2")) - - request1 := &oracle.ConsensusRequest{ - Metadata: metadata, - Input: &sdk.SimpleConsensusInputs{ - // Different input data - Observation: &sdk.SimpleConsensusInputs_Value{ - Value: value1Pb, - }, - }, - } - - request2 := &oracle.ConsensusRequest{ - Metadata: metadata, - Input: &sdk.SimpleConsensusInputs{ - // Different input data but same metadata - Observation: &sdk.SimpleConsensusInputs_Value{ - Value: value2Pb, - }, - }, - } - - key1 := GetIDKey(request1) - key2 := GetIDKey(request2) - - require.Equal(t, key1, key2, "Requests with same metadata should produce identical keys") - }) - - t.Run("different requests produce different keys", func(t *testing.T) { - baseMetadata := capabilities.RequestMetadata{ - WorkflowExecutionID: "exec-123", - ReferenceID: "ref-456", - WorkflowID: "workflow-789", - WorkflowOwner: "owner@example.com", - } - - testCases := []struct { - name string - metadata oracle.ConsensusRequestMetadata - }{ - { - name: "different execution ID", - metadata: oracle.ConsensusRequestMetadata{ - RequestMetadata: capabilities.RequestMetadata{ - WorkflowExecutionID: "exec-different", - ReferenceID: baseMetadata.ReferenceID, - WorkflowID: baseMetadata.WorkflowID, - WorkflowOwner: baseMetadata.WorkflowOwner, - }, - }, - }, - { - name: "different reference ID", - metadata: oracle.ConsensusRequestMetadata{ - RequestMetadata: capabilities.RequestMetadata{ - WorkflowExecutionID: baseMetadata.WorkflowExecutionID, - ReferenceID: "ref-different", - WorkflowID: baseMetadata.WorkflowID, - WorkflowOwner: baseMetadata.WorkflowOwner, - }, - }, - }, - } - - baseRequest := &oracle.ConsensusRequest{ - Metadata: oracle.ConsensusRequestMetadata{RequestMetadata: baseMetadata}, - } - baseKey := GetIDKey(baseRequest) - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - request := &oracle.ConsensusRequest{Metadata: tc.metadata} - key := GetIDKey(request) - - require.NotEqual(t, baseKey, key, "Different metadata should produce different keys") - }) - } - }) - - t.Run("duplicate detection in map", func(t *testing.T) { - // Simulate the duplicate detection logic from plugin.go - seenIDs := make(map[IDKey]bool) - - metadata := oracle.ConsensusRequestMetadata{ - RequestMetadata: capabilities.RequestMetadata{ - WorkflowExecutionID: "exec-123", - ReferenceID: "ref-456", - WorkflowID: "workflow-789", - WorkflowOwner: "owner@example.com", - }, - } - - requests := []*oracle.ConsensusRequest{ - {Metadata: metadata, RequestID: "req-1"}, - {Metadata: metadata, RequestID: "req-2"}, // Same metadata, different ID - { - Metadata: oracle.ConsensusRequestMetadata{ - RequestMetadata: capabilities.RequestMetadata{ - WorkflowExecutionID: "exec-different", - ReferenceID: "ref-456", - WorkflowID: "workflow-789", - WorkflowOwner: "owner@example.com", - }, - }, - RequestID: "req-3", - }, // Different metadata - } - - var processedRequests []*oracle.ConsensusRequest - - for _, rq := range requests { - key := GetIDKey(rq) - - if seenIDs[key] { - continue // Skip duplicate - } - - seenIDs[key] = true - processedRequests = append(processedRequests, rq) - } - - require.Len(t, processedRequests, 2, "Should process 2 unique requests (first two are duplicates)") - require.Equal(t, "req-1", processedRequests[0].RequestID, "First unique request should be processed") - require.Equal(t, "req-3", processedRequests[1].RequestID, "Second unique request should be processed") - }) -} - -func TestObservationsBatchHasCapacity_SizeEstimation(t *testing.T) { - t.Run("observations size estimation accuracy", func(t *testing.T) { - // Create test observation message - obs := &oracletypes.Observation{ - Observations: []*oracletypes.RequestObservation{}, - } - - // Test initial size calculation - initialSize := CalculateMessageSize(obs) - require.GreaterOrEqual(t, initialSize, 0, "Empty observation should have non-negative size") - - // Create a test RequestObservation - requestObs := &oracletypes.RequestObservation{ - Metadata: &oracletypes.RequestMetaData{ - RequestId: "test-request-123", - WorkflowExecutionId: "exec-456", - }, - Observation: []byte("test-observation-data"), - } - - // Test capacity checking - maxSize := 1000 - hasCapacity, estimatedNewSize := BatchHasCapacity(initialSize, requestObs, maxSize, func() {}, func() {}) - require.True(t, hasCapacity, "Should have capacity for reasonable-sized observation") - require.Greater(t, estimatedNewSize, initialSize, "New size should be larger than initial size") - - // Create actual observation with the request observation and calculate real marshalled bytes length - obsWithData := &oracletypes.Observation{ - Observations: []*oracletypes.RequestObservation{requestObs}, - } - marshalledBytes, err := proto.MarshalOptions{Deterministic: true}.Marshal(obsWithData) - require.NoError(t, err, "Failed to marshal observation") - actualMarshalledSize := len(marshalledBytes) - - t.Logf("Initial size: %d, Estimated new size: %d, Actual marshalled bytes length: %d", - initialSize, estimatedNewSize, actualMarshalledSize) - - // The estimation should be exactly equal to actual marshalled bytes length - require.Equal(t, actualMarshalledSize, estimatedNewSize, - "Size estimation should be exactly equal to actual marshalled bytes length. Estimated: %d, Actual: %d", - estimatedNewSize, actualMarshalledSize) - }) - - t.Run("multiple observations deterministic size calculation", func(t *testing.T) { - // Start with empty observation - obs := &oracletypes.Observation{Observations: []*oracletypes.RequestObservation{}} - currentSize := CalculateMessageSize(obs) - - // Create multiple test RequestObservations with different sizes - observations := []*oracletypes.RequestObservation{ - { - Metadata: &oracletypes.RequestMetaData{ - RequestId: "req-1", - WorkflowExecutionId: "exec-1", - }, - Observation: []byte("small"), - }, - { - Metadata: &oracletypes.RequestMetaData{ - RequestId: "req-2-with-longer-id", - WorkflowExecutionId: "exec-2-longer", - WorkflowStepReference: "step-ref", - WorkflowId: "workflow-id", - WorkflowOwner: "owner@example.com", - WorkflowName: "test-workflow", - WorkflowDonId: 12345, - WorkflowDonConfigVersion: 1, - }, - Observation: []byte("medium-sized-observation-data"), - }, - { - Metadata: &oracletypes.RequestMetaData{ - RequestId: "req-3", - }, - Observation: make([]byte, 200), // Large observation - }, - } - - var actualObservations []*oracletypes.RequestObservation - - // Add observations one by one and verify size calculation at each step - for i, newObs := range observations { - // Calculate estimated size after adding this observation - maxSize := 10000 // Large enough limit - hasCapacity, estimatedSize := BatchHasCapacity(currentSize, newObs, maxSize, func() {}, func() {}) - require.True(t, hasCapacity, "Should have capacity for observation %d", i) - - // Actually add the observation and calculate real marshalled bytes length - actualObservations = append(actualObservations, newObs) - obsWithAllData := &oracletypes.Observation{Observations: actualObservations} - marshalledBytes, err := proto.MarshalOptions{Deterministic: true}.Marshal(obsWithAllData) - require.NoError(t, err, "Failed to marshal observation at step %d", i+1) - actualSize := len(marshalledBytes) - - t.Logf("Step %d: Estimated size: %d, Actual marshalled bytes length: %d", i+1, estimatedSize, actualSize) - - // Verify exact match - require.Equal(t, actualSize, estimatedSize, - "Size estimation should be exactly equal at step %d. Estimated: %d, Actual: %d", - i+1, estimatedSize, actualSize) - - // Update current size for next iteration - currentSize = estimatedSize - } - }) - - t.Run("observations capacity config respected", func(t *testing.T) { - obs := &oracletypes.Observation{Observations: []*oracletypes.RequestObservation{}} - initialSize := CalculateMessageSize(obs) - - // Create a large observation - largeObs := &oracletypes.RequestObservation{ - Metadata: &oracletypes.RequestMetaData{ - RequestId: "test", - }, - Observation: make([]byte, 500), // 500 bytes of data - } - - actualObsSize := CalculateMessageSize(largeObs) - t.Logf("Large observation size: %d bytes", actualObsSize) - - // Test with limit smaller than observation size - smallLimit := actualObsSize - 1 - hasCapacity, _ := BatchHasCapacity(initialSize, largeObs, smallLimit, func() {}, func() {}) - require.False(t, hasCapacity, "Should not have capacity when observation would exceed limit") - - // Test with adequate limit - largeLimit := initialSize + actualObsSize + 100 - hasCapacity, _ = BatchHasCapacity(initialSize, largeObs, largeLimit, func() {}, func() {}) - require.True(t, hasCapacity, "Should have capacity when observation is within limit") - }) -} - -func TestOutcomeBatchHasCapacity_SizeEstimation(t *testing.T) { - t.Run("outcome size estimation accuracy", func(t *testing.T) { - // Create test outcome message - outcome := &oracletypes.Outcome{ - Outcomes: []*oracletypes.RequestOutcome{}, - } - - // Test initial size calculation - initialSize := CalculateMessageSize(outcome) - require.GreaterOrEqual(t, initialSize, 0, "Empty outcome should have non-negative size") - - // Create a test RequestOutcome - requestOutcome := &oracletypes.RequestOutcome{ - Metadata: &oracletypes.RequestMetaData{ - RequestId: "test-request-123", - WorkflowExecutionId: "exec-456", - }, - Outcome: []byte("test-outcome-data"), - } - - // Test capacity checking - maxSize := 1000 - hasCapacity, estimatedNewSize := BatchHasCapacity(initialSize, requestOutcome, maxSize, func() {}, func() {}) - require.True(t, hasCapacity, "Should have capacity for reasonable-sized outcome") - require.Greater(t, estimatedNewSize, initialSize, "New size should be larger than initial size") - - // Create actual outcome with the request outcome and calculate real marshalled bytes length - outcomeWithData := &oracletypes.Outcome{ - Outcomes: []*oracletypes.RequestOutcome{requestOutcome}, - } - marshalledBytes, err := proto.MarshalOptions{Deterministic: true}.Marshal(outcomeWithData) - require.NoError(t, err, "Failed to marshal outcome") - actualMarshalledSize := len(marshalledBytes) - - t.Logf("Initial size: %d, Estimated new size: %d, Actual marshalled bytes length: %d", - initialSize, estimatedNewSize, actualMarshalledSize) - - // The estimation should be exactly equal to actual marshalled bytes length - require.Equal(t, actualMarshalledSize, estimatedNewSize, - "Size estimation should be exactly equal to actual marshalled bytes length. Estimated: %d, Actual: %d", - estimatedNewSize, actualMarshalledSize) - }) - - t.Run("multiple outcomes deterministic size calculation", func(t *testing.T) { - // Start with empty outcome - outcome := &oracletypes.Outcome{Outcomes: []*oracletypes.RequestOutcome{}} - currentSize := CalculateMessageSize(outcome) - - // Create multiple test RequestOutcomes with different sizes - outcomes := []*oracletypes.RequestOutcome{ - { - Metadata: &oracletypes.RequestMetaData{ - RequestId: "req-1", - WorkflowExecutionId: "exec-1", - }, - Outcome: []byte("small"), - }, - { - Metadata: &oracletypes.RequestMetaData{ - RequestId: "req-2-with-longer-id", - WorkflowExecutionId: "exec-2-longer", - WorkflowStepReference: "step-ref", - WorkflowId: "workflow-id", - WorkflowOwner: "owner@example.com", - WorkflowName: "test-workflow", - WorkflowDonId: 12345, - WorkflowDonConfigVersion: 1, - }, - Outcome: []byte("medium-sized-outcome-data"), - }, - { - Metadata: &oracletypes.RequestMetaData{ - RequestId: "req-3", - }, - Outcome: make([]byte, 200), // Large outcome - }, - } - - var actualOutcomes []*oracletypes.RequestOutcome - - // Add outcomes one by one and verify size calculation at each step - for i, newOutcome := range outcomes { - // Calculate estimated size after adding this outcome - maxSize := 10000 // Large enough limit - hasCapacity, estimatedSize := BatchHasCapacity(currentSize, newOutcome, maxSize, func() {}, func() {}) - require.True(t, hasCapacity, "Should have capacity for outcome %d", i) - - // Actually add the outcome and calculate real marshalled bytes length - actualOutcomes = append(actualOutcomes, newOutcome) - outcomeWithAllData := &oracletypes.Outcome{Outcomes: actualOutcomes} - marshalledBytes, err := proto.MarshalOptions{Deterministic: true}.Marshal(outcomeWithAllData) - require.NoError(t, err, "Failed to marshal outcome at step %d", i+1) - actualSize := len(marshalledBytes) - - t.Logf("Step %d: Estimated size: %d, Actual marshalled bytes length: %d", i+1, estimatedSize, actualSize) - - // Verify exact match - require.Equal(t, actualSize, estimatedSize, - "Size estimation should be exactly equal at step %d. Estimated: %d, Actual: %d", - i+1, estimatedSize, actualSize) - - // Update current size for next iteration - currentSize = estimatedSize - } - }) - - t.Run("outcome capacity config respected", func(t *testing.T) { - outcome := &oracletypes.Outcome{Outcomes: []*oracletypes.RequestOutcome{}} - initialSize := CalculateMessageSize(outcome) - - // Create a large outcome - largeOutcome := &oracletypes.RequestOutcome{ - Metadata: &oracletypes.RequestMetaData{ - RequestId: "test", - }, - Outcome: make([]byte, 500), // 500 bytes of data - } - - actualOutcomeSize := CalculateMessageSize(largeOutcome) - t.Logf("Large outcome size: %d bytes", actualOutcomeSize) - - // Test with limit smaller than outcome size - smallLimit := actualOutcomeSize - 1 - hasCapacity, _ := BatchHasCapacity(initialSize, largeOutcome, smallLimit, func() {}, func() {}) - require.False(t, hasCapacity, "Should not have capacity when outcome would exceed limit") - - // Test with adequate limit - largeLimit := initialSize + actualOutcomeSize + 100 - hasCapacity, _ = BatchHasCapacity(initialSize, largeOutcome, largeLimit, func() {}, func() {}) - require.True(t, hasCapacity, "Should have capacity when outcome is within limit") - }) -} diff --git a/consensus/oracle/plugin/duplicate_outcomes_test.go b/consensus/oracle/plugin/duplicate_outcomes_test.go index 5c501e6aa..d01d9c433 100644 --- a/consensus/oracle/plugin/duplicate_outcomes_test.go +++ b/consensus/oracle/plugin/duplicate_outcomes_test.go @@ -7,13 +7,14 @@ import ( "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/structpb" + "github.com/smartcontractkit/capabilities/consensus/oracle" + oracletypes "github.com/smartcontractkit/capabilities/consensus/oracle/types" + "github.com/smartcontractkit/chainlink-common/pkg/logger" "github.com/smartcontractkit/chainlink-protos/cre/go/values" - "github.com/smartcontractkit/libocr/offchainreporting2plus/ocr3types" - "github.com/smartcontractkit/capabilities/consensus/oracle" - oracletypes "github.com/smartcontractkit/capabilities/consensus/oracle/types" + "github.com/smartcontractkit/libocr/offchainreporting2plus/ocr3types" ) func Test_DuplicateOutcomePrevention(t *testing.T) { @@ -44,7 +45,7 @@ func Test_DuplicateOutcomePrevention(t *testing.T) { }, } - pluginsAndStores := createPluginsAndStores(n, t, lggr, f, batchSize, 5) + pluginsAndStores := createPluginsAndStores(n, t, lggr, f, 5) addRequestsToAllStores(pluginsAndStores, reqToObservations, t) @@ -92,7 +93,7 @@ func Test_HistoricalOutcomesAreRemovedOnExpiry(t *testing.T) { verifyReport: verifyReport1, }, - // Consensus will fail as insufficient observations and the request will remain pending + // Consensus will remain pending as < 2f+1 observations are received initially pendingRequest.RequestID(): {requests: []*oracle.ConsensusRequest{ newCr(t, 110, pendingRequest), nil, newCr(t, 130, pendingRequest), newCr(t, 140, pendingRequest), nil, newCr(t, 160, pendingRequest), @@ -102,24 +103,22 @@ func Test_HistoricalOutcomesAreRemovedOnExpiry(t *testing.T) { } historicalOutcomeExpirySpan := uint64(3) - pluginsAndStores := createPluginsAndStores(n, t, lggr, f, batchSize, historicalOutcomeExpirySpan) + pluginsAndStores := createPluginsAndStores(n, t, lggr, f, historicalOutcomeExpirySpan) addRequestsToAllStores(pluginsAndStores, reqToObservations, t) - postProtocolRound := func(t *testing.T, outcome *oracletypes.Outcome, requestIDToOutcome map[string]*oracletypes.RequestOutcome, - requestIDToHistoricalOutcome map[string]*oracletypes.HistoricalRequestOutcome) { + postProtocolRound := func(t *testing.T, outcome *oracletypes.Outcome, requestIDToOutcome map[string]*oracletypes.ConsensusSuccessOutcome, + requestIDToHistoricalOutcome map[string]uint64) { // If a request is successful post round remove it here - for requestID, ro := range requestIDToOutcome { - if ro.Status == oracletypes.RequestStatus_REQUEST_STATUS_CONSENSUS_SUCCESS { - removeRequestFromAllStores(pluginsAndStores, requestID) - } + for requestID := range requestIDToOutcome { + removeRequestFromAllStores(pluginsAndStores, requestID) } } var previousOutcome []byte - for seqNr := uint64(1); seqNr <= 6; seqNr++ { - var verifyOutcome func(t *testing.T, outcome *oracletypes.Outcome, requestIDToOutcome map[string]*oracletypes.RequestOutcome, - requestIDToHistoricalOutcome map[string]*oracletypes.HistoricalRequestOutcome) + for seqNr := uint64(1); seqNr <= 10; seqNr++ { + var verifyOutcome func(t *testing.T, outcome *oracletypes.Outcome, requestIDToOutcome map[string]*oracletypes.ConsensusSuccessOutcome, + requestIDToHistoricalOutcome map[string]uint64) switch seqNr { case 1: @@ -130,12 +129,12 @@ func Test_HistoricalOutcomesAreRemovedOnExpiry(t *testing.T) { outcome2 := reqToObservations[pendingRequest.RequestID()] outcome2.verifyReport = nil - verifyOutcome = func(t *testing.T, outcome *oracletypes.Outcome, requestIDToOutcome map[string]*oracletypes.RequestOutcome, - requestIDToHistoricalOutcome map[string]*oracletypes.HistoricalRequestOutcome) { + verifyOutcome = func(t *testing.T, outcome *oracletypes.Outcome, requestIDToOutcome map[string]*oracletypes.ConsensusSuccessOutcome, + requestIDToHistoricalOutcome map[string]uint64) { require.Len(t, outcome.Outcomes, 1) - require.Len(t, outcome.HistoricalOutcomes, 2) + require.Len(t, outcome.HistoricalOutcomes, 1) - require.Equal(t, requestIDToHistoricalOutcome[successfulRequest.RequestID()].Status, oracletypes.RequestStatus_REQUEST_STATUS_CONSENSUS_SUCCESS) + require.NotNil(t, requestIDToHistoricalOutcome[successfulRequest.RequestID()]) } case 2: @@ -149,10 +148,10 @@ func Test_HistoricalOutcomesAreRemovedOnExpiry(t *testing.T) { outcome2.verifyReport = nil verifyOutcome = func(t *testing.T, outcome *oracletypes.Outcome, - requestIDToOutcome map[string]*oracletypes.RequestOutcome, - requestIDToHistoricalOutcome map[string]*oracletypes.HistoricalRequestOutcome) { + requestIDToOutcome map[string]*oracletypes.ConsensusSuccessOutcome, + requestIDToHistoricalOutcome map[string]uint64) { require.Len(t, outcome.Outcomes, 0) - require.Len(t, outcome.HistoricalOutcomes, 2) + require.Len(t, outcome.HistoricalOutcomes, 1) } case 3: @@ -177,8 +176,8 @@ func Test_HistoricalOutcomesAreRemovedOnExpiry(t *testing.T) { outcome2.verifyReport = verifyReport2 verifyOutcome = func(t *testing.T, outcome *oracletypes.Outcome, - requestIDToOutcome map[string]*oracletypes.RequestOutcome, - requestIDToHistoricalOutcome map[string]*oracletypes.HistoricalRequestOutcome) { + requestIDToOutcome map[string]*oracletypes.ConsensusSuccessOutcome, + requestIDToHistoricalOutcome map[string]uint64) { require.Len(t, outcome.Outcomes, 1) require.Len(t, outcome.HistoricalOutcomes, 2) } @@ -190,8 +189,8 @@ func Test_HistoricalOutcomesAreRemovedOnExpiry(t *testing.T) { outcome2.verifyReport = nil verifyOutcome = func(t *testing.T, outcome *oracletypes.Outcome, - requestIDToOutcome map[string]*oracletypes.RequestOutcome, - requestIDToHistoricalOutcome map[string]*oracletypes.HistoricalRequestOutcome) { + requestIDToOutcome map[string]*oracletypes.ConsensusSuccessOutcome, + requestIDToHistoricalOutcome map[string]uint64) { require.Len(t, outcome.Outcomes, 0) require.Len(t, outcome.HistoricalOutcomes, 2) } @@ -202,35 +201,33 @@ func Test_HistoricalOutcomesAreRemovedOnExpiry(t *testing.T) { removeRequestFromAllStores(pluginsAndStores, successfulRequest.RequestID()) case 5: - // By this point the historical record of the outcomes for the requests should have been removed + // By this point the historical record of the first outcome should have expired, but the historical record of the second outcome should still exist outcome1 := reqToObservations[successfulRequest.RequestID()] outcome1.verifyReport = nil outcome2 := reqToObservations[pendingRequest.RequestID()] outcome2.verifyReport = nil verifyOutcome = func(t *testing.T, outcome *oracletypes.Outcome, - requestIDToOutcome map[string]*oracletypes.RequestOutcome, - requestIDToHistoricalOutcome map[string]*oracletypes.HistoricalRequestOutcome) { + requestIDToOutcome map[string]*oracletypes.ConsensusSuccessOutcome, + requestIDToHistoricalOutcome map[string]uint64) { require.Len(t, outcome.Outcomes, 0) - require.Len(t, outcome.HistoricalOutcomes, 0) + require.Len(t, outcome.HistoricalOutcomes, 1) } - case 6: - // Simulate what would happen if the historical outcome expiry span was too small and the outcome for the first request was removed too early - // followed by a resubmission of the request - addRequestsToAllStores(pluginsAndStores, map[string]*consensusPluginTest{successfulRequest.RequestID(): reqToObservations[successfulRequest.RequestID()]}, t) - + case 6, 7, 8, 9, 10: + // Eventually by this round all historical outcomes should have expired outcome1 := reqToObservations[successfulRequest.RequestID()] - outcome1.verifyReport = verifyReport1 + outcome1.verifyReport = nil outcome2 := reqToObservations[pendingRequest.RequestID()] outcome2.verifyReport = nil - verifyOutcome = func(t *testing.T, outcome *oracletypes.Outcome, requestIDToOutcome map[string]*oracletypes.RequestOutcome, - requestIDToHistoricalOutcome map[string]*oracletypes.HistoricalRequestOutcome) { - require.Len(t, outcome.Outcomes, 1) - require.Len(t, outcome.HistoricalOutcomes, 1) - - require.Equal(t, requestIDToHistoricalOutcome[successfulRequest.RequestID()].Status, oracletypes.RequestStatus_REQUEST_STATUS_CONSENSUS_SUCCESS) + verifyOutcome = func(t *testing.T, outcome *oracletypes.Outcome, requestIDToOutcome map[string]*oracletypes.ConsensusSuccessOutcome, + requestIDToHistoricalOutcome map[string]uint64) { + if seqNr == 10 { + require.Len(t, outcome.Outcomes, 0) + require.Len(t, outcome.HistoricalOutcomes, 0) + } } + } previousOutcome = runProtocolRoundTestsWithPlugins(ctx, t, reqToObservations, pluginsAndStores, ocr3types.OutcomeContext{ @@ -241,18 +238,17 @@ func Test_HistoricalOutcomesAreRemovedOnExpiry(t *testing.T) { err := proto.Unmarshal(previousOutcome, requestsOutcome) require.NoError(t, err) - requestIDToOutcome := make(map[string]*oracletypes.RequestOutcome) + requestIDToOutcome := make(map[string]*oracletypes.ConsensusSuccessOutcome) for _, ro := range requestsOutcome.Outcomes { - requestIDToOutcome[ro.Metadata.RequestId] = ro - } - - requestIDToHistoricalOutcome := make(map[string]*oracletypes.HistoricalRequestOutcome) - for _, ho := range requestsOutcome.HistoricalOutcomes { - requestIDToHistoricalOutcome[ho.RequestId] = ho + switch v := ro.GetOutcome().(type) { + case *oracletypes.ConsensusOutcome_Success: + requestIDToOutcome[v.Success.Metadata.RequestId] = v.Success + default: + t.Fatalf("expected ConsensusSuccessOutcome, got %T", v) + } } - verifyOutcome(t, requestsOutcome, requestIDToOutcome, requestIDToHistoricalOutcome) - - postProtocolRound(t, requestsOutcome, requestIDToOutcome, requestIDToHistoricalOutcome) + verifyOutcome(t, requestsOutcome, requestIDToOutcome, requestsOutcome.HistoricalOutcomes) + postProtocolRound(t, requestsOutcome, requestIDToOutcome, requestsOutcome.HistoricalOutcomes) } } diff --git a/consensus/oracle/plugin/errors_consensus_test.go b/consensus/oracle/plugin/errors_consensus_test.go new file mode 100644 index 000000000..52aa71fa9 --- /dev/null +++ b/consensus/oracle/plugin/errors_consensus_test.go @@ -0,0 +1,76 @@ +package plugin_test + +import ( + "errors" + "testing" + + "google.golang.org/protobuf/types/known/structpb" + + "github.com/smartcontractkit/capabilities/consensus/oracle" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + + "github.com/smartcontractkit/chainlink-protos/cre/go/values" + + "github.com/smartcontractkit/libocr/offchainreporting2plus/ocr3types" +) + +func Test_ReceivedTooManyErrors(t *testing.T) { + lggr := logger.Test(t) + ctx := t.Context() + + md1 := newRequestMetaData() + md1.KeyBundleID = "evm" + + reqToObservations := map[string]*consensusPluginTest{ + md1.RequestID(): {requests: []*oracle.ConsensusRequest{ + newCr(t, 10, md1), newCrWithError(t, errors.New("its broken"), md1), newCr(t, 30, md1), + newCrWithError(t, errors.New("its broken"), md1), newCr(t, 50, md1), newCr(t, 60, md1), + newCrWithError(t, errors.New("its broken"), md1)}, + expectedConsensusFailureMessage: "consensus calculation failed: received 3 errors which is >= f+1 (3)"}, + } + + runProtocolRoundTests(ctx, t, lggr, n, f, reqToObservations) +} + +func Test_ReceivedTooManyErrorsWithDefault(t *testing.T) { + lggr := logger.Test(t) + ctx := t.Context() + + md1 := newRequestMetaData() + + md1.KeyBundleID = "evm" + + reqToObservations := map[string]*consensusPluginTest{ + md1.RequestID(): {requests: []*oracle.ConsensusRequest{ + newCrWithObsAndDef(t, 10, 20, md1), newCrWithErrorAndDefault(t, errors.New("its broken"), 20, md1), newCrWithObsAndDef(t, 30, 20, md1), + newCrWithObsAndDef(t, 40, 20, md1), newCrWithErrorAndDefault(t, errors.New("its broken"), 20, md1), newCrWithObsAndDef(t, 60, 20, md1), + newCrWithErrorAndDefault(t, errors.New("its broken"), 20, md1)}, + verifyReport: func(t *testing.T, report ocr3types.ReportPlus[[]byte], infos *structpb.Struct) { + verifyValueConsensusReport(t, report, infos, values.NewInt64(20), "evm") + }}, + } + + runProtocolRoundTests(ctx, t, lggr, n, f, reqToObservations) +} + +func Test_ReceivedSufficientObservationsAndSomeErrors(t *testing.T) { + lggr := logger.Test(t) + ctx := t.Context() + + md1 := newRequestMetaData() + + md1.KeyBundleID = "evm" + + reqToObservations := map[string]*consensusPluginTest{ + md1.RequestID(): {requests: []*oracle.ConsensusRequest{ + newCr(t, 10, md1), newCrWithError(t, errors.New("its broken"), md1), newCr(t, 30, md1), + newCr(t, 40, md1), newCr(t, 50, md1), newCr(t, 60, md1), + newCr(t, 70, md1)}, + verifyReport: func(t *testing.T, report ocr3types.ReportPlus[[]byte], infos *structpb.Struct) { + verifyValueConsensusReport(t, report, infos, values.NewInt64(40), "evm") + }}, + } + + runProtocolRoundTests(ctx, t, lggr, n, f, reqToObservations) +} diff --git a/consensus/oracle/plugin/factory.go b/consensus/oracle/plugin/factory.go index 7b7713953..ce9611a92 100644 --- a/consensus/oracle/plugin/factory.go +++ b/consensus/oracle/plugin/factory.go @@ -19,9 +19,8 @@ import ( const ( defaultMaxPhaseOutputBytes = 1_000_000 // 1 MB - defaultMaxReportCount = 20 - defaultBatchSize = 20 - defaultOutcomePruningThreshold = 3600 + defaultMaxReportLengthBytes = 100_000 // 100 KB + defaultMaxReportCount = 100 defaultRequestExpiry = 20 * time.Second defaultHistoricalOutcomeExpirySeqNrSpan = uint64(4) ) @@ -38,17 +37,20 @@ type factory struct { lggr logger.Logger metrics *metrics.Metrics + defaultKeyBundleIDForConsensusFailure string + services.StateMachine } func NewReportingPluginFactory(lggr logger.Logger, metrics *metrics.Metrics, s *requests.Store[*oracle.ConsensusRequest], - setRequestTimeout SetRequestTimeout, batchSize int) (*factory, error) { + setRequestTimeout SetRequestTimeout, batchSize int, defaultKeyBundleIDForConsensusFailure string) (*factory, error) { return &factory{ - store: s, - setRequestTimeout: setRequestTimeout, - batchSize: batchSize, - lggr: logger.Named(lggr, "ConsensusCapabilityPluginFactory"), - metrics: metrics, + store: s, + setRequestTimeout: setRequestTimeout, + batchSize: batchSize, + lggr: logger.Named(lggr, "ConsensusCapabilityPluginFactory"), + metrics: metrics, + defaultKeyBundleIDForConsensusFailure: defaultKeyBundleIDForConsensusFailure, }, nil } @@ -69,13 +71,7 @@ func (o *factory) NewReportingPlugin(_ context.Context, config ocr3types.Reporti configProto.MaxOutcomeLengthBytes = defaultMaxPhaseOutputBytes } if configProto.MaxReportLengthBytes <= 0 { - configProto.MaxReportLengthBytes = defaultMaxPhaseOutputBytes - } - if configProto.MaxBatchSize <= 0 { - configProto.MaxBatchSize = defaultBatchSize - } - if configProto.OutcomePruningThreshold <= 0 { - configProto.OutcomePruningThreshold = defaultOutcomePruningThreshold + configProto.MaxReportLengthBytes = defaultMaxReportLengthBytes } if configProto.MaxReportCount <= 0 { configProto.MaxReportCount = defaultMaxReportCount @@ -100,7 +96,7 @@ func (o *factory) NewReportingPlugin(_ context.Context, config ocr3types.Reporti configProto.HistoricalOutcomeExpirySeqNrSpan = defaultHistoricalOutcomeExpirySeqNrSpan } - rp, err := NewReportingPlugin(o.lggr, o.metrics, config.F, config.N, o.store, &configProto) + rp, err := NewReportingPlugin(o.lggr, o.metrics, config.F, config.N, o.store, &configProto, o.defaultKeyBundleIDForConsensusFailure) rpInfo := ocr3types.ReportingPluginInfo{ Name: "Consensus Capability Plugin", Limits: ocr3types.ReportingPluginLimits{ diff --git a/consensus/oracle/plugin/plugin.go b/consensus/oracle/plugin/plugin.go index daef5e1fe..e5c0ef2c6 100644 --- a/consensus/oracle/plugin/plugin.go +++ b/consensus/oracle/plugin/plugin.go @@ -20,36 +20,35 @@ import ( var _ ocr3types.ReportingPlugin[[]byte] = (*reportingPlugin)(nil) type reportingPlugin struct { - batchSize int - store *requests.Store[*oracle.ConsensusRequest] + store *requests.Store[*oracle.ConsensusRequest] f int n int - minimumObservations int - // outcomeExpirySeqNrSpan is the duration, expressed as a seq number span, after which a request outcome will be pruned from the plugins outcome outcomeExpirySeqNrSpan uint64 config *ocrtypes.ReportingPluginConfig metrics *metrics.Metrics + // defaultKeyBundleIDForConsensusFailure is the key bundle ID to be used when reporting consensus failures before consensus is reached on request metadata + defaultKeyBundleIDForConsensusFailure string + lggr logger.Logger } // NewReportingPlugin creates a new reporting plugin for the OCR3 capability func NewReportingPlugin(lggr logger.Logger, metrics *metrics.Metrics, f int, n int, store *requests.Store[*oracle.ConsensusRequest], - configProto *ocrtypes.ReportingPluginConfig) (*reportingPlugin, error) { + configProto *ocrtypes.ReportingPluginConfig, defaultKeyBundleIDForConsensusFailure string) (*reportingPlugin, error) { return &reportingPlugin{ - store: store, - batchSize: int(configProto.MaxBatchSize), - f: f, - n: n, - minimumObservations: 2*f + 1, - outcomeExpirySeqNrSpan: configProto.HistoricalOutcomeExpirySeqNrSpan, - lggr: logger.Named(lggr, "CapabilityConsensusReportingPlugin"), - config: configProto, - metrics: metrics, + store: store, + f: f, + n: n, + outcomeExpirySeqNrSpan: configProto.HistoricalOutcomeExpirySeqNrSpan, + lggr: logger.Named(lggr, "CapabilityConsensusReportingPlugin"), + config: configProto, + metrics: metrics, + defaultKeyBundleIDForConsensusFailure: defaultKeyBundleIDForConsensusFailure, }, nil } diff --git a/consensus/oracle/plugin/plugin_observation.go b/consensus/oracle/plugin/plugin_observation.go index b0f01eaa4..dfa5edffd 100644 --- a/consensus/oracle/plugin/plugin_observation.go +++ b/consensus/oracle/plugin/plugin_observation.go @@ -1,27 +1,20 @@ package plugin import ( - "bytes" "context" - "fmt" - "reflect" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" - "github.com/smartcontractkit/capabilities/consensus/oracle" + "github.com/smartcontractkit/capabilities/consensus/oracle/plugin/batching" oracletypes "github.com/smartcontractkit/capabilities/consensus/oracle/types" - "github.com/smartcontractkit/chainlink-protos/cre/go/values" - valuespb "github.com/smartcontractkit/chainlink-protos/cre/go/values/pb" - - "github.com/smartcontractkit/chainlink-protos/cre/go/sdk" "github.com/smartcontractkit/libocr/offchainreporting2/types" "github.com/smartcontractkit/libocr/offchainreporting2plus/ocr3types" - - "github.com/smartcontractkit/chainlink-common/pkg/logger" ) +// Observation processes the query and returns the observation for the reporting plugin. If a request for a given +// request ID is not found locally, it is simply skipped in the observation. func (r *reportingPlugin) Observation(ctx context.Context, outctx ocr3types.OutcomeContext, query types.Query) (types.Observation, error) { requestsQuery := &oracletypes.Query{} err := proto.Unmarshal(query, requestsQuery) @@ -29,200 +22,24 @@ func (r *reportingPlugin) Observation(ctx context.Context, outctx ocr3types.Outc return nil, err } - var requestIDs []string - for _, req := range requestsQuery.Requests { - requestIDs = append(requestIDs, req.Metadata.RequestId) - } - - reqIDToQueryRequest := map[string]*oracletypes.Request{} - for _, req := range requestsQuery.Requests { - reqIDToQueryRequest[req.Metadata.RequestId] = req - } - - reqs := r.store.GetByIDs(requestIDs) - - // Observations for a request are only included if the consensus descriptor, metadata and default match those one in the query - // to ensure that the leader node cannot unduly influence the outcome by choosing which consensus descriptor, default or metadata - // to associate with a request. - var requestObservations []*oracletypes.RequestObservation - // Initialize cached size with the base message size - obs := &oracletypes.Observation{Observations: make([]*oracletypes.RequestObservation, 0, len(reqs))} - cachedObsSize := CalculateMessageSize(obs) - - for _, req := range reqs { - queryRequest, ok := reqIDToQueryRequest[req.ID()] - if !ok { - return nil, fmt.Errorf("request %s not found in query", req.ID()) - } - - match, err := requestDescriptorMetadataAndDefaultMatch(r.lggr, req, queryRequest) - if err != nil { - return nil, fmt.Errorf("failed to compare request and query for request %s: %w", req.ID(), err) - } - - // If the consensus descriptor, metadata or default do not match that of the query skip this request - if !match { - // TODO - for DoS protection will mark the request as mismatched in subsequent PR - continue - } - - // Now we know the consensus descriptor, metadata and default match, we can include the observation (if it exists) - var newOb *oracletypes.RequestObservation - switch obs := req.Input.GetObservation().(type) { - case *sdk.SimpleConsensusInputs_Value: - - isValueNil, err := isNilOrEmptySlice(obs.Value) - if err != nil { - return nil, fmt.Errorf("failed to check if observation value is nil for request %s: %w", req.ID(), err) - } - - if !isValueNil { - marshalledValue, err := proto.MarshalOptions{Deterministic: true}.Marshal(obs.Value) - if err != nil { - return nil, fmt.Errorf("failed to marshal observation value for request %s: %w", req.ID(), err) - } + localRequests := r.store.GetByIDs(requestsQuery.RequestIDs) - newOb = &oracletypes.RequestObservation{ - Metadata: queryRequest.Metadata, - Observation: marshalledValue, - ReceivedAt: timestamppb.New(req.ReceivedAt), - } - } else { - isDefaultNil, err := isNilOrEmptySlice(req.Input.Default) - if err != nil { - return nil, fmt.Errorf("failed to check if default value is nil for request %s: %w", req.ID(), err) - } + observationBatch := batching.NewObservationBatch(ctx, r.lggr, int(r.config.MaxObservationLengthBytes), r.metrics) - if !isDefaultNil { - serialisedDefault, err := proto.MarshalOptions{Deterministic: true}.Marshal(req.Input.Default) - if err != nil { - return nil, fmt.Errorf("failed to marshal default value for request %s: %w", req.ID(), err) - } - - newOb = &oracletypes.RequestObservation{ - Metadata: queryRequest.Metadata, - Observation: serialisedDefault, - ReceivedAt: timestamppb.New(req.ReceivedAt), - } - } else { - r.lggr.Debugw("neither value, error or default is set in the observation input for request", "requestID", req.ID()) - } - } - case *sdk.SimpleConsensusInputs_Error: - r.lggr.Debugw("observation is an error, skipping", "error", obs.Error, "requestID", req.ID()) - continue - default: - r.lggr.Debugw("observation is of unknown type, skipping", "requestID", req.ID()) + for _, req := range localRequests { + reqObs := &oracletypes.RequestObservation{ + Metadata: ToRequestMetaData(req.Metadata), + ReceivedAt: timestamppb.New(req.ReceivedAt), + Input: req.Input, } - if newOb != nil { - ok, newSize := BatchHasCapacity(cachedObsSize, newOb, int(r.config.MaxObservationLengthBytes), - func() { r.metrics.IncBatchRequestsTotal(ctx, "observation") }, - func() { r.metrics.IncBatchCapacityExceeded(ctx, "observation") }) - if !ok { - break - } - - requestObservations = append(requestObservations, newOb) - cachedObsSize = newSize + hasCapacity := observationBatch.AddObservation(ctx, reqObs) + if !hasCapacity { + break } - } - - observation := &oracletypes.Observation{Observations: requestObservations} - - r.lggr.Debugw("consensus plugin observation complete", "numObservations", len(requestObservations), "numOfRequestsInQuery", len(requestsQuery.Requests)) - return proto.MarshalOptions{Deterministic: true}.Marshal(observation) -} - -func isNilOrEmptySlice(valueAsProto *valuespb.Value) (bool, error) { - if valueAsProto == nil { - return true, nil - } - - marshalledValue, err := proto.MarshalOptions{Deterministic: true}.Marshal(valueAsProto) - if err != nil { - return false, fmt.Errorf("failed to marshal observation value %w", err) - } - - if len(marshalledValue) == 0 { - return true, nil - } - - value, err := values.FromProto(valueAsProto) - if err != nil { - return false, fmt.Errorf("failed to convert observation value from proto %w", err) - } - unwrappedVal, err := value.Unwrap() - if err != nil { - return false, fmt.Errorf("failed to unwrap observation value %w", err) - } - - if unwrappedVal == nil { - return true, nil - } - - return isEmptySlice(unwrappedVal), nil -} - -func isEmptySlice(val any) bool { - v := reflect.ValueOf(val) - return v.Kind() == reflect.Slice && v.Len() == 0 -} - -func requestDescriptorMetadataAndDefaultMatch(lggr logger.Logger, req *oracle.ConsensusRequest, - queryRequest *oracletypes.Request) (bool, error) { - serialisedConsensusDescriptor, err := proto.MarshalOptions{Deterministic: true}.Marshal(req.Input.Descriptors) - if err != nil { - return false, fmt.Errorf("failed to marshal consensus descriptor for request %s: %w", req.ID(), err) - } - - if !bytes.Equal(queryRequest.RequestConsensusDescriptor, serialisedConsensusDescriptor) { - lggr.Debugw("Consensus descriptor mismatch", "requestID", req.ID()) - return false, nil - } - - serialisedRequestMetaData, err := proto.MarshalOptions{Deterministic: true}.Marshal(ToRequestMetaData(req.Metadata)) - if err != nil { - return false, fmt.Errorf("failed to marshal request metadata for request %s: %w", req.ID(), err) - } - - serialisedQueryRequestMetaData, err := proto.MarshalOptions{Deterministic: true}.Marshal(queryRequest.Metadata) - if err != nil { - return false, fmt.Errorf("failed to marshal query request metadata for request %s: %w", req.ID(), err) - } - - if !bytes.Equal(serialisedRequestMetaData, serialisedQueryRequestMetaData) { - lggr.Debugw("Metadata mismatch", "requestID", req.ID()) - return false, nil - } - - isReqDefaultNil, err := isNilOrEmptySlice(req.Input.Default) - if err != nil { - return false, fmt.Errorf("failed to check if default is nil for request %s: %w", req.ID(), err) - } - - if len(queryRequest.RequestDefault) > 0 { - if isReqDefaultNil { - lggr.Debugw("Default value mismatch - query has default but request does not", "requestID", req.ID()) - return false, nil - } - - serialisedDefault, err := proto.MarshalOptions{Deterministic: true}.Marshal(req.Input.Default) - if err != nil { - return false, fmt.Errorf("failed to marshal default for request %s: %w", req.ID(), err) - } - - if !bytes.Equal(queryRequest.RequestDefault, serialisedDefault) { - lggr.Debugw("Default value mismatch", "requestID", req.ID()) - return false, nil - } - } else { - if !isReqDefaultNil { - lggr.Debugw("Default value mismatch - request has default but query does not", "requestID", req.ID()) - return false, nil - } } - return true, nil + r.lggr.Debugw("consensus plugin observation complete", "numObservations", observationBatch.NumObservationsInBatch(), "numOfRequestsInQuery", len(requestsQuery.RequestIDs)) + return observationBatch.SerialiseObservationBatch() } diff --git a/consensus/oracle/plugin/plugin_outcome.go b/consensus/oracle/plugin/plugin_outcome.go index d7a96ad3d..7982c5054 100644 --- a/consensus/oracle/plugin/plugin_outcome.go +++ b/consensus/oracle/plugin/plugin_outcome.go @@ -3,18 +3,20 @@ package plugin import ( "context" "fmt" + "reflect" "slices" "time" + "github.com/cloudevents/sdk-go/v2/event/datacodec/json" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" "github.com/smartcontractkit/capabilities/consensus/oracle" + "github.com/smartcontractkit/capabilities/consensus/oracle/plugin/batching" oracletypes "github.com/smartcontractkit/capabilities/consensus/oracle/types" - "github.com/smartcontractkit/chainlink-protos/cre/go/sdk" - "github.com/smartcontractkit/chainlink-protos/cre/go/values" + valuespb "github.com/smartcontractkit/chainlink-protos/cre/go/values/pb" "github.com/smartcontractkit/libocr/offchainreporting2/types" @@ -23,179 +25,196 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/logger" ) -type timestampedObservation struct { - Observation *valuespb.Value - Timestamp *timestamppb.Timestamp -} - func (r *reportingPlugin) Outcome(ctx context.Context, outctx ocr3types.OutcomeContext, query types.Query, attributedObservations []types.AttributedObservation) (ocr3types.Outcome, error) { requestsQuery := &oracletypes.Query{} err := proto.Unmarshal(query, requestsQuery) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to unmarshal query: %w", err) } - historicalOutcomes, requestIDToHistoricalOutcome, err := getNonExpiredHistoricalRequestOutcomes(r.lggr, outctx, r.outcomeExpirySeqNrSpan) + outcome, err := batching.NewOutcomeBatch(ctx, r.lggr, outctx, r.outcomeExpirySeqNrSpan, int(r.config.MaxOutcomeLengthBytes), r.defaultKeyBundleIDForConsensusFailure, + r.metrics) if err != nil { - return nil, fmt.Errorf("failed to get previous outcomes: %w", err) + return nil, fmt.Errorf("failed to create new outcome batch: %w", err) } requestIDToObservations := groupAttributedObservationsByRequestID(r.lggr, attributedObservations) - var outcomes []*oracletypes.RequestOutcome - cachedOutcomeSize := CalculateMessageSize(&oracletypes.Outcome{Outcomes: outcomes, HistoricalOutcomes: historicalOutcomes}) - for _, request := range requestsQuery.Requests { - requestID := request.Metadata.RequestId + for _, requestID := range requestsQuery.RequestIDs { observations := requestIDToObservations[requestID] - consensusDescriptor := &sdk.ConsensusDescriptor{} - err = proto.Unmarshal(request.RequestConsensusDescriptor, consensusDescriptor) - if err != nil { - return nil, fmt.Errorf("could not unmarshal consensus descriptor for request %s: %w", requestID, err) - } - - values := make([]*valuespb.Value, 0, len(observations)) - timestamps := make([]*timestamppb.Timestamp, 0, len(observations)) - for _, obs := range observations { - if obs.Observation == nil || obs.Timestamp == nil { - r.lggr.Errorw("observation or timestamp is nil for request, skipping", "requestID", requestID) - continue + // 2f+1 or more observations have been received, calculate the outcome for the request + if len(observations) >= 2*r.f+1 { + hasCapacity, err := r.addRequestOutcomeToBatch(ctx, requestID, observations, outcome) + if err != nil { + return nil, fmt.Errorf("failed to add request outcome to batch for request %s: %w", requestID, err) } - timestamps = append(timestamps, obs.Timestamp) - values = append(values, obs.Observation) - } - - // Get the default value from the query request if it exists - var defaultValue *valuespb.Value - if request.RequestDefault != nil { - defaultValue = &valuespb.Value{} - err := proto.Unmarshal(request.RequestDefault, defaultValue) - if err != nil { - return nil, fmt.Errorf("could not unmarshal default value for request %s: %w", requestID, err) + if !hasCapacity { + break } } + } - var requestOutcome *oracletypes.RequestOutcome - var historicalRequestOutcome *oracletypes.HistoricalRequestOutcome - value, err := oracle.CalculateOutcomeForObservations(r.lggr, values, consensusDescriptor, defaultValue, r.minimumObservations, r.f) - if err != nil { - // TODO - pending this JIRA https://smartcontract-it.atlassian.net/browse/CAPPL-1076 mark the request as - // pending so it is included in the next round. Subsequent PR for the latter JIRA will address better consensus failure and - // error handling separately to avoid unnecessary consensus retries for the request and address DoS (+allow request to fail fast if consensus is not possible). - r.lggr.Errorw("failed to calculate outcome for observations", "requestID", requestID, "error", err) - historicalRequestOutcome = &oracletypes.HistoricalRequestOutcome{ - RequestId: request.Metadata.RequestId, - Status: oracletypes.RequestStatus_REQUEST_STATUS_CONSENSUS_PENDING, - FirstSeenAtSeqNr: outctx.SeqNr, - } - } else { - serialisedValue, err := proto.MarshalOptions{Deterministic: true}.Marshal(value) - if err != nil { - return nil, fmt.Errorf("failed to marshal outcome value for request %s: %w", requestID, err) - } - requestOutcome = &oracletypes.RequestOutcome{ - Metadata: request.Metadata, - Outcome: serialisedValue, - Timestamp: calculateMedianTimestamp(timestamps), - Status: oracletypes.RequestStatus_REQUEST_STATUS_CONSENSUS_SUCCESS, - } + return outcome.SerialiseOutcomeBatch() +} - historicalRequestOutcome = &oracletypes.HistoricalRequestOutcome{ - RequestId: request.Metadata.RequestId, - Status: oracletypes.RequestStatus_REQUEST_STATUS_CONSENSUS_SUCCESS, - FirstSeenAtSeqNr: outctx.SeqNr, - } - } +// addRequestOutcomeToBatch adds the outcome for a single request to the outcome batch. Returns false if batch does not have capacity to add the outcome. +func (r *reportingPlugin) addRequestOutcomeToBatch(ctx context.Context, requestID string, observations []*oracletypes.RequestObservation, outcome *batching.OutcomeBatch) (bool, error) { + consensusMDD, err := r.calculateConsensusMetadataDescriptorAndDefault(observations) + if err != nil { + return outcome.AddFailedConsensusRequestOutcomeToBatch(ctx, requestID, fmt.Sprintf("failed to calculate consensus metadata, descriptor and default for request: %v", err)) + } - if existingHistoricalRequestOutcome, ok := requestIDToHistoricalOutcome[requestID]; ok { - // If the request already exists in the historical outcomes update the status only - existingHistoricalRequestOutcome.Status = historicalRequestOutcome.Status - historicalRequestOutcome = nil - } + var errors []string + var obsValues []*valuespb.Value + var timestamps []*timestamppb.Timestamp - hasCapacity, newOutcomeSize := r.checkOutcomeBatchHasCapacity(ctx, cachedOutcomeSize, requestOutcome, historicalRequestOutcome) - if !hasCapacity { - break + for _, obs := range observations { + // Does the observation have a timestamp? + if obs.ReceivedAt == nil { + r.lggr.Warnw("observation missing receivedAt timestamp", "requestID", requestID, "observerMetadata", obs.Metadata) + continue } - cachedOutcomeSize = newOutcomeSize - - if requestOutcome != nil { - outcomes = append(outcomes, requestOutcome) + // Does the observation's metadata, descriptor and default match the consensus? + if !verifyMetadataDescriptorAndDefaultMatchConsensus(obs, consensusMDD) { + r.lggr.Warnw("observation metadata, descriptor or default does not match consensus", "requestID", requestID, "observation", obs, "consensusMDD", consensusMDD) + continue } - if historicalRequestOutcome != nil { - historicalOutcomes = append(historicalOutcomes, historicalRequestOutcome) + // Is the observation an error or a value? + switch inputObservation := obs.Input.GetObservation().(type) { + case *sdk.SimpleConsensusInputs_Value: + obsValues = append(obsValues, inputObservation.Value) + timestamps = append(timestamps, obs.ReceivedAt) + case *sdk.SimpleConsensusInputs_Error: + errors = append(errors, inputObservation.Error) } } - serialisedOutcome, err := proto.MarshalOptions{Deterministic: true}.Marshal(&oracletypes.Outcome{ - Outcomes: outcomes, - HistoricalOutcomes: historicalOutcomes, - }) + timestamp := ×tamppb.Timestamp{} + if len(timestamps) > 0 { + timestamp = calculateMedianTimestamp(timestamps) + } + + if len(errors) >= r.f+1 { + consensusFailedMsg := fmt.Sprintf( + "consensus calculation failed: received %d errors which is >= f+1 (%d) for requestID %s\nconsensus metadata, descriptor and default: %+v\nerrors received: %+v", + len(errors), r.f+1, requestID, consensusMDD, errors, + ) + return outcome.FailConsensusWithDefaultCheck(ctx, r.lggr, requestID, consensusFailedMsg, consensusMDD, timestamp) + } + + value, err := oracle.CalculateOutcomeForObservations(r.lggr, obsValues, consensusMDD.Input.Descriptors, consensusMDD.Input.Default, r.f) + if err != nil { - return nil, fmt.Errorf("failed to marshal outcome: %w", err) + valuesJSON := formatValuesForLogging(ctx, r.lggr, obsValues) + consensusFailedMsg := fmt.Sprintf( + "consensus calculation failed: %v\nconsensus metadata, descriptor and default:\n %+v\nvalues received: %s\nerrors received: %+v", + err, consensusMDD, valuesJSON, errors, + ) + return outcome.FailConsensusWithDefaultCheck(ctx, r.lggr, requestID, consensusFailedMsg, consensusMDD, timestamp) } - return serialisedOutcome, nil + return outcome.AddSuccessfulConsensusRequestOutcomeToBatch(ctx, consensusMDD.Metadata, value, timestamp) } -func (r *reportingPlugin) checkOutcomeBatchHasCapacity(ctx context.Context, existingOutcomeSize int, requestOutcome *oracletypes.RequestOutcome, - historicalRequestOutcome *oracletypes.HistoricalRequestOutcome) (bool, int) { - if requestOutcome != nil { - ok, newSize := BatchHasCapacity(existingOutcomeSize, requestOutcome, int(r.config.MaxOutcomeLengthBytes), - func() { r.metrics.IncBatchRequestsTotal(ctx, "outcome") }, - func() { r.metrics.IncBatchCapacityExceeded(ctx, "outcome") }) +type valueWithType struct { + Type string `json:"type"` + Value interface{} `json:"value"` +} - if !ok { - r.lggr.Debugw("max outcome batch size reached, skipping other requests", "requestID", requestOutcome.Metadata.RequestId) - return false, 0 +func formatValuesForLogging(ctx context.Context, lggr logger.Logger, obsValues []*valuespb.Value) string { + var typedValues []*valueWithType + for _, protoVal := range obsValues { + val, err := values.FromProto(protoVal) + if err != nil { + lggr.Warnw("could not convert observation value from proto", "error", err) + continue } - existingOutcomeSize = newSize - } - - if historicalRequestOutcome != nil { - ok, newSize := BatchHasCapacity(existingOutcomeSize, historicalRequestOutcome, int(r.config.MaxOutcomeLengthBytes), - func() { r.metrics.IncBatchRequestsTotal(ctx, "outcome") }, - func() { r.metrics.IncBatchCapacityExceeded(ctx, "outcome") }) + var tv *valueWithType + if val == nil { + tv = &valueWithType{ + Type: "nil", + Value: nil, + } + } else { + unwrappedValue, err := val.Unwrap() + if err != nil { + lggr.Warnw("could not unwrap observation value", "error", err) + continue + } - if !ok { - r.lggr.Debugw("max outcome batch size reached when adding historical request outcome, skipping other requests", "requestID", historicalRequestOutcome.RequestId) - return false, 0 + tv = &valueWithType{ + Type: reflect.TypeOf(unwrappedValue).String(), + Value: unwrappedValue, + } } - existingOutcomeSize = newSize + typedValues = append(typedValues, tv) } - return true, existingOutcomeSize + valuesJson, err := json.Encode(ctx, typedValues) + if err != nil { + lggr.Warnw("could not marshal observation values to json", "error", err) + return "could not marshal observation values" + } + return string(valuesJson) } -func getNonExpiredHistoricalRequestOutcomes(lggr logger.Logger, outctx ocr3types.OutcomeContext, outcomeExpirySeqNrSpan uint64) ([]*oracletypes.HistoricalRequestOutcome, map[string]*oracletypes.HistoricalRequestOutcome, error) { - var nonExpiredHistoricalOutcomes []*oracletypes.HistoricalRequestOutcome - requestIDToHistoricalOutcome := map[string]*oracletypes.HistoricalRequestOutcome{} - if outctx.PreviousOutcome != nil { - prevOutcome := &oracletypes.Outcome{} - err := proto.Unmarshal(outctx.PreviousOutcome, prevOutcome) +// verifyMetadataDescriptorAndDefaultMatchConsensus checks if the observation's metadata, descriptor and default match the consensus. +func verifyMetadataDescriptorAndDefaultMatchConsensus(obs *oracletypes.RequestObservation, consensusMDD *oracletypes.RequestObservation) bool { + obsMDD := &oracletypes.RequestObservation{ + Metadata: obs.Metadata, + Input: &sdk.SimpleConsensusInputs{ + Descriptors: obs.Input.Descriptors, + Default: obs.Input.Default, + }, + } + + return proto.Equal(obsMDD, consensusMDD) +} + +func (r *reportingPlugin) calculateConsensusMetadataDescriptorAndDefault(observations []*oracletypes.RequestObservation) (*oracletypes.RequestObservation, error) { + var allObservationsMDDBytes []*valuespb.Value + for _, obs := range observations { + mddBytes, err := proto.MarshalOptions{Deterministic: true}.Marshal(&oracletypes.RequestObservation{ + Metadata: obs.Metadata, + Input: &sdk.SimpleConsensusInputs{ + Descriptors: obs.Input.Descriptors, + Default: obs.Input.Default, + }, + }) if err != nil { - lggr.Errorw("could not unmarshal previous outcome", "error", err) - return nil, nil, err + r.lggr.Errorw("could not marshal RequestObservation", "error", err) + continue } - for _, ho := range prevOutcome.HistoricalOutcomes { - if outctx.SeqNr-ho.FirstSeenAtSeqNr <= outcomeExpirySeqNrSpan { - nonExpiredHistoricalOutcomes = append(nonExpiredHistoricalOutcomes, ho) - requestIDToHistoricalOutcome[ho.RequestId] = ho - } - } + // Wrapped here to allow reuse of the existing CalculateOutcomeForObservations function for identical aggregation + allObservationsMDDBytes = append(allObservationsMDDBytes, values.Proto(values.NewBytes(mddBytes))) + } + + consensusMDDBytes, err := oracle.CalculateOutcomeForObservations(r.lggr, allObservationsMDDBytes, + &sdk.ConsensusDescriptor{Descriptor_: &sdk.ConsensusDescriptor_Aggregation{Aggregation: sdk.AggregationType_AGGREGATION_TYPE_IDENTICAL}}, + nil, r.f) + + if err != nil { + return nil, err + } + + consensusMDD := &oracletypes.RequestObservation{} + err = proto.Unmarshal(consensusMDDBytes.GetBytesValue(), consensusMDD) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal consensus metadata, descriptor and default for request: %w", err) } - return nonExpiredHistoricalOutcomes, requestIDToHistoricalOutcome, nil + return consensusMDD, nil } -func groupAttributedObservationsByRequestID(lggr logger.Logger, attributedObservations []types.AttributedObservation) map[string][]timestampedObservation { - requestIDToObservations := make(map[string][]timestampedObservation) +func groupAttributedObservationsByRequestID(lggr logger.Logger, attributedObservations []types.AttributedObservation) map[string][]*oracletypes.RequestObservation { + requestIDToObservations := make(map[string][]*oracletypes.RequestObservation) for _, ao := range attributedObservations { obs := &oracletypes.Observation{} err := proto.Unmarshal(ao.Observation, obs) @@ -204,28 +223,12 @@ func groupAttributedObservationsByRequestID(lggr logger.Logger, attributedObserv continue } - for _, requestObservation := range obs.Observations { - requestID := requestObservation.Metadata.RequestId - observationValue := &valuespb.Value{} - err = proto.Unmarshal(requestObservation.Observation, observationValue) - if err != nil { - lggr.Errorw("could not unmarshal observation for request from observer", "error", err, "requestID", requestID, "observer", ao.Observer) - continue - } - - // Check the observation correctly marshals to a value to ensure it is a valid observation - _, err = values.FromProto(observationValue) - if err != nil { - lggr.Errorw("could not convert observation value proto to value", "error", err, "requestID", requestID, "observer", ao.Observer) - continue - } - - requestIDToObservations[requestID] = append(requestIDToObservations[requestID], timestampedObservation{ - Observation: observationValue, - Timestamp: requestObservation.ReceivedAt, - }) + // Observations will be added in the same order as received in the attributedObservations slice + for requestID, reqObservation := range obs.Observations { + requestIDToObservations[requestID] = append(requestIDToObservations[requestID], reqObservation) } } + return requestIDToObservations } diff --git a/consensus/oracle/plugin/plugin_query.go b/consensus/oracle/plugin/plugin_query.go index 57ea53ec3..573f1328a 100644 --- a/consensus/oracle/plugin/plugin_query.go +++ b/consensus/oracle/plugin/plugin_query.go @@ -7,6 +7,7 @@ import ( "google.golang.org/protobuf/proto" "github.com/smartcontractkit/capabilities/consensus/oracle" + "github.com/smartcontractkit/capabilities/consensus/oracle/plugin/batching" oracletypes "github.com/smartcontractkit/capabilities/consensus/oracle/types" "github.com/smartcontractkit/libocr/offchainreporting2/types" @@ -25,79 +26,17 @@ func (r *reportingPlugin) Query(ctx context.Context, outctx ocr3types.OutcomeCon return nil, fmt.Errorf("failed to remove completed requests: %w", err) } - // Take the first batchSize requests after filtering out completed requests - if len(pendingRequests) > r.batchSize { - pendingRequests = pendingRequests[:r.batchSize] - } - - // To achieve a deterministic Outcome requires that each node has access to the same set of request observations, defaults and - // consensus descriptors. Variations in the latter 2 would result in different nodes producing different outcomes. As - // the order of arrival of requests at each node is non-deterministic, relying on the request set at each node to provide - // the consensus descriptors for a node would result in different nodes producing different outcomes for the same set - // of observations. - // - // The solution to this problem is to embed the consensus descriptor and default into the query. With this done, all nodes - // will have access to the same consensus descriptor set and default when calculating the outcome. One issue with this is that - // it would allow the leader node to unduly influence the outcome by choosing which consensus descriptor and/or default to associate with - // a request in the query. - // To prevent this each node checks the consensus descriptor and default for a request in the query against the consensus descriptor - // and default it has for the request and only contributes an observation for the request if they match. - // - // The same reasoning applies to the metadata for the request, which is also included in the query. - - seenIDs := make(map[IDKey]bool) - cachedQuerySize := 0 + queryBatch := batching.NewQueryBatch(ctx, r.lggr, int(r.config.MaxQueryLengthBytes), r.metrics) - var reqs []*oracletypes.Request for _, rq := range pendingRequests { - key := GetIDKey(rq) - - // Simple duplicate elimination using a map - if seenIDs[key] { - continue - } - - serialisedConsensusDescriptor, err := proto.MarshalOptions{Deterministic: true}.Marshal(rq.Input.Descriptors) - if err != nil { - return nil, fmt.Errorf("failed to marshal consensus descriptor for request %s: %w", rq.ID(), err) - } - - isDefaultNil, err := isNilOrEmptySlice(rq.Input.Default) - if err != nil { - return nil, fmt.Errorf("failed to check if default is nil for request %s: %w", rq.ID(), err) - } - - var serialisedDefault []byte - if !isDefaultNil { - serialisedDefault, err = proto.MarshalOptions{Deterministic: true}.Marshal(rq.Input.Default) - if err != nil { - return nil, fmt.Errorf("failed to marshal default for request %s: %w", rq.ID(), err) - } - } - - newReq := &oracletypes.Request{ - Metadata: ToRequestMetaData(rq.Metadata), - RequestConsensusDescriptor: serialisedConsensusDescriptor, - RequestDefault: serialisedDefault, - } - - // If the new id would exceed the max query size, stop adding more ids - ok, newSize := BatchHasCapacity(cachedQuerySize, newReq, int(r.config.MaxQueryLengthBytes), - func() { r.metrics.IncBatchRequestsTotal(ctx, "query") }, - func() { r.metrics.IncBatchCapacityExceeded(ctx, "query") }) - if !ok { + hasCapacity := queryBatch.AddRequestID(ctx, rq.RequestID) + if !hasCapacity { break } - - seenIDs[key] = true - reqs = append(reqs, newReq) - cachedQuerySize = newSize } - r.lggr.Debugw("consensus plugin query complete", "number of requests", len(reqs)) - return proto.MarshalOptions{Deterministic: true}.Marshal(&oracletypes.Query{ - Requests: reqs, - }) + r.lggr.Debugw("consensus plugin query complete", "number of request ids", queryBatch.NumberOfRequestIDs()) + return queryBatch.SerialiseQueryBatch() } // Removes any requests that have already been completed (successfully/failed/errored) from the batch @@ -115,17 +54,10 @@ func (r *reportingPlugin) getPendingRequests(outctx ocr3types.OutcomeContext, al return nil, err } - // Remove any requests from the batch that are already in the previous outcome and not marked as pending - // This ensures that requests that have been completed (whether successfully/failed/errored) are not included in the new query - requestIDToHistoricalOutcome := make(map[string]*oracletypes.HistoricalRequestOutcome) - for _, ro := range prevOutcome.HistoricalOutcomes { - requestIDToHistoricalOutcome[ro.RequestId] = ro - } - + // Remove any requests from the batch that already have a historical outcome to prevent duplicate outcome generation for _, rq := range allRequests { - previousRequestOutcome, exists := requestIDToHistoricalOutcome[rq.ID()] - // If the request ID exists in the historical outcome and is not marked as pending, skip it - if exists && previousRequestOutcome.Status != oracletypes.RequestStatus_REQUEST_STATUS_CONSENSUS_PENDING { + _, exists := prevOutcome.HistoricalOutcomes[rq.ID()] + if exists { continue } diff --git a/consensus/oracle/plugin/plugin_reports.go b/consensus/oracle/plugin/plugin_reports.go index 86a2c905e..bbd1f0dc6 100644 --- a/consensus/oracle/plugin/plugin_reports.go +++ b/consensus/oracle/plugin/plugin_reports.go @@ -15,9 +15,12 @@ import ( "github.com/smartcontractkit/libocr/offchainreporting2plus/ocr3types" ) -const InfoRequestID = "requestID" const ReportMetaDataPrependLength = 109 +const InfoRequestID = "requestID" +const InfoConsensusFailureMessage = "failureMessage" +const InfoKeyBundleName = "keyBundleName" + func (r *reportingPlugin) Reports(ctx context.Context, seqNr uint64, outcome ocr3types.Outcome) ([]ocr3types.ReportPlus[[]byte], error) { requestsOutcome := &oracletypes.Outcome{} err := proto.Unmarshal(outcome, requestsOutcome) @@ -27,77 +30,111 @@ func (r *reportingPlugin) Reports(ctx context.Context, seqNr uint64, outcome ocr var reports []ocr3types.ReportPlus[[]byte] - for _, requestOutcome := range requestsOutcome.Outcomes { - // TODO as part of https://smartcontract-it.atlassian.net/browse/CAPPL-1076 - // handle other status outcomes - if requestOutcome.Status != oracletypes.RequestStatus_REQUEST_STATUS_CONSENSUS_SUCCESS { - r.lggr.Debugw("skipping report generation for request as outcome status is not success", "requestID", requestOutcome.Metadata.RequestId, "status", requestOutcome.Status.String()) - continue - } + // Create a report for each outcome + for _, reqOutcome := range requestsOutcome.Outcomes { + switch v := reqOutcome.GetOutcome().(type) { + case *oracletypes.ConsensusOutcome_Success: + successOutcome := v.Success + r.lggr.Debugw("received successful consensus outcome", "requestID", successOutcome.Metadata.RequestId) + reqMetadata := successOutcome.Metadata + var report []byte + switch reqMetadata.RequestType { + case oracletypes.RequestType_VALUE_CONSENSUS: + report = successOutcome.Outcome + case oracletypes.RequestType_REPORT_GENERATION: + // If the request type is report extract the report from the values.Value before signing it + serialisedValue := successOutcome.Outcome + value := &valuespb.Value{} + if err := proto.Unmarshal(serialisedValue, value); err != nil { + return nil, fmt.Errorf("failed to unmarshal value for request %s: %w", reqMetadata.RequestId, err) + } + + report = value.GetBytesValue() + if report == nil { + return nil, fmt.Errorf("failed to get report bytes for request %s", reqMetadata.RequestId) + } + } - reqMetadata := requestOutcome.Metadata - var report []byte - switch reqMetadata.RequestType { - case oracletypes.RequestType_VALUE_CONSENSUS: - report = requestOutcome.Outcome - case oracletypes.RequestType_REPORT_GENERATION: - // If the request type is report extract the report from the values.Value before signing it - serialisedValue := requestOutcome.Outcome - value := &valuespb.Value{} - if err := proto.Unmarshal(serialisedValue, value); err != nil { - return nil, fmt.Errorf("failed to unmarshal value for request %s: %w", reqMetadata.RequestId, err) + meta := ocrtypes.Metadata{ + Version: 1, + ExecutionID: reqMetadata.WorkflowExecutionId, + Timestamp: uint32(successOutcome.Timestamp.AsTime().Unix()), // nolint + DONID: reqMetadata.WorkflowDonId, + DONConfigVersion: reqMetadata.WorkflowDonConfigVersion, + WorkflowID: reqMetadata.WorkflowId, + WorkflowName: reqMetadata.WorkflowName, + WorkflowOwner: reqMetadata.WorkflowOwner, + ReportID: reqMetadata.ReportId, } - report = value.GetBytesValue() - if report == nil { - return nil, fmt.Errorf("failed to get report bytes for request %s", reqMetadata.RequestId) + metadataPrepend, err := meta.Encode() + if err != nil { + return nil, fmt.Errorf("failed to encode metadata for request %s: %w", reqMetadata.RequestId, err) } - } - meta := ocrtypes.Metadata{ - Version: 1, - ExecutionID: reqMetadata.WorkflowExecutionId, - Timestamp: uint32(requestOutcome.Timestamp.AsTime().Unix()), // nolint - DONID: reqMetadata.WorkflowDonId, - DONConfigVersion: reqMetadata.WorkflowDonConfigVersion, - WorkflowID: reqMetadata.WorkflowId, - WorkflowName: reqMetadata.WorkflowName, - WorkflowOwner: reqMetadata.WorkflowOwner, - ReportID: reqMetadata.ReportId, - } + reportWithMetaData := append(metadataPrepend, report...) - metadataPrepend, err := meta.Encode() - if err != nil { - return nil, fmt.Errorf("failed to encode metadata for request %s: %w", reqMetadata.RequestId, err) - } + info, err := createSuccessfulConsensusReportInfo(reqMetadata) + if err != nil { + return nil, fmt.Errorf("failed to create report info for successful consensus request %s: %w", reqMetadata.RequestId, err) + } - reportWithMetaData := append(metadataPrepend, report...) + reports = append(reports, ocr3types.ReportPlus[[]byte]{ + ReportWithInfo: ocr3types.ReportWithInfo[[]byte]{ + Report: reportWithMetaData, + Info: info, + }, + TransmissionScheduleOverride: nil, + }) + case *oracletypes.ConsensusOutcome_Failure: + failedOutcome := v.Failure + r.lggr.Debugw("received failed consensus outcome", "requestID", failedOutcome.RequestID) + info, err := createFailedConsensusReportInfo(failedOutcome.RequestID, failedOutcome.KeyBundleId, failedOutcome.FailureMessage) + if err != nil { + return nil, fmt.Errorf("failed to create report info for successful consensus request %s: %w", failedOutcome.RequestID, err) + } - info, err := createReportInfo(reqMetadata) - if err != nil { - return nil, fmt.Errorf("failed to create report info for request %s: %w", reqMetadata.RequestId, err) + reports = append(reports, ocr3types.ReportPlus[[]byte]{ + ReportWithInfo: ocr3types.ReportWithInfo[[]byte]{ + Report: []byte{}, + Info: info, + }, + TransmissionScheduleOverride: nil, + }) + default: + r.lggr.Warnw("received unknown consensus outcome type", "outcome", outcome) } - - reports = append(reports, ocr3types.ReportPlus[[]byte]{ - ReportWithInfo: ocr3types.ReportWithInfo[[]byte]{ - Report: reportWithMetaData, - Info: info, - }, - TransmissionScheduleOverride: nil, - }) } - r.lggr.Debug("consensus plugin reports complete, number of reports", len(reports)) + r.lggr.Debug("consensus plugin reports complete, number of reports ", len(reports)) return reports, nil } // The report info is created as a map else the OCR3OnchainKeyringMultiChainAdapter will not work. // OCR3OnchainKeyringMultiChainAdapter (in core) requires that the key bundle id is added to the map with the key -// "keyBundleName" -func createReportInfo(reqMetadata *oracletypes.RequestMetaData) ([]byte, error) { +// "keyBundleName". +func createSuccessfulConsensusReportInfo(reqMetadata *oracletypes.RequestMetaData) ([]byte, error) { + infos, err := structpb.NewStruct(map[string]any{ + InfoKeyBundleName: reqMetadata.KeyBundleId, + InfoRequestID: reqMetadata.RequestId, + }) + if err != nil { + return nil, fmt.Errorf("failed to create structpb for report info: %w", err) + } + + infoBytes, err := proto.MarshalOptions{Deterministic: true}.Marshal(infos) + if err != nil { + return nil, fmt.Errorf("failed to marshal report info: %w", err) + } + + return infoBytes, nil +} + +func createFailedConsensusReportInfo(requestID string, keyBundleID string, failureMessage string) ([]byte, error) { infos, err := structpb.NewStruct(map[string]any{ - "keyBundleName": reqMetadata.KeyBundleId, - InfoRequestID: reqMetadata.RequestId, + InfoKeyBundleName: keyBundleID, + InfoRequestID: requestID, + InfoConsensusFailureMessage: failureMessage, }) if err != nil { return nil, fmt.Errorf("failed to create structpb for report info: %w", err) diff --git a/consensus/oracle/plugin/report_generation_test.go b/consensus/oracle/plugin/report_generation_test.go index 003896ef8..3058505b7 100644 --- a/consensus/oracle/plugin/report_generation_test.go +++ b/consensus/oracle/plugin/report_generation_test.go @@ -44,7 +44,7 @@ func Test_Report_MedianTimeStamp(t *testing.T) { }}, } - runProtocolRoundTests(ctx, t, lggr, n, f, batchSize, reqToObservations) + runProtocolRoundTests(ctx, t, lggr, n, f, reqToObservations) } // Test_MedianTimeStampsWithMismatchedObservationsIncludesAllTimestampsInCalculation checks that the median timestamp @@ -74,7 +74,7 @@ func Test_Report_MedianTimeStampWithMismatchedObservationsIncludesAllTimestampsI }}, } - runProtocolRoundTests(ctx, t, lggr, n, f, batchSize, reqToObservations) + runProtocolRoundTests(ctx, t, lggr, n, f, reqToObservations) } func Test_ReceivedIdenticalReportFromAllNodes(t *testing.T) { @@ -107,7 +107,7 @@ func Test_ReceivedIdenticalReportFromAllNodes(t *testing.T) { }}, } - runProtocolRoundTests(ctx, t, lggr, n, f, batchSize, reqToObservations) + runProtocolRoundTests(ctx, t, lggr, n, f, reqToObservations) } func Test_ReceivedIdenticalReportFromSufficientNodes(t *testing.T) { @@ -140,7 +140,7 @@ func Test_ReceivedIdenticalReportFromSufficientNodes(t *testing.T) { }}, } - runProtocolRoundTests(ctx, t, lggr, n, f, batchSize, reqToObservations) + runProtocolRoundTests(ctx, t, lggr, n, f, reqToObservations) } func Test_SufficientAndInsufficentReportsInSingleRound(t *testing.T) { @@ -167,13 +167,13 @@ func Test_SufficientAndInsufficentReportsInSingleRound(t *testing.T) { newRR([]byte("somerandombytes2"), md2), newRR([]byte("somerandombytes4"), md2), newRR([]byte("somerandombytes3"), md2), newRR([]byte("somerandombytes"), md2), newRR([]byte("somerandombytes"), md2), newRR([]byte("somerandombytes3"), md2), newRR([]byte("somerandombytes2"), md2)}, - verifyReport: nil}, + expectedConsensusFailureMessage: "no values met f+1 threshold"}, } - runProtocolRoundTests(ctx, t, lggr, n, f, batchSize, reqToObservations) + runProtocolRoundTests(ctx, t, lggr, n, f, reqToObservations) } -func Test_ReceivedIdenticalReportFromInSufficientNodes(t *testing.T) { +func Test_ReceivedIdenticalMultipleQualifyingSetsOfIdenticalValues(t *testing.T) { lggr := logger.Test(t) ctx := t.Context() @@ -185,10 +185,10 @@ func Test_ReceivedIdenticalReportFromInSufficientNodes(t *testing.T) { newRR([]byte("somerandombytes2"), md1), newRR([]byte("somerandombytes"), md1), newRR([]byte("somerandombytes"), md1), newRR([]byte("somerandombytes2"), md1), newRR([]byte("somerandombytes"), md1), newRR([]byte("somerandombytes"), md1), newRR([]byte("somerandombytes2"), md1)}, - verifyReport: nil}, + expectedConsensusFailureMessage: "not identical, multiple values with f+1 occurrences"}, } - runProtocolRoundTests(ctx, t, lggr, n, f, batchSize, reqToObservations) + runProtocolRoundTests(ctx, t, lggr, n, f, reqToObservations) } func newRR(rawBytes []byte, metaData oracle.ConsensusRequestMetadata) *oracle.ConsensusRequest { diff --git a/consensus/oracle/plugin/value_consensus_test.go b/consensus/oracle/plugin/value_consensus_test.go index 1ec07ca7a..b75a4301e 100644 --- a/consensus/oracle/plugin/value_consensus_test.go +++ b/consensus/oracle/plugin/value_consensus_test.go @@ -5,7 +5,6 @@ import ( "context" "crypto/rand" "encoding/hex" - "errors" "fmt" "testing" "time" @@ -15,31 +14,32 @@ import ( "google.golang.org/protobuf/types/known/structpb" "github.com/smartcontractkit/capabilities/consensus/metrics" + "github.com/smartcontractkit/capabilities/consensus/oracle" "github.com/smartcontractkit/capabilities/consensus/oracle/plugin" + oracletypes "github.com/smartcontractkit/capabilities/consensus/oracle/types" "github.com/smartcontractkit/chainlink-common/pkg/capabilities" pbtypes "github.com/smartcontractkit/chainlink-common/pkg/capabilities/consensus/ocr3/types" "github.com/smartcontractkit/chainlink-common/pkg/capabilities/consensus/requests" "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-protos/cre/go/sdk" "github.com/smartcontractkit/chainlink-protos/cre/go/values" valuespb "github.com/smartcontractkit/chainlink-protos/cre/go/values/pb" + "github.com/smartcontractkit/libocr/commontypes" "github.com/smartcontractkit/libocr/offchainreporting2plus/ocr3types" libocrTypes "github.com/smartcontractkit/libocr/offchainreporting2plus/types" - - "github.com/smartcontractkit/capabilities/consensus/oracle" - oracletypes "github.com/smartcontractkit/capabilities/consensus/oracle/types" ) type consensusPluginTest struct { - requests []*oracle.ConsensusRequest - verifyReport func(t *testing.T, report ocr3types.ReportPlus[[]byte], infos *structpb.Struct) + requests []*oracle.ConsensusRequest + verifyReport func(t *testing.T, report ocr3types.ReportPlus[[]byte], infos *structpb.Struct) + expectedConsensusFailureMessage string } const n = 7 const f = 2 -const batchSize = 10 const defaultMaxLengthBytes = 1000000 // 1 MB // nillable observation and nillable default value, -1 indicates the value should be set as nil @@ -59,6 +59,57 @@ func newSliceCr(t *testing.T, observation []byte, def []byte, metaData oracle.Co return oracle.NewConsensusRequest(serializeDeserialize(t, simpleConsensusInputs), time.Now(), time.Now().Add(1*time.Hour).UTC(), nil, metaData) } +func Test_InsufficientIdenticalObservations(t *testing.T) { + lggr := logger.Test(t) + ctx := t.Context() + + md1 := newRequestMetaData() + + reqToObservations := map[string]*consensusPluginTest{ + md1.RequestID(): {requests: []*oracle.ConsensusRequest{ + newIdenticalCr(t, 110, md1), newIdenticalCr(t, 110, md1), + newIdenticalCr(t, 120, md1), newIdenticalCr(t, 120, md1), + newIdenticalCr(t, 130, md1), newIdenticalCr(t, 130, md1), + newIdenticalCr(t, 140, md1), newIdenticalCr(t, 140, md1), + }, + expectedConsensusFailureMessage: "no values met f+1 threshold"}, + } + + runProtocolRoundTests(ctx, t, lggr, n, f, reqToObservations) +} + +func Test_InsufficientIdenticalMapObservations(t *testing.T) { + lggr := logger.Test(t) + ctx := t.Context() + + md1 := newRequestMetaData() + + type testStruct struct { + Field1 int + } + + reqToObservations := map[string]*consensusPluginTest{ + md1.RequestID(): {requests: []*oracle.ConsensusRequest{ + newIdenticalValueCr(t, mustWrap(t, testStruct{Field1: 100}), md1), + newIdenticalValueCr(t, mustWrap(t, testStruct{Field1: 110}), md1), + newIdenticalValueCr(t, mustWrap(t, testStruct{Field1: 120}), md1), + newIdenticalValueCr(t, mustWrap(t, testStruct{Field1: 130}), md1), + newIdenticalValueCr(t, mustWrap(t, testStruct{Field1: 140}), md1), + newIdenticalValueCr(t, mustWrap(t, testStruct{Field1: 150}), md1), + newIdenticalValueCr(t, mustWrap(t, testStruct{Field1: 160}), md1), + }, + expectedConsensusFailureMessage: "no values met f+1 threshold"}, + } + + runProtocolRoundTests(ctx, t, lggr, n, f, reqToObservations) +} + +func mustWrap(t *testing.T, v any) values.Value { + val, err := values.Wrap(v) + require.NoError(t, err) + return val +} + func Test_SliceObservationAndDefaults(t *testing.T) { lggr := logger.Test(t) ctx := t.Context() @@ -85,15 +136,15 @@ func Test_SliceObservationAndDefaults(t *testing.T) { // Test with just defaults as byte slices md2.RequestID(): {requests: []*oracle.ConsensusRequest{ - newSliceCr(t, nil, []byte("otherstuff"), md2), - newSliceCr(t, nil, []byte("otherstuff"), md2), - newSliceCr(t, nil, []byte("otherstuff"), md2), - newSliceCr(t, nil, []byte("otherstuff"), md2), - newSliceCr(t, nil, []byte("otherstuff"), md2), - newSliceCr(t, nil, []byte("otherstuff"), md2), - newSliceCr(t, nil, []byte("otherstuff"), md2)}, + newSliceCr(t, []byte{}, []byte("otherstuff"), md2), + newSliceCr(t, []byte{}, []byte("otherstuff"), md2), + newSliceCr(t, []byte{}, []byte("otherstuff"), md2), + newSliceCr(t, []byte{}, []byte("otherstuff"), md2), + newSliceCr(t, []byte{}, []byte("otherstuff"), md2), + newSliceCr(t, []byte{}, []byte("otherstuff"), md2), + newSliceCr(t, []byte{}, []byte("otherstuff"), md2)}, verifyReport: func(t *testing.T, report ocr3types.ReportPlus[[]byte], infos *structpb.Struct) { - val, err := values.Wrap([]byte("otherstuff")) + val, err := values.Wrap([]byte{}) require.NoError(t, err) verifyValueConsensusReport(t, report, infos, val, "") @@ -101,13 +152,13 @@ func Test_SliceObservationAndDefaults(t *testing.T) { // Test with a mixture of observations and defaults as byte slices md2.RequestID(): {requests: []*oracle.ConsensusRequest{ - newSliceCr(t, nil, []byte("otherstuff"), md2), - newSliceCr(t, nil, []byte("otherstuff"), md2), + newSliceCr(t, []byte("guff"), []byte("otherstuff"), md2), + newSliceCr(t, []byte("somestuff"), []byte("otherstuff"), md2), newSliceCr(t, []byte("stuff"), []byte("otherstuff"), md2), newSliceCr(t, nil, []byte("otherstuff"), md2), newSliceCr(t, nil, []byte("otherstuff"), md2), newSliceCr(t, []byte("stuff"), []byte("otherstuff"), md2), - newSliceCr(t, nil, []byte("otherstuff"), md2)}, + newSliceCr(t, []byte("somestuff"), []byte("otherstuff"), md2)}, verifyReport: func(t *testing.T, report ocr3types.ReportPlus[[]byte], infos *structpb.Struct) { val, err := values.Wrap([]byte("otherstuff")) require.NoError(t, err) @@ -116,7 +167,7 @@ func Test_SliceObservationAndDefaults(t *testing.T) { }}, } - runProtocolRoundTests(ctx, t, lggr, n, f, batchSize, reqToObservations) + runProtocolRoundTests(ctx, t, lggr, n, f, reqToObservations) } func Test_MismatchedLeaderConsensusDescriptor(t *testing.T) { @@ -125,24 +176,17 @@ func Test_MismatchedLeaderConsensusDescriptor(t *testing.T) { metaData := newRequestMetaData() - newCrIdenticalConsensus := func(observation int64, metaData oracle.ConsensusRequestMetadata) *oracle.ConsensusRequest { - simpleConsensusInputs := &sdk.SimpleConsensusInputs{ - Observation: &sdk.SimpleConsensusInputs_Value{Value: values.Proto(values.NewInt64(observation))}, - Descriptors: &sdk.ConsensusDescriptor{Descriptor_: &sdk.ConsensusDescriptor_Aggregation{Aggregation: sdk.AggregationType_AGGREGATION_TYPE_IDENTICAL}}, - } - - return oracle.NewConsensusRequest(simpleConsensusInputs, time.Now().Add(1*time.Hour).UTC(), time.Now(), nil, metaData) - } - protocolRoundTests := map[string]*consensusPluginTest{ metaData.RequestID(): {requests: []*oracle.ConsensusRequest{ - newCrIdenticalConsensus(110, metaData), newCr(t, 120, metaData), newCr(t, 130, metaData), + newIdenticalCr(t, 110, metaData), newCr(t, 120, metaData), newCr(t, 130, metaData), newCr(t, 140, metaData), newCr(t, 150, metaData), newCr(t, 160, metaData), newCr(t, 170, metaData)}, - verifyReport: nil}, + verifyReport: func(t *testing.T, report ocr3types.ReportPlus[[]byte], infos *structpb.Struct) { + verifyValueConsensusReport(t, report, infos, values.NewInt64(140), "") + }}, } - runProtocolRoundTests(ctx, t, lggr, n, f, batchSize, protocolRoundTests) + runProtocolRoundTests(ctx, t, lggr, n, f, protocolRoundTests) } func Test_MismatchedNonLeaderConsensusDescriptor(t *testing.T) { @@ -170,7 +214,7 @@ func Test_MismatchedNonLeaderConsensusDescriptor(t *testing.T) { }}, } - runProtocolRoundTests(ctx, t, lggr, n, f, batchSize, protocolRoundTests) + runProtocolRoundTests(ctx, t, lggr, n, f, protocolRoundTests) } func Test_MismatchedLeaderMetaData(t *testing.T) { @@ -187,10 +231,13 @@ func Test_MismatchedLeaderMetaData(t *testing.T) { newCr(t, 110, leaderMetaData), newCr(t, 120, metaData), newCr(t, 130, metaData), newCr(t, 140, metaData), newCr(t, 150, metaData), newCr(t, 160, metaData), newCr(t, 170, metaData)}, - verifyReport: nil}, + verifyReport: func(t *testing.T, report ocr3types.ReportPlus[[]byte], infos *structpb.Struct) { + verifyValueConsensusReport(t, report, infos, values.NewInt64(140), "") + }, + }, } - runProtocolRoundTests(ctx, t, lggr, n, f, batchSize, protocolRoundTests) + runProtocolRoundTests(ctx, t, lggr, n, f, protocolRoundTests) } func Test_MismatchedNonLeaderMetaData(t *testing.T) { @@ -212,7 +259,7 @@ func Test_MismatchedNonLeaderMetaData(t *testing.T) { }}, } - runProtocolRoundTests(ctx, t, lggr, n, f, batchSize, protocolRoundTests) + runProtocolRoundTests(ctx, t, lggr, n, f, protocolRoundTests) } func Test_ObservationDefaults(t *testing.T) { @@ -246,15 +293,15 @@ func Test_ObservationDefaults(t *testing.T) { // Test insufficient non-nil observations but with sufficient matching defaults md3.RequestID(): {requests: []*oracle.ConsensusRequest{ - newNillableCr(t, 10, 40, md3), newNillableCr(t, 20, 40, md3), newNillableCr(t, 30, 40, md3), - newNillableCr(t, 35, 40, md3), newNillableCr(t, -1, 40, md3), newNillableCr(t, -1, 40, md3), + newNillableCr(t, 10, 40, md3), newNillableCr(t, -1, 40, md3), newNillableCr(t, 30, 40, md3), + newNillableCr(t, -1, 40, md3), newNillableCr(t, -1, 40, md3), newNillableCr(t, -1, 40, md3), newNillableCr(t, -1, 40, md3)}, verifyReport: func(t *testing.T, report ocr3types.ReportPlus[[]byte], infos *structpb.Struct) { - verifyValueConsensusReport(t, report, infos, values.NewInt64(35), "") + verifyValueConsensusReport(t, report, infos, values.NewInt64(40), "") }}, } - runProtocolRoundTests(ctx, t, lggr, n, f, batchSize, reqToObservations) + runProtocolRoundTests(ctx, t, lggr, n, f, reqToObservations) } func Test_ReceivedAllObservationsFromAllNodes(t *testing.T) { @@ -284,37 +331,7 @@ func Test_ReceivedAllObservationsFromAllNodes(t *testing.T) { }}, } - runProtocolRoundTests(ctx, t, lggr, n, f, batchSize, reqToObservations) -} - -func Test_ReceivedObservationsWithErrors(t *testing.T) { - lggr := logger.Test(t) - ctx := t.Context() - - md1 := newRequestMetaData() - md2 := newRequestMetaData() - - md1.KeyBundleID = "evm" - - reqToObservations := map[string]*consensusPluginTest{ - md1.RequestID(): {requests: []*oracle.ConsensusRequest{ - newCr(t, 10, md1), newCrWithError(t, errors.New("its broken"), md1), newCr(t, 30, md1), - newCr(t, 40, md1), newCr(t, 50, md1), newCr(t, 60, md1), - newCr(t, 70, md1)}, - verifyReport: func(t *testing.T, report ocr3types.ReportPlus[[]byte], infos *structpb.Struct) { - verifyValueConsensusReport(t, report, infos, values.NewInt64(40), "evm") - }}, - - md2.RequestID(): {requests: []*oracle.ConsensusRequest{ - newCr(t, 110, md2), newCr(t, 120, md2), newCr(t, 130, md2), - newCr(t, 140, md2), newCr(t, 150, md2), newCr(t, 160, md2), - newCrWithError(t, errors.New("its broken"), md2)}, - verifyReport: func(t *testing.T, report ocr3types.ReportPlus[[]byte], infos *structpb.Struct) { - verifyValueConsensusReport(t, report, infos, values.NewInt64(130), "") - }}, - } - - runProtocolRoundTests(ctx, t, lggr, n, f, batchSize, reqToObservations) + runProtocolRoundTests(ctx, t, lggr, n, f, reqToObservations) } func Test_ReceivedObservationsWithMatchingDefaults(t *testing.T) { @@ -334,7 +351,7 @@ func Test_ReceivedObservationsWithMatchingDefaults(t *testing.T) { }}, } - runProtocolRoundTests(ctx, t, lggr, n, f, batchSize, reqToObservations) + runProtocolRoundTests(ctx, t, lggr, n, f, reqToObservations) } // In this test some nodes have observations that match the default, and some have observations that do not match the default @@ -356,7 +373,7 @@ func Test_ReceivedObservationsWithSomeMisMatchedDefaults_SufficientForConsensus( }}, } - runProtocolRoundTests(ctx, t, lggr, n, f, batchSize, reqToObservations) + runProtocolRoundTests(ctx, t, lggr, n, f, reqToObservations) } // In this test some nodes have observations that match the default, and some have observations that do not match the default @@ -371,31 +388,56 @@ func Test_ReceivedObservationsWithSomeMisMatchedDefaults_InsufficientForConsensu reqToObservations := map[string]*consensusPluginTest{ md1.RequestID(): {requests: []*oracle.ConsensusRequest{ newCrWithObsAndDef(t, 10, 17, md1), newCrWithObsAndDef(t, 20, 12, md1), newCrWithObsAndDef(t, 30, 17, md1), - newCrWithObsAndDef(t, 40, 16, md1), newCrWithObsAndDef(t, 50, 17, md1), newCrWithObsAndDef(t, 60, 11, md1), - newCrWithObsAndDef(t, 70, 17, md1)}, - verifyReport: nil}, + newCrWithObsAndDef(t, 40, 16, md1), newCrWithObsAndDef(t, 50, 15, md1), newCrWithObsAndDef(t, 60, 11, md1), + newCrWithObsAndDef(t, 70, 15, md1)}, + expectedConsensusFailureMessage: "failed to calculate consensus metadata, descriptor and default for request: no values met f+1 threshold"}, } - runProtocolRoundTests(ctx, t, lggr, n, f, batchSize, reqToObservations) + runProtocolRoundTests(ctx, t, lggr, n, f, reqToObservations) } -// In this test other nodes have observations that do not match the leader's default -func Test_LeaderNodeMisMatchedDefault_InsufficientForConsensus(t *testing.T) { +func Test_MisMatchedDefaults_SufficientForConsensus_ReturnsDefault(t *testing.T) { lggr := logger.Test(t) ctx := t.Context() md1 := newRequestMetaData() md1.KeyBundleID = "evm" + md2 := md1 + md2.WorkflowOwner = generateRandomHexString(20) + + md3 := md1 + md3.WorkflowOwner = generateRandomHexString(20) + reqToObservations := map[string]*consensusPluginTest{ md1.RequestID(): {requests: []*oracle.ConsensusRequest{ - newCrWithObsAndDef(t, 10, 14, md1), newCrWithObsAndDef(t, 20, 17, md1), newCrWithObsAndDef(t, 30, 17, md1), - newCrWithObsAndDef(t, 40, 17, md1), newCrWithObsAndDef(t, 50, 17, md1), newCrWithObsAndDef(t, 60, 17, md1), + newIdenticalCrWithDefault(t, 10, 14, md2), newIdenticalCrWithDefault(t, 20, 17, md3), newIdenticalCrWithDefault(t, 30, 17, md3), + newIdenticalCrWithDefault(t, 40, 16, md1), newIdenticalCrWithDefault(t, 50, 17, md3), newIdenticalCrWithDefault(t, 60, 15, md1), + newIdenticalCrWithDefault(t, 70, 19, md1)}, + verifyReport: func(t *testing.T, report ocr3types.ReportPlus[[]byte], infos *structpb.Struct) { + verifyValueConsensusReport(t, report, infos, values.NewInt64(17), "evm") + }}, + } + + runProtocolRoundTests(ctx, t, lggr, n, f, reqToObservations) +} + +func Test_MisMatchedDefaults_InsufficientForConsensus(t *testing.T) { + lggr := logger.Test(t) + ctx := t.Context() + + md1 := newRequestMetaData() + md1.KeyBundleID = "evm" + + reqToObservations := map[string]*consensusPluginTest{ + md1.RequestID(): {requests: []*oracle.ConsensusRequest{ + newCrWithObsAndDef(t, 10, 14, md1), newCrWithObsAndDef(t, 20, 15, md1), newCrWithObsAndDef(t, 30, 15, md1), + newCrWithObsAndDef(t, 40, 16, md1), newCrWithObsAndDef(t, 50, 16, md1), newCrWithObsAndDef(t, 60, 17, md1), newCrWithObsAndDef(t, 70, 17, md1)}, - verifyReport: nil}, + expectedConsensusFailureMessage: "failed to calculate consensus metadata, descriptor and default for request: no values met f+1 threshold"}, } - runProtocolRoundTests(ctx, t, lggr, n, f, batchSize, reqToObservations) + runProtocolRoundTests(ctx, t, lggr, n, f, reqToObservations) } func Test_MissingButSufficientObservations(t *testing.T) { @@ -428,7 +470,7 @@ func Test_MissingButSufficientObservations(t *testing.T) { verifyReport: nil}, } - runProtocolRoundTests(ctx, t, lggr, n, f, batchSize, reqToObservations) + runProtocolRoundTests(ctx, t, lggr, n, f, reqToObservations) } func Test_InsufficientObservations(t *testing.T) { @@ -456,7 +498,7 @@ func Test_InsufficientObservations(t *testing.T) { verifyReport: nil}, } - runProtocolRoundTests(ctx, t, lggr, n, f, batchSize, reqToObservations) + runProtocolRoundTests(ctx, t, lggr, n, f, reqToObservations) } func Test_LeaderHasNoMatchingRequest(t *testing.T) { @@ -485,7 +527,7 @@ func Test_LeaderHasNoMatchingRequest(t *testing.T) { verifyReport: nil}, } - runProtocolRoundTests(ctx, t, lggr, n, f, batchSize, reqToObservations) + runProtocolRoundTests(ctx, t, lggr, n, f, reqToObservations) } func Test_WithOutcomeContext(t *testing.T) { @@ -506,7 +548,7 @@ func Test_WithOutcomeContext(t *testing.T) { }}, } - runProtocolRoundTests(ctx, t, lggr, n, f, batchSize, reqToObservations) + runProtocolRoundTests(ctx, t, lggr, n, f, reqToObservations) } func newRequestMetaData() oracle.ConsensusRequestMetadata { @@ -538,13 +580,37 @@ func generateRandomHexString(byteLength int) string { return hex.EncodeToString(randomBytes) } -func newCr(t *testing.T, observation int64, metaData oracle.ConsensusRequestMetadata) *oracle.ConsensusRequest { - defaultVal, err := values.Wrap(nil) - require.NoError(t, err, "failed to wrap default value") +func newIdenticalCr(t *testing.T, observation int64, metaData oracle.ConsensusRequestMetadata) *oracle.ConsensusRequest { + simpleConsensusInputs := &sdk.SimpleConsensusInputs{ + Observation: &sdk.SimpleConsensusInputs_Value{Value: values.Proto(values.NewInt64(observation))}, + Descriptors: &sdk.ConsensusDescriptor{Descriptor_: &sdk.ConsensusDescriptor_Aggregation{Aggregation: sdk.AggregationType_AGGREGATION_TYPE_IDENTICAL}}, + } + return oracle.NewConsensusRequest(serializeDeserialize(t, simpleConsensusInputs), time.Now(), time.Now().Add(1*time.Hour).UTC(), nil, metaData) +} + +func newIdenticalValueCr(t *testing.T, observation values.Value, metaData oracle.ConsensusRequestMetadata) *oracle.ConsensusRequest { + simpleConsensusInputs := &sdk.SimpleConsensusInputs{ + Observation: &sdk.SimpleConsensusInputs_Value{Value: values.Proto(observation)}, + Descriptors: &sdk.ConsensusDescriptor{Descriptor_: &sdk.ConsensusDescriptor_Aggregation{Aggregation: sdk.AggregationType_AGGREGATION_TYPE_IDENTICAL}}, + } + + return oracle.NewConsensusRequest(serializeDeserialize(t, simpleConsensusInputs), time.Now(), time.Now().Add(1*time.Hour).UTC(), nil, metaData) +} + +func newIdenticalCrWithDefault(t *testing.T, observation int64, defaultObs int64, metaData oracle.ConsensusRequestMetadata) *oracle.ConsensusRequest { + simpleConsensusInputs := &sdk.SimpleConsensusInputs{ + Observation: &sdk.SimpleConsensusInputs_Value{Value: values.Proto(values.NewInt64(observation))}, + Default: values.Proto(values.NewInt64(defaultObs)), + Descriptors: &sdk.ConsensusDescriptor{Descriptor_: &sdk.ConsensusDescriptor_Aggregation{Aggregation: sdk.AggregationType_AGGREGATION_TYPE_IDENTICAL}}, + } + + return oracle.NewConsensusRequest(serializeDeserialize(t, simpleConsensusInputs), time.Now(), time.Now().Add(1*time.Hour).UTC(), nil, metaData) +} + +func newCr(t *testing.T, observation int64, metaData oracle.ConsensusRequestMetadata) *oracle.ConsensusRequest { simpleConsensusInputs := &sdk.SimpleConsensusInputs{ Observation: &sdk.SimpleConsensusInputs_Value{Value: values.Proto(values.NewInt64(observation))}, - Default: values.Proto(defaultVal), Descriptors: &sdk.ConsensusDescriptor{Descriptor_: &sdk.ConsensusDescriptor_Aggregation{Aggregation: sdk.AggregationType_AGGREGATION_TYPE_MEDIAN}}, } @@ -562,14 +628,22 @@ func serializeDeserialize(t *testing.T, simpleConsensusInputs *sdk.SimpleConsens } func newCrWithError(t *testing.T, crErr error, metaData oracle.ConsensusRequestMetadata) *oracle.ConsensusRequest { - defaultVal, err := values.Wrap(nil) - require.NoError(t, err, "failed to wrap default value") + simpleConsensusInputs := &sdk.SimpleConsensusInputs{ + Observation: &sdk.SimpleConsensusInputs_Error{ + Error: crErr.Error(), + }, + Descriptors: &sdk.ConsensusDescriptor{Descriptor_: &sdk.ConsensusDescriptor_Aggregation{Aggregation: sdk.AggregationType_AGGREGATION_TYPE_MEDIAN}}, + } + return oracle.NewConsensusRequest(serializeDeserialize(t, simpleConsensusInputs), time.Now(), time.Now().Add(1*time.Hour).UTC(), nil, metaData) +} + +func newCrWithErrorAndDefault(t *testing.T, crErr error, def int64, metaData oracle.ConsensusRequestMetadata) *oracle.ConsensusRequest { simpleConsensusInputs := &sdk.SimpleConsensusInputs{ Observation: &sdk.SimpleConsensusInputs_Error{ Error: crErr.Error(), }, - Default: values.Proto(defaultVal), + Default: values.Proto(values.NewInt64(def)), Descriptors: &sdk.ConsensusDescriptor{Descriptor_: &sdk.ConsensusDescriptor_Aggregation{Aggregation: sdk.AggregationType_AGGREGATION_TYPE_MEDIAN}}, } @@ -591,23 +665,27 @@ func newNillableCr(t *testing.T, observation int64, def int64, metaData oracle.C observationVal, err := values.Wrap(nil) require.NoError(t, err, "failed to wrap nil value") - defaultVal, err := values.Wrap(nil) - require.NoError(t, err, "failed to wrap nil value") + var defaultVal values.Value if observation != -1 { observationVal, err = values.Wrap(values.NewInt64(observation)) require.NoError(t, err, "failed to wrap observation value") } + var simpleConsensusInputs *sdk.SimpleConsensusInputs if def != -1 { defaultVal, err = values.Wrap(values.NewInt64(def)) require.NoError(t, err, "failed to wrap default value") - } - - simpleConsensusInputs := &sdk.SimpleConsensusInputs{ - Observation: &sdk.SimpleConsensusInputs_Value{Value: values.Proto(observationVal)}, - Default: values.Proto(defaultVal), - Descriptors: &sdk.ConsensusDescriptor{Descriptor_: &sdk.ConsensusDescriptor_Aggregation{Aggregation: sdk.AggregationType_AGGREGATION_TYPE_MEDIAN}}, + simpleConsensusInputs = &sdk.SimpleConsensusInputs{ + Observation: &sdk.SimpleConsensusInputs_Value{Value: values.Proto(observationVal)}, + Default: values.Proto(defaultVal), + Descriptors: &sdk.ConsensusDescriptor{Descriptor_: &sdk.ConsensusDescriptor_Aggregation{Aggregation: sdk.AggregationType_AGGREGATION_TYPE_MEDIAN}}, + } + } else { + simpleConsensusInputs = &sdk.SimpleConsensusInputs{ + Observation: &sdk.SimpleConsensusInputs_Value{Value: values.Proto(observationVal)}, + Descriptors: &sdk.ConsensusDescriptor{Descriptor_: &sdk.ConsensusDescriptor_Aggregation{Aggregation: sdk.AggregationType_AGGREGATION_TYPE_MEDIAN}}, + } } return oracle.NewConsensusRequest(serializeDeserialize(t, simpleConsensusInputs), time.Now(), time.Now().Add(1*time.Hour).UTC(), nil, metaData) @@ -618,19 +696,18 @@ type pluginAndRequestStore struct { store *requests.Store[*oracle.ConsensusRequest] } -func runProtocolRoundTests(ctx context.Context, t *testing.T, lggr logger.Logger, n, f, batchSize int, - reqToObservations map[string]*consensusPluginTest) { - pluginAndRequestStores := createPluginsAndStores(n, t, lggr, f, batchSize, 5) +func runProtocolRoundTests(ctx context.Context, t *testing.T, lggr logger.Logger, n, f int, reqToObservations map[string]*consensusPluginTest) { + pluginAndRequestStores := createPluginsAndStores(n, t, lggr, f, 5) addRequestsToAllStores(pluginAndRequestStores, reqToObservations, t) runProtocolRoundTestsWithPlugins(ctx, t, reqToObservations, pluginAndRequestStores, ocr3types.OutcomeContext{}) } -func createPluginsAndStores(n int, t *testing.T, lggr logger.Logger, f int, batchSize int, outcomeExpirySpan uint64) []pluginAndRequestStore { +func createPluginsAndStores(n int, t *testing.T, lggr logger.Logger, f int, outcomeExpirySpan uint64) []pluginAndRequestStore { var pluginAndRequestStores []pluginAndRequestStore for i := 0; i < n; i++ { - reportingPlugin, reqStore := createReportingPlugin(t, lggr, f, n, batchSize, outcomeExpirySpan) + reportingPlugin, reqStore := createReportingPlugin(t, lggr, f, n, outcomeExpirySpan) pluginAndRequestStores = append(pluginAndRequestStores, pluginAndRequestStore{ plugin: reportingPlugin, store: reqStore, @@ -703,14 +780,7 @@ func runProtocolRoundTestsWithPlugins(ctx context.Context, t *testing.T, err = proto.Unmarshal(nodeOutcomes[0], outcome) require.NoError(t, err, "failed to unmarshal value from outcome") - var successfulOutcomes []ocr3types.Outcome - for _, ro := range outcome.Outcomes { - if ro.Status == oracletypes.RequestStatus_REQUEST_STATUS_CONSENSUS_SUCCESS { - successfulOutcomes = append(successfulOutcomes, ro.Outcome) - } - } - - require.Len(t, reports, len(successfulOutcomes), "reporting plugin returned wrong number of reports") + require.Len(t, reports, len(outcome.Outcomes), "reporting plugin returned wrong number of reports") allReports = append(allReports, reports) } @@ -733,26 +803,44 @@ func runProtocolRoundTestsWithPlugins(ctx context.Context, t *testing.T, // Get reports and verify the value selected reports := allReports[0] receivedReportForRequestIDs := map[string]bool{} + receivedFailureMessageForRequestIDs := map[string]bool{} for _, report := range reports { - serialisedValue := report.ReportWithInfo.Report[plugin.ReportMetaDataPrependLength:] - actualProto := &valuespb.Value{} - err := proto.Unmarshal(serialisedValue, actualProto) - require.NoError(t, err, "failed to unmarshal value from report") var infos structpb.Struct err = proto.Unmarshal(report.ReportWithInfo.Info, &infos) require.NoError(t, err, "failed to unmarshal value from report") - reqID := infos.Fields[plugin.InfoRequestID].GetStringValue() + infoMap := infos.AsMap() - receivedReportForRequestIDs[reqID] = true - expectedOutcome, ok := requestIDToOutcome[reqID] - require.True(t, ok, "got report for a request without a test outcome %s", reqID) + if failureMessage, exists := infoMap[plugin.InfoConsensusFailureMessage]; exists { + reqID := infos.Fields[plugin.InfoRequestID].GetStringValue() + expectedOutcome, ok := requestIDToOutcome[reqID] + require.True(t, ok, "got report for a request without a test outcome %s", reqID) + + if len(expectedOutcome.expectedConsensusFailureMessage) == 0 { + require.FailNow(t, "not expecting failure message for request %s", reqID) + } - if expectedOutcome.verifyReport != nil { - expectedOutcome.verifyReport(t, report, &infos) + receivedFailureMessageForRequestIDs[reqID] = true + + require.Contains(t, failureMessage.(string), expectedOutcome.expectedConsensusFailureMessage) } else { - require.FailNow(t, "not expecting report for request %s", reqID) + serialisedValue := report.ReportWithInfo.Report[plugin.ReportMetaDataPrependLength:] + actualProto := &valuespb.Value{} + err := proto.Unmarshal(serialisedValue, actualProto) + require.NoError(t, err, "failed to unmarshal value from report") + + reqID := infos.Fields[plugin.InfoRequestID].GetStringValue() + + receivedReportForRequestIDs[reqID] = true + expectedOutcome, ok := requestIDToOutcome[reqID] + require.True(t, ok, "got report for a request without a test outcome %s", reqID) + + if expectedOutcome.verifyReport != nil { + expectedOutcome.verifyReport(t, report, &infos) + } else { + require.FailNow(t, "not expecting report for request %s", reqID) + } } } @@ -763,6 +851,13 @@ func runProtocolRoundTestsWithPlugins(ctx context.Context, t *testing.T, } } + // Verify all expected failure messages were received + for reqID, outcome := range requestIDToOutcome { + if len(outcome.expectedConsensusFailureMessage) > 0 { + require.True(t, receivedFailureMessageForRequestIDs[reqID], "expected failure message for request ID %s was not received", reqID) + } + } + return nodeOutcomes[0] } @@ -817,24 +912,19 @@ func verifyValueConsensusReport(t *testing.T, report ocr3types.ReportPlus[[]byte } func createReportingPlugin(t *testing.T, lggr logger.Logger, f int, n int, - batchSize int, outcomeExpirySpan uint64) (ocr3types.ReportingPlugin[[]byte], *requests.Store[*oracle.ConsensusRequest]) { + outcomeExpirySpan uint64) (ocr3types.ReportingPlugin[[]byte], *requests.Store[*oracle.ConsensusRequest]) { reqStore := requests.NewStore[*oracle.ConsensusRequest]() metricsInstance, err := metrics.NewMetrics() require.NoError(t, err) reportingPlugin, err := plugin.NewReportingPlugin(lggr, metricsInstance, f, n, reqStore, &pbtypes.ReportingPluginConfig{ - MaxQueryLengthBytes: defaultMaxLengthBytes, - MaxObservationLengthBytes: defaultMaxLengthBytes, - MaxOutcomeLengthBytes: defaultMaxLengthBytes, - MaxBatchSize: func() uint32 { - if batchSize < 0 || batchSize > int(^uint32(0)) { - return 0 - } - return uint32(batchSize) - }(), + MaxQueryLengthBytes: defaultMaxLengthBytes, + MaxObservationLengthBytes: defaultMaxLengthBytes, + MaxOutcomeLengthBytes: defaultMaxLengthBytes, + MaxReportLengthBytes: defaultMaxLengthBytes, HistoricalOutcomeExpirySeqNrSpan: outcomeExpirySpan, - }) + }, "evm") require.NoError(t, err) return reportingPlugin, reqStore } diff --git a/consensus/oracle/transmitter/transmitter.go b/consensus/oracle/transmitter/transmitter.go index 4432cd701..e18962a25 100644 --- a/consensus/oracle/transmitter/transmitter.go +++ b/consensus/oracle/transmitter/transmitter.go @@ -28,6 +28,7 @@ type ContractTransmitter struct { func (c *ContractTransmitter) Transmit(ctx context.Context, configDigest types.ConfigDigest, seqNr uint64, rwi ocr3types.ReportWithInfo[[]byte], signatures []types.AttributedOnchainSignature) error { + unmarshalledInfo := new(structpb.Struct) err := proto.Unmarshal(rwi.Info, unmarshalledInfo) if err != nil { @@ -44,19 +45,38 @@ func (c *ContractTransmitter) Transmit(ctx context.Context, configDigest types.C return errors.New("infoRequestID is not a string") } - // report context is the config digest + the sequence number padded with zeros - repContext := report.GenerateReportContext(seqNr, configDigest) + if failureMessage, exists := infoMap[plugin.InfoConsensusFailureMessage]; exists { + failureMessageStr, ok := failureMessage.(string) + if !ok { + return errors.New("message is not a string") + } - response := oracle.ConsensusResponse{ - ReqID: requestIDStr, - ConfigDigest: configDigest, - SeqNr: seqNr, - ReportContext: repContext, - RawReport: rwi.Report, - Sigs: signatures, - } + c.lggr.Debugw("received consensus failure message report", "requestID", requestIDStr, + "failureMessage", failureMessageStr) + + response := oracle.ConsensusResponse{ + ReqID: requestIDStr, + SeqNr: seqNr, + Err: errors.New(failureMessageStr), + } - c.sendResponse(ctx, response) + c.sendResponse(ctx, response) + } else { + c.lggr.Debugw("received consensus success report", "requestID", requestIDStr) + // report context is the config digest + the sequence number padded with zeros + repContext := report.GenerateReportContext(seqNr, configDigest) + + response := oracle.ConsensusResponse{ + ReqID: requestIDStr, + ConfigDigest: configDigest, + SeqNr: seqNr, + ReportContext: repContext, + RawReport: rwi.Report, + Sigs: signatures, + } + + c.sendResponse(ctx, response) + } return nil } diff --git a/consensus/oracle/transmitter/transmitter_test.go b/consensus/oracle/transmitter/transmitter_test.go index e531bc2b8..1b6b31870 100644 --- a/consensus/oracle/transmitter/transmitter_test.go +++ b/consensus/oracle/transmitter/transmitter_test.go @@ -63,3 +63,49 @@ func Test_Transmit(t *testing.T) { require.NoError(t, err, "Transmit method returned an error") require.True(t, sendResponseCalled, "sendResponse should be called") } + +func Test_Transmit_FailureMessage(t *testing.T) { + lggr := logger.Test(t) + sendResponseCalled := false + + configDigest := [32]byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, + 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20} + seqNr := uint64(2) + report := []byte("test-report-failure") + signatures := []types.AttributedOnchainSignature{ + {Signature: []byte("signature-2"), Signer: commontypes.OracleID(2)}, + } + + failureMsg := "consensus failed" + sendResponse := func(ctx context.Context, response oracle.ConsensusResponse) { + sendResponseCalled = true + require.Equal(t, "test-request-id-failure", response.ReqID) + require.Equal(t, seqNr, response.SeqNr) + require.Error(t, response.Err) + require.Contains(t, response.Err.Error(), failureMsg) + require.Empty(t, response.ConfigDigest) + require.Empty(t, response.ReportContext) + require.Empty(t, response.RawReport) + require.Empty(t, response.Sigs) + } + + transmitter := NewContractTransmitter(lggr, sendResponse) + + info := &structpb.Struct{ + Fields: map[string]*structpb.Value{ + plugin.InfoRequestID: structpb.NewStringValue("test-request-id-failure"), + plugin.InfoConsensusFailureMessage: structpb.NewStringValue(failureMsg), + }, + } + infoBytes, err := proto.Marshal(info) + require.NoError(t, err) + + rwi := ocr3types.ReportWithInfo[[]byte]{ + Report: report, + Info: infoBytes, + } + + err = transmitter.Transmit(context.Background(), configDigest, seqNr, rwi, signatures) + require.NoError(t, err) + require.True(t, sendResponseCalled) +} diff --git a/consensus/oracle/types/generate.go b/consensus/oracle/types/generate.go index fe87617db..b8ba6c93d 100644 --- a/consensus/oracle/types/generate.go +++ b/consensus/oracle/types/generate.go @@ -1,3 +1,2 @@ +//go:generate go run ./generate/main.go package types - -//go:generate protoc --go_out=. --go_opt=paths=source_relative value_consensus_types.proto diff --git a/consensus/oracle/types/generate/main.go b/consensus/oracle/types/generate/main.go new file mode 100644 index 000000000..856508cd3 --- /dev/null +++ b/consensus/oracle/types/generate/main.go @@ -0,0 +1,23 @@ +package main + +import "github.com/smartcontractkit/chainlink-protos/cre/go/installer/pkg" + +func main() { + gen := &pkg.ProtocGen{ + Plugins: []pkg.Plugin{ + {Name: "go"}, + }, + } + gen.AddSourceDirectories(".") + gen.LinkPackage(pkg.Packages{ + Go: "github.com/smartcontractkit/chainlink-protos/cre/values/v1", + Proto: "values/v1/values.proto", + }) + gen.LinkPackage(pkg.Packages{ + Go: "github.com/smartcontractkit/chainlink-protos/cre/sdk/v1alpha", + Proto: "sdk/v1alpha/sdk.proto", + }) + if err := gen.GenerateFile("value_consensus_types.proto", "."); err != nil { + panic(err) + } +} diff --git a/consensus/oracle/types/value_consensus_types.pb.go b/consensus/oracle/types/value_consensus_types.pb.go index d89d7120a..23266cdc1 100644 --- a/consensus/oracle/types/value_consensus_types.pb.go +++ b/consensus/oracle/types/value_consensus_types.pb.go @@ -7,6 +7,7 @@ package types import ( + sdk "github.com/smartcontractkit/chainlink-protos/cre/go/sdk" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" timestamppb "google.golang.org/protobuf/types/known/timestamppb" @@ -68,53 +69,6 @@ func (RequestType) EnumDescriptor() ([]byte, []int) { return file_value_consensus_types_proto_rawDescGZIP(), []int{0} } -// TODO as part of https://smartcontract-it.atlassian.net/browse/CAPPL-1076 add additional statuses (errored, failed etc.....): -type RequestStatus int32 - -const ( - RequestStatus_REQUEST_STATUS_CONSENSUS_SUCCESS RequestStatus = 0 - RequestStatus_REQUEST_STATUS_CONSENSUS_PENDING RequestStatus = 1 -) - -// Enum value maps for RequestStatus. -var ( - RequestStatus_name = map[int32]string{ - 0: "REQUEST_STATUS_CONSENSUS_SUCCESS", - 1: "REQUEST_STATUS_CONSENSUS_PENDING", - } - RequestStatus_value = map[string]int32{ - "REQUEST_STATUS_CONSENSUS_SUCCESS": 0, - "REQUEST_STATUS_CONSENSUS_PENDING": 1, - } -) - -func (x RequestStatus) Enum() *RequestStatus { - p := new(RequestStatus) - *p = x - return p -} - -func (x RequestStatus) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (RequestStatus) Descriptor() protoreflect.EnumDescriptor { - return file_value_consensus_types_proto_enumTypes[1].Descriptor() -} - -func (RequestStatus) Type() protoreflect.EnumType { - return &file_value_consensus_types_proto_enumTypes[1] -} - -func (x RequestStatus) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use RequestStatus.Descriptor instead. -func (RequestStatus) EnumDescriptor() ([]byte, []int) { - return file_value_consensus_types_proto_rawDescGZIP(), []int{1} -} - type RequestMetaData struct { state protoimpl.MessageState `protogen:"open.v1"` RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` @@ -239,29 +193,27 @@ func (x *RequestMetaData) GetRequestType() RequestType { return RequestType_VALUE_CONSENSUS } -type Request struct { - state protoimpl.MessageState `protogen:"open.v1"` - Metadata *RequestMetaData `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` - RequestConsensusDescriptor []byte `protobuf:"bytes,2,opt,name=request_consensus_descriptor,json=requestConsensusDescriptor,proto3" json:"request_consensus_descriptor,omitempty"` - RequestDefault []byte `protobuf:"bytes,3,opt,name=request_default,json=requestDefault,proto3" json:"request_default,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache +type Query struct { + state protoimpl.MessageState `protogen:"open.v1"` + RequestIDs []string `protobuf:"bytes,1,rep,name=requestIDs,proto3" json:"requestIDs,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *Request) Reset() { - *x = Request{} +func (x *Query) Reset() { + *x = Query{} mi := &file_value_consensus_types_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *Request) String() string { +func (x *Query) String() string { return protoimpl.X.MessageStringOf(x) } -func (*Request) ProtoMessage() {} +func (*Query) ProtoMessage() {} -func (x *Request) ProtoReflect() protoreflect.Message { +func (x *Query) ProtoReflect() protoreflect.Message { mi := &file_value_consensus_types_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -273,54 +225,100 @@ func (x *Request) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use Request.ProtoReflect.Descriptor instead. -func (*Request) Descriptor() ([]byte, []int) { +// Deprecated: Use Query.ProtoReflect.Descriptor instead. +func (*Query) Descriptor() ([]byte, []int) { return file_value_consensus_types_proto_rawDescGZIP(), []int{1} } -func (x *Request) GetMetadata() *RequestMetaData { +func (x *Query) GetRequestIDs() []string { + if x != nil { + return x.RequestIDs + } + return nil +} + +type RequestObservation struct { + state protoimpl.MessageState `protogen:"open.v1"` + Metadata *RequestMetaData `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` + Input *sdk.SimpleConsensusInputs `protobuf:"bytes,2,opt,name=input,proto3" json:"input,omitempty"` + ReceivedAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=received_at,json=receivedAt,proto3" json:"received_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RequestObservation) Reset() { + *x = RequestObservation{} + mi := &file_value_consensus_types_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RequestObservation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RequestObservation) ProtoMessage() {} + +func (x *RequestObservation) ProtoReflect() protoreflect.Message { + mi := &file_value_consensus_types_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 RequestObservation.ProtoReflect.Descriptor instead. +func (*RequestObservation) Descriptor() ([]byte, []int) { + return file_value_consensus_types_proto_rawDescGZIP(), []int{2} +} + +func (x *RequestObservation) GetMetadata() *RequestMetaData { if x != nil { return x.Metadata } return nil } -func (x *Request) GetRequestConsensusDescriptor() []byte { +func (x *RequestObservation) GetInput() *sdk.SimpleConsensusInputs { if x != nil { - return x.RequestConsensusDescriptor + return x.Input } return nil } -func (x *Request) GetRequestDefault() []byte { +func (x *RequestObservation) GetReceivedAt() *timestamppb.Timestamp { if x != nil { - return x.RequestDefault + return x.ReceivedAt } return nil } -type Query struct { - state protoimpl.MessageState `protogen:"open.v1"` - Requests []*Request `protobuf:"bytes,1,rep,name=requests,proto3" json:"requests,omitempty"` +type Observation struct { + state protoimpl.MessageState `protogen:"open.v1"` + Observations map[string]*RequestObservation `protobuf:"bytes,1,rep,name=observations,proto3" json:"observations,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *Query) Reset() { - *x = Query{} - mi := &file_value_consensus_types_proto_msgTypes[2] +func (x *Observation) Reset() { + *x = Observation{} + mi := &file_value_consensus_types_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *Query) String() string { +func (x *Observation) String() string { return protoimpl.X.MessageStringOf(x) } -func (*Query) ProtoMessage() {} +func (*Observation) ProtoMessage() {} -func (x *Query) ProtoReflect() protoreflect.Message { - mi := &file_value_consensus_types_proto_msgTypes[2] +func (x *Observation) ProtoReflect() protoreflect.Message { + mi := &file_value_consensus_types_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -331,42 +329,42 @@ func (x *Query) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use Query.ProtoReflect.Descriptor instead. -func (*Query) Descriptor() ([]byte, []int) { - return file_value_consensus_types_proto_rawDescGZIP(), []int{2} +// Deprecated: Use Observation.ProtoReflect.Descriptor instead. +func (*Observation) Descriptor() ([]byte, []int) { + return file_value_consensus_types_proto_rawDescGZIP(), []int{3} } -func (x *Query) GetRequests() []*Request { +func (x *Observation) GetObservations() map[string]*RequestObservation { if x != nil { - return x.Requests + return x.Observations } return nil } -type RequestObservation struct { +// this message is used when calculating the current batch size and serves no other purpose +type ObservationMapEntry struct { state protoimpl.MessageState `protogen:"open.v1"` - Metadata *RequestMetaData `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` - Observation []byte `protobuf:"bytes,2,opt,name=observation,proto3" json:"observation,omitempty"` - ReceivedAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=received_at,json=receivedAt,proto3" json:"received_at,omitempty"` + Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` + Value *RequestObservation `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *RequestObservation) Reset() { - *x = RequestObservation{} - mi := &file_value_consensus_types_proto_msgTypes[3] +func (x *ObservationMapEntry) Reset() { + *x = ObservationMapEntry{} + mi := &file_value_consensus_types_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *RequestObservation) String() string { +func (x *ObservationMapEntry) String() string { return protoimpl.X.MessageStringOf(x) } -func (*RequestObservation) ProtoMessage() {} +func (*ObservationMapEntry) ProtoMessage() {} -func (x *RequestObservation) ProtoReflect() protoreflect.Message { - mi := &file_value_consensus_types_proto_msgTypes[3] +func (x *ObservationMapEntry) ProtoReflect() protoreflect.Message { + mi := &file_value_consensus_types_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -377,54 +375,51 @@ func (x *RequestObservation) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use RequestObservation.ProtoReflect.Descriptor instead. -func (*RequestObservation) Descriptor() ([]byte, []int) { - return file_value_consensus_types_proto_rawDescGZIP(), []int{3} -} - -func (x *RequestObservation) GetMetadata() *RequestMetaData { - if x != nil { - return x.Metadata - } - return nil +// Deprecated: Use ObservationMapEntry.ProtoReflect.Descriptor instead. +func (*ObservationMapEntry) Descriptor() ([]byte, []int) { + return file_value_consensus_types_proto_rawDescGZIP(), []int{4} } -func (x *RequestObservation) GetObservation() []byte { +func (x *ObservationMapEntry) GetKey() string { if x != nil { - return x.Observation + return x.Key } - return nil + return "" } -func (x *RequestObservation) GetReceivedAt() *timestamppb.Timestamp { +func (x *ObservationMapEntry) GetValue() *RequestObservation { if x != nil { - return x.ReceivedAt + return x.Value } return nil } -type Observation struct { - state protoimpl.MessageState `protogen:"open.v1"` - Observations []*RequestObservation `protobuf:"bytes,1,rep,name=observations,proto3" json:"observations,omitempty"` +type ConsensusOutcome struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Outcome: + // + // *ConsensusOutcome_Success + // *ConsensusOutcome_Failure + Outcome isConsensusOutcome_Outcome `protobuf_oneof:"outcome"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *Observation) Reset() { - *x = Observation{} - mi := &file_value_consensus_types_proto_msgTypes[4] +func (x *ConsensusOutcome) Reset() { + *x = ConsensusOutcome{} + mi := &file_value_consensus_types_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *Observation) String() string { +func (x *ConsensusOutcome) String() string { return protoimpl.X.MessageStringOf(x) } -func (*Observation) ProtoMessage() {} +func (*ConsensusOutcome) ProtoMessage() {} -func (x *Observation) ProtoReflect() protoreflect.Message { - mi := &file_value_consensus_types_proto_msgTypes[4] +func (x *ConsensusOutcome) ProtoReflect() protoreflect.Message { + mi := &file_value_consensus_types_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -435,43 +430,76 @@ func (x *Observation) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use Observation.ProtoReflect.Descriptor instead. -func (*Observation) Descriptor() ([]byte, []int) { - return file_value_consensus_types_proto_rawDescGZIP(), []int{4} +// Deprecated: Use ConsensusOutcome.ProtoReflect.Descriptor instead. +func (*ConsensusOutcome) Descriptor() ([]byte, []int) { + return file_value_consensus_types_proto_rawDescGZIP(), []int{5} } -func (x *Observation) GetObservations() []*RequestObservation { +func (x *ConsensusOutcome) GetOutcome() isConsensusOutcome_Outcome { if x != nil { - return x.Observations + return x.Outcome + } + return nil +} + +func (x *ConsensusOutcome) GetSuccess() *ConsensusSuccessOutcome { + if x != nil { + if x, ok := x.Outcome.(*ConsensusOutcome_Success); ok { + return x.Success + } + } + return nil +} + +func (x *ConsensusOutcome) GetFailure() *ConsensusFailedOutcome { + if x != nil { + if x, ok := x.Outcome.(*ConsensusOutcome_Failure); ok { + return x.Failure + } } return nil } -type RequestOutcome struct { +type isConsensusOutcome_Outcome interface { + isConsensusOutcome_Outcome() +} + +type ConsensusOutcome_Success struct { + Success *ConsensusSuccessOutcome `protobuf:"bytes,1,opt,name=success,proto3,oneof"` +} + +type ConsensusOutcome_Failure struct { + Failure *ConsensusFailedOutcome `protobuf:"bytes,2,opt,name=failure,proto3,oneof"` +} + +func (*ConsensusOutcome_Success) isConsensusOutcome_Outcome() {} + +func (*ConsensusOutcome_Failure) isConsensusOutcome_Outcome() {} + +type ConsensusSuccessOutcome struct { state protoimpl.MessageState `protogen:"open.v1"` Metadata *RequestMetaData `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` Outcome []byte `protobuf:"bytes,2,opt,name=outcome,proto3" json:"outcome,omitempty"` Timestamp *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=timestamp,proto3" json:"timestamp,omitempty"` - Status RequestStatus `protobuf:"varint,4,opt,name=status,proto3,enum=value_consensus_types.RequestStatus" json:"status,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *RequestOutcome) Reset() { - *x = RequestOutcome{} - mi := &file_value_consensus_types_proto_msgTypes[5] +func (x *ConsensusSuccessOutcome) Reset() { + *x = ConsensusSuccessOutcome{} + mi := &file_value_consensus_types_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *RequestOutcome) String() string { +func (x *ConsensusSuccessOutcome) String() string { return protoimpl.X.MessageStringOf(x) } -func (*RequestOutcome) ProtoMessage() {} +func (*ConsensusSuccessOutcome) ProtoMessage() {} -func (x *RequestOutcome) ProtoReflect() protoreflect.Message { - mi := &file_value_consensus_types_proto_msgTypes[5] +func (x *ConsensusSuccessOutcome) ProtoReflect() protoreflect.Message { + mi := &file_value_consensus_types_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -482,63 +510,56 @@ func (x *RequestOutcome) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use RequestOutcome.ProtoReflect.Descriptor instead. -func (*RequestOutcome) Descriptor() ([]byte, []int) { - return file_value_consensus_types_proto_rawDescGZIP(), []int{5} +// Deprecated: Use ConsensusSuccessOutcome.ProtoReflect.Descriptor instead. +func (*ConsensusSuccessOutcome) Descriptor() ([]byte, []int) { + return file_value_consensus_types_proto_rawDescGZIP(), []int{6} } -func (x *RequestOutcome) GetMetadata() *RequestMetaData { +func (x *ConsensusSuccessOutcome) GetMetadata() *RequestMetaData { if x != nil { return x.Metadata } return nil } -func (x *RequestOutcome) GetOutcome() []byte { +func (x *ConsensusSuccessOutcome) GetOutcome() []byte { if x != nil { return x.Outcome } return nil } -func (x *RequestOutcome) GetTimestamp() *timestamppb.Timestamp { +func (x *ConsensusSuccessOutcome) GetTimestamp() *timestamppb.Timestamp { if x != nil { return x.Timestamp } return nil } -func (x *RequestOutcome) GetStatus() RequestStatus { - if x != nil { - return x.Status - } - return RequestStatus_REQUEST_STATUS_CONSENSUS_SUCCESS -} - -type HistoricalRequestOutcome struct { - state protoimpl.MessageState `protogen:"open.v1"` - RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` - Status RequestStatus `protobuf:"varint,2,opt,name=status,proto3,enum=value_consensus_types.RequestStatus" json:"status,omitempty"` - FirstSeenAtSeqNr uint64 `protobuf:"varint,3,opt,name=first_seen_at_seq_nr,json=firstSeenAtSeqNr,proto3" json:"first_seen_at_seq_nr,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache +type ConsensusFailedOutcome struct { + state protoimpl.MessageState `protogen:"open.v1"` + RequestID string `protobuf:"bytes,1,opt,name=requestID,proto3" json:"requestID,omitempty"` + FailureMessage string `protobuf:"bytes,2,opt,name=failure_message,json=failureMessage,proto3" json:"failure_message,omitempty"` + KeyBundleId string `protobuf:"bytes,3,opt,name=keyBundleId,proto3" json:"keyBundleId,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *HistoricalRequestOutcome) Reset() { - *x = HistoricalRequestOutcome{} - mi := &file_value_consensus_types_proto_msgTypes[6] +func (x *ConsensusFailedOutcome) Reset() { + *x = ConsensusFailedOutcome{} + mi := &file_value_consensus_types_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *HistoricalRequestOutcome) String() string { +func (x *ConsensusFailedOutcome) String() string { return protoimpl.X.MessageStringOf(x) } -func (*HistoricalRequestOutcome) ProtoMessage() {} +func (*ConsensusFailedOutcome) ProtoMessage() {} -func (x *HistoricalRequestOutcome) ProtoReflect() protoreflect.Message { - mi := &file_value_consensus_types_proto_msgTypes[6] +func (x *ConsensusFailedOutcome) ProtoReflect() protoreflect.Message { + mi := &file_value_consensus_types_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -549,46 +570,44 @@ func (x *HistoricalRequestOutcome) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use HistoricalRequestOutcome.ProtoReflect.Descriptor instead. -func (*HistoricalRequestOutcome) Descriptor() ([]byte, []int) { - return file_value_consensus_types_proto_rawDescGZIP(), []int{6} +// Deprecated: Use ConsensusFailedOutcome.ProtoReflect.Descriptor instead. +func (*ConsensusFailedOutcome) Descriptor() ([]byte, []int) { + return file_value_consensus_types_proto_rawDescGZIP(), []int{7} } -func (x *HistoricalRequestOutcome) GetRequestId() string { +func (x *ConsensusFailedOutcome) GetRequestID() string { if x != nil { - return x.RequestId + return x.RequestID } return "" } -func (x *HistoricalRequestOutcome) GetStatus() RequestStatus { +func (x *ConsensusFailedOutcome) GetFailureMessage() string { if x != nil { - return x.Status + return x.FailureMessage } - return RequestStatus_REQUEST_STATUS_CONSENSUS_SUCCESS + return "" } -func (x *HistoricalRequestOutcome) GetFirstSeenAtSeqNr() uint64 { +func (x *ConsensusFailedOutcome) GetKeyBundleId() string { if x != nil { - return x.FirstSeenAtSeqNr + return x.KeyBundleId } - return 0 + return "" } type Outcome struct { state protoimpl.MessageState `protogen:"open.v1"` - Outcomes []*RequestOutcome `protobuf:"bytes,1,rep,name=outcomes,proto3" json:"outcomes,omitempty"` - // A record of recent historical request outcomes, expired based on the sequence number of the consensus round in which they were first seen. - // It does not include the actual outcome data to save space in the plugin outcome. (Note, this prevents it from being used by nodes to recover from - // missed outcomes, but that is not a use case we currently support and there are different options to address this use case, i.e. a decentralised store) - HistoricalOutcomes []*HistoricalRequestOutcome `protobuf:"bytes,2,rep,name=historical_outcomes,json=historicalOutcomes,proto3" json:"historical_outcomes,omitempty"` + Outcomes []*ConsensusOutcome `protobuf:"bytes,1,rep,name=outcomes,proto3" json:"outcomes,omitempty"` + // A record of the seq nr when the outcome was calculated for recent historical outcomes + HistoricalOutcomes map[string]uint64 `protobuf:"bytes,2,rep,name=historical_outcomes,json=historicalOutcomes,proto3" json:"historical_outcomes,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"varint,2,opt,name=value"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Outcome) Reset() { *x = Outcome{} - mi := &file_value_consensus_types_proto_msgTypes[7] + mi := &file_value_consensus_types_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -600,7 +619,7 @@ func (x *Outcome) String() string { func (*Outcome) ProtoMessage() {} func (x *Outcome) ProtoReflect() protoreflect.Message { - mi := &file_value_consensus_types_proto_msgTypes[7] + mi := &file_value_consensus_types_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -613,28 +632,81 @@ func (x *Outcome) ProtoReflect() protoreflect.Message { // Deprecated: Use Outcome.ProtoReflect.Descriptor instead. func (*Outcome) Descriptor() ([]byte, []int) { - return file_value_consensus_types_proto_rawDescGZIP(), []int{7} + return file_value_consensus_types_proto_rawDescGZIP(), []int{8} } -func (x *Outcome) GetOutcomes() []*RequestOutcome { +func (x *Outcome) GetOutcomes() []*ConsensusOutcome { if x != nil { return x.Outcomes } return nil } -func (x *Outcome) GetHistoricalOutcomes() []*HistoricalRequestOutcome { +func (x *Outcome) GetHistoricalOutcomes() map[string]uint64 { if x != nil { return x.HistoricalOutcomes } return nil } +// this message is used when calculating the current batch size and serves no other purpose +type HistoricalOutcomeMapEntry struct { + state protoimpl.MessageState `protogen:"open.v1"` + Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` + Value uint64 `protobuf:"varint,2,opt,name=value,proto3" json:"value,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HistoricalOutcomeMapEntry) Reset() { + *x = HistoricalOutcomeMapEntry{} + mi := &file_value_consensus_types_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HistoricalOutcomeMapEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HistoricalOutcomeMapEntry) ProtoMessage() {} + +func (x *HistoricalOutcomeMapEntry) ProtoReflect() protoreflect.Message { + mi := &file_value_consensus_types_proto_msgTypes[9] + 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 HistoricalOutcomeMapEntry.ProtoReflect.Descriptor instead. +func (*HistoricalOutcomeMapEntry) Descriptor() ([]byte, []int) { + return file_value_consensus_types_proto_rawDescGZIP(), []int{9} +} + +func (x *HistoricalOutcomeMapEntry) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +func (x *HistoricalOutcomeMapEntry) GetValue() uint64 { + if x != nil { + return x.Value + } + return 0 +} + var File_value_consensus_types_proto protoreflect.FileDescriptor const file_value_consensus_types_proto_rawDesc = "" + "\n" + - "\x1bvalue_consensus_types.proto\x12\x15value_consensus_types\x1a\x1fgoogle/protobuf/timestamp.proto\"\xf6\x03\n" + + "\x1bvalue_consensus_types.proto\x12\x15value_consensus_types\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x15sdk/v1alpha/sdk.proto\"\xf6\x03\n" + "\x0fRequestMetaData\x12\x1d\n" + "\n" + "request_id\x18\x01 \x01(\tR\trequestId\x122\n" + @@ -649,39 +721,48 @@ const file_value_consensus_types_proto_rawDesc = "" + "\treport_id\x18\t \x01(\tR\breportId\x12 \n" + "\vkeyBundleId\x18\n" + " \x01(\tR\vkeyBundleId\x12E\n" + - "\frequest_type\x18\v \x01(\x0e2\".value_consensus_types.RequestTypeR\vrequestType\"\xb8\x01\n" + - "\aRequest\x12B\n" + - "\bmetadata\x18\x01 \x01(\v2&.value_consensus_types.RequestMetaDataR\bmetadata\x12@\n" + - "\x1crequest_consensus_descriptor\x18\x02 \x01(\fR\x1arequestConsensusDescriptor\x12'\n" + - "\x0frequest_default\x18\x03 \x01(\fR\x0erequestDefault\"C\n" + - "\x05Query\x12:\n" + - "\brequests\x18\x01 \x03(\v2\x1e.value_consensus_types.RequestR\brequests\"\xb7\x01\n" + + "\frequest_type\x18\v \x01(\x0e2\".value_consensus_types.RequestTypeR\vrequestType\"'\n" + + "\x05Query\x12\x1e\n" + + "\n" + + "requestIDs\x18\x01 \x03(\tR\n" + + "requestIDs\"\xcf\x01\n" + "\x12RequestObservation\x12B\n" + - "\bmetadata\x18\x01 \x01(\v2&.value_consensus_types.RequestMetaDataR\bmetadata\x12 \n" + - "\vobservation\x18\x02 \x01(\fR\vobservation\x12;\n" + + "\bmetadata\x18\x01 \x01(\v2&.value_consensus_types.RequestMetaDataR\bmetadata\x128\n" + + "\x05input\x18\x02 \x01(\v2\".sdk.v1alpha.SimpleConsensusInputsR\x05input\x12;\n" + "\vreceived_at\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\n" + - "receivedAt\"\\\n" + - "\vObservation\x12M\n" + - "\fobservations\x18\x01 \x03(\v2).value_consensus_types.RequestObservationR\fobservations\"\xe6\x01\n" + - "\x0eRequestOutcome\x12B\n" + + "receivedAt\"\xd3\x01\n" + + "\vObservation\x12X\n" + + "\fobservations\x18\x01 \x03(\v24.value_consensus_types.Observation.ObservationsEntryR\fobservations\x1aj\n" + + "\x11ObservationsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12?\n" + + "\x05value\x18\x02 \x01(\v2).value_consensus_types.RequestObservationR\x05value:\x028\x01\"h\n" + + "\x13ObservationMapEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12?\n" + + "\x05value\x18\x02 \x01(\v2).value_consensus_types.RequestObservationR\x05value\"\xb4\x01\n" + + "\x10ConsensusOutcome\x12J\n" + + "\asuccess\x18\x01 \x01(\v2..value_consensus_types.ConsensusSuccessOutcomeH\x00R\asuccess\x12I\n" + + "\afailure\x18\x02 \x01(\v2-.value_consensus_types.ConsensusFailedOutcomeH\x00R\afailureB\t\n" + + "\aoutcome\"\xb1\x01\n" + + "\x17ConsensusSuccessOutcome\x12B\n" + "\bmetadata\x18\x01 \x01(\v2&.value_consensus_types.RequestMetaDataR\bmetadata\x12\x18\n" + "\aoutcome\x18\x02 \x01(\fR\aoutcome\x128\n" + - "\ttimestamp\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\ttimestamp\x12<\n" + - "\x06status\x18\x04 \x01(\x0e2$.value_consensus_types.RequestStatusR\x06status\"\xa7\x01\n" + - "\x18HistoricalRequestOutcome\x12\x1d\n" + - "\n" + - "request_id\x18\x01 \x01(\tR\trequestId\x12<\n" + - "\x06status\x18\x02 \x01(\x0e2$.value_consensus_types.RequestStatusR\x06status\x12.\n" + - "\x14first_seen_at_seq_nr\x18\x03 \x01(\x04R\x10firstSeenAtSeqNr\"\xae\x01\n" + - "\aOutcome\x12A\n" + - "\boutcomes\x18\x01 \x03(\v2%.value_consensus_types.RequestOutcomeR\boutcomes\x12`\n" + - "\x13historical_outcomes\x18\x02 \x03(\v2/.value_consensus_types.HistoricalRequestOutcomeR\x12historicalOutcomes*9\n" + + "\ttimestamp\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\ttimestamp\"\x81\x01\n" + + "\x16ConsensusFailedOutcome\x12\x1c\n" + + "\trequestID\x18\x01 \x01(\tR\trequestID\x12'\n" + + "\x0ffailure_message\x18\x02 \x01(\tR\x0efailureMessage\x12 \n" + + "\vkeyBundleId\x18\x03 \x01(\tR\vkeyBundleId\"\xfe\x01\n" + + "\aOutcome\x12C\n" + + "\boutcomes\x18\x01 \x03(\v2'.value_consensus_types.ConsensusOutcomeR\boutcomes\x12g\n" + + "\x13historical_outcomes\x18\x02 \x03(\v26.value_consensus_types.Outcome.HistoricalOutcomesEntryR\x12historicalOutcomes\x1aE\n" + + "\x17HistoricalOutcomesEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\x04R\x05value:\x028\x01\"C\n" + + "\x19HistoricalOutcomeMapEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\x04R\x05value*9\n" + "\vRequestType\x12\x13\n" + "\x0fVALUE_CONSENSUS\x10\x00\x12\x15\n" + - "\x11REPORT_GENERATION\x10\x01*[\n" + - "\rRequestStatus\x12$\n" + - " REQUEST_STATUS_CONSENSUS_SUCCESS\x10\x00\x12$\n" + - " REQUEST_STATUS_CONSENSUS_PENDING\x10\x01B\x18Z\x16consensus/oracle/typesb\x06proto3" + "\x11REPORT_GENERATION\x10\x01B\x18Z\x16consensus/oracle/typesb\x06proto3" var ( file_value_consensus_types_proto_rawDescOnce sync.Once @@ -695,39 +776,44 @@ func file_value_consensus_types_proto_rawDescGZIP() []byte { return file_value_consensus_types_proto_rawDescData } -var file_value_consensus_types_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_value_consensus_types_proto_msgTypes = make([]protoimpl.MessageInfo, 8) +var file_value_consensus_types_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_value_consensus_types_proto_msgTypes = make([]protoimpl.MessageInfo, 12) var file_value_consensus_types_proto_goTypes = []any{ - (RequestType)(0), // 0: value_consensus_types.RequestType - (RequestStatus)(0), // 1: value_consensus_types.RequestStatus - (*RequestMetaData)(nil), // 2: value_consensus_types.RequestMetaData - (*Request)(nil), // 3: value_consensus_types.Request - (*Query)(nil), // 4: value_consensus_types.Query - (*RequestObservation)(nil), // 5: value_consensus_types.RequestObservation - (*Observation)(nil), // 6: value_consensus_types.Observation - (*RequestOutcome)(nil), // 7: value_consensus_types.RequestOutcome - (*HistoricalRequestOutcome)(nil), // 8: value_consensus_types.HistoricalRequestOutcome - (*Outcome)(nil), // 9: value_consensus_types.Outcome - (*timestamppb.Timestamp)(nil), // 10: google.protobuf.Timestamp + (RequestType)(0), // 0: value_consensus_types.RequestType + (*RequestMetaData)(nil), // 1: value_consensus_types.RequestMetaData + (*Query)(nil), // 2: value_consensus_types.Query + (*RequestObservation)(nil), // 3: value_consensus_types.RequestObservation + (*Observation)(nil), // 4: value_consensus_types.Observation + (*ObservationMapEntry)(nil), // 5: value_consensus_types.ObservationMapEntry + (*ConsensusOutcome)(nil), // 6: value_consensus_types.ConsensusOutcome + (*ConsensusSuccessOutcome)(nil), // 7: value_consensus_types.ConsensusSuccessOutcome + (*ConsensusFailedOutcome)(nil), // 8: value_consensus_types.ConsensusFailedOutcome + (*Outcome)(nil), // 9: value_consensus_types.Outcome + (*HistoricalOutcomeMapEntry)(nil), // 10: value_consensus_types.HistoricalOutcomeMapEntry + nil, // 11: value_consensus_types.Observation.ObservationsEntry + nil, // 12: value_consensus_types.Outcome.HistoricalOutcomesEntry + (*sdk.SimpleConsensusInputs)(nil), // 13: sdk.v1alpha.SimpleConsensusInputs + (*timestamppb.Timestamp)(nil), // 14: google.protobuf.Timestamp } var file_value_consensus_types_proto_depIdxs = []int32{ 0, // 0: value_consensus_types.RequestMetaData.request_type:type_name -> value_consensus_types.RequestType - 2, // 1: value_consensus_types.Request.metadata:type_name -> value_consensus_types.RequestMetaData - 3, // 2: value_consensus_types.Query.requests:type_name -> value_consensus_types.Request - 2, // 3: value_consensus_types.RequestObservation.metadata:type_name -> value_consensus_types.RequestMetaData - 10, // 4: value_consensus_types.RequestObservation.received_at:type_name -> google.protobuf.Timestamp - 5, // 5: value_consensus_types.Observation.observations:type_name -> value_consensus_types.RequestObservation - 2, // 6: value_consensus_types.RequestOutcome.metadata:type_name -> value_consensus_types.RequestMetaData - 10, // 7: value_consensus_types.RequestOutcome.timestamp:type_name -> google.protobuf.Timestamp - 1, // 8: value_consensus_types.RequestOutcome.status:type_name -> value_consensus_types.RequestStatus - 1, // 9: value_consensus_types.HistoricalRequestOutcome.status:type_name -> value_consensus_types.RequestStatus - 7, // 10: value_consensus_types.Outcome.outcomes:type_name -> value_consensus_types.RequestOutcome - 8, // 11: value_consensus_types.Outcome.historical_outcomes:type_name -> value_consensus_types.HistoricalRequestOutcome - 12, // [12:12] is the sub-list for method output_type - 12, // [12:12] is the sub-list for method input_type - 12, // [12:12] is the sub-list for extension type_name - 12, // [12:12] is the sub-list for extension extendee - 0, // [0:12] is the sub-list for field type_name + 1, // 1: value_consensus_types.RequestObservation.metadata:type_name -> value_consensus_types.RequestMetaData + 13, // 2: value_consensus_types.RequestObservation.input:type_name -> sdk.v1alpha.SimpleConsensusInputs + 14, // 3: value_consensus_types.RequestObservation.received_at:type_name -> google.protobuf.Timestamp + 11, // 4: value_consensus_types.Observation.observations:type_name -> value_consensus_types.Observation.ObservationsEntry + 3, // 5: value_consensus_types.ObservationMapEntry.value:type_name -> value_consensus_types.RequestObservation + 7, // 6: value_consensus_types.ConsensusOutcome.success:type_name -> value_consensus_types.ConsensusSuccessOutcome + 8, // 7: value_consensus_types.ConsensusOutcome.failure:type_name -> value_consensus_types.ConsensusFailedOutcome + 1, // 8: value_consensus_types.ConsensusSuccessOutcome.metadata:type_name -> value_consensus_types.RequestMetaData + 14, // 9: value_consensus_types.ConsensusSuccessOutcome.timestamp:type_name -> google.protobuf.Timestamp + 6, // 10: value_consensus_types.Outcome.outcomes:type_name -> value_consensus_types.ConsensusOutcome + 12, // 11: value_consensus_types.Outcome.historical_outcomes:type_name -> value_consensus_types.Outcome.HistoricalOutcomesEntry + 3, // 12: value_consensus_types.Observation.ObservationsEntry.value:type_name -> value_consensus_types.RequestObservation + 13, // [13:13] is the sub-list for method output_type + 13, // [13:13] is the sub-list for method input_type + 13, // [13:13] is the sub-list for extension type_name + 13, // [13:13] is the sub-list for extension extendee + 0, // [0:13] is the sub-list for field type_name } func init() { file_value_consensus_types_proto_init() } @@ -735,13 +821,17 @@ func file_value_consensus_types_proto_init() { if File_value_consensus_types_proto != nil { return } + file_value_consensus_types_proto_msgTypes[5].OneofWrappers = []any{ + (*ConsensusOutcome_Success)(nil), + (*ConsensusOutcome_Failure)(nil), + } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_value_consensus_types_proto_rawDesc), len(file_value_consensus_types_proto_rawDesc)), - NumEnums: 2, - NumMessages: 8, + NumEnums: 1, + NumMessages: 12, NumExtensions: 0, NumServices: 0, }, diff --git a/consensus/oracle/types/value_consensus_types.proto b/consensus/oracle/types/value_consensus_types.proto index 46abeb609..d704ce759 100644 --- a/consensus/oracle/types/value_consensus_types.proto +++ b/consensus/oracle/types/value_consensus_types.proto @@ -2,11 +2,10 @@ syntax = "proto3"; option go_package = "consensus/oracle/types"; - - package value_consensus_types; import "google/protobuf/timestamp.proto"; +import "sdk/v1alpha/sdk.proto"; enum RequestType { VALUE_CONSENSUS = 0; @@ -30,51 +29,54 @@ message RequestMetaData { RequestType request_type = 11; } -message Request { - RequestMetaData metadata = 1; - bytes request_consensus_descriptor = 2; - bytes request_default = 3; -} - - message Query { - repeated Request requests = 1; + repeated string requestIDs = 1; } message RequestObservation { RequestMetaData metadata = 1; - bytes observation = 2; + sdk.v1alpha.SimpleConsensusInputs input = 2; google.protobuf.Timestamp received_at = 3; } message Observation { - repeated RequestObservation observations = 1; + map observations = 1; } -// TODO as part of https://smartcontract-it.atlassian.net/browse/CAPPL-1076 add additional statuses (errored, failed etc.....): -enum RequestStatus { - REQUEST_STATUS_CONSENSUS_SUCCESS = 0; - REQUEST_STATUS_CONSENSUS_PENDING = 1; +// this message is used when calculating the current batch size and serves no other purpose +message ObservationMapEntry { + string key = 1; + RequestObservation value = 2; } -message RequestOutcome { +message ConsensusOutcome { + oneof outcome { + ConsensusSuccessOutcome success = 1; + ConsensusFailedOutcome failure = 2; + } +} + +message ConsensusSuccessOutcome { RequestMetaData metadata = 1; bytes outcome = 2; google.protobuf.Timestamp timestamp = 3; - RequestStatus status = 4; } -message HistoricalRequestOutcome { - string request_id = 1; - RequestStatus status = 2; - uint64 first_seen_at_seq_nr = 3; +message ConsensusFailedOutcome { + string requestID = 1; + string failure_message = 2; + string keyBundleId = 3; } message Outcome { - repeated RequestOutcome outcomes = 1; + repeated ConsensusOutcome outcomes = 1; + + // A record of the seq nr when the outcome was calculated for recent historical outcomes + map historical_outcomes = 2; +} - // A record of recent historical request outcomes, expired based on the sequence number of the consensus round in which they were first seen. - // It does not include the actual outcome data to save space in the plugin outcome. (Note, this prevents it from being used by nodes to recover from - // missed outcomes, but that is not a use case we currently support and there are different options to address this use case, i.e. a decentralised store) - repeated HistoricalRequestOutcome historical_outcomes = 2; +// this message is used when calculating the current batch size and serves no other purpose +message HistoricalOutcomeMapEntry { + string key = 1; + uint64 value = 2; } \ No newline at end of file From f71c3e79896972d2557e51622ed59884dd0def1f Mon Sep 17 00:00:00 2001 From: Matthew Pendrey Date: Tue, 4 Nov 2025 16:34:38 +0000 Subject: [PATCH 2/4] lint --- consensus/oracle/plugin/plugin_outcome.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/consensus/oracle/plugin/plugin_outcome.go b/consensus/oracle/plugin/plugin_outcome.go index 7982c5054..674cba372 100644 --- a/consensus/oracle/plugin/plugin_outcome.go +++ b/consensus/oracle/plugin/plugin_outcome.go @@ -156,12 +156,12 @@ func formatValuesForLogging(ctx context.Context, lggr logger.Logger, obsValues [ typedValues = append(typedValues, tv) } - valuesJson, err := json.Encode(ctx, typedValues) + valuesJSON, err := json.Encode(ctx, typedValues) if err != nil { lggr.Warnw("could not marshal observation values to json", "error", err) return "could not marshal observation values" } - return string(valuesJson) + return string(valuesJSON) } // verifyMetadataDescriptorAndDefaultMatchConsensus checks if the observation's metadata, descriptor and default match the consensus. From 15b85348136b4ffcc6b0f304d184b4d645b3c6c9 Mon Sep 17 00:00:00 2001 From: Matthew Pendrey Date: Tue, 4 Nov 2025 17:13:21 +0000 Subject: [PATCH 3/4] mod tidy --- consensus/go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/consensus/go.mod b/consensus/go.mod index 9a4ebf396..7a2e71bb9 100644 --- a/consensus/go.mod +++ b/consensus/go.mod @@ -3,6 +3,7 @@ module github.com/smartcontractkit/capabilities/consensus go 1.25.3 require ( + github.com/cloudevents/sdk-go/v2 v2.16.1 github.com/google/uuid v1.6.0 github.com/jonboulle/clockwork v0.5.0 github.com/shopspring/decimal v1.4.0 @@ -28,7 +29,6 @@ require ( github.com/cenkalti/backoff/v5 v5.0.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudevents/sdk-go/binding/format/protobuf/v2 v2.16.1 // indirect - github.com/cloudevents/sdk-go/v2 v2.16.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fatih/color v1.18.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect From 5021275851ce88cbbd1992fba92c8dc166bff2e8 Mon Sep 17 00:00:00 2001 From: Matthew Pendrey Date: Wed, 5 Nov 2025 10:31:09 +0000 Subject: [PATCH 4/4] remove type info from consensus failure message --- consensus/oracle/plugin/plugin_outcome.go | 24 ++++------------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/consensus/oracle/plugin/plugin_outcome.go b/consensus/oracle/plugin/plugin_outcome.go index 674cba372..cfe36d722 100644 --- a/consensus/oracle/plugin/plugin_outcome.go +++ b/consensus/oracle/plugin/plugin_outcome.go @@ -3,7 +3,6 @@ package plugin import ( "context" "fmt" - "reflect" "slices" "time" @@ -120,13 +119,8 @@ func (r *reportingPlugin) addRequestOutcomeToBatch(ctx context.Context, requestI return outcome.AddSuccessfulConsensusRequestOutcomeToBatch(ctx, consensusMDD.Metadata, value, timestamp) } -type valueWithType struct { - Type string `json:"type"` - Value interface{} `json:"value"` -} - func formatValuesForLogging(ctx context.Context, lggr logger.Logger, obsValues []*valuespb.Value) string { - var typedValues []*valueWithType + var unwrappedValues []any for _, protoVal := range obsValues { val, err := values.FromProto(protoVal) if err != nil { @@ -134,29 +128,19 @@ func formatValuesForLogging(ctx context.Context, lggr logger.Logger, obsValues [ continue } - var tv *valueWithType if val == nil { - tv = &valueWithType{ - Type: "nil", - Value: nil, - } + unwrappedValues = append(unwrappedValues, nil) } else { unwrappedValue, err := val.Unwrap() if err != nil { lggr.Warnw("could not unwrap observation value", "error", err) continue } - - tv = &valueWithType{ - Type: reflect.TypeOf(unwrappedValue).String(), - Value: unwrappedValue, - } + unwrappedValues = append(unwrappedValues, unwrappedValue) } - - typedValues = append(typedValues, tv) } - valuesJSON, err := json.Encode(ctx, typedValues) + valuesJSON, err := json.Encode(ctx, unwrappedValues) if err != nil { lggr.Warnw("could not marshal observation values to json", "error", err) return "could not marshal observation values"