Skip to content

Commit 1c4716c

Browse files
committed
feat(pubsub/v2): add publish hedging
1 parent 7e21cfb commit 1c4716c

3 files changed

Lines changed: 612 additions & 8 deletions

File tree

pubsub/v2/publisher.go

Lines changed: 295 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -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

116225
func (ps *PublishSettings) shouldCompress(batchSize int) bool {
@@ -154,11 +263,17 @@ func (c *Client) Publisher(topicNameOrID string) *Publisher {
154263
}
155264

156265
func 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
192307
type PublishResult = ipubsub.PublishResult
193308

194309
var 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

Comments
 (0)