From e3b26b9b36ab26d1d4f03210c320c80f96b7ca63 Mon Sep 17 00:00:00 2001 From: "chenghan.ying" Date: Wed, 5 Aug 2026 20:47:29 +0000 Subject: [PATCH] feat(stovepipe): add the record controller, advancing a queue's last-green bookmark on a successful build --- service/stovepipe/server/BUILD.bazel | 1 + service/stovepipe/server/main.go | 14 +- stovepipe/controller/record/BUILD.bazel | 37 +++ stovepipe/controller/record/record.go | 218 ++++++++++++++ stovepipe/controller/record/record_test.go | 327 +++++++++++++++++++++ 5 files changed, 595 insertions(+), 2 deletions(-) create mode 100644 stovepipe/controller/record/BUILD.bazel create mode 100644 stovepipe/controller/record/record.go create mode 100644 stovepipe/controller/record/record_test.go diff --git a/service/stovepipe/server/BUILD.bazel b/service/stovepipe/server/BUILD.bazel index 592f88db..7ca45141 100644 --- a/service/stovepipe/server/BUILD.bazel +++ b/service/stovepipe/server/BUILD.bazel @@ -20,6 +20,7 @@ go_library( "//stovepipe/controller/buildsignal:go_default_library", "//stovepipe/controller/dlq:go_default_library", "//stovepipe/controller/process:go_default_library", + "//stovepipe/controller/record:go_default_library", "//stovepipe/core/messagequeue:go_default_library", "//stovepipe/extension/buildrunner:go_default_library", "//stovepipe/extension/buildrunner/fake:go_default_library", diff --git a/service/stovepipe/server/main.go b/service/stovepipe/server/main.go index 89cf735c..6485ccba 100644 --- a/service/stovepipe/server/main.go +++ b/service/stovepipe/server/main.go @@ -42,6 +42,7 @@ import ( "github.com/uber/submitqueue/stovepipe/controller/buildsignal" "github.com/uber/submitqueue/stovepipe/controller/dlq" "github.com/uber/submitqueue/stovepipe/controller/process" + "github.com/uber/submitqueue/stovepipe/controller/record" stovepipemq "github.com/uber/submitqueue/stovepipe/core/messagequeue" "github.com/uber/submitqueue/stovepipe/extension/buildrunner" buildrunnerfake "github.com/uber/submitqueue/stovepipe/extension/buildrunner/fake" @@ -394,6 +395,12 @@ func registerPrimaryControllers( } count++ + recordController := record.NewController(logger, scope, store, stovepipemq.TopicKeyRecord, "stovepipe-record") + if err := c.Register(recordController); err != nil { + return count, fmt.Errorf("failed to register record controller: %w", err) + } + count++ + return count, nil } @@ -421,8 +428,8 @@ func registerDLQControllers( // publishes to the process topic and the process consumer subscribes to it; process publishes // to the build topic and the build consumer subscribes to it; build publishes to the buildsignal // topic and the buildsignal consumer subscribes to it, and also republishes to itself while -// polling. buildsignal publishes to the record topic once a build reaches a terminal status; it -// has no Subscription yet since no consumer for it exists until the record stage lands. +// polling. buildsignal publishes to the record topic once a build reaches a terminal status, +// and the record consumer subscribes to it. func newTopicRegistry(q extqueue.Queue, subscriberName string) (consumer.TopicRegistry, error) { return consumer.NewTopicRegistry([]consumer.TopicConfig{ { @@ -453,6 +460,9 @@ func newTopicRegistry(q extqueue.Queue, subscriberName string) (consumer.TopicRe Key: stovepipemq.TopicKeyRecord, Name: "record", Queue: q, + Subscription: extqueue.DefaultSubscriptionConfig( + subscriberName, "stovepipe-record", + ), }, { Key: dlq.TopicKey(stovepipemq.TopicKeyProcess), diff --git a/stovepipe/controller/record/BUILD.bazel b/stovepipe/controller/record/BUILD.bazel new file mode 100644 index 00000000..a66522e3 --- /dev/null +++ b/stovepipe/controller/record/BUILD.bazel @@ -0,0 +1,37 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = ["record.go"], + importpath = "github.com/uber/submitqueue/stovepipe/controller/record", + visibility = ["//visibility:public"], + deps = [ + "//platform/consumer:go_default_library", + "//platform/metrics:go_default_library", + "//stovepipe/core/loader:go_default_library", + "//stovepipe/core/messagequeue:go_default_library", + "//stovepipe/entity:go_default_library", + "//stovepipe/extension/storage:go_default_library", + "@com_github_uber_go_tally//:go_default_library", + "@org_uber_go_zap//:go_default_library", + ], +) + +go_test( + name = "go_default_test", + srcs = ["record_test.go"], + embed = [":go_default_library"], + deps = [ + "//platform/base/messagequeue:go_default_library", + "//platform/consumer/mock:go_default_library", + "//stovepipe/core/messagequeue:go_default_library", + "//stovepipe/entity:go_default_library", + "//stovepipe/extension/storage:go_default_library", + "//stovepipe/extension/storage/mock:go_default_library", + "@com_github_stretchr_testify//assert:go_default_library", + "@com_github_stretchr_testify//require:go_default_library", + "@com_github_uber_go_tally//:go_default_library", + "@org_uber_go_mock//gomock:go_default_library", + "@org_uber_go_zap//:go_default_library", + ], +) diff --git a/stovepipe/controller/record/record.go b/stovepipe/controller/record/record.go new file mode 100644 index 00000000..1aa43183 --- /dev/null +++ b/stovepipe/controller/record/record.go @@ -0,0 +1,218 @@ +// Copyright (c) 2025 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package record holds the record-stage queue controller. It consumes Record +// messages (a request id) published by buildsignal once a build reaches a +// terminal status, and turns that outcome into durable validation state. See +// doc/rfc/stovepipe/steps/record.md. +// +// Phase 1 records only the queue's last-green bookmark, which process reads to +// choose an incremental build baseline. Validation facts and downstream hooks +// are not implemented yet. +package record + +import ( + "context" + "errors" + "fmt" + + "github.com/uber-go/tally" + "github.com/uber/submitqueue/platform/consumer" + "github.com/uber/submitqueue/platform/metrics" + "github.com/uber/submitqueue/stovepipe/core/loader" + stovepipemq "github.com/uber/submitqueue/stovepipe/core/messagequeue" + "github.com/uber/submitqueue/stovepipe/entity" + "github.com/uber/submitqueue/stovepipe/extension/storage" + "go.uber.org/zap" +) + +// Controller consumes Record messages and advances the queue's last-green +// bookmark when the request's build succeeded. Implements consumer.Controller. +type Controller struct { + logger *zap.SugaredLogger + metricsScope tally.Scope + stores storage.Factory + topicKey consumer.TopicKey + consumerGroup string +} + +// Verify Controller implements consumer.Controller interface at compile time. +var _ consumer.Controller = (*Controller)(nil) + +// _opName is the metric operation name shared by every emit in this file. +const _opName = "record" + +// NewController creates a new record controller. +func NewController( + logger *zap.SugaredLogger, + scope tally.Scope, + stores storage.Factory, + topicKey consumer.TopicKey, + consumerGroup string, +) *Controller { + return &Controller{ + logger: logger.Named("record_controller"), + metricsScope: scope.SubScope("record_controller"), + stores: stores, + topicKey: topicKey, + consumerGroup: consumerGroup, + } +} + +// Process loads the request referenced by the delivery and, when its build +// succeeded, advances the queue's last-green bookmark. Returns nil to ack +// (success) or an error to nack (retry) / reject (DLQ). +// +// buildsignal stamps the outcome on the request before publishing here, so a +// request without a build outcome is a producer invariant violation rather +// than a state this stage waits for. +func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) error { + msg := delivery.Message() + + rec := &stovepipemq.Record{} + if err := stovepipemq.Unmarshal(msg.Payload, rec); err != nil { + metrics.NamedCounter(c.metricsScope, _opName, "deserialize_errors", 1) + // Non-retryable: a malformed message will never succeed regardless of retries. + return fmt.Errorf("failed to deserialize record: %w", err) + } + + store, err := c.stores.For(storage.Config{QueueName: rec.GetQueueName()}) + if err != nil { + metrics.NamedCounter(c.metricsScope, _opName, "storage_resolve_errors", 1) + // Non-retryable: a missing or unresolvable queue is a malformed message. + return fmt.Errorf("failed to resolve storage for queue %q: %w", rec.GetQueueName(), err) + } + + request, err := c.loadRequest(ctx, store, rec.Id) + if err != nil { + metrics.NamedCounter(c.metricsScope, _opName, "storage_errors", 1) + return err + } + + // The payload's queue must match the request's authoritative queue; a + // mismatch is a malformed message. Non-retryable — reject to the DLQ. + if rec.GetQueueName() != "" && rec.GetQueueName() != request.Queue { + metrics.NamedCounter(c.metricsScope, _opName, "queue_mismatch", 1) + return fmt.Errorf("payload queue %q does not match queue %q of request %s", rec.GetQueueName(), request.Queue, request.ID) + } + + switch request.State { + case entity.RequestStateSucceeded: + if err := c.advanceLastGreen(ctx, store, request); err != nil { + metrics.NamedCounter(c.metricsScope, _opName, "storage_errors", 1) + return err + } + return nil + + case entity.RequestStateFailed, entity.RequestStateCancelled: + // A verdict, but not a green one: nothing to record in phase 1. A + // cancelled build decided nothing about the commit at all. + metrics.NamedCounter(c.metricsScope, _opName, "not_green", 1) + return nil + + case entity.RequestStateSuperseded: + // Terminal without a build outcome. buildsignal never publishes for a + // superseded request, so this is unreachable in practice. + metrics.NamedCounter(c.metricsScope, _opName, "superseded", 1) + return nil + + default: + // Non-retryable: buildsignal publishes only after committing the + // outcome, so a non-terminal request here is a broken invariant that + // retrying cannot fix. + metrics.NamedCounter(c.metricsScope, _opName, "invariant_errors", 1) + return fmt.Errorf("request %s reached record in non-terminal state %q", request.ID, request.State) + } +} + +// advanceLastGreen points the queue's bookmark at request, retrying on version +// conflicts. The bookmark only moves forward: a candidate whose id is not newer +// than the stored one is skipped without a write, which also makes a redelivery +// of the same request a no-op. +// +// Greenness comes from request.State rather than a persisted validation fact, +// which is what record.md specifies. The two agree — a fact's degree is derived +// from the same immutable state — and this reads the fact once the fact store +// lands. +func (c *Controller) advanceLastGreen(ctx context.Context, store storage.Storage, request entity.Request) error { + queueStore := store.GetQueueStore() + + for { + queueRow, err := queueStore.Get(ctx, request.Queue) + if err != nil { + return fmt.Errorf("failed to load queue %s to advance last green: %w", request.Queue, err) + } + + newer, err := isNewerRequest(request.Queue, request.ID, queueRow.LastGreenRequestID) + if err != nil { + // Non-retryable: re-parsing the same ids cannot start succeeding. + return err + } + if !newer { + return nil + } + + updated := queueRow + updated.LastGreenURI = request.URI + updated.LastGreenRequestID = request.ID + newVersion := queueRow.Version + 1 + if err := queueStore.Update(ctx, updated, queueRow.Version, newVersion); err != nil { + if errors.Is(err, storage.ErrVersionMismatch) { + continue + } + return fmt.Errorf("failed to advance last green for queue %s: %w", request.Queue, err) + } + + metrics.NamedCounter(c.metricsScope, _opName, "last_green_advanced", 1) + c.logger.Infow("advanced last green bookmark", + "queue", request.Queue, + "request_id", request.ID, + "last_green_uri", request.URI, + ) + return nil + } +} + +// isNewerRequest reports whether candidate was ingested after current. An empty +// current means the bookmark has never been set, so any candidate is newer. +func isNewerRequest(queue, candidate, current string) (bool, error) { + if current == "" { + return true, nil + } + cmp, err := entity.CompareRequestID(queue, candidate, current) + if err != nil { + return false, fmt.Errorf("failed to compare request ids for queue %s: %w", queue, err) + } + return cmp > 0, nil +} + +// loadRequest loads the request by id. +func (c *Controller) loadRequest(ctx context.Context, store storage.Storage, id string) (entity.Request, error) { + return loader.ByID(ctx, id, store.GetRequestStore().Get, "request") +} + +// Name returns the controller name for logging and metrics. +func (c *Controller) Name() string { + return "record" +} + +// TopicKey returns the topic key this controller subscribes to. +func (c *Controller) TopicKey() consumer.TopicKey { + return c.topicKey +} + +// ConsumerGroup returns the consumer group for offset tracking. +func (c *Controller) ConsumerGroup() string { + return c.consumerGroup +} diff --git a/stovepipe/controller/record/record_test.go b/stovepipe/controller/record/record_test.go new file mode 100644 index 00000000..34def05e --- /dev/null +++ b/stovepipe/controller/record/record_test.go @@ -0,0 +1,327 @@ +// Copyright (c) 2025 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package record + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/uber-go/tally" + entityqueue "github.com/uber/submitqueue/platform/base/messagequeue" + consumermock "github.com/uber/submitqueue/platform/consumer/mock" + stovepipemq "github.com/uber/submitqueue/stovepipe/core/messagequeue" + "github.com/uber/submitqueue/stovepipe/entity" + "github.com/uber/submitqueue/stovepipe/extension/storage" + storagemock "github.com/uber/submitqueue/stovepipe/extension/storage/mock" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +const ( + testQueue = "monorepo/main" + testID = "request/monorepo/main/7" + testURI = "git://remote/monorepo/main/head-sha" +) + +// recordMocks bundles the mocks a record controller test case wires +// expectations on. +type recordMocks struct { + reqStore *storagemock.MockRequestStore + queueStore *storagemock.MockQueueStore +} + +// staticStorageFactory resolves every queue to one fixed store aggregate. +type staticStorageFactory struct{ store storage.Storage } + +// For returns the fixed store aggregate for any queue. +func (f staticStorageFactory) For(storage.Config) (storage.Storage, error) { return f.store, nil } + +func newController(t *testing.T, ctrl *gomock.Controller) (*Controller, recordMocks) { + t.Helper() + + m := recordMocks{ + reqStore: storagemock.NewMockRequestStore(ctrl), + queueStore: storagemock.NewMockQueueStore(ctrl), + } + + store := storagemock.NewMockStorage(ctrl) + store.EXPECT().GetRequestStore().Return(m.reqStore).AnyTimes() + store.EXPECT().GetQueueStore().Return(m.queueStore).AnyTimes() + + c := NewController( + zap.NewNop().Sugar(), + tally.NewTestScope("test", nil), + staticStorageFactory{store: store}, + stovepipemq.TopicKeyRecord, + "stovepipe-record", + ) + return c, m +} + +func delivery(t *testing.T, ctrl *gomock.Controller, payload []byte) *consumermock.MockDelivery { + t.Helper() + d := consumermock.NewMockDelivery(ctrl) + d.EXPECT().Message().Return(entityqueue.NewMessage(testID, payload, testID, nil)).AnyTimes() + d.EXPECT().Attempt().Return(1).AnyTimes() + return d +} + +func recordPayload(t *testing.T, id string) []byte { + t.Helper() + b, err := stovepipemq.Marshal(&stovepipemq.Record{Id: id, QueueName: testQueue}) + require.NoError(t, err) + return b +} + +// requestWithState returns a terminal-stage Request in the given state. +func requestWithState(state entity.RequestState) entity.Request { + return entity.Request{ + ID: testID, + Queue: testQueue, + URI: testURI, + State: state, + Version: 2, + } +} + +// queueRow returns the testQueue's row holding the given bookmark. +func queueRow(lastGreenURI, lastGreenRequestID string, version int32) entity.Queue { + return entity.Queue{ + Name: testQueue, + LastGreenURI: lastGreenURI, + LastGreenRequestID: lastGreenRequestID, + Version: version, + } +} + +func TestProcess_AdvancesBookmarkOnSuccess(t *testing.T) { + tests := []struct { + name string + stored entity.Queue + wantURI string + }{ + { + name: "bookmark unset", + stored: queueRow("", "", 1), + wantURI: testURI, + }, + { + name: "stored bookmark is older", + stored: queueRow("git://remote/monorepo/main/old", "request/monorepo/main/3", 4), + wantURI: testURI, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + c, m := newController(t, ctrl) + + m.reqStore.EXPECT().Get(gomock.Any(), testID). + Return(requestWithState(entity.RequestStateSucceeded), nil) + m.queueStore.EXPECT().Get(gomock.Any(), testQueue).Return(tt.stored, nil) + + var written entity.Queue + m.queueStore.EXPECT(). + Update(gomock.Any(), gomock.Any(), tt.stored.Version, tt.stored.Version+1). + DoAndReturn(func(_ context.Context, q entity.Queue, _, _ int32) error { + written = q + return nil + }) + + require.NoError(t, c.Process(context.Background(), delivery(t, ctrl, recordPayload(t, testID)))) + assert.Equal(t, tt.wantURI, written.LastGreenURI) + assert.Equal(t, testID, written.LastGreenRequestID) + }) + } +} + +func TestProcess_SkipsBookmarkWhenNotNewer(t *testing.T) { + tests := []struct { + name string + stored entity.Queue + }{ + { + name: "same request redelivered", + stored: queueRow(testURI, testID, 3), + }, + { + name: "stored bookmark is newer", + stored: queueRow("git://remote/monorepo/main/newer", "request/monorepo/main/9", 5), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + c, m := newController(t, ctrl) + + m.reqStore.EXPECT().Get(gomock.Any(), testID). + Return(requestWithState(entity.RequestStateSucceeded), nil) + m.queueStore.EXPECT().Get(gomock.Any(), testQueue).Return(tt.stored, nil) + // No Update: the bookmark only moves forward. + + require.NoError(t, c.Process(context.Background(), delivery(t, ctrl, recordPayload(t, testID)))) + }) + } +} + +func TestProcess_TerminalWithoutGreenDoesNotTouchQueue(t *testing.T) { + tests := []struct { + name string + state entity.RequestState + }{ + {name: "failed", state: entity.RequestStateFailed}, + {name: "cancelled", state: entity.RequestStateCancelled}, + {name: "superseded", state: entity.RequestStateSuperseded}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + c, m := newController(t, ctrl) + + m.reqStore.EXPECT().Get(gomock.Any(), testID).Return(requestWithState(tt.state), nil) + // No queue reads or writes at all. + + require.NoError(t, c.Process(context.Background(), delivery(t, ctrl, recordPayload(t, testID)))) + }) + } +} + +func TestProcess_NonTerminalRequestFails(t *testing.T) { + tests := []struct { + name string + state entity.RequestState + }{ + {name: "accepted", state: entity.RequestStateAccepted}, + {name: "processing", state: entity.RequestStateProcessing}, + {name: "unknown", state: entity.RequestStateUnknown}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + c, m := newController(t, ctrl) + + m.reqStore.EXPECT().Get(gomock.Any(), testID).Return(requestWithState(tt.state), nil) + + require.Error(t, c.Process(context.Background(), delivery(t, ctrl, recordPayload(t, testID)))) + }) + } +} + +func TestProcess_RetriesBookmarkOnVersionMismatch(t *testing.T) { + ctrl := gomock.NewController(t) + c, m := newController(t, ctrl) + + stale := queueRow("", "", 1) + fresh := queueRow("", "", 2) + + m.reqStore.EXPECT().Get(gomock.Any(), testID). + Return(requestWithState(entity.RequestStateSucceeded), nil) + + gomock.InOrder( + m.queueStore.EXPECT().Get(gomock.Any(), testQueue).Return(stale, nil), + m.queueStore.EXPECT().Update(gomock.Any(), gomock.Any(), stale.Version, stale.Version+1). + Return(storage.ErrVersionMismatch), + m.queueStore.EXPECT().Get(gomock.Any(), testQueue).Return(fresh, nil), + m.queueStore.EXPECT().Update(gomock.Any(), gomock.Any(), fresh.Version, fresh.Version+1). + Return(nil), + ) + + require.NoError(t, c.Process(context.Background(), delivery(t, ctrl, recordPayload(t, testID)))) +} + +func TestProcess_MalformedRequestIDFails(t *testing.T) { + ctrl := gomock.NewController(t) + c, m := newController(t, ctrl) + + request := requestWithState(entity.RequestStateSucceeded) + m.reqStore.EXPECT().Get(gomock.Any(), testID).Return(request, nil) + m.queueStore.EXPECT().Get(gomock.Any(), testQueue). + Return(queueRow("git://remote/monorepo/main/old", "not-a-request-id", 1), nil) + + require.Error(t, c.Process(context.Background(), delivery(t, ctrl, recordPayload(t, testID)))) +} + +func TestProcess_StorageErrorsPropagate(t *testing.T) { + tests := []struct { + name string + setup func(m recordMocks) + }{ + { + name: "request load fails", + setup: func(m recordMocks) { + m.reqStore.EXPECT().Get(gomock.Any(), testID). + Return(entity.Request{}, errors.New("boom")) + }, + }, + { + name: "queue load fails", + setup: func(m recordMocks) { + m.reqStore.EXPECT().Get(gomock.Any(), testID). + Return(requestWithState(entity.RequestStateSucceeded), nil) + m.queueStore.EXPECT().Get(gomock.Any(), testQueue). + Return(entity.Queue{}, errors.New("boom")) + }, + }, + { + name: "queue update fails", + setup: func(m recordMocks) { + m.reqStore.EXPECT().Get(gomock.Any(), testID). + Return(requestWithState(entity.RequestStateSucceeded), nil) + m.queueStore.EXPECT().Get(gomock.Any(), testQueue).Return(queueRow("", "", 1), nil) + m.queueStore.EXPECT().Update(gomock.Any(), gomock.Any(), int32(1), int32(2)). + Return(errors.New("boom")) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + c, m := newController(t, ctrl) + tt.setup(m) + + require.Error(t, c.Process(context.Background(), delivery(t, ctrl, recordPayload(t, testID)))) + }) + } +} + +func TestProcess_MalformedPayloadFails(t *testing.T) { + ctrl := gomock.NewController(t) + c, _ := newController(t, ctrl) + + require.Error(t, c.Process(context.Background(), delivery(t, ctrl, []byte("not-protojson")))) +} + +func TestProcess_QueueMismatchFails(t *testing.T) { + ctrl := gomock.NewController(t) + c, m := newController(t, ctrl) + + // The request's own queue is authoritative; a payload disagreeing with it + // is malformed, so the bookmark must not be touched. + m.reqStore.EXPECT().Get(gomock.Any(), testID). + Return(requestWithState(entity.RequestStateSucceeded), nil) + + payload, err := stovepipemq.Marshal(&stovepipemq.Record{Id: testID, QueueName: "monorepo/other"}) + require.NoError(t, err) + + require.Error(t, c.Process(context.Background(), delivery(t, ctrl, payload))) +}