From 8cc5563553ba57b451e6161041c80029fcac9f00 Mon Sep 17 00:00:00 2001 From: mnoah1 Date: Tue, 27 Oct 2026 21:05:23 +0000 Subject: [PATCH] feat(stovepipe): wire queue enablement control into ingest RPC Summary: Wire the existing queue enablement state and history into Ingest so callers can record the commit boundaries consumed by GetQueueStatus. Accept ENABLED or DISABLED and return applied or observed enablement status with the request ID. Omitted/UNKNOWN preserves state; repeated settings preserve the boundary and version. Changed settings use the same captured commit URI as the validation request. Enablement remains independent of pause and does not block ingestion or processing. Recording is prospective and uses the existing queue state, history, and summary stores. Test Plan: Bazel tests for Stovepipe, server handlers/mappers, MySQL integration, end-to-end behavior, and generated web API bindings. Repository lint, mock generation, dependency, and build-file checks. --- Makefile | 1 + api/stovepipe/proto/stovepipe.proto | 5 + api/stovepipe/protopb/stovepipe.pb.go | 99 ++++-- api/stovepipe/protopb/stovepipe.pb.yarpc.go | 171 +++++----- doc/rfc/stovepipe/queue-state.md | 8 +- doc/rfc/stovepipe/workflow.md | 4 +- service/stovepipe/server/handler/BUILD.bazel | 1 + service/stovepipe/server/handler/server.go | 5 + service/stovepipe/server/main.go | 5 +- service/stovepipe/server/mapper/ingest.go | 15 +- .../stovepipe/server/mapper/ingest_test.go | 27 +- stovepipe/controller/BUILD.bazel | 3 + stovepipe/controller/ingest.go | 67 ++-- stovepipe/controller/ingest_test.go | 317 ++++++++++++++++-- stovepipe/core/queuestate/mock/BUILD.bazel | 14 + .../core/queuestate/mock/queuestate_mock.go | 88 +++++ stovepipe/core/queuestate/queuestate.go | 24 +- stovepipe/core/queuestate/queuestate_test.go | 44 ++- stovepipe/entity/ingest.go | 7 +- test/e2e/stovepipe/queue_status_test.go | 34 +- .../mysql/queue_state_transitions_test.go | 4 +- test/integration/stovepipe/suite_test.go | 2 +- .../gen/api/stovepipe/proto/stovepipe_pb.ts | 17 +- 23 files changed, 736 insertions(+), 226 deletions(-) create mode 100644 stovepipe/core/queuestate/mock/BUILD.bazel create mode 100644 stovepipe/core/queuestate/mock/queuestate_mock.go diff --git a/Makefile b/Makefile index de514a8fd..f8e6c187f 100644 --- a/Makefile +++ b/Makefile @@ -626,6 +626,7 @@ GO_GENERATE_PACKAGES := \ ./platform/extension/messagequeue/... \ ./runway/extension/merger/... \ ./stovepipe/controller/... \ + ./stovepipe/core/queuestate/... \ ./stovepipe/core/requestlog/... \ ./stovepipe/extension/buildissue/... \ ./stovepipe/extension/buildrunner/... \ diff --git a/api/stovepipe/proto/stovepipe.proto b/api/stovepipe/proto/stovepipe.proto index 8de6c2c90..772b5b954 100644 --- a/api/stovepipe/proto/stovepipe.proto +++ b/api/stovepipe/proto/stovepipe.proto @@ -45,12 +45,17 @@ message IngestRequest { // Logical queue name to validate (e.g. "monorepo/main"). Together with the minted // request ID it identifies the request and is the stable handle for the repo+ref. string queue = 1; + // Optional ENABLED or DISABLED coverage decision, independent of pause or processing. + // Omission or UNKNOWN preserves durable enablement. + QueueEnablement enablement = 2; } // IngestResponse is the response for the Ingest method. message IngestResponse { // Canonical decimal request ID, unique within the selected queue. string id = 1; + // Applied decision, or observed state when no decision was supplied. + QueueEnablementStatus enablement = 2; } // GetRequestHistoryByIDRequest selects one request history by the ID returned from Ingest. diff --git a/api/stovepipe/protopb/stovepipe.pb.go b/api/stovepipe/protopb/stovepipe.pb.go index ff308230d..2c7f6161a 100644 --- a/api/stovepipe/protopb/stovepipe.pb.go +++ b/api/stovepipe/protopb/stovepipe.pb.go @@ -212,7 +212,10 @@ type IngestRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // Logical queue name to validate (e.g. "monorepo/main"). Together with the minted // request ID it identifies the request and is the stable handle for the repo+ref. - Queue string `protobuf:"bytes,1,opt,name=queue,proto3" json:"queue,omitempty"` + Queue string `protobuf:"bytes,1,opt,name=queue,proto3" json:"queue,omitempty"` + // Optional ENABLED or DISABLED coverage decision, independent of pause or processing. + // Omission or UNKNOWN preserves durable enablement. + Enablement QueueEnablement `protobuf:"varint,2,opt,name=enablement,proto3,enum=uber.submitqueue.stovepipe.QueueEnablement" json:"enablement,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -254,11 +257,20 @@ func (x *IngestRequest) GetQueue() string { return "" } +func (x *IngestRequest) GetEnablement() QueueEnablement { + if x != nil { + return x.Enablement + } + return QueueEnablement_QUEUE_ENABLEMENT_UNKNOWN +} + // IngestResponse is the response for the Ingest method. type IngestResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // Canonical decimal request ID, unique within the selected queue. - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // Applied decision, or observed state when no decision was supplied. + Enablement *QueueEnablementStatus `protobuf:"bytes,2,opt,name=enablement,proto3" json:"enablement,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -300,6 +312,13 @@ func (x *IngestResponse) GetId() string { return "" } +func (x *IngestResponse) GetEnablement() *QueueEnablementStatus { + if x != nil { + return x.Enablement + } + return nil +} + // GetRequestHistoryByIDRequest selects one request history by the ID returned from Ingest. type GetRequestHistoryByIDRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1716,11 +1735,17 @@ const file_stovepipe_proto_rawDesc = "" + "\amessage\x18\x01 \x01(\tR\amessage\x12!\n" + "\fservice_name\x18\x02 \x01(\tR\vserviceName\x12\x1c\n" + "\ttimestamp\x18\x03 \x01(\x03R\ttimestamp\x12\x1a\n" + - "\bhostname\x18\x04 \x01(\tR\bhostname\"%\n" + + "\bhostname\x18\x04 \x01(\tR\bhostname\"r\n" + "\rIngestRequest\x12\x14\n" + - "\x05queue\x18\x01 \x01(\tR\x05queue\" \n" + + "\x05queue\x18\x01 \x01(\tR\x05queue\x12K\n" + + "\n" + + "enablement\x18\x02 \x01(\x0e2+.uber.submitqueue.stovepipe.QueueEnablementR\n" + + "enablement\"s\n" + "\x0eIngestResponse\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\"S\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12Q\n" + + "\n" + + "enablement\x18\x02 \x01(\v21.uber.submitqueue.stovepipe.QueueEnablementStatusR\n" + + "enablement\"S\n" + "\x1cGetRequestHistoryByIDRequest\x12\x14\n" + "\x05queue\x18\x01 \x01(\tR\x05queue\x12\x1d\n" + "\n" + @@ -1887,37 +1912,39 @@ var file_stovepipe_proto_goTypes = []any{ (*GetQueueStatusResponse)(nil), // 21: uber.submitqueue.stovepipe.GetQueueStatusResponse } var file_stovepipe_proto_depIdxs = []int32{ - 7, // 0: uber.submitqueue.stovepipe.RequestHistory.events:type_name -> uber.submitqueue.stovepipe.HistoryEvent - 16, // 1: uber.submitqueue.stovepipe.RequestHistory.request_summary:type_name -> uber.submitqueue.stovepipe.RequestSummary - 7, // 2: uber.submitqueue.stovepipe.GetRequestHistoryByIDResponse.events:type_name -> uber.submitqueue.stovepipe.HistoryEvent - 16, // 3: uber.submitqueue.stovepipe.GetRequestHistoryByIDResponse.request_summary:type_name -> uber.submitqueue.stovepipe.RequestSummary - 8, // 4: uber.submitqueue.stovepipe.GetRequestHistoryByURIResponse.histories:type_name -> uber.submitqueue.stovepipe.RequestHistory - 13, // 5: uber.submitqueue.stovepipe.GetProjectStatusByIDResponse.projects:type_name -> uber.submitqueue.stovepipe.ProjectValidation - 13, // 6: uber.submitqueue.stovepipe.GetProjectStatusByURIResponse.projects:type_name -> uber.submitqueue.stovepipe.ProjectValidation - 16, // 7: uber.submitqueue.stovepipe.ListResponse.requests:type_name -> uber.submitqueue.stovepipe.RequestSummary - 0, // 8: uber.submitqueue.stovepipe.QueueEnablementStatus.state:type_name -> uber.submitqueue.stovepipe.QueueEnablement - 20, // 9: uber.submitqueue.stovepipe.GetQueueStatusResponse.enablement:type_name -> uber.submitqueue.stovepipe.QueueEnablementStatus - 1, // 10: uber.submitqueue.stovepipe.Stovepipe.Ping:input_type -> uber.submitqueue.stovepipe.PingRequest - 3, // 11: uber.submitqueue.stovepipe.Stovepipe.Ingest:input_type -> uber.submitqueue.stovepipe.IngestRequest - 5, // 12: uber.submitqueue.stovepipe.Stovepipe.GetRequestHistoryByID:input_type -> uber.submitqueue.stovepipe.GetRequestHistoryByIDRequest - 6, // 13: uber.submitqueue.stovepipe.Stovepipe.GetRequestHistoryByURI:input_type -> uber.submitqueue.stovepipe.GetRequestHistoryByURIRequest - 11, // 14: uber.submitqueue.stovepipe.Stovepipe.GetProjectStatusByID:input_type -> uber.submitqueue.stovepipe.GetProjectStatusByIDRequest - 12, // 15: uber.submitqueue.stovepipe.Stovepipe.GetProjectStatusByURI:input_type -> uber.submitqueue.stovepipe.GetProjectStatusByURIRequest - 17, // 16: uber.submitqueue.stovepipe.Stovepipe.List:input_type -> uber.submitqueue.stovepipe.ListRequest - 19, // 17: uber.submitqueue.stovepipe.Stovepipe.GetQueueStatus:input_type -> uber.submitqueue.stovepipe.GetQueueStatusRequest - 2, // 18: uber.submitqueue.stovepipe.Stovepipe.Ping:output_type -> uber.submitqueue.stovepipe.PingResponse - 4, // 19: uber.submitqueue.stovepipe.Stovepipe.Ingest:output_type -> uber.submitqueue.stovepipe.IngestResponse - 9, // 20: uber.submitqueue.stovepipe.Stovepipe.GetRequestHistoryByID:output_type -> uber.submitqueue.stovepipe.GetRequestHistoryByIDResponse - 10, // 21: uber.submitqueue.stovepipe.Stovepipe.GetRequestHistoryByURI:output_type -> uber.submitqueue.stovepipe.GetRequestHistoryByURIResponse - 14, // 22: uber.submitqueue.stovepipe.Stovepipe.GetProjectStatusByID:output_type -> uber.submitqueue.stovepipe.GetProjectStatusByIDResponse - 15, // 23: uber.submitqueue.stovepipe.Stovepipe.GetProjectStatusByURI:output_type -> uber.submitqueue.stovepipe.GetProjectStatusByURIResponse - 18, // 24: uber.submitqueue.stovepipe.Stovepipe.List:output_type -> uber.submitqueue.stovepipe.ListResponse - 21, // 25: uber.submitqueue.stovepipe.Stovepipe.GetQueueStatus:output_type -> uber.submitqueue.stovepipe.GetQueueStatusResponse - 18, // [18:26] is the sub-list for method output_type - 10, // [10:18] is the sub-list for method input_type - 10, // [10:10] is the sub-list for extension type_name - 10, // [10:10] is the sub-list for extension extendee - 0, // [0:10] is the sub-list for field type_name + 0, // 0: uber.submitqueue.stovepipe.IngestRequest.enablement:type_name -> uber.submitqueue.stovepipe.QueueEnablement + 20, // 1: uber.submitqueue.stovepipe.IngestResponse.enablement:type_name -> uber.submitqueue.stovepipe.QueueEnablementStatus + 7, // 2: uber.submitqueue.stovepipe.RequestHistory.events:type_name -> uber.submitqueue.stovepipe.HistoryEvent + 16, // 3: uber.submitqueue.stovepipe.RequestHistory.request_summary:type_name -> uber.submitqueue.stovepipe.RequestSummary + 7, // 4: uber.submitqueue.stovepipe.GetRequestHistoryByIDResponse.events:type_name -> uber.submitqueue.stovepipe.HistoryEvent + 16, // 5: uber.submitqueue.stovepipe.GetRequestHistoryByIDResponse.request_summary:type_name -> uber.submitqueue.stovepipe.RequestSummary + 8, // 6: uber.submitqueue.stovepipe.GetRequestHistoryByURIResponse.histories:type_name -> uber.submitqueue.stovepipe.RequestHistory + 13, // 7: uber.submitqueue.stovepipe.GetProjectStatusByIDResponse.projects:type_name -> uber.submitqueue.stovepipe.ProjectValidation + 13, // 8: uber.submitqueue.stovepipe.GetProjectStatusByURIResponse.projects:type_name -> uber.submitqueue.stovepipe.ProjectValidation + 16, // 9: uber.submitqueue.stovepipe.ListResponse.requests:type_name -> uber.submitqueue.stovepipe.RequestSummary + 0, // 10: uber.submitqueue.stovepipe.QueueEnablementStatus.state:type_name -> uber.submitqueue.stovepipe.QueueEnablement + 20, // 11: uber.submitqueue.stovepipe.GetQueueStatusResponse.enablement:type_name -> uber.submitqueue.stovepipe.QueueEnablementStatus + 1, // 12: uber.submitqueue.stovepipe.Stovepipe.Ping:input_type -> uber.submitqueue.stovepipe.PingRequest + 3, // 13: uber.submitqueue.stovepipe.Stovepipe.Ingest:input_type -> uber.submitqueue.stovepipe.IngestRequest + 5, // 14: uber.submitqueue.stovepipe.Stovepipe.GetRequestHistoryByID:input_type -> uber.submitqueue.stovepipe.GetRequestHistoryByIDRequest + 6, // 15: uber.submitqueue.stovepipe.Stovepipe.GetRequestHistoryByURI:input_type -> uber.submitqueue.stovepipe.GetRequestHistoryByURIRequest + 11, // 16: uber.submitqueue.stovepipe.Stovepipe.GetProjectStatusByID:input_type -> uber.submitqueue.stovepipe.GetProjectStatusByIDRequest + 12, // 17: uber.submitqueue.stovepipe.Stovepipe.GetProjectStatusByURI:input_type -> uber.submitqueue.stovepipe.GetProjectStatusByURIRequest + 17, // 18: uber.submitqueue.stovepipe.Stovepipe.List:input_type -> uber.submitqueue.stovepipe.ListRequest + 19, // 19: uber.submitqueue.stovepipe.Stovepipe.GetQueueStatus:input_type -> uber.submitqueue.stovepipe.GetQueueStatusRequest + 2, // 20: uber.submitqueue.stovepipe.Stovepipe.Ping:output_type -> uber.submitqueue.stovepipe.PingResponse + 4, // 21: uber.submitqueue.stovepipe.Stovepipe.Ingest:output_type -> uber.submitqueue.stovepipe.IngestResponse + 9, // 22: uber.submitqueue.stovepipe.Stovepipe.GetRequestHistoryByID:output_type -> uber.submitqueue.stovepipe.GetRequestHistoryByIDResponse + 10, // 23: uber.submitqueue.stovepipe.Stovepipe.GetRequestHistoryByURI:output_type -> uber.submitqueue.stovepipe.GetRequestHistoryByURIResponse + 14, // 24: uber.submitqueue.stovepipe.Stovepipe.GetProjectStatusByID:output_type -> uber.submitqueue.stovepipe.GetProjectStatusByIDResponse + 15, // 25: uber.submitqueue.stovepipe.Stovepipe.GetProjectStatusByURI:output_type -> uber.submitqueue.stovepipe.GetProjectStatusByURIResponse + 18, // 26: uber.submitqueue.stovepipe.Stovepipe.List:output_type -> uber.submitqueue.stovepipe.ListResponse + 21, // 27: uber.submitqueue.stovepipe.Stovepipe.GetQueueStatus:output_type -> uber.submitqueue.stovepipe.GetQueueStatusResponse + 20, // [20:28] is the sub-list for method output_type + 12, // [12:20] 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 } func init() { file_stovepipe_proto_init() } diff --git a/api/stovepipe/protopb/stovepipe.pb.yarpc.go b/api/stovepipe/protopb/stovepipe.pb.yarpc.go index b2283430a..b67fd871b 100644 --- a/api/stovepipe/protopb/stovepipe.pb.yarpc.go +++ b/api/stovepipe/protopb/stovepipe.pb.yarpc.go @@ -576,91 +576,92 @@ var ( var yarpcFileDescriptorClosurefabdb6b3c0b09022 = [][]byte{ // stovepipe.proto []byte{ - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xec, 0x58, 0x4d, 0x6f, 0x1b, 0xc5, - 0x1b, 0xf7, 0xda, 0x79, 0xb1, 0x9f, 0x38, 0x4e, 0x3a, 0x6d, 0xf3, 0x77, 0xdc, 0xa6, 0xca, 0x7f, - 0xa1, 0x34, 0x2d, 0xaa, 0xa3, 0x06, 0x09, 0x5a, 0x0e, 0x88, 0xb8, 0x71, 0x63, 0x8b, 0x26, 0x4d, - 0xd7, 0x0d, 0x48, 0xf4, 0xb0, 0x5a, 0xdb, 0x4f, 0x9c, 0x6d, 0xb3, 0x2f, 0x9d, 0x99, 0x0d, 0xa4, - 0x07, 0x24, 0x0e, 0x9c, 0x90, 0x90, 0xf8, 0x06, 0x9c, 0xe1, 0xce, 0x99, 0x13, 0x5f, 0x80, 0x0b, - 0x7c, 0x02, 0x3e, 0x02, 0x57, 0x34, 0x2f, 0xbb, 0x5e, 0x3b, 0x8e, 0xd3, 0x34, 0x20, 0x81, 0xc4, - 0xcd, 0xf3, 0xbc, 0xfc, 0xe6, 0x99, 0xe7, 0xe5, 0xb7, 0x33, 0x86, 0x39, 0xc6, 0x83, 0x43, 0x0c, - 0xdd, 0x10, 0xab, 0x21, 0x0d, 0x78, 0x40, 0x2a, 0x51, 0x1b, 0x69, 0x95, 0x45, 0x6d, 0xcf, 0xe5, - 0x2f, 0x22, 0x8c, 0xb0, 0x9a, 0x58, 0x98, 0x37, 0x60, 0x66, 0xc7, 0xf5, 0x7b, 0x16, 0xbe, 0x88, - 0x90, 0x71, 0x52, 0x86, 0x69, 0x0f, 0x19, 0x73, 0x7a, 0x58, 0x36, 0x96, 0x8d, 0x95, 0x82, 0x15, - 0x2f, 0xcd, 0xaf, 0x0c, 0x28, 0x2a, 0x4b, 0x16, 0x06, 0x3e, 0xc3, 0x93, 0x4d, 0xc9, 0xff, 0xa1, - 0xc8, 0x90, 0x1e, 0xba, 0x1d, 0xb4, 0x7d, 0xc7, 0xc3, 0x72, 0x56, 0xaa, 0x67, 0xb4, 0x6c, 0xdb, - 0xf1, 0x90, 0x5c, 0x85, 0x02, 0x77, 0x3d, 0x64, 0xdc, 0xf1, 0xc2, 0x72, 0x6e, 0xd9, 0x58, 0xc9, - 0x59, 0x7d, 0x01, 0xa9, 0x40, 0x7e, 0x3f, 0x60, 0x5c, 0x3a, 0x4f, 0x48, 0xe7, 0x64, 0x6d, 0x5e, - 0x87, 0xd9, 0xa6, 0xdf, 0x43, 0xc6, 0xe3, 0x90, 0x2f, 0xc1, 0xa4, 0x3c, 0x94, 0x8e, 0x42, 0x2d, - 0xcc, 0x65, 0x28, 0xc5, 0x66, 0x3a, 0xde, 0x12, 0x64, 0xdd, 0xae, 0x36, 0xca, 0xba, 0x5d, 0xb3, - 0x05, 0x57, 0x37, 0x31, 0x46, 0x69, 0xb8, 0x8c, 0x07, 0xf4, 0xa8, 0x76, 0xd4, 0xdc, 0x18, 0x8b, - 0x4b, 0x96, 0x00, 0xa8, 0x32, 0xb0, 0xdd, 0xae, 0x3e, 0x59, 0x41, 0x4b, 0x9a, 0x5d, 0x73, 0x13, - 0x96, 0x46, 0x80, 0xee, 0x5a, 0xcd, 0xf1, 0xa8, 0xf3, 0x90, 0x8b, 0xa8, 0xab, 0xe1, 0xc4, 0x4f, - 0xf3, 0x27, 0x03, 0x8a, 0xda, 0xbf, 0x7e, 0x88, 0x3e, 0x27, 0x8b, 0x90, 0x47, 0xf1, 0xc3, 0x4e, - 0x0e, 0x31, 0x2d, 0xd7, 0xcd, 0xae, 0xc8, 0x77, 0x92, 0x3b, 0xdb, 0x63, 0x12, 0x26, 0x67, 0xcd, - 0x24, 0xb2, 0x2d, 0x46, 0xae, 0xc3, 0x6c, 0x1c, 0x36, 0xe3, 0x0e, 0x47, 0x99, 0xf3, 0x42, 0x23, - 0x63, 0x15, 0xb5, 0xb8, 0x25, 0xa4, 0x64, 0x01, 0x26, 0x25, 0xa8, 0xca, 0x7a, 0x23, 0x63, 0xa9, - 0x25, 0xb9, 0x0e, 0xa5, 0x20, 0xe2, 0x9d, 0xc0, 0x43, 0x9b, 0xa2, 0xc3, 0x02, 0xbf, 0x3c, 0x29, - 0x43, 0x98, 0xd5, 0x52, 0x4b, 0x0a, 0x6b, 0x45, 0x80, 0xa0, 0xd3, 0x89, 0x28, 0x45, 0xbf, 0x83, - 0xe6, 0xcf, 0x06, 0x94, 0x06, 0x33, 0x31, 0x94, 0x3d, 0x63, 0x28, 0x7b, 0xe4, 0x43, 0x98, 0x92, - 0xfb, 0x89, 0x23, 0xe4, 0x56, 0x66, 0xd6, 0x56, 0xaa, 0x27, 0x77, 0x6e, 0x35, 0x9d, 0x1d, 0x4b, - 0xfb, 0x91, 0x16, 0xcc, 0x25, 0xe7, 0x8c, 0x3c, 0xcf, 0xa1, 0x47, 0xf2, 0xa4, 0x33, 0x6b, 0xb7, - 0xc6, 0x41, 0xe9, 0x28, 0x5b, 0xca, 0xc3, 0x2a, 0xd1, 0x81, 0xb5, 0xf9, 0xa3, 0x31, 0xb2, 0xaa, - 0xa2, 0x55, 0x74, 0x6f, 0xf5, 0x03, 0x37, 0xfe, 0xba, 0xc0, 0xb3, 0xe7, 0x0e, 0xfc, 0x19, 0x5c, - 0x3b, 0xa9, 0x1b, 0x75, 0xe0, 0x0d, 0x28, 0xec, 0x4b, 0xb9, 0x8b, 0x71, 0xec, 0xaf, 0xb2, 0xa1, - 0xc6, 0xb2, 0xfa, 0xce, 0xe6, 0xf7, 0x06, 0x5c, 0xd9, 0x44, 0xbe, 0x43, 0x83, 0x67, 0xd8, 0x91, - 0xed, 0x14, 0xb1, 0xf3, 0x8e, 0x93, 0x20, 0x82, 0x50, 0x01, 0xb2, 0x72, 0x6e, 0x39, 0x27, 0x88, - 0x20, 0x5e, 0x93, 0x2b, 0x50, 0x08, 0x9d, 0x1e, 0xda, 0xcc, 0x7d, 0xa9, 0x58, 0x62, 0xd2, 0xca, - 0x0b, 0x41, 0xcb, 0x7d, 0x29, 0x71, 0xa5, 0x92, 0x07, 0xcf, 0x31, 0x6e, 0x56, 0x69, 0xfe, 0x44, - 0x08, 0xcc, 0x1f, 0x0c, 0x39, 0xfc, 0x43, 0xc1, 0x9e, 0x3a, 0xa6, 0x4b, 0x00, 0x9d, 0x7d, 0xc7, - 0xef, 0xa1, 0xdd, 0x9f, 0xd6, 0x82, 0x92, 0xec, 0x52, 0xf7, 0x6f, 0x8b, 0xb6, 0x0d, 0x17, 0x74, - 0xa4, 0x1f, 0x3b, 0x07, 0x6e, 0xd7, 0xe1, 0x6e, 0xe0, 0x0b, 0xfa, 0xd5, 0xe0, 0x31, 0x1d, 0xe8, - 0x25, 0xb9, 0x09, 0x73, 0x6d, 0x8a, 0xce, 0x73, 0x81, 0xd8, 0xc5, 0x1e, 0x45, 0xc5, 0xc0, 0x46, - 0x23, 0x63, 0x95, 0x62, 0xc5, 0x86, 0x94, 0xd7, 0xf2, 0x30, 0x45, 0x91, 0x45, 0x07, 0xdc, 0xfc, - 0x35, 0x37, 0x2a, 0x23, 0xa9, 0x16, 0x3f, 0x65, 0x74, 0x93, 0x84, 0x65, 0x4f, 0x4e, 0x58, 0x6e, - 0x38, 0x61, 0x8b, 0x90, 0x6f, 0x3b, 0x4c, 0x29, 0x15, 0xcf, 0x4f, 0x8b, 0xb5, 0x50, 0xbd, 0x31, - 0x4c, 0x58, 0x2a, 0x2b, 0x83, 0x74, 0x65, 0xc2, 0x6c, 0x14, 0x76, 0x1d, 0x8e, 0x5d, 0xdb, 0xe1, - 0x82, 0xf9, 0x40, 0x31, 0x9f, 0x16, 0xae, 0xf3, 0x2d, 0x46, 0x3e, 0x80, 0x0a, 0xc5, 0x30, 0x60, - 0xae, 0x68, 0x58, 0x7b, 0x38, 0x31, 0x53, 0x3a, 0x31, 0xe5, 0xbe, 0x4d, 0x6d, 0x20, 0x45, 0xe4, - 0x2e, 0x94, 0x75, 0x62, 0x6d, 0x95, 0x2a, 0x66, 0x77, 0x02, 0x2f, 0x3c, 0x40, 0x8e, 0xe5, 0xe9, - 0x65, 0x63, 0x25, 0x6f, 0x2d, 0x68, 0xbd, 0xa5, 0xd4, 0xf7, 0xb5, 0x96, 0x34, 0x53, 0xed, 0x90, - 0x97, 0xa3, 0x75, 0x7b, 0xdc, 0x68, 0x1d, 0x2b, 0x71, 0xaa, 0x7b, 0xde, 0x82, 0x39, 0x1f, 0x3f, - 0xe7, 0x76, 0xaa, 0x4b, 0x0a, 0x8a, 0x80, 0x85, 0x78, 0x27, 0xee, 0x94, 0xda, 0x45, 0xb8, 0x90, - 0x3a, 0xac, 0x2e, 0xed, 0x6f, 0x39, 0x49, 0x5f, 0xa3, 0x9a, 0xfd, 0xbf, 0xda, 0xfe, 0xcb, 0x6b, - 0xfb, 0x5d, 0x36, 0xf9, 0xc6, 0x6a, 0xd2, 0xff, 0x47, 0x16, 0xf3, 0x36, 0x5c, 0x94, 0x4a, 0x7b, - 0xb0, 0xa4, 0x53, 0xb2, 0xa4, 0xf3, 0x52, 0xb5, 0x9b, 0xaa, 0xeb, 0x9b, 0x50, 0x72, 0x3a, 0x1d, - 0x0c, 0xfb, 0x96, 0xd3, 0xd2, 0xb2, 0x18, 0x4b, 0xa5, 0xd5, 0xf1, 0x4b, 0x49, 0x7e, 0xc4, 0xa5, - 0xc4, 0xfc, 0xc3, 0x80, 0x99, 0x87, 0xee, 0x29, 0xf7, 0x45, 0xf2, 0x2e, 0xfc, 0x2f, 0xbd, 0x65, - 0x40, 0x6d, 0x67, 0x8f, 0x23, 0x4d, 0xae, 0x53, 0x8d, 0x8c, 0x75, 0xb1, 0xbf, 0xfb, 0x23, 0xba, - 0x2e, 0xb4, 0x5b, 0x8c, 0x54, 0x81, 0x24, 0x7e, 0x6d, 0xdc, 0x0b, 0x28, 0x0a, 0x17, 0x79, 0xa3, - 0x6d, 0x18, 0xd6, 0x7c, 0xac, 0xab, 0x49, 0xd5, 0xd6, 0xb9, 0xbe, 0x03, 0xb5, 0x05, 0xb8, 0x94, - 0xec, 0x75, 0x10, 0x7c, 0x86, 0xd4, 0x6e, 0x07, 0x91, 0xdf, 0x1d, 0x90, 0x47, 0x61, 0x18, 0xcb, - 0xcd, 0x2f, 0xa0, 0xa8, 0x0e, 0xae, 0xc7, 0xfc, 0x01, 0xe4, 0x75, 0x55, 0xce, 0xf2, 0xad, 0x8f, - 0x2f, 0x17, 0x89, 0xef, 0xa8, 0x8e, 0xcd, 0x8e, 0xe8, 0x58, 0xf3, 0x21, 0x5c, 0xde, 0x44, 0xfe, - 0x58, 0xc0, 0x2a, 0xd6, 0x39, 0xcf, 0xd7, 0xd5, 0xfc, 0xc5, 0x80, 0xcb, 0x12, 0xab, 0xee, 0x3b, - 0xed, 0x03, 0xf4, 0xd0, 0xd7, 0x5c, 0x26, 0x3e, 0x85, 0x87, 0x48, 0x99, 0x1b, 0xf8, 0x12, 0x30, - 0x67, 0xc5, 0x4b, 0xb2, 0x0e, 0x93, 0xaa, 0x29, 0x05, 0x5a, 0x69, 0xed, 0xed, 0x71, 0xc7, 0x1d, - 0xc2, 0xb6, 0x94, 0x27, 0xb9, 0x07, 0x8b, 0xb8, 0xb7, 0x87, 0x1d, 0xee, 0x1e, 0xa2, 0xbd, 0x47, - 0x03, 0x4f, 0x50, 0x84, 0xe7, 0xf2, 0xd4, 0xa0, 0x2c, 0x24, 0x06, 0x0f, 0x68, 0xe0, 0xdd, 0x97, - 0x6a, 0x31, 0x1a, 0x26, 0xcc, 0xaa, 0xf0, 0xe3, 0x2e, 0x9e, 0x50, 0x14, 0xa6, 0x85, 0xa2, 0x89, - 0xcd, 0x2f, 0x0d, 0x58, 0x18, 0x4e, 0x92, 0x2e, 0xd7, 0xe8, 0x2c, 0x3d, 0x06, 0xc0, 0x24, 0x48, - 0x7d, 0x47, 0xbc, 0x73, 0x86, 0x73, 0xe9, 0x4d, 0x52, 0x20, 0xb7, 0x0e, 0x60, 0x6e, 0xc8, 0x88, - 0x5c, 0x85, 0xf2, 0xe3, 0xdd, 0xfa, 0x6e, 0xdd, 0xae, 0x6f, 0xaf, 0xd7, 0x1e, 0xd6, 0xb7, 0xea, - 0xdb, 0x4f, 0xec, 0xdd, 0xed, 0x8f, 0xb6, 0x1f, 0x7d, 0xb2, 0x3d, 0x9f, 0x19, 0xa9, 0x55, 0x3f, - 0x37, 0xe6, 0x0d, 0xb2, 0x04, 0x8b, 0xc7, 0xb4, 0x1b, 0xcd, 0x96, 0x52, 0x67, 0xd7, 0x7e, 0x9f, - 0x86, 0x42, 0x2b, 0x8e, 0x8e, 0x3c, 0x85, 0x09, 0xf1, 0xaa, 0x24, 0x37, 0xc6, 0xd2, 0x67, 0xff, - 0x85, 0x5a, 0x59, 0x39, 0xdd, 0x50, 0xe5, 0xcf, 0xcc, 0x10, 0x07, 0xa6, 0xd4, 0x23, 0x90, 0xdc, - 0x1c, 0xe7, 0x35, 0xf0, 0x9e, 0xac, 0xdc, 0x7a, 0x15, 0xd3, 0x64, 0x8b, 0x6f, 0x0c, 0xd9, 0xe4, - 0xc7, 0xdf, 0x06, 0xe4, 0xee, 0x38, 0x9c, 0x71, 0x2f, 0xcf, 0xca, 0xbd, 0xd7, 0xf0, 0x4c, 0x02, - 0xfa, 0x56, 0x35, 0xd4, 0x88, 0x4b, 0x3f, 0x39, 0x2b, 0x6e, 0xff, 0x3e, 0x5c, 0x79, 0xff, 0x75, - 0x5c, 0x93, 0x98, 0xbe, 0x36, 0xe0, 0xd2, 0xa8, 0xcb, 0x25, 0x79, 0xef, 0x14, 0xd8, 0x93, 0x5e, - 0x13, 0x95, 0xbb, 0x67, 0x77, 0x1c, 0x2e, 0xd9, 0xf1, 0xfb, 0x10, 0x39, 0x23, 0x6a, 0x2a, 0x3f, - 0xf7, 0x5e, 0xc3, 0x33, 0x09, 0xe8, 0x29, 0x4c, 0x08, 0x9e, 0x1e, 0x3f, 0x03, 0xa9, 0x4f, 0xd8, - 0xf8, 0x19, 0x48, 0x53, 0xbe, 0x99, 0x21, 0x47, 0x50, 0x1a, 0xe4, 0x17, 0x72, 0xe7, 0x94, 0x58, - 0x8f, 0x13, 0x76, 0x65, 0xed, 0x2c, 0x2e, 0xf1, 0xd6, 0x35, 0x84, 0x6b, 0x9d, 0xc0, 0x1b, 0xe3, - 0x5a, 0x2b, 0x25, 0x44, 0xb0, 0x43, 0x03, 0x1e, 0xec, 0x18, 0x9f, 0xde, 0xe9, 0xb9, 0x7c, 0x3f, - 0x6a, 0x57, 0x3b, 0x81, 0xb7, 0x2a, 0x1c, 0x57, 0x53, 0x8e, 0xab, 0x4e, 0xe8, 0xae, 0x26, 0xce, - 0xab, 0xf2, 0xcf, 0xad, 0xb0, 0xdd, 0x9e, 0x92, 0x3f, 0xde, 0xf9, 0x33, 0x00, 0x00, 0xff, 0xff, - 0xd1, 0x24, 0x65, 0xea, 0xf8, 0x12, 0x00, 0x00, + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xec, 0x18, 0x4b, 0x6f, 0x1b, 0x55, + 0x37, 0x63, 0xe7, 0x61, 0x9f, 0x24, 0x4e, 0x7a, 0xdb, 0xe6, 0x73, 0xdc, 0xa6, 0xca, 0x37, 0x50, + 0x9a, 0x16, 0xd5, 0x51, 0x83, 0x04, 0x2d, 0x0b, 0x44, 0xdc, 0xb8, 0xb1, 0xd5, 0x26, 0x4d, 0xc7, + 0x0d, 0x48, 0x74, 0x31, 0x1a, 0xdb, 0x27, 0xce, 0xb4, 0x99, 0x47, 0xef, 0xbd, 0x13, 0x48, 0x17, + 0x48, 0x2c, 0x58, 0x21, 0x21, 0xf1, 0x0f, 0x58, 0xc3, 0x9e, 0x35, 0x2b, 0xfe, 0x00, 0x1b, 0xf8, + 0x05, 0xfc, 0x04, 0xb6, 0xe8, 0x3e, 0x66, 0x3c, 0x7e, 0xc4, 0x69, 0x92, 0x22, 0x81, 0xc4, 0x6e, + 0xce, 0xfb, 0xdc, 0xf3, 0xba, 0xe7, 0x0e, 0xcc, 0x31, 0x1e, 0x1c, 0x62, 0xe8, 0x86, 0x58, 0x0e, + 0x69, 0xc0, 0x03, 0x52, 0x8a, 0x9a, 0x48, 0xcb, 0x2c, 0x6a, 0x7a, 0x2e, 0x7f, 0x19, 0x61, 0x84, + 0xe5, 0x84, 0xc3, 0xbc, 0x01, 0xd3, 0x3b, 0xae, 0xdf, 0xb1, 0xf0, 0x65, 0x84, 0x8c, 0x93, 0x22, + 0x4c, 0x79, 0xc8, 0x98, 0xd3, 0xc1, 0xa2, 0xb1, 0x6c, 0xac, 0xe4, 0xad, 0x18, 0x34, 0xbf, 0x36, + 0x60, 0x46, 0x71, 0xb2, 0x30, 0xf0, 0x19, 0x1e, 0xcf, 0x4a, 0xfe, 0x0f, 0x33, 0x0c, 0xe9, 0xa1, + 0xdb, 0x42, 0xdb, 0x77, 0x3c, 0x2c, 0x66, 0x24, 0x79, 0x5a, 0xe3, 0xb6, 0x1d, 0x0f, 0xc9, 0x55, + 0xc8, 0x73, 0xd7, 0x43, 0xc6, 0x1d, 0x2f, 0x2c, 0x66, 0x97, 0x8d, 0x95, 0xac, 0xd5, 0x45, 0x90, + 0x12, 0xe4, 0xf6, 0x03, 0xc6, 0xa5, 0xf0, 0xb8, 0x14, 0x4e, 0x60, 0x93, 0xc2, 0x6c, 0xdd, 0xef, + 0x20, 0xe3, 0xb1, 0xcb, 0x97, 0x60, 0x42, 0x1e, 0x4a, 0x7b, 0xa1, 0x00, 0xf2, 0x10, 0x00, 0x7d, + 0xa7, 0x79, 0x80, 0x1e, 0xfa, 0x5c, 0x7a, 0x50, 0x58, 0x7b, 0xb7, 0x7c, 0x7c, 0x20, 0xca, 0x4f, + 0x04, 0x5c, 0x4d, 0x44, 0xac, 0x94, 0xb8, 0xc9, 0xa0, 0x10, 0xdb, 0xd4, 0x87, 0x2f, 0x40, 0xc6, + 0x6d, 0x6b, 0x8b, 0x19, 0xb7, 0x4d, 0x9e, 0x0c, 0x98, 0x9b, 0x5e, 0xbb, 0x73, 0x0a, 0x73, 0x0d, + 0xee, 0xf0, 0x88, 0xf5, 0x18, 0x6d, 0xc0, 0xd5, 0x4d, 0x8c, 0x4f, 0x59, 0x73, 0x19, 0x0f, 0xe8, + 0x51, 0xe5, 0xa8, 0xbe, 0x31, 0xfa, 0xdc, 0x4b, 0x00, 0x54, 0x31, 0xd8, 0x6e, 0x5b, 0x47, 0x3e, + 0xaf, 0x31, 0xf5, 0xb6, 0xb9, 0x09, 0x4b, 0x43, 0x94, 0xee, 0x5a, 0xf5, 0xd1, 0x5a, 0xe7, 0x21, + 0x1b, 0x51, 0x57, 0xab, 0x13, 0x9f, 0xe6, 0xcf, 0x06, 0xcc, 0x68, 0xf9, 0xea, 0x21, 0xfa, 0x9c, + 0x2c, 0x42, 0x0e, 0xc5, 0x87, 0x9d, 0xc4, 0x65, 0x4a, 0xc2, 0xf5, 0xb6, 0xa8, 0x87, 0x24, 0xb7, + 0xb6, 0xc7, 0xa4, 0x9a, 0xac, 0x35, 0x9d, 0xe0, 0xb6, 0x18, 0xb9, 0x0e, 0xb3, 0xb1, 0xdb, 0x8c, + 0x3b, 0x1c, 0x65, 0x4d, 0xe4, 0x6b, 0x63, 0xd6, 0x8c, 0x46, 0x8b, 0x00, 0x21, 0x59, 0x80, 0x09, + 0xa9, 0x54, 0x55, 0x45, 0x6d, 0xcc, 0x52, 0x20, 0xb9, 0x0e, 0x85, 0x20, 0xe2, 0xad, 0xc0, 0x43, + 0x9b, 0xa2, 0xc3, 0x02, 0xbf, 0x38, 0x21, 0x5d, 0x98, 0xd5, 0x58, 0x4b, 0x22, 0x2b, 0x33, 0x00, + 0x41, 0xab, 0x15, 0x51, 0x8a, 0x7e, 0x0b, 0xcd, 0x5f, 0x0c, 0x28, 0xf4, 0x46, 0xa2, 0x2f, 0x7a, + 0x46, 0x5f, 0xf4, 0xc8, 0xc7, 0x30, 0x29, 0xed, 0x89, 0x23, 0x64, 0x57, 0xa6, 0xd7, 0x56, 0x46, + 0x65, 0x38, 0x1d, 0x1d, 0x4b, 0xcb, 0x91, 0x06, 0xcc, 0x25, 0xe7, 0x8c, 0x3c, 0xcf, 0xa1, 0x47, + 0xf2, 0xa4, 0xd3, 0x6b, 0xb7, 0x46, 0xa9, 0xd2, 0x5e, 0x36, 0x94, 0x84, 0x55, 0xa0, 0x3d, 0xb0, + 0xf9, 0x93, 0x31, 0x34, 0xab, 0xa2, 0x54, 0x74, 0xb9, 0x76, 0x1d, 0x37, 0xde, 0x9c, 0xe3, 0x99, + 0x73, 0x3b, 0xfe, 0x1c, 0xae, 0x1d, 0x57, 0x8d, 0xda, 0xf1, 0x1a, 0xe4, 0xf7, 0x25, 0xde, 0xc5, + 0xd8, 0xf7, 0xd7, 0x31, 0xa8, 0x75, 0x59, 0x5d, 0x61, 0xf3, 0x07, 0x03, 0xae, 0x6c, 0x22, 0xdf, + 0xa1, 0xc1, 0x73, 0x6c, 0xe9, 0x7e, 0x3b, 0x6f, 0x3b, 0x89, 0x41, 0x15, 0x2a, 0x85, 0xac, 0x98, + 0x5d, 0xce, 0x8a, 0x41, 0x15, 0xc3, 0xe4, 0x0a, 0xe4, 0x43, 0xa7, 0x83, 0x36, 0x73, 0x5f, 0xa9, + 0x29, 0x36, 0x61, 0xe5, 0x04, 0xa2, 0xe1, 0xbe, 0x92, 0x7a, 0x25, 0x91, 0x07, 0x2f, 0x30, 0x2e, + 0x56, 0xc9, 0xfe, 0x54, 0x20, 0xcc, 0x1f, 0x0d, 0xd9, 0xfc, 0x7d, 0xce, 0x9e, 0xd8, 0xa6, 0x4b, + 0x00, 0xad, 0x7d, 0xc7, 0xef, 0xa0, 0xdd, 0xed, 0xd6, 0xbc, 0xc2, 0xec, 0x52, 0xf7, 0x6f, 0xf3, + 0xb6, 0x09, 0x17, 0xb4, 0xa7, 0x9f, 0x38, 0x07, 0x6e, 0xdb, 0xe1, 0x6e, 0xe0, 0x8b, 0xeb, 0x41, + 0x2b, 0x8f, 0xc7, 0x81, 0x06, 0xc9, 0x4d, 0x98, 0x6b, 0x52, 0x74, 0x5e, 0x08, 0x8d, 0x6d, 0xec, + 0x50, 0x54, 0x37, 0x84, 0x51, 0x1b, 0xb3, 0x0a, 0x31, 0x61, 0x43, 0xe2, 0x2b, 0x39, 0x98, 0xa4, + 0xc8, 0xa2, 0x03, 0x6e, 0xfe, 0x96, 0x1d, 0x16, 0x91, 0x54, 0x89, 0x9f, 0xd0, 0xba, 0x49, 0xc0, + 0x32, 0xc7, 0x07, 0x2c, 0xdb, 0x1f, 0xb0, 0x45, 0xc8, 0x35, 0x1d, 0xa6, 0x88, 0xea, 0x1e, 0x9a, + 0x12, 0xb0, 0x20, 0xbd, 0xd5, 0x3f, 0xb0, 0x54, 0x54, 0x7a, 0xc7, 0x95, 0x09, 0xb3, 0x51, 0xd8, + 0x76, 0x38, 0xb6, 0x6d, 0x87, 0x8b, 0xc9, 0x07, 0x6a, 0xf2, 0x69, 0xe4, 0x3a, 0xdf, 0x62, 0xe4, + 0x23, 0x28, 0x51, 0x0c, 0x03, 0xe6, 0x8a, 0x82, 0xb5, 0xfb, 0x03, 0x33, 0xa9, 0x03, 0x53, 0xec, + 0xf2, 0x54, 0x7a, 0x42, 0x44, 0xee, 0x42, 0x51, 0x07, 0xd6, 0x56, 0xa1, 0x62, 0x76, 0x2b, 0xf0, + 0xc2, 0x03, 0xe4, 0x58, 0x9c, 0x5a, 0x36, 0x56, 0x72, 0xd6, 0x82, 0xa6, 0x5b, 0x8a, 0x7c, 0x5f, + 0x53, 0x49, 0x3d, 0x55, 0x0e, 0x39, 0xd9, 0x5a, 0xb7, 0x47, 0xb5, 0xd6, 0x40, 0x8a, 0x53, 0xd5, + 0xf3, 0x0e, 0xcc, 0xf9, 0xf8, 0x05, 0xb7, 0x53, 0x55, 0x92, 0x57, 0x03, 0x58, 0xa0, 0x77, 0xe2, + 0x4a, 0xa9, 0x5c, 0x84, 0x0b, 0xa9, 0xc3, 0xea, 0xd4, 0xfe, 0x9e, 0x95, 0xe3, 0x6b, 0x58, 0xb1, + 0xff, 0x97, 0xdb, 0x7f, 0x79, 0x6e, 0xbf, 0xcf, 0x24, 0x77, 0xac, 0x1e, 0xfa, 0xff, 0xc8, 0x64, + 0xde, 0x86, 0x8b, 0x92, 0x68, 0xf7, 0xa6, 0x74, 0x52, 0xa6, 0x74, 0x5e, 0x92, 0x76, 0x53, 0x79, + 0x7d, 0x1b, 0x0a, 0x4e, 0xab, 0x85, 0x61, 0x97, 0x73, 0x4a, 0x72, 0xce, 0xc4, 0x58, 0xc9, 0x35, + 0xb8, 0x94, 0xe4, 0x86, 0x2c, 0x25, 0xe6, 0x9f, 0x06, 0x4c, 0x3f, 0x72, 0x4f, 0xda, 0x67, 0xdf, + 0x87, 0xff, 0xa5, 0x4d, 0x06, 0xd4, 0x76, 0xf6, 0x38, 0xd2, 0x64, 0x9d, 0xaa, 0x8d, 0x59, 0x17, + 0xbb, 0xd6, 0x1f, 0xd3, 0x75, 0x41, 0xdd, 0x62, 0xa4, 0x0c, 0x24, 0x91, 0x6b, 0xe2, 0x5e, 0x40, + 0x51, 0x88, 0xc8, 0x8d, 0xbb, 0x66, 0x58, 0xf3, 0x31, 0xad, 0x22, 0x49, 0x5b, 0xe7, 0xba, 0x07, + 0x2a, 0x0b, 0x70, 0x29, 0xb1, 0x75, 0x10, 0x7c, 0x8e, 0xd4, 0x6e, 0x06, 0x91, 0xdf, 0xee, 0xc1, + 0x47, 0x61, 0x18, 0xe3, 0xcd, 0x2f, 0x61, 0x46, 0x1d, 0x5c, 0xb7, 0xf9, 0x03, 0xc8, 0xe9, 0xac, + 0x9c, 0xe6, 0xae, 0x8f, 0x97, 0x8b, 0x44, 0x76, 0x58, 0xc5, 0x66, 0x86, 0x54, 0xac, 0xf9, 0x08, + 0x2e, 0x6f, 0x22, 0x97, 0x9b, 0xb8, 0xde, 0xbf, 0xcf, 0x71, 0xbb, 0x9a, 0xbf, 0x1a, 0x70, 0x79, + 0xe8, 0x56, 0x2f, 0xae, 0xc2, 0x43, 0xa4, 0xcc, 0x0d, 0x7c, 0xa9, 0x30, 0x6b, 0xc5, 0x20, 0x59, + 0x87, 0x09, 0x55, 0x94, 0x67, 0x78, 0xa0, 0x28, 0x49, 0x72, 0x0f, 0x16, 0x71, 0x6f, 0x0f, 0x5b, + 0xdc, 0x3d, 0x44, 0x7b, 0x8f, 0x06, 0x9e, 0x18, 0x11, 0x9e, 0xcb, 0x53, 0x8d, 0xb2, 0x90, 0x30, + 0x3c, 0xa0, 0x81, 0x77, 0x5f, 0x92, 0x45, 0x6b, 0x98, 0x30, 0xab, 0xdc, 0x8f, 0xab, 0x78, 0x5c, + 0x8d, 0x30, 0x8d, 0x14, 0x45, 0x6c, 0x7e, 0x65, 0xc0, 0x42, 0x7f, 0x90, 0x74, 0xba, 0x86, 0x47, + 0xe9, 0xcd, 0xbf, 0x84, 0x6e, 0x1d, 0xc0, 0x5c, 0x1f, 0x13, 0xb9, 0x0a, 0xc5, 0x27, 0xbb, 0xd5, + 0xdd, 0xaa, 0x5d, 0xdd, 0x5e, 0xaf, 0x3c, 0xaa, 0x6e, 0x55, 0xb7, 0x9f, 0xda, 0xbb, 0xdb, 0x0f, + 0xb7, 0x1f, 0x7f, 0xba, 0x3d, 0x3f, 0x36, 0x94, 0xaa, 0x3e, 0x37, 0xe6, 0x0d, 0xb2, 0x04, 0x8b, + 0x03, 0xd4, 0x8d, 0x7a, 0x43, 0x91, 0x33, 0x6b, 0x7f, 0x4c, 0x41, 0xbe, 0x11, 0x7b, 0x47, 0x9e, + 0xc1, 0xb8, 0x78, 0xf5, 0x92, 0x1b, 0x23, 0xc7, 0x67, 0xf7, 0x05, 0x5d, 0x5a, 0x39, 0x99, 0x51, + 0xc5, 0xcf, 0x1c, 0x23, 0x0e, 0x4c, 0xaa, 0x77, 0x25, 0xb9, 0x39, 0x4a, 0xaa, 0xe7, 0xbd, 0x5b, + 0xba, 0xf5, 0x3a, 0xac, 0x89, 0x89, 0x6f, 0x0d, 0x59, 0xe4, 0x83, 0x6f, 0x03, 0x72, 0x77, 0x94, + 0x9e, 0x51, 0x2f, 0xcf, 0xd2, 0xbd, 0x33, 0x48, 0x26, 0x0e, 0x7d, 0xa7, 0x0a, 0x6a, 0xc8, 0xd2, + 0x4f, 0x4e, 0xab, 0xb7, 0xbb, 0x0f, 0x97, 0x3e, 0x3c, 0x8b, 0x68, 0xe2, 0xd3, 0x37, 0x06, 0x5c, + 0x1a, 0xb6, 0x5c, 0x92, 0x0f, 0x4e, 0x50, 0x7b, 0xdc, 0x6b, 0xa2, 0x74, 0xf7, 0xf4, 0x82, 0xfd, + 0x29, 0x1b, 0xdc, 0x87, 0xc8, 0x29, 0xb5, 0xa6, 0xe2, 0x73, 0xef, 0x0c, 0x92, 0x89, 0x43, 0xcf, + 0x60, 0x5c, 0xcc, 0xe9, 0xd1, 0x3d, 0x90, 0xba, 0xc2, 0x46, 0xf7, 0x40, 0x7a, 0xe4, 0x9b, 0x63, + 0xe4, 0x08, 0x0a, 0xbd, 0xf3, 0x85, 0xdc, 0x39, 0xc1, 0xd7, 0xc1, 0x81, 0x5d, 0x5a, 0x3b, 0x8d, + 0x48, 0x6c, 0xba, 0x82, 0x70, 0xad, 0x15, 0x78, 0x23, 0x44, 0x2b, 0x85, 0x64, 0x10, 0xec, 0xd0, + 0x80, 0x07, 0x3b, 0xc6, 0x67, 0x77, 0x3a, 0x2e, 0xdf, 0x8f, 0x9a, 0xe5, 0x56, 0xe0, 0xad, 0x0a, + 0xc1, 0xd5, 0x94, 0xe0, 0xaa, 0x13, 0xba, 0xab, 0x89, 0xf0, 0xaa, 0xfc, 0xf9, 0x16, 0x36, 0x9b, + 0x93, 0xf2, 0xe3, 0xbd, 0xbf, 0x02, 0x00, 0x00, 0xff, 0xff, 0x68, 0x4f, 0xd2, 0xf5, 0x98, 0x13, + 0x00, 0x00, }, } diff --git a/doc/rfc/stovepipe/queue-state.md b/doc/rfc/stovepipe/queue-state.md index 3dcf68b08..c42df864a 100644 --- a/doc/rfc/stovepipe/queue-state.md +++ b/doc/rfc/stovepipe/queue-state.md @@ -52,6 +52,8 @@ C and F need no validation requests. Their ancestry relative to the boundaries d Registration starts a queue as disabled version 1, with no boundary; this covers commits before its first enablement. Missing state is an error, not disabled. If changes share a boundary, the highest version wins. Versions order changes; timestamps do not. +`Ingest` accepts optional `ENABLED` or `DISABLED` enablement and returns the applied or observed status with the request ID. Omission or `UNKNOWN` preserves state; repeating the current setting preserves its boundary and version. A change records an inclusive boundary at the commit being ingested, without backfilling earlier requests. Enablement does not pause ingestion or processing. Deploy server support before callers send decisions, since older servers ignore the field. + ## Internal state, summary and history Follow the model used for request summaries and history, while keeping the internal queue authoritative: @@ -64,9 +66,9 @@ Follow the model used for request summaries and history, while keeping the inter The queue row and summary each have a storage version for optimistic locking. The API exposes a separate enablement version, which advances only for enabled/disabled changes. Routine pipeline updates create no enablement history entries or gaps. -The transition controller captures the queue's tip and a timestamp once when preparing a change. Retries keep those values; requesting the existing state is a no-op. Application first finishes recording the previous state, then conditionally updates the queue and records the new state through `queuelog.Materializer.PersistLog`. +Ingest supplies its captured head URI to the transition controller, which pins that boundary and a timestamp when preparing a change. Replaying a prepared change preserves those values; requesting the existing state is a no-op. Application first finishes recording the previous state, then conditionally updates the queue and records the new state through `queuelog.Materializer.PersistLog`. -History is written before its summary. If either write fails after the queue update, the queue retains the missing change. Initialization and transition retries repair the read models before another enablement change can replace it. Repeating an identical change is safe, and older changes cannot move the summary backward. Concurrent pipeline updates preserve their coordination fields; competing enablement decisions are rejected as stale. +History is written before its summary. If either write fails after the queue update, the queue retains the missing change. Initialization and transition retries repair the read models before another enablement change can replace it. Repeating an identical change is safe, and older changes cannot move the summary backward. Concurrent pipeline updates preserve their coordination fields. Ingest retries competing enablement decisions against current state while preserving the captured commit boundary. A changed setting cannot move the boundary backward. ## Commit lookup and consistency @@ -85,4 +87,4 @@ Dependency and cancellation errors retain their transport classification. An unr ## Scope -This stack records enablement and exposes status lookup. It does not enforce ingestion or execution controls. Configuration freshness, administration, pause enforcement and public history access remain separate work. Validation results and last-green stay in their existing APIs. +This stack records enablement and exposes status lookup. It does not enforce ingestion or execution controls. Configuration freshness, pause enforcement and public history access remain separate work. Validation results and last-green stay in their existing APIs. diff --git a/doc/rfc/stovepipe/workflow.md b/doc/rfc/stovepipe/workflow.md index 3d54e7c9c..f385371e0 100644 --- a/doc/rfc/stovepipe/workflow.md +++ b/doc/rfc/stovepipe/workflow.md @@ -114,7 +114,7 @@ What runs is one pass per Request. It establishes whole-repository greenness, an └──────────────────────────────┘ ``` -1. **ingest** — invoked by the external poller with a **Queue name**. It asks `SourceControl` for that Queue's current head URI, mints a Request namespaced by the Queue, persists it with no recorded greenness yet, and dedups on `(Queue, head URI)` so a re-reported head is processed once. It publishes the RequestID onward. +1. **ingest** — invoked by the external poller with a **Queue name**. It asks `SourceControl` for that Queue's current head URI, mints a Request namespaced by the Queue, persists it with no recorded greenness yet, and dedups on `(Queue, head URI)` so a re-reported head is processed once. It publishes the RequestID onward. It can also record an optional [enablement decision](queue-state.md) at that head. 2. **process** — decides build strategy (incremental since last-green vs full monorepo), gates concurrent work per Queue, coalesces backlog to the latest head, publishes a **hook event** announcing that validation of the commit has begun, and publishes to `build`. See [process.md](steps/process.md). 3. **build** — runs the build-runner for the chosen scope. A flag derived from `process` decides whether to build relative to the last-green **baseline URI** (incremental) or from scratch (full). It records a build and publishes the BuildID. 4. **buildsignal** — polls until terminal, records the status, releases the build slot, persists the winning Request outcome, and advances a successful-build baseline before publishing the RequestID to `record`. Slot release and baseline advancement are separate updates, so the next admission may still select the previous baseline. @@ -128,7 +128,7 @@ An `analyze` stage is not part of the pipeline. `stovepipe/core/messagequeue` ha | Controller | In | Out | One-line role | |---|---|---|---| -| **ingest** | Queue name (from poller) | process | Resolve head URI via SourceControl, mint Request, persist (no greenness), dedup on `(Queue, head URI)` | +| **ingest** | Queue name and optional enablement | process | Resolve head URI via SourceControl, mint Request, persist (no greenness), dedup on `(Queue, head URI)` | | **process** | RequestID | build, hook topic | Build strategy, concurrency gate, backlog coalescing; announce validation start on admit → [process.md](steps/process.md) | | **build** | RequestID | buildsignal | Run the build-runner for the chosen scope; baseline = last-green URI iff incremental | | **buildsignal** | BuildID | record, buildanalysis | Poll until terminal, release capacity, persist the outcome, advance a successful baseline, and publish downstream work | diff --git a/service/stovepipe/server/handler/BUILD.bazel b/service/stovepipe/server/handler/BUILD.bazel index 7e4b8f946..7a449480d 100644 --- a/service/stovepipe/server/handler/BUILD.bazel +++ b/service/stovepipe/server/handler/BUILD.bazel @@ -10,6 +10,7 @@ go_library( "//service/stovepipe/server/mapper:go_default_library", "//stovepipe/controller:go_default_library", "//stovepipe/core/queuelog:go_default_library", + "//stovepipe/core/queuestate:go_default_library", "@org_golang_google_grpc//codes:go_default_library", "@org_golang_google_grpc//status:go_default_library", ], diff --git a/service/stovepipe/server/handler/server.go b/service/stovepipe/server/handler/server.go index d34639d3c..5ca7d9db3 100644 --- a/service/stovepipe/server/handler/server.go +++ b/service/stovepipe/server/handler/server.go @@ -20,6 +20,7 @@ import ( "errors" "github.com/uber/submitqueue/stovepipe/core/queuelog" + "github.com/uber/submitqueue/stovepipe/core/queuestate" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -68,6 +69,10 @@ func (s *StovepipeServer) Ping(ctx context.Context, req *pb.PingRequest) (*pb.Pi func (s *StovepipeServer) Ingest(ctx context.Context, req *pb.IngestRequest) (*pb.IngestResponse, error) { result, err := s.ingestController.Ingest(ctx, mapper.ProtoToIngestRequest(req)) if err != nil { + switch { + case errors.Is(err, queuestate.ErrInvalidChange), controller.IsInvalidRequest(err): + return nil, status.Error(codes.InvalidArgument, err.Error()) + } return nil, err } return mapper.IngestResultToProto(result), nil diff --git a/service/stovepipe/server/main.go b/service/stovepipe/server/main.go index 509122734..1e21b836f 100644 --- a/service/stovepipe/server/main.go +++ b/service/stovepipe/server/main.go @@ -251,9 +251,9 @@ func run() error { brf := fakeBuildRunnerFactory{} storageFty := storageFactory{backend: store} - stateTransitions := queuestate.NewTransitionController(storageFty, sourceControl, queuelog.NewMaterializer(scope)) + queueEnablementController := queuestate.NewTransitionController(storageFty, sourceControl, queuelog.NewMaterializer(scope)) for _, queue := range tenants { - if _, err := stateTransitions.Initialize(ctx, queue); err != nil { + if _, err := queueEnablementController.Initialize(ctx, queue); err != nil { return fmt.Errorf("initialize queue enablement %q: %w", queue, err) } } @@ -295,6 +295,7 @@ func run() error { materializer, registry, tenants, + queueEnablementController, ) requestHistoryController := controller.NewRequestHistoryController(logger.Sugar(), scope, storageFty) projectStatusController := controller.NewProjectStatusController(logger.Sugar(), scope, storageFty) diff --git a/service/stovepipe/server/mapper/ingest.go b/service/stovepipe/server/mapper/ingest.go index 148fc5d1b..7c1ac0781 100644 --- a/service/stovepipe/server/mapper/ingest.go +++ b/service/stovepipe/server/mapper/ingest.go @@ -25,14 +25,23 @@ import ( // ProtoToIngestRequest maps the wire IngestRequest to the entity.IngestRequest // the controller operates on. func ProtoToIngestRequest(req *pb.IngestRequest) entity.IngestRequest { - return entity.IngestRequest{ - Queue: req.GetQueue(), + result := entity.IngestRequest{Queue: req.GetQueue()} + if req.GetEnablement() != pb.QueueEnablement_QUEUE_ENABLEMENT_UNKNOWN { + result.HasEnablement = true + switch req.GetEnablement() { + case pb.QueueEnablement_QUEUE_ENABLEMENT_ENABLED: + result.Enablement = entity.QueueEnablementEnabled + case pb.QueueEnablement_QUEUE_ENABLEMENT_DISABLED: + result.Enablement = entity.QueueEnablementDisabled + } } + return result } // IngestResultToProto maps the domain result to the wire IngestResponse. func IngestResultToProto(result entity.IngestResult) *pb.IngestResponse { return &pb.IngestResponse{ - Id: result.ID, + Id: result.ID, + Enablement: queueEnablementStatusToProto(result.Enablement), } } diff --git a/service/stovepipe/server/mapper/ingest_test.go b/service/stovepipe/server/mapper/ingest_test.go index 3861049b7..886fe05ef 100644 --- a/service/stovepipe/server/mapper/ingest_test.go +++ b/service/stovepipe/server/mapper/ingest_test.go @@ -57,12 +57,12 @@ func TestIngestResultToProto(t *testing.T) { { name: "maps ID", result: entity.IngestResult{ID: "7"}, - expected: &pb.IngestResponse{Id: "7"}, + expected: &pb.IngestResponse{Id: "7", Enablement: &pb.QueueEnablementStatus{}}, }, { name: "zero value result yields empty response", result: entity.IngestResult{}, - expected: &pb.IngestResponse{}, + expected: &pb.IngestResponse{Enablement: &pb.QueueEnablementStatus{}}, }, } @@ -73,3 +73,26 @@ func TestIngestResultToProto(t *testing.T) { }) } } + +func TestIngestDecisionMapping(t *testing.T) { + for _, tc := range []struct { + name string + wire pb.QueueEnablement + state entity.QueueEnablement + }{ + {"enable", pb.QueueEnablement_QUEUE_ENABLEMENT_ENABLED, entity.QueueEnablementEnabled}, + {"disable", pb.QueueEnablement_QUEUE_ENABLEMENT_DISABLED, entity.QueueEnablementDisabled}, + {"unknown", pb.QueueEnablement_QUEUE_ENABLEMENT_UNKNOWN, entity.QueueEnablementUnknown}, + {"future enum", pb.QueueEnablement(99), entity.QueueEnablementUnknown}, + } { + t.Run(tc.name, func(t *testing.T) { + got := ProtoToIngestRequest(&pb.IngestRequest{Queue: "repo/main", Enablement: tc.wire}) + assert.Equal(t, tc.wire != pb.QueueEnablement_QUEUE_ENABLEMENT_UNKNOWN, got.HasEnablement) + assert.Equal(t, tc.state, got.Enablement) + }) + } + assert.Equal(t, entity.IngestRequest{}, ProtoToIngestRequest(nil)) + assert.False(t, ProtoToIngestRequest(&pb.IngestRequest{Enablement: pb.QueueEnablement_QUEUE_ENABLEMENT_UNKNOWN}).HasEnablement) + state := entity.QueueState{State: entity.QueueEnablementDisabled, Version: 3, EffectiveFromCommitURI: "D", ChangedAtMs: 1000} + assert.Equal(t, &pb.QueueEnablementStatus{State: pb.QueueEnablement_QUEUE_ENABLEMENT_DISABLED, Version: 3, EffectiveFromCommitUri: "D", ChangedAtMs: 1000}, IngestResultToProto(entity.IngestResult{Enablement: state}).Enablement) +} diff --git a/stovepipe/controller/BUILD.bazel b/stovepipe/controller/BUILD.bazel index cd1d37c29..701635829 100644 --- a/stovepipe/controller/BUILD.bazel +++ b/stovepipe/controller/BUILD.bazel @@ -24,6 +24,7 @@ go_library( "//platform/publish:go_default_library", "//stovepipe/core/messagequeue:go_default_library", "//stovepipe/core/queuelog:go_default_library", + "//stovepipe/core/queuestate:go_default_library", "//stovepipe/core/requestlog:go_default_library", "//stovepipe/entity:go_default_library", "//stovepipe/extension/sourcecontrol:go_default_library", @@ -57,6 +58,8 @@ go_test( "//platform/metrics:go_default_library", "//stovepipe/core/messagequeue:go_default_library", "//stovepipe/core/queuelog:go_default_library", + "//stovepipe/core/queuestate:go_default_library", + "//stovepipe/core/queuestate/mock:go_default_library", "//stovepipe/core/requestlog:go_default_library", "//stovepipe/core/requestlog/mock:go_default_library", "//stovepipe/entity:go_default_library", diff --git a/stovepipe/controller/ingest.go b/stovepipe/controller/ingest.go index 0208a5bfb..18a044af1 100644 --- a/stovepipe/controller/ingest.go +++ b/stovepipe/controller/ingest.go @@ -26,6 +26,7 @@ import ( "github.com/uber/submitqueue/platform/metrics" "github.com/uber/submitqueue/platform/publish" stovepipemq "github.com/uber/submitqueue/stovepipe/core/messagequeue" + "github.com/uber/submitqueue/stovepipe/core/queuestate" "github.com/uber/submitqueue/stovepipe/core/requestlog" "github.com/uber/submitqueue/stovepipe/entity" "github.com/uber/submitqueue/stovepipe/extension/sourcecontrol" @@ -44,14 +45,15 @@ const counterDomainRequest = "request" // the request ID onto the process stage. Ingestion is idempotent: a re-reported head resolves to // the already-minted request and republishes it only while it remains accepted. type IngestController struct { - logger *zap.SugaredLogger - metricsScope tally.Scope - counters counter.Factory - sourceControl sourcecontrol.Factory - stores storage.Factory - materializer requestlog.Materializer - registry consumer.TopicRegistry - configuredQueues map[string]struct{} + logger *zap.SugaredLogger + metricsScope tally.Scope + counters counter.Factory + sourceControl sourcecontrol.Factory + stores storage.Factory + materializer requestlog.Materializer + registry consumer.TopicRegistry + configuredQueues map[string]struct{} + queueEnablementController queuestate.TransitionController } // NewIngestController creates a new instance of the stovepipe ingest controller. It publishes @@ -65,25 +67,28 @@ func NewIngestController( materializer requestlog.Materializer, registry consumer.TopicRegistry, configuredQueues []string, + queueEnablementController queuestate.TransitionController, ) *IngestController { queueNames := make(map[string]struct{}, len(configuredQueues)) for _, queue := range configuredQueues { queueNames[queue] = struct{}{} } return &IngestController{ - logger: logger, - metricsScope: scope.SubScope("ingest_controller"), - counters: counters, - sourceControl: sourceControl, - stores: stores, - materializer: materializer, - registry: registry, - configuredQueues: queueNames, + logger: logger, + metricsScope: scope.SubScope("ingest_controller"), + counters: counters, + sourceControl: sourceControl, + stores: stores, + materializer: materializer, + registry: registry, + configuredQueues: queueNames, + queueEnablementController: queueEnablementController, } } // Ingest admits a queue's newly observed commit into the validation pipeline and returns the -// request ID validating it. +// request ID validating it, with the applied or observed enablement status. +// An optional enablement decision records coverage; it does not pause ingestion or processing. // // It is idempotent and runs to completion on every call, each step tolerant of having already // run: it resolves (or claims) the (queue, URI) mapping, ensures the Request row and accepted log @@ -110,8 +115,6 @@ func (c *IngestController) Ingest(ctx context.Context, req entity.IngestRequest) return entity.IngestResult{}, fmt.Errorf("failed to resolve storage for queue %q: %w", queue, err) } - // Resolve the queue's current head commit to its opaque URI via SourceControl. - // An unresolvable queue/ref is a caller error (unknown queue), not infrastructure. sc, err := c.sourceControl.For(sourcecontrol.Config{QueueName: queue}) if err != nil { return entity.IngestResult{}, fmt.Errorf("failed to resolve source control for queue=%s: %w", queue, err) @@ -124,6 +127,16 @@ func (c *IngestController) Ingest(ctx context.Context, req entity.IngestRequest) return entity.IngestResult{}, fmt.Errorf("failed to resolve head for queue=%s: %w", queue, err) } + var state entity.QueueState + if req.HasEnablement { + state, err = c.applyEnablementDecision(ctx, queue, req.Enablement, uri) + } else { + state, err = c.queueEnablementController.Initialize(ctx, queue) + } + if err != nil { + return entity.IngestResult{}, fmt.Errorf("resolve enablement for queue %q: %w", queue, err) + } + // The (queue, URI) mapping is the dedup gate and the source of truth for "does this head // have a request id". id, err := c.resolveID(ctx, store, queue, uri) @@ -166,7 +179,21 @@ func (c *IngestController) Ingest(ctx context.Context, req entity.IngestRequest) "state", request.State, ) - return entity.IngestResult{ID: id}, nil + return entity.IngestResult{ID: id, Enablement: state}, nil +} + +func (c *IngestController) applyEnablementDecision(ctx context.Context, queue string, desired entity.QueueEnablement, uri string) (entity.QueueState, error) { + for { + change, err := c.queueEnablementController.Prepare(ctx, queue, desired, uri) + if err != nil { + return entity.QueueState{}, err + } + applied, err := c.queueEnablementController.Apply(ctx, change) + if errors.Is(err, storage.ErrVersionMismatch) { + continue + } + return applied, err + } } // resolveID returns the request id mapped to (queue, URI), minting and claiming a new one if the diff --git a/stovepipe/controller/ingest_test.go b/stovepipe/controller/ingest_test.go index 12650fecf..835e5dfe2 100644 --- a/stovepipe/controller/ingest_test.go +++ b/stovepipe/controller/ingest_test.go @@ -28,10 +28,14 @@ import ( countermock "github.com/uber/submitqueue/platform/extension/counter/mock" mqmock "github.com/uber/submitqueue/platform/extension/messagequeue/mock" stovepipemq "github.com/uber/submitqueue/stovepipe/core/messagequeue" + "github.com/uber/submitqueue/stovepipe/core/queuelog" + "github.com/uber/submitqueue/stovepipe/core/queuestate" + queuestatemock "github.com/uber/submitqueue/stovepipe/core/queuestate/mock" "github.com/uber/submitqueue/stovepipe/core/requestlog" requestlogmock "github.com/uber/submitqueue/stovepipe/core/requestlog/mock" "github.com/uber/submitqueue/stovepipe/entity" "github.com/uber/submitqueue/stovepipe/extension/sourcecontrol" + scfake "github.com/uber/submitqueue/stovepipe/extension/sourcecontrol/fake" scmock "github.com/uber/submitqueue/stovepipe/extension/sourcecontrol/mock" "github.com/uber/submitqueue/stovepipe/extension/storage" storagemock "github.com/uber/submitqueue/stovepipe/extension/storage/mock" @@ -46,15 +50,16 @@ const ( // ingestMocks bundles the mocks an Ingest test case wires expectations on. type ingestMocks struct { - counter *countermock.MockCounter - factory *scmock.MockFactory - sc *scmock.MockSourceControl - reqStore *storagemock.MockRequestStore - uriStore *storagemock.MockRequestURIStore - queueStore *storagemock.MockQueueStore - store *storagemock.MockStorage - materializer *requestlogmock.MockMaterializer - publisher *mqmock.MockPublisher + queueEnablementController *queuestatemock.MockTransitionController + counter *countermock.MockCounter + factory *scmock.MockFactory + sc *scmock.MockSourceControl + reqStore *storagemock.MockRequestStore + uriStore *storagemock.MockRequestURIStore + queueStore *storagemock.MockQueueStore + store *storagemock.MockStorage + materializer *requestlogmock.MockMaterializer + publisher *mqmock.MockPublisher } // staticStorageFactory resolves every queue to one fixed store aggregate. @@ -73,14 +78,15 @@ func newIngestController(t *testing.T, ctrl *gomock.Controller) (*IngestControll t.Helper() m := ingestMocks{ - counter: countermock.NewMockCounter(ctrl), - factory: scmock.NewMockFactory(ctrl), - sc: scmock.NewMockSourceControl(ctrl), - reqStore: storagemock.NewMockRequestStore(ctrl), - uriStore: storagemock.NewMockRequestURIStore(ctrl), - queueStore: storagemock.NewMockQueueStore(ctrl), - materializer: requestlogmock.NewMockMaterializer(ctrl), - publisher: mqmock.NewMockPublisher(ctrl), + queueEnablementController: queuestatemock.NewMockTransitionController(ctrl), + counter: countermock.NewMockCounter(ctrl), + factory: scmock.NewMockFactory(ctrl), + sc: scmock.NewMockSourceControl(ctrl), + reqStore: storagemock.NewMockRequestStore(ctrl), + uriStore: storagemock.NewMockRequestURIStore(ctrl), + queueStore: storagemock.NewMockQueueStore(ctrl), + materializer: requestlogmock.NewMockMaterializer(ctrl), + publisher: mqmock.NewMockPublisher(ctrl), } store := storagemock.NewMockStorage(ctrl) @@ -97,7 +103,7 @@ func newIngestController(t *testing.T, ctrl *gomock.Controller) (*IngestControll }) require.NoError(t, err) - c := NewIngestController(zap.NewNop().Sugar(), tally.NewTestScope("test", nil), staticCounterFactory{counter: m.counter}, m.factory, staticStorageFactory{store: store}, m.materializer, registry, []string{testQueue}) + c := NewIngestController(zap.NewNop().Sugar(), tally.NewTestScope("test", nil), staticCounterFactory{counter: m.counter}, m.factory, staticStorageFactory{store: store}, m.materializer, registry, []string{testQueue}, m.queueEnablementController) return c, m } @@ -119,10 +125,10 @@ func expectMaterializeAccepted(m ingestMocks, id string) { ).Return(nil) } -// expectResolve wires the SourceControl factory + Latest happy path returning testURI. -func expectResolve(m ingestMocks) { +func expectHeadAndEnablement(m ingestMocks) { m.factory.EXPECT().For(sourcecontrol.Config{QueueName: testQueue}).Return(m.sc, nil) m.sc.EXPECT().Latest(gomock.Any()).Return(testURI, nil) + m.queueEnablementController.EXPECT().Initialize(gomock.Any(), testQueue).Return(entity.QueueState{Queue: testQueue, State: entity.QueueEnablementDisabled, Version: 1}, nil) } // expectAdvanceLatestRequestID wires Get + Create + Update for queue.latest_request_id. @@ -172,7 +178,7 @@ func TestIngestController_Ingest(t *testing.T) { name: "happy path mints persists and publishes", queue: testQueue, setup: func(m ingestMocks) { - expectResolve(m) + expectHeadAndEnablement(m) m.uriStore.EXPECT().GetIDByURI(gomock.Any(), testURI).Return("", storage.ErrNotFound) m.counter.EXPECT().Next(gomock.Any(), counterDomainRequest).Return(int64(7), nil) m.uriStore.EXPECT().Create(gomock.Any(), testURI, "7").Return(nil) @@ -188,7 +194,7 @@ func TestIngestController_Ingest(t *testing.T) { name: "legacy latest pointer prevents advancement and publish", queue: testQueue, setup: func(m ingestMocks) { - expectResolve(m) + expectHeadAndEnablement(m) m.uriStore.EXPECT().GetIDByURI(gomock.Any(), testURI).Return("", storage.ErrNotFound) m.counter.EXPECT().Next(gomock.Any(), counterDomainRequest).Return(int64(1), nil) m.uriStore.EXPECT().Create(gomock.Any(), testURI, "1").Return(nil) @@ -205,7 +211,7 @@ func TestIngestController_Ingest(t *testing.T) { name: "retry rejects legacy latest pointer", queue: testQueue, setup: func(m ingestMocks) { - expectResolve(m) + expectHeadAndEnablement(m) m.uriStore.EXPECT().GetIDByURI(gomock.Any(), testURI).Return("1", nil) m.reqStore.EXPECT().Get(gomock.Any(), "1").Return(acceptedRequest("1"), nil) expectMaterializeAccepted(m, "1") @@ -219,7 +225,7 @@ func TestIngestController_Ingest(t *testing.T) { name: "dedup with existing accepted request republishes without minting", queue: testQueue, setup: func(m ingestMocks) { - expectResolve(m) + expectHeadAndEnablement(m) m.uriStore.EXPECT().GetIDByURI(gomock.Any(), testURI).Return("3", nil) m.reqStore.EXPECT().Get(gomock.Any(), "3").Return(acceptedRequest("3"), nil) expectMaterializeAccepted(m, "3") @@ -232,7 +238,7 @@ func TestIngestController_Ingest(t *testing.T) { name: "heals when uri mapped but request missing", queue: testQueue, setup: func(m ingestMocks) { - expectResolve(m) + expectHeadAndEnablement(m) m.uriStore.EXPECT().GetIDByURI(gomock.Any(), testURI).Return("3", nil) m.reqStore.EXPECT().Get(gomock.Any(), "3").Return(entity.Request{}, storage.ErrNotFound) m.reqStore.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) @@ -246,7 +252,7 @@ func TestIngestController_Ingest(t *testing.T) { name: "dedup race returns winner id and completes", queue: testQueue, setup: func(m ingestMocks) { - expectResolve(m) + expectHeadAndEnablement(m) m.uriStore.EXPECT().GetIDByURI(gomock.Any(), testURI).Return("", storage.ErrNotFound) m.counter.EXPECT().Next(gomock.Any(), counterDomainRequest).Return(int64(7), nil) m.uriStore.EXPECT().Create(gomock.Any(), testURI, "7").Return(storage.ErrAlreadyExists) @@ -295,7 +301,7 @@ func TestIngestController_Ingest(t *testing.T) { name: "counter error", queue: testQueue, setup: func(m ingestMocks) { - expectResolve(m) + expectHeadAndEnablement(m) m.uriStore.EXPECT().GetIDByURI(gomock.Any(), testURI).Return("", storage.ErrNotFound) m.counter.EXPECT().Next(gomock.Any(), gomock.Any()).Return(int64(0), errors.New("counter unavailable")) }, @@ -305,7 +311,7 @@ func TestIngestController_Ingest(t *testing.T) { name: "non-positive counter value", queue: testQueue, setup: func(m ingestMocks) { - expectResolve(m) + expectHeadAndEnablement(m) m.uriStore.EXPECT().GetIDByURI(gomock.Any(), testURI).Return("", storage.ErrNotFound) m.counter.EXPECT().Next(gomock.Any(), gomock.Any()).Return(int64(0), nil) }, @@ -315,7 +321,7 @@ func TestIngestController_Ingest(t *testing.T) { name: "request store create error", queue: testQueue, setup: func(m ingestMocks) { - expectResolve(m) + expectHeadAndEnablement(m) m.uriStore.EXPECT().GetIDByURI(gomock.Any(), testURI).Return("", storage.ErrNotFound) m.counter.EXPECT().Next(gomock.Any(), gomock.Any()).Return(int64(7), nil) m.uriStore.EXPECT().Create(gomock.Any(), testURI, gomock.Any()).Return(nil) @@ -328,7 +334,7 @@ func TestIngestController_Ingest(t *testing.T) { name: "publish error", queue: testQueue, setup: func(m ingestMocks) { - expectResolve(m) + expectHeadAndEnablement(m) m.uriStore.EXPECT().GetIDByURI(gomock.Any(), testURI).Return("", storage.ErrNotFound) m.counter.EXPECT().Next(gomock.Any(), gomock.Any()).Return(int64(7), nil) m.uriStore.EXPECT().Create(gomock.Any(), testURI, gomock.Any()).Return(nil) @@ -344,7 +350,7 @@ func TestIngestController_Ingest(t *testing.T) { name: "request log error prevents advancement and publish", queue: testQueue, setup: func(m ingestMocks) { - expectResolve(m) + expectHeadAndEnablement(m) m.uriStore.EXPECT().GetIDByURI(gomock.Any(), testURI).Return("3", nil) m.reqStore.EXPECT().Get(gomock.Any(), "3").Return(acceptedRequest("3"), nil) m.materializer.EXPECT().PersistLog( @@ -375,3 +381,252 @@ func TestIngestController_Ingest(t *testing.T) { }) } } + +const nextIngestURI = "git://repo/monorepo/main/def456" + +type ingestEnablementFixture struct { + controller *IngestController + mocks ingestMocks + queue entity.Queue + summary entity.QueueSummary + logs map[string]entity.QueueLog + head string + beforeUpdate func() + logErr, summaryErr error +} + +func newIngestEnablementFixture(t *testing.T) *ingestEnablementFixture { + t.Helper() + ctrl := gomock.NewController(t) + c, m := newIngestController(t, ctrl) + f := &ingestEnablementFixture{controller: c, mocks: m, head: testURI, + queue: entity.Queue{Name: testQueue, Version: 1, Enablement: entity.QueueEnablementDisabled, EnablementVersion: 1, EnablementChangedAtMs: 1000}, + summary: entity.QueueSummary{Queue: testQueue, State: entity.QueueEnablementDisabled, QueueStateVersion: 1, StateTimestampMs: 1000, Version: 1}, + logs: make(map[string]entity.QueueLog), + } + f.logs[entity.QueueLogID(1)] = queuelog.NewQueueStateLog(f.queue) + m.queueStore.EXPECT().Get(gomock.Any(), testQueue).DoAndReturn(func(context.Context, string) (entity.Queue, error) { + return f.queue, nil + }).AnyTimes() + m.queueStore.EXPECT().Update(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, row entity.Queue, oldVersion, newVersion int32) error { + if hook := f.beforeUpdate; hook != nil { + f.beforeUpdate = nil + hook() + } + if f.queue.Version != oldVersion { + return storage.ErrVersionMismatch + } + row.Version = newVersion + f.queue = row + return nil + }).AnyTimes() + logs := storagemock.NewMockQueueLogStore(ctrl) + m.store.EXPECT().GetQueueLogStore().Return(logs).AnyTimes() + logs.EXPECT().Get(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, id string) (entity.QueueLog, error) { + log, ok := f.logs[id] + if !ok { + return entity.QueueLog{}, storage.ErrNotFound + } + return log, nil + }).AnyTimes() + logs.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, log entity.QueueLog) error { + if f.logErr != nil { + return f.logErr + } + if _, ok := f.logs[log.ID]; ok { + return storage.ErrAlreadyExists + } + f.logs[log.ID] = log + return nil + }).AnyTimes() + summaries := storagemock.NewMockQueueSummaryStore(ctrl) + m.store.EXPECT().GetQueueSummaryStore().Return(summaries).AnyTimes() + summaries.EXPECT().Get(gomock.Any()).DoAndReturn(func(context.Context) (entity.QueueSummary, error) { + return f.summary, nil + }).AnyTimes() + summaries.EXPECT().Update(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, summary entity.QueueSummary, oldVersion, newVersion int64) error { + if f.summaryErr != nil { + return f.summaryErr + } + if f.summary.Version != oldVersion { + return storage.ErrVersionMismatch + } + summary.Version = newVersion + f.summary = summary + return nil + }).AnyTimes() + m.factory.EXPECT().For(sourcecontrol.Config{QueueName: testQueue}).Return(m.sc, nil).AnyTimes() + m.sc.EXPECT().Latest(gomock.Any()).DoAndReturn(func(context.Context) (string, error) { return f.head, nil }).AnyTimes() + history := scfake.New(sourcecontrol.Config{QueueName: testQueue}, []string{nextIngestURI, testURI}) + m.sc.EXPECT().IsAncestor(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(history.IsAncestor).AnyTimes() + c.queueEnablementController = queuestate.NewTransitionController(staticStorageFactory{store: m.store}, m.factory, queuelog.NewMaterializer(tally.NoopScope)) + return f +} + +func TestIngestEnablementDoesNotPauseWork(t *testing.T) { + for _, tc := range []struct { + name string + explicit bool + state entity.QueueEnablement + repeated bool + }{ + {"ordinary disabled", false, entity.QueueEnablementDisabled, false}, + {"ordinary enabled", false, entity.QueueEnablementEnabled, false}, + {"explicit disable", true, entity.QueueEnablementDisabled, false}, + {"explicit enable", true, entity.QueueEnablementEnabled, false}, + {"repeated disable", true, entity.QueueEnablementDisabled, true}, + {"repeated enable", true, entity.QueueEnablementEnabled, true}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newIngestEnablementFixture(t) + c, m := f.controller, f.mocks + if (!tc.explicit && tc.state == entity.QueueEnablementEnabled) || (tc.explicit && tc.state == entity.QueueEnablementDisabled) { + _, err := c.applyEnablementDecision(context.Background(), testQueue, entity.QueueEnablementEnabled, testURI) + require.NoError(t, err) + } + if tc.repeated { + _, err := c.applyEnablementDecision(context.Background(), testQueue, tc.state, testURI) + require.NoError(t, err) + f.head = nextIngestURI + } else if tc.explicit { + // The head advances during application; the request must retain the captured boundary. + f.beforeUpdate = func() { f.head = nextIngestURI } + } + uri := f.head + m.uriStore.EXPECT().GetIDByURI(gomock.Any(), uri).Return("", storage.ErrNotFound) + m.counter.EXPECT().Next(gomock.Any(), counterDomainRequest).Return(int64(7), nil) + m.uriStore.EXPECT().Create(gomock.Any(), uri, "7").Return(nil) + m.reqStore.EXPECT().Get(gomock.Any(), "7").Return(entity.Request{}, storage.ErrNotFound) + request := acceptedRequest("7") + request.URI = uri + m.reqStore.EXPECT().Create(gomock.Any(), request).Return(nil) + m.materializer.EXPECT().PersistLog(gomock.Any(), m.store, requestlog.NewRequestStateLog(request, entity.RequestOutcomeReasonUnknown)).Return(nil) + m.publisher.EXPECT().Publish(gomock.Any(), "process", gomock.Any()).Return(nil) + + result, err := c.Ingest(context.Background(), entity.IngestRequest{Queue: testQueue, HasEnablement: tc.explicit, Enablement: tc.state}) + require.NoError(t, err) + require.Equal(t, "7", result.ID) + require.Equal(t, tc.state, result.Enablement.State) + require.Equal(t, f.queue.EnablementState(), result.Enablement) + require.Equal(t, result.Enablement, f.summary.Snapshot()) + require.Equal(t, "7", f.queue.LatestRequestID) + if tc.explicit { + require.Equal(t, nextIngestURI, f.head) + require.Equal(t, testURI, result.Enablement.EffectiveFromCommitURI) + } + }) + } +} + +func TestIngestEnablementFailure(t *testing.T) { + for _, tc := range []struct { + name string + explicit bool + err error + }{ + {"invalid decision", true, queuestate.ErrInvalidChange}, + {"transition storage unavailable", true, errors.New("unavailable")}, + {"observed state unavailable", false, errors.New("unavailable")}, + } { + t.Run(tc.name, func(t *testing.T) { + c, m := newIngestController(t, gomock.NewController(t)) + m.factory.EXPECT().For(sourcecontrol.Config{QueueName: testQueue}).Return(m.sc, nil) + m.sc.EXPECT().Latest(gomock.Any()).Return(testURI, nil) + if tc.explicit { + m.queueEnablementController.EXPECT().Prepare(gomock.Any(), testQueue, entity.QueueEnablementUnknown, testURI).Return(queuestate.PreparedChange{}, tc.err) + } else { + m.queueEnablementController.EXPECT().Initialize(gomock.Any(), testQueue).Return(entity.QueueState{}, tc.err) + } + _, err := c.Ingest(context.Background(), entity.IngestRequest{Queue: testQueue, HasEnablement: tc.explicit}) + require.ErrorIs(t, err, tc.err) + }) + } +} + +func TestIngestEnablementDecisionsPreserveBoundaries(t *testing.T) { + ctx := context.Background() + f := newIngestEnablementFixture(t) + initial := f.queue.EnablementState() + disabled, err := f.controller.applyEnablementDecision(ctx, testQueue, entity.QueueEnablementDisabled, testURI) + require.NoError(t, err) + require.Equal(t, initial, disabled) + enabled, err := f.controller.applyEnablementDecision(ctx, testQueue, entity.QueueEnablementEnabled, testURI) + require.NoError(t, err) + require.Equal(t, entity.QueueEnablementEnabled, enabled.State) + require.Equal(t, int64(2), enabled.Version) + require.Equal(t, testURI, enabled.EffectiveFromCommitURI) + f.head = nextIngestURI + unchanged, err := f.controller.applyEnablementDecision(ctx, testQueue, entity.QueueEnablementEnabled, nextIngestURI) + require.NoError(t, err) + require.Equal(t, enabled, unchanged) + disabled, err = f.controller.applyEnablementDecision(ctx, testQueue, entity.QueueEnablementDisabled, nextIngestURI) + require.NoError(t, err) + require.Equal(t, entity.QueueEnablementDisabled, disabled.State) + require.Equal(t, nextIngestURI, disabled.EffectiveFromCommitURI) + require.Equal(t, int64(3), disabled.Version) + _, err = f.controller.applyEnablementDecision(ctx, testQueue, entity.QueueEnablementUnknown, nextIngestURI) + require.ErrorIs(t, err, queuestate.ErrInvalidChange) +} + +func TestIngestEnablementRetriesCompetingChanges(t *testing.T) { + for _, tc := range []struct { + name string + capturedURI string + competingURI string + wantInvalidChange bool + }{ + {"competing boundary before captured head", nextIngestURI, testURI, false}, + {"competing boundary after captured head", testURI, nextIngestURI, true}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newIngestEnablementFixture(t) + f.head = nextIngestURI + f.beforeUpdate = func() { + for _, desired := range []entity.QueueEnablement{entity.QueueEnablementEnabled, entity.QueueEnablementDisabled} { + _, err := f.controller.applyEnablementDecision(context.Background(), testQueue, desired, tc.competingURI) + require.NoError(t, err) + } + } + applied, err := f.controller.applyEnablementDecision(context.Background(), testQueue, entity.QueueEnablementEnabled, tc.capturedURI) + if tc.wantInvalidChange { + require.ErrorIs(t, err, queuestate.ErrInvalidChange) + require.Equal(t, int64(3), f.queue.EnablementVersion) + require.Equal(t, entity.QueueEnablementDisabled, f.queue.Enablement) + require.Equal(t, nextIngestURI, f.queue.EffectiveFromCommitURI) + return + } + require.NoError(t, err) + require.Equal(t, entity.QueueEnablementEnabled, applied.State) + require.Equal(t, int64(4), applied.Version) + require.Equal(t, tc.capturedURI, applied.EffectiveFromCommitURI) + require.Equal(t, applied, f.summary.Snapshot()) + }) + } +} + +func TestIngestEnablementRepairsAnAppliedTransition(t *testing.T) { + for _, stage := range []string{"history", "summary"} { + t.Run(stage, func(t *testing.T) { + ctx := context.Background() + f := newIngestEnablementFixture(t) + failure := errors.New("unavailable") + f.beforeUpdate = func() { + if stage == "history" { + f.logErr = failure + } else { + f.summaryErr = failure + } + } + _, err := f.controller.applyEnablementDecision(ctx, testQueue, entity.QueueEnablementEnabled, testURI) + require.ErrorIs(t, err, failure) + require.Equal(t, int64(2), f.queue.EnablementVersion) + f.logErr, f.summaryErr = nil, nil + f.head = nextIngestURI + applied, err := f.controller.applyEnablementDecision(ctx, testQueue, entity.QueueEnablementEnabled, nextIngestURI) + require.NoError(t, err) + require.Equal(t, int64(2), applied.Version) + require.Equal(t, testURI, applied.EffectiveFromCommitURI) + require.Equal(t, applied, f.summary.Snapshot()) + }) + } +} diff --git a/stovepipe/core/queuestate/mock/BUILD.bazel b/stovepipe/core/queuestate/mock/BUILD.bazel new file mode 100644 index 000000000..a858380d8 --- /dev/null +++ b/stovepipe/core/queuestate/mock/BUILD.bazel @@ -0,0 +1,14 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + testonly = True, + srcs = ["queuestate_mock.go"], + importpath = "github.com/uber/submitqueue/stovepipe/core/queuestate/mock", + visibility = ["//stovepipe/controller:__pkg__"], + deps = [ + "//stovepipe/core/queuestate:go_default_library", + "//stovepipe/entity:go_default_library", + "@org_uber_go_mock//gomock:go_default_library", + ], +) diff --git a/stovepipe/core/queuestate/mock/queuestate_mock.go b/stovepipe/core/queuestate/mock/queuestate_mock.go new file mode 100644 index 000000000..4187f238c --- /dev/null +++ b/stovepipe/core/queuestate/mock/queuestate_mock.go @@ -0,0 +1,88 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: queuestate.go +// +// Generated by this command: +// +// mockgen -source=queuestate.go -destination=mock/queuestate_mock.go -package=mock +// + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + reflect "reflect" + + queuestate "github.com/uber/submitqueue/stovepipe/core/queuestate" + entity "github.com/uber/submitqueue/stovepipe/entity" + gomock "go.uber.org/mock/gomock" +) + +// MockTransitionController is a mock of TransitionController interface. +type MockTransitionController struct { + ctrl *gomock.Controller + recorder *MockTransitionControllerMockRecorder + isgomock struct{} +} + +// MockTransitionControllerMockRecorder is the mock recorder for MockTransitionController. +type MockTransitionControllerMockRecorder struct { + mock *MockTransitionController +} + +// NewMockTransitionController creates a new mock instance. +func NewMockTransitionController(ctrl *gomock.Controller) *MockTransitionController { + mock := &MockTransitionController{ctrl: ctrl} + mock.recorder = &MockTransitionControllerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockTransitionController) EXPECT() *MockTransitionControllerMockRecorder { + return m.recorder +} + +// Apply mocks base method. +func (m *MockTransitionController) Apply(ctx context.Context, change queuestate.PreparedChange) (entity.QueueState, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Apply", ctx, change) + ret0, _ := ret[0].(entity.QueueState) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Apply indicates an expected call of Apply. +func (mr *MockTransitionControllerMockRecorder) Apply(ctx, change any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Apply", reflect.TypeOf((*MockTransitionController)(nil).Apply), ctx, change) +} + +// Initialize mocks base method. +func (m *MockTransitionController) Initialize(ctx context.Context, queue string) (entity.QueueState, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Initialize", ctx, queue) + ret0, _ := ret[0].(entity.QueueState) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Initialize indicates an expected call of Initialize. +func (mr *MockTransitionControllerMockRecorder) Initialize(ctx, queue any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Initialize", reflect.TypeOf((*MockTransitionController)(nil).Initialize), ctx, queue) +} + +// Prepare mocks base method. +func (m *MockTransitionController) Prepare(ctx context.Context, queue string, enablement entity.QueueEnablement, boundaryURI string) (queuestate.PreparedChange, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Prepare", ctx, queue, enablement, boundaryURI) + ret0, _ := ret[0].(queuestate.PreparedChange) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Prepare indicates an expected call of Prepare. +func (mr *MockTransitionControllerMockRecorder) Prepare(ctx, queue, enablement, boundaryURI any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Prepare", reflect.TypeOf((*MockTransitionController)(nil).Prepare), ctx, queue, enablement, boundaryURI) +} diff --git a/stovepipe/core/queuestate/queuestate.go b/stovepipe/core/queuestate/queuestate.go index 852b6862d..7ffd61b88 100644 --- a/stovepipe/core/queuestate/queuestate.go +++ b/stovepipe/core/queuestate/queuestate.go @@ -15,6 +15,8 @@ // Package queuestate applies enablement to internal queue snapshots and repairs their read models. package queuestate +//go:generate mockgen -source=queuestate.go -destination=mock/queuestate_mock.go -package=mock + import ( "context" "errors" @@ -40,11 +42,11 @@ type PreparedChange struct { // TransitionController changes internal queue enablement and repairs its history and summary. type TransitionController interface { // Initialize explicitly registers disabled enablement while preserving existing state. - Initialize(context.Context, string) (entity.QueueState, error) - // Prepare pins the enablement sequence and boundary; same-state decisions are no-ops. - Prepare(context.Context, string, entity.QueueEnablement) (PreparedChange, error) + Initialize(ctx context.Context, queue string) (state entity.QueueState, err error) + // Prepare pins the enablement sequence at the supplied commit URI; same-state decisions are no-ops. + Prepare(ctx context.Context, queue string, enablement entity.QueueEnablement, boundaryURI string) (change PreparedChange, err error) // Apply commits a prepared change; competing stale decisions are never rebased. - Apply(context.Context, PreparedChange) (entity.QueueState, error) + Apply(ctx context.Context, change PreparedChange) (state entity.QueueState, err error) } type transitionController struct { @@ -111,7 +113,7 @@ func (c *transitionController) Initialize(ctx context.Context, name string) (ent } } -func (c *transitionController) Prepare(ctx context.Context, queue string, enablement entity.QueueEnablement) (PreparedChange, error) { +func (c *transitionController) Prepare(ctx context.Context, queue string, enablement entity.QueueEnablement, boundary string) (PreparedChange, error) { if enablement != entity.QueueEnablementEnabled && enablement != entity.QueueEnablementDisabled { return PreparedChange{}, fmt.Errorf("prepare queue %q: unsupported enablement %q: %w", queue, enablement, ErrInvalidChange) } @@ -131,18 +133,14 @@ func (c *transitionController) Prepare(ctx context.Context, queue string, enable if current.Version == math.MaxInt64 { return PreparedChange{}, fmt.Errorf("prepare queue %q: enablement version %d exhausted: %w", queue, current.Version, ErrInvalidChange) } - sc, err := c.sourceControl.For(sourcecontrol.Config{QueueName: queue}) - if err != nil { - return PreparedChange{}, err - } - boundary, err := sc.Latest(ctx) - if err != nil { - return PreparedChange{}, err - } if boundary == "" || len(boundary) > 255 { return PreparedChange{}, fmt.Errorf("prepare queue %q: invalid boundary URI %q (length %d): %w", queue, boundary, len(boundary), ErrInvalidChange) } if current.EffectiveFromCommitURI != "" { + sc, err := c.sourceControl.For(sourcecontrol.Config{QueueName: queue}) + if err != nil { + return PreparedChange{}, err + } forward, err := sc.IsAncestor(ctx, current.EffectiveFromCommitURI, boundary) if err != nil { return PreparedChange{}, err diff --git a/stovepipe/core/queuestate/queuestate_test.go b/stovepipe/core/queuestate/queuestate_test.go index fe44dcf15..8c4f99cc9 100644 --- a/stovepipe/core/queuestate/queuestate_test.go +++ b/stovepipe/core/queuestate/queuestate_test.go @@ -193,7 +193,7 @@ func (f *stateFixture) setTip(tip string) { func (f *stateFixture) applyState(t *testing.T, state entity.QueueEnablement, tip string) (PreparedChange, entity.QueueState) { t.Helper() f.setTip(tip) - change, err := f.manager.Prepare(context.Background(), "repo/main", state) + change, err := f.manager.Prepare(context.Background(), "repo/main", state, tip) require.NoError(t, err) applied, err := f.manager.Apply(context.Background(), change) require.NoError(t, err) @@ -247,7 +247,7 @@ func TestStateCommittedHistoryAndPreparedReplay(t *testing.T) { func TestStateNoOpPreservesBoundaryAndReplays(t *testing.T) { f := newStateFixture() initial := initializeFixture(t, f) - noop, err := f.manager.Prepare(context.Background(), "repo/main", entity.QueueEnablementDisabled) + noop, err := f.manager.Prepare(context.Background(), "repo/main", entity.QueueEnablementDisabled, "A") require.NoError(t, err) require.Zero(t, f.source.calls) state, err := f.manager.Apply(context.Background(), noop) @@ -263,19 +263,19 @@ func TestStateNoOpPreservesBoundaryAndReplays(t *testing.T) { func TestStatePartialLogWriteBlocksNextTransition(t *testing.T) { f := newStateFixture() initializeFixture(t, f) - change, err := f.manager.Prepare(context.Background(), "repo/main", entity.QueueEnablementEnabled) + change, err := f.manager.Prepare(context.Background(), "repo/main", entity.QueueEnablementEnabled, "A") require.NoError(t, err) f.beforeUpdate = func() { f.logCreateErr = errors.New("history unavailable") } _, err = f.manager.Apply(context.Background(), change) require.ErrorIs(t, err, f.logCreateErr) require.Equal(t, int64(2), f.state.Version) require.Len(t, f.logs, 1) - _, err = f.manager.Prepare(context.Background(), "repo/main", entity.QueueEnablementDisabled) + _, err = f.manager.Prepare(context.Background(), "repo/main", entity.QueueEnablementDisabled, "A") require.ErrorIs(t, err, f.logCreateErr) require.Equal(t, int64(2), f.state.Version) f.logCreateErr = nil f.clock = 9000 - noop, err := f.manager.Prepare(context.Background(), "repo/main", entity.QueueEnablementEnabled) + noop, err := f.manager.Prepare(context.Background(), "repo/main", entity.QueueEnablementEnabled, "A") require.NoError(t, err) _, err = f.manager.Apply(context.Background(), noop) require.NoError(t, err) @@ -292,7 +292,7 @@ func TestStateUncertainWriteReconciliation(t *testing.T) { t.Run(map[bool]string{false: "failed before commit", true: "committed before connection failed"}[committed], func(t *testing.T) { f := newStateFixture() initializeFixture(t, f) - change, err := f.manager.Prepare(context.Background(), "repo/main", entity.QueueEnablementEnabled) + change, err := f.manager.Prepare(context.Background(), "repo/main", entity.QueueEnablementEnabled, "A") require.NoError(t, err) f.updateErr = errors.New("connection lost") f.commitOnError = committed @@ -320,10 +320,10 @@ func TestStateUncertainWriteReconciliation(t *testing.T) { func TestStateCompetingPreparedChanges(t *testing.T) { f := newStateFixture() initializeFixture(t, f) - first, err := f.manager.Prepare(context.Background(), "repo/main", entity.QueueEnablementEnabled) + first, err := f.manager.Prepare(context.Background(), "repo/main", entity.QueueEnablementEnabled, "A") require.NoError(t, err) f.setTip("B") - second, err := f.manager.Prepare(context.Background(), "repo/main", entity.QueueEnablementEnabled) + second, err := f.manager.Prepare(context.Background(), "repo/main", entity.QueueEnablementEnabled, "B") require.NoError(t, err) f.beforeUpdate = func() { _, err := f.manager.Apply(context.Background(), second); require.NoError(t, err) } _, err = f.manager.Apply(context.Background(), first) @@ -334,6 +334,17 @@ func TestStateCompetingPreparedChanges(t *testing.T) { require.ErrorIs(t, err, storage.ErrVersionMismatch) } +func TestStateUsesSuppliedBoundaryWhenHeadAdvances(t *testing.T) { + f := newStateFixture() + initializeFixture(t, f) + f.setTip("D") + change, err := f.manager.Prepare(context.Background(), "repo/main", entity.QueueEnablementEnabled, "A") + require.NoError(t, err) + applied, err := f.manager.Apply(context.Background(), change) + require.NoError(t, err) + require.Equal(t, "A", applied.EffectiveFromCommitURI) +} + func TestStateBoundaryAndDecisionValidation(t *testing.T) { for _, tc := range []struct { name string @@ -343,6 +354,7 @@ func TestStateBoundaryAndDecisionValidation(t *testing.T) { }{ {"equal boundary", "A", entity.QueueEnablementDisabled, nil}, {"forward boundary", "D", entity.QueueEnablementDisabled, nil}, + {"empty boundary", "", entity.QueueEnablementDisabled, ErrInvalidChange}, {"backward boundary", "before", entity.QueueEnablementDisabled, ErrInvalidChange}, {"rewritten lineage", "other", entity.QueueEnablementDisabled, sourcecontrol.ErrNotFound}, {"unknown enablement", "D", entity.QueueEnablementUnknown, ErrInvalidChange}, @@ -355,7 +367,7 @@ func TestStateBoundaryAndDecisionValidation(t *testing.T) { if tc.tip == "before" { f.source.sc = fixedLatestSource{SourceControl: scfake.New(sourcecontrol.Config{QueueName: "repo/main"}, []string{"G", "A", "before"}), tip: "before"} } - change, err := f.manager.Prepare(context.Background(), "repo/main", tc.state) + change, err := f.manager.Prepare(context.Background(), "repo/main", tc.state, tc.tip) if tc.want != nil { require.ErrorIs(t, err, tc.want) require.Equal(t, int64(2), f.state.Version) @@ -369,7 +381,7 @@ func TestStateBoundaryAndDecisionValidation(t *testing.T) { } f := newStateFixture() initializeFixture(t, f) - change, err := f.manager.Prepare(context.Background(), "repo/main", entity.QueueEnablementEnabled) + change, err := f.manager.Prepare(context.Background(), "repo/main", entity.QueueEnablementEnabled, "A") require.NoError(t, err) f.setTip("other") _, err = f.manager.Apply(context.Background(), change) @@ -406,7 +418,7 @@ func TestStateFailuresAndHistoryCorruption(t *testing.T) { require.False(t, storage.IsNotFound(err)) f.state.Version = math.MaxInt64 f.state.EffectiveFromCommitURI = "A" - _, err = f.manager.Prepare(context.Background(), "repo/main", entity.QueueEnablementEnabled) + _, err = f.manager.Prepare(context.Background(), "repo/main", entity.QueueEnablementEnabled, "A") require.ErrorIs(t, err, ErrInvalidChange) _, err = f.manager.Apply(context.Background(), PreparedChange{}) require.ErrorIs(t, err, ErrInvalidChange) @@ -427,11 +439,13 @@ func TestStateDependencyFailuresRemainVisible(t *testing.T) { f.createErr = nil initializeFixture(t, f) f.getErr = failure - _, err = f.manager.Prepare(context.Background(), "repo/main", entity.QueueEnablementEnabled) + _, err = f.manager.Prepare(context.Background(), "repo/main", entity.QueueEnablementEnabled, "A") require.ErrorIs(t, err, failure) f.getErr = nil + change, err := f.manager.Prepare(context.Background(), "repo/main", entity.QueueEnablementEnabled, "A") + require.NoError(t, err) f.source.err = failure - _, err = f.manager.Prepare(context.Background(), "repo/main", entity.QueueEnablementEnabled) + _, err = f.manager.Apply(context.Background(), change) require.ErrorIs(t, err, failure) f.source.err = nil f.logGetErr = failure @@ -444,7 +458,7 @@ func TestStateChangesPreserveOperationalQueueUpdates(t *testing.T) { t.Run(map[bool]string{false: "between prepare and apply", true: "CAS conflict during apply"}[duringWrite], func(t *testing.T) { f := newStateFixture() initializeFixture(t, f) - change, err := f.manager.Prepare(context.Background(), "repo/main", entity.QueueEnablementEnabled) + change, err := f.manager.Prepare(context.Background(), "repo/main", entity.QueueEnablementEnabled, "A") require.NoError(t, err) mutateQueue := func() { f.queue.Version++ @@ -485,7 +499,7 @@ func TestStateInitializationAdoptsExistingOperationalQueue(t *testing.T) { func TestStateProjectionFailureDoesNotLoseAppliedState(t *testing.T) { f := newStateFixture() initial := initializeFixture(t, f) - change, err := f.manager.Prepare(context.Background(), "repo/main", entity.QueueEnablementEnabled) + change, err := f.manager.Prepare(context.Background(), "repo/main", entity.QueueEnablementEnabled, "A") require.NoError(t, err) f.beforeUpdate = func() { f.summaryUpdateErr = errors.New("projection unavailable") } _, err = f.manager.Apply(context.Background(), change) diff --git a/stovepipe/entity/ingest.go b/stovepipe/entity/ingest.go index 39c0c4591..f547a825f 100644 --- a/stovepipe/entity/ingest.go +++ b/stovepipe/entity/ingest.go @@ -19,11 +19,16 @@ package entity type IngestRequest struct { // Queue is the name of the queue whose head commit should be ingested. Queue string + // HasEnablement distinguishes an explicit decision from ordinary ingestion. + HasEnablement bool + // Enablement is the optional explicit enabled or disabled setting. + Enablement QueueEnablement } // IngestResult is the outcome of a successful ingest operation. type IngestResult struct { // ID is the canonical decimal request identifier assigned within the selected queue. - // The value is a canonical positive decimal string scoped by the request queue. ID string + // Enablement is the applied decision or the observed durable enablement. + Enablement QueueState } diff --git a/test/e2e/stovepipe/queue_status_test.go b/test/e2e/stovepipe/queue_status_test.go index dfd2504f4..bee057b7a 100644 --- a/test/e2e/stovepipe/queue_status_test.go +++ b/test/e2e/stovepipe/queue_status_test.go @@ -43,7 +43,7 @@ func (queueStateSourceFactory) For(cfg sourcecontrol.Config) (sourcecontrol.Sour return scfake.New(cfg, []string{"git://" + cfg.QueueName + "/HEAD"}), nil } -func (s *StovepipeE2ESuite) TestQueueStatusWithoutValidation() { +func (s *StovepipeE2ESuite) TestQueueStatusViaIngest() { t := s.T() queue := queueStateTestQueue uri := "git://" + queue + "/HEAD" @@ -53,32 +53,48 @@ func (s *StovepipeE2ESuite) TestQueueStatusWithoutValidation() { require.Equal(t, pb.QueueEnablement_QUEUE_ENABLEMENT_DISABLED, initial.Enablement.State) require.Equal(t, int64(1), initial.Enablement.Version) require.Positive(t, initial.Enablement.ChangedAtMs) + ordinary, err := s.client.Ingest(s.ctx, &pb.IngestRequest{Queue: queue}) + require.NoError(t, err) + require.NotEmpty(t, ordinary.Id) + require.Equal(t, initial.Enablement, ordinary.Enablement) manager := queuestate.NewTransitionController(queueStateStorageFactory{backend: s.appStorage}, queueStateSourceFactory{}, queuelog.NewMaterializer(tally.NoopScope)) for _, tc := range []struct { name string - state entity.QueueEnablement wire pb.QueueEnablement version int64 }{ - {"enable", entity.QueueEnablementEnabled, pb.QueueEnablement_QUEUE_ENABLEMENT_ENABLED, 2}, - {"disable", entity.QueueEnablementDisabled, pb.QueueEnablement_QUEUE_ENABLEMENT_DISABLED, 3}, - {"reenable", entity.QueueEnablementEnabled, pb.QueueEnablement_QUEUE_ENABLEMENT_ENABLED, 4}, + {"enable", pb.QueueEnablement_QUEUE_ENABLEMENT_ENABLED, 2}, + {"disable", pb.QueueEnablement_QUEUE_ENABLEMENT_DISABLED, 3}, + {"reenable", pb.QueueEnablement_QUEUE_ENABLEMENT_ENABLED, 4}, } { s.Run(tc.name, func() { t := s.T() - change, err := manager.Prepare(s.ctx, queue, tc.state) + request := &pb.IngestRequest{Queue: queue, Enablement: tc.wire} + applied, err := s.client.Ingest(s.ctx, request) require.NoError(t, err) - applied, err := manager.Apply(s.ctx, change) + require.NotNil(t, applied.Enablement) + require.Equal(t, tc.wire, applied.Enablement.State) + require.Equal(t, tc.version, applied.Enablement.Version) + require.Equal(t, ordinary.Id, applied.Id) + retry, err := s.client.Ingest(s.ctx, request) require.NoError(t, err) + require.Equal(t, applied, retry) result, err := s.client.GetQueueStatus(s.ctx, &pb.GetQueueStatusRequest{Queue: queue, ChangeUri: uri}) require.NoError(t, err) require.NotNil(t, result.Enablement) require.Equal(t, tc.version, result.Enablement.Version) require.Equal(t, tc.wire, result.Enablement.State) require.Equal(t, uri, result.Enablement.EffectiveFromCommitUri) - require.Equal(t, applied.ChangedAtMs, result.Enablement.ChangedAtMs) + require.Equal(t, applied.Enablement.ChangedAtMs, result.Enablement.ChangedAtMs) }) } + unchanged, err := s.client.Ingest(s.ctx, &pb.IngestRequest{Queue: queue, Enablement: pb.QueueEnablement_QUEUE_ENABLEMENT_UNKNOWN}) + require.NoError(t, err) + require.Equal(t, ordinary.Id, unchanged.Id) + require.Equal(t, pb.QueueEnablement_QUEUE_ENABLEMENT_ENABLED, unchanged.Enablement.State) + require.Equal(t, int64(4), unchanged.Enablement.Version) + _, err = s.client.Ingest(s.ctx, &pb.IngestRequest{Queue: queue, Enablement: pb.QueueEnablement(99)}) + require.Equal(t, codes.InvalidArgument, status.Code(err)) _, err = s.db.ExecContext(s.ctx, "DELETE FROM queue_log WHERE queue = ? AND log_id = ?", queue, entity.QueueLogID(4)) require.NoError(t, err) result, err := s.client.GetQueueStatus(s.ctx, &pb.GetQueueStatusRequest{Queue: queue}) @@ -96,7 +112,7 @@ func (s *StovepipeE2ESuite) TestQueueStatusWithoutValidation() { require.Equal(t, entity.QueueEnablementEnabled, repaired.State) var count int require.NoError(t, s.db.QueryRowContext(s.ctx, "SELECT COUNT(*) FROM request WHERE queue = ?", queue).Scan(&count)) - require.Zero(t, count) + require.Equal(t, 1, count) } func (s *StovepipeE2ESuite) TestQueueStatusErrors() { diff --git a/test/integration/stovepipe/extension/storage/mysql/queue_state_transitions_test.go b/test/integration/stovepipe/extension/storage/mysql/queue_state_transitions_test.go index fc127fccc..07f00cbe7 100644 --- a/test/integration/stovepipe/extension/storage/mysql/queue_state_transitions_test.go +++ b/test/integration/stovepipe/extension/storage/mysql/queue_state_transitions_test.go @@ -42,7 +42,7 @@ func testQueueStateTransitions(t *testing.T, ctx context.Context, db *sql.DB, ba manager := queuestate.NewTransitionController(mysqlFactory{backend: backend}, source, queuelog.NewMaterializer(tally.NoopScope)) _, err := manager.Initialize(ctx, queue) require.NoError(t, err) - enable, err := manager.Prepare(ctx, queue, entity.QueueEnablementEnabled) + enable, err := manager.Prepare(ctx, queue, entity.QueueEnablementEnabled, "A") require.NoError(t, err) enabled, err := manager.Apply(ctx, enable) require.NoError(t, err) @@ -51,7 +51,7 @@ func testQueueStateTransitions(t *testing.T, ctx context.Context, db *sql.DB, ba _, err = db.ExecContext(ctx, "DELETE FROM queue_log WHERE queue = ? AND log_id = ?", queue, entity.QueueLogID(2)) require.NoError(t, err) source.sc = scfake.New(sourcecontrol.Config{QueueName: queue}, []string{"B", "A", "before"}) - disable, err := manager.Prepare(ctx, queue, entity.QueueEnablementDisabled) + disable, err := manager.Prepare(ctx, queue, entity.QueueEnablementDisabled, "B") require.NoError(t, err) bound, err := backend.For(queue) require.NoError(t, err) diff --git a/test/integration/stovepipe/suite_test.go b/test/integration/stovepipe/suite_test.go index 7f483e9b5..ea868f694 100644 --- a/test/integration/stovepipe/suite_test.go +++ b/test/integration/stovepipe/suite_test.go @@ -123,7 +123,7 @@ func (s *StovepipeIntegrationSuite) TestIngestAPI() { const queue = "monorepo/main" - resp, err := s.client.Ingest(s.ctx, &pb.IngestRequest{Queue: queue}) + resp, err := s.client.Ingest(s.ctx, &pb.IngestRequest{Queue: queue, Enablement: pb.QueueEnablement_QUEUE_ENABLEMENT_ENABLED}) require.NoError(t, err, "Ingest failed") require.NotEmpty(t, resp.Id, "minted request id should not be empty") id := resp.Id diff --git a/web/api/src/gen/api/stovepipe/proto/stovepipe_pb.ts b/web/api/src/gen/api/stovepipe/proto/stovepipe_pb.ts index 2432f9341..5f59064af 100644 --- a/web/api/src/gen/api/stovepipe/proto/stovepipe_pb.ts +++ b/web/api/src/gen/api/stovepipe/proto/stovepipe_pb.ts @@ -24,7 +24,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file api/stovepipe/proto/stovepipe.proto. */ export const file_api_stovepipe_proto_stovepipe: GenFile = /*@__PURE__*/ - fileDesc("CiNhcGkvc3RvdmVwaXBlL3Byb3RvL3N0b3ZlcGlwZS5wcm90bxIadWJlci5zdWJtaXRxdWV1ZS5zdG92ZXBpcGUiHgoLUGluZ1JlcXVlc3QSDwoHbWVzc2FnZRgBIAEoCSJaCgxQaW5nUmVzcG9uc2USDwoHbWVzc2FnZRgBIAEoCRIUCgxzZXJ2aWNlX25hbWUYAiABKAkSEQoJdGltZXN0YW1wGAMgASgDEhAKCGhvc3RuYW1lGAQgASgJIh4KDUluZ2VzdFJlcXVlc3QSDQoFcXVldWUYASABKAkiHAoOSW5nZXN0UmVzcG9uc2USCgoCaWQYASABKAkiQQocR2V0UmVxdWVzdEhpc3RvcnlCeUlEUmVxdWVzdBINCgVxdWV1ZRgBIAEoCRISCgpyZXF1ZXN0X2lkGAIgASgJIjsKHUdldFJlcXVlc3RIaXN0b3J5QnlVUklSZXF1ZXN0Eg0KBXF1ZXVlGAEgASgJEgsKA3VyaRgCIAEoCSKGAQoMSGlzdG9yeUV2ZW50EhAKCGV2ZW50X2lkGAEgASgJEhQKDHRpbWVzdGFtcF9tcxgCIAEoAxIXCg1yZXF1ZXN0X3N0YXRlGAMgASgJSAASDwoFZXZlbnQYBCABKAlIABIWCg5vdXRjb21lX3JlYXNvbhgFIAEoCUIMCgpvY2N1cnJlbmNlIqMBCg5SZXF1ZXN0SGlzdG9yeRISCgpyZXF1ZXN0X2lkGAEgASgJEjgKBmV2ZW50cxgCIAMoCzIoLnViZXIuc3VibWl0cXVldWUuc3RvdmVwaXBlLkhpc3RvcnlFdmVudBJDCg9yZXF1ZXN0X3N1bW1hcnkYAyABKAsyKi51YmVyLnN1Ym1pdHF1ZXVlLnN0b3ZlcGlwZS5SZXF1ZXN0U3VtbWFyeSKeAQodR2V0UmVxdWVzdEhpc3RvcnlCeUlEUmVzcG9uc2USOAoGZXZlbnRzGAEgAygLMigudWJlci5zdWJtaXRxdWV1ZS5zdG92ZXBpcGUuSGlzdG9yeUV2ZW50EkMKD3JlcXVlc3Rfc3VtbWFyeRgCIAEoCzIqLnViZXIuc3VibWl0cXVldWUuc3RvdmVwaXBlLlJlcXVlc3RTdW1tYXJ5Il8KHkdldFJlcXVlc3RIaXN0b3J5QnlVUklSZXNwb25zZRI9CgloaXN0b3JpZXMYASADKAsyKi51YmVyLnN1Ym1pdHF1ZXVlLnN0b3ZlcGlwZS5SZXF1ZXN0SGlzdG9yeSJ5ChtHZXRQcm9qZWN0U3RhdHVzQnlJRFJlcXVlc3QSDQoFcXVldWUYASABKAkSEgoKcmVxdWVzdF9pZBgCIAEoCRIQCghwcm9qZWN0cxgDIAMoCRIRCglwYWdlX3NpemUYBCABKAUSEgoKcGFnZV90b2tlbhgFIAEoCSJ6ChxHZXRQcm9qZWN0U3RhdHVzQnlVUklSZXF1ZXN0Eg0KBXF1ZXVlGAEgASgJEhIKCmNoYW5nZV91cmkYAiABKAkSEAoIcHJvamVjdHMYAyADKAkSEQoJcGFnZV9zaXplGAQgASgFEhIKCnBhZ2VfdG9rZW4YBSABKAkiSQoRUHJvamVjdFZhbGlkYXRpb24SDwoHcHJvamVjdBgBIAEoCRIZCg9icmVha2FnZV9kZWdyZWUYAiABKAFIAEIICgZyZXN1bHQizAIKHEdldFByb2plY3RTdGF0dXNCeUlEUmVzcG9uc2USEgoKcmVxdWVzdF9pZBgBIAEoCRINCgVxdWV1ZRgCIAEoCRISCgpjaGFuZ2VfdXJpGAMgASgJEhAKCGJhc2VfdXJpGAQgASgJEhUKDXJlcXVlc3Rfc3RhdGUYBSABKAkSFQoNdXBkYXRlZF9hdF9tcxgKIAEoAxIkChpyZXBvc2l0b3J5X2JyZWFrYWdlX2RlZ3JlZRgGIAEoAUgAEiAKGHByb2plY3RfcmVzdWx0c19jb21wbGV0ZRgHIAEoCBI/Cghwcm9qZWN0cxgIIAMoCzItLnViZXIuc3VibWl0cXVldWUuc3RvdmVwaXBlLlByb2plY3RWYWxpZGF0aW9uEhcKD25leHRfcGFnZV90b2tlbhgJIAEoCUITChFyZXBvc2l0b3J5X3Jlc3VsdCLNAgodR2V0UHJvamVjdFN0YXR1c0J5VVJJUmVzcG9uc2USEgoKcmVxdWVzdF9pZBgBIAEoCRINCgVxdWV1ZRgCIAEoCRISCgpjaGFuZ2VfdXJpGAMgASgJEhAKCGJhc2VfdXJpGAQgASgJEhUKDXJlcXVlc3Rfc3RhdGUYBSABKAkSFQoNdXBkYXRlZF9hdF9tcxgKIAEoAxIkChpyZXBvc2l0b3J5X2JyZWFrYWdlX2RlZ3JlZRgGIAEoAUgAEiAKGHByb2plY3RfcmVzdWx0c19jb21wbGV0ZRgHIAEoCBI/Cghwcm9qZWN0cxgIIAMoCzItLnViZXIuc3VibWl0cXVldWUuc3RvdmVwaXBlLlByb2plY3RWYWxpZGF0aW9uEhcKD25leHRfcGFnZV90b2tlbhgJIAEoCUITChFyZXBvc2l0b3J5X3Jlc3VsdCK9AQoOUmVxdWVzdFN1bW1hcnkSEgoKcmVxdWVzdF9pZBgBIAEoCRINCgVxdWV1ZRgCIAEoCRISCgpjaGFuZ2VfdXJpGAMgASgJEhAKCGJhc2VfdXJpGAQgASgJEhUKDXJlcXVlc3Rfc3RhdGUYBSABKAkSGwoTc3RhdGVfdXBkYXRlZF9hdF9tcxgGIAEoAxIWCg5hY2NlcHRlZF9hdF9tcxgHIAEoAxIWCg5vdXRjb21lX3JlYXNvbhgIIAEoCSK0AQoLTGlzdFJlcXVlc3QSDQoFcXVldWUYASABKAkSIQoXYWNjZXB0ZWRfYXRfb3JfYWZ0ZXJfbXMYAiABKANIABIcChJhY2NlcHRlZF9iZWZvcmVfbXMYAyABKANIARIRCglwYWdlX3NpemUYBCABKAUSEgoKcGFnZV90b2tlbhgFIAEoCUIWChRhY2NlcHRlZF9sb3dlcl9ib3VuZEIWChRhY2NlcHRlZF91cHBlcl9ib3VuZCJlCgxMaXN0UmVzcG9uc2USPAoIcmVxdWVzdHMYASADKAsyKi51YmVyLnN1Ym1pdHF1ZXVlLnN0b3ZlcGlwZS5SZXF1ZXN0U3VtbWFyeRIXCg9uZXh0X3BhZ2VfdG9rZW4YAiABKAkiOgoVR2V0UXVldWVTdGF0dXNSZXF1ZXN0Eg0KBXF1ZXVlGAEgASgJEhIKCmNoYW5nZV91cmkYAiABKAkingEKFVF1ZXVlRW5hYmxlbWVudFN0YXR1cxIPCgd2ZXJzaW9uGAEgASgDEjoKBXN0YXRlGAIgASgOMisudWJlci5zdWJtaXRxdWV1ZS5zdG92ZXBpcGUuUXVldWVFbmFibGVtZW50EiEKGWVmZmVjdGl2ZV9mcm9tX2NvbW1pdF91cmkYAyABKAkSFQoNY2hhbmdlZF9hdF9tcxgEIAEoAyJuChZHZXRRdWV1ZVN0YXR1c1Jlc3BvbnNlEg0KBXF1ZXVlGAEgASgJEkUKCmVuYWJsZW1lbnQYAiABKAsyMS51YmVyLnN1Ym1pdHF1ZXVlLnN0b3ZlcGlwZS5RdWV1ZUVuYWJsZW1lbnRTdGF0dXMqbAoPUXVldWVFbmFibGVtZW50EhwKGFFVRVVFX0VOQUJMRU1FTlRfVU5LTk9XThAAEhwKGFFVRVVFX0VOQUJMRU1FTlRfRU5BQkxFRBABEh0KGVFVRVVFX0VOQUJMRU1FTlRfRElTQUJMRUQQAjLnBwoJU3RvdmVwaXBlElsKBFBpbmcSJy51YmVyLnN1Ym1pdHF1ZXVlLnN0b3ZlcGlwZS5QaW5nUmVxdWVzdBooLnViZXIuc3VibWl0cXVldWUuc3RvdmVwaXBlLlBpbmdSZXNwb25zZSIAEmEKBkluZ2VzdBIpLnViZXIuc3VibWl0cXVldWUuc3RvdmVwaXBlLkluZ2VzdFJlcXVlc3QaKi51YmVyLnN1Ym1pdHF1ZXVlLnN0b3ZlcGlwZS5Jbmdlc3RSZXNwb25zZSIAEo4BChVHZXRSZXF1ZXN0SGlzdG9yeUJ5SUQSOC51YmVyLnN1Ym1pdHF1ZXVlLnN0b3ZlcGlwZS5HZXRSZXF1ZXN0SGlzdG9yeUJ5SURSZXF1ZXN0GjkudWJlci5zdWJtaXRxdWV1ZS5zdG92ZXBpcGUuR2V0UmVxdWVzdEhpc3RvcnlCeUlEUmVzcG9uc2UiABKRAQoWR2V0UmVxdWVzdEhpc3RvcnlCeVVSSRI5LnViZXIuc3VibWl0cXVldWUuc3RvdmVwaXBlLkdldFJlcXVlc3RIaXN0b3J5QnlVUklSZXF1ZXN0GjoudWJlci5zdWJtaXRxdWV1ZS5zdG92ZXBpcGUuR2V0UmVxdWVzdEhpc3RvcnlCeVVSSVJlc3BvbnNlIgASiwEKFEdldFByb2plY3RTdGF0dXNCeUlEEjcudWJlci5zdWJtaXRxdWV1ZS5zdG92ZXBpcGUuR2V0UHJvamVjdFN0YXR1c0J5SURSZXF1ZXN0GjgudWJlci5zdWJtaXRxdWV1ZS5zdG92ZXBpcGUuR2V0UHJvamVjdFN0YXR1c0J5SURSZXNwb25zZSIAEo4BChVHZXRQcm9qZWN0U3RhdHVzQnlVUkkSOC51YmVyLnN1Ym1pdHF1ZXVlLnN0b3ZlcGlwZS5HZXRQcm9qZWN0U3RhdHVzQnlVUklSZXF1ZXN0GjkudWJlci5zdWJtaXRxdWV1ZS5zdG92ZXBpcGUuR2V0UHJvamVjdFN0YXR1c0J5VVJJUmVzcG9uc2UiABJbCgRMaXN0EicudWJlci5zdWJtaXRxdWV1ZS5zdG92ZXBpcGUuTGlzdFJlcXVlc3QaKC51YmVyLnN1Ym1pdHF1ZXVlLnN0b3ZlcGlwZS5MaXN0UmVzcG9uc2UiABJ5Cg5HZXRRdWV1ZVN0YXR1cxIxLnViZXIuc3VibWl0cXVldWUuc3RvdmVwaXBlLkdldFF1ZXVlU3RhdHVzUmVxdWVzdBoyLnViZXIuc3VibWl0cXVldWUuc3RvdmVwaXBlLkdldFF1ZXVlU3RhdHVzUmVzcG9uc2UiAEJlCh5jb20udWJlci5zdWJtaXRxdWV1ZS5zdG92ZXBpcGVCDlN0b3ZlcGlwZVByb3RvUAFaMWdpdGh1Yi5jb20vdWJlci9zdWJtaXRxdWV1ZS9hcGkvc3RvdmVwaXBlL3Byb3RvcGJiBnByb3RvMw"); + fileDesc("CiNhcGkvc3RvdmVwaXBlL3Byb3RvL3N0b3ZlcGlwZS5wcm90bxIadWJlci5zdWJtaXRxdWV1ZS5zdG92ZXBpcGUiHgoLUGluZ1JlcXVlc3QSDwoHbWVzc2FnZRgBIAEoCSJaCgxQaW5nUmVzcG9uc2USDwoHbWVzc2FnZRgBIAEoCRIUCgxzZXJ2aWNlX25hbWUYAiABKAkSEQoJdGltZXN0YW1wGAMgASgDEhAKCGhvc3RuYW1lGAQgASgJIl8KDUluZ2VzdFJlcXVlc3QSDQoFcXVldWUYASABKAkSPwoKZW5hYmxlbWVudBgCIAEoDjIrLnViZXIuc3VibWl0cXVldWUuc3RvdmVwaXBlLlF1ZXVlRW5hYmxlbWVudCJjCg5Jbmdlc3RSZXNwb25zZRIKCgJpZBgBIAEoCRJFCgplbmFibGVtZW50GAIgASgLMjEudWJlci5zdWJtaXRxdWV1ZS5zdG92ZXBpcGUuUXVldWVFbmFibGVtZW50U3RhdHVzIkEKHEdldFJlcXVlc3RIaXN0b3J5QnlJRFJlcXVlc3QSDQoFcXVldWUYASABKAkSEgoKcmVxdWVzdF9pZBgCIAEoCSI7Ch1HZXRSZXF1ZXN0SGlzdG9yeUJ5VVJJUmVxdWVzdBINCgVxdWV1ZRgBIAEoCRILCgN1cmkYAiABKAkihgEKDEhpc3RvcnlFdmVudBIQCghldmVudF9pZBgBIAEoCRIUCgx0aW1lc3RhbXBfbXMYAiABKAMSFwoNcmVxdWVzdF9zdGF0ZRgDIAEoCUgAEg8KBWV2ZW50GAQgASgJSAASFgoOb3V0Y29tZV9yZWFzb24YBSABKAlCDAoKb2NjdXJyZW5jZSKjAQoOUmVxdWVzdEhpc3RvcnkSEgoKcmVxdWVzdF9pZBgBIAEoCRI4CgZldmVudHMYAiADKAsyKC51YmVyLnN1Ym1pdHF1ZXVlLnN0b3ZlcGlwZS5IaXN0b3J5RXZlbnQSQwoPcmVxdWVzdF9zdW1tYXJ5GAMgASgLMioudWJlci5zdWJtaXRxdWV1ZS5zdG92ZXBpcGUuUmVxdWVzdFN1bW1hcnkingEKHUdldFJlcXVlc3RIaXN0b3J5QnlJRFJlc3BvbnNlEjgKBmV2ZW50cxgBIAMoCzIoLnViZXIuc3VibWl0cXVldWUuc3RvdmVwaXBlLkhpc3RvcnlFdmVudBJDCg9yZXF1ZXN0X3N1bW1hcnkYAiABKAsyKi51YmVyLnN1Ym1pdHF1ZXVlLnN0b3ZlcGlwZS5SZXF1ZXN0U3VtbWFyeSJfCh5HZXRSZXF1ZXN0SGlzdG9yeUJ5VVJJUmVzcG9uc2USPQoJaGlzdG9yaWVzGAEgAygLMioudWJlci5zdWJtaXRxdWV1ZS5zdG92ZXBpcGUuUmVxdWVzdEhpc3RvcnkieQobR2V0UHJvamVjdFN0YXR1c0J5SURSZXF1ZXN0Eg0KBXF1ZXVlGAEgASgJEhIKCnJlcXVlc3RfaWQYAiABKAkSEAoIcHJvamVjdHMYAyADKAkSEQoJcGFnZV9zaXplGAQgASgFEhIKCnBhZ2VfdG9rZW4YBSABKAkiegocR2V0UHJvamVjdFN0YXR1c0J5VVJJUmVxdWVzdBINCgVxdWV1ZRgBIAEoCRISCgpjaGFuZ2VfdXJpGAIgASgJEhAKCHByb2plY3RzGAMgAygJEhEKCXBhZ2Vfc2l6ZRgEIAEoBRISCgpwYWdlX3Rva2VuGAUgASgJIkkKEVByb2plY3RWYWxpZGF0aW9uEg8KB3Byb2plY3QYASABKAkSGQoPYnJlYWthZ2VfZGVncmVlGAIgASgBSABCCAoGcmVzdWx0IswCChxHZXRQcm9qZWN0U3RhdHVzQnlJRFJlc3BvbnNlEhIKCnJlcXVlc3RfaWQYASABKAkSDQoFcXVldWUYAiABKAkSEgoKY2hhbmdlX3VyaRgDIAEoCRIQCghiYXNlX3VyaRgEIAEoCRIVCg1yZXF1ZXN0X3N0YXRlGAUgASgJEhUKDXVwZGF0ZWRfYXRfbXMYCiABKAMSJAoacmVwb3NpdG9yeV9icmVha2FnZV9kZWdyZWUYBiABKAFIABIgChhwcm9qZWN0X3Jlc3VsdHNfY29tcGxldGUYByABKAgSPwoIcHJvamVjdHMYCCADKAsyLS51YmVyLnN1Ym1pdHF1ZXVlLnN0b3ZlcGlwZS5Qcm9qZWN0VmFsaWRhdGlvbhIXCg9uZXh0X3BhZ2VfdG9rZW4YCSABKAlCEwoRcmVwb3NpdG9yeV9yZXN1bHQizQIKHUdldFByb2plY3RTdGF0dXNCeVVSSVJlc3BvbnNlEhIKCnJlcXVlc3RfaWQYASABKAkSDQoFcXVldWUYAiABKAkSEgoKY2hhbmdlX3VyaRgDIAEoCRIQCghiYXNlX3VyaRgEIAEoCRIVCg1yZXF1ZXN0X3N0YXRlGAUgASgJEhUKDXVwZGF0ZWRfYXRfbXMYCiABKAMSJAoacmVwb3NpdG9yeV9icmVha2FnZV9kZWdyZWUYBiABKAFIABIgChhwcm9qZWN0X3Jlc3VsdHNfY29tcGxldGUYByABKAgSPwoIcHJvamVjdHMYCCADKAsyLS51YmVyLnN1Ym1pdHF1ZXVlLnN0b3ZlcGlwZS5Qcm9qZWN0VmFsaWRhdGlvbhIXCg9uZXh0X3BhZ2VfdG9rZW4YCSABKAlCEwoRcmVwb3NpdG9yeV9yZXN1bHQivQEKDlJlcXVlc3RTdW1tYXJ5EhIKCnJlcXVlc3RfaWQYASABKAkSDQoFcXVldWUYAiABKAkSEgoKY2hhbmdlX3VyaRgDIAEoCRIQCghiYXNlX3VyaRgEIAEoCRIVCg1yZXF1ZXN0X3N0YXRlGAUgASgJEhsKE3N0YXRlX3VwZGF0ZWRfYXRfbXMYBiABKAMSFgoOYWNjZXB0ZWRfYXRfbXMYByABKAMSFgoOb3V0Y29tZV9yZWFzb24YCCABKAkitAEKC0xpc3RSZXF1ZXN0Eg0KBXF1ZXVlGAEgASgJEiEKF2FjY2VwdGVkX2F0X29yX2FmdGVyX21zGAIgASgDSAASHAoSYWNjZXB0ZWRfYmVmb3JlX21zGAMgASgDSAESEQoJcGFnZV9zaXplGAQgASgFEhIKCnBhZ2VfdG9rZW4YBSABKAlCFgoUYWNjZXB0ZWRfbG93ZXJfYm91bmRCFgoUYWNjZXB0ZWRfdXBwZXJfYm91bmQiZQoMTGlzdFJlc3BvbnNlEjwKCHJlcXVlc3RzGAEgAygLMioudWJlci5zdWJtaXRxdWV1ZS5zdG92ZXBpcGUuUmVxdWVzdFN1bW1hcnkSFwoPbmV4dF9wYWdlX3Rva2VuGAIgASgJIjoKFUdldFF1ZXVlU3RhdHVzUmVxdWVzdBINCgVxdWV1ZRgBIAEoCRISCgpjaGFuZ2VfdXJpGAIgASgJIp4BChVRdWV1ZUVuYWJsZW1lbnRTdGF0dXMSDwoHdmVyc2lvbhgBIAEoAxI6CgVzdGF0ZRgCIAEoDjIrLnViZXIuc3VibWl0cXVldWUuc3RvdmVwaXBlLlF1ZXVlRW5hYmxlbWVudBIhChllZmZlY3RpdmVfZnJvbV9jb21taXRfdXJpGAMgASgJEhUKDWNoYW5nZWRfYXRfbXMYBCABKAMibgoWR2V0UXVldWVTdGF0dXNSZXNwb25zZRINCgVxdWV1ZRgBIAEoCRJFCgplbmFibGVtZW50GAIgASgLMjEudWJlci5zdWJtaXRxdWV1ZS5zdG92ZXBpcGUuUXVldWVFbmFibGVtZW50U3RhdHVzKmwKD1F1ZXVlRW5hYmxlbWVudBIcChhRVUVVRV9FTkFCTEVNRU5UX1VOS05PV04QABIcChhRVUVVRV9FTkFCTEVNRU5UX0VOQUJMRUQQARIdChlRVUVVRV9FTkFCTEVNRU5UX0RJU0FCTEVEEAIy5wcKCVN0b3ZlcGlwZRJbCgRQaW5nEicudWJlci5zdWJtaXRxdWV1ZS5zdG92ZXBpcGUuUGluZ1JlcXVlc3QaKC51YmVyLnN1Ym1pdHF1ZXVlLnN0b3ZlcGlwZS5QaW5nUmVzcG9uc2UiABJhCgZJbmdlc3QSKS51YmVyLnN1Ym1pdHF1ZXVlLnN0b3ZlcGlwZS5Jbmdlc3RSZXF1ZXN0GioudWJlci5zdWJtaXRxdWV1ZS5zdG92ZXBpcGUuSW5nZXN0UmVzcG9uc2UiABKOAQoVR2V0UmVxdWVzdEhpc3RvcnlCeUlEEjgudWJlci5zdWJtaXRxdWV1ZS5zdG92ZXBpcGUuR2V0UmVxdWVzdEhpc3RvcnlCeUlEUmVxdWVzdBo5LnViZXIuc3VibWl0cXVldWUuc3RvdmVwaXBlLkdldFJlcXVlc3RIaXN0b3J5QnlJRFJlc3BvbnNlIgASkQEKFkdldFJlcXVlc3RIaXN0b3J5QnlVUkkSOS51YmVyLnN1Ym1pdHF1ZXVlLnN0b3ZlcGlwZS5HZXRSZXF1ZXN0SGlzdG9yeUJ5VVJJUmVxdWVzdBo6LnViZXIuc3VibWl0cXVldWUuc3RvdmVwaXBlLkdldFJlcXVlc3RIaXN0b3J5QnlVUklSZXNwb25zZSIAEosBChRHZXRQcm9qZWN0U3RhdHVzQnlJRBI3LnViZXIuc3VibWl0cXVldWUuc3RvdmVwaXBlLkdldFByb2plY3RTdGF0dXNCeUlEUmVxdWVzdBo4LnViZXIuc3VibWl0cXVldWUuc3RvdmVwaXBlLkdldFByb2plY3RTdGF0dXNCeUlEUmVzcG9uc2UiABKOAQoVR2V0UHJvamVjdFN0YXR1c0J5VVJJEjgudWJlci5zdWJtaXRxdWV1ZS5zdG92ZXBpcGUuR2V0UHJvamVjdFN0YXR1c0J5VVJJUmVxdWVzdBo5LnViZXIuc3VibWl0cXVldWUuc3RvdmVwaXBlLkdldFByb2plY3RTdGF0dXNCeVVSSVJlc3BvbnNlIgASWwoETGlzdBInLnViZXIuc3VibWl0cXVldWUuc3RvdmVwaXBlLkxpc3RSZXF1ZXN0GigudWJlci5zdWJtaXRxdWV1ZS5zdG92ZXBpcGUuTGlzdFJlc3BvbnNlIgASeQoOR2V0UXVldWVTdGF0dXMSMS51YmVyLnN1Ym1pdHF1ZXVlLnN0b3ZlcGlwZS5HZXRRdWV1ZVN0YXR1c1JlcXVlc3QaMi51YmVyLnN1Ym1pdHF1ZXVlLnN0b3ZlcGlwZS5HZXRRdWV1ZVN0YXR1c1Jlc3BvbnNlIgBCZQoeY29tLnViZXIuc3VibWl0cXVldWUuc3RvdmVwaXBlQg5TdG92ZXBpcGVQcm90b1ABWjFnaXRodWIuY29tL3ViZXIvc3VibWl0cXVldWUvYXBpL3N0b3ZlcGlwZS9wcm90b3BiYgZwcm90bzM"); /** * PingRequest is the request for the Ping method @@ -103,6 +103,14 @@ export type IngestRequest = Message<"uber.submitqueue.stovepipe.IngestRequest"> * @generated from field: string queue = 1; */ queue: string; + + /** + * Optional ENABLED or DISABLED coverage decision, independent of pause or processing. + * Omission or UNKNOWN preserves durable enablement. + * + * @generated from field: uber.submitqueue.stovepipe.QueueEnablement enablement = 2; + */ + enablement: QueueEnablement; }; /** @@ -124,6 +132,13 @@ export type IngestResponse = Message<"uber.submitqueue.stovepipe.IngestResponse" * @generated from field: string id = 1; */ id: string; + + /** + * Applied decision, or observed state when no decision was supplied. + * + * @generated from field: uber.submitqueue.stovepipe.QueueEnablementStatus enablement = 2; + */ + enablement?: QueueEnablementStatus | undefined; }; /**