vitess.io/vitess@v0.16.2/go/vt/vttablet/tabletserver/stream_consolidator.go (about)

     1  /*
     2  Copyright 2021 The Vitess Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package tabletserver
    18  
    19  import (
    20  	"sync"
    21  	"sync/atomic"
    22  
    23  	"vitess.io/vitess/go/sqltypes"
    24  	vtrpcpb "vitess.io/vitess/go/vt/proto/vtrpc"
    25  	"vitess.io/vitess/go/vt/vterrors"
    26  	"vitess.io/vitess/go/vt/vttablet/tabletserver/tabletenv"
    27  )
    28  
    29  const streamBufferSize = 8
    30  
    31  // StreamConsolidator is a data structure capable of merging several identical streaming queries so only
    32  // one query is executed in MySQL and its response is fanned out to all the clients simultaneously.
    33  type StreamConsolidator struct {
    34  	mu                             sync.Mutex
    35  	inflight                       map[string]*streamInFlight
    36  	memory                         int64
    37  	maxMemoryTotal, maxMemoryQuery int64
    38  	blocking                       bool
    39  	cleanup                        StreamCallback
    40  }
    41  
    42  // NewStreamConsolidator allocates a stream consolidator. The consolidator will use up to maxMemoryTotal
    43  // bytes in order to allow simultaneous queries to "catch up" to each other. Each individual stream will
    44  // only use up to maxMemoryQuery bytes of memory as a history buffer to catch up.
    45  func NewStreamConsolidator(maxMemoryTotal, maxMemoryQuery int64, cleanup StreamCallback) *StreamConsolidator {
    46  	return &StreamConsolidator{
    47  		inflight:       make(map[string]*streamInFlight),
    48  		maxMemoryTotal: maxMemoryTotal,
    49  		maxMemoryQuery: maxMemoryQuery,
    50  		blocking:       false,
    51  		cleanup:        cleanup,
    52  	}
    53  }
    54  
    55  // StreamCallback is a function that is called with every Result object from a streaming query
    56  type StreamCallback func(result *sqltypes.Result) error
    57  
    58  // SetBlocking sets whether fanning out should block to wait for slower clients to
    59  // catch up, or should immediately disconnect clients that are taking too long to process the
    60  // consolidated stream. By default, blocking is only enabled when running with the race detector.
    61  func (sc *StreamConsolidator) SetBlocking(block bool) {
    62  	sc.blocking = block
    63  }
    64  
    65  // Consolidate wraps the execution of a streaming query so that any other queries being executed
    66  // simultaneously will wait for the results of the original query, instead of being executed from
    67  // scratch in MySQL.
    68  // Query consolidation is based by comparing the resulting `sql` string, which should not contain
    69  // comments in it. The original `callback` that will yield results to the client must be passed as
    70  // `callback`. A `leaderCallback` must also be supplied: this function must perform the actual
    71  // query in the upstream MySQL server, yielding results into the modified callback that it receives
    72  // as an argument.
    73  func (sc *StreamConsolidator) Consolidate(logStats *tabletenv.LogStats, sql string, callback StreamCallback, leaderCallback func(StreamCallback) error) error {
    74  	var (
    75  		inflight        *streamInFlight
    76  		catchup         []*sqltypes.Result
    77  		followChan      chan *sqltypes.Result
    78  		err             error
    79  		leaderClientErr error
    80  	)
    81  
    82  	sc.mu.Lock()
    83  	// check if we have an existing identical query in our consolidation table
    84  	inflight = sc.inflight[sql]
    85  
    86  	// if there's an existing stream for our query, try to follow it
    87  	if inflight != nil {
    88  		catchup, followChan = inflight.follow()
    89  	}
    90  
    91  	// if there isn't an existing stream; OR if there is an existing stream but
    92  	// we're too late to catch up to it, we declare ourselves the leader for this query
    93  	if inflight == nil || followChan == nil {
    94  		inflight = &streamInFlight{
    95  			catchupAllowed: true,
    96  		}
    97  		sc.inflight[sql] = inflight
    98  	}
    99  	sc.mu.Unlock()
   100  
   101  	// if we have a followChan, we're following up on a query that is already being served
   102  	if followChan != nil {
   103  		defer func() {
   104  			memchange := inflight.unfollow(followChan, sc.cleanup)
   105  			atomic.AddInt64(&sc.memory, memchange)
   106  		}()
   107  
   108  		logStats.QuerySources |= tabletenv.QuerySourceConsolidator
   109  
   110  		// first, catch up our client by sending all the Results to the streaming query
   111  		// that the leader has already sent
   112  		for _, result := range catchup {
   113  			if err := callback(result); err != nil {
   114  				return err
   115  			}
   116  		}
   117  
   118  		// now we can follow the leader: it will send in real time all new Results through
   119  		// our follower channel
   120  		for result := range followChan {
   121  			if err := callback(result); err != nil {
   122  				return err
   123  			}
   124  		}
   125  
   126  		// followChan has been closed by the leader, so there are no more results to send.
   127  		// check the final error return for the stream
   128  		return inflight.result(followChan)
   129  	}
   130  
   131  	// we don't have a followChan so we're the leaders for this query. we must run it in the
   132  	// upstream MySQL and fan out all the Results to any followers that show up
   133  
   134  	defer func() {
   135  		sc.mu.Lock()
   136  		// only remove ourselves from the in-flight streams map if we're still there;
   137  		// if our stream has been running for too long so that new followers wouldn't be able
   138  		// to catch up, a follower may have replaced us in the map.
   139  		if existing := sc.inflight[sql]; existing == inflight {
   140  			delete(sc.inflight, sql)
   141  		}
   142  		sc.mu.Unlock()
   143  
   144  		// finalize the stream with the error return we got from the leaderCallback
   145  		memchange := inflight.finishLeader(err, sc.cleanup)
   146  		atomic.AddInt64(&sc.memory, memchange)
   147  	}()
   148  
   149  	// leaderCallback will perform the actual streaming query in MySQL; we provide it a custom
   150  	// results callback so that we can intercept the results as they come in
   151  	err = leaderCallback(func(result *sqltypes.Result) error {
   152  		// update the live consolidated stream; this will fan out the Result to all our active followers
   153  		// and tell us how much more memory we're using by temporarily storing the result so other followers
   154  		// in the future can catch up to this stream
   155  		memChange := inflight.update(result, sc.blocking, sc.maxMemoryQuery, sc.maxMemoryTotal-atomic.LoadInt64(&sc.memory))
   156  		atomic.AddInt64(&sc.memory, memChange)
   157  
   158  		// yield the result to the very first client that started the query; this client is not listening
   159  		// on a follower channel.
   160  		if leaderClientErr == nil {
   161  			// if our leader client returns an error from the callback, we do NOT want to send it upstream,
   162  			// because that would cancel the stream from MySQL. Keep track of the error so we can return it
   163  			// once we've finished the stream for all our followers UNLESS we currently have 0 active followers;
   164  			// if that's the case, we can terminate early.
   165  			leaderClientErr = callback(result)
   166  			if leaderClientErr != nil && !inflight.shouldContinueStreaming() {
   167  				return leaderClientErr
   168  			}
   169  		}
   170  		return nil
   171  	})
   172  	if err != nil {
   173  		return err
   174  	}
   175  	return leaderClientErr
   176  }
   177  
   178  type streamInFlight struct {
   179  	mu             sync.Mutex
   180  	catchup        []*sqltypes.Result
   181  	fanout         map[chan *sqltypes.Result]bool
   182  	err            error
   183  	memory         int64
   184  	catchupAllowed bool
   185  	finished       bool
   186  }
   187  
   188  // follow adds a follower to this in-flight stream, returning a slice with all
   189  // the Results that have been sent so far (so the client can catch up) and a channel
   190  // that will receive all the Results in the future.
   191  // If this stream has been running for too long and we cannot catch up to it, follow
   192  // returns a nil channel.
   193  func (s *streamInFlight) follow() ([]*sqltypes.Result, chan *sqltypes.Result) {
   194  	s.mu.Lock()
   195  	defer s.mu.Unlock()
   196  
   197  	if !s.catchupAllowed {
   198  		return nil, nil
   199  	}
   200  	if s.fanout == nil {
   201  		s.fanout = make(map[chan *sqltypes.Result]bool)
   202  	}
   203  	follow := make(chan *sqltypes.Result, streamBufferSize)
   204  	s.fanout[follow] = true
   205  	return s.catchup, follow
   206  }
   207  
   208  // unfollow unsubscribes the given follower from receiving more results from the stream.
   209  func (s *streamInFlight) unfollow(ch chan *sqltypes.Result, cleanup StreamCallback) int64 {
   210  	s.mu.Lock()
   211  	defer s.mu.Unlock()
   212  
   213  	delete(s.fanout, ch)
   214  	return s.checkFollowers(cleanup)
   215  }
   216  
   217  // result returns the final error for this stream. If the stream finished successfully,
   218  // this is nil. If the stream had an upstream error (i.e. from MySQL), this error is
   219  // returned. Lastly, if this specific follower had an error that caused it to fall behind
   220  // from the consolidation stream, a specific error is returned.
   221  func (s *streamInFlight) result(ch chan *sqltypes.Result) error {
   222  	s.mu.Lock()
   223  	defer s.mu.Unlock()
   224  
   225  	alive := s.fanout[ch]
   226  	if !alive {
   227  		return vterrors.Errorf(vtrpcpb.Code_DEADLINE_EXCEEDED, "stream lagged behind during consolidation")
   228  	}
   229  	return s.err
   230  }
   231  
   232  // shouldContinueStreaming returns whether this stream has active followers;
   233  // if it doesn't, it marks the stream as terminated.
   234  func (s *streamInFlight) shouldContinueStreaming() bool {
   235  	s.mu.Lock()
   236  	defer s.mu.Unlock()
   237  
   238  	if len(s.fanout) > 0 {
   239  		return true
   240  	}
   241  	s.catchupAllowed = false
   242  	s.catchup = nil
   243  	return false
   244  }
   245  
   246  // update fans out the given result to all the active followers for the stream and
   247  // returns the amount of memory that is being used by the catchup buffer
   248  func (s *streamInFlight) update(result *sqltypes.Result, block bool, maxMemoryQuery, maxMemoryTotal int64) int64 {
   249  	var memoryChange int64
   250  	resultSize := result.CachedSize(true)
   251  
   252  	s.mu.Lock()
   253  	defer s.mu.Unlock()
   254  
   255  	// if this stream can still be catched up with, we need to store the result in
   256  	// a catch up buffer; otherwise, we can skip this altogether and just fan out the result
   257  	// to all the followers that are already caught up
   258  	if s.catchupAllowed {
   259  		if s.memory+resultSize > maxMemoryQuery || resultSize > maxMemoryTotal {
   260  			// if the catch up buffer has grown too large, disable catching up to this stream.
   261  			s.catchupAllowed = false
   262  		} else {
   263  			// otherwise store the result in our catchup buffer for future clients
   264  			s.catchup = append(s.catchup, result)
   265  			s.memory += resultSize
   266  			memoryChange = resultSize
   267  		}
   268  	}
   269  
   270  	if block {
   271  		for follower := range s.fanout {
   272  			follower <- result
   273  		}
   274  	} else {
   275  		// fan out the result to all the followers that are currently active
   276  		for follower, alive := range s.fanout {
   277  			if alive {
   278  				select {
   279  				case follower <- result:
   280  				default:
   281  					// if we cannot write to this follower's channel, it means its client is taking
   282  					// too long to relay the stream; we must drop it from our our consolidation. the
   283  					// client will receive an error.
   284  					s.fanout[follower] = false
   285  					close(follower)
   286  				}
   287  			}
   288  		}
   289  	}
   290  
   291  	return memoryChange
   292  }
   293  
   294  // finishLeader terminates this consolidated stream by storing the final error result from
   295  // MySQL and notifying all the followers that there are no more Results left to be sent
   296  func (s *streamInFlight) finishLeader(err error, cleanup StreamCallback) int64 {
   297  	s.mu.Lock()
   298  	defer s.mu.Unlock()
   299  
   300  	s.err = err
   301  	s.finished = true
   302  	for follower, alive := range s.fanout {
   303  		if alive {
   304  			close(follower)
   305  		}
   306  	}
   307  	return s.checkFollowers(cleanup)
   308  }
   309  
   310  func (s *streamInFlight) checkFollowers(cleanup StreamCallback) int64 {
   311  	if s.finished && len(s.fanout) == 0 {
   312  		for _, result := range s.catchup {
   313  			_ = cleanup(result)
   314  		}
   315  		s.catchup = nil
   316  		return -s.memory
   317  	}
   318  	return 0
   319  }