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 }