@@ -48,6 +48,16 @@ const (
4848 // MaxPublishRequestBytes is the maximum size of a single publish request
4949 // in bytes, as defined by the PubSub service.
5050 MaxPublishRequestBytes = 1e7
51+
52+ // hedging is disabled by default but if HedgingSettings is set to a zero value,
53+ // we will use a default value of 50ms.
54+ defaultHedgingDelay time.Duration = 50 * time .Millisecond
55+ )
56+
57+ // Default Token Bucket configurations
58+ const (
59+ defaultHedgingRatio float64 = 0.1
60+ defaultMaxHedgingTokens float64 = 100.0
5161)
5262
5363// ErrOversizedMessage indicates that a message's size exceeds MaxPublishRequestBytes.
@@ -78,6 +88,88 @@ type Publisher struct {
7888 // This is configured at client instantiation, and allows
7989 // disabling tracing even when a tracer provider is detectd.
8090 enableTracing bool
91+
92+ // if non-zero, publish requests will be hedged after this delay.
93+ // The first request will be sent immediately, and if it has not
94+ // completed after the hedging delay, a second request will be sent.
95+ // The first response to arrive will be used, and the other request
96+ // will be cancelled.
97+ hedgingDelay time.Duration
98+
99+ // the current number of tokens which limits the number of hedged requests
100+ // that can be sent concurrently.
101+ hedgingTokenBucket float64
102+
103+ hedgingMu sync.Mutex
104+ hedgingQueue []* hedgedRequest
105+ hedgingStopped bool
106+ }
107+
108+ type hedgedRequest struct {
109+ sendAfter time.Time
110+ exec func ()
111+ isDone func () bool
112+ }
113+
114+ // cancellationSharer coordinates cancellation between all publish attempts.
115+ // When one attempt completes, it cancels all other attempts to minimize
116+ // duplicate messages on the server.
117+ type cancellationSharer struct {
118+ mu sync.Mutex
119+ cancels map [int ]context.CancelFunc
120+ done bool
121+ nextID int
122+ }
123+
124+ func newCancellationSharer () * cancellationSharer {
125+ return & cancellationSharer {
126+ cancels : make (map [int ]context.CancelFunc ),
127+ }
128+ }
129+
130+ func (cs * cancellationSharer ) isDone () bool {
131+ cs .mu .Lock ()
132+ defer cs .mu .Unlock ()
133+ return cs .done
134+ }
135+
136+ // add registers a cancel function and returns its ID. Returns -1 if already done.
137+ func (cs * cancellationSharer ) add (cancel context.CancelFunc ) int {
138+ cs .mu .Lock ()
139+ defer cs .mu .Unlock ()
140+ if cs .done {
141+ cancel ()
142+ return - 1
143+ }
144+ id := cs .nextID
145+ cs .nextID ++
146+ cs .cancels [id ] = cancel
147+ return id
148+ }
149+
150+ // win marks the coordinator as resolved by winnerID and cancels all other attempts.
151+ func (cs * cancellationSharer ) win (winnerID int ) {
152+ cs .mu .Lock ()
153+ defer cs .mu .Unlock ()
154+ if cs .done {
155+ return
156+ }
157+ cs .done = true
158+ for id , cancel := range cs .cancels {
159+ if id != winnerID {
160+ cancel ()
161+ }
162+ }
163+ }
164+
165+ // cancelAll cancels all registered attempt contexts.
166+ func (cs * cancellationSharer ) cancelAll () {
167+ cs .mu .Lock ()
168+ defer cs .mu .Unlock ()
169+ cs .done = true
170+ for _ , cancel := range cs .cancels {
171+ cancel ()
172+ }
81173}
82174
83175// PublishSettings control the bundling of published messages.
@@ -111,6 +203,23 @@ type PublishSettings struct {
111203 // CompressionBytesThreshold defines the threshold (in bytes) above which messages
112204 // are compressed for transport. Only takes effect if EnableCompression is true.
113205 CompressionBytesThreshold int
206+
207+ HedgingSettings * HedgingSettings
208+ }
209+
210+ type HedgingSettings struct {
211+ Delay time.Duration
212+
213+ // MaxHedgedAttempts is the maximum number of hedged requests to send.
214+ // If 0, there is no limit on hedged attempts (dynamic multi-hedging).
215+ // If set to 1, at most 1 hedged request is sent per bundle (single hedging).
216+ MaxHedgedAttempts int
217+
218+ // MaxTokens is the maximum number of tokens for the hedging token bucket.
219+ MaxTokens float64
220+
221+ // TokenRatio is the amount of tokens added to the bucket per successful publish.
222+ TokenRatio float64
114223}
115224
116225func (ps * PublishSettings ) shouldCompress (batchSize int ) bool {
@@ -154,11 +263,17 @@ func (c *Client) Publisher(topicNameOrID string) *Publisher {
154263}
155264
156265func newPublisher (c * Client , name string ) * Publisher {
266+ var maxTokens float64 = defaultMaxHedgingTokens
267+ if DefaultPublishSettings .HedgingSettings != nil && DefaultPublishSettings .HedgingSettings .MaxTokens > 0 {
268+ maxTokens = DefaultPublishSettings .HedgingSettings .MaxTokens
269+ }
270+
157271 return & Publisher {
158- c : c ,
159- name : name ,
160- PublishSettings : DefaultPublishSettings ,
161- enableTracing : c .enableTracing ,
272+ c : c ,
273+ name : name ,
274+ PublishSettings : DefaultPublishSettings ,
275+ enableTracing : c .enableTracing ,
276+ hedgingTokenBucket : maxTokens ,
162277 }
163278}
164279
@@ -192,6 +307,7 @@ var ErrPublisherStopped = errors.New("pubsub: Stop has been called for this publ
192307type PublishResult = ipubsub.PublishResult
193308
194309var errPublisherOrderingNotEnabled = errors .New ("Publisher.EnableMessageOrdering=false, but an OrderingKey was set in Message. Please remove the OrderingKey or turn on Publisher.EnableMessageOrdering" )
310+ var errPublisherHedgingAndOrderingEnabled = errors .New ("pubsub: Hedging and MessageOrdering cannot both be enabled on the Publisher" )
195311
196312// Publish publishes msg to the topic asynchronously. Messages are batched and
197313// sent according to the topic's PublishSettings. Publish never blocks.
@@ -220,6 +336,11 @@ func (t *Publisher) Publish(ctx context.Context, msg *Message) *PublishResult {
220336 spanRecordError (createSpan , errPublisherOrderingNotEnabled )
221337 return r
222338 }
339+ if t .EnableMessageOrdering && t .PublishSettings .HedgingSettings != nil {
340+ ipubsub .SetPublishResult (r , "" , errPublisherHedgingAndOrderingEnabled )
341+ spanRecordError (createSpan , errPublisherHedgingAndOrderingEnabled )
342+ return r
343+ }
223344
224345 // Calculate the size of the encoded proto message by accounting
225346 // for the length of an individual PubSubMessage and Data/Attributes field.
@@ -293,6 +414,7 @@ func (t *Publisher) Stop() {
293414 if noop {
294415 return
295416 }
417+ t .stopHedging ()
296418 t .scheduler .FlushAndStop ()
297419}
298420
@@ -387,6 +509,97 @@ func (t *Publisher) initBundler() {
387509 // The max size of publish messages in a system should be handled by the flow controller,
388510 // not the scheduler or bundler. Disable this by setting to MaxInt.
389511 t .scheduler .BufferedByteLimit = math .MaxInt
512+
513+ if t .PublishSettings .HedgingSettings != nil && ! t .EnableMessageOrdering {
514+ t .hedgingDelay = t .PublishSettings .HedgingSettings .Delay
515+ if t .hedgingDelay == 0 {
516+ t .hedgingDelay = defaultHedgingDelay
517+ }
518+ go t .runHedgingQueue ()
519+ }
520+ }
521+
522+ func (t * Publisher ) stopHedging () {
523+ t .hedgingMu .Lock ()
524+ t .hedgingStopped = true
525+ t .hedgingQueue = nil
526+ t .hedgingMu .Unlock ()
527+ }
528+
529+ func (t * Publisher ) enqueueHedgedRequest (req * hedgedRequest ) {
530+ t .hedgingMu .Lock ()
531+ defer t .hedgingMu .Unlock ()
532+ if t .hedgingStopped {
533+ return
534+ }
535+ t .hedgingQueue = append (t .hedgingQueue , req )
536+ }
537+
538+ func (t * Publisher ) runHedgingQueue () {
539+ ticker := time .NewTicker (5 * time .Millisecond )
540+ defer ticker .Stop ()
541+ for range ticker .C {
542+ t .hedgingMu .Lock ()
543+ if t .hedgingStopped {
544+ t .hedgingMu .Unlock ()
545+ return
546+ }
547+ now := time .Now ()
548+ var ready []* hedgedRequest
549+ var remaining []* hedgedRequest
550+ for _ , req := range t .hedgingQueue {
551+ if req .isDone != nil && req .isDone () {
552+ // Request already completed or cancelled; discard without token usage.
553+ continue
554+ }
555+ if ! now .Before (req .sendAfter ) {
556+ ready = append (ready , req )
557+ } else {
558+ remaining = append (remaining , req )
559+ }
560+ }
561+ t .hedgingQueue = remaining
562+ t .hedgingMu .Unlock ()
563+
564+ for _ , req := range ready {
565+ if req .isDone != nil && req .isDone () {
566+ continue
567+ }
568+ t .hedgingMu .Lock ()
569+ hasToken := t .hedgingTokenBucket >= 1.0
570+ if hasToken {
571+ t .hedgingTokenBucket -= 1.0
572+ }
573+ t .hedgingMu .Unlock ()
574+
575+ if hasToken {
576+ go req .exec ()
577+ }
578+ }
579+ }
580+ }
581+
582+ func (t * Publisher ) replenishHedgingTokens () {
583+ t .hedgingMu .Lock ()
584+ defer t .hedgingMu .Unlock ()
585+
586+ ratio := defaultHedgingRatio
587+ maxTokens := defaultMaxHedgingTokens
588+ if t .PublishSettings .HedgingSettings != nil {
589+ if t .PublishSettings .HedgingSettings .TokenRatio > 0 {
590+ ratio = t .PublishSettings .HedgingSettings .TokenRatio
591+ }
592+ if t .PublishSettings .HedgingSettings .MaxTokens > 0 {
593+ maxTokens = t .PublishSettings .HedgingSettings .MaxTokens
594+ }
595+ }
596+
597+ if t .hedgingTokenBucket < maxTokens {
598+ t .hedgingTokenBucket += ratio
599+ if t .hedgingTokenBucket > maxTokens {
600+ t .hedgingTokenBucket = maxTokens
601+ }
602+ }
390603}
391604
392605// ErrPublishingPaused is a custom error indicating that the publish paused for the specified ordering key.
@@ -480,10 +693,84 @@ func (t *Publisher) publishMessageBundle(ctx context.Context, bms []*bundledMess
480693 if t .PublishSettings .shouldCompress (batchSize ) {
481694 gaxOpts = append (gaxOpts , gax .WithGRPCOptions (grpc .UseCompressor (gzip .Name )))
482695 }
483- res , err = t .c .TopicAdminClient .Publish (ctx , & pb.PublishRequest {
484- Topic : t .name ,
485- Messages : pbMsgs ,
486- }, gaxOpts ... )
696+
697+ if t .hedgingDelay > 0 && orderingKey == "" {
698+ type attemptResult struct {
699+ res * pb.PublishResponse
700+ err error
701+ isMain bool
702+ id int
703+ }
704+ cs := newCancellationSharer ()
705+ defer cs .cancelAll ()
706+
707+ resCh := make (chan attemptResult , 1 )
708+
709+ var scheduleNextHedge func (int )
710+ scheduleNextHedge = func (attemptNum int ) {
711+ maxHedged := t .PublishSettings .HedgingSettings .MaxHedgedAttempts
712+ if maxHedged > 0 && attemptNum > maxHedged {
713+ return
714+ }
715+ t .enqueueHedgedRequest (& hedgedRequest {
716+ sendAfter : time .Now ().Add (t .hedgingDelay ),
717+ isDone : cs .isDone ,
718+ exec : func () {
719+ if cs .isDone () || ctx .Err () != nil {
720+ return
721+ }
722+ // Schedule the next hedged attempt dynamically
723+ scheduleNextHedge (attemptNum + 1 )
724+
725+ hedgedCtx , hedgedCancel := context .WithCancel (ctx )
726+ id := cs .add (hedgedCancel )
727+ if id == - 1 {
728+ return
729+ }
730+ r , e := t .c .TopicAdminClient .Publish (hedgedCtx , & pb.PublishRequest {
731+ Topic : t .name ,
732+ Messages : pbMsgs ,
733+ }, gaxOpts ... )
734+ select {
735+ case resCh <- attemptResult {res : r , err : e , isMain : false , id : id }:
736+ cs .win (id )
737+ default :
738+ }
739+ },
740+ })
741+ }
742+
743+ scheduleNextHedge (1 )
744+
745+ mainCtx , mainCancel := context .WithCancel (ctx )
746+ mainID := cs .add (mainCancel )
747+ r , e := t .c .TopicAdminClient .Publish (mainCtx , & pb.PublishRequest {
748+ Topic : t .name ,
749+ Messages : pbMsgs ,
750+ }, gaxOpts ... )
751+
752+ select {
753+ case winner := <- resCh :
754+ res = winner .res
755+ err = winner .err
756+ default :
757+ cs .win (mainID )
758+ res = r
759+ err = e
760+ if err == nil {
761+ t .replenishHedgingTokens ()
762+ }
763+ }
764+ } else {
765+ // regular publish without hedging
766+ res , err = t .c .TopicAdminClient .Publish (ctx , & pb.PublishRequest {
767+ Topic : t .name ,
768+ Messages : pbMsgs ,
769+ }, gaxOpts ... )
770+ if err == nil && t .hedgingDelay > 0 {
771+ t .replenishHedgingTokens ()
772+ }
773+ }
487774 }
488775 end := time .Now ()
489776 if err != nil {
0 commit comments