From b02589edc2718ad8b895846a9c469e50c4468018 Mon Sep 17 00:00:00 2001 From: David Justice Date: Wed, 7 Feb 2018 10:29:09 -0800 Subject: [PATCH 1/4] queue management requests functioning tests are running without panic, but still need work --- Gopkg.lock | 74 ++++--- Gopkg.toml | 18 +- Makefile | 48 ++--- cbs.go | 151 ------------- event.go | 136 ++++++++++++ helpers.go | 127 ----------- internal/test/test.go | 148 +++++++++++++ mgmt.go | 152 +++++++++++++ mgmt_test.go | 42 ++++ namespace.go | 130 +++++++++--- namespace_test.go | 68 ++++++ queue.go | 288 +++++++++++++++++++++---- queue_test.go | 481 ++++++++++++++++++++++++++++++++++++++---- receiver.go | 239 ++++++++++++++------- sender.go | 203 +++++++++++++----- servicebus.go | 344 ------------------------------ servicebus_test.go | 329 ----------------------------- session.go | 18 +- subscription.go | 254 +++++++++++----------- subscription_test.go | 211 +++++++++--------- topic.go | 302 +++++++++++++------------- topic_test.go | 257 +++++++++++----------- tracing.go | 61 ++++++ 23 files changed, 2310 insertions(+), 1771 deletions(-) delete mode 100644 cbs.go create mode 100644 event.go delete mode 100644 helpers.go create mode 100644 internal/test/test.go create mode 100644 mgmt.go create mode 100644 mgmt_test.go create mode 100644 namespace_test.go delete mode 100644 servicebus.go delete mode 100644 servicebus_test.go create mode 100644 tracing.go diff --git a/Gopkg.lock b/Gopkg.lock index 627dbb292951..9f0f8ddbb383 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -1,14 +1,33 @@ # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. +[[projects]] + branch = "sas-refactor" + name = "github.com/Azure/azure-amqp-common-go" + packages = [ + ".", + "auth", + "cbs", + "conn", + "internal", + "internal/tracing", + "log", + "rpc", + "sas", + "uuid" + ] + revision = "37a2f000ef04319ea7168a0bcdf53837cd1d2187" + source = "github.com/devigned/azure-amqp-common-go" + [[projects]] name = "github.com/Azure/azure-sdk-for-go" packages = [ "services/resources/mgmt/2017-05-10/resources", - "services/servicebus/mgmt/2017-04-01/servicebus" + "services/servicebus/mgmt/2017-04-01/servicebus", + "version" ] - revision = "eae258195456be76b2ec9ad2ee2ab63cdda365d9" - version = "v12.2.0-beta" + revision = "fad8443a79b0e755c18c3bec29a8d2bedab0b421" + version = "v15.3.0" [[projects]] name = "github.com/Azure/go-autorest" @@ -20,8 +39,8 @@ "autorest/to", "autorest/validation" ] - revision = "d4e6b95c12a08b4de2d48b45d5b4d594e5d32fab" - version = "v9.9.0" + revision = "eaa7994b2278094c904d31993d26f56324db3052" + version = "v10.8.1" [[projects]] name = "github.com/davecgh/go-spew" @@ -32,8 +51,18 @@ [[projects]] name = "github.com/dgrijalva/jwt-go" packages = ["."] - revision = "dbeaa9332f19a944acb5736b4456cfcc02140e29" - version = "v3.1.0" + revision = "06ea1031745cb8b3dab3f6a236daf2b0aa468b7e" + version = "v3.2.0" + +[[projects]] + name = "github.com/opentracing/opentracing-go" + packages = [ + ".", + "ext", + "log" + ] + revision = "1949ddbfd147afd4d964a9f00b24eb291e0e7c38" + version = "v1.0.2" [[projects]] name = "github.com/pkg/errors" @@ -47,18 +76,6 @@ revision = "792786c7400a136282c1664665ae0a8db921c6c2" version = "v1.0.0" -[[projects]] - name = "github.com/satori/go.uuid" - packages = ["."] - revision = "f58768cc1a7a7e77a3bd49e98cdd21419399b6a3" - version = "v1.2.0" - -[[projects]] - name = "github.com/sirupsen/logrus" - packages = ["."] - revision = "d682213848ed68c0a260ca37d6dd5ace8423f5ba" - version = "v1.0.4" - [[projects]] name = "github.com/stretchr/testify" packages = [ @@ -71,18 +88,9 @@ [[projects]] branch = "master" - name = "golang.org/x/crypto" - packages = ["ssh/terminal"] - revision = "1875d0a70c90e57f11972aefd42276df65e895b9" - -[[projects]] - branch = "master" - name = "golang.org/x/sys" - packages = [ - "unix", - "windows" - ] - revision = "8f27ce8a604014414f8dfffc25cbcde83a3f2216" + name = "golang.org/x/net" + packages = ["context"] + revision = "640f4622ab692b87c2f3a94265e6f579fe38263d" [[projects]] branch = "master" @@ -91,11 +99,11 @@ ".", "internal/testconn" ] - revision = "156a96cbd71de6a80dd774d60de212f31c7272c7" + revision = "1961b4812356984db063a6cb3419b70d4375383b" [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "14fc79857167a37f58387ca518cc8fe0392b6fce931ea0c216d4a42eccb3f286" + inputs-digest = "582fa2064fe97ab1f7437988259f34ad14c395b08c9660d71e0e818edadd289d" solver-name = "gps-cdcl" solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index dcd298e43ab5..46690418d6d5 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -1,6 +1,7 @@ -[[constraint]] - name = "github.com/stretchr/testify" - version = "1.2.0" + +[prune] + go-tests = true + unused-packages = true [[constraint]] name = "pack.ag/amqp" @@ -8,12 +9,9 @@ [[constraint]] name = "github.com/Azure/azure-sdk-for-go" - version = "12.2.0-beta" - -[[constraint]] - name = "github.com/sirupsen/logrus" - version = "1.0.4" + version = "15" [[constraint]] - name = "github.com/satori/go.uuid" - version = "1.2.0" + name = "github.com/Azure/azure-amqp-common-go" + branch = "sas-refactor" + source = "github.com/devigned/azure-amqp-common-go" diff --git a/Makefile b/Makefile index c0632d68ebe2..c84f960dedba 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -PACKAGE = github.com/azure/azure-service-bus-go +PACKAGE = github.com/Azure/azure-service-bus-go DATE ?= $(shell date +%FT%T%z) VERSION ?= $(shell git describe --tags --always --dirty --match=v* 2> /dev/null || \ cat $(CURDIR)/.version 2> /dev/null || echo v0) @@ -20,52 +20,43 @@ M = $(shell printf "\033[34;1m▶\033[0m") TIMEOUT = 300 .PHONY: all -all: fmt vendor lint vet | $(BASE) ; $(info $(M) building library…) @ ## Build program - $Q cd $(BASE) && $(GO) build \ - -tags release \ - -ldflags '-X $(PACKAGE)/cmd.Version=$(VERSION) -X $(PACKAGE)/cmd.BuildDate=$(DATE)' - -.PHONY: prod -prod: vendor | $(BASE) ; $(info $(M) building library…) @ ## Build program - $Q cd $(BASE) && CGO_ENABLED=0 GOOS=linux $(GO) build \ - -tags release \ - -ldflags '-X $(PACKAGE)/cmd.Version=$(VERSION) -X $(PACKAGE)/cmd.BuildDate=$(DATE)' - -$(BASE): ; $(info $(M) setting GOPATH…) - @mkdir -p $(dir $@) - @ln -sf $(CURDIR) $@ +all: fmt vendor lint vet megacheck ; $(info $(M) building library…) @ ## Build program + $Q cd $(BASE) && $(GO) build -tags release # Tools GOLINT = $(BIN)/golint -$(BIN)/golint: | $(BASE) ; $(info $(M) building golint…) +$(BIN)/golint: ; $(info $(M) building golint…) $Q go get github.com/golang/lint/golint # Tests -TEST_TARGETS := test-default test-bench test-short test-verbose test-race +TEST_TARGETS := test-default test-bench test-verbose test-race test-debug test-cover .PHONY: $(TEST_TARGETS) test-xml check test tests -test-bench: ARGS=-run=__absolutelynothing__ -bench=. ## Run benchmarks -test-short: ARGS=-short ## Run only short tests -test-verbose: ARGS=-v ## Run tests in verbose mode -test-debug: ARGS=-v -debug ## Run tests in verbose mode with debug output -test-race: ARGS=-race ## Run tests with race detector -test-cover: ARGS=-v -cover ## Run tests in verbose mode with coverage +test-bench: ARGS=-run=__absolutelynothing__ -bench=. ## Run benchmarks +test-verbose: ARGS=-v ## Run tests in verbose mode +test-debug: ARGS=-v -debug ## Run tests in verbose mode with debug output +test-race: ARGS=-race ## Run tests with race detector +test-cover: ARGS=-cover -coverprofile=cover.out -v ## Run tests in verbose mode with coverage $(TEST_TARGETS): NAME=$(MAKECMDGOALS:test-%=%) $(TEST_TARGETS): test -check test tests: cyclo lint vet vendor | $(BASE) ; $(info $(M) running $(NAME:%=% )tests…) @ ## Run tests +check test tests: cyclo lint vet vendor megacheck ; $(info $(M) running $(NAME:%=% )tests…) @ ## Run tests $Q cd $(BASE) && $(GO) test -timeout $(TIMEOUT)s $(ARGS) $(TESTPKGS) .PHONY: vet -vet: vendor | $(BASE) $(GOLINT) ; $(info $(M) running vet…) @ ## Run vet +vet: vendor | $(GOLINT) ; $(info $(M) running vet…) @ ## Run vet $Q cd $(BASE) && $(GO) vet ./... .PHONY: lint -lint: vendor | $(BASE) $(GOLINT) ; $(info $(M) running golint…) @ ## Run golint +lint: vendor | $(GOLINT) ; $(info $(M) running golint…) @ ## Run golint $Q cd $(BASE) && ret=0 && for pkg in $(PKGS); do \ test -z "$$($(GOLINT) $$pkg | tee /dev/stderr)" || ret=1 ; \ done ; exit $$ret +.PHONY: megacheck +megacheck: vendor ; $(info $(M) running megacheck…) @ ## Run megacheck + $Q cd $(BASE) && megacheck + .PHONY: fmt fmt: ; $(info $(M) running gofmt…) @ ## Run gofmt on all source files @ret=0 && for d in $$($(GO) list -f '{{.Dir}}' ./... | grep -v /vendor/); do \ @@ -75,12 +66,13 @@ fmt: ; $(info $(M) running gofmt…) @ ## Run gofmt on all source files .PHONY: cyclo cyclo: ; $(info $(M) running gocyclo...) @ ## Run gocyclo on all source files $Q cd $(BASE) && $(GOCYCLO) -over 19 $$($(GO_FILES)) + # Dependency management -Gopkg.lock: Gopkg.toml | $(BASE) ; $(info $(M) updating dependencies…) +Gopkg.lock: Gopkg.toml | ; $(info $(M) updating dependencies…) $Q cd $(BASE) && $(DEP) ensure @touch $@ -vendor: Gopkg.lock | $(BASE) ; $(info $(M) retrieving dependencies…) +vendor: Gopkg.lock | ; $(info $(M) retrieving dependencies…) $Q cd $(BASE) && $(DEP) ensure @touch $@ diff --git a/cbs.go b/cbs.go deleted file mode 100644 index ee31a121cd85..000000000000 --- a/cbs.go +++ /dev/null @@ -1,151 +0,0 @@ -package servicebus - -import ( - "context" - "fmt" - "sync" - "time" - - log "github.com/sirupsen/logrus" - "pack.ag/amqp" -) - -const ( - cbsAddress = "$cbs" - cbsReplyToPrefix = "cbs-tmp-" - cbsOperationKey = "operation" - cbsOperationPutToken = "put-token" - cbsTokenTypeKey = "type" - cbsTokenTypeJwt = "jwt" - cbsAudienceKey = "name" - cbsExpirationKey = "expiration" - cbsStatusCodeKey = "status-code" - cbsDescriptionKey = "status-description" -) - -type ( - cbsLink struct { - session *amqp.Session - receiver *amqp.Receiver - sender *amqp.Sender - clientAddress string - negotiateMu sync.Mutex - } -) - -func (sb *serviceBus) newCbsLink() (*cbsLink, error) { - conn, err := sb.connection() - if err != nil { - return nil, err - } - authSession, err := conn.NewSession() - if err != nil { - return nil, err - } - - authSender, err := authSession.NewSender(amqp.LinkTargetAddress(cbsAddress)) - if err != nil { - return nil, err - } - - cbsClientAddress := cbsReplyToPrefix + sb.name.String() - authReceiver, err := authSession.NewReceiver( - amqp.LinkSourceAddress(cbsAddress), - amqp.LinkTargetAddress(cbsClientAddress), - ) - if err != nil { - return nil, err - } - - return &cbsLink{ - sender: authSender, - receiver: authReceiver, - session: authSession, - clientAddress: cbsClientAddress, - }, nil -} - -func (sb *serviceBus) ensureCbsLink() error { - sb.cbsMu.Lock() - defer sb.cbsMu.Unlock() - - if sb.cbsLink != nil { - return nil - } - - link, err := sb.newCbsLink() - sb.cbsLink = link - return err -} - -func (sb *serviceBus) negotiateClaim(entityPath string) error { - err := sb.ensureCbsLink() - if err != nil { - return err - } - sb.cbsLink.negotiateMu.Lock() - defer sb.cbsLink.negotiateMu.Unlock() - - name := "amqp://" + sb.namespace + ".servicebus.windows.net/" + entityPath - log.Debugf("sending to: %s, expiring on: %q, via: %s", name, sb.sbToken.ExpiresOn, sb.cbsLink.clientAddress) - msg := &amqp.Message{ - Value: sb.sbToken.AccessToken, - Properties: &amqp.MessageProperties{ - ReplyTo: sb.cbsLink.clientAddress, - }, - ApplicationProperties: map[string]interface{}{ - cbsOperationKey: cbsOperationPutToken, - cbsTokenTypeKey: cbsTokenTypeJwt, - cbsAudienceKey: name, - cbsExpirationKey: sb.sbToken.ExpiresOn, - }, - } - - _, err = retry(3, 1*time.Second, func() (interface{}, error) { - log.Debugf("Attempting to negotiate cbs for %s in namespace %s", entityPath, sb.namespace) - - ctx := context.Background() - - err := sb.cbsLink.send(ctx, msg) - if err != nil { - return nil, err - } - - res, err := sb.cbsLink.receive(ctx) - if err != nil { - return nil, err - } - - statusCode, ok := res.ApplicationProperties[cbsStatusCodeKey].(int32) - if !ok { - return nil, &retryable{message: "cbs error: didn't understand the replied message status code"} - } - - description, ok := res.ApplicationProperties[cbsDescriptionKey].(string) - if !ok { - return nil, &retryable{message: "cbs error: didn't understand the replied message description"} - } - - switch { - case statusCode >= 200 && statusCode < 300: - log.Debugf("Successfully negotiated cbs for %s in namespace %s", entityPath, sb.namespace) - return res, nil - case statusCode >= 500: - log.Debugf("Re-negotiating cbs for %s in namespace %s. Received status code: %d and error: %s", entityPath, sb.namespace, statusCode, description) - return nil, &retryable{message: "cbs error: " + description} - default: - log.Debugf("Failed negotiating cbs for %s in namespace %s with error %d", entityPath, sb.namespace, statusCode) - return nil, fmt.Errorf("cbs error: failed with code %d and message: %s", statusCode, description) - } - }) - - return err -} - -func (cl *cbsLink) send(ctx context.Context, msg *amqp.Message) error { - return cl.sender.Send(ctx, msg) -} - -func (cl *cbsLink) receive(ctx context.Context) (*amqp.Message, error) { - return cl.receiver.Receive(ctx) -} diff --git a/event.go b/event.go new file mode 100644 index 000000000000..8b3f85c83dbe --- /dev/null +++ b/event.go @@ -0,0 +1,136 @@ +package servicebus + +// MIT License +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE + +import ( + "pack.ag/amqp" +) + +const ( + partitionKeyAnnotationName = "x-opt-partition-key" +) + +type ( + // Event is an Event Hubs message to be sent or received + Event struct { + Data []byte + PartitionKey *string + Properties map[string]interface{} + ID string + GroupID *string + GroupSequence *uint32 + message *amqp.Message + } + + // EventBatch is a batch of Event Hubs messages to be sent + EventBatch struct { + Events []*Event + PartitionKey *string + Properties map[string]interface{} + ID string + } +) + +// NewEventFromString builds an Event from a string message +func NewEventFromString(message string) *Event { + return NewEvent([]byte(message)) +} + +// NewEvent builds an Event from a slice of data +func NewEvent(data []byte) *Event { + return &Event{ + Data: data, + } +} + +// NewEventBatch builds an EventBatch from an array of Events +func NewEventBatch(events []*Event) *EventBatch { + return &EventBatch{ + Events: events, + } +} + +// Set implements opentracing.TextMapWriter and sets properties on the event to be propagated to the message broker +func (e *Event) Set(key, value string) { + if e.Properties == nil { + e.Properties = make(map[string]interface{}) + } + e.Properties[key] = value +} + +// ForeachKey implements the opentracing.TextMapReader and gets properties on the event to be propagated from the message broker +func (e *Event) ForeachKey(handler func(key string, val interface{}) error) error { + for key, value := range e.Properties { + err := handler(key, value) + if err != nil { + return err + } + } + return nil +} + +func (e *Event) toMsg() *amqp.Message { + msg := e.message + if msg == nil { + msg = amqp.NewMessage(e.Data) + } + + msg.Properties = &amqp.MessageProperties{ + MessageID: e.ID, + } + + if len(e.Properties) > 0 { + msg.ApplicationProperties = make(map[string]interface{}) + for key, value := range e.Properties { + msg.ApplicationProperties[key] = value + } + } + + if e.PartitionKey != nil { + msg.Annotations = make(amqp.Annotations) + msg.Annotations[partitionKeyAnnotationName] = e.PartitionKey + } + return msg +} + +func eventFromMsg(msg *amqp.Message) *Event { + return newEvent(msg.Data[0], msg) +} + +func newEvent(data []byte, msg *amqp.Message) *Event { + event := &Event{ + Data: data, + message: msg, + } + + if msg.Properties != nil { + if id, ok := msg.Properties.MessageID.(string); ok { + event.ID = id + } + } + + if msg != nil { + event.Properties = msg.ApplicationProperties + } + return event +} diff --git a/helpers.go b/helpers.go deleted file mode 100644 index 4fc13d33abed..000000000000 --- a/helpers.go +++ /dev/null @@ -1,127 +0,0 @@ -package servicebus - -import ( - "fmt" - "net/url" - "strings" - "time" -) - -type ( - // resourceID represents a parsed long-form Azure Resource Manager ID - // with the Subscription ID, Resource Group and the Provider as top- - // level fields, and other key-value pairs available via a map in the - // Path field. - resourceID struct { - SubscriptionID string - ResourceGroup string - Provider string - Path map[string]string - } -) - -// ptrBool takes a boolean and returns a pointer to that bool. For use in literal pointers, ptrBool(true) -> *bool -func ptrBool(toPtr bool) *bool { - return &toPtr -} - -// ptrString takes a string and returns a pointer to that string. For use in literal pointers, -// ptrString(fmt.Sprintf("..", foo)) -> *string -func ptrString(toPtr string) *string { - return &toPtr -} - -// durationTo8601Seconds takes a duration and returns a string period of whole seconds (int cast of float) -func durationTo8601Seconds(duration *time.Duration) *string { - return ptrString(fmt.Sprintf("PT%dS", int(duration.Seconds()))) -} - -// parseAzureResourceID converts a long-form Azure Resource Manager ID -// into a ResourceID. We make assumptions about the structure of URLs, -// which is obviously not good, but the best thing available given the -// SDK. -func parseAzureResourceID(id string) (*resourceID, error) { - idURL, err := url.ParseRequestURI(id) - if err != nil { - return nil, fmt.Errorf("cannot parse Azure Id: %s", err) - } - - path := idURL.Path - path = strings.TrimSpace(path) - path = strings.Trim(path, "/") - components := strings.Split(path, "/") - - // We should have an even number of key-value pairs. - if len(components)%2 != 0 { - return nil, fmt.Errorf("the number of path segments is not divisible by 2 in %q", path) - } - - idObj := &resourceID{ - Path: make(map[string]string, len(components)/2), - } - - // Put the constituent key-value pairs into a map - for current := 0; current < len(components); current += 2 { - key := components[current] - value := components[current+1] - - switch { - // Check key/value for empty strings. - case key == "" || value == "": - return nil, fmt.Errorf("key/value cannot be empty strings. Key: '%s', Value: '%s'", key, value) - - // Catch the subscriptionID before it can be overwritten by another "subscriptions" - // value in the ID which is the case for the Service Bus subscription resource - case idObj.SubscriptionID == "" && key == "subscriptions": - idObj.SubscriptionID = value - - // Some Azure APIs are weird and provide things in lower case... - // However it's not clear whether the casing of other elements in the URI - // matter, so we explicitly look for that case here. - case strings.EqualFold(key, "resourceGroups"): - idObj.ResourceGroup = value - - case key == "providers": - idObj.Provider = value - - default: - idObj.Path[key] = value - } - } - - // It is OK not to have a provider in the case of a resource group - switch { - case idObj.SubscriptionID == "": - return nil, fmt.Errorf("no subscription ID found in: %q", path) - case idObj.ResourceGroup == "": - return nil, fmt.Errorf("no resource group name found in: %q", path) - } - - return idObj, nil -} - -type retryable struct { - message string -} - -func (r *retryable) Error() string { - return r.message -} - -func retry(times int, delay time.Duration, action func() (interface{}, error)) (interface{}, error) { - var lastErr error - for i := 0; i < times; i++ { - item, err := action() - if err != nil { - if err, ok := err.(*retryable); ok { - lastErr = err - time.Sleep(delay) - continue - } else { - return nil, err - } - } - return item, nil - } - return nil, lastErr -} diff --git a/internal/test/test.go b/internal/test/test.go new file mode 100644 index 000000000000..5bf277879d91 --- /dev/null +++ b/internal/test/test.go @@ -0,0 +1,148 @@ +package test + +import ( + "math/rand" + "os" + "time" + + "context" + rm "github.com/Azure/azure-sdk-for-go/services/resources/mgmt/2017-05-10/resources" + sbmgmt "github.com/Azure/azure-sdk-for-go/services/servicebus/mgmt/2017-04-01/servicebus" + "github.com/Azure/go-autorest/autorest" + "github.com/Azure/go-autorest/autorest/adal" + "github.com/Azure/go-autorest/autorest/azure" + "github.com/stretchr/testify/suite" +) + +const ( + location = "eastus" + resourceGroupName = "test" +) + +type ( + // BaseSuite encapsulates a end to end test of Service Bus with build up and tear down of all SB resources + BaseSuite struct { + suite.Suite + TenantID string + SubscriptionID string + ClientID string + ClientSecret string + ConnStr string + Namespace string + Token *adal.ServicePrincipalToken + Environment azure.Environment + TagID string + } +) + +var ( + letterRunes = []rune("abcdefghijklmnopqrstuvwxyz123456789") +) + +func init() { + rand.Seed(time.Now().Unix()) +} + +// SetupSuite prepares the test suite and provisions a standard Service Bus Namespace +func (suite *BaseSuite) SetupSuite() { + suite.TenantID = mustGetEnv("AZURE_TENANT_ID") + suite.SubscriptionID = mustGetEnv("AZURE_SUBSCRIPTION_ID") + suite.ClientID = mustGetEnv("AZURE_CLIENT_ID") + suite.ClientSecret = mustGetEnv("AZURE_CLIENT_SECRET") + suite.Namespace = mustGetEnv("SERVICEBUS_NAMESPACE") + suite.ConnStr = mustGetEnv("SERVICEBUS_CONNECTION_STRING") + suite.Token = suite.servicePrincipalToken() + suite.Environment = azure.PublicCloud + suite.TagID = RandomString("tag", 10) + + err := suite.ensureProvisioned(sbmgmt.SkuTierStandard) + if err != nil { + suite.T().Fatal(err) + } +} + +// TearDownSuite destroys created resources during the run of the suite +func (suite *BaseSuite) TearDownSuite() { + // tear down queues and subscriptions maybe?? +} + +func mustGetEnv(key string) string { + v := os.Getenv(key) + if v == "" { + panic("Environment variable '" + key + "' required for integration tests.") + } + return v +} + +func (suite *BaseSuite) servicePrincipalToken() *adal.ServicePrincipalToken { + + oauthConfig, err := adal.NewOAuthConfig(azure.PublicCloud.ActiveDirectoryEndpoint, suite.TenantID) + if err != nil { + suite.T().Fatal(err) + } + + tokenProvider, err := adal.NewServicePrincipalToken(*oauthConfig, + suite.ClientID, + suite.ClientSecret, + azure.PublicCloud.ResourceManagerEndpoint) + if err != nil { + suite.T().Fatal(err) + } + + return tokenProvider +} + +func (suite *BaseSuite) getRmGroupClient() *rm.GroupsClient { + groupsClient := rm.NewGroupsClient(suite.SubscriptionID) + groupsClient.Authorizer = autorest.NewBearerAuthorizer(suite.Token) + return &groupsClient +} + +func (suite *BaseSuite) getNamespaceClient() *sbmgmt.NamespacesClient { + nsClient := sbmgmt.NewNamespacesClient(suite.SubscriptionID) + nsClient.Authorizer = autorest.NewBearerAuthorizer(suite.Token) + return &nsClient +} + +func (suite *BaseSuite) ensureProvisioned(tier sbmgmt.SkuTier) error { + groupsClient := suite.getRmGroupClient() + location := location + _, err := groupsClient.CreateOrUpdate(context.Background(), resourceGroupName, rm.Group{Location: &location}) + if err != nil { + return err + } + + nsClient := suite.getNamespaceClient() + _, err = nsClient.Get(context.Background(), resourceGroupName, suite.Namespace) + if err != nil { + ns := sbmgmt.SBNamespace{ + Sku: &sbmgmt.SBSku{ + Name: sbmgmt.SkuName(tier), + Tier: tier, + }, + Location: &location, + } + res, err := nsClient.CreateOrUpdate(context.Background(), resourceGroupName, suite.Namespace, ns) + if err != nil { + return err + } + + return res.WaitForCompletion(context.Background(), nsClient.Client) + } + + return nil +} + +// RandomName generates a random Event Hub name tagged with the suite id +func (suite *BaseSuite) RandomName(prefix string, length int) string { + return RandomString(prefix, length) + "-" + suite.TagID +} + +// RandomString generates a random string with prefix +func RandomString(prefix string, length int) string { + b := make([]rune, length) + for i := range b { + b[i] = letterRunes[rand.Intn(len(letterRunes))] + } + return prefix + string(b) +} diff --git a/mgmt.go b/mgmt.go new file mode 100644 index 000000000000..f4dfb9aaa66d --- /dev/null +++ b/mgmt.go @@ -0,0 +1,152 @@ +package servicebus + +import ( + "bytes" + "context" + "encoding/xml" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/Azure/azure-amqp-common-go/auth" + "github.com/Azure/go-autorest/autorest/date" +) + +const ( + instanceMetadataSchema = "http://www.w3.org/2001/XMLSchema-instance" + serviceBusSchema = "http://schemas.microsoft.com/netservices/2010/10/servicebus/connect" + dataServiceSchema = "http://schemas.microsoft.com/ado/2007/08/dataservices" + dataServiceMetadataSchema = "http://schemas.microsoft.com/ado/2007/08/dataservices/metadata" + atomSchema = "http://www.w3.org/2005/Atom" + applicationXML = "application/xml" +) + +type ( + // EntityManager provides CRUD functionality for Service Bus entities (Queues, Topics, Subscriptions...) + EntityManager struct { + TokenProvider auth.TokenProvider + Host string + } + + // Feed is an Atom feed which contains entries + Feed struct { + XMLName xml.Name `xml:"feed"` + ID string `xml:"id"` + Title string `xml:"title"` + Updated *date.Time `xml:"updated,omitempty"` + Entries []Entry `xml:"entry"` + } + + // Author is an Atom author used in an Entry + Author struct { + XMLName xml.Name `xml:"author"` + Name *string `xml:"name,omitempty"` + } + + // Link is an Atom link used in an Entry + Link struct { + XMLName xml.Name `xml:"link"` + Rel string `xml:"rel,attr"` + HREF string `xml:"href,attr"` + } + + // Content is a generic body for an Atom Entry + Content struct { + XMLName xml.Name `xml:"content"` + Type string `xml:"type,attr"` + Body string `xml:",innerxml"` + } +) + +// NewEntityManager creates a new instance of an EntityManager given a token provider and host +func NewEntityManager(host string, tokenProvider auth.TokenProvider) *EntityManager { + return &EntityManager{ + Host: host, + TokenProvider: tokenProvider, + } +} + +// Get performs an HTTP Get for a given entity path +func (em *EntityManager) Get(ctx context.Context, entityPath string) (*http.Response, error) { + return em.Execute(ctx, http.MethodGet, entityPath, http.NoBody) +} + +// Put performs an HTTP PUT for a given entity path and body +func (em *EntityManager) Put(ctx context.Context, entityPath string, body []byte) (*http.Response, error) { + return em.Execute(ctx, http.MethodPut, entityPath, bytes.NewReader(body)) +} + +// Delete performs an HTTP DELETE for a given entity path +func (em *EntityManager) Delete(ctx context.Context, entityPath string) (*http.Response, error) { + return em.Execute(ctx, http.MethodDelete, entityPath, http.NoBody) +} + +// Post performs an HTTP POST for a given entity path and body +func (em *EntityManager) Post(ctx context.Context, entityPath string, body []byte) (*http.Response, error) { + return em.Execute(ctx, http.MethodPost, entityPath, bytes.NewReader(body)) +} + +// Execute performs an HTTP request given a http method, path and body +func (em *EntityManager) Execute(ctx context.Context, method string, entityPath string, body io.Reader) (*http.Response, error) { + client := &http.Client{ + Timeout: 60 * time.Second, + } + req, err := http.NewRequest(method, em.Host+strings.TrimPrefix(entityPath, "/"), body) + if err != nil { + return nil, err + } + req = addAtomXMLContentType(req) + req = addAPIVersion201704(req) + req, err = em.addAuthorization(req) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + return client.Do(req) +} + +func (em *EntityManager) addAuthorization(req *http.Request) (*http.Request, error) { + signature, err := em.TokenProvider.GetToken(req.URL.String()) + if err != nil { + return nil, err + } + + req.Header.Add("Authorization", signature.Token) + return req, nil +} + +func addAtomXMLContentType(req *http.Request) *http.Request { + if req.Method != http.MethodGet && req.Method != http.MethodHead { + req.Header.Add("Content-Type", "application/atom+xml;type=entry;charset=utf-8") + } + return req +} + +func addAPIVersion201704(req *http.Request) *http.Request { + q := req.URL.Query() + q.Add("api-version", "2017-04") + req.URL.RawQuery = q.Encode() + return req +} + +func xmlDoc(content []byte) []byte { + return []byte(xml.Header + string(content)) +} + +// ptrBool takes a boolean and returns a pointer to that bool. For use in literal pointers, ptrBool(true) -> *bool +func ptrBool(toPtr bool) *bool { + return &toPtr +} + +// ptrString takes a string and returns a pointer to that string. For use in literal pointers, +// ptrString(fmt.Sprintf("..", foo)) -> *string +func ptrString(toPtr string) *string { + return &toPtr +} + +// durationTo8601Seconds takes a duration and returns a string period of whole seconds (int cast of float) +func durationTo8601Seconds(duration *time.Duration) *string { + return ptrString(fmt.Sprintf("PT%dS", int(duration.Seconds()))) +} diff --git a/mgmt_test.go b/mgmt_test.go new file mode 100644 index 000000000000..ed9a0b9ab3f2 --- /dev/null +++ b/mgmt_test.go @@ -0,0 +1,42 @@ +package servicebus + +import ( + "encoding/xml" + "time" + + "github.com/Azure/go-autorest/autorest/date" + "github.com/stretchr/testify/assert" +) + +func (suite *serviceBusSuite) TestFeedUnmarshal() { + var feed Feed + err := xml.Unmarshal([]byte(feedOfQueues), &feed) + assert.Nil(suite.T(), err) + updated, err := date.ParseTime(time.RFC3339, "2018-05-03T00:21:15Z") + assert.Nil(suite.T(), err) + assert.Equal(suite.T(), "https://sbdjtest.servicebus.windows.net/$Resources/Queues", feed.ID) + assert.Equal(suite.T(), "Queues", feed.Title) + assert.WithinDuration(suite.T(), updated, feed.Updated.ToTime(), 100*time.Millisecond) + assert.Equal(suite.T(), updated, (*feed.Updated).ToTime()) + if assert.Len(suite.T(), feed.Entries, 2) { + assert.NotNil(suite.T(), feed.Entries[0].Content) + } + +} + +func (suite *serviceBusSuite) TestEntryUnmarshal() { + var entry Entry + err := xml.Unmarshal([]byte(queueEntry1), &entry) + assert.Nil(suite.T(), err) + assert.Equal(suite.T(), "https://sbdjtest.servicebus.windows.net/foo", entry.ID) + assert.Equal(suite.T(), "foo", entry.Title) + assert.Equal(suite.T(), "sbdjtest", *entry.Author.Name) + assert.Equal(suite.T(), "https://sbdjtest.servicebus.windows.net/foo", entry.Link.HREF) + for _, item := range []string{ + `PT30S", + "0", + } { + assert.Contains(suite.T(), entry.Content.Body, item) + } +} diff --git a/namespace.go b/namespace.go index 0f7222820643..5eb4ece7a77d 100644 --- a/namespace.go +++ b/namespace.go @@ -2,48 +2,122 @@ package servicebus import ( "context" + "runtime" - mgmt "github.com/Azure/azure-sdk-for-go/services/servicebus/mgmt/2017-04-01/servicebus" - "github.com/Azure/go-autorest/autorest" - "github.com/pkg/errors" + "github.com/Azure/azure-amqp-common-go/auth" + "github.com/Azure/azure-amqp-common-go/cbs" + "github.com/Azure/azure-amqp-common-go/conn" + "github.com/Azure/azure-amqp-common-go/sas" + "github.com/Azure/go-autorest/autorest/azure" + "pack.ag/amqp" ) -func (sb *serviceBus) GetNamespace(ctx context.Context) (*mgmt.SBNamespace, error) { - client := sb.getNamespaceMgmtClient() - res, err := client.List(ctx) - if err != nil { - return nil, err +const ( + // banner = ` + // _____ _ ____ + // / ___/___ ______ __(_)________ / __ )__ _______ + // \__ \/ _ \/ ___/ | / / // ___/ _ \ / __ / / / / ___/ + // ___/ / __/ / | |/ / // /__/ __/ / /_/ / /_/ (__ ) + ///____/\___/_/ |___/_/ \___/\___/ /_____/\__,_/____/ + //` + + // Version is the semantic version number + Version = "0.0.1" + + rootUserAgent = "/golang-service-bus" + + // Megabytes is a helper for specifying MaxSizeInMegabytes and equals 1024, thus 5 GB is 5 * Megabytes + Megabytes = 1024 +) + +type ( + // Namespace provides a simplified facade over the AMQP implementation of Azure Service Bus and is the entry point + // for using Queues, Topics and Subscriptions + Namespace struct { + Name string + TokenProvider auth.TokenProvider + Environment azure.Environment } - findNamespace := func(namespaces []mgmt.SBNamespace) (*mgmt.SBNamespace, bool) { - for _, ns := range namespaces { - if *ns.Name == sb.namespace { - return &ns, true - } + // Handler is the function signature for any receiver of AMQP messages + Handler func(context.Context, *Event) error + + // NamespaceOption provides structure for configuring a new Service Bus namespace + NamespaceOption func(h *Namespace) error +) + +// NamespaceWithConnectionString configures a namespace with the information provided in a Service Bus connection string +func NamespaceWithConnectionString(connStr string) NamespaceOption { + return func(ns *Namespace) error { + parsed, err := conn.ParsedConnectionFromStr(connStr) + if err != nil { + return err } - return nil, false + if parsed.Namespace != "" { + ns.Name = parsed.Namespace + } + provider, err := sas.NewTokenProvider(sas.TokenProviderWithKey(parsed.KeyName, parsed.Key)) + if err != nil { + return err + } + ns.TokenProvider = provider + return nil } +} - ns, ok := findNamespace(res.Values()) - if ok { - return ns, nil +// NewNamespace creates a new namespace configured through NamespaceOption(s) +func NewNamespace(opts ...NamespaceOption) (*Namespace, error) { + ns := &Namespace{ + Environment: azure.PublicCloud, } - for res.NotDone() { - err := res.Next() + for _, opt := range opts { + err := opt(ns) if err != nil { return nil, err } - ns, ok := findNamespace(res.Values()) - if ok { - return ns, nil - } } - return nil, errors.New("could not find namespace") + + return ns, nil +} + +func (ns *Namespace) newConnection() (*amqp.Client, error) { + host := ns.getAMQPHostURI() + return amqp.Dial(host, + amqp.ConnSASLAnonymous(), + amqp.ConnMaxSessions(65535), + amqp.ConnProperty("product", "MSGolangClient"), + amqp.ConnProperty("version", Version), + amqp.ConnProperty("platform", runtime.GOOS), + amqp.ConnProperty("framework", runtime.Version()), + amqp.ConnProperty("user-agent", rootUserAgent), + ) } -func (sb *serviceBus) getNamespaceMgmtClient() mgmt.NamespacesClient { - client := mgmt.NewNamespacesClientWithBaseURI(sb.environment.ResourceManagerEndpoint, sb.subscriptionID) - client.Authorizer = autorest.NewBearerAuthorizer(sb.armToken) - return client +func (ns *Namespace) negotiateClaim(ctx context.Context, conn *amqp.Client, entityPath string) error { + span, ctx := ns.startSpanFromContext(ctx, "eventhub.namespace.negotiateClaim") + defer span.Finish() + + audience := ns.getEntityAudience(entityPath) + return cbs.NegotiateClaim(ctx, audience, conn, ns.TokenProvider) +} + +func (ns *Namespace) getAMQPHostURI() string { + return "amqps://" + ns.Name + "." + ns.Environment.ServiceBusEndpointSuffix + "/" +} + +func (ns *Namespace) getHTTPSHostURI() string { + return "https://" + ns.Name + "." + ns.Environment.ServiceBusEndpointSuffix + "/" +} + +func (ns *Namespace) getEntityAudience(entityPath string) string { + return ns.getAMQPHostURI() + entityPath +} + +// max provides an integer function for math.Max +func max(a, b int) int { + if a > b { + return a + } + return b } diff --git a/namespace_test.go b/namespace_test.go new file mode 100644 index 000000000000..16033a75332a --- /dev/null +++ b/namespace_test.go @@ -0,0 +1,68 @@ +package servicebus + +import ( + "context" + "os" + "strings" + "testing" + "time" + + "github.com/Azure/azure-service-bus-go/internal/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type ( + serviceBusSuite struct { + test.BaseSuite + } +) + +func TestCreateNamespaceFromConnectionString(t *testing.T) { + connStr := os.Getenv("SERVICEBUS_CONNECTION_STRING") // `Endpoint=sb://XXXX.servicebus.windows.net/;SharedAccessKeyName=XXXX;SharedAccessKey=XXXX` + ns, err := NewNamespace(NamespaceWithConnectionString(connStr)) + assert.Nil(t, err) + assert.Contains(t, connStr, ns.Name) +} + +func TestServiceBusSuite(t *testing.T) { + suite.Run(t, new(serviceBusSuite)) +} + +// TearDownSuite destroys created resources during the run of the suite +func (suite *serviceBusSuite) TearDownSuite() { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + suite.deleteAllTaggedQueues(ctx) +} + +func (suite *serviceBusSuite) deleteAllTaggedQueues(ctx context.Context) { + ns := suite.getNewSasInstance() + qm := ns.NewQueueManager() + + feed, err := qm.List(ctx) + if err != nil { + suite.T().Fatal(err) + } + + for _, entry := range feed.Entries { + if strings.HasSuffix(entry.Title, suite.TagID) { + err := qm.Delete(ctx, entry.Title) + if err != nil { + suite.T().Fatal(err) + } + } + } +} + +func (suite *serviceBusSuite) getNewSasInstance() *Namespace { + ns, err := getNewSasInstance(suite.ConnStr) + if err != nil { + suite.T().Fatal(err) + } + return ns +} + +func getNewSasInstance(connStr string) (*Namespace, error) { + return NewNamespace(NamespaceWithConnectionString(connStr)) +} diff --git a/queue.go b/queue.go index 2f9912a96da3..f56eb1767c8f 100644 --- a/queue.go +++ b/queue.go @@ -2,23 +2,97 @@ package servicebus import ( "context" + "encoding/xml" "errors" + "io/ioutil" + "sync" "time" - mgmt "github.com/Azure/azure-sdk-for-go/services/servicebus/mgmt/2017-04-01/servicebus" - "github.com/Azure/go-autorest/autorest" - log "github.com/sirupsen/logrus" + "github.com/Azure/go-autorest/autorest/date" ) type ( + // Queue represents a Service Bus Queue entity, which offers First In, First Out (FIFO) message delivery to one or + // more competing consumers. That is, messages are typically expected to be received and processed by the receivers + // in the order in which they were added to the queue, and each message is received and processed by only one + // message consumer. + Queue struct { + Name string + namespace *Namespace + sender *sender + receiver *receiver + receiverMu sync.Mutex + senderMu sync.Mutex + } + + // QueueManager provides CRUD functionality for Service Bus Queues + QueueManager struct { + *EntityManager + } + + // QueueFeed is a specialized Feed containing QueueEntries + QueueFeed struct { + *Feed + Entries []QueueEntry `xml:"entry"` + } + + // Entry is the Atom wrapper for a management request + Entry struct { + XMLName xml.Name `xml:"entry"` + ID string `xml:"id"` + Title string `xml:"title"` + Published *date.Time `xml:"published,omitempty"` + Updated *date.Time `xml:"updated,omitempty"` + Author *Author `xml:"author,omitempty"` + Link *Link `xml:"link,omitempty"` + Content *Content `xml:"content"` + DataServiceSchema string `xml:"xmlns:d,attr"` + DataServiceMetadataSchema string `xml:"xmlns:m,attr"` + AtomSchema string `xml:"xmlns,attr"` + } + + // QueueEntry is a specialized Queue Feed Entry + QueueEntry struct { + *Entry + Content *QueueContent `xml:"content"` + } + + // QueueContent is a specialized Queue body for an Atom Entry + QueueContent struct { + XMLName xml.Name `xml:"content"` + Type string `xml:"type,attr"` + QueueDescription QueueDescription `xml:"QueueDescription"` + } + + // QueueDescription is the content type for Queue management requests + QueueDescription struct { + XMLName xml.Name `xml:"QueueDescription"` + LockDuration *string `xml:"LockDuration,omitempty"` // LockDuration - ISO 8601 timespan duration of a peek-lock; that is, the amount of time that the message is locked for other receivers. The maximum value for LockDuration is 5 minutes; the default value is 1 minute. + MaxSizeInMegabytes *int32 `xml:"MaxSizeInMegabytes,omitempty"` // MaxSizeInMegabytes - The maximum size of the queue in megabytes, which is the size of memory allocated for the queue. Default is 1024. + RequiresDuplicateDetection *bool `xml:"RequiresDuplicateDetection,omitempty"` // RequiresDuplicateDetection - A value indicating if this queue requires duplicate detection. + RequiresSession *bool `xml:"RequiresSession,omitempty"` // RequiresSession - A value that indicates whether the queue supports the concept of sessions. + DefaultMessageTimeToLive *string `xml:"DefaultMessageTimeToLive,omitempty"` // DefaultMessageTimeToLive - ISO 8601 default message timespan to live value. This is the duration after which the message expires, starting from when the message is sent to Service Bus. This is the default value used when TimeToLive is not set on a message itself. + DeadLetteringOnMessageExpiration *bool `xml:"DeadLetteringOnMessageExpiration,omitempty"` // DeadLetteringOnMessageExpiration - A value that indicates whether this queue has dead letter support when a message expires. + DuplicateDetectionHistoryTimeWindow *string `xml:"DuplicateDetectionHistoryTimeWindow,omitempty"` // DuplicateDetectionHistoryTimeWindow - ISO 8601 timeSpan structure that defines the duration of the duplicate detection history. The default value is 10 minutes. + MaxDeliveryCount *int32 `xml:"MaxDeliveryCount,omitempty"` // MaxDeliveryCount - The maximum delivery count. A message is automatically deadlettered after this number of deliveries. default value is 10. + EnableBatchedOperations *bool `xml:"EnableBatchedOperations,omitempty"` // EnableBatchedOperations - Value that indicates whether server-side batched operations are enabled. + SizeInBytes *int64 `xml:"SizeInBytes,omitempty"` // SizeInBytes - The size of the queue, in bytes. + MessageCount *int64 `xml:"MessageCount,omitempty"` // MessageCount - The number of messages in the queue. + EnablePartitioning *bool `xml:"EnablePartitioning,omitempty"` + AutoDeleteOnIdle *string `xml:"AutoDeleteOnIdle,omitempty"` + EnableExpress *bool `xml:"EnableExpress,omitempty"` + InstanceMetadataSchema string `xml:"xmlns:i,attr"` + ServiceBusSchema string `xml:"xmlns,attr"` + } + // QueueOption represents named options for assisting queue creation - QueueOption func(queue *mgmt.SBQueue) error + QueueOption func(queue *QueueDescription) error ) /* QueueWithPartitioning ensure the created queue will be a partitioned queue. Partitioned queues offer increased storage and availability compared to non-partitioned queues with the trade-off of requiring the following to ensure -FIFO message retrieval: +FIFO message retreival: SessionId. If a message has the SessionId property set, then Service Bus uses the SessionId property as the partition key. This way, all messages that belong to the same session are assigned to the same fragment and handled @@ -36,7 +110,7 @@ all copies of the same message are handled by the same message broker and, thus, eliminate duplicate messages */ func QueueWithPartitioning() QueueOption { - return func(queue *mgmt.SBQueue) error { + return func(queue *QueueDescription) error { queue.EnablePartitioning = ptrBool(true) return nil } @@ -45,12 +119,12 @@ func QueueWithPartitioning() QueueOption { // QueueWithMaxSizeInMegabytes configures the maximum size of the queue in megabytes (1 * 1024 - 5 * 1024), which is the size of // the memory allocated for the queue. Default is 1 MB (1 * 1024). func QueueWithMaxSizeInMegabytes(size int) QueueOption { - return func(q *mgmt.SBQueue) error { + return func(q *QueueDescription) error { if size < 1*Megabytes || size > 5*Megabytes { return errors.New("QueueWithMaxSizeInMegabytes: must be between 1 * Megabytes and 5 * Megabytes") } - size32 := int32(size) - q.MaxSizeInMegabytes = &size32 + int32Size := int32(size) + q.MaxSizeInMegabytes = &int32Size return nil } } @@ -58,7 +132,7 @@ func QueueWithMaxSizeInMegabytes(size int) QueueOption { // QueueWithDuplicateDetection configures the queue to detect duplicates for a given time window. If window // is not specified, then it uses the default of 10 minutes. func QueueWithDuplicateDetection(window *time.Duration) QueueOption { - return func(q *mgmt.SBQueue) error { + return func(q *QueueDescription) error { q.RequiresDuplicateDetection = ptrBool(true) if window != nil { q.DuplicateDetectionHistoryTimeWindow = durationTo8601Seconds(window) @@ -69,7 +143,7 @@ func QueueWithDuplicateDetection(window *time.Duration) QueueOption { // QueueWithRequiredSessions will ensure the queue requires senders and receivers to have sessionIDs func QueueWithRequiredSessions() QueueOption { - return func(q *mgmt.SBQueue) error { + return func(q *QueueDescription) error { q.RequiresSession = ptrBool(true) return nil } @@ -77,7 +151,7 @@ func QueueWithRequiredSessions() QueueOption { // QueueWithDeadLetteringOnMessageExpiration will ensure the queue sends expired messages to the dead letter queue func QueueWithDeadLetteringOnMessageExpiration() QueueOption { - return func(q *mgmt.SBQueue) error { + return func(q *QueueDescription) error { q.DeadLetteringOnMessageExpiration = ptrBool(true) return nil } @@ -86,7 +160,7 @@ func QueueWithDeadLetteringOnMessageExpiration() QueueOption { // QueueWithAutoDeleteOnIdle configures the queue to automatically delete after the specified idle interval. The // minimum duration is 5 minutes. func QueueWithAutoDeleteOnIdle(window *time.Duration) QueueOption { - return func(q *mgmt.SBQueue) error { + return func(q *QueueDescription) error { if window != nil { if window.Minutes() < 5 { return errors.New("QueueWithAutoDeleteOnIdle: window must be greater than 5 minutes") @@ -101,9 +175,9 @@ func QueueWithAutoDeleteOnIdle(window *time.Duration) QueueOption { // the message expires, starting from when the message is sent to Service Bus. This is the default value used when // TimeToLive is not set on a message itself. If nil, defaults to 14 days. func QueueWithMessageTimeToLive(window *time.Duration) QueueOption { - return func(q *mgmt.SBQueue) error { + return func(q *QueueDescription) error { if window == nil { - duration := 14 * 24 * time.Hour + duration := time.Duration(14 * 24 * time.Hour) window = &duration } q.DefaultMessageTimeToLive = durationTo8601Seconds(window) @@ -115,9 +189,9 @@ func QueueWithMessageTimeToLive(window *time.Duration) QueueOption { // message is locked for other receivers. The maximum value for LockDuration is 5 minutes; the default value is 1 // minute. func QueueWithLockDuration(window *time.Duration) QueueOption { - return func(q *mgmt.SBQueue) error { + return func(q *QueueDescription) error { if window == nil { - duration := 1 * time.Minute + duration := time.Duration(1 * time.Minute) window = &duration } q.LockDuration = durationTo8601Seconds(window) @@ -125,44 +199,168 @@ func QueueWithLockDuration(window *time.Duration) QueueOption { } } -// EnsureQueue makes sure a queue exists in the given namespace. If the queue doesn't exist, it will create it with -// the specified name and properties. If properties are not specified, it will build a default partitioned queue. -func (sb *serviceBus) EnsureQueue(ctx context.Context, name string, opts ...QueueOption) (*mgmt.SBQueue, error) { - log.Debugf("ensuring exists queue %s", name) - queueClient := sb.getQueueMgmtClient() - queue, err := queueClient.Get(ctx, sb.resourceGroup, sb.namespace, name) +// NewQueueManager creates a new QueueManager for a Service Bus Namespace +func (ns *Namespace) NewQueueManager() *QueueManager { + return &QueueManager{ + EntityManager: NewEntityManager(ns.getHTTPSHostURI(), ns.TokenProvider), + } +} + +// Delete deletes a Service Bus Queue entity by name +func (qm *QueueManager) Delete(ctx context.Context, name string) error { + _, err := qm.EntityManager.Delete(ctx, "/"+name) + return err +} - // TODO: check if the queue properties are the same as the requested. If not, throw error or build new queue?? - if err != nil { - newQueue := &mgmt.SBQueue{ - Name: &name, - SBQueueProperties: &mgmt.SBQueueProperties{}, +// Put creates or updates a Service Bus Queue +func (qm *QueueManager) Put(ctx context.Context, name string, opts ...QueueOption) (*QueueEntry, error) { + queueDescription := new(QueueDescription) + + for _, opt := range opts { + if err := opt(queueDescription); err != nil { + return nil, err } + } - for _, opt := range opts { - err = opt(newQueue) - if err != nil { - return nil, err - } + queueDescription.InstanceMetadataSchema = instanceMetadataSchema + queueDescription.ServiceBusSchema = serviceBusSchema + + qe := &QueueEntry{ + Entry: &Entry{ + DataServiceSchema: dataServiceSchema, + DataServiceMetadataSchema: dataServiceMetadataSchema, + AtomSchema: atomSchema, + }, + Content: &QueueContent{ + Type: applicationXML, + QueueDescription: *queueDescription, + }, + } + + reqBytes, err := xml.Marshal(qe) + if err != nil { + return nil, err + } + + reqBytes = xmlDoc(reqBytes) + res, err := qm.EntityManager.Put(ctx, "/"+name, reqBytes) + if err != nil { + return nil, err + } + + b, err := ioutil.ReadAll(res.Body) + if err != nil { + return nil, err + } + + var entry QueueEntry + err = xml.Unmarshal(b, &entry) + return &entry, err +} + +// List fetches all of the queues for a Service Bus Namespace +func (qm *QueueManager) List(ctx context.Context) (*QueueFeed, error) { + res, err := qm.EntityManager.Get(ctx, `/$Resources/Queues`) + if err != nil { + return nil, err + } + + b, err := ioutil.ReadAll(res.Body) + if err != nil { + return nil, err + } + + var feed QueueFeed + err = xml.Unmarshal(b, &feed) + return &feed, err +} + +// Get fetches a Service Bus Queue entity by name +func (qm *QueueManager) Get(ctx context.Context, name string) (*QueueEntry, error) { + res, err := qm.EntityManager.Get(ctx, name) + if err != nil { + return nil, err + } + + b, err := ioutil.ReadAll(res.Body) + if err != nil { + return nil, err + } + + var entry QueueEntry + err = xml.Unmarshal(b, &entry) + return &entry, err +} + +// NewQueue creates a new Queue Sender / Receiver +func (ns *Namespace) NewQueue(name string) *Queue { + return &Queue{ + namespace: ns, + Name: name, + } +} + +// Send sends messages to the Queue +func (q *Queue) Send(ctx context.Context, event *Event, opts ...SendOption) error { + err := q.ensureSender(ctx) + if err != nil { + return err + } + return q.sender.Send(ctx, event, opts...) +} + +// Receive subscribes for messages sent to the Queue +func (q *Queue) Receive(ctx context.Context, handler Handler, opts ...ReceiverOptions) (*ListenerHandle, error) { + q.receiverMu.Lock() + defer q.receiverMu.Unlock() + + if q.receiver != nil { + if err := q.receiver.Close(ctx); err != nil { + return nil, err } + } - queue, err = queueClient.CreateOrUpdate(ctx, sb.resourceGroup, sb.namespace, name, *newQueue) - if err != nil { + receiver, err := q.namespace.newReceiver(ctx, q.Name) + for _, opt := range opts { + if err := opt(receiver); err != nil { return nil, err } } - return &queue, nil + + if err != nil { + return nil, err + } + + q.receiver = receiver + return receiver.Listen(handler), err } -// DeleteQueue deletes an existing queue -func (sb *serviceBus) DeleteQueue(ctx context.Context, queueName string) error { - queueClient := sb.getQueueMgmtClient() - _, err := queueClient.Delete(ctx, sb.resourceGroup, sb.namespace, queueName) - return err +// Close the underlying connection to Service Bus +func (q *Queue) Close(ctx context.Context) error { + if q.receiver != nil { + if err := q.receiver.Close(ctx); err != nil { + _ = q.sender.Close(ctx) + return err + } + } + + if q.sender != nil { + return q.sender.Close(ctx) + } + + return nil } -func (sb *serviceBus) getQueueMgmtClient() mgmt.QueuesClient { - client := mgmt.NewQueuesClientWithBaseURI(sb.environment.ResourceManagerEndpoint, sb.subscriptionID) - client.Authorizer = autorest.NewBearerAuthorizer(sb.armToken) - return client +func (q *Queue) ensureSender(ctx context.Context) error { + q.senderMu.Lock() + defer q.senderMu.Unlock() + + if q.sender == nil { + s, err := q.namespace.newSender(ctx, q.Name) + if err != nil { + return err + } + q.sender = s + } + return nil } diff --git a/queue_test.go b/queue_test.go index 8b2e351f7be1..5ed70486102e 100644 --- a/queue_test.go +++ b/queue_test.go @@ -2,49 +2,248 @@ package servicebus import ( "context" + + "encoding/xml" "fmt" + "log" + "math/rand" + "sync" "testing" "time" - mgmt "github.com/Azure/azure-sdk-for-go/services/servicebus/mgmt/2017-04-01/servicebus" - log "github.com/sirupsen/logrus" + "github.com/Azure/azure-amqp-common-go/uuid" + "github.com/Azure/azure-service-bus-go/internal/test" "github.com/stretchr/testify/assert" ) -func (suite *ServiceBusSuite) TestQueueManagement() { - tests := map[string]func(*testing.T, SenderReceiverManager, string){ +const ( + queueDescription1 = ` + + PT30S + 16384 + false + false + P14D + false + PT10M + 10 + true + 0 + 0 + ` + + queueDescription2 = ` + + PT15S + 1024 + true + true + P14D + false + PT10M + 100 + true + 10 + 10 + ` + + queueEntry1 = ` + + https://sbdjtest.servicebus.windows.net/foo + foo + 2018-05-02T20:54:59Z + 2018-05-02T20:54:59Z + + sbdjtest + + + ` + queueDescription1 + + ` + ` + + queueEntry2 = ` + + https://sbdjtest.servicebus.windows.net/bar + bar + 2018-05-02T20:54:59Z + 2018-05-02T20:54:59Z + + sbdjtest + + + ` + queueDescription2 + + ` + ` + + feedOfQueues = ` + + Queues + https://sbdjtest.servicebus.windows.net/$Resources/Queues + 2018-05-03T00:21:15Z + ` + queueEntry1 + queueEntry2 + + `` +) + +var ( + timeout = 20 * time.Second +) + +func (suite *serviceBusSuite) TestQueueEntryUnmarshal() { + var entry QueueEntry + err := xml.Unmarshal([]byte(queueEntry1), &entry) + assert.Nil(suite.T(), err) + assert.Equal(suite.T(), "https://sbdjtest.servicebus.windows.net/foo", entry.ID) + assert.Equal(suite.T(), "foo", entry.Title) + assert.Equal(suite.T(), "sbdjtest", *entry.Author.Name) + assert.Equal(suite.T(), "https://sbdjtest.servicebus.windows.net/foo", entry.Link.HREF) + assert.Equal(suite.T(), "PT30S", *entry.Content.QueueDescription.LockDuration) + assert.NotNil(suite.T(), entry.Content) +} + +func (suite *serviceBusSuite) TestQueueUnmarshal() { + var entry Entry + err := xml.Unmarshal([]byte(queueEntry1), &entry) + assert.Nil(suite.T(), err) + + var q QueueDescription + err = xml.Unmarshal([]byte(entry.Content.Body), &q) + assert.Nil(suite.T(), err) + assert.Equal(suite.T(), "PT30S", *q.LockDuration) + assert.Equal(suite.T(), int32(16384), *q.MaxSizeInMegabytes) + assert.Equal(suite.T(), false, *q.RequiresDuplicateDetection) + assert.Equal(suite.T(), false, *q.RequiresSession) + assert.Equal(suite.T(), "P14D", *q.DefaultMessageTimeToLive) + assert.Equal(suite.T(), false, *q.DeadLetteringOnMessageExpiration) + assert.Equal(suite.T(), "PT10M", *q.DuplicateDetectionHistoryTimeWindow) + assert.Equal(suite.T(), int32(10), *q.MaxDeliveryCount) + assert.Equal(suite.T(), true, *q.EnableBatchedOperations) + assert.Equal(suite.T(), int64(0), *q.SizeInBytes) + assert.Equal(suite.T(), int64(0), *q.MessageCount) +} + +func (suite *serviceBusSuite) TestQueueManagementWrites() { + tests := map[string]func(context.Context, *testing.T, *QueueManager, string){ + "TestPutDefaultQueue": testPutQueue, + } + + ns := suite.getNewSasInstance() + qm := ns.NewQueueManager() + for name, testFunc := range tests { + suite.T().Run(name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + name := suite.RandomName("gosb", 6) + testFunc(ctx, t, qm, name) + + err := qm.Delete(ctx, name) + assert.Nil(t, err) + }) + } +} + +func testPutQueue(ctx context.Context, t *testing.T, qm *QueueManager, name string) { + q, err := qm.Put(ctx, name) + if !assert.Nil(t, err) { + t.FailNow() + } + if assert.NotNil(t, q) { + assert.Equal(t, name, q.Title) + } +} + +func (suite *serviceBusSuite) TestQueueManagementReads() { + tests := map[string]func(context.Context, *testing.T, *QueueManager, []string){ + "TestGetQueue": testGetQueue, + "TestListQueues": testListQueues, + } + + ns := suite.getNewSasInstance() + qm := ns.NewQueueManager() + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + names := []string{suite.randQueueName(), suite.randQueueName()} + for _, name := range names { + if _, err := qm.Put(ctx, name); err != nil { + suite.T().Fatal(err) + } + } + + for name, testFunc := range tests { + suite.T().Run(name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + testFunc(ctx, t, qm, names) + }) + } + + for _, name := range names { + if err := qm.Delete(ctx, name); err != nil { + suite.T().Fatal(err) + } + } +} + +func testGetQueue(ctx context.Context, t *testing.T, qm *QueueManager, names []string) { + q, err := qm.Get(ctx, names[0]) + assert.Nil(t, err) + assert.NotNil(t, q) + assert.Equal(t, q.Entry.Title, names[0]) +} + +func testListQueues(ctx context.Context, t *testing.T, qm *QueueManager, names []string) { + q, err := qm.List(ctx) + assert.Nil(t, err) + assert.NotNil(t, q) + queueNames := make([]string, len(q.Entries)) + for idx, entry := range q.Entries { + queueNames[idx] = entry.Title + } + + for _, name := range names { + assert.Contains(t, queueNames, name) + } +} + +func (suite *serviceBusSuite) randQueueName() string { + return suite.RandomName("goq", 6) +} + +func (suite *serviceBusSuite) TestQueueManagement() { + tests := map[string]func(*testing.T, *QueueManager, string){ "TestQueueDefaultSettings": testDefaultQueue, - "TestQueueWithAutoDeleteOnIdle": testQueueWithAutoDeleteOnIdle, "TestQueueWithRequiredSessions": testQueueWithRequiredSessions, "TestQueueWithDeadLetteringOnMessageExpiration": testQueueWithDeadLetteringOnMessageExpiration, - "TestQueueWithPartitioning": testQueueWithPartitioning, "TestQueueWithMaxSizeInMegabytes": testQueueWithMaxSizeInMegabytes, "TestQueueWithDuplicateDetection": testQueueWithDuplicateDetection, "TestQueueWithMessageTimeToLive": testQueueWithMessageTimeToLive, "TestQueueWithLockDuration": testQueueWithLockDuration, + "TestQueueWithAutoDeleteOnIdle": testQueueWithAutoDeleteOnIdle, + "TestQueueWithPartitioning": testQueueWithPartitioning, } - sb := suite.getNewInstance() - defer sb.Close() - + ns := suite.getNewSasInstance() + qm := ns.NewQueueManager() for name, testFunc := range tests { setupTestTeardown := func(t *testing.T) { - entityName := randomName("gosbtest", 10) - defer func(name string) { - err := sb.DeleteQueue(context.Background(), name) + name := suite.randQueueName() + defer func(n string) { + err := qm.Delete(context.Background(), n) if err != nil { - log.Fatalln(err) + t.Fatal(err) } - }(entityName) - testFunc(t, sb, entityName) + }(name) + testFunc(t, qm, name) } suite.T().Run(name, setupTestTeardown) } } -func testDefaultQueue(t *testing.T, sb SenderReceiverManager, name string) { - q := buildQueue(t, sb, name) +func testDefaultQueue(t *testing.T, qm *QueueManager, name string) { + q := buildQueue(t, qm, name) assert.False(t, *q.EnableExpress, "should not have Express enabled") assert.False(t, *q.EnablePartitioning, "should not have partitioning enabled") assert.False(t, *q.DeadLetteringOnMessageExpiration, "should not have dead lettering on expiration") @@ -52,61 +251,267 @@ func testDefaultQueue(t *testing.T, sb SenderReceiverManager, name string) { assert.False(t, *q.RequiresSession, "should not require session") assert.Equal(t, "P10675199DT2H48M5.4775807S", *q.AutoDeleteOnIdle, "auto delete is not 10 minutes") assert.Equal(t, "PT10M", *q.DuplicateDetectionHistoryTimeWindow, "dup detection is not 10 minutes") - assert.Equal(t, int32(5*Megabytes), *q.MaxSizeInMegabytes, "max size in MBs") + assert.Equal(t, int32(1*Megabytes), *q.MaxSizeInMegabytes, "max size in MBs") assert.Equal(t, "P10675199DT2H48M5.4775807S", *q.DefaultMessageTimeToLive, "default TTL") - assert.Equal(t, mgmt.Active, q.Status, "queue status") assert.Equal(t, "PT1M", *q.LockDuration, "lock duration") } -func testQueueWithAutoDeleteOnIdle(t *testing.T, sb SenderReceiverManager, name string) { +func testQueueWithAutoDeleteOnIdle(t *testing.T, qm *QueueManager, name string) { window := time.Duration(20 * time.Minute) - q := buildQueue(t, sb, name, QueueWithAutoDeleteOnIdle(&window)) + q := buildQueue(t, qm, name, QueueWithAutoDeleteOnIdle(&window)) assert.Equal(t, "PT20M", *q.AutoDeleteOnIdle) } -func testQueueWithRequiredSessions(t *testing.T, sb SenderReceiverManager, name string) { - q := buildQueue(t, sb, name, QueueWithRequiredSessions()) +func testQueueWithRequiredSessions(t *testing.T, qm *QueueManager, name string) { + q := buildQueue(t, qm, name, QueueWithRequiredSessions()) assert.True(t, *q.RequiresSession) } -func testQueueWithDeadLetteringOnMessageExpiration(t *testing.T, sb SenderReceiverManager, name string) { - q := buildQueue(t, sb, name, QueueWithDeadLetteringOnMessageExpiration()) +func testQueueWithDeadLetteringOnMessageExpiration(t *testing.T, qm *QueueManager, name string) { + q := buildQueue(t, qm, name, QueueWithDeadLetteringOnMessageExpiration()) assert.True(t, *q.DeadLetteringOnMessageExpiration) } -func testQueueWithPartitioning(t *testing.T, sb SenderReceiverManager, name string) { - q := buildQueue(t, sb, name, QueueWithPartitioning()) +func testQueueWithPartitioning(t *testing.T, qm *QueueManager, name string) { + q := buildQueue(t, qm, name, QueueWithPartitioning()) assert.True(t, *q.EnablePartitioning) } -func testQueueWithMaxSizeInMegabytes(t *testing.T, sb SenderReceiverManager, name string) { +func testQueueWithMaxSizeInMegabytes(t *testing.T, qm *QueueManager, name string) { size := 3 * Megabytes - q := buildQueue(t, sb, name, QueueWithMaxSizeInMegabytes(size)) + q := buildQueue(t, qm, name, QueueWithMaxSizeInMegabytes(size)) assert.Equal(t, int32(size), *q.MaxSizeInMegabytes) } -func testQueueWithDuplicateDetection(t *testing.T, sb SenderReceiverManager, name string) { +func testQueueWithDuplicateDetection(t *testing.T, qm *QueueManager, name string) { window := time.Duration(20 * time.Minute) - q := buildQueue(t, sb, name, QueueWithDuplicateDetection(&window)) + q := buildQueue(t, qm, name, QueueWithDuplicateDetection(&window)) assert.Equal(t, "PT20M", *q.DuplicateDetectionHistoryTimeWindow) } -func testQueueWithMessageTimeToLive(t *testing.T, sb SenderReceiverManager, name string) { +func testQueueWithMessageTimeToLive(t *testing.T, qm *QueueManager, name string) { window := time.Duration(10 * 24 * 60 * time.Minute) - q := buildQueue(t, sb, name, QueueWithMessageTimeToLive(&window)) + q := buildQueue(t, qm, name, QueueWithMessageTimeToLive(&window)) assert.Equal(t, "P10D", *q.DefaultMessageTimeToLive) } -func testQueueWithLockDuration(t *testing.T, sb SenderReceiverManager, name string) { +func testQueueWithLockDuration(t *testing.T, qm *QueueManager, name string) { window := time.Duration(3 * time.Minute) - q := buildQueue(t, sb, name, QueueWithLockDuration(&window)) + q := buildQueue(t, qm, name, QueueWithLockDuration(&window)) assert.Equal(t, "PT3M", *q.LockDuration) } -func buildQueue(t *testing.T, sb SenderReceiverManager, name string, opts ...QueueOption) *mgmt.SBQueue { - q, err := sb.EnsureQueue(context.Background(), name, opts...) +func buildQueue(t *testing.T, qm *QueueManager, name string, opts ...QueueOption) QueueDescription { + q, err := qm.Put(context.Background(), name, opts...) if err != nil { assert.FailNow(t, fmt.Sprintf("%v", err)) } - return q + return q.Content.QueueDescription +} + +func (suite *serviceBusSuite) TestQueue() { + tests := map[string]func(context.Context, *testing.T, *Queue){ + "SimpleSend": testQueueSend, + "SendAndReceiveInOrder": testQueueSendAndReceiveInOrder, + "DuplicateDetection": testDuplicateDetection, + } + + ns := suite.getNewSasInstance() + qm := ns.NewQueueManager() + for name, testFunc := range tests { + setupTestTeardown := func(t *testing.T) { + queueName := suite.randQueueName() + defer qm.Delete(context.Background(), queueName) + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + window := 3 * time.Minute + _, err := qm.Put( + ctx, + queueName, + QueueWithPartitioning(), + QueueWithDuplicateDetection(nil), + QueueWithLockDuration(&window)) + if err != nil { + log.Fatalln(err) + } + + q := ns.NewQueue(queueName) + defer q.Close(ctx) + testFunc(ctx, t, q) + } + + suite.T().Run(name, setupTestTeardown) + } +} + +func testQueueSend(ctx context.Context, t *testing.T, queue *Queue) { + err := queue.Send(ctx, NewEventFromString("hello!")) + assert.Nil(t, err) +} + +func testQueueSendAndReceiveInOrder(ctx context.Context, t *testing.T, queue *Queue) { + numMessages := rand.Intn(100) + 20 + messages := make([]string, numMessages) + for i := 0; i < numMessages; i++ { + messages[i] = test.RandomString("hello", 10) + } + + for _, message := range messages { + err := queue.Send(ctx, NewEventFromString(message)) + if err != nil { + t.Fatal(err) + } + } + + var wg sync.WaitGroup + wg.Add(numMessages) + // ensure in-order processing of messages from the queue + count := 0 + queue.Receive(ctx, func(ctx context.Context, event *Event) error { + assert.Equal(t, messages[count], string(event.Data)) + count++ + wg.Done() + return nil + }) + end, _ := ctx.Deadline() + waitUntil(t, &wg, time.Until(end)) +} + +func testDuplicateDetection(ctx context.Context, t *testing.T, queue *Queue) { + dupID := mustUUID(t) + messages := []struct { + ID string + Data string + }{ + { + ID: dupID.String(), + Data: "hello 1!", + }, + { + ID: dupID.String(), + Data: "hello 1!", + }, + { + ID: mustUUID(t).String(), + Data: "hello 2!", + }, + } + + for _, msg := range messages { + err := queue.Send(ctx, NewEventFromString(msg.Data), SendWithMessageID(msg.ID)) + if err != nil { + t.Fatal(err) + } + } + + var wg sync.WaitGroup + wg.Add(2) + received := make(map[interface{}]string) + queue.Receive(ctx, func(ctx context.Context, event *Event) error { + // we should get 2 messages discarding the duplicate ID + received[event.ID] = string(event.Data) + wg.Done() + return nil + }) + end, _ := ctx.Deadline() + waitUntil(t, &wg, time.Until(end)) + assert.Equal(t, 2, len(received), "should not have more than 2 messages", received) +} + +func (suite *serviceBusSuite) TestQueueWithRequiredSessions() { + tests := map[string]func(context.Context, *testing.T, *Queue){ + "TestSendAndReceiveSession": testQueueWithRequiredSessionSendAndReceive, + } + + for name, testFunc := range tests { + setupTestTeardown := func(t *testing.T) { + ns := suite.getNewSasInstance() + qm := ns.NewQueueManager() + + queueName := suite.randQueueName() + window := 3 * time.Minute + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + _, err := qm.Put( + ctx, + queueName, + QueueWithPartitioning(), + QueueWithDuplicateDetection(nil), + QueueWithLockDuration(&window), + QueueWithRequiredSessions()) + if err != nil { + t.Fatal(err) + } + + q := ns.NewQueue(queueName) + defer q.Close(ctx) + testFunc(ctx, t, q) + + qd, err := qm.Get(ctx, queueName) + if assert.NoError(t, err) { + assert.Zero(t, *qd.Content.QueueDescription.MessageCount, "message count for queue should be zero") + } + } + + suite.T().Run(name, setupTestTeardown) + } +} + +func testQueueWithRequiredSessionSendAndReceive(ctx context.Context, t *testing.T, queue *Queue) { + sessionID := mustUUID(t).String() + numMessages := rand.Intn(100) + 20 + messages := make([]string, numMessages) + for i := 0; i < numMessages; i++ { + messages[i] = test.RandomString("hello", 10) + } + + for idx, message := range messages { + err := queue.Send(ctx, NewEventFromString(message), SendWithSession(sessionID, uint32(idx))) + if err != nil { + t.Fatal(err) + } + } + + var wg sync.WaitGroup + wg.Add(numMessages) + // ensure in-order processing of messages from the queue + count := 0 + handler := func(ctx context.Context, event *Event) error { + assert.Equal(t, messages[count], string(event.Data)) + count++ + wg.Done() + return nil + } + queue.Receive(ctx, handler, ReceiverWithSession(sessionID)) + end, _ := ctx.Deadline() + waitUntil(t, &wg, time.Until(end)) +} + +func mustUUID(t *testing.T) uuid.UUID { + id, err := uuid.NewV4() + if err != nil { + t.Fatal(err) + } + return id +} + +func waitUntil(t *testing.T, wg *sync.WaitGroup, d time.Duration) { + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + + select { + case <-done: + return + case <-time.After(d): + t.Error("took longer than " + fmtDuration(d)) + } +} + +func fmtDuration(d time.Duration) string { + d = d.Round(time.Second) / time.Second + return fmt.Sprintf("%d seconds", d) } diff --git a/receiver.go b/receiver.go index 67e398fba837..eafc7d13bb58 100644 --- a/receiver.go +++ b/receiver.go @@ -2,119 +2,163 @@ package servicebus import ( "context" + "fmt" + "time" - "github.com/satori/go.uuid" - log "github.com/sirupsen/logrus" "pack.ag/amqp" + + "github.com/Azure/azure-amqp-common-go" + "github.com/Azure/azure-amqp-common-go/log" + "github.com/opentracing/opentracing-go" ) // receiver provides session and link handling for a receiving entity path type ( receiver struct { - sb *serviceBus - session *session - receiver *amqp.Receiver - entityPath string - done func() - Name uuid.UUID + namespace *Namespace + connection *amqp.Client + session *session + receiver *amqp.Receiver + entityPath string + done func() + Name string + requiredSessionID *string + lastError error + } + + // ReceiverOptions provides a structure for configuring receivers + ReceiverOptions func(receiver *receiver) error + + // ListenerHandle provides the ability to close or listen to the close of a Receiver + ListenerHandle struct { + r *receiver + ctx context.Context } ) // newReceiver creates a new Service Bus message listener given an AMQP client and an entity path -func (sb *serviceBus) newReceiver(entityPath string) (*receiver, error) { +func (ns *Namespace) newReceiver(ctx context.Context, entityPath string, opts ...ReceiverOptions) (*receiver, error) { + span, ctx := ns.startSpanFromContext(ctx, "servicebus.Hub.newReceiver") + defer span.Finish() + receiver := &receiver{ - sb: sb, + namespace: ns, entityPath: entityPath, } - err := receiver.newSessionAndLink() + + for _, opt := range opts { + if err := opt(receiver); err != nil { + return nil, err + } + } + + err := receiver.newSessionAndLink(ctx) return receiver, err } // Close will close the AMQP session and link of the receiver -func (r *receiver) Close() error { - // This isn't safe to be called concurrently with Listen +func (r *receiver) Close(ctx context.Context) error { if r.done != nil { r.done() } - err := r.receiver.Close() - if err != nil { - // ensure session is closed on receiver error - _ = r.session.Close() - return err - } - return r.session.Close() + return r.connection.Close() } // Recover will attempt to close the current session and link, then rebuild them -func (r *receiver) Recover() error { - err := r.Close() - if err != nil { - return err - } - - return r.newSessionAndLink() +func (r *receiver) Recover(ctx context.Context) error { + _ = r.Close(ctx) // we expect the receiver is in an error state + return r.newSessionAndLink(ctx) } // Listen start a listener for messages sent to the entity path -func (r *receiver) Listen(handler Handler) { +func (r *receiver) Listen(handler Handler) *ListenerHandle { ctx, done := context.WithCancel(context.Background()) r.done = done + + span, ctx := r.startConsumerSpanFromContext(ctx, "servicebus.receiver.Listen") + defer span.Finish() + messages := make(chan *amqp.Message) go r.listenForMessages(ctx, messages) go r.handleMessages(ctx, messages, handler) + + return &ListenerHandle{ + r: r, + ctx: ctx, + } } func (r *receiver) handleMessages(ctx context.Context, messages chan *amqp.Message, handler Handler) { + span, ctx := r.startConsumerSpanFromContext(ctx, "servicebus.receiver.handleMessages") + defer span.Finish() for { select { case <-ctx.Done(): - log.Debug("done handling messages") return case msg := <-messages: - var id interface{} = "null" - if msg.Properties != nil { - id = msg.Properties.MessageID - } + r.handleMessage(ctx, msg, handler) + } + } +} - log.Debugf("Message id: %s is being passed to handler", id) - err := handler(ctx, msg) - if err != nil { - msg.Reject() - log.Debugf("Message rejected: id: %s", id) - continue - } +func (r *receiver) handleMessage(ctx context.Context, msg *amqp.Message, handler Handler) { + event := eventFromMsg(msg) + var span opentracing.Span + wireContext, err := opentracing.GlobalTracer().Extract(opentracing.TextMap, event) + if err == nil { + span, ctx = r.startConsumerSpanFromWire(ctx, "servicebus.receiver.handleMessage", wireContext) + } else { + span, ctx = r.startConsumerSpanFromContext(ctx, "servicebus.receiver.handleMessage") + } + defer span.Finish() - // Accept message - msg.Accept() - log.Debugf("Message accepted: id: %s", id) - } + id := messageID(msg) + span.SetTag("amqp.message-id", id) + + err = handler(ctx, event) + if err != nil { + msg.Reject() + log.For(ctx).Error(fmt.Errorf("message rejected: id: %v", id)) + return } + msg.Accept() } func (r *receiver) listenForMessages(ctx context.Context, msgChan chan *amqp.Message) { - for { - //log.Debug("attempting to receive messages") - msg, err := r.receiver.Receive(ctx) - // TODO (vcabbage): This previously checked `net.Error.Timeout() == true`, which - // should never happen. If it does it's a bug in pack.ag/amqp. - if err != nil { - if ctx.Err() != nil { - return - } + span, ctx := r.startConsumerSpanFromContext(ctx, "servicebus.receiver.listenForMessages") + defer span.Finish() - // TODO (vcabbage): I'm not sure what the appropriate action is here, this was - // previously a call to `log.Fatalln`, which calls os.Exit(1). - log.Error(err) + for { + msg, err := r.listenForMessage(ctx) + if ctx.Err() != nil && ctx.Err() == context.DeadlineExceeded { return } - var id interface{} = "null" - if msg.Properties != nil { - id = msg.Properties.MessageID + if err != nil { + _, retryErr := common.Retry(5, 10*time.Second, func() (interface{}, error) { + sp, ctx := r.startConsumerSpanFromContext(ctx, "servicebus.receiver.listenForMessages.tryRecover") + defer sp.Finish() + + err := r.Recover(ctx) + if ctx.Err() != nil && ctx.Err() == context.DeadlineExceeded { + return nil, ctx.Err() + } + + if err != nil { + log.For(ctx).Error(err) + return nil, common.Retryable(err.Error()) + } + return nil, nil + }) + + if retryErr != nil { + r.lastError = retryErr + r.Close(ctx) + return + } + continue } - log.Debugf("Message received: %s", id) - select { case msgChan <- msg: case <-ctx.Done(): @@ -123,30 +167,79 @@ func (r *receiver) listenForMessages(ctx context.Context, msgChan chan *amqp.Mes } } +func (r *receiver) listenForMessage(ctx context.Context) (*amqp.Message, error) { + span, ctx := r.startConsumerSpanFromContext(ctx, "servicebus.receiver.listenForMessage") + defer span.Finish() + + msg, err := r.receiver.Receive(ctx) + if err != nil { + log.For(ctx).Debug(err.Error()) + return nil, err + } + + id := messageID(msg) + span.SetTag("amqp.message-id", id) + return msg, nil +} + // newSessionAndLink will replace the session and link on the receiver -func (r *receiver) newSessionAndLink() error { - if r.sb.claimsBasedSecurityEnabled() { - err := r.sb.negotiateClaim(r.entityPath) - if err != nil { - return err - } +func (r *receiver) newSessionAndLink(ctx context.Context) error { + connection, err := r.namespace.newConnection() + if err != nil { + return err } + r.connection = connection - amqpSession, err := r.sb.newSession() + err = r.namespace.negotiateClaim(ctx, connection, r.entityPath) if err != nil { + log.For(ctx).Error(err) return err } - amqpReceiver, err := amqpSession.NewReceiver( + amqpSession, err := connection.NewSession() + if err != nil { + log.For(ctx).Error(err) + return err + } + + r.session, err = newSession(amqpSession) + if err != nil { + log.For(ctx).Error(err) + return err + } + + opts := []amqp.LinkOption{ amqp.LinkSourceAddress(r.entityPath), - amqp.LinkCredit(10), - ) + amqp.LinkCredit(100), + } + + // TODO: fix this with after SB team replies with bug fix for session filters + //if r.requiredSessionID != nil { + // opts = append(opts, amqp.LinkSourceFilterString("com.microsoft:session-filter", *r.requiredSessionID)) + // r.session.SessionID = *r.requiredSessionID + //} + + amqpReceiver, err := amqpSession.NewReceiver(opts...) if err != nil { return err } - r.session = newSession(amqpSession) r.receiver = amqpReceiver - return nil } + +// ReceiverWithSession configures a receiver to use a session +func ReceiverWithSession(sessionID string) ReceiverOptions { + return func(r *receiver) error { + r.requiredSessionID = &sessionID + return nil + } +} + +func messageID(msg *amqp.Message) interface{} { + var id interface{} = "null" + if msg.Properties != nil { + id = msg.Properties.MessageID + } + return id +} diff --git a/sender.go b/sender.go index f49e69180428..e4f2aebd4cbb 100644 --- a/sender.go +++ b/sender.go @@ -2,116 +2,213 @@ package servicebus import ( "context" + "fmt" + "time" - log "github.com/sirupsen/logrus" + "github.com/Azure/azure-amqp-common-go" + "github.com/Azure/azure-amqp-common-go/log" + "github.com/Azure/azure-amqp-common-go/uuid" + "github.com/opentracing/opentracing-go" "pack.ag/amqp" ) // sender provides session and link handling for an sending entity path type ( sender struct { - sb *serviceBus + namespace *Namespace + connection *amqp.Client session *session sender *amqp.Sender entityPath string Name string } + + // SendOption provides a way to customize a message on sending + SendOption func(event *Event) error + + eventer interface { + Set(key, value string) + toMsg() *amqp.Message + } ) // newSender creates a new Service Bus message sender given an AMQP client and entity path -func (sb *serviceBus) newSender(entityPath string) (*sender, error) { +func (ns *Namespace) newSender(ctx context.Context, entityPath string) (*sender, error) { + span, ctx := ns.startSpanFromContext(ctx, "sb.sender.newSender") + defer span.Finish() + s := &sender{ - sb: sb, + namespace: ns, entityPath: entityPath, } - - log.Debugf("creating a new sender for entity path %s", entityPath) - err := s.newSessionAndLink() + log.For(ctx).Debug(fmt.Sprintf("creating a new sender for entity path %s", s.entityPath)) + err := s.newSessionAndLink(ctx) return s, err } // Recover will attempt to close the current session and link, then rebuild them -func (s *sender) Recover() error { - err := s.Close() - if err != nil { - return err - } - - return s.newSessionAndLink() +func (s *sender) Recover(ctx context.Context) error { + span, ctx := s.startProducerSpanFromContext(ctx, "sb.sender.Recover") + defer span.Finish() + _ = s.Close(ctx) // we expect the sender is in an error state + return s.newSessionAndLink(ctx) } -// Close will close the AMQP session and link of the sender -func (s *sender) Close() error { - err := s.sender.Close() - if err != nil { - _ = s.session.Close() - return err - } +// Close will close the AMQP connection, session and link of the sender +func (s *sender) Close(ctx context.Context) error { + span, _ := s.startProducerSpanFromContext(ctx, "sb.sender.Close") + defer span.Finish() - return s.session.Close() + return s.connection.Close() } // Send will send a message to the entity path with options -func (s *sender) Send(ctx context.Context, msg *amqp.Message, opts ...SendOption) error { - // TODO: Add in recovery logic in case the link / session has gone down - s.prepareMessage(msg) +// +// This will retry sending the message if the server responds with a busy error. +func (s *sender) Send(ctx context.Context, event *Event, opts ...SendOption) error { + span, ctx := s.startProducerSpanFromContext(ctx, "sb.sender.Send") + defer span.Finish() + for _, opt := range opts { - err := opt(msg) + err := opt(event) + if err != nil { + return err + } + } + + if event.ID == "" { + id, err := uuid.NewV4() if err != nil { return err } + event.ID = id.String() } - log.Debugf("sending message...") - return s.sender.Send(ctx, msg) + if event.GroupID == nil { + event.GroupID = &s.session.SessionID + next := s.session.getNext() + event.GroupSequence = &next + } + + return s.trySend(ctx, event) +} + +func (s *sender) trySend(ctx context.Context, evt eventer) error { + sp, ctx := s.startProducerSpanFromContext(ctx, "sb.sender.trySend") + defer sp.Finish() + + times := 3 + delay := 10 * time.Second + durationOfSend := 3 * time.Second + if deadline, ok := ctx.Deadline(); ok { + times = int(time.Until(deadline) / (delay + durationOfSend)) + times = max(times, 1) // give at least one chance at sending + } + _, err := common.Retry(times, delay, func() (interface{}, error) { + sp, ctx := s.startProducerSpanFromContext(ctx, "sb.sender.trySend.transmit") + defer sp.Finish() + + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + innerCtx, cancel := context.WithTimeout(ctx, durationOfSend) + defer cancel() + + err := opentracing.GlobalTracer().Inject(sp.Context(), opentracing.TextMap, evt) + if err != nil { + log.For(ctx).Error(err) + return nil, err + } + + msg := evt.toMsg() + sp.SetTag("sb.message-id", msg.Properties.MessageID) + err = s.sender.Send(innerCtx, msg) + if err != nil { + recoverErr := s.Recover(ctx) + if recoverErr != nil { + log.For(ctx).Error(recoverErr) + } + } + + if amqpErr, ok := err.(*amqp.Error); ok { + if amqpErr.Condition == "com.microsoft:server-busy" { + return nil, common.Retryable(amqpErr.Condition) + } + } + + return nil, err + } + }) + return err } func (s *sender) String() string { return s.Name } -func (s *sender) prepareMessage(msg *amqp.Message) { - if msg.Properties == nil { - msg.Properties = &amqp.MessageProperties{} - } +func (s *sender) getAddress() string { + return s.entityPath +} - if msg.Properties.GroupID == "" { - msg.Properties.GroupID = s.session.String() - msg.Properties.GroupSequence = s.session.getNext() - } +func (s *sender) getFullIdentifier() string { + return s.namespace.getEntityAudience(s.getAddress()) } // newSessionAndLink will replace the existing session and link -func (s *sender) newSessionAndLink() error { - if s.sb.claimsBasedSecurityEnabled() { - err := s.sb.negotiateClaim(s.entityPath) - if err != nil { - return err - } +func (s *sender) newSessionAndLink(ctx context.Context) error { + span, ctx := s.startProducerSpanFromContext(ctx, "sb.sender.newSessionAndLink") + defer span.Finish() + + connection, err := s.namespace.newConnection() + if err != nil { + log.For(ctx).Error(err) + return err } + s.connection = connection - amqpSession, err := s.sb.newSession() + err = s.namespace.negotiateClaim(ctx, connection, s.getAddress()) if err != nil { + log.For(ctx).Error(err) return err } - amqpSender, err := amqpSession.NewSender(amqp.LinkTargetAddress(s.entityPath)) + amqpSession, err := connection.NewSession() if err != nil { + log.For(ctx).Error(err) + return err + } + + amqpSender, err := amqpSession.NewSender(amqp.LinkTargetAddress(s.getAddress())) + if err != nil { + log.For(ctx).Error(err) + return err + } + + s.session, err = newSession(amqpSession) + if err != nil { + log.For(ctx).Error(err) return err } - s.session = newSession(amqpSession) s.sender = amqpSender return nil } -// SendOption provides a way to customize a message on sending -type SendOption func(message *amqp.Message) error +// SendWithMessageID configures the message with a message ID +func SendWithMessageID(messageID string) SendOption { + return func(event *Event) error { + event.ID = messageID + return nil + } +} -// SendWithMessageID provides an option of adding a message ID for the sent message -func SendWithMessageID(msgID interface{}) SendOption { - return func(msg *amqp.Message) error { - msg.Properties.MessageID = msgID +// SendWithSession configures the message to send with a specific session and sequence. By default, a sender has a +// default session (uuid.NewV4()) and sequence generator. +func SendWithSession(sessionID string, sequence uint32) SendOption { + return func(event *Event) error { + event.GroupID = &sessionID + event.GroupSequence = &sequence return nil } } @@ -120,8 +217,8 @@ func SendWithMessageID(msgID interface{}) SendOption { // the queue distributed the message in a round robin fashion to the next available partition with the effect of not // enforcing FIFO ordering of messages, but enabling more efficient distribution of messages across partitions. func SendWithoutSessionID() SendOption { - return func(msg *amqp.Message) error { - msg.Properties.GroupID = "" + return func(event *Event) error { + event.GroupID = nil return nil } } diff --git a/servicebus.go b/servicebus.go deleted file mode 100644 index caf31cbc06dd..000000000000 --- a/servicebus.go +++ /dev/null @@ -1,344 +0,0 @@ -package servicebus - -import ( - "context" - "errors" - "regexp" - "sync" - - mgmt "github.com/Azure/azure-sdk-for-go/services/servicebus/mgmt/2017-04-01/servicebus" - "github.com/Azure/go-autorest/autorest/adal" - "github.com/Azure/go-autorest/autorest/azure" - "github.com/satori/go.uuid" - log "github.com/sirupsen/logrus" - "pack.ag/amqp" -) - -const ( - banner = ` - _____ _ ____ - / ___/___ ______ __(_)________ / __ )__ _______ - \__ \/ _ \/ ___/ | / / // ___/ _ \ / __ / / / / ___/ - ___/ / __/ / | |/ / // /__/ __/ / /_/ / /_/ (__ ) -/____/\___/_/ |___/_/ \___/\___/ /_____/\__,_/____/ - -` -) - -var ( - connStrRegex = regexp.MustCompile(`Endpoint=sb:\/\/(?P.+?);SharedAccessKeyName=(?P.+?);SharedAccessKey=(?P.+)`) -) - -// SenderReceiver provides the ability to send and receive messages -type ( - SenderReceiver interface { - Send(ctx context.Context, entityPath string, msg *amqp.Message, opts ...SendOption) error - Receive(entityPath string, handler Handler) error - Close() error - } - - // EntityManager provides the ability to manage Service Bus entities (Queues, Topics, Subscriptions, etc.) - EntityManager interface { - EnsureQueue(ctx context.Context, name string, opts ...QueueOption) (*mgmt.SBQueue, error) - DeleteQueue(ctx context.Context, name string) error - EnsureTopic(ctx context.Context, name string, opts ...TopicOption) (*mgmt.SBTopic, error) - DeleteTopic(ctx context.Context, name string) error - EnsureSubscription(ctx context.Context, topicName, name string, opts ...SubscriptionOption) (*mgmt.SBSubscription, error) - DeleteSubscription(ctx context.Context, topicName, name string) error - } - - // SenderReceiverManager provides Service Bus entity management as well as access to send and receive messages - SenderReceiverManager interface { - SenderReceiver - EntityManager - } - - // ServicePrincipalCredentials contains the details needed to authenticate to Azure Active Directory with a Service - // Principal. For more info on Service Principals see: https://docs.microsoft.com/en-us/azure/azure-resource-manager/resource-group-create-service-principal-portal - ServicePrincipalCredentials struct { - TenantID string - ApplicationID string - Secret string - } - - // serviceBus provides a simplified facade over the AMQP implementation of Azure Service Bus. - serviceBus struct { - name uuid.UUID - client *amqp.Client - clientMu sync.Mutex - armToken *adal.ServicePrincipalToken - sbToken *adal.ServicePrincipalToken - connectionString string - environment azure.Environment - subscriptionID string - resourceGroup string - namespace string - receivers []*receiver - senders map[string]*sender - receiverMu sync.Mutex - senderMu sync.Mutex - Logger *log.Logger - cbsMu sync.Mutex - cbsLink *cbsLink - } - - // parsedConn is the structure of a parsed Service Bus connection string. - parsedConn struct { - Host string - KeyName string - Key string - } - - // Handler is the function signature for any receiver of AMQP messages - Handler func(context.Context, *amqp.Message) error -) - -// NewWithConnectionString creates a new connected instance of an Azure Service Bus given a connection string with the -// same format as the Azure portal -// (e.g. Endpoint=sb://XXXXX.servicebus.windows.net/;SharedAccessKeyName=XXXXX;SharedAccessKey=XXXXX). The Service Bus -// instance returned from this function does not have the ability to manage Subscriptions, Topics or Queues. The -// instance is only able to use existing Service Bus entities. -func NewWithConnectionString(connStr string) (SenderReceiver, error) { - return newWithConnectionString(connStr) -} - -// NewWithServicePrincipal builds a Service Bus SenderReceiverManager which authenticates with Azure Active Directory -// using Claims-based Security -func NewWithServicePrincipal(subscriptionID, namespace string, credentials ServicePrincipalCredentials, env azure.Environment) (SenderReceiverManager, error) { - armToken, err := getArmTokenProvider(credentials, env) - if err != nil { - return nil, err - } - - sbToken, err := getServiceBusTokenProvider(credentials, env) - if err != nil { - return nil, err - } - - return NewWithTokenProviders(subscriptionID, namespace, armToken, sbToken, env) -} - -// NewWithTokenProviders builds a Service Bus SenderReceiverManager which authenticates with Azure Active Directory -// using Claims-based Security using Azure Active Directory token providers -func NewWithTokenProviders(subscriptionID, namespace string, armToken, serviceBusToken *adal.ServicePrincipalToken, env azure.Environment) (SenderReceiverManager, error) { - sb := &serviceBus{ - name: uuid.NewV4(), - sbToken: serviceBusToken, - armToken: armToken, - namespace: namespace, - subscriptionID: subscriptionID, - environment: env, - Logger: log.New(), - senders: make(map[string]*sender), - } - sb.Logger.SetLevel(log.WarnLevel) - - ns, err := sb.GetNamespace(context.Background()) - if err != nil { - return nil, err - } - - parsedID, err := parseAzureResourceID(*ns.ID) - if err != nil { - return nil, err - } - sb.resourceGroup = parsedID.ResourceGroup - - return sb, nil -} - -func (sb *serviceBus) Start() error { - log.Println(banner) - return nil -} - -// Close drains and closes all of the existing senders, receivers and connections -func (sb *serviceBus) Close() error { - // TODO: add some better error handling for cleaning up on Close - sb.drainReceivers() - sb.drainSenders() - if sb.client != nil { - log.Debugf("closing sb amqp connection %v", sb) - sb.client.Close() - } - return nil -} - -// Receive subscribes for messages sent to the provided entityPath. -func (sb *serviceBus) Receive(entityPath string, handler Handler) error { - sb.receiverMu.Lock() - defer sb.receiverMu.Unlock() - - receiver, err := sb.newReceiver(entityPath) - if err != nil { - return err - } - - sb.receivers = append(sb.receivers, receiver) - receiver.Listen(handler) - return nil -} - -// Send sends a message to a provided entity path with options -func (sb *serviceBus) Send(ctx context.Context, entityPath string, msg *amqp.Message, opts ...SendOption) error { - sender, err := sb.fetchSender(entityPath) - if err != nil { - return err - } - - return sender.Send(ctx, msg, opts...) -} - -func (sb *serviceBus) connection() (*amqp.Client, error) { - sb.clientMu.Lock() - defer sb.clientMu.Unlock() - - // TODO (vcabbage): this will return nil, nil if sb.claimsBasedSecurityEnabled() == false. - // eventually leading to a panic when the consuming code calls conn.NewSession() - if sb.client == nil && sb.claimsBasedSecurityEnabled() { - host := getHostName(sb.namespace) - client, err := amqp.Dial(host, amqp.ConnSASLAnonymous(), amqp.ConnMaxSessions(65535)) - if err != nil { - return nil, err - } - sb.client = client - } - return sb.client, nil -} - -func (sb *serviceBus) newSession() (*amqp.Session, error) { - conn, err := sb.connection() - if err != nil { - return nil, err - } - return conn.NewSession() -} - -func (sb *serviceBus) fetchSender(entityPath string) (*sender, error) { - sb.senderMu.Lock() - defer sb.senderMu.Unlock() - - entry, ok := sb.senders[entityPath] - if ok { - return entry, nil - } - - sender, err := sb.newSender(entityPath) - if err != nil { - return nil, err - } - sb.senders[entityPath] = sender - return sender, nil -} - -func newClientWithConnectionString(connStr string) (*amqp.Client, error) { - if connStr == "" { - return nil, errors.New("connection string can not be null") - } - parsed, err := parsedConnectionFromStr(connStr) - if err != nil { - return nil, errors.New("connection string was not in expected format (Endpoint=sb://XXXXX.servicebus.windows.net/;SharedAccessKeyName=XXXXX;SharedAccessKey=XXXXX)") - } - - client, err := amqp.Dial(parsed.Host, amqp.ConnSASLPlain(parsed.KeyName, parsed.Key), amqp.ConnMaxSessions(65535)) - if err != nil { - return nil, err - } - return client, nil -} - -func newWithConnectionString(connStr string) (*serviceBus, error) { - client, err := newClientWithConnectionString(connStr) - if err != nil { - return nil, err - } - - sb := &serviceBus{ - name: uuid.NewV4(), - Logger: log.New(), - client: client, - connectionString: connStr, - } - sb.Logger.SetLevel(log.WarnLevel) - sb.senders = make(map[string]*sender) - return sb, nil -} - -// parsedConnectionFromStr takes a string connection string from the Azure portal and returns the parsed representation. -func parsedConnectionFromStr(connStr string) (*parsedConn, error) { - matches := connStrRegex.FindStringSubmatch(connStr) - return newParsedConnection(matches[1], matches[2], matches[3]) -} - -// newParsedConnection is a constructor for a parsedConn and verifies each of the inputs is non-null. -func newParsedConnection(host string, keyName string, key string) (*parsedConn, error) { - if host == "" || keyName == "" || key == "" { - return nil, errors.New("connection string contains an empty entry") - } - return &parsedConn{ - Host: "amqps://" + host, - KeyName: keyName, - Key: key, - }, nil -} - -func getHostName(namespace string) string { - return "amqps://" + namespace + ".servicebus.windows.net" -} - -// claimsBasedSecurityEnabled indicates that the connection will use AAD JWT RBAC to authenticate in connections -func (sb *serviceBus) claimsBasedSecurityEnabled() bool { - return sb.sbToken != nil -} - -func getArmTokenProvider(credential ServicePrincipalCredentials, env azure.Environment) (*adal.ServicePrincipalToken, error) { - return getTokenProvider(azure.PublicCloud.ResourceManagerEndpoint, credential, env) -} - -func getServiceBusTokenProvider(credential ServicePrincipalCredentials, env azure.Environment) (*adal.ServicePrincipalToken, error) { - return getTokenProvider("https://servicebus.azure.net/", credential, env) -} - -func getTokenProvider(resourceURI string, cred ServicePrincipalCredentials, env azure.Environment) (*adal.ServicePrincipalToken, error) { - oauthConfig, err := adal.NewOAuthConfig(env.ActiveDirectoryEndpoint, cred.TenantID) - if err != nil { - return nil, err - } - - tokenProvider, err := adal.NewServicePrincipalToken(*oauthConfig, cred.ApplicationID, cred.Secret, resourceURI) - if err != nil { - return nil, err - } - - err = tokenProvider.Refresh() - if err != nil { - return nil, err - } - - return tokenProvider, nil -} - -func (sb *serviceBus) drainReceivers() error { - log.Debugln("draining receivers") - sb.receiverMu.Lock() - defer sb.receiverMu.Unlock() - - for _, receiver := range sb.receivers { - // TODO (vcabbage): what if an error occurs here? - receiver.Close() - } - sb.receivers = []*receiver{} - return nil -} - -func (sb *serviceBus) drainSenders() error { - log.Debugln("draining senders") - sb.senderMu.Lock() - defer sb.senderMu.Unlock() - - for key, sender := range sb.senders { - sender.Close() - delete(sb.senders, key) - } - return nil -} diff --git a/servicebus_test.go b/servicebus_test.go deleted file mode 100644 index 3ec85cfdc9d9..000000000000 --- a/servicebus_test.go +++ /dev/null @@ -1,329 +0,0 @@ -package servicebus - -import ( - "context" - "flag" - "math/rand" - "os" - "sync" - "testing" - "time" - - rm "github.com/Azure/azure-sdk-for-go/services/resources/mgmt/2017-05-10/resources" - sbmgmt "github.com/Azure/azure-sdk-for-go/services/servicebus/mgmt/2017-04-01/servicebus" - "github.com/Azure/go-autorest/autorest" - "github.com/Azure/go-autorest/autorest/adal" - "github.com/Azure/go-autorest/autorest/azure" - "github.com/satori/go.uuid" - log "github.com/sirupsen/logrus" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/suite" - "pack.ag/amqp" -) - -var ( - letterRunes = []rune("abcdefghijklmnopqrstuvwxyz123456789") - debug = flag.Bool("debug", false, "output debug level logging") -) - -const ( - RootRuleName = "RootManageSharedAccessKey" - Location = "westus" - ResourceGroupName = "sbtest" -) - -func init() { - rand.Seed(time.Now().Unix()) -} - -type ( - // ServiceBusSuite encapsulates a end to end test of Service Bus with build up and tear down of all SB resources - ServiceBusSuite struct { - suite.Suite - TenantID string - SubscriptionID string - ClientID string - ClientSecret string - Namespace string - Token *adal.ServicePrincipalToken - Environment azure.Environment - } -) - -func (suite *ServiceBusSuite) SetupSuite() { - flag.Parse() - if *debug { - log.SetLevel(log.DebugLevel) - } - - suite.TenantID = mustGetenv("AZURE_TENANT_ID") - suite.SubscriptionID = mustGetenv("AZURE_SUBSCRIPTION_ID") - suite.ClientID = mustGetenv("AZURE_CLIENT_ID") - suite.ClientSecret = mustGetenv("AZURE_CLIENT_SECRET") - suite.Namespace = "something" //mustGetenv("SERVICEBUS_NAMESPACE") - suite.Token = suite.servicePrincipalToken() - suite.Environment = azure.PublicCloud - - err := suite.ensureProvisioned(sbmgmt.SkuTierStandard) - if err != nil { - log.Fatalln(err) - } -} - -func (suite *ServiceBusSuite) TearDownSuite() { - // tear down queues and subscriptions maybe?? -} - -func (suite *ServiceBusSuite) TestQueue() { - tests := map[string]func(*testing.T, SenderReceiver, string){ - "SimpleSend": testQueueSend, - "SendAndReceiveInOrder": testQueueSendAndReceiveInOrder, - "DuplicateDetection": testDuplicateDetection, - } - - sb := suite.getNewInstance() - defer sb.Close() - - for name, testFunc := range tests { - setupTestTeardown := func(t *testing.T) { - queueName := randomName("gosbtest", 10) - defer sb.DeleteQueue(context.Background(), queueName) - - window := 3 * time.Minute - _, err := sb.EnsureQueue( - context.Background(), - queueName, - QueueWithPartitioning(), - QueueWithDuplicateDetection(nil), - QueueWithLockDuration(&window)) - if err != nil { - log.Fatalln(err) - } - - testFunc(t, sb, queueName) - } - - suite.T().Run(name, setupTestTeardown) - } -} - -func testQueueSend(t *testing.T, sb SenderReceiver, queueName string) { - err := sb.Send(context.Background(), queueName, &amqp.Message{ - Data: []byte("Hello!"), - }) - assert.Nil(t, err) -} - -func testQueueSendAndReceiveInOrder(t *testing.T, sb SenderReceiver, queueName string) { - numMessages := rand.Intn(100) + 20 - var wg sync.WaitGroup - wg.Add(numMessages + 1) - messages := make([]string, numMessages) - for i := 0; i < numMessages; i++ { - messages[i] = randomName("hello", 10) - } - - go func() { - for _, message := range messages { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - err := sb.Send(ctx, queueName, &amqp.Message{Data: []byte(message)}) - cancel() - if err != nil { - log.Fatalln(err) - } - } - defer wg.Done() - }() - - // ensure in-order processing of messages from the queue - count := 0 - sb.Receive(queueName, func(ctx context.Context, msg *amqp.Message) error { - assert.Equal(t, messages[count], string(msg.Data)) - count++ - wg.Done() - return nil - }) - wg.Wait() -} - -func testDuplicateDetection(t *testing.T, sb SenderReceiver, queueName string) { - dupID := uuid.NewV4().String() - messages := []struct { - ID string - Data string - }{ - { - ID: dupID, - Data: "hello 1!", - }, - { - ID: dupID, - Data: "hello 1!", - }, - { - ID: uuid.NewV4().String(), - Data: "hello 2!", - }, - } - - var wg sync.WaitGroup - wg.Add(3) - go func() { - for _, msg := range messages { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - err := sb.Send(ctx, queueName, &amqp.Message{Data: []byte(msg.Data)}, SendWithMessageID(msg.ID)) - cancel() - if err != nil { - log.Fatalln(err) - } - } - defer wg.Done() - }() - - received := make(map[interface{}]string) - sb.Receive(queueName, func(ctx context.Context, msg *amqp.Message) error { - // we should get 2 messages discarding the duplicate ID - received[msg.Properties.MessageID] = string(msg.Data) - wg.Done() - return nil - }) - wg.Wait() - assert.Equal(t, 2, len(received), "should not have more than 2 messages", received) -} - -func TestServiceBusSuite(t *testing.T) { - suite.Run(t, new(ServiceBusSuite)) -} - -func TestCreateFromConnectionString(t *testing.T) { - connStr := os.Getenv("AZURE_SERVICE_BUS_CONN_STR") // `Endpoint=sb://XXXX.servicebus.windows.net/;SharedAccessKeyName=XXXX;SharedAccessKey=XXXX` - sb, err := NewWithConnectionString(connStr) - if err != nil { - sb.Close() - } - assert.Nil(t, err) -} - -func BenchmarkSend(b *testing.B) { - sbSuite := &ServiceBusSuite{} - sbSuite.SetupSuite() - defer sbSuite.TearDownSuite() - - sb := sbSuite.getNewInstance() - defer func() { - err := sb.Close() - if err != nil { - log.Fatalln(err) - } - }() - - queueName := randomName("gosbbench", 10) - _, err := sb.EnsureQueue(context.Background(), queueName, nil) - if err != nil { - log.Fatalln(err) - } - - b.ResetTimer() - for i := 0; i < b.N; i++ { - sb.Send(context.Background(), queueName, &amqp.Message{ - Data: []byte("Hello!"), - }) - } - b.StopTimer() - err = sb.DeleteQueue(context.Background(), queueName) - if err != nil { - log.Fatalln(err) - } -} - -func mustGetenv(key string) string { - v := os.Getenv(key) - if v == "" { - panic("Environment variable '" + key + "' required for integration tests.") - } - return v -} - -func randomName(prefix string, length int) string { - b := make([]rune, length) - for i := range b { - b[i] = letterRunes[rand.Intn(len(letterRunes))] - } - return prefix + "-" + string(b) -} - -func (suite *ServiceBusSuite) servicePrincipalToken() *adal.ServicePrincipalToken { - - oauthConfig, err := adal.NewOAuthConfig(azure.PublicCloud.ActiveDirectoryEndpoint, suite.TenantID) - if err != nil { - log.Fatalln(err) - } - - tokenProvider, err := adal.NewServicePrincipalToken(*oauthConfig, - suite.ClientID, - suite.ClientSecret, - azure.PublicCloud.ResourceManagerEndpoint) - if err != nil { - log.Fatalln(err) - } - - return tokenProvider -} - -func (suite *ServiceBusSuite) getRmGroupClient() *rm.GroupsClient { - groupsClient := rm.NewGroupsClient(suite.SubscriptionID) - groupsClient.Authorizer = autorest.NewBearerAuthorizer(suite.Token) - return &groupsClient -} - -func (suite *ServiceBusSuite) getServiceBusNamespaceClient() *sbmgmt.NamespacesClient { - nsClient := sbmgmt.NewNamespacesClient(suite.SubscriptionID) - nsClient.Authorizer = autorest.NewBearerAuthorizer(suite.Token) - return &nsClient -} - -func (suite *ServiceBusSuite) ensureProvisioned(tier sbmgmt.SkuTier) error { - groupsClient := suite.getRmGroupClient() - location := Location - _, err := groupsClient.CreateOrUpdate(context.Background(), ResourceGroupName, rm.Group{Location: &location}) - if err != nil { - return err - } - - nsClient := suite.getServiceBusNamespaceClient() - _, err = nsClient.Get(context.Background(), ResourceGroupName, suite.Namespace) - if err != nil { - ns := sbmgmt.SBNamespace{ - Sku: &sbmgmt.SBSku{ - Name: sbmgmt.SkuName(tier), - Tier: tier, - }, - Location: &location, - } - res, err := nsClient.CreateOrUpdate(context.Background(), ResourceGroupName, suite.Namespace, ns) - if err != nil { - return err - } - - return res.WaitForCompletion(context.Background(), nsClient.Client) - } - - return nil -} - -func (suite *ServiceBusSuite) getNewInstance() SenderReceiverManager { - return getNewInstance(suite.TenantID, suite.SubscriptionID, suite.Namespace, suite.ClientID, suite.ClientSecret, suite.Environment) -} - -func getNewInstance(tenantID, subscriptionID, namespace, appID, secret string, env azure.Environment) SenderReceiverManager { - cred := ServicePrincipalCredentials{ - TenantID: tenantID, - ApplicationID: appID, - Secret: secret, - } - sb, err := NewWithServicePrincipal(subscriptionID, namespace, cred, env) - if err != nil { - log.Fatalln(err) - } - return sb -} diff --git a/session.go b/session.go index 2da3568c3f14..5858f7cc1a70 100644 --- a/session.go +++ b/session.go @@ -3,7 +3,7 @@ package servicebus import ( "sync/atomic" - "github.com/satori/go.uuid" + "github.com/Azure/azure-amqp-common-go/uuid" "pack.ag/amqp" ) @@ -11,17 +11,23 @@ type ( // session is a wrapper for the AMQP session with some added information to help with Service Bus messaging session struct { *amqp.Session - SessionID uuid.UUID + SessionID string counter uint32 } ) // newSession is a constructor for a Service Bus session which will pre-populate the SessionID with a new UUID -func newSession(amqpSession *amqp.Session) *session { +func newSession(amqpSession *amqp.Session) (*session, error) { + id, err := uuid.NewV4() + if err != nil { + return nil, err + } + return &session{ Session: amqpSession, - SessionID: uuid.NewV4(), - } + SessionID: id.String(), + counter: 0, + }, nil } // getNext gets and increments the next group sequence number for the session @@ -30,5 +36,5 @@ func (s *session) getNext() uint32 { } func (s *session) String() string { - return s.SessionID.String() + return s.SessionID } diff --git a/subscription.go b/subscription.go index 9aa0c451f399..09c291cd8262 100644 --- a/subscription.go +++ b/subscription.go @@ -1,125 +1,133 @@ package servicebus -import ( - "context" - "errors" - "time" - - mgmt "github.com/Azure/azure-sdk-for-go/services/servicebus/mgmt/2017-04-01/servicebus" - "github.com/Azure/go-autorest/autorest" - log "github.com/sirupsen/logrus" -) - -type ( - // SubscriptionOption represents an option for configuring a topic. - SubscriptionOption func(subscription *mgmt.SBSubscription) error -) - -// SubscriptionWithBatchedOperations configures the subscription to batch server-side operations. -func SubscriptionWithBatchedOperations() SubscriptionOption { - return func(t *mgmt.SBSubscription) error { - t.EnableBatchedOperations = ptrBool(true) - return nil - } -} - -// SubscriptionWithLockDuration configures the subscription to have a duration of a peek-lock; that is, the amount of -// time that the message is locked for other receivers. The maximum value for LockDuration is 5 minutes; the default -// value is 1 minute. -func SubscriptionWithLockDuration(window *time.Duration) SubscriptionOption { - return func(q *mgmt.SBSubscription) error { - if window == nil { - duration := 1 * time.Minute - window = &duration - } - q.LockDuration = durationTo8601Seconds(window) - return nil - } -} - -// SubscriptionWithRequiredSessions will ensure the subscription requires senders and receivers to have sessionIDs -func SubscriptionWithRequiredSessions() SubscriptionOption { - return func(q *mgmt.SBSubscription) error { - q.RequiresSession = ptrBool(true) - return nil - } -} - -// SubscriptionWithDeadLetteringOnMessageExpiration will ensure the Subscription sends expired messages to the dead -// letter queue -func SubscriptionWithDeadLetteringOnMessageExpiration() SubscriptionOption { - return func(q *mgmt.SBSubscription) error { - q.DeadLetteringOnMessageExpiration = ptrBool(true) - return nil - } -} - -// SubscriptionWithAutoDeleteOnIdle configures the subscription to automatically delete after the specified idle -// interval. The minimum duration is 5 minutes. -func SubscriptionWithAutoDeleteOnIdle(window *time.Duration) SubscriptionOption { - return func(q *mgmt.SBSubscription) error { - if window != nil { - if window.Minutes() < 5 { - return errors.New("SubscriptionWithAutoDeleteOnIdle: window must be greater than 5 minutes") - } - q.AutoDeleteOnIdle = durationTo8601Seconds(window) - } - return nil - } -} - -// SubscriptionWithMessageTimeToLive configures the subscription to set a time to live on messages. This is the duration -// after which the message expires, starting from when the message is sent to Service Bus. This is the default value -// used when TimeToLive is not set on a message itself. If nil, defaults to 14 days. -func SubscriptionWithMessageTimeToLive(window *time.Duration) SubscriptionOption { - return func(q *mgmt.SBSubscription) error { - if window == nil { - duration := 14 * 24 * time.Hour - window = &duration - } - q.DefaultMessageTimeToLive = durationTo8601Seconds(window) - return nil - } -} - -// EnsureSubscription creates a subscription if the subscription does not exist -func (sb *serviceBus) EnsureSubscription(ctx context.Context, topicName, name string, opts ...SubscriptionOption) (*mgmt.SBSubscription, error) { - log.Debugf("ensuring subscription %s exists", name) - subClient := sb.getSubscriptionMgmtClient() - subscription, err := subClient.Get(ctx, sb.resourceGroup, sb.namespace, topicName, name) - - if err != nil { - newSub := &mgmt.SBSubscription{ - Name: &name, - SBSubscriptionProperties: &mgmt.SBSubscriptionProperties{ - EnableBatchedOperations: ptrBool(false), - }, - } - - for _, opt := range opts { - err = opt(newSub) - if err != nil { - return nil, err - } - } - - subscription, err = subClient.CreateOrUpdate(ctx, sb.resourceGroup, sb.namespace, topicName, name, *newSub) - if err != nil { - return nil, err - } - } - return &subscription, nil -} - -// DeleteSubscription deletes an existing subscription -func (sb *serviceBus) DeleteSubscription(ctx context.Context, topicName, name string) error { - subscriptionClient := sb.getSubscriptionMgmtClient() - _, err := subscriptionClient.Delete(ctx, sb.resourceGroup, sb.namespace, topicName, name) - return err -} - -func (sb *serviceBus) getSubscriptionMgmtClient() *mgmt.SubscriptionsClient { - client := mgmt.NewSubscriptionsClientWithBaseURI(sb.environment.ResourceManagerEndpoint, sb.subscriptionID) - client.Authorizer = autorest.NewBearerAuthorizer(sb.armToken) - return &client -} +//import ( +// "context" +// "errors" +// mgmt "github.com/Azure/azure-sdk-for-go/services/servicebus/mgmt/2017-04-01/servicebus" +// "github.com/Azure/go-autorest/autorest" +// "time" +//) +// +//type ( +// // SubscriptionOption represents an option for configuring a topic. +// SubscriptionOption func(subscription *mgmt.SBSubscription) error +//) +// +//// SubscriptionWithBatchedOperations configures the subscription to batch server-side operations. +//func SubscriptionWithBatchedOperations() SubscriptionOption { +// return func(t *mgmt.SBSubscription) error { +// t.EnableBatchedOperations = ptrBool(true) +// return nil +// } +//} +// +//// SubscriptionWithLockDuration configures the subscription to have a duration of a peek-lock; that is, the amount of +//// time that the message is locked for other receivers. The maximum value for LockDuration is 5 minutes; the default +//// value is 1 minute. +//func SubscriptionWithLockDuration(window *time.Duration) SubscriptionOption { +// return func(q *mgmt.SBSubscription) error { +// if window == nil { +// duration := time.Duration(1 * time.Minute) +// window = &duration +// } +// q.LockDuration = durationTo8601Seconds(window) +// return nil +// } +//} +// +//// SubscriptionWithRequiredSessions will ensure the subscription requires senders and receivers to have sessionIDs +//func SubscriptionWithRequiredSessions() SubscriptionOption { +// return func(q *mgmt.SBSubscription) error { +// q.RequiresSession = ptrBool(true) +// return nil +// } +//} +// +//// SubscriptionWithDeadLetteringOnMessageExpiration will ensure the Subscription sends expired messages to the dead +//// letter queue +//func SubscriptionWithDeadLetteringOnMessageExpiration() SubscriptionOption { +// return func(q *mgmt.SBSubscription) error { +// q.DeadLetteringOnMessageExpiration = ptrBool(true) +// return nil +// } +//} +// +//// SubscriptionWithAutoDeleteOnIdle configures the subscription to automatically delete after the specified idle +//// interval. The minimum duration is 5 minutes. +//func SubscriptionWithAutoDeleteOnIdle(window *time.Duration) SubscriptionOption { +// return func(q *mgmt.SBSubscription) error { +// if window != nil { +// if window.Minutes() < 5 { +// return errors.New("SubscriptionWithAutoDeleteOnIdle: window must be greater than 5 minutes") +// } +// q.AutoDeleteOnIdle = durationTo8601Seconds(window) +// } +// return nil +// } +//} +// +//// SubscriptionWithMessageTimeToLive configures the subscription to set a time to live on messages. This is the duration +//// after which the message expires, starting from when the message is sent to Service Bus. This is the default value +//// used when TimeToLive is not set on a message itself. If nil, defaults to 14 days. +//func SubscriptionWithMessageTimeToLive(window *time.Duration) SubscriptionOption { +// return func(q *mgmt.SBSubscription) error { +// if window == nil { +// duration := time.Duration(14 * 24 * time.Hour) +// window = &duration +// } +// q.DefaultMessageTimeToLive = durationTo8601Seconds(window) +// return nil +// } +//} +// +//// EnsureSubscription creates a subscription if the subscription does not exist +//func (sb *serviceBus) EnsureSubscription(ctx context.Context, topicName, name string, opts ...SubscriptionOption) (*mgmt.SBSubscription, error) { +// log.Debugf("ensuring subscription %s exists", name) +// subClient := sb.getSubscriptionMgmtClient() +// subscription, err := subClient.Get(ctx, sb.resourceGroup, sb.namespace, topicName, name) +// +// if err != nil { +// newSub := &mgmt.SBSubscription{ +// Name: &name, +// SBSubscriptionProperties: &mgmt.SBSubscriptionProperties{ +// EnableBatchedOperations: ptrBool(false), +// }, +// } +// +// for _, opt := range opts { +// err = opt(newSub) +// if err != nil { +// return nil, err +// } +// } +// +// subscription, err = subClient.CreateOrUpdate(ctx, sb.resourceGroup, sb.namespace, topicName, name, *newSub) +// if err != nil { +// return nil, err +// } +// } +// return &subscription, nil +//} +// +//// GetSubscription fetches a topic by name +//func (sb *serviceBus) GetSubscription(ctx context.Context, topicName, name string) (*mgmt.SBSubscription, error) { +// client := sb.getSubscriptionMgmtClient() +// subscription, err := client.Get(ctx, sb.resourceGroup, sb.namespace, topicName, name) +// if err != nil { +// return nil, err +// } +// return &subscription, nil +//} +// +//// DeleteSubscription deletes an existing subscription +//func (sb *serviceBus) DeleteSubscription(ctx context.Context, topicName, name string) error { +// subscriptionClient := sb.getSubscriptionMgmtClient() +// _, err := subscriptionClient.Delete(ctx, sb.resourceGroup, sb.namespace, topicName, name) +// return err +//} +// +//func (sb *serviceBus) getSubscriptionMgmtClient() *mgmt.SubscriptionsClient { +// client := mgmt.NewSubscriptionsClientWithBaseURI(sb.environment.ResourceManagerEndpoint, sb.subscriptionID) +// client.Authorizer = autorest.NewBearerAuthorizer(sb.armToken) +// return &client +//} diff --git a/subscription_test.go b/subscription_test.go index 94a2e2811859..a88729cd213a 100644 --- a/subscription_test.go +++ b/subscription_test.go @@ -1,108 +1,107 @@ package servicebus -import ( - "context" - "fmt" - "testing" - "time" - - mgmt "github.com/Azure/azure-sdk-for-go/services/servicebus/mgmt/2017-04-01/servicebus" - log "github.com/sirupsen/logrus" - "github.com/stretchr/testify/assert" -) - -func (suite *ServiceBusSuite) TestSubscriptionManagement() { - tests := map[string]func(*testing.T, SenderReceiverManager, string, string){ - "TestSubscriptionDefaultSettings": testDefaultSubscription, - "TestSubscriptionWithAutoDeleteOnIdle": testSubscriptionWithAutoDeleteOnIdle, - "TestSubscriptionWithRequiredSessions": testSubscriptionWithRequiredSessions, - "TestSubscriptionWithDeadLetteringOnMessageExpiration": testSubscriptionWithDeadLetteringOnMessageExpiration, - "TestSubscriptionWithMessageTimeToLive": testSubscriptionWithMessageTimeToLive, - "TestSubscriptionWithLockDuration": testSubscriptionWithLockDuration, - "TestSubscriptionWithBatchedOperations": testSubscriptionWithBatchedOperations, - } - - sb := suite.getNewInstance() - defer func() { - sb.Close() - }() - - for name, testFunc := range tests { - setupTestTeardown := func(t *testing.T) { - ctx := context.Background() - topicName := randomName("gosbtest", 10) - subName := randomName("gosbtest", 10) - _, err := sb.EnsureTopic(ctx, topicName) - if err != nil { - log.Fatalln(err) - } - - defer func(tName, sName string) { - err = sb.DeleteSubscription(ctx, tName, sName) - err2 := sb.DeleteTopic(ctx, tName) - if err != nil { - log.Fatalln(err) - } - - if err2 != nil { - log.Fatalln(err2) - } - }(topicName, subName) - - testFunc(t, sb, topicName, subName) - } - suite.T().Run(name, setupTestTeardown) - } -} - -func testDefaultSubscription(t *testing.T, sb SenderReceiverManager, topicName, name string) { - s := buildSubscription(t, sb, topicName, name) - assert.False(t, *s.DeadLetteringOnMessageExpiration, "should not have dead lettering on expiration") - assert.False(t, *s.RequiresSession, "should not require session") - assert.Equal(t, "P10675199DT2H48M5.4775807S", *s.AutoDeleteOnIdle, "auto delete is not 10 minutes") - assert.Nil(t, s.DuplicateDetectionHistoryTimeWindow, "dup detection is nil") - assert.Equal(t, "P10675199DT2H48M5.4775807S", *s.DefaultMessageTimeToLive, "default TTL") - assert.Equal(t, mgmt.Active, s.Status, "subscription status") - assert.Equal(t, "PT1M", *s.LockDuration, "lock duration") -} - -func testSubscriptionWithBatchedOperations(t *testing.T, sb SenderReceiverManager, topicName, name string) { - s := buildSubscription(t, sb, topicName, name, SubscriptionWithBatchedOperations()) - assert.True(t, *s.EnableBatchedOperations) -} - -func testSubscriptionWithAutoDeleteOnIdle(t *testing.T, sb SenderReceiverManager, topicName, name string) { - window := time.Duration(20 * time.Minute) - s := buildSubscription(t, sb, topicName, name, SubscriptionWithAutoDeleteOnIdle(&window)) - assert.Equal(t, "PT20M", *s.AutoDeleteOnIdle) -} - -func testSubscriptionWithRequiredSessions(t *testing.T, sb SenderReceiverManager, topicName, name string) { - s := buildSubscription(t, sb, topicName, name, SubscriptionWithRequiredSessions()) - assert.True(t, *s.RequiresSession) -} - -func testSubscriptionWithDeadLetteringOnMessageExpiration(t *testing.T, sb SenderReceiverManager, topicName, name string) { - s := buildSubscription(t, sb, topicName, name, SubscriptionWithDeadLetteringOnMessageExpiration()) - assert.True(t, *s.DeadLetteringOnMessageExpiration) -} - -func testSubscriptionWithMessageTimeToLive(t *testing.T, sb SenderReceiverManager, topicName, name string) { - window := time.Duration(10 * 24 * 60 * time.Minute) - s := buildSubscription(t, sb, topicName, name, SubscriptionWithMessageTimeToLive(&window)) - assert.Equal(t, "P10D", *s.DefaultMessageTimeToLive) -} - -func testSubscriptionWithLockDuration(t *testing.T, sb SenderReceiverManager, topicName, name string) { - window := time.Duration(3 * time.Minute) - s := buildSubscription(t, sb, topicName, name, SubscriptionWithLockDuration(&window)) - assert.Equal(t, "PT3M", *s.LockDuration) -} - -func buildSubscription(t *testing.T, sb SenderReceiverManager, topicName, name string, opts ...SubscriptionOption) *mgmt.SBSubscription { - s, err := sb.EnsureSubscription(context.Background(), topicName, name, opts...) - if err != nil { - assert.FailNow(t, fmt.Sprintf("%v", err)) - } - return s -} +//import ( +// "context" +// "fmt" +// mgmt "github.com/Azure/azure-sdk-for-go/services/servicebus/mgmt/2017-04-01/servicebus" +// "github.com/stretchr/testify/assert" +// "testing" +// "time" +// "log" +//) +// +//func (suite *ServiceBusSuite) TestSubscriptionManagement() { +// tests := map[string]func(*testing.T, SenderReceiverManager, string, string){ +// "TestSubscriptionDefaultSettings": testDefaultSubscription, +// "TestSubscriptionWithAutoDeleteOnIdle": testSubscriptionWithAutoDeleteOnIdle, +// "TestSubscriptionWithRequiredSessions": testSubscriptionWithRequiredSessions, +// "TestSubscriptionWithDeadLetteringOnMessageExpiration": testSubscriptionWithDeadLetteringOnMessageExpiration, +// "TestSubscriptionWithMessageTimeToLive": testSubscriptionWithMessageTimeToLive, +// "TestSubscriptionWithLockDuration": testSubscriptionWithLockDuration, +// "TestSubscriptionWithBatchedOperations": testSubscriptionWithBatchedOperations, +// } +// +// sb := suite.getNewSasInstance() +// defer func() { +// sb.Close() +// }() +// +// for name, testFunc := range tests { +// setupTestTeardown := func(t *testing.T) { +// ctx := context.Background() +// topicName := randomName("gosbtest", 10) +// subName := randomName("gosbtest", 10) +// _, err := sb.EnsureTopic(ctx, topicName) +// if err != nil { +// log.Fatalln(err) +// } +// +// defer func(tName, sName string) { +// err = sb.DeleteSubscription(ctx, tName, sName) +// err2 := sb.DeleteTopic(ctx, tName) +// if err != nil { +// log.Fatalln(err) +// } +// +// if err2 != nil { +// log.Fatalln(err2) +// } +// }(topicName, subName) +// +// testFunc(t, sb, topicName, subName) +// } +// suite.T().Run(name, setupTestTeardown) +// } +//} +// +//func testDefaultSubscription(t *testing.T, sb SenderReceiverManager, topicName, name string) { +// s := buildSubscription(t, sb, topicName, name) +// assert.False(t, *s.DeadLetteringOnMessageExpiration, "should not have dead lettering on expiration") +// assert.False(t, *s.RequiresSession, "should not require session") +// assert.Equal(t, "P10675199DT2H48M5.4775807S", *s.AutoDeleteOnIdle, "auto delete is not 10 minutes") +// assert.Nil(t, s.DuplicateDetectionHistoryTimeWindow, "dup detection is nil") +// assert.Equal(t, "P10675199DT2H48M5.4775807S", *s.DefaultMessageTimeToLive, "default TTL") +// assert.Equal(t, mgmt.Active, s.Status, "subscription status") +// assert.Equal(t, "PT1M", *s.LockDuration, "lock duration") +//} +// +//func testSubscriptionWithBatchedOperations(t *testing.T, sb SenderReceiverManager, topicName, name string) { +// s := buildSubscription(t, sb, topicName, name, SubscriptionWithBatchedOperations()) +// assert.True(t, *s.EnableBatchedOperations) +//} +// +//func testSubscriptionWithAutoDeleteOnIdle(t *testing.T, sb SenderReceiverManager, topicName, name string) { +// window := time.Duration(20 * time.Minute) +// s := buildSubscription(t, sb, topicName, name, SubscriptionWithAutoDeleteOnIdle(&window)) +// assert.Equal(t, "PT20M", *s.AutoDeleteOnIdle) +//} +// +//func testSubscriptionWithRequiredSessions(t *testing.T, sb SenderReceiverManager, topicName, name string) { +// s := buildSubscription(t, sb, topicName, name, SubscriptionWithRequiredSessions()) +// assert.True(t, *s.RequiresSession) +//} +// +//func testSubscriptionWithDeadLetteringOnMessageExpiration(t *testing.T, sb SenderReceiverManager, topicName, name string) { +// s := buildSubscription(t, sb, topicName, name, SubscriptionWithDeadLetteringOnMessageExpiration()) +// assert.True(t, *s.DeadLetteringOnMessageExpiration) +//} +// +//func testSubscriptionWithMessageTimeToLive(t *testing.T, sb SenderReceiverManager, topicName, name string) { +// window := time.Duration(10 * 24 * 60 * time.Minute) +// s := buildSubscription(t, sb, topicName, name, SubscriptionWithMessageTimeToLive(&window)) +// assert.Equal(t, "P10D", *s.DefaultMessageTimeToLive) +//} +// +//func testSubscriptionWithLockDuration(t *testing.T, sb SenderReceiverManager, topicName, name string) { +// window := time.Duration(3 * time.Minute) +// s := buildSubscription(t, sb, topicName, name, SubscriptionWithLockDuration(&window)) +// assert.Equal(t, "PT3M", *s.LockDuration) +//} +// +//func buildSubscription(t *testing.T, sb SenderReceiverManager, topicName, name string, opts ...SubscriptionOption) *mgmt.SBSubscription { +// s, err := sb.EnsureSubscription(context.Background(), topicName, name, opts...) +// if err != nil { +// assert.FailNow(t, fmt.Sprintf("%v", err)) +// } +// return s +//} diff --git a/topic.go b/topic.go index da49aa064e27..aeaa21a7a2be 100644 --- a/topic.go +++ b/topic.go @@ -1,151 +1,155 @@ package servicebus -import ( - "context" - "errors" - "time" - - mgmt "github.com/Azure/azure-sdk-for-go/services/servicebus/mgmt/2017-04-01/servicebus" - "github.com/Azure/go-autorest/autorest" - log "github.com/sirupsen/logrus" -) - -const ( - // Megabytes is a helper for specifying MaxSizeInMegabytes and equals 1024, thus 5 GB is 5 * Megabytes - Megabytes = 1024 -) - -type ( - // TopicOption represents an option for configuring a topic. - TopicOption func(*mgmt.SBTopic) error -) - -// TopicWithMaxSizeInMegabytes configures the maximum size of the topic in megabytes (1 * 1024 - 5 * 1024), which is the size of -// the memory allocated for the topic. Default is 1 MB (1 * 1024). -func TopicWithMaxSizeInMegabytes(size int) TopicOption { - return func(t *mgmt.SBTopic) error { - if size < 1*Megabytes || size > 5*Megabytes { - return errors.New("TopicWithMaxSizeInMegabytes: must be between 1 * Megabytes and 5 * Megabytes") - } - size32 := int32(size) - t.MaxSizeInMegabytes = &size32 - return nil - } -} - -// TopicWithPartitioning configures the topic to be partitioned across multiple message brokers. -func TopicWithPartitioning() TopicOption { - return func(t *mgmt.SBTopic) error { - t.EnablePartitioning = ptrBool(true) - return nil - } -} - -// TopicWithOrdering configures the topic to support ordering of messages. -func TopicWithOrdering() TopicOption { - return func(t *mgmt.SBTopic) error { - t.SupportOrdering = ptrBool(true) - return nil - } -} - -// TopicWithDuplicateDetection configures the topic to detect duplicates for a given time window. If window -// is not specified, then it uses the default of 10 minutes. -func TopicWithDuplicateDetection(window *time.Duration) TopicOption { - return func(t *mgmt.SBTopic) error { - t.RequiresDuplicateDetection = ptrBool(true) - if window != nil { - t.DuplicateDetectionHistoryTimeWindow = durationTo8601Seconds(window) - } - return nil - } -} - -// TopicWithExpress configures the topic to hold a message in memory temporarily before writing it to persistent storage. -func TopicWithExpress() TopicOption { - return func(t *mgmt.SBTopic) error { - t.EnableExpress = ptrBool(true) - return nil - } -} - -// TopicWithBatchedOperations configures the topic to batch server-side operations. -func TopicWithBatchedOperations() TopicOption { - return func(t *mgmt.SBTopic) error { - t.EnableBatchedOperations = ptrBool(true) - return nil - } -} - -// TopicWithAutoDeleteOnIdle configures the topic to automatically delete after the specified idle interval. The -// minimum duration is 5 minutes. -func TopicWithAutoDeleteOnIdle(window *time.Duration) TopicOption { - return func(t *mgmt.SBTopic) error { - if window != nil { - if window.Minutes() < 5 { - return errors.New("TopicWithAutoDeleteOnIdle: window must be greater than 5 minutes") - } - t.AutoDeleteOnIdle = durationTo8601Seconds(window) - } - return nil - } -} - -// TopicWithMessageTimeToLive configures the topic to set a time to live on messages. This is the duration after which -// the message expires, starting from when the message is sent to Service Bus. This is the default value used when -// TimeToLive is not set on a message itself. If nil, defaults to 14 days. -func TopicWithMessageTimeToLive(window *time.Duration) TopicOption { - return func(t *mgmt.SBTopic) error { - if window == nil { - duration := 14 * 24 * time.Hour - window = &duration - } - t.DefaultMessageTimeToLive = durationTo8601Seconds(window) - return nil - } -} - -// EnsureTopic creates a topic if an existing topic does not exist -func (sb *serviceBus) EnsureTopic(ctx context.Context, name string, opts ...TopicOption) (*mgmt.SBTopic, error) { - log.Debugf("ensuring topic %s exists", name) - topicClient := sb.getTopicMgmtClient() - topic, err := topicClient.Get(ctx, sb.resourceGroup, sb.namespace, name) - - if err != nil { - newTopic := &mgmt.SBTopic{ - Name: &name, - SBTopicProperties: &mgmt.SBTopicProperties{ - EnablePartitioning: ptrBool(false), - EnableBatchedOperations: ptrBool(false), - EnableExpress: ptrBool(false), - SupportOrdering: ptrBool(false), - }, - } - - for _, opt := range opts { - err = opt(newTopic) - if err != nil { - return nil, err - } - } - - topic, err = topicClient.CreateOrUpdate(ctx, sb.resourceGroup, sb.namespace, name, *newTopic) - if err != nil { - return nil, err - } - } - return &topic, nil -} - -// DeleteTopic deletes an existing topic -func (sb *serviceBus) DeleteTopic(ctx context.Context, topicName string) error { - topicClient := sb.getTopicMgmtClient() - _, err := topicClient.Delete(ctx, sb.resourceGroup, sb.namespace, topicName) - return err -} - -func (sb *serviceBus) getTopicMgmtClient() *mgmt.TopicsClient { - client := mgmt.NewTopicsClientWithBaseURI(sb.environment.ResourceManagerEndpoint, sb.subscriptionID) - client.Authorizer = autorest.NewBearerAuthorizer(sb.armToken) - return &client -} +// +//import ( +// "context" +// "errors" +// mgmt "github.com/Azure/azure-sdk-for-go/services/servicebus/mgmt/2017-04-01/servicebus" +// "github.com/Azure/go-autorest/autorest" +// "time" +//) +// +// +//type ( +// // TopicOption represents an option for configuring a topic. +// TopicOption func(*mgmt.SBTopic) error +//) +// +//// TopicWithMaxSizeInMegabytes configures the maximum size of the topic in megabytes (1 * 1024 - 5 * 1024), which is the size of +//// the memory allocated for the topic. Default is 1 MB (1 * 1024). +//func TopicWithMaxSizeInMegabytes(size int) TopicOption { +// return func(t *mgmt.SBTopic) error { +// if size < 1*Megabytes || size > 5*Megabytes { +// return errors.New("TopicWithMaxSizeInMegabytes: must be between 1 * Megabytes and 5 * Megabytes") +// } +// size32 := int32(size) +// t.MaxSizeInMegabytes = &size32 +// return nil +// } +//} +// +//// TopicWithPartitioning configures the topic to be partitioned across multiple message brokers. +//func TopicWithPartitioning() TopicOption { +// return func(t *mgmt.SBTopic) error { +// t.EnablePartitioning = ptrBool(true) +// return nil +// } +//} +// +//// TopicWithOrdering configures the topic to support ordering of messages. +//func TopicWithOrdering() TopicOption { +// return func(t *mgmt.SBTopic) error { +// t.SupportOrdering = ptrBool(true) +// return nil +// } +//} +// +//// TopicWithDuplicateDetection configures the topic to detect duplicates for a given time window. If window +//// is not specified, then it uses the default of 10 minutes. +//func TopicWithDuplicateDetection(window *time.Duration) TopicOption { +// return func(t *mgmt.SBTopic) error { +// t.RequiresDuplicateDetection = ptrBool(true) +// if window != nil { +// t.DuplicateDetectionHistoryTimeWindow = durationTo8601Seconds(window) +// } +// return nil +// } +//} +// +//// TopicWithExpress configures the topic to hold a message in memory temporarily before writing it to persistent storage. +//func TopicWithExpress() TopicOption { +// return func(t *mgmt.SBTopic) error { +// t.EnableExpress = ptrBool(true) +// return nil +// } +//} +// +//// TopicWithBatchedOperations configures the topic to batch server-side operations. +//func TopicWithBatchedOperations() TopicOption { +// return func(t *mgmt.SBTopic) error { +// t.EnableBatchedOperations = ptrBool(true) +// return nil +// } +//} +// +//// TopicWithAutoDeleteOnIdle configures the topic to automatically delete after the specified idle interval. The +//// minimum duration is 5 minutes. +//func TopicWithAutoDeleteOnIdle(window *time.Duration) TopicOption { +// return func(t *mgmt.SBTopic) error { +// if window != nil { +// if window.Minutes() < 5 { +// return errors.New("TopicWithAutoDeleteOnIdle: window must be greater than 5 minutes") +// } +// t.AutoDeleteOnIdle = durationTo8601Seconds(window) +// } +// return nil +// } +//} +// +//// TopicWithMessageTimeToLive configures the topic to set a time to live on messages. This is the duration after which +//// the message expires, starting from when the message is sent to Service Bus. This is the default value used when +//// TimeToLive is not set on a message itself. If nil, defaults to 14 days. +//func TopicWithMessageTimeToLive(window *time.Duration) TopicOption { +// return func(t *mgmt.SBTopic) error { +// if window == nil { +// duration := time.Duration(14 * 24 * time.Hour) +// window = &duration +// } +// t.DefaultMessageTimeToLive = durationTo8601Seconds(window) +// return nil +// } +//} +// +//// EnsureTopic creates a topic if an existing topic does not exist +//func (sb *serviceBus) EnsureTopic(ctx context.Context, name string, opts ...TopicOption) (*mgmt.SBTopic, error) { +// topicClient := sb.getTopicMgmtClient() +// topic, err := topicClient.Get(ctx, sb.resourceGroup, sb.namespace, name) +// +// if err != nil { +// newTopic := &mgmt.SBTopic{ +// Name: &name, +// SBTopicProperties: &mgmt.SBTopicProperties{ +// EnablePartitioning: ptrBool(false), +// EnableBatchedOperations: ptrBool(false), +// EnableExpress: ptrBool(false), +// SupportOrdering: ptrBool(false), +// }, +// } +// +// for _, opt := range opts { +// err = opt(newTopic) +// if err != nil { +// return nil, err +// } +// } +// +// topic, err = topicClient.CreateOrUpdate(ctx, sb.resourceGroup, sb.namespace, name, *newTopic) +// if err != nil { +// return nil, err +// } +// } +// return &topic, nil +//} +// +//// GetTopic fetches a topic by name +//func (sb *serviceBus) GetTopic(ctx context.Context, name string) (*mgmt.SBTopic, error) { +// client := sb.getTopicMgmtClient() +// topic, err := client.Get(ctx, sb.resourceGroup, sb.namespace, name) +// if err != nil { +// return nil, err +// } +// return &topic, nil +//} +// +//// DeleteTopic deletes an existing topic +//func (sb *serviceBus) DeleteTopic(ctx context.Context, topicName string) error { +// topicClient := sb.getTopicMgmtClient() +// _, err := topicClient.Delete(ctx, sb.resourceGroup, sb.namespace, topicName) +// return err +//} +// +//func (sb *serviceBus) getTopicMgmtClient() *mgmt.TopicsClient { +// client := mgmt.NewTopicsClientWithBaseURI(sb.environment.ResourceManagerEndpoint, sb.subscriptionID) +// client.Authorizer = autorest.NewBearerAuthorizer(sb.armToken) +// return &client +//} diff --git a/topic_test.go b/topic_test.go index 67a14110d76b..308648f466e8 100644 --- a/topic_test.go +++ b/topic_test.go @@ -1,130 +1,131 @@ package servicebus -import ( - "context" - "fmt" - "testing" - "time" - - mgmt "github.com/Azure/azure-sdk-for-go/services/servicebus/mgmt/2017-04-01/servicebus" - log "github.com/sirupsen/logrus" - "github.com/stretchr/testify/assert" -) - -func (suite *ServiceBusSuite) TestTopicManagement() { - tests := map[string]func(*testing.T, SenderReceiverManager, string){ - "DefaultTopicCreation": testDefaultTopic, - "TopicWithPartitioning": testPartitionedTopic, - "TopicWithOrdering": testSupportOrdering, - "TopicWithDuplicateDetection": testTopicWithDuplicateDetection, - "TopicWithAutoDeleteOnIdle": testTopicWithAutoDeleteOnIdle, - "TopicWithTimeToLive": testTopicWithMessageTimeToLive, - "TopicWithBatchOperations": testTopicWithBatchedOperations, - "TopicWithExpress": testTopicWithExpress, - "TopicWithMaxSizeInMegabytes": testTopicWithMaxSizeInMegabytes, - } - - sb := suite.getNewInstance() - defer func() { - sb.Close() - }() - - for name, testFunc := range tests { - setupTestTeardown := func(t *testing.T) { - entityName := randomName("gosbtest", 10) - defer func(name string) { - err := sb.DeleteTopic(context.Background(), name) - if err != nil { - log.Fatalln(err) - } - }(entityName) - testFunc(t, sb, entityName) - - } - suite.T().Run(name, setupTestTeardown) - } -} - -func testDefaultTopic(t *testing.T, sb SenderReceiverManager, name string) { - topic := buildTopic(t, sb, name) - assert.False(t, *topic.EnableExpress, "should not have Express enabled") - assert.False(t, *topic.EnableBatchedOperations, "should not have batching enabled") - assert.False(t, *topic.EnablePartitioning, "should not have partitioning enabled") - assert.False(t, *topic.SupportOrdering, "should not support ordering") - assert.False(t, *topic.RequiresDuplicateDetection, "should not require dup detection") - assert.Equal(t, "P10675199DT2H48M5.4775807S", *topic.AutoDeleteOnIdle, "auto delete is not 10 minutes") - assert.Equal(t, "PT10M", *topic.DuplicateDetectionHistoryTimeWindow, "dup detection is not 10 minutes") - assert.Equal(t, mgmt.Active, topic.Status, "topic status") -} - -func testPartitionedTopic(t *testing.T, sb SenderReceiverManager, name string) { - topic := buildTopic(t, sb, name, TopicWithPartitioning()) - assert.True(t, *topic.EnablePartitioning) -} - -func testSupportOrdering(t *testing.T, sb SenderReceiverManager, name string) { - topic := buildTopic(t, sb, name, TopicWithOrdering()) - assert.True(t, *topic.SupportOrdering) -} - -func testTopicWithDuplicateDetection(t *testing.T, sb SenderReceiverManager, name string) { - window := time.Duration(20 * time.Minute) - topic := buildTopic(t, sb, name, TopicWithDuplicateDetection(&window)) - assert.True(t, *topic.RequiresDuplicateDetection) - assert.Equal(t, "PT20M", *topic.DuplicateDetectionHistoryTimeWindow) -} - -func testTopicWithAutoDeleteOnIdle(t *testing.T, sb SenderReceiverManager, name string) { - window := time.Duration(20 * time.Minute) - topic := buildTopic(t, sb, name, TopicWithAutoDeleteOnIdle(&window)) - assert.Equal(t, "PT20M", *topic.AutoDeleteOnIdle) -} - -func testTopicWithBatchedOperations(t *testing.T, sb SenderReceiverManager, name string) { - topic := buildTopic(t, sb, name, TopicWithBatchedOperations()) - assert.True(t, *topic.EnableBatchedOperations) -} - -func testTopicWithExpress(t *testing.T, sb SenderReceiverManager, name string) { - topic := buildTopic(t, sb, name, TopicWithExpress()) - assert.True(t, *topic.EnableExpress) -} - -func testTopicWithMessageTimeToLive(t *testing.T, sb SenderReceiverManager, name string) { - window := time.Duration(20 * time.Minute) - topic := buildTopic(t, sb, name, TopicWithMessageTimeToLive(&window)) - assert.Equal(t, "PT20M", *topic.DefaultMessageTimeToLive) -} - -func testTopicWithMaxSizeInMegabytes(t *testing.T, sb SenderReceiverManager, name string) { - size := 2 * Megabytes - topic := buildTopic(t, sb, name, TopicWithMaxSizeInMegabytes(size)) - assert.Equal(t, int32(size), *topic.MaxSizeInMegabytes) -} - -func buildTopic(t *testing.T, sb SenderReceiverManager, name string, opts ...TopicOption) *mgmt.SBTopic { - topic, err := sb.EnsureTopic(context.Background(), name, opts...) - if err != nil { - assert.FailNow(t, fmt.Sprintf("%v", err)) - } - return topic -} - -func (suite *ServiceBusSuite) TestTopicSend() { - tests := map[string]func(*testing.T, SenderReceiverManager, string){} - - sb := suite.getNewInstance() - defer func() { - sb.Close() - }() - - for name, testFunc := range tests { - entityName := randomName("gosbtest", 10) - sb.EnsureTopic(context.Background(), entityName) - suite.T().Run(name, func(t *testing.T) { testFunc(t, sb, entityName) }) - err := sb.DeleteTopic(context.Background(), entityName) - if err != nil { - log.Fatalln(err) - } - } -} +//import ( +// "context" +// "fmt" +// mgmt "github.com/Azure/azure-sdk-for-go/services/servicebus/mgmt/2017-04-01/servicebus" +// "github.com/stretchr/testify/assert" +// "testing" +// "time" +//) +// +//func (suite *ServiceBusSuite) TestTopicManagement() { +// tests := map[string]func(*testing.T, SenderReceiverManager, string){ +// "DefaultTopicCreation": testDefaultTopic, +// "TopicWithPartitioning": testPartitionedTopic, +// "TopicWithOrdering": testSupportOrdering, +// "TopicWithDuplicateDetection": testTopicWithDuplicateDetection, +// "TopicWithAutoDeleteOnIdle": testTopicWithAutoDeleteOnIdle, +// "TopicWithTimeToLive": testTopicWithMessageTimeToLive, +// "TopicWithBatchOperations": testTopicWithBatchedOperations, +// "TopicWithExpress": testTopicWithExpress, +// "TopicWithMaxSizeInMegabytes": testTopicWithMaxSizeInMegabytes, +// } +// +// sb := suite.getNewSasInstance() +// defer func() { +// sb.Close() +// }() +// +// for name, testFunc := range tests { +// setupTestTeardown := func(t *testing.T) { +// entityName := randomName("gosbtest", 10) +// defer func(name string) { +// err := sb.DeleteTopic(context.Background(), name) +// if err != nil { +// t.Fatal(err) +// } +// }(entityName) +// testFunc(t, sb, entityName) +// +// } +// suite.T().Run(name, setupTestTeardown) +// } +//} +// +//func testDefaultTopic(t *testing.T, sb SenderReceiverManager, name string) { +// topic := buildTopic(t, sb, name) +// assert.False(t, *topic.EnableExpress, "should not have Express enabled") +// assert.False(t, *topic.EnableBatchedOperations, "should not have batching enabled") +// assert.False(t, *topic.EnablePartitioning, "should not have partitioning enabled") +// assert.False(t, *topic.SupportOrdering, "should not support ordering") +// assert.False(t, *topic.RequiresDuplicateDetection, "should not require dup detection") +// assert.Equal(t, "P10675199DT2H48M5.4775807S", *topic.AutoDeleteOnIdle, "auto delete is not 10 minutes") +// assert.Equal(t, "PT10M", *topic.DuplicateDetectionHistoryTimeWindow, "dup detection is not 10 minutes") +// assert.Equal(t, mgmt.Active, topic.Status, "topic status") +//} +// +//func testPartitionedTopic(t *testing.T, sb SenderReceiverManager, name string) { +// topic := buildTopic(t, sb, name, TopicWithPartitioning()) +// assert.True(t, *topic.EnablePartitioning) +//} +// +//func testSupportOrdering(t *testing.T, sb SenderReceiverManager, name string) { +// topic := buildTopic(t, sb, name, TopicWithOrdering()) +// assert.True(t, *topic.SupportOrdering) +//} +// +//func testTopicWithDuplicateDetection(t *testing.T, sb SenderReceiverManager, name string) { +// window := time.Duration(20 * time.Minute) +// topic := buildTopic(t, sb, name, TopicWithDuplicateDetection(&window)) +// assert.True(t, *topic.RequiresDuplicateDetection) +// assert.Equal(t, "PT20M", *topic.DuplicateDetectionHistoryTimeWindow) +//} +// +//func testTopicWithAutoDeleteOnIdle(t *testing.T, sb SenderReceiverManager, name string) { +// window := time.Duration(20 * time.Minute) +// topic := buildTopic(t, sb, name, TopicWithAutoDeleteOnIdle(&window)) +// assert.Equal(t, "PT20M", *topic.AutoDeleteOnIdle) +//} +// +//func testTopicWithBatchedOperations(t *testing.T, sb SenderReceiverManager, name string) { +// topic := buildTopic(t, sb, name, TopicWithBatchedOperations()) +// assert.True(t, *topic.EnableBatchedOperations) +//} +// +//func testTopicWithExpress(t *testing.T, sb SenderReceiverManager, name string) { +// topic := buildTopic(t, sb, name, TopicWithExpress()) +// assert.True(t, *topic.EnableExpress) +//} +// +//func testTopicWithMessageTimeToLive(t *testing.T, sb SenderReceiverManager, name string) { +// window := time.Duration(20 * time.Minute) +// topic := buildTopic(t, sb, name, TopicWithMessageTimeToLive(&window)) +// assert.Equal(t, "PT20M", *topic.DefaultMessageTimeToLive) +//} +// +//func testTopicWithMaxSizeInMegabytes(t *testing.T, sb SenderReceiverManager, name string) { +// size := 2 * Megabytes +// topic := buildTopic(t, sb, name, TopicWithMaxSizeInMegabytes(size)) +// assert.Equal(t, int32(size), *topic.MaxSizeInMegabytes) +//} +// +//func buildTopic(t *testing.T, sb SenderReceiverManager, name string, opts ...TopicOption) *mgmt.SBTopic { +// topic, err := sb.EnsureTopic(context.Background(), name, opts...) +// if err != nil { +// assert.FailNow(t, fmt.Sprintf("%v", err)) +// } +// return topic +//} +// +//func (suite *ServiceBusSuite) TestTopicSend() { +// tests := make(map[string]func(*testing.T, SenderReceiverManager, string)) +// +// sb := suite.getNewSasInstance() +// defer func() { +// sb.Close() +// }() +// +// for name, testFunc := range tests { +// wrappedTest := func(t *testing.T) { +// entityName := randomName("gosbtest", 10) +// sb.EnsureTopic(context.Background(), entityName) +// testFunc(suite.T(), sb, entityName) +// err := sb.DeleteTopic(context.Background(), entityName) +// if err != nil { +// suite.T().Fatal(err) +// } +// } +// suite.T().Run(name, wrappedTest) +// } +//} diff --git a/tracing.go b/tracing.go new file mode 100644 index 000000000000..e5ed9a8a3409 --- /dev/null +++ b/tracing.go @@ -0,0 +1,61 @@ +package servicebus + +import ( + "context" + "os" + + "github.com/opentracing/opentracing-go" + tag "github.com/opentracing/opentracing-go/ext" +) + +//func (sb *serviceBus) startSpanFromContext(ctx context.Context, operationName string, opts ...opentracing.StartSpanOption) (opentracing.Span, context.Context) { +// span, ctx := opentracing.StartSpanFromContext(ctx, operationName, opts...) +// ApplyComponentInfo(span) +// return span, ctx +//} + +func (ns *Namespace) startSpanFromContext(ctx context.Context, operationName string, opts ...opentracing.StartSpanOption) (opentracing.Span, context.Context) { + span, ctx := opentracing.StartSpanFromContext(ctx, operationName, opts...) + ApplyComponentInfo(span) + return span, ctx +} + +func (s *sender) startProducerSpanFromContext(ctx context.Context, operationName string, opts ...opentracing.StartSpanOption) (opentracing.Span, context.Context) { + span, ctx := opentracing.StartSpanFromContext(ctx, operationName, opts...) + ApplyComponentInfo(span) + tag.SpanKindProducer.Set(span) + tag.MessageBusDestination.Set(span, s.getFullIdentifier()) + return span, ctx +} + +func (r *receiver) startConsumerSpanFromContext(ctx context.Context, operationName string, opts ...opentracing.StartSpanOption) (opentracing.Span, context.Context) { + span, ctx := opentracing.StartSpanFromContext(ctx, operationName, opts...) + ApplyComponentInfo(span) + tag.SpanKindConsumer.Set(span) + tag.MessageBusDestination.Set(span, r.entityPath) + return span, ctx +} + +func (r *receiver) startConsumerSpanFromWire(ctx context.Context, operationName string, reference opentracing.SpanContext, opts ...opentracing.StartSpanOption) (opentracing.Span, context.Context) { + opts = append(opts, opentracing.FollowsFrom(reference)) + span := opentracing.StartSpan(operationName, opts...) + ctx = opentracing.ContextWithSpan(ctx, span) + ApplyComponentInfo(span) + tag.SpanKindConsumer.Set(span) + tag.MessageBusDestination.Set(span, r.entityPath) + return span, ctx +} + +// ApplyComponentInfo applies eventhub library and network info to the span +func ApplyComponentInfo(span opentracing.Span) { + tag.Component.Set(span, "github.com/Azure/azure-service-bus-go") + span.SetTag("version", Version) + applyNetworkInfo(span) +} + +func applyNetworkInfo(span opentracing.Span) { + hostname, err := os.Hostname() + if err == nil { + tag.PeerHostname.Set(span, hostname) + } +} From 3a00c66015d07bdbf530e34ce72df861d41b08fd Mon Sep 17 00:00:00 2001 From: David Justice Date: Fri, 4 May 2018 14:42:00 -0700 Subject: [PATCH 2/4] topics management and sending to topic is working --- event.go | 17 +- mgmt.go | 36 +++++ mgmt_test.go | 4 +- queue.go | 41 +---- queue_test.go | 209 ++++++++++++++---------- sender.go | 19 +-- topic.go | 431 +++++++++++++++++++++++++++++++------------------ topic_test.go | 432 +++++++++++++++++++++++++++++++++++--------------- 8 files changed, 764 insertions(+), 425 deletions(-) diff --git a/event.go b/event.go index 8b3f85c83dbe..a25fc8d050a3 100644 --- a/event.go +++ b/event.go @@ -26,15 +26,10 @@ import ( "pack.ag/amqp" ) -const ( - partitionKeyAnnotationName = "x-opt-partition-key" -) - type ( // Event is an Event Hubs message to be sent or received Event struct { Data []byte - PartitionKey *string Properties map[string]interface{} ID string GroupID *string @@ -45,7 +40,6 @@ type ( // EventBatch is a batch of Event Hubs messages to be sent EventBatch struct { Events []*Event - PartitionKey *string Properties map[string]interface{} ID string } @@ -99,6 +93,11 @@ func (e *Event) toMsg() *amqp.Message { MessageID: e.ID, } + if e.GroupID != nil && e.GroupSequence != nil { + msg.Properties.GroupID = *e.GroupID + msg.Properties.GroupSequence = *e.GroupSequence + } + if len(e.Properties) > 0 { msg.ApplicationProperties = make(map[string]interface{}) for key, value := range e.Properties { @@ -106,10 +105,6 @@ func (e *Event) toMsg() *amqp.Message { } } - if e.PartitionKey != nil { - msg.Annotations = make(amqp.Annotations) - msg.Annotations[partitionKeyAnnotationName] = e.PartitionKey - } return msg } @@ -127,6 +122,8 @@ func newEvent(data []byte, msg *amqp.Message) *Event { if id, ok := msg.Properties.MessageID.(string); ok { event.ID = id } + event.GroupID = &msg.Properties.GroupID + event.GroupSequence = &msg.Properties.GroupSequence } if msg != nil { diff --git a/mgmt.go b/mgmt.go index f4dfb9aaa66d..d25271acde2a 100644 --- a/mgmt.go +++ b/mgmt.go @@ -11,6 +11,7 @@ import ( "time" "github.com/Azure/azure-amqp-common-go/auth" + "github.com/Azure/azure-sdk-for-go/services/servicebus/mgmt/2017-04-01/servicebus" "github.com/Azure/go-autorest/autorest/date" ) @@ -39,6 +40,21 @@ type ( Entries []Entry `xml:"entry"` } + // Entry is the Atom wrapper for a management request + Entry struct { + XMLName xml.Name `xml:"entry"` + ID string `xml:"id"` + Title string `xml:"title"` + Published *date.Time `xml:"published,omitempty"` + Updated *date.Time `xml:"updated,omitempty"` + Author *Author `xml:"author,omitempty"` + Link *Link `xml:"link,omitempty"` + Content *Content `xml:"content"` + DataServiceSchema string `xml:"xmlns:d,attr"` + DataServiceMetadataSchema string `xml:"xmlns:m,attr"` + AtomSchema string `xml:"xmlns,attr"` + } + // Author is an Atom author used in an Entry Author struct { XMLName xml.Name `xml:"author"` @@ -58,6 +74,26 @@ type ( Type string `xml:"type,attr"` Body string `xml:",innerxml"` } + + // BaseEntityDescription provides common fields which are part of Queues, Topics and Subscriptions + BaseEntityDescription struct { + InstanceMetadataSchema string `xml:"xmlns:i,attr"` + ServiceBusSchema string `xml:"xmlns,attr"` + DefaultMessageTimeToLive *string `xml:"DefaultMessageTimeToLive,omitempty"` // DefaultMessageTimeToLive - ISO 8601 default message timespan to live value. This is the duration after which the message expires, starting from when the message is sent to Service Bus. This is the default value used when TimeToLive is not set on a message itself. + MaxSizeInMegabytes *int32 `xml:"MaxSizeInMegabytes,omitempty"` // MaxSizeInMegabytes - The maximum size of the queue in megabytes, which is the size of memory allocated for the queue. Default is 1024. + RequiresDuplicateDetection *bool `xml:"RequiresDuplicateDetection,omitempty"` // RequiresDuplicateDetection - A value indicating if this queue requires duplicate detection. + DuplicateDetectionHistoryTimeWindow *string `xml:"DuplicateDetectionHistoryTimeWindow,omitempty"` // DuplicateDetectionHistoryTimeWindow - ISO 8601 timeSpan structure that defines the duration of the duplicate detection history. The default value is 10 minutes. + EnableBatchedOperations *bool `xml:"EnableBatchedOperations,omitempty"` // EnableBatchedOperations - Value that indicates whether server-side batched operations are enabled. + SizeInBytes *int64 `xml:"SizeInBytes,omitempty"` // SizeInBytes - The size of the queue, in bytes. + IsAnonymousAccessible *bool `xml:"IsAnonymousAccessible,omitempty"` + Status *servicebus.EntityStatus `xml:"Status,omitempty"` + CreatedAt *date.Time `xml:"CreatedAt,omitempty"` + UpdatedAt *date.Time `xml:"UpdatedAt,omitempty"` + SupportOrdering *bool `xml:"SupportOrdering,omitempty"` + AutoDeleteOnIdle *string `xml:"AutoDeleteOnIdle,omitempty"` + EnablePartitioning *bool `xml:"EnablePartitioning,omitempty"` + EnableExpress *bool `xml:"EnableExpress,omitempty"` + } ) // NewEntityManager creates a new instance of an EntityManager given a token provider and host diff --git a/mgmt_test.go b/mgmt_test.go index ed9a0b9ab3f2..5c84c20b8eae 100644 --- a/mgmt_test.go +++ b/mgmt_test.go @@ -33,8 +33,8 @@ func (suite *serviceBusSuite) TestEntryUnmarshal() { assert.Equal(suite.T(), "sbdjtest", *entry.Author.Name) assert.Equal(suite.T(), "https://sbdjtest.servicebus.windows.net/foo", entry.Link.HREF) for _, item := range []string{ - `PT30S", + `PT1M", "0", } { assert.Contains(suite.T(), entry.Content.Body, item) diff --git a/queue.go b/queue.go index f56eb1767c8f..9dc22d1735d1 100644 --- a/queue.go +++ b/queue.go @@ -7,8 +7,6 @@ import ( "io/ioutil" "sync" "time" - - "github.com/Azure/go-autorest/autorest/date" ) type ( @@ -36,21 +34,6 @@ type ( Entries []QueueEntry `xml:"entry"` } - // Entry is the Atom wrapper for a management request - Entry struct { - XMLName xml.Name `xml:"entry"` - ID string `xml:"id"` - Title string `xml:"title"` - Published *date.Time `xml:"published,omitempty"` - Updated *date.Time `xml:"updated,omitempty"` - Author *Author `xml:"author,omitempty"` - Link *Link `xml:"link,omitempty"` - Content *Content `xml:"content"` - DataServiceSchema string `xml:"xmlns:d,attr"` - DataServiceMetadataSchema string `xml:"xmlns:m,attr"` - AtomSchema string `xml:"xmlns,attr"` - } - // QueueEntry is a specialized Queue Feed Entry QueueEntry struct { *Entry @@ -66,23 +49,13 @@ type ( // QueueDescription is the content type for Queue management requests QueueDescription struct { - XMLName xml.Name `xml:"QueueDescription"` - LockDuration *string `xml:"LockDuration,omitempty"` // LockDuration - ISO 8601 timespan duration of a peek-lock; that is, the amount of time that the message is locked for other receivers. The maximum value for LockDuration is 5 minutes; the default value is 1 minute. - MaxSizeInMegabytes *int32 `xml:"MaxSizeInMegabytes,omitempty"` // MaxSizeInMegabytes - The maximum size of the queue in megabytes, which is the size of memory allocated for the queue. Default is 1024. - RequiresDuplicateDetection *bool `xml:"RequiresDuplicateDetection,omitempty"` // RequiresDuplicateDetection - A value indicating if this queue requires duplicate detection. - RequiresSession *bool `xml:"RequiresSession,omitempty"` // RequiresSession - A value that indicates whether the queue supports the concept of sessions. - DefaultMessageTimeToLive *string `xml:"DefaultMessageTimeToLive,omitempty"` // DefaultMessageTimeToLive - ISO 8601 default message timespan to live value. This is the duration after which the message expires, starting from when the message is sent to Service Bus. This is the default value used when TimeToLive is not set on a message itself. - DeadLetteringOnMessageExpiration *bool `xml:"DeadLetteringOnMessageExpiration,omitempty"` // DeadLetteringOnMessageExpiration - A value that indicates whether this queue has dead letter support when a message expires. - DuplicateDetectionHistoryTimeWindow *string `xml:"DuplicateDetectionHistoryTimeWindow,omitempty"` // DuplicateDetectionHistoryTimeWindow - ISO 8601 timeSpan structure that defines the duration of the duplicate detection history. The default value is 10 minutes. - MaxDeliveryCount *int32 `xml:"MaxDeliveryCount,omitempty"` // MaxDeliveryCount - The maximum delivery count. A message is automatically deadlettered after this number of deliveries. default value is 10. - EnableBatchedOperations *bool `xml:"EnableBatchedOperations,omitempty"` // EnableBatchedOperations - Value that indicates whether server-side batched operations are enabled. - SizeInBytes *int64 `xml:"SizeInBytes,omitempty"` // SizeInBytes - The size of the queue, in bytes. - MessageCount *int64 `xml:"MessageCount,omitempty"` // MessageCount - The number of messages in the queue. - EnablePartitioning *bool `xml:"EnablePartitioning,omitempty"` - AutoDeleteOnIdle *string `xml:"AutoDeleteOnIdle,omitempty"` - EnableExpress *bool `xml:"EnableExpress,omitempty"` - InstanceMetadataSchema string `xml:"xmlns:i,attr"` - ServiceBusSchema string `xml:"xmlns,attr"` + XMLName xml.Name `xml:"QueueDescription"` + BaseEntityDescription + LockDuration *string `xml:"LockDuration,omitempty"` // LockDuration - ISO 8601 timespan duration of a peek-lock; that is, the amount of time that the message is locked for other receivers. The maximum value for LockDuration is 5 minutes; the default value is 1 minute. + RequiresSession *bool `xml:"RequiresSession,omitempty"` // RequiresSession - A value that indicates whether the queue supports the concept of sessions. + DeadLetteringOnMessageExpiration *bool `xml:"DeadLetteringOnMessageExpiration,omitempty"` // DeadLetteringOnMessageExpiration - A value that indicates whether this queue has dead letter support when a message expires. + MaxDeliveryCount *int32 `xml:"MaxDeliveryCount,omitempty"` // MaxDeliveryCount - The maximum delivery count. A message is automatically deadlettered after this number of deliveries. default value is 10. + MessageCount *int64 `xml:"MessageCount,omitempty"` // MessageCount - The number of messages in the queue. } // QueueOption represents named options for assisting queue creation diff --git a/queue_test.go b/queue_test.go index 5ed70486102e..528cbb9a0e78 100644 --- a/queue_test.go +++ b/queue_test.go @@ -2,7 +2,6 @@ package servicebus import ( "context" - "encoding/xml" "fmt" "log" @@ -12,40 +11,63 @@ import ( "time" "github.com/Azure/azure-amqp-common-go/uuid" + "github.com/Azure/azure-sdk-for-go/services/servicebus/mgmt/2015-08-01/servicebus" "github.com/Azure/azure-service-bus-go/internal/test" "github.com/stretchr/testify/assert" ) const ( queueDescription1 = ` - - PT30S - 16384 - false - false - P14D - false - PT10M - 10 - true - 0 - 0 - ` + + PT1M + 1024 + false + false + P14D + false + PT10M + 10 + true + 0 + 0 + false + Active + 2018-05-04T16:38:27.913Z + 2018-05-04T16:38:41.897Z + true + P14D + false + Available + false + ` queueDescription2 = ` - - PT15S - 1024 - true - true - P14D - false - PT10M - 100 - true - 10 - 10 - ` + + PT2M + 2048 + false + false + P14D + true + PT20M + 100 + true + 256 + 23 + false + Active + 2018-05-04T16:38:27.913Z + 2018-05-04T16:38:41.897Z + true + P14D + true + Available + false + ` queueEntry1 = ` @@ -85,7 +107,7 @@ const ( ) var ( - timeout = 20 * time.Second + timeout = 60 * time.Second ) func (suite *serviceBusSuite) TestQueueEntryUnmarshal() { @@ -96,7 +118,7 @@ func (suite *serviceBusSuite) TestQueueEntryUnmarshal() { assert.Equal(suite.T(), "foo", entry.Title) assert.Equal(suite.T(), "sbdjtest", *entry.Author.Name) assert.Equal(suite.T(), "https://sbdjtest.servicebus.windows.net/foo", entry.Link.HREF) - assert.Equal(suite.T(), "PT30S", *entry.Content.QueueDescription.LockDuration) + assert.Equal(suite.T(), "PT1M", *entry.Content.QueueDescription.LockDuration) assert.NotNil(suite.T(), entry.Content) } @@ -107,18 +129,20 @@ func (suite *serviceBusSuite) TestQueueUnmarshal() { var q QueueDescription err = xml.Unmarshal([]byte(entry.Content.Body), &q) - assert.Nil(suite.T(), err) - assert.Equal(suite.T(), "PT30S", *q.LockDuration) - assert.Equal(suite.T(), int32(16384), *q.MaxSizeInMegabytes) - assert.Equal(suite.T(), false, *q.RequiresDuplicateDetection) - assert.Equal(suite.T(), false, *q.RequiresSession) - assert.Equal(suite.T(), "P14D", *q.DefaultMessageTimeToLive) - assert.Equal(suite.T(), false, *q.DeadLetteringOnMessageExpiration) - assert.Equal(suite.T(), "PT10M", *q.DuplicateDetectionHistoryTimeWindow) - assert.Equal(suite.T(), int32(10), *q.MaxDeliveryCount) - assert.Equal(suite.T(), true, *q.EnableBatchedOperations) - assert.Equal(suite.T(), int64(0), *q.SizeInBytes) - assert.Equal(suite.T(), int64(0), *q.MessageCount) + t := suite.T() + assert.Nil(t, err) + assert.Equal(t, "PT1M", *q.LockDuration) + assert.Equal(t, int32(1024), *q.MaxSizeInMegabytes) + assert.Equal(t, false, *q.RequiresDuplicateDetection) + assert.Equal(t, false, *q.RequiresSession) + assert.Equal(t, "P14D", *q.DefaultMessageTimeToLive) + assert.Equal(t, false, *q.DeadLetteringOnMessageExpiration) + assert.Equal(t, "PT10M", *q.DuplicateDetectionHistoryTimeWindow) + assert.Equal(t, int32(10), *q.MaxDeliveryCount) + assert.Equal(t, true, *q.EnableBatchedOperations) + assert.Equal(t, int64(0), *q.SizeInBytes) + assert.Equal(t, int64(0), *q.MessageCount) + assert.EqualValues(t, servicebus.EntityStatusActive, *q.Status) } func (suite *serviceBusSuite) TestQueueManagementWrites() { @@ -132,12 +156,9 @@ func (suite *serviceBusSuite) TestQueueManagementWrites() { suite.T().Run(name, func(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - name := suite.RandomName("gosb", 6) testFunc(ctx, t, qm, name) - - err := qm.Delete(ctx, name) - assert.Nil(t, err) + defer suite.cleanupQueue(name) }) } } @@ -164,7 +185,7 @@ func (suite *serviceBusSuite) TestQueueManagementReads() { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - names := []string{suite.randQueueName(), suite.randQueueName()} + names := []string{suite.randEntityName(), suite.randEntityName()} for _, name := range names { if _, err := qm.Put(ctx, name); err != nil { suite.T().Fatal(err) @@ -180,9 +201,7 @@ func (suite *serviceBusSuite) TestQueueManagementReads() { } for _, name := range names { - if err := qm.Delete(ctx, name); err != nil { - suite.T().Fatal(err) - } + suite.cleanupQueue(name) } } @@ -207,12 +226,12 @@ func testListQueues(ctx context.Context, t *testing.T, qm *QueueManager, names [ } } -func (suite *serviceBusSuite) randQueueName() string { +func (suite *serviceBusSuite) randEntityName() string { return suite.RandomName("goq", 6) } func (suite *serviceBusSuite) TestQueueManagement() { - tests := map[string]func(*testing.T, *QueueManager, string){ + tests := map[string]func(context.Context, *testing.T, *QueueManager, string){ "TestQueueDefaultSettings": testDefaultQueue, "TestQueueWithRequiredSessions": testQueueWithRequiredSessions, "TestQueueWithDeadLetteringOnMessageExpiration": testQueueWithDeadLetteringOnMessageExpiration, @@ -228,22 +247,19 @@ func (suite *serviceBusSuite) TestQueueManagement() { qm := ns.NewQueueManager() for name, testFunc := range tests { setupTestTeardown := func(t *testing.T) { - name := suite.randQueueName() - defer func(n string) { - err := qm.Delete(context.Background(), n) - if err != nil { - t.Fatal(err) - } - }(name) - testFunc(t, qm, name) + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + name := suite.randEntityName() + testFunc(ctx, t, qm, name) + defer suite.cleanupQueue(name) } suite.T().Run(name, setupTestTeardown) } } -func testDefaultQueue(t *testing.T, qm *QueueManager, name string) { - q := buildQueue(t, qm, name) +func testDefaultQueue(ctx context.Context, t *testing.T, qm *QueueManager, name string) { + q := buildQueue(ctx, t, qm, name) assert.False(t, *q.EnableExpress, "should not have Express enabled") assert.False(t, *q.EnablePartitioning, "should not have partitioning enabled") assert.False(t, *q.DeadLetteringOnMessageExpiration, "should not have dead lettering on expiration") @@ -256,53 +272,53 @@ func testDefaultQueue(t *testing.T, qm *QueueManager, name string) { assert.Equal(t, "PT1M", *q.LockDuration, "lock duration") } -func testQueueWithAutoDeleteOnIdle(t *testing.T, qm *QueueManager, name string) { +func testQueueWithAutoDeleteOnIdle(ctx context.Context, t *testing.T, qm *QueueManager, name string) { window := time.Duration(20 * time.Minute) - q := buildQueue(t, qm, name, QueueWithAutoDeleteOnIdle(&window)) + q := buildQueue(ctx, t, qm, name, QueueWithAutoDeleteOnIdle(&window)) assert.Equal(t, "PT20M", *q.AutoDeleteOnIdle) } -func testQueueWithRequiredSessions(t *testing.T, qm *QueueManager, name string) { - q := buildQueue(t, qm, name, QueueWithRequiredSessions()) +func testQueueWithRequiredSessions(ctx context.Context, t *testing.T, qm *QueueManager, name string) { + q := buildQueue(ctx, t, qm, name, QueueWithRequiredSessions()) assert.True(t, *q.RequiresSession) } -func testQueueWithDeadLetteringOnMessageExpiration(t *testing.T, qm *QueueManager, name string) { - q := buildQueue(t, qm, name, QueueWithDeadLetteringOnMessageExpiration()) +func testQueueWithDeadLetteringOnMessageExpiration(ctx context.Context, t *testing.T, qm *QueueManager, name string) { + q := buildQueue(ctx, t, qm, name, QueueWithDeadLetteringOnMessageExpiration()) assert.True(t, *q.DeadLetteringOnMessageExpiration) } -func testQueueWithPartitioning(t *testing.T, qm *QueueManager, name string) { - q := buildQueue(t, qm, name, QueueWithPartitioning()) +func testQueueWithPartitioning(ctx context.Context, t *testing.T, qm *QueueManager, name string) { + q := buildQueue(ctx, t, qm, name, QueueWithPartitioning()) assert.True(t, *q.EnablePartitioning) } -func testQueueWithMaxSizeInMegabytes(t *testing.T, qm *QueueManager, name string) { +func testQueueWithMaxSizeInMegabytes(ctx context.Context, t *testing.T, qm *QueueManager, name string) { size := 3 * Megabytes - q := buildQueue(t, qm, name, QueueWithMaxSizeInMegabytes(size)) + q := buildQueue(ctx, t, qm, name, QueueWithMaxSizeInMegabytes(size)) assert.Equal(t, int32(size), *q.MaxSizeInMegabytes) } -func testQueueWithDuplicateDetection(t *testing.T, qm *QueueManager, name string) { +func testQueueWithDuplicateDetection(ctx context.Context, t *testing.T, qm *QueueManager, name string) { window := time.Duration(20 * time.Minute) - q := buildQueue(t, qm, name, QueueWithDuplicateDetection(&window)) + q := buildQueue(ctx, t, qm, name, QueueWithDuplicateDetection(&window)) assert.Equal(t, "PT20M", *q.DuplicateDetectionHistoryTimeWindow) } -func testQueueWithMessageTimeToLive(t *testing.T, qm *QueueManager, name string) { +func testQueueWithMessageTimeToLive(ctx context.Context, t *testing.T, qm *QueueManager, name string) { window := time.Duration(10 * 24 * 60 * time.Minute) - q := buildQueue(t, qm, name, QueueWithMessageTimeToLive(&window)) + q := buildQueue(ctx, t, qm, name, QueueWithMessageTimeToLive(&window)) assert.Equal(t, "P10D", *q.DefaultMessageTimeToLive) } -func testQueueWithLockDuration(t *testing.T, qm *QueueManager, name string) { +func testQueueWithLockDuration(ctx context.Context, t *testing.T, qm *QueueManager, name string) { window := time.Duration(3 * time.Minute) - q := buildQueue(t, qm, name, QueueWithLockDuration(&window)) + q := buildQueue(ctx, t, qm, name, QueueWithLockDuration(&window)) assert.Equal(t, "PT3M", *q.LockDuration) } -func buildQueue(t *testing.T, qm *QueueManager, name string, opts ...QueueOption) QueueDescription { - q, err := qm.Put(context.Background(), name, opts...) +func buildQueue(ctx context.Context, t *testing.T, qm *QueueManager, name string, opts ...QueueOption) QueueDescription { + q, err := qm.Put(ctx, name, opts...) if err != nil { assert.FailNow(t, fmt.Sprintf("%v", err)) } @@ -320,9 +336,7 @@ func (suite *serviceBusSuite) TestQueue() { qm := ns.NewQueueManager() for name, testFunc := range tests { setupTestTeardown := func(t *testing.T) { - queueName := suite.randQueueName() - defer qm.Delete(context.Background(), queueName) - + queueName := suite.randEntityName() ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() window := 3 * time.Minute @@ -330,14 +344,17 @@ func (suite *serviceBusSuite) TestQueue() { ctx, queueName, QueueWithPartitioning(), - QueueWithDuplicateDetection(nil), + QueueWithDuplicateDetection(&window), QueueWithLockDuration(&window)) if err != nil { log.Fatalln(err) } q := ns.NewQueue(queueName) - defer q.Close(ctx) + defer func() { + q.Close(ctx) + suite.cleanupQueue(queueName) + }() testFunc(ctx, t, q) } @@ -429,15 +446,14 @@ func (suite *serviceBusSuite) TestQueueWithRequiredSessions() { ns := suite.getNewSasInstance() qm := ns.NewQueueManager() - queueName := suite.randQueueName() + queueName := suite.randEntityName() window := 3 * time.Minute ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() _, err := qm.Put( ctx, queueName, QueueWithPartitioning(), - QueueWithDuplicateDetection(nil), + QueueWithDuplicateDetection(&window), QueueWithLockDuration(&window), QueueWithRequiredSessions()) if err != nil { @@ -445,9 +461,14 @@ func (suite *serviceBusSuite) TestQueueWithRequiredSessions() { } q := ns.NewQueue(queueName) - defer q.Close(ctx) + defer func() { + q.Close(ctx) + cancel() + suite.cleanupQueue(queueName) + }() testFunc(ctx, t, q) + time.Sleep(5 * time.Second) qd, err := qm.Get(ctx, queueName) if assert.NoError(t, err) { assert.Zero(t, *qd.Content.QueueDescription.MessageCount, "message count for queue should be zero") @@ -515,3 +536,15 @@ func fmtDuration(d time.Duration) string { d = d.Round(time.Second) / time.Second return fmt.Sprintf("%d seconds", d) } + +func (suite *serviceBusSuite) cleanupQueue(name string) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + ns := suite.getNewSasInstance() + qm := ns.NewQueueManager() + err := qm.Delete(ctx, name) + if err != nil { + suite.T().Fatal(err) + } +} diff --git a/sender.go b/sender.go index e4f2aebd4cbb..9689cbf27431 100644 --- a/sender.go +++ b/sender.go @@ -69,11 +69,10 @@ func (s *sender) Send(ctx context.Context, event *Event, opts ...SendOption) err span, ctx := s.startProducerSpanFromContext(ctx, "sb.sender.Send") defer span.Finish() - for _, opt := range opts { - err := opt(event) - if err != nil { - return err - } + if event.GroupID == nil { + event.GroupID = &s.session.SessionID + next := s.session.getNext() + event.GroupSequence = &next } if event.ID == "" { @@ -84,10 +83,11 @@ func (s *sender) Send(ctx context.Context, event *Event, opts ...SendOption) err event.ID = id.String() } - if event.GroupID == nil { - event.GroupID = &s.session.SessionID - next := s.session.getNext() - event.GroupSequence = &next + for _, opt := range opts { + err := opt(event) + if err != nil { + return err + } } return s.trySend(ctx, event) @@ -219,6 +219,7 @@ func SendWithSession(sessionID string, sequence uint32) SendOption { func SendWithoutSessionID() SendOption { return func(event *Event) error { event.GroupID = nil + event.GroupSequence = nil return nil } } diff --git a/topic.go b/topic.go index aeaa21a7a2be..85ead30198d4 100644 --- a/topic.go +++ b/topic.go @@ -1,155 +1,280 @@ package servicebus -// -//import ( -// "context" -// "errors" -// mgmt "github.com/Azure/azure-sdk-for-go/services/servicebus/mgmt/2017-04-01/servicebus" -// "github.com/Azure/go-autorest/autorest" -// "time" -//) -// -// -//type ( -// // TopicOption represents an option for configuring a topic. -// TopicOption func(*mgmt.SBTopic) error -//) -// -//// TopicWithMaxSizeInMegabytes configures the maximum size of the topic in megabytes (1 * 1024 - 5 * 1024), which is the size of -//// the memory allocated for the topic. Default is 1 MB (1 * 1024). -//func TopicWithMaxSizeInMegabytes(size int) TopicOption { -// return func(t *mgmt.SBTopic) error { -// if size < 1*Megabytes || size > 5*Megabytes { -// return errors.New("TopicWithMaxSizeInMegabytes: must be between 1 * Megabytes and 5 * Megabytes") -// } -// size32 := int32(size) -// t.MaxSizeInMegabytes = &size32 -// return nil -// } -//} -// -//// TopicWithPartitioning configures the topic to be partitioned across multiple message brokers. -//func TopicWithPartitioning() TopicOption { -// return func(t *mgmt.SBTopic) error { -// t.EnablePartitioning = ptrBool(true) -// return nil -// } -//} -// -//// TopicWithOrdering configures the topic to support ordering of messages. -//func TopicWithOrdering() TopicOption { -// return func(t *mgmt.SBTopic) error { -// t.SupportOrdering = ptrBool(true) -// return nil -// } -//} -// -//// TopicWithDuplicateDetection configures the topic to detect duplicates for a given time window. If window -//// is not specified, then it uses the default of 10 minutes. -//func TopicWithDuplicateDetection(window *time.Duration) TopicOption { -// return func(t *mgmt.SBTopic) error { -// t.RequiresDuplicateDetection = ptrBool(true) -// if window != nil { -// t.DuplicateDetectionHistoryTimeWindow = durationTo8601Seconds(window) -// } -// return nil -// } -//} -// -//// TopicWithExpress configures the topic to hold a message in memory temporarily before writing it to persistent storage. -//func TopicWithExpress() TopicOption { -// return func(t *mgmt.SBTopic) error { -// t.EnableExpress = ptrBool(true) -// return nil -// } -//} -// -//// TopicWithBatchedOperations configures the topic to batch server-side operations. -//func TopicWithBatchedOperations() TopicOption { -// return func(t *mgmt.SBTopic) error { -// t.EnableBatchedOperations = ptrBool(true) -// return nil -// } -//} -// -//// TopicWithAutoDeleteOnIdle configures the topic to automatically delete after the specified idle interval. The -//// minimum duration is 5 minutes. -//func TopicWithAutoDeleteOnIdle(window *time.Duration) TopicOption { -// return func(t *mgmt.SBTopic) error { -// if window != nil { -// if window.Minutes() < 5 { -// return errors.New("TopicWithAutoDeleteOnIdle: window must be greater than 5 minutes") -// } -// t.AutoDeleteOnIdle = durationTo8601Seconds(window) -// } -// return nil -// } -//} -// -//// TopicWithMessageTimeToLive configures the topic to set a time to live on messages. This is the duration after which -//// the message expires, starting from when the message is sent to Service Bus. This is the default value used when -//// TimeToLive is not set on a message itself. If nil, defaults to 14 days. -//func TopicWithMessageTimeToLive(window *time.Duration) TopicOption { -// return func(t *mgmt.SBTopic) error { -// if window == nil { -// duration := time.Duration(14 * 24 * time.Hour) -// window = &duration -// } -// t.DefaultMessageTimeToLive = durationTo8601Seconds(window) -// return nil -// } -//} -// -//// EnsureTopic creates a topic if an existing topic does not exist -//func (sb *serviceBus) EnsureTopic(ctx context.Context, name string, opts ...TopicOption) (*mgmt.SBTopic, error) { -// topicClient := sb.getTopicMgmtClient() -// topic, err := topicClient.Get(ctx, sb.resourceGroup, sb.namespace, name) -// -// if err != nil { -// newTopic := &mgmt.SBTopic{ -// Name: &name, -// SBTopicProperties: &mgmt.SBTopicProperties{ -// EnablePartitioning: ptrBool(false), -// EnableBatchedOperations: ptrBool(false), -// EnableExpress: ptrBool(false), -// SupportOrdering: ptrBool(false), -// }, -// } -// -// for _, opt := range opts { -// err = opt(newTopic) -// if err != nil { -// return nil, err -// } -// } -// -// topic, err = topicClient.CreateOrUpdate(ctx, sb.resourceGroup, sb.namespace, name, *newTopic) -// if err != nil { -// return nil, err -// } -// } -// return &topic, nil -//} -// -//// GetTopic fetches a topic by name -//func (sb *serviceBus) GetTopic(ctx context.Context, name string) (*mgmt.SBTopic, error) { -// client := sb.getTopicMgmtClient() -// topic, err := client.Get(ctx, sb.resourceGroup, sb.namespace, name) -// if err != nil { -// return nil, err -// } -// return &topic, nil -//} -// -//// DeleteTopic deletes an existing topic -//func (sb *serviceBus) DeleteTopic(ctx context.Context, topicName string) error { -// topicClient := sb.getTopicMgmtClient() -// _, err := topicClient.Delete(ctx, sb.resourceGroup, sb.namespace, topicName) -// return err -//} -// -//func (sb *serviceBus) getTopicMgmtClient() *mgmt.TopicsClient { -// client := mgmt.NewTopicsClientWithBaseURI(sb.environment.ResourceManagerEndpoint, sb.subscriptionID) -// client.Authorizer = autorest.NewBearerAuthorizer(sb.armToken) -// return &client -//} +import ( + "context" + "encoding/xml" + "errors" + "io/ioutil" + "sync" + "time" +) + +type ( + // Topic in contrast to queues, in which each message is processed by a single consumer, topics and subscriptions + // provide a one-to-many form of communication, in a publish/subscribe pattern. Useful for scaling to very large + // numbers of recipients, each published message is made available to each subscription registered with the topic. + // Messages are sent to a topic and delivered to one or more associated subscriptions, depending on filter rules + // that can be set on a per-subscription basis. The subscriptions can use additional filters to restrict the + // messages that they want to receive. Messages are sent to a topic in the same way they are sent to a queue, + // but messages are not received from the topic directly. Instead, they are received from subscriptions. A topic + // subscription resembles a virtual queue that receives copies of the messages that are sent to the topic. + // Messages are received from a subscription identically to the way they are received from a queue. + Topic struct { + Name string + namespace *Namespace + sender *sender + senderMu sync.Mutex + } + + // TopicManager provides CRUD functionality for Service Bus Topics + TopicManager struct { + *EntityManager + } + + // TopicFeed is a specialized Feed containing Topic Entries + TopicFeed struct { + *Feed + Entries []TopicEntry `xml:"entry"` + } + // TopicEntry is a specialized Topic Feed Entry + TopicEntry struct { + *Entry + Content *TopicContent `xml:"content"` + } + + // TopicContent is a specialized Topic body for an Atom Entry + TopicContent struct { + XMLName xml.Name `xml:"content"` + Type string `xml:"type,attr"` + TopicDescription TopicDescription `xml:"TopicDescription"` + } + + // TopicDescription is the content type for Topic management requests + TopicDescription struct { + XMLName xml.Name `xml:"TopicDescription"` + BaseEntityDescription + FilteringMessagesBeforePublishing *bool `xml:"FilteringMessagesBeforePublishing,omitempty"` + EnableSubscriptionPartitioning *bool `xml:"EnableSubscriptionPartitioning,omitempty"` + } + + // TopicOption represents named options for assisting Topic creation + TopicOption func(topic *TopicDescription) error +) + +// NewTopicManager creates a new TopicManager for a Service Bus Namespace +func (ns *Namespace) NewTopicManager() *TopicManager { + return &TopicManager{ + EntityManager: NewEntityManager(ns.getHTTPSHostURI(), ns.TokenProvider), + } +} + +// Delete deletes a Service Bus Topic entity by name +func (tm *TopicManager) Delete(ctx context.Context, name string) error { + _, err := tm.EntityManager.Delete(ctx, "/"+name) + return err +} + +// Put creates or updates a Service Bus Topic +func (tm *TopicManager) Put(ctx context.Context, name string, opts ...TopicOption) (*TopicEntry, error) { + topicDescription := new(TopicDescription) + + for _, opt := range opts { + if err := opt(topicDescription); err != nil { + return nil, err + } + } + + topicDescription.InstanceMetadataSchema = instanceMetadataSchema + topicDescription.ServiceBusSchema = serviceBusSchema + + qe := &TopicEntry{ + Entry: &Entry{ + DataServiceSchema: dataServiceSchema, + DataServiceMetadataSchema: dataServiceMetadataSchema, + AtomSchema: atomSchema, + }, + Content: &TopicContent{ + Type: applicationXML, + TopicDescription: *topicDescription, + }, + } + + reqBytes, err := xml.Marshal(qe) + if err != nil { + return nil, err + } + + reqBytes = xmlDoc(reqBytes) + res, err := tm.EntityManager.Put(ctx, "/"+name, reqBytes) + if err != nil { + return nil, err + } + + b, err := ioutil.ReadAll(res.Body) + if err != nil { + return nil, err + } + + var entry TopicEntry + err = xml.Unmarshal(b, &entry) + return &entry, err +} + +// List fetches all of the Topics for a Service Bus Namespace +func (tm *TopicManager) List(ctx context.Context) (*TopicFeed, error) { + res, err := tm.EntityManager.Get(ctx, `/$Resources/Topics`) + if err != nil { + return nil, err + } + + b, err := ioutil.ReadAll(res.Body) + if err != nil { + return nil, err + } + + var feed TopicFeed + err = xml.Unmarshal(b, &feed) + return &feed, err +} + +// Get fetches a Service Bus Topic entity by name +func (tm *TopicManager) Get(ctx context.Context, name string) (*TopicEntry, error) { + res, err := tm.EntityManager.Get(ctx, name) + if err != nil { + return nil, err + } + + b, err := ioutil.ReadAll(res.Body) + if err != nil { + return nil, err + } + + var entry TopicEntry + err = xml.Unmarshal(b, &entry) + return &entry, err +} + +// NewTopic creates a new Topic Sender +func (ns *Namespace) NewTopic(name string) *Topic { + return &Topic{ + namespace: ns, + Name: name, + } +} + +// Send sends messages to the Topic +func (q *Topic) Send(ctx context.Context, event *Event, opts ...SendOption) error { + err := q.ensureSender(ctx) + if err != nil { + return err + } + return q.sender.Send(ctx, event, opts...) +} + +// Close the underlying connection to Service Bus +func (q *Topic) Close(ctx context.Context) error { + if q.sender != nil { + return q.sender.Close(ctx) + } + + return nil +} + +func (q *Topic) ensureSender(ctx context.Context) error { + q.senderMu.Lock() + defer q.senderMu.Unlock() + + if q.sender == nil { + s, err := q.namespace.newSender(ctx, q.Name) + if err != nil { + return err + } + q.sender = s + } + return nil +} + +// TopicWithMaxSizeInMegabytes configures the maximum size of the topic in megabytes (1 * 1024 - 5 * 1024), which is the size of +// the memory allocated for the topic. Default is 1 MB (1 * 1024). +func TopicWithMaxSizeInMegabytes(size int) TopicOption { + return func(t *TopicDescription) error { + if size < 1*Megabytes || size > 5*Megabytes { + return errors.New("TopicWithMaxSizeInMegabytes: must be between 1 * Megabytes and 5 * Megabytes") + } + size32 := int32(size) + t.MaxSizeInMegabytes = &size32 + return nil + } +} + +// TopicWithPartitioning configures the topic to be partitioned across multiple message brokers. +func TopicWithPartitioning() TopicOption { + return func(t *TopicDescription) error { + t.EnablePartitioning = ptrBool(true) + return nil + } +} + +// TopicWithOrdering configures the topic to support ordering of messages. +func TopicWithOrdering() TopicOption { + return func(t *TopicDescription) error { + t.SupportOrdering = ptrBool(true) + return nil + } +} + +// TopicWithDuplicateDetection configures the topic to detect duplicates for a given time window. If window +// is not specified, then it uses the default of 10 minutes. +func TopicWithDuplicateDetection(window *time.Duration) TopicOption { + return func(t *TopicDescription) error { + t.RequiresDuplicateDetection = ptrBool(true) + if window != nil { + t.DuplicateDetectionHistoryTimeWindow = durationTo8601Seconds(window) + } + return nil + } +} + +// TopicWithExpress configures the topic to hold a message in memory temporarily before writing it to persistent storage. +func TopicWithExpress() TopicOption { + return func(t *TopicDescription) error { + t.EnableExpress = ptrBool(true) + return nil + } +} + +// TopicWithBatchedOperations configures the topic to batch server-side operations. +func TopicWithBatchedOperations() TopicOption { + return func(t *TopicDescription) error { + t.EnableBatchedOperations = ptrBool(true) + return nil + } +} + +// TopicWithAutoDeleteOnIdle configures the topic to automatically delete after the specified idle interval. The +// minimum duration is 5 minutes. +func TopicWithAutoDeleteOnIdle(window *time.Duration) TopicOption { + return func(t *TopicDescription) error { + if window != nil { + if window.Minutes() < 5 { + return errors.New("TopicWithAutoDeleteOnIdle: window must be greater than 5 minutes") + } + t.AutoDeleteOnIdle = durationTo8601Seconds(window) + } + return nil + } +} + +// TopicWithMessageTimeToLive configures the topic to set a time to live on messages. This is the duration after which +// the message expires, starting from when the message is sent to Service Bus. This is the default value used when +// TimeToLive is not set on a message itself. If nil, defaults to 14 days. +func TopicWithMessageTimeToLive(window *time.Duration) TopicOption { + return func(t *TopicDescription) error { + if window == nil { + duration := time.Duration(14 * 24 * time.Hour) + window = &duration + } + t.DefaultMessageTimeToLive = durationTo8601Seconds(window) + return nil + } +} \ No newline at end of file diff --git a/topic_test.go b/topic_test.go index 308648f466e8..95971c612fb7 100644 --- a/topic_test.go +++ b/topic_test.go @@ -1,131 +1,305 @@ package servicebus -//import ( -// "context" -// "fmt" -// mgmt "github.com/Azure/azure-sdk-for-go/services/servicebus/mgmt/2017-04-01/servicebus" -// "github.com/stretchr/testify/assert" -// "testing" -// "time" -//) -// -//func (suite *ServiceBusSuite) TestTopicManagement() { -// tests := map[string]func(*testing.T, SenderReceiverManager, string){ -// "DefaultTopicCreation": testDefaultTopic, -// "TopicWithPartitioning": testPartitionedTopic, -// "TopicWithOrdering": testSupportOrdering, -// "TopicWithDuplicateDetection": testTopicWithDuplicateDetection, -// "TopicWithAutoDeleteOnIdle": testTopicWithAutoDeleteOnIdle, -// "TopicWithTimeToLive": testTopicWithMessageTimeToLive, -// "TopicWithBatchOperations": testTopicWithBatchedOperations, -// "TopicWithExpress": testTopicWithExpress, -// "TopicWithMaxSizeInMegabytes": testTopicWithMaxSizeInMegabytes, -// } -// -// sb := suite.getNewSasInstance() -// defer func() { -// sb.Close() -// }() -// -// for name, testFunc := range tests { -// setupTestTeardown := func(t *testing.T) { -// entityName := randomName("gosbtest", 10) -// defer func(name string) { -// err := sb.DeleteTopic(context.Background(), name) -// if err != nil { -// t.Fatal(err) -// } -// }(entityName) -// testFunc(t, sb, entityName) -// -// } -// suite.T().Run(name, setupTestTeardown) -// } -//} -// -//func testDefaultTopic(t *testing.T, sb SenderReceiverManager, name string) { -// topic := buildTopic(t, sb, name) -// assert.False(t, *topic.EnableExpress, "should not have Express enabled") -// assert.False(t, *topic.EnableBatchedOperations, "should not have batching enabled") -// assert.False(t, *topic.EnablePartitioning, "should not have partitioning enabled") -// assert.False(t, *topic.SupportOrdering, "should not support ordering") -// assert.False(t, *topic.RequiresDuplicateDetection, "should not require dup detection") -// assert.Equal(t, "P10675199DT2H48M5.4775807S", *topic.AutoDeleteOnIdle, "auto delete is not 10 minutes") -// assert.Equal(t, "PT10M", *topic.DuplicateDetectionHistoryTimeWindow, "dup detection is not 10 minutes") -// assert.Equal(t, mgmt.Active, topic.Status, "topic status") -//} -// -//func testPartitionedTopic(t *testing.T, sb SenderReceiverManager, name string) { -// topic := buildTopic(t, sb, name, TopicWithPartitioning()) -// assert.True(t, *topic.EnablePartitioning) -//} -// -//func testSupportOrdering(t *testing.T, sb SenderReceiverManager, name string) { -// topic := buildTopic(t, sb, name, TopicWithOrdering()) -// assert.True(t, *topic.SupportOrdering) -//} -// -//func testTopicWithDuplicateDetection(t *testing.T, sb SenderReceiverManager, name string) { -// window := time.Duration(20 * time.Minute) -// topic := buildTopic(t, sb, name, TopicWithDuplicateDetection(&window)) -// assert.True(t, *topic.RequiresDuplicateDetection) -// assert.Equal(t, "PT20M", *topic.DuplicateDetectionHistoryTimeWindow) -//} -// -//func testTopicWithAutoDeleteOnIdle(t *testing.T, sb SenderReceiverManager, name string) { -// window := time.Duration(20 * time.Minute) -// topic := buildTopic(t, sb, name, TopicWithAutoDeleteOnIdle(&window)) -// assert.Equal(t, "PT20M", *topic.AutoDeleteOnIdle) -//} -// -//func testTopicWithBatchedOperations(t *testing.T, sb SenderReceiverManager, name string) { -// topic := buildTopic(t, sb, name, TopicWithBatchedOperations()) -// assert.True(t, *topic.EnableBatchedOperations) -//} -// -//func testTopicWithExpress(t *testing.T, sb SenderReceiverManager, name string) { -// topic := buildTopic(t, sb, name, TopicWithExpress()) -// assert.True(t, *topic.EnableExpress) -//} -// -//func testTopicWithMessageTimeToLive(t *testing.T, sb SenderReceiverManager, name string) { -// window := time.Duration(20 * time.Minute) -// topic := buildTopic(t, sb, name, TopicWithMessageTimeToLive(&window)) -// assert.Equal(t, "PT20M", *topic.DefaultMessageTimeToLive) -//} -// -//func testTopicWithMaxSizeInMegabytes(t *testing.T, sb SenderReceiverManager, name string) { -// size := 2 * Megabytes -// topic := buildTopic(t, sb, name, TopicWithMaxSizeInMegabytes(size)) -// assert.Equal(t, int32(size), *topic.MaxSizeInMegabytes) -//} -// -//func buildTopic(t *testing.T, sb SenderReceiverManager, name string, opts ...TopicOption) *mgmt.SBTopic { -// topic, err := sb.EnsureTopic(context.Background(), name, opts...) -// if err != nil { -// assert.FailNow(t, fmt.Sprintf("%v", err)) -// } -// return topic -//} -// -//func (suite *ServiceBusSuite) TestTopicSend() { -// tests := make(map[string]func(*testing.T, SenderReceiverManager, string)) -// -// sb := suite.getNewSasInstance() -// defer func() { -// sb.Close() -// }() -// -// for name, testFunc := range tests { -// wrappedTest := func(t *testing.T) { -// entityName := randomName("gosbtest", 10) -// sb.EnsureTopic(context.Background(), entityName) -// testFunc(suite.T(), sb, entityName) -// err := sb.DeleteTopic(context.Background(), entityName) -// if err != nil { -// suite.T().Fatal(err) -// } -// } -// suite.T().Run(name, wrappedTest) -// } -//} +import ( + "context" + "encoding/xml" + "log" + "testing" + "time" + + "github.com/Azure/azure-sdk-for-go/services/servicebus/mgmt/2015-08-01/servicebus" + "github.com/stretchr/testify/assert" +) + +const ( + topicDescription1 = ` + + P10675199DT2H48M5.4775807S + 1024 + false + PT10M + true + 0 + false + false + + Active + 2018-05-04T20:59:02.86Z + 2018-05-04T20:59:03Z + true + P10675199DT2H48M5.4775807S + false + false + Available + false + false + ` + + topicEntry1 = ` + + https://sbdjtest.servicebus.windows.net/foo + foo + 2018-05-02T20:54:59Z + 2018-05-02T20:54:59Z + + sbdjtest + + + ` + topicDescription1 + + ` + ` +) + +func (suite *serviceBusSuite) TestTopicEntryUnmarshal() { + var entry TopicEntry + err := xml.Unmarshal([]byte(topicEntry1), &entry) + assert.Nil(suite.T(), err) + assert.Equal(suite.T(), "https://sbdjtest.servicebus.windows.net/foo", entry.ID) + assert.Equal(suite.T(), "foo", entry.Title) + assert.Equal(suite.T(), "sbdjtest", *entry.Author.Name) + assert.Equal(suite.T(), "https://sbdjtest.servicebus.windows.net/foo", entry.Link.HREF) + assert.Equal(suite.T(), "P10675199DT2H48M5.4775807S", *entry.Content.TopicDescription.DefaultMessageTimeToLive) + assert.NotNil(suite.T(), entry.Content) +} + +func (suite *serviceBusSuite) TestTopicUnmarshal() { + var entry Entry + err := xml.Unmarshal([]byte(topicEntry1), &entry) + assert.Nil(suite.T(), err) + + var td TopicDescription + err = xml.Unmarshal([]byte(entry.Content.Body), &td) + t := suite.T() + assert.Nil(t, err) + assert.Equal(t, int32(1024), *td.MaxSizeInMegabytes) + assert.Equal(t, false, *td.RequiresDuplicateDetection) + assert.Equal(t, "P10675199DT2H48M5.4775807S", *td.DefaultMessageTimeToLive) + assert.Equal(t, "PT10M", *td.DuplicateDetectionHistoryTimeWindow) + assert.Equal(t, true, *td.EnableBatchedOperations) + assert.Equal(t, false, *td.FilteringMessagesBeforePublishing) + assert.Equal(t, false, *td.EnableExpress) + assert.Equal(t, int64(0), *td.SizeInBytes) + assert.EqualValues(t, servicebus.EntityStatusActive, *td.Status) +} + +func (suite *serviceBusSuite) TestTopicManagementWrites() { + tests := map[string]func(context.Context, *testing.T, *TopicManager, string){ + "TestPutDefaultTopic": testPutTopic, + } + + ns := suite.getNewSasInstance() + tm := ns.NewTopicManager() + for name, testFunc := range tests { + suite.T().Run(name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + name := suite.RandomName("gosb", 6) + testFunc(ctx, t, tm, name) + defer suite.cleanupQueue(name) + }) + } +} + +func (suite *serviceBusSuite) TestTopicManagementReads() { + tests := map[string]func(context.Context, *testing.T, *TopicManager, []string){ + "TestGetTopic": testGetTopic, + "TestListTopics": testListTopics, + } + + ns := suite.getNewSasInstance() + tm := ns.NewTopicManager() + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + names := []string{suite.randEntityName(), suite.randEntityName()} + for _, name := range names { + if _, err := tm.Put(ctx, name); err != nil { + suite.T().Fatal(err) + } + } + + for name, testFunc := range tests { + suite.T().Run(name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + testFunc(ctx, t, tm, names) + }) + } + + for _, name := range names { + suite.cleanupTopic(name) + } +} + +func testGetTopic(ctx context.Context, t *testing.T, tm *TopicManager, names []string) { + topic, err := tm.Get(ctx, names[0]) + assert.Nil(t, err) + assert.NotNil(t, t) + assert.Equal(t, topic.Entry.Title, names[0]) +} + +func testListTopics(ctx context.Context, t *testing.T, tm *TopicManager, names []string) { + feed, err := tm.List(ctx) + assert.Nil(t, err) + assert.NotNil(t, feed) + queueNames := make([]string, len(feed.Entries)) + for idx, entry := range feed.Entries { + queueNames[idx] = entry.Title + } + + for _, name := range names { + assert.Contains(t, queueNames, name) + } +} + +func testPutTopic(ctx context.Context, t *testing.T, tm *TopicManager, name string) { + topic, err := tm.Put(ctx, name) + if !assert.Nil(t, err) { + t.FailNow() + } + if assert.NotNil(t, topic) { + assert.Equal(t, name, topic.Title) + } +} + +func (suite *serviceBusSuite) TestTopicManagement() { + tests := map[string]func(context.Context, *testing.T, *TopicManager, string){ + "DefaultTopicCreation": testDefaultTopic, + "TopicWithPartitioning": testPartitionedTopic, + "TopicWithOrdering": testSupportOrdering, + "TopicWithDuplicateDetection": testTopicWithDuplicateDetection, + "TopicWithAutoDeleteOnIdle": testTopicWithAutoDeleteOnIdle, + "TopicWithTimeToLive": testTopicWithMessageTimeToLive, + "TopicWithBatchOperations": testTopicWithBatchedOperations, + "TopicWithExpress": testTopicWithExpress, + "TopicWithMaxSizeInMegabytes": testTopicWithMaxSizeInMegabytes, + } + + ns := suite.getNewSasInstance() + tm := ns.NewTopicManager() + for name, testFunc := range tests { + setupTestTeardown := func(t *testing.T) { + entityName := suite.randEntityName() + defer suite.cleanupTopic(entityName) + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + testFunc(ctx, t, tm, entityName) + + } + suite.T().Run(name, setupTestTeardown) + } +} + +func testDefaultTopic(ctx context.Context, t *testing.T, tm *TopicManager, name string) { + topic := buildTopic(ctx, t, tm, name) + assert.False(t, *topic.EnableExpress, "should not have Express enabled") + assert.True(t, *topic.EnableBatchedOperations, "should not have batching enabled") + assert.False(t, *topic.EnablePartitioning, "should not have partitioning enabled") + assert.True(t, *topic.SupportOrdering, "should not support ordering") + assert.False(t, *topic.RequiresDuplicateDetection, "should not require dup detection") + assert.Equal(t, "P10675199DT2H48M5.4775807S", *topic.AutoDeleteOnIdle, "auto delete is not 10 minutes") + assert.Equal(t, "PT10M", *topic.DuplicateDetectionHistoryTimeWindow, "dup detection is not 10 minutes") + assert.EqualValues(t, servicebus.EntityStatusActive, *topic.Status, "topic status") +} + +func testPartitionedTopic(ctx context.Context, t *testing.T, tm *TopicManager, name string) { + topic := buildTopic(ctx, t, tm, name, TopicWithPartitioning()) + assert.True(t, *topic.EnablePartitioning) +} + +func testSupportOrdering(ctx context.Context, t *testing.T, tm *TopicManager, name string) { + topic := buildTopic(ctx, t, tm, name, TopicWithOrdering()) + assert.True(t, *topic.SupportOrdering) +} + +func testTopicWithDuplicateDetection(ctx context.Context, t *testing.T, tm *TopicManager, name string) { + window := time.Duration(20 * time.Minute) + topic := buildTopic(ctx, t, tm, name, TopicWithDuplicateDetection(&window)) + assert.True(t, *topic.RequiresDuplicateDetection) + assert.Equal(t, "PT20M", *topic.DuplicateDetectionHistoryTimeWindow) +} + +func testTopicWithAutoDeleteOnIdle(ctx context.Context, t *testing.T, tm *TopicManager, name string) { + window := time.Duration(20 * time.Minute) + topic := buildTopic(ctx, t, tm, name, TopicWithAutoDeleteOnIdle(&window)) + assert.Equal(t, "PT20M", *topic.AutoDeleteOnIdle) +} + +func testTopicWithBatchedOperations(ctx context.Context, t *testing.T, tm *TopicManager, name string) { + topic := buildTopic(ctx, t, tm, name, TopicWithBatchedOperations()) + assert.True(t, *topic.EnableBatchedOperations) +} + +func testTopicWithExpress(ctx context.Context, t *testing.T, tm *TopicManager, name string) { + topic := buildTopic(ctx, t, tm, name, TopicWithExpress()) + assert.True(t, *topic.EnableExpress) +} + +func testTopicWithMessageTimeToLive(ctx context.Context, t *testing.T, tm *TopicManager, name string) { + window := time.Duration(20 * time.Minute) + topic := buildTopic(ctx, t, tm, name, TopicWithMessageTimeToLive(&window)) + assert.Equal(t, "PT20M", *topic.DefaultMessageTimeToLive) +} + +func testTopicWithMaxSizeInMegabytes(ctx context.Context, t *testing.T, tm *TopicManager, name string) { + size := 2 * Megabytes + topic := buildTopic(ctx, t, tm, name, TopicWithMaxSizeInMegabytes(size)) + assert.Equal(t, int32(size), *topic.MaxSizeInMegabytes) +} + +func buildTopic(ctx context.Context, t *testing.T, tm *TopicManager, name string, opts ...TopicOption) *TopicDescription { + te, err := tm.Put(ctx, name, opts...) + if err != nil { + t.Fatal(err) + } + return &te.Content.TopicDescription +} + +func (suite *serviceBusSuite) TestTopic() { + tests := map[string]func(context.Context, *testing.T, *Topic){ + "SimpleSend": testTopicSend, + } + + ns := suite.getNewSasInstance() + tm := ns.NewTopicManager() + for name, testFunc := range tests { + setupTestTeardown := func(t *testing.T) { + name := suite.randEntityName() + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + _, err := tm.Put(ctx, name) + if err != nil { + log.Fatalln(err) + } + + topic := ns.NewTopic(name) + defer func() { + topic.Close(ctx) + suite.cleanupTopic(name) + }() + testFunc(ctx, t, topic) + } + + suite.T().Run(name, setupTestTeardown) + } +} + +func testTopicSend(ctx context.Context, t *testing.T, topic *Topic) { + err := topic.Send(ctx, NewEventFromString("hello!")) + assert.Nil(t, err) +} + +func (suite *serviceBusSuite) cleanupTopic(name string) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + ns := suite.getNewSasInstance() + tm := ns.NewTopicManager() + err := tm.Delete(ctx, name) + if err != nil { + suite.T().Fatal(err) + } +} From bdb96f8e3fb53b9834bd9607802c993a9fc48beb Mon Sep 17 00:00:00 2001 From: David Justice Date: Fri, 4 May 2018 16:26:03 -0700 Subject: [PATCH 3/4] subscription and topics can send and receive subscription management enabled --- event.go | 6 +- mgmt.go | 45 +++-- queue.go | 15 +- queue_test.go | 16 +- receiver.go | 18 ++ subscription.go | 410 +++++++++++++++++++++++++++++-------------- subscription_test.go | 386 +++++++++++++++++++++++++++++----------- topic.go | 33 ++-- topic_test.go | 22 +-- 9 files changed, 660 insertions(+), 291 deletions(-) diff --git a/event.go b/event.go index a25fc8d050a3..b52d288c8ca7 100644 --- a/event.go +++ b/event.go @@ -39,9 +39,9 @@ type ( // EventBatch is a batch of Event Hubs messages to be sent EventBatch struct { - Events []*Event - Properties map[string]interface{} - ID string + Events []*Event + Properties map[string]interface{} + ID string } ) diff --git a/mgmt.go b/mgmt.go index d25271acde2a..42f009cfa051 100644 --- a/mgmt.go +++ b/mgmt.go @@ -75,24 +75,37 @@ type ( Body string `xml:",innerxml"` } + // ReceiveBaseDescription provides common fields for Subscriptions and Queues + ReceiveBaseDescription struct { + LockDuration *string `xml:"LockDuration,omitempty"` // LockDuration - ISO 8601 timespan duration of a peek-lock; that is, the amount of time that the message is locked for other receivers. The maximum value for LockDuration is 5 minutes; the default value is 1 minute. + RequiresSession *bool `xml:"RequiresSession,omitempty"` + DeadLetteringOnMessageExpiration *bool `xml:"DeadLetteringOnMessageExpiration,omitempty"` // DeadLetteringOnMessageExpiration - A value that indicates whether this queue has dead letter support when a message expires. + MaxDeliveryCount *int32 `xml:"MaxDeliveryCount,omitempty"` // MaxDeliveryCount - The maximum delivery count. A message is automatically deadlettered after this number of deliveries. default value is 10. + MessageCount *int64 `xml:"MessageCount,omitempty"` // MessageCount - The number of messages in the queue. + } + + // SendBaseDescription provides common fields for Queues and Topics + SendBaseDescription struct { + RequiresDuplicateDetection *bool `xml:"RequiresDuplicateDetection,omitempty"` // RequiresDuplicateDetection - A value indicating if this queue requires duplicate detection. + DuplicateDetectionHistoryTimeWindow *string `xml:"DuplicateDetectionHistoryTimeWindow,omitempty"` // DuplicateDetectionHistoryTimeWindow - ISO 8601 timeSpan structure that defines the duration of the duplicate detection history. The default value is 10 minutes. + SizeInBytes *int64 `xml:"SizeInBytes,omitempty"` // SizeInBytes - The size of the queue, in bytes. + } + // BaseEntityDescription provides common fields which are part of Queues, Topics and Subscriptions BaseEntityDescription struct { - InstanceMetadataSchema string `xml:"xmlns:i,attr"` - ServiceBusSchema string `xml:"xmlns,attr"` - DefaultMessageTimeToLive *string `xml:"DefaultMessageTimeToLive,omitempty"` // DefaultMessageTimeToLive - ISO 8601 default message timespan to live value. This is the duration after which the message expires, starting from when the message is sent to Service Bus. This is the default value used when TimeToLive is not set on a message itself. - MaxSizeInMegabytes *int32 `xml:"MaxSizeInMegabytes,omitempty"` // MaxSizeInMegabytes - The maximum size of the queue in megabytes, which is the size of memory allocated for the queue. Default is 1024. - RequiresDuplicateDetection *bool `xml:"RequiresDuplicateDetection,omitempty"` // RequiresDuplicateDetection - A value indicating if this queue requires duplicate detection. - DuplicateDetectionHistoryTimeWindow *string `xml:"DuplicateDetectionHistoryTimeWindow,omitempty"` // DuplicateDetectionHistoryTimeWindow - ISO 8601 timeSpan structure that defines the duration of the duplicate detection history. The default value is 10 minutes. - EnableBatchedOperations *bool `xml:"EnableBatchedOperations,omitempty"` // EnableBatchedOperations - Value that indicates whether server-side batched operations are enabled. - SizeInBytes *int64 `xml:"SizeInBytes,omitempty"` // SizeInBytes - The size of the queue, in bytes. - IsAnonymousAccessible *bool `xml:"IsAnonymousAccessible,omitempty"` - Status *servicebus.EntityStatus `xml:"Status,omitempty"` - CreatedAt *date.Time `xml:"CreatedAt,omitempty"` - UpdatedAt *date.Time `xml:"UpdatedAt,omitempty"` - SupportOrdering *bool `xml:"SupportOrdering,omitempty"` - AutoDeleteOnIdle *string `xml:"AutoDeleteOnIdle,omitempty"` - EnablePartitioning *bool `xml:"EnablePartitioning,omitempty"` - EnableExpress *bool `xml:"EnableExpress,omitempty"` + InstanceMetadataSchema string `xml:"xmlns:i,attr"` + ServiceBusSchema string `xml:"xmlns,attr"` + MaxSizeInMegabytes *int32 `xml:"MaxSizeInMegabytes,omitempty"` // MaxSizeInMegabytes - The maximum size of the queue in megabytes, which is the size of memory allocated for the queue. Default is 1024. + EnableBatchedOperations *bool `xml:"EnableBatchedOperations,omitempty"` // EnableBatchedOperations - Value that indicates whether server-side batched operations are enabled. + IsAnonymousAccessible *bool `xml:"IsAnonymousAccessible,omitempty"` + Status *servicebus.EntityStatus `xml:"Status,omitempty"` + CreatedAt *date.Time `xml:"CreatedAt,omitempty"` + UpdatedAt *date.Time `xml:"UpdatedAt,omitempty"` + SupportOrdering *bool `xml:"SupportOrdering,omitempty"` + AutoDeleteOnIdle *string `xml:"AutoDeleteOnIdle,omitempty"` + EnablePartitioning *bool `xml:"EnablePartitioning,omitempty"` + EnableExpress *bool `xml:"EnableExpress,omitempty"` + DefaultMessageTimeToLive *string `xml:"DefaultMessageTimeToLive,omitempty"` // DefaultMessageTimeToLive - ISO 8601 default message timespan to live value. This is the duration after which the message expires, starting from when the message is sent to Service Bus. This is the default value used when TimeToLive is not set on a message itself. } ) diff --git a/queue.go b/queue.go index 9dc22d1735d1..8db42446fb0a 100644 --- a/queue.go +++ b/queue.go @@ -49,13 +49,10 @@ type ( // QueueDescription is the content type for Queue management requests QueueDescription struct { - XMLName xml.Name `xml:"QueueDescription"` + XMLName xml.Name `xml:"QueueDescription"` + ReceiveBaseDescription + SendBaseDescription BaseEntityDescription - LockDuration *string `xml:"LockDuration,omitempty"` // LockDuration - ISO 8601 timespan duration of a peek-lock; that is, the amount of time that the message is locked for other receivers. The maximum value for LockDuration is 5 minutes; the default value is 1 minute. - RequiresSession *bool `xml:"RequiresSession,omitempty"` // RequiresSession - A value that indicates whether the queue supports the concept of sessions. - DeadLetteringOnMessageExpiration *bool `xml:"DeadLetteringOnMessageExpiration,omitempty"` // DeadLetteringOnMessageExpiration - A value that indicates whether this queue has dead letter support when a message expires. - MaxDeliveryCount *int32 `xml:"MaxDeliveryCount,omitempty"` // MaxDeliveryCount - The maximum delivery count. A message is automatically deadlettered after this number of deliveries. default value is 10. - MessageCount *int64 `xml:"MessageCount,omitempty"` // MessageCount - The number of messages in the queue. } // QueueOption represents named options for assisting queue creation @@ -65,7 +62,7 @@ type ( /* QueueWithPartitioning ensure the created queue will be a partitioned queue. Partitioned queues offer increased storage and availability compared to non-partitioned queues with the trade-off of requiring the following to ensure -FIFO message retreival: +FIFO message retrieval: SessionId. If a message has the SessionId property set, then Service Bus uses the SessionId property as the partition key. This way, all messages that belong to the same session are assigned to the same fragment and handled @@ -294,6 +291,10 @@ func (q *Queue) Receive(ctx context.Context, handler Handler, opts ...ReceiverOp } receiver, err := q.namespace.newReceiver(ctx, q.Name) + if err != nil { + return nil, err + } + for _, opt := range opts { if err := opt(receiver); err != nil { return nil, err diff --git a/queue_test.go b/queue_test.go index 528cbb9a0e78..997a006543d3 100644 --- a/queue_test.go +++ b/queue_test.go @@ -437,6 +437,7 @@ func testDuplicateDetection(ctx context.Context, t *testing.T, queue *Queue) { } func (suite *serviceBusSuite) TestQueueWithRequiredSessions() { + suite.T().Skip("Add Required Sessions test back after Service Bus team changes the functionality to be AMQP spec compliant") tests := map[string]func(context.Context, *testing.T, *Queue){ "TestSendAndReceiveSession": testQueueWithRequiredSessionSendAndReceive, } @@ -499,12 +500,23 @@ func testQueueWithRequiredSessionSendAndReceive(ctx context.Context, t *testing. // ensure in-order processing of messages from the queue count := 0 handler := func(ctx context.Context, event *Event) error { - assert.Equal(t, messages[count], string(event.Data)) + if !assert.Equal(t, messages[count], string(event.Data)) { + assert.FailNow(t, fmt.Sprintf("message %d %q didn't match %q", count, messages[count], string(event.Data))) + } count++ wg.Done() return nil } - queue.Receive(ctx, handler, ReceiverWithSession(sessionID)) + listenHandle, err := queue.Receive(ctx, handler, ReceiverWithSession(sessionID)) + if err != nil { + t.Fatal(err) + } + defer func() { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + listenHandle.Close(ctx) + }() + end, _ := ctx.Deadline() waitUntil(t, &wg, time.Until(end)) } diff --git a/receiver.go b/receiver.go index eafc7d13bb58..ffc0a7aff7d7 100644 --- a/receiver.go +++ b/receiver.go @@ -243,3 +243,21 @@ func messageID(msg *amqp.Message) interface{} { } return id } + +// Close will close the listener +func (lc *ListenerHandle) Close(ctx context.Context) error { + return lc.r.Close(ctx) +} + +// Done will close the channel when the listener has stopped +func (lc *ListenerHandle) Done() <-chan struct{} { + return lc.ctx.Done() +} + +// Err will return the last error encountered +func (lc *ListenerHandle) Err() error { + if lc.r.lastError != nil { + return lc.r.lastError + } + return lc.ctx.Err() +} diff --git a/subscription.go b/subscription.go index 09c291cd8262..86b7e63d2372 100644 --- a/subscription.go +++ b/subscription.go @@ -1,133 +1,281 @@ package servicebus -//import ( -// "context" -// "errors" -// mgmt "github.com/Azure/azure-sdk-for-go/services/servicebus/mgmt/2017-04-01/servicebus" -// "github.com/Azure/go-autorest/autorest" -// "time" -//) -// -//type ( -// // SubscriptionOption represents an option for configuring a topic. -// SubscriptionOption func(subscription *mgmt.SBSubscription) error -//) -// -//// SubscriptionWithBatchedOperations configures the subscription to batch server-side operations. -//func SubscriptionWithBatchedOperations() SubscriptionOption { -// return func(t *mgmt.SBSubscription) error { -// t.EnableBatchedOperations = ptrBool(true) -// return nil -// } -//} -// -//// SubscriptionWithLockDuration configures the subscription to have a duration of a peek-lock; that is, the amount of -//// time that the message is locked for other receivers. The maximum value for LockDuration is 5 minutes; the default -//// value is 1 minute. -//func SubscriptionWithLockDuration(window *time.Duration) SubscriptionOption { -// return func(q *mgmt.SBSubscription) error { -// if window == nil { -// duration := time.Duration(1 * time.Minute) -// window = &duration -// } -// q.LockDuration = durationTo8601Seconds(window) -// return nil -// } -//} -// -//// SubscriptionWithRequiredSessions will ensure the subscription requires senders and receivers to have sessionIDs -//func SubscriptionWithRequiredSessions() SubscriptionOption { -// return func(q *mgmt.SBSubscription) error { -// q.RequiresSession = ptrBool(true) -// return nil -// } -//} -// -//// SubscriptionWithDeadLetteringOnMessageExpiration will ensure the Subscription sends expired messages to the dead -//// letter queue -//func SubscriptionWithDeadLetteringOnMessageExpiration() SubscriptionOption { -// return func(q *mgmt.SBSubscription) error { -// q.DeadLetteringOnMessageExpiration = ptrBool(true) -// return nil -// } -//} -// -//// SubscriptionWithAutoDeleteOnIdle configures the subscription to automatically delete after the specified idle -//// interval. The minimum duration is 5 minutes. -//func SubscriptionWithAutoDeleteOnIdle(window *time.Duration) SubscriptionOption { -// return func(q *mgmt.SBSubscription) error { -// if window != nil { -// if window.Minutes() < 5 { -// return errors.New("SubscriptionWithAutoDeleteOnIdle: window must be greater than 5 minutes") -// } -// q.AutoDeleteOnIdle = durationTo8601Seconds(window) -// } -// return nil -// } -//} -// -//// SubscriptionWithMessageTimeToLive configures the subscription to set a time to live on messages. This is the duration -//// after which the message expires, starting from when the message is sent to Service Bus. This is the default value -//// used when TimeToLive is not set on a message itself. If nil, defaults to 14 days. -//func SubscriptionWithMessageTimeToLive(window *time.Duration) SubscriptionOption { -// return func(q *mgmt.SBSubscription) error { -// if window == nil { -// duration := time.Duration(14 * 24 * time.Hour) -// window = &duration -// } -// q.DefaultMessageTimeToLive = durationTo8601Seconds(window) -// return nil -// } -//} -// -//// EnsureSubscription creates a subscription if the subscription does not exist -//func (sb *serviceBus) EnsureSubscription(ctx context.Context, topicName, name string, opts ...SubscriptionOption) (*mgmt.SBSubscription, error) { -// log.Debugf("ensuring subscription %s exists", name) -// subClient := sb.getSubscriptionMgmtClient() -// subscription, err := subClient.Get(ctx, sb.resourceGroup, sb.namespace, topicName, name) -// -// if err != nil { -// newSub := &mgmt.SBSubscription{ -// Name: &name, -// SBSubscriptionProperties: &mgmt.SBSubscriptionProperties{ -// EnableBatchedOperations: ptrBool(false), -// }, -// } -// -// for _, opt := range opts { -// err = opt(newSub) -// if err != nil { -// return nil, err -// } -// } -// -// subscription, err = subClient.CreateOrUpdate(ctx, sb.resourceGroup, sb.namespace, topicName, name, *newSub) -// if err != nil { -// return nil, err -// } -// } -// return &subscription, nil -//} -// -//// GetSubscription fetches a topic by name -//func (sb *serviceBus) GetSubscription(ctx context.Context, topicName, name string) (*mgmt.SBSubscription, error) { -// client := sb.getSubscriptionMgmtClient() -// subscription, err := client.Get(ctx, sb.resourceGroup, sb.namespace, topicName, name) -// if err != nil { -// return nil, err -// } -// return &subscription, nil -//} -// -//// DeleteSubscription deletes an existing subscription -//func (sb *serviceBus) DeleteSubscription(ctx context.Context, topicName, name string) error { -// subscriptionClient := sb.getSubscriptionMgmtClient() -// _, err := subscriptionClient.Delete(ctx, sb.resourceGroup, sb.namespace, topicName, name) -// return err -//} -// -//func (sb *serviceBus) getSubscriptionMgmtClient() *mgmt.SubscriptionsClient { -// client := mgmt.NewSubscriptionsClientWithBaseURI(sb.environment.ResourceManagerEndpoint, sb.subscriptionID) -// client.Authorizer = autorest.NewBearerAuthorizer(sb.armToken) -// return &client -//} +import ( + "context" + "encoding/xml" + "errors" + "io/ioutil" + "sync" + "time" + + "github.com/Azure/go-autorest/autorest/date" +) + +type ( + // Subscription represents a Service Bus Subscription entity which are used to receive topic messages. A topic + // subscription resembles a virtual queue that receives copies of the messages that are sent to the topic. + //Messages are received from a subscription identically to the way they are received from a queue. + Subscription struct { + Name string + Topic *Topic + namespace *Namespace + receiver *receiver + receiverMu sync.Mutex + } + + // SubscriptionManager provides CRUD functionality for Service Bus Subscription + SubscriptionManager struct { + *EntityManager + Topic *Topic + } + + // SubscriptionFeed is a specialized Feed containing Topic Subscriptions + SubscriptionFeed struct { + *Feed + Entries []TopicEntry `xml:"entry"` + } + // SubscriptionEntry is a specialized Topic Feed Subscription + SubscriptionEntry struct { + *Entry + Content *SubscriptionContent `xml:"content"` + } + + // SubscriptionContent is a specialized Subscription body for an Atom Entry + SubscriptionContent struct { + XMLName xml.Name `xml:"content"` + Type string `xml:"type,attr"` + SubscriptionDescription SubscriptionDescription `xml:"SubscriptionDescription"` + } + + //true + //0001-01-01T00:00:00 + + // SubscriptionDescription is the content type for Subscription management requests + SubscriptionDescription struct { + XMLName xml.Name `xml:"SubscriptionDescription"` + ReceiveBaseDescription + BaseEntityDescription + DeadLetteringOnFilterEvaluationExceptions *bool `xml:"DeadLetteringOnFilterEvaluationExceptions,omitempty"` + AccessedAt date.Time `xml:"AccessedAt,omitempty"` + } + + // SubscriptionOption represents named options for assisting Subscription creation + SubscriptionOption func(topic *SubscriptionDescription) error +) + +// NewSubscriptionManager creates a new SubscriptionManager for a Service Bus Topic +func (t *Topic) NewSubscriptionManager() *SubscriptionManager { + return &SubscriptionManager{ + EntityManager: NewEntityManager(t.namespace.getHTTPSHostURI(), t.namespace.TokenProvider), + Topic: t, + } +} + +// NewSubscriptionManager creates a new SubscriptionManger for a Service Bus Namespace +func (ns *Namespace) NewSubscriptionManager(topicName, name string) *SubscriptionManager { + t := ns.NewTopic(topicName) + return &SubscriptionManager{ + EntityManager: NewEntityManager(t.namespace.getHTTPSHostURI(), t.namespace.TokenProvider), + Topic: t, + } +} + +// Delete deletes a Service Bus Topic entity by name +func (sm *SubscriptionManager) Delete(ctx context.Context, name string) error { + _, err := sm.EntityManager.Delete(ctx, sm.getResourceURI(name)) + return err +} + +// Put creates or updates a Service Bus Topic +func (sm *SubscriptionManager) Put(ctx context.Context, name string, opts ...SubscriptionOption) (*SubscriptionEntry, error) { + subscriptionDescription := new(SubscriptionDescription) + + for _, opt := range opts { + if err := opt(subscriptionDescription); err != nil { + return nil, err + } + } + + subscriptionDescription.InstanceMetadataSchema = instanceMetadataSchema + subscriptionDescription.ServiceBusSchema = serviceBusSchema + + qe := &SubscriptionEntry{ + Entry: &Entry{ + DataServiceSchema: dataServiceSchema, + DataServiceMetadataSchema: dataServiceMetadataSchema, + AtomSchema: atomSchema, + }, + Content: &SubscriptionContent{ + Type: applicationXML, + SubscriptionDescription: *subscriptionDescription, + }, + } + + reqBytes, err := xml.Marshal(qe) + if err != nil { + return nil, err + } + + reqBytes = xmlDoc(reqBytes) + res, err := sm.EntityManager.Put(ctx, sm.getResourceURI(name), reqBytes) + if err != nil { + return nil, err + } + + b, err := ioutil.ReadAll(res.Body) + if err != nil { + return nil, err + } + + var entry SubscriptionEntry + err = xml.Unmarshal(b, &entry) + return &entry, err +} + +// List fetches all of the Topics for a Service Bus Namespace +func (sm *SubscriptionManager) List(ctx context.Context) (*SubscriptionFeed, error) { + res, err := sm.EntityManager.Get(ctx, "/"+sm.Topic.Name+"/subscriptions") + if err != nil { + return nil, err + } + + b, err := ioutil.ReadAll(res.Body) + if err != nil { + return nil, err + } + + var feed SubscriptionFeed + err = xml.Unmarshal(b, &feed) + return &feed, err +} + +// Get fetches a Service Bus Topic entity by name +func (sm *SubscriptionManager) Get(ctx context.Context, name string) (*SubscriptionEntry, error) { + res, err := sm.EntityManager.Get(ctx, sm.getResourceURI(name)) + if err != nil { + return nil, err + } + + b, err := ioutil.ReadAll(res.Body) + if err != nil { + return nil, err + } + + var entry SubscriptionEntry + err = xml.Unmarshal(b, &entry) + return &entry, err +} + +func (sm *SubscriptionManager) getResourceURI(name string) string { + return "/" + sm.Topic.Name + "/subscriptions/" + name +} + +// NewSubscription creates a new Topic Subscription client +func (t *Topic) NewSubscription(name string) *Subscription { + return &Subscription{ + namespace: t.namespace, + Name: name, + Topic: t, + } +} + +// Receive subscribes for messages sent to the Subscription +func (s *Subscription) Receive(ctx context.Context, handler Handler, opts ...ReceiverOptions) (*ListenerHandle, error) { + s.receiverMu.Lock() + defer s.receiverMu.Unlock() + + if s.receiver != nil { + if err := s.receiver.Close(ctx); err != nil { + return nil, err + } + } + + receiver, err := s.namespace.newReceiver(ctx, s.Topic.Name + "/Subscriptions/" + s.Name) + for _, opt := range opts { + if err := opt(receiver); err != nil { + return nil, err + } + } + + if err != nil { + return nil, err + } + + s.receiver = receiver + return receiver.Listen(handler), err +} + +// Close the underlying connection to Service Bus +func (s *Subscription) Close(ctx context.Context) error { + if s.receiver != nil { + return s.receiver.Close(ctx) + } + return nil +} + +// SubscriptionWithBatchedOperations configures the subscription to batch server-side operations. +func SubscriptionWithBatchedOperations() SubscriptionOption { + return func(s *SubscriptionDescription) error { + s.EnableBatchedOperations = ptrBool(true) + return nil + } +} + +// SubscriptionWithLockDuration configures the subscription to have a duration of a peek-lock; that is, the amount of +// time that the message is locked for other receivers. The maximum value for LockDuration is 5 minutes; the default +// value is 1 minute. +func SubscriptionWithLockDuration(window *time.Duration) SubscriptionOption { + return func(s *SubscriptionDescription) error { + if window == nil { + duration := time.Duration(1 * time.Minute) + window = &duration + } + s.LockDuration = durationTo8601Seconds(window) + return nil + } +} + +// SubscriptionWithRequiredSessions will ensure the subscription requires senders and receivers to have sessionIDs +func SubscriptionWithRequiredSessions() SubscriptionOption { + return func(s *SubscriptionDescription) error { + s.RequiresSession = ptrBool(true) + return nil + } +} + +// SubscriptionWithDeadLetteringOnMessageExpiration will ensure the Subscription sends expired messages to the dead +// letter queue +func SubscriptionWithDeadLetteringOnMessageExpiration() SubscriptionOption { + return func(s *SubscriptionDescription) error { + s.DeadLetteringOnMessageExpiration = ptrBool(true) + return nil + } +} + +// SubscriptionWithAutoDeleteOnIdle configures the subscription to automatically delete after the specified idle +// interval. The minimum duration is 5 minutes. +func SubscriptionWithAutoDeleteOnIdle(window *time.Duration) SubscriptionOption { + return func(s *SubscriptionDescription) error { + if window != nil { + if window.Minutes() < 5 { + return errors.New("window must be greater than 5 minutes") + } + s.AutoDeleteOnIdle = durationTo8601Seconds(window) + } + return nil + } +} + +// SubscriptionWithMessageTimeToLive configures the subscription to set a time to live on messages. This is the duration +// after which the message expires, starting from when the message is sent to Service Bus. This is the default value +// used when TimeToLive is not set on a message itself. If nil, defaults to 14 days. +func SubscriptionWithMessageTimeToLive(window *time.Duration) SubscriptionOption { + return func(s *SubscriptionDescription) error { + if window == nil { + duration := time.Duration(14 * 24 * time.Hour) + window = &duration + } + s.DefaultMessageTimeToLive = durationTo8601Seconds(window) + return nil + } +} diff --git a/subscription_test.go b/subscription_test.go index a88729cd213a..997e8b5edc77 100644 --- a/subscription_test.go +++ b/subscription_test.go @@ -1,107 +1,283 @@ package servicebus -//import ( -// "context" -// "fmt" -// mgmt "github.com/Azure/azure-sdk-for-go/services/servicebus/mgmt/2017-04-01/servicebus" -// "github.com/stretchr/testify/assert" -// "testing" -// "time" -// "log" -//) -// -//func (suite *ServiceBusSuite) TestSubscriptionManagement() { -// tests := map[string]func(*testing.T, SenderReceiverManager, string, string){ -// "TestSubscriptionDefaultSettings": testDefaultSubscription, -// "TestSubscriptionWithAutoDeleteOnIdle": testSubscriptionWithAutoDeleteOnIdle, -// "TestSubscriptionWithRequiredSessions": testSubscriptionWithRequiredSessions, -// "TestSubscriptionWithDeadLetteringOnMessageExpiration": testSubscriptionWithDeadLetteringOnMessageExpiration, -// "TestSubscriptionWithMessageTimeToLive": testSubscriptionWithMessageTimeToLive, -// "TestSubscriptionWithLockDuration": testSubscriptionWithLockDuration, -// "TestSubscriptionWithBatchedOperations": testSubscriptionWithBatchedOperations, -// } -// -// sb := suite.getNewSasInstance() -// defer func() { -// sb.Close() -// }() -// -// for name, testFunc := range tests { -// setupTestTeardown := func(t *testing.T) { -// ctx := context.Background() -// topicName := randomName("gosbtest", 10) -// subName := randomName("gosbtest", 10) -// _, err := sb.EnsureTopic(ctx, topicName) -// if err != nil { -// log.Fatalln(err) -// } -// -// defer func(tName, sName string) { -// err = sb.DeleteSubscription(ctx, tName, sName) -// err2 := sb.DeleteTopic(ctx, tName) -// if err != nil { -// log.Fatalln(err) -// } -// -// if err2 != nil { -// log.Fatalln(err2) -// } -// }(topicName, subName) -// -// testFunc(t, sb, topicName, subName) -// } -// suite.T().Run(name, setupTestTeardown) -// } -//} -// -//func testDefaultSubscription(t *testing.T, sb SenderReceiverManager, topicName, name string) { -// s := buildSubscription(t, sb, topicName, name) -// assert.False(t, *s.DeadLetteringOnMessageExpiration, "should not have dead lettering on expiration") -// assert.False(t, *s.RequiresSession, "should not require session") -// assert.Equal(t, "P10675199DT2H48M5.4775807S", *s.AutoDeleteOnIdle, "auto delete is not 10 minutes") -// assert.Nil(t, s.DuplicateDetectionHistoryTimeWindow, "dup detection is nil") -// assert.Equal(t, "P10675199DT2H48M5.4775807S", *s.DefaultMessageTimeToLive, "default TTL") -// assert.Equal(t, mgmt.Active, s.Status, "subscription status") -// assert.Equal(t, "PT1M", *s.LockDuration, "lock duration") -//} -// -//func testSubscriptionWithBatchedOperations(t *testing.T, sb SenderReceiverManager, topicName, name string) { -// s := buildSubscription(t, sb, topicName, name, SubscriptionWithBatchedOperations()) -// assert.True(t, *s.EnableBatchedOperations) -//} -// -//func testSubscriptionWithAutoDeleteOnIdle(t *testing.T, sb SenderReceiverManager, topicName, name string) { -// window := time.Duration(20 * time.Minute) -// s := buildSubscription(t, sb, topicName, name, SubscriptionWithAutoDeleteOnIdle(&window)) -// assert.Equal(t, "PT20M", *s.AutoDeleteOnIdle) -//} -// -//func testSubscriptionWithRequiredSessions(t *testing.T, sb SenderReceiverManager, topicName, name string) { -// s := buildSubscription(t, sb, topicName, name, SubscriptionWithRequiredSessions()) -// assert.True(t, *s.RequiresSession) -//} -// -//func testSubscriptionWithDeadLetteringOnMessageExpiration(t *testing.T, sb SenderReceiverManager, topicName, name string) { -// s := buildSubscription(t, sb, topicName, name, SubscriptionWithDeadLetteringOnMessageExpiration()) -// assert.True(t, *s.DeadLetteringOnMessageExpiration) -//} -// -//func testSubscriptionWithMessageTimeToLive(t *testing.T, sb SenderReceiverManager, topicName, name string) { -// window := time.Duration(10 * 24 * 60 * time.Minute) -// s := buildSubscription(t, sb, topicName, name, SubscriptionWithMessageTimeToLive(&window)) -// assert.Equal(t, "P10D", *s.DefaultMessageTimeToLive) -//} -// -//func testSubscriptionWithLockDuration(t *testing.T, sb SenderReceiverManager, topicName, name string) { -// window := time.Duration(3 * time.Minute) -// s := buildSubscription(t, sb, topicName, name, SubscriptionWithLockDuration(&window)) -// assert.Equal(t, "PT3M", *s.LockDuration) -//} -// -//func buildSubscription(t *testing.T, sb SenderReceiverManager, topicName, name string, opts ...SubscriptionOption) *mgmt.SBSubscription { -// s, err := sb.EnsureSubscription(context.Background(), topicName, name, opts...) -// if err != nil { -// assert.FailNow(t, fmt.Sprintf("%v", err)) -// } -// return s -//} +import ( + "context" + "encoding/xml" + "fmt" + "log" + "sync" + "testing" + "time" + + "github.com/Azure/azure-sdk-for-go/services/servicebus/mgmt/2015-08-01/servicebus" + "github.com/stretchr/testify/assert" +) + +const ( + subscriptionDescription = ` + + PT1M + false + P10675199DT2H48M5.4775807S + false + true + 0 + 10 + true + Active + 2018-05-04T22:41:54.183101Z + 2018-05-04T22:41:54.183101Z + 0001-01-01T00:00:00 + P10675199DT2H48M5.4775807S + Available + ` + + subscriptionEntry = ` + + https://sbdjtest.servicebus.windows.net/gosbh6of3g-tagz3cfzrp93m/subscriptions/gosbwg424p-tagz3cfzrp93m?api-version=2017-04 + gosbwg424p-tagz3cfzrp93m + 2018-05-02T20:54:59Z + 2018-05-02T20:54:59Z + + ` + subscriptionDescription + + ` + ` +) + +func (suite *serviceBusSuite) TestSubscriptionEntryUnmarshal() { + var entry SubscriptionEntry + err := xml.Unmarshal([]byte(subscriptionEntry), &entry) + assert.Nil(suite.T(), err) + assert.Equal(suite.T(), "https://sbdjtest.servicebus.windows.net/gosbh6of3g-tagz3cfzrp93m/subscriptions/gosbwg424p-tagz3cfzrp93m?api-version=2017-04", entry.ID) + assert.Equal(suite.T(), "gosbwg424p-tagz3cfzrp93m", entry.Title) + assert.Equal(suite.T(), "https://sbdjtest.servicebus.windows.net/gosbh6of3g-tagz3cfzrp93m/subscriptions/gosbwg424p-tagz3cfzrp93m?api-version=2017-04", entry.Link.HREF) + assert.Equal(suite.T(), "PT1M", *entry.Content.SubscriptionDescription.LockDuration) + assert.NotNil(suite.T(), entry.Content) +} + +func (suite *serviceBusSuite) TestSubscriptionUnmarshal() { + var entry SubscriptionEntry + err := xml.Unmarshal([]byte(subscriptionEntry), &entry) + assert.Nil(suite.T(), err) + t := suite.T() + s := entry.Content.SubscriptionDescription + assert.Equal(t, "PT1M", *s.LockDuration) + assert.Equal(t, false, *s.RequiresSession) + assert.Equal(t, "P10675199DT2H48M5.4775807S", *s.DefaultMessageTimeToLive) + assert.Equal(t, false, *s.DeadLetteringOnMessageExpiration) + assert.Equal(t, int32(10), *s.MaxDeliveryCount) + assert.Equal(t, true, *s.EnableBatchedOperations) + assert.Equal(t, int64(0), *s.MessageCount) + assert.EqualValues(t, servicebus.EntityStatusActive, *s.Status) +} + +func (suite *serviceBusSuite) TestSubscriptionManagementWrites() { + tests := map[string]func(context.Context, *testing.T, *SubscriptionManager, string){ + "TestPutDefaultSubscription": testPutSubscription, + } + + ns := suite.getNewSasInstance() + tm := ns.NewTopicManager() + + outerCtx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + topicName := suite.RandomName("gosb", 6) + _, err := tm.Put(outerCtx, topicName) + if err != nil { + suite.T().Fatal(err) + } + topic := ns.NewTopic(topicName) + sm := topic.NewSubscriptionManager() + for name, testFunc := range tests { + suite.T().Run(name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + name := suite.RandomName("gosb", 6) + testFunc(ctx, t, sm, name) + defer suite.cleanupSubscription(topicName, name) + }) + } +} + +func testPutSubscription(ctx context.Context, t *testing.T, sm *SubscriptionManager, name string) { + topic, err := sm.Put(ctx, name) + if !assert.Nil(t, err) { + t.FailNow() + } + if assert.NotNil(t, topic) { + assert.Equal(t, name, topic.Title) + } +} + +func (suite *serviceBusSuite) cleanupSubscription(topicName, subscriptionName string) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + ns := suite.getNewSasInstance() + topic := ns.NewTopic(topicName) + sm := topic.NewSubscriptionManager() + err := sm.Delete(ctx, subscriptionName) + if err != nil { + suite.T().Fatal(err) + } +} + +func (suite *serviceBusSuite) TestSubscriptionManagement() { + tests := map[string]func(context.Context, *testing.T, *SubscriptionManager, string, string){ + "TestSubscriptionDefaultSettings": testDefaultSubscription, + "TestSubscriptionWithAutoDeleteOnIdle": testSubscriptionWithAutoDeleteOnIdle, + "TestSubscriptionWithRequiredSessions": testSubscriptionWithRequiredSessions, + "TestSubscriptionWithDeadLetteringOnMessageExpiration": testSubscriptionWithDeadLetteringOnMessageExpiration, + "TestSubscriptionWithMessageTimeToLive": testSubscriptionWithMessageTimeToLive, + "TestSubscriptionWithLockDuration": testSubscriptionWithLockDuration, + "TestSubscriptionWithBatchedOperations": testSubscriptionWithBatchedOperations, + } + + ns := suite.getNewSasInstance() + tm := ns.NewTopicManager() + + for name, testFunc := range tests { + setupTestTeardown := func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + topicName := suite.randEntityName() + subName := suite.randEntityName() + + _, err := tm.Put(ctx, topicName) + if err != nil { + t.Fatal(err) + } + topic := ns.NewTopic(topicName) + sm := topic.NewSubscriptionManager() + _, err = sm.Put(ctx, topicName) + if err != nil { + t.Fatal(err) + } + + defer func(tName, sName string) { + innerCtx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + err = sm.Delete(innerCtx, sName) + err2 := tm.Delete(innerCtx, tName) + if err != nil { + suite.T().Fatal(err) + } + if err2 != nil { + suite.T().Fatal(err2) + } + }(topicName, subName) + + testFunc(ctx, t, sm, topicName, subName) + } + suite.T().Run(name, setupTestTeardown) + } +} + +func testDefaultSubscription(ctx context.Context, t *testing.T, sm *SubscriptionManager, _, name string) { + s := buildSubscription(ctx, t, sm, name) + assert.False(t, *s.DeadLetteringOnMessageExpiration, "should not have dead lettering on expiration") + assert.False(t, *s.RequiresSession, "should not require session") + assert.Equal(t, "P10675199DT2H48M5.4775807S", *s.AutoDeleteOnIdle, "auto delete is not 10 minutes") + assert.Equal(t, "P10675199DT2H48M5.4775807S", *s.DefaultMessageTimeToLive, "default TTL") + assert.EqualValues(t, servicebus.EntityStatusActive, *s.Status) + assert.Equal(t, "PT1M", *s.LockDuration, "lock duration") +} + +func testSubscriptionWithBatchedOperations(ctx context.Context, t *testing.T, sm *SubscriptionManager, _, name string) { + s := buildSubscription(ctx, t, sm, name) + assert.True(t, *s.EnableBatchedOperations) +} + +func testSubscriptionWithAutoDeleteOnIdle(ctx context.Context, t *testing.T, sm *SubscriptionManager, _, name string) { + window := time.Duration(20 * time.Minute) + s := buildSubscription(ctx, t, sm, name, SubscriptionWithAutoDeleteOnIdle(&window)) + assert.Equal(t, "PT20M", *s.AutoDeleteOnIdle) +} + +func testSubscriptionWithRequiredSessions(ctx context.Context, t *testing.T, sm *SubscriptionManager, _, name string) { + s := buildSubscription(ctx, t, sm, name, SubscriptionWithRequiredSessions()) + assert.True(t, *s.RequiresSession) +} + +func testSubscriptionWithDeadLetteringOnMessageExpiration(ctx context.Context, t *testing.T, sm *SubscriptionManager, _, name string) { + s := buildSubscription(ctx, t, sm, name, SubscriptionWithDeadLetteringOnMessageExpiration()) + assert.True(t, *s.DeadLetteringOnMessageExpiration) +} + +func testSubscriptionWithMessageTimeToLive(ctx context.Context, t *testing.T, sm *SubscriptionManager, _, name string) { + window := time.Duration(10 * 24 * 60 * time.Minute) + s := buildSubscription(ctx, t, sm, name, SubscriptionWithMessageTimeToLive(&window)) + assert.Equal(t, "P10D", *s.DefaultMessageTimeToLive) +} + +func testSubscriptionWithLockDuration(ctx context.Context, t *testing.T, sm *SubscriptionManager, _, name string) { + window := time.Duration(3 * time.Minute) + s := buildSubscription(ctx, t, sm, name, SubscriptionWithLockDuration(&window)) + assert.Equal(t, "PT3M", *s.LockDuration) +} + +func buildSubscription(ctx context.Context, t *testing.T, sm *SubscriptionManager, name string, opts ...SubscriptionOption) *SubscriptionDescription { + s, err := sm.Put(ctx, name, opts...) + if err != nil { + assert.FailNow(t, fmt.Sprintf("%v", err)) + } + return &s.Content.SubscriptionDescription +} + +func (suite *serviceBusSuite) TestSubscription() { + tests := map[string]func(context.Context, *testing.T, *Topic, *Subscription){ + "SimpleReceive": testSubscriptionReceive, + } + + ns := suite.getNewSasInstance() + tm := ns.NewTopicManager() + for name, testFunc := range tests { + setupTestTeardown := func(t *testing.T) { + topicName := suite.randEntityName() + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + _, err := tm.Put(ctx, topicName) + if err != nil { + log.Fatalln(err) + } + + topic := ns.NewTopic(topicName) + sm := topic.NewSubscriptionManager() + subName := suite.randEntityName() + sm.Put(ctx, subName) + subscription := topic.NewSubscription(subName) + defer func() { + closeCtx, closeCancel := context.WithTimeout(context.Background(), timeout) + defer closeCancel() + topic.Close(closeCtx) + suite.cleanupTopic(topicName) + subscription.Close(closeCtx) + suite.cleanupSubscription(topicName, subName) + + }() + testFunc(ctx, t, topic, subscription) + } + + suite.T().Run(name, setupTestTeardown) + } +} + +func testSubscriptionReceive(ctx context.Context, t *testing.T, topic *Topic, sub *Subscription) { + err := topic.Send(ctx, NewEventFromString("hello!")) + assert.Nil(t, err) + + var wg sync.WaitGroup + wg.Add(1) + _, err = sub.Receive(ctx, func(eventCtx context.Context, evt *Event) error { + wg.Done() + return nil + }) + if err != nil { + t.Fatal(err) + } + end, _ := ctx.Deadline() + waitUntil(t, &wg, time.Until(end)) +} diff --git a/topic.go b/topic.go index 85ead30198d4..92596152a39b 100644 --- a/topic.go +++ b/topic.go @@ -51,10 +51,11 @@ type ( // TopicDescription is the content type for Topic management requests TopicDescription struct { - XMLName xml.Name `xml:"TopicDescription"` + XMLName xml.Name `xml:"TopicDescription"` + SendBaseDescription BaseEntityDescription - FilteringMessagesBeforePublishing *bool `xml:"FilteringMessagesBeforePublishing,omitempty"` - EnableSubscriptionPartitioning *bool `xml:"EnableSubscriptionPartitioning,omitempty"` + FilteringMessagesBeforePublishing *bool `xml:"FilteringMessagesBeforePublishing,omitempty"` + EnableSubscriptionPartitioning *bool `xml:"EnableSubscriptionPartitioning,omitempty"` } // TopicOption represents named options for assisting Topic creation @@ -163,33 +164,33 @@ func (ns *Namespace) NewTopic(name string) *Topic { } // Send sends messages to the Topic -func (q *Topic) Send(ctx context.Context, event *Event, opts ...SendOption) error { - err := q.ensureSender(ctx) +func (t *Topic) Send(ctx context.Context, event *Event, opts ...SendOption) error { + err := t.ensureSender(ctx) if err != nil { return err } - return q.sender.Send(ctx, event, opts...) + return t.sender.Send(ctx, event, opts...) } // Close the underlying connection to Service Bus -func (q *Topic) Close(ctx context.Context) error { - if q.sender != nil { - return q.sender.Close(ctx) +func (t *Topic) Close(ctx context.Context) error { + if t.sender != nil { + return t.sender.Close(ctx) } return nil } -func (q *Topic) ensureSender(ctx context.Context) error { - q.senderMu.Lock() - defer q.senderMu.Unlock() +func (t *Topic) ensureSender(ctx context.Context) error { + t.senderMu.Lock() + defer t.senderMu.Unlock() - if q.sender == nil { - s, err := q.namespace.newSender(ctx, q.Name) + if t.sender == nil { + s, err := t.namespace.newSender(ctx, t.Name) if err != nil { return err } - q.sender = s + t.sender = s } return nil } @@ -277,4 +278,4 @@ func TopicWithMessageTimeToLive(window *time.Duration) TopicOption { t.DefaultMessageTimeToLive = durationTo8601Seconds(window) return nil } -} \ No newline at end of file +} diff --git a/topic_test.go b/topic_test.go index 95971c612fb7..baa6f2773384 100644 --- a/topic_test.go +++ b/topic_test.go @@ -97,11 +97,21 @@ func (suite *serviceBusSuite) TestTopicManagementWrites() { defer cancel() name := suite.RandomName("gosb", 6) testFunc(ctx, t, tm, name) - defer suite.cleanupQueue(name) + defer suite.cleanupTopic(name) }) } } +func testPutTopic(ctx context.Context, t *testing.T, tm *TopicManager, name string) { + topic, err := tm.Put(ctx, name) + if !assert.Nil(t, err) { + t.FailNow() + } + if assert.NotNil(t, topic) { + assert.Equal(t, name, topic.Title) + } +} + func (suite *serviceBusSuite) TestTopicManagementReads() { tests := map[string]func(context.Context, *testing.T, *TopicManager, []string){ "TestGetTopic": testGetTopic, @@ -155,16 +165,6 @@ func testListTopics(ctx context.Context, t *testing.T, tm *TopicManager, names [ } } -func testPutTopic(ctx context.Context, t *testing.T, tm *TopicManager, name string) { - topic, err := tm.Put(ctx, name) - if !assert.Nil(t, err) { - t.FailNow() - } - if assert.NotNil(t, topic) { - assert.Equal(t, name, topic.Title) - } -} - func (suite *serviceBusSuite) TestTopicManagement() { tests := map[string]func(context.Context, *testing.T, *TopicManager, string){ "DefaultTopicCreation": testDefaultTopic, From 80d2a51fb5533928d2639833185c31299c5a88e2 Mon Sep 17 00:00:00 2001 From: David Justice Date: Wed, 9 May 2018 09:14:56 -0700 Subject: [PATCH 4/4] update amqp common dep --- Gopkg.lock | 10 +++++----- Gopkg.toml | 3 +-- subscription.go | 6 +++--- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/Gopkg.lock b/Gopkg.lock index 9f0f8ddbb383..7ea32dcc2f80 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -2,7 +2,6 @@ [[projects]] - branch = "sas-refactor" name = "github.com/Azure/azure-amqp-common-go" packages = [ ".", @@ -16,13 +15,14 @@ "sas", "uuid" ] - revision = "37a2f000ef04319ea7168a0bcdf53837cd1d2187" - source = "github.com/devigned/azure-amqp-common-go" + revision = "7c6d990e663e3d68d2e7db38a2ede690bb9ad002" + version = "v0.4.0" [[projects]] name = "github.com/Azure/azure-sdk-for-go" packages = [ "services/resources/mgmt/2017-05-10/resources", + "services/servicebus/mgmt/2015-08-01/servicebus", "services/servicebus/mgmt/2017-04-01/servicebus", "version" ] @@ -90,7 +90,7 @@ branch = "master" name = "golang.org/x/net" packages = ["context"] - revision = "640f4622ab692b87c2f3a94265e6f579fe38263d" + revision = "f73e4c9ed3b7ebdd5f699a16a880c2b1994e50dd" [[projects]] branch = "master" @@ -104,6 +104,6 @@ [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "582fa2064fe97ab1f7437988259f34ad14c395b08c9660d71e0e818edadd289d" + inputs-digest = "650c8d9022e6e985cd93762ec02122314aa6daeeb2b083b3b3ba3c45a5c64549" solver-name = "gps-cdcl" solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index 46690418d6d5..168ff6454e19 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -13,5 +13,4 @@ [[constraint]] name = "github.com/Azure/azure-amqp-common-go" - branch = "sas-refactor" - source = "github.com/devigned/azure-amqp-common-go" + version = "0.4" diff --git a/subscription.go b/subscription.go index 86b7e63d2372..b4f012ad9a61 100644 --- a/subscription.go +++ b/subscription.go @@ -52,7 +52,7 @@ type ( // SubscriptionDescription is the content type for Subscription management requests SubscriptionDescription struct { - XMLName xml.Name `xml:"SubscriptionDescription"` + XMLName xml.Name `xml:"SubscriptionDescription"` ReceiveBaseDescription BaseEntityDescription DeadLetteringOnFilterEvaluationExceptions *bool `xml:"DeadLetteringOnFilterEvaluationExceptions,omitempty"` @@ -106,7 +106,7 @@ func (sm *SubscriptionManager) Put(ctx context.Context, name string, opts ...Sub AtomSchema: atomSchema, }, Content: &SubscriptionContent{ - Type: applicationXML, + Type: applicationXML, SubscriptionDescription: *subscriptionDescription, }, } @@ -190,7 +190,7 @@ func (s *Subscription) Receive(ctx context.Context, handler Handler, opts ...Rec } } - receiver, err := s.namespace.newReceiver(ctx, s.Topic.Name + "/Subscriptions/" + s.Name) + receiver, err := s.namespace.newReceiver(ctx, s.Topic.Name+"/Subscriptions/"+s.Name) for _, opt := range opts { if err := opt(receiver); err != nil { return nil, err