github.com/dolthub/dolt/go@v0.40.5-0.20240520175717-68db7794bea6/libraries/doltcore/sqle/cluster/commithook.go (about) 1 // Copyright 2022 Dolthub, Inc. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 package cluster 16 17 import ( 18 "context" 19 "errors" 20 "fmt" 21 "io" 22 "sync" 23 "sync/atomic" 24 "time" 25 26 "github.com/dolthub/go-mysql-server/sql" 27 "github.com/sirupsen/logrus" 28 29 "github.com/dolthub/dolt/go/libraries/doltcore/doltdb" 30 "github.com/dolthub/dolt/go/store/datas" 31 "github.com/dolthub/dolt/go/store/hash" 32 ) 33 34 var _ doltdb.CommitHook = (*commithook)(nil) 35 var _ doltdb.NotifyWaitFailedCommitHook = (*commithook)(nil) 36 37 type commithook struct { 38 rootLgr *logrus.Entry 39 lgr atomic.Value // *logrus.Entry 40 remotename string 41 remoteurl string 42 dbname string 43 mu sync.Mutex 44 wg sync.WaitGroup 45 cond *sync.Cond 46 shutdown atomic.Bool 47 nextHead hash.Hash 48 lastPushedHead hash.Hash 49 nextPushAttempt time.Time 50 nextHeadIncomingTime time.Time 51 lastSuccess time.Time 52 currentError *string 53 cancelReplicate func() 54 55 // waitNotify is set by controller when it needs to track whether the 56 // commithooks are caught up with replicating to the standby. 57 waitNotify func() 58 59 // |mu| must be held for all accesses. 60 progressNotifier ProgressNotifier 61 62 // If this is true, the waitF returned by Execute() will fast fail if 63 // we are not already caught up, instead of blocking on a successCh 64 // actually indicated we are caught up. This is set to by a call to 65 // NotifyWaitFailed(), an optional interface on CommitHook. 66 fastFailReplicationWait bool 67 68 role Role 69 70 // The standby replica to which the new root gets replicated. 71 destDB *doltdb.DoltDB 72 // When we first start replicating to the destination, we lazily 73 // instantiate the remote and we do not treat failures as terminal. 74 destDBF func(context.Context) (*doltdb.DoltDB, error) 75 // This database, which we are replicating from. In our current 76 // configuration, it is local to this server process. 77 srcDB *doltdb.DoltDB 78 79 tempDir string 80 } 81 82 var errDestDBRootHashMoved error = errors.New("cluster/commithook: standby replication: destination database root hash moved during our write, while it is assumed we are the only writer.") 83 84 const logFieldThread = "thread" 85 const logFieldRole = "role" 86 87 func newCommitHook(lgr *logrus.Logger, remotename, remoteurl, dbname string, role Role, destDBF func(context.Context) (*doltdb.DoltDB, error), srcDB *doltdb.DoltDB, tempDir string) *commithook { 88 var ret commithook 89 ret.rootLgr = lgr.WithField(logFieldThread, "Standby Replication - "+dbname+" to "+remotename) 90 ret.lgr.Store(ret.rootLgr.WithField(logFieldRole, string(role))) 91 ret.remotename = remotename 92 ret.remoteurl = remoteurl 93 ret.dbname = dbname 94 ret.role = role 95 ret.destDBF = destDBF 96 ret.srcDB = srcDB 97 ret.tempDir = tempDir 98 ret.cond = sync.NewCond(&ret.mu) 99 return &ret 100 } 101 102 func (h *commithook) Run(bt *sql.BackgroundThreads) error { 103 return bt.Add("Standby Replication - "+h.dbname+" to "+h.remotename, h.run) 104 } 105 106 func (h *commithook) run(ctx context.Context) { 107 // The hook comes up attempting to replicate the current head. 108 h.logger().Tracef("cluster/commithook: background thread: running.") 109 h.wg.Add(2) 110 go h.replicate(ctx) 111 go h.tick(ctx) 112 <-ctx.Done() 113 h.logger().Tracef("cluster/commithook: background thread: requested shutdown, signaling replication thread.") 114 h.mu.Lock() 115 if h.cancelReplicate != nil { 116 h.cancelReplicate() 117 h.cancelReplicate = nil 118 } 119 h.cond.Signal() 120 h.mu.Unlock() 121 h.wg.Wait() 122 h.logger().Tracef("cluster/commithook: background thread: completed.") 123 } 124 125 func (h *commithook) replicate(ctx context.Context) { 126 defer h.wg.Done() 127 defer h.logger().Tracef("cluster/commithook: background thread: replicate: shutdown.") 128 h.mu.Lock() 129 defer h.mu.Unlock() 130 shouldHeartbeat := false 131 for !h.shutdown.Load() { 132 lgr := h.logger() 133 // Shutdown for context canceled. 134 if ctx.Err() != nil { 135 lgr.Tracef("cluster/commithook replicate thread exiting; saw ctx.Err(): %v", ctx.Err()) 136 if h.shouldReplicate() { 137 // attempt a last true-up of our standby as we shutdown 138 // TODO: context.WithDeadline based on config / convention? 139 h.attemptReplicate(context.Background()) 140 } 141 return 142 } 143 if h.primaryNeedsInit() { 144 lgr.Tracef("cluster/commithook: fetching current head.") 145 // When the replicate thread comes up, it attempts to replicate the current head. 146 datasDB := doltdb.HackDatasDatabaseFromDoltDB(h.srcDB) 147 cs := datas.ChunkStoreFromDatabase(datasDB) 148 var err error 149 h.nextHead, err = cs.Root(ctx) 150 if err != nil { 151 // TODO: if err != nil, something is really wrong; should shutdown or backoff. 152 lgr.Warningf("standby replication thread failed to load database root: %v", err) 153 h.nextHead = hash.Hash{} 154 } 155 156 // We do not know when this head was written, but we 157 // are starting to try to replicate it now. 158 h.nextHeadIncomingTime = time.Now() 159 } else if h.shouldReplicate() { 160 h.attemptReplicate(ctx) 161 shouldHeartbeat = false 162 } else { 163 lgr.Tracef("cluster/commithook: background thread: waiting for signal.") 164 if h.waitNotify != nil { 165 h.waitNotify() 166 } 167 caughtUp := h.isCaughtUp() 168 if caughtUp { 169 h.fastFailReplicationWait = false 170 171 // If we ABA on h.nextHead, so that it gets set 172 // to one value, then another, then back to the 173 // first, then the setter for B can make an 174 // outstanding wait while we are replicating 175 // the first set to A. We can be back to 176 // nextHead == A by the time we complete 177 // replicating the first A and we will have the 178 // outstanding waiter for the work for B but we 179 // will be fully quiesced. We make sure to 180 // notify B of success here. 181 if h.progressNotifier.HasWaiters() { 182 a := h.progressNotifier.BeginAttempt() 183 h.progressNotifier.RecordSuccess(a) 184 } 185 } 186 if shouldHeartbeat { 187 h.attemptHeartbeat(ctx) 188 189 // attemptHeartbeat releases |h.mu| for part of 190 // its work. We could miss a shutdown signal 191 // here, but the shutdown signal is always 192 // delivered after the shared Context is 193 // canceled. We check the context again here so 194 // that we don't fail to shutdown if we miss a 195 // shutdown signal. 196 if ctx.Err() != nil { 197 continue 198 } 199 } else if caughtUp { 200 shouldHeartbeat = true 201 } 202 h.cond.Wait() 203 lgr.Tracef("cluster/commithook: background thread: woken up.") 204 } 205 } 206 } 207 208 // called with h.mu locked. 209 func (h *commithook) shouldReplicate() bool { 210 if h.isCaughtUp() { 211 return false 212 } 213 return (h.nextPushAttempt == (time.Time{}) || time.Now().After(h.nextPushAttempt)) 214 } 215 216 // called with h.mu locked. Returns true if the standby is true-d up, false 217 // otherwise. Different from shouldReplicate() in that it does not care about 218 // nextPushAttempt, for example. Used in Controller.waitForReplicate. 219 func (h *commithook) isCaughtUp() bool { 220 if h.role != RolePrimary { 221 return true 222 } 223 if h.nextHead == (hash.Hash{}) { 224 return false 225 } 226 return h.nextHead == h.lastPushedHead 227 } 228 229 // called with h.mu locked. 230 func (h *commithook) primaryNeedsInit() bool { 231 return h.role == RolePrimary && h.nextHead == (hash.Hash{}) 232 } 233 234 // Called by the replicate thread to periodically heartbeat liveness to a 235 // standby if we are a primary. These heartbeats are best effort and currently 236 // do not affect the data plane much. 237 // 238 // preconditions: h.mu is locked and shouldReplicate() returned false. 239 func (h *commithook) attemptHeartbeat(ctx context.Context) { 240 if h.role != RolePrimary { 241 return 242 } 243 head := h.lastPushedHead 244 if head.IsEmpty() { 245 return 246 } 247 destDB := h.destDB 248 if destDB == nil { 249 return 250 } 251 ctx, h.cancelReplicate = context.WithTimeout(ctx, 5*time.Second) 252 defer func() { 253 if h.cancelReplicate != nil { 254 h.cancelReplicate() 255 } 256 h.cancelReplicate = nil 257 }() 258 h.mu.Unlock() 259 datasDB := doltdb.HackDatasDatabaseFromDoltDB(destDB) 260 cs := datas.ChunkStoreFromDatabase(datasDB) 261 cs.Commit(ctx, head, head) 262 h.mu.Lock() 263 } 264 265 // Called by the replicate thread to push the nextHead to the destDB and set 266 // its root to the new value. 267 // 268 // preconditions: h.mu is locked and shouldReplicate() returned true. 269 // when this function returns, h.mu is locked. 270 func (h *commithook) attemptReplicate(ctx context.Context) { 271 lgr := h.logger() 272 toPush := h.nextHead 273 incomingTime := h.nextHeadIncomingTime 274 destDB := h.destDB 275 ctx, h.cancelReplicate = context.WithCancel(ctx) 276 defer func() { 277 if h.cancelReplicate != nil { 278 h.cancelReplicate() 279 } 280 h.cancelReplicate = nil 281 }() 282 attempt := h.progressNotifier.BeginAttempt() 283 defer h.progressNotifier.RecordFailure(attempt) 284 h.mu.Unlock() 285 286 if destDB == nil { 287 lgr.Tracef("cluster/commithook: attempting to fetch destDB.") 288 var err error 289 destDB, err = h.destDBF(ctx) 290 if err != nil { 291 h.currentError = new(string) 292 *h.currentError = fmt.Sprintf("could not replicate to standby: error fetching destDB: %v", err) 293 lgr.Warnf("cluster/commithook: could not replicate to standby: error fetching destDB: %v.", err) 294 h.mu.Lock() 295 // TODO: We could add some backoff here. 296 if toPush == h.nextHead { 297 h.nextPushAttempt = time.Now().Add(1 * time.Second) 298 } 299 h.cancelReplicate = nil 300 return 301 } 302 lgr.Tracef("cluster/commithook: fetched destDB") 303 h.mu.Lock() 304 h.destDB = destDB 305 h.mu.Unlock() 306 } 307 308 lgr.Tracef("cluster/commithook: pushing chunks for root hash %v to destDB", toPush.String()) 309 err := destDB.PullChunks(ctx, h.tempDir, h.srcDB, []hash.Hash{toPush}, nil, nil) 310 if err == nil { 311 lgr.Tracef("cluster/commithook: successfully pushed chunks, setting root") 312 datasDB := doltdb.HackDatasDatabaseFromDoltDB(destDB) 313 cs := datas.ChunkStoreFromDatabase(datasDB) 314 var curRootHash hash.Hash 315 if err = cs.Rebase(ctx); err == nil { 316 if curRootHash, err = cs.Root(ctx); err == nil { 317 var ok bool 318 ok, err = cs.Commit(ctx, toPush, curRootHash) 319 if err == nil && !ok { 320 err = errDestDBRootHashMoved 321 } 322 } 323 } 324 } 325 326 h.mu.Lock() 327 if h.role == RolePrimary { 328 if err == nil { 329 h.currentError = nil 330 lgr.Tracef("cluster/commithook: successfully Committed chunks on destDB") 331 h.lastPushedHead = toPush 332 h.lastSuccess = incomingTime 333 h.nextPushAttempt = time.Time{} 334 h.progressNotifier.RecordSuccess(attempt) 335 } else { 336 h.currentError = new(string) 337 *h.currentError = fmt.Sprintf("failed to commit chunks on destDB: %v", err) 338 lgr.Warnf("cluster/commithook: failed to commit chunks on destDB: %v", err) 339 // add some delay if a new head didn't come in while we were pushing. 340 if toPush == h.nextHead { 341 // TODO: We could add some backoff here. 342 h.nextPushAttempt = time.Now().Add(1 * time.Second) 343 } 344 } 345 } 346 } 347 348 func (h *commithook) status() (replicationLag *time.Duration, lastUpdate *time.Time, currentErr *string) { 349 h.mu.Lock() 350 defer h.mu.Unlock() 351 if h.role == RolePrimary { 352 if h.lastPushedHead != (hash.Hash{}) { 353 replicationLag = new(time.Duration) 354 if h.nextHead != h.lastPushedHead { 355 // We return the wallclock time between now and the last time we were 356 // successful. If h.nextHeadIncomingTime is significantly earlier than 357 // time.Now(), because the server has not received a write in a long 358 // time, then this metric may report a high number when the number of 359 // seconds of writes outstanding could actually be much smaller. 360 // Operationally, failure to replicate a write for a long time is a 361 // problem that merits investigation, regardless of how many pending 362 // writes are failing to replicate. 363 *replicationLag = time.Now().Sub(h.lastSuccess) 364 } 365 } 366 367 } 368 369 if h.lastSuccess != (time.Time{}) { 370 lastUpdate = new(time.Time) 371 *lastUpdate = h.lastSuccess 372 } 373 374 currentErr = h.currentError 375 376 return 377 } 378 379 func (h *commithook) logger() *logrus.Entry { 380 return h.lgr.Load().(*logrus.Entry) 381 } 382 383 // TODO: Would be more efficient to only tick when we have outstanding work... 384 func (h *commithook) tick(ctx context.Context) { 385 defer h.wg.Done() 386 ticker := time.NewTicker(1 * time.Second) 387 defer ticker.Stop() 388 for !h.shutdown.Load() { 389 select { 390 case <-ctx.Done(): 391 return 392 case <-ticker.C: 393 h.cond.Signal() 394 } 395 } 396 } 397 398 func (h *commithook) databaseWasDropped() { 399 h.shutdown.Store(true) 400 h.mu.Lock() 401 defer h.mu.Unlock() 402 if h.cancelReplicate != nil { 403 h.cancelReplicate() 404 h.cancelReplicate = nil 405 } 406 h.cond.Signal() 407 } 408 409 func (h *commithook) recordSuccessfulRemoteSrvCommit() { 410 h.mu.Lock() 411 defer h.mu.Unlock() 412 if h.role != RoleStandby { 413 return 414 } 415 h.lastSuccess = time.Now() 416 h.currentError = nil 417 } 418 419 func (h *commithook) setRole(role Role) { 420 h.mu.Lock() 421 defer h.mu.Unlock() 422 // Reset head-to-push and timers here. When we transition into Primary, 423 // the replicate() loop will take these from the current chunk store. 424 h.currentError = nil 425 h.nextHead = hash.Hash{} 426 h.lastPushedHead = hash.Hash{} 427 h.lastSuccess = time.Time{} 428 h.nextPushAttempt = time.Time{} 429 h.role = role 430 h.lgr.Store(h.rootLgr.WithField(logFieldRole, string(role))) 431 if h.cancelReplicate != nil { 432 h.cancelReplicate() 433 h.cancelReplicate = nil 434 } 435 if role == RoleDetectedBrokenConfig { 436 h.currentError = &errDetectedBrokenConfigStr 437 } 438 h.cond.Signal() 439 } 440 441 func (h *commithook) setWaitNotify(f func()) bool { 442 h.mu.Lock() 443 defer h.mu.Unlock() 444 if f != nil { 445 if h.waitNotify != nil { 446 return false 447 } 448 f() 449 } 450 h.waitNotify = f 451 return true 452 } 453 454 var errDetectedBrokenConfigStr = "error: more than one server was configured as primary in the same epoch. this server has stopped accepting writes. choose a primary in the cluster and call dolt_assume_cluster_role() on servers in the cluster to start replication at a higher epoch" 455 456 // Execute on this commithook updates the target root hash we're attempting to 457 // replicate and wakes the replication thread. 458 func (h *commithook) Execute(ctx context.Context, ds datas.Dataset, db datas.Database) (func(context.Context) error, error) { 459 lgr := h.logger() 460 lgr.Tracef("cluster/commithook: Execute called post commit") 461 cs := datas.ChunkStoreFromDatabase(db) 462 root, err := cs.Root(ctx) 463 if err != nil { 464 lgr.Errorf("cluster/commithook: Execute: error retrieving local database root: %v", err) 465 return nil, err 466 } 467 h.mu.Lock() 468 defer h.mu.Unlock() 469 lgr = h.logger() 470 if h.role != RolePrimary { 471 lgr.Warnf("cluster/commithook received commit callback for a commit on %s, but we are not role primary; not replicating the commit, which is likely to be lost.", ds.ID()) 472 return nil, nil 473 } 474 if root != h.nextHead { 475 lgr.Tracef("signaling replication thread to push new head: %v", root.String()) 476 h.nextHeadIncomingTime = time.Now() 477 h.nextHead = root 478 h.nextPushAttempt = time.Time{} 479 h.cond.Signal() 480 } 481 var waitF func(context.Context) error 482 if !h.isCaughtUp() { 483 if h.fastFailReplicationWait { 484 waitF = func(ctx context.Context) error { 485 return fmt.Errorf("circuit breaker for replication to %s/%s is open. this commit did not necessarily replicate successfully.", h.remotename, h.dbname) 486 } 487 } else { 488 waitF = h.progressNotifier.Wait() 489 } 490 } 491 return waitF, nil 492 } 493 494 func (h *commithook) NotifyWaitFailed() { 495 h.mu.Lock() 496 defer h.mu.Unlock() 497 h.fastFailReplicationWait = true 498 } 499 500 func (h *commithook) HandleError(ctx context.Context, err error) error { 501 return nil 502 } 503 504 func (h *commithook) SetLogger(ctx context.Context, wr io.Writer) error { 505 return nil 506 } 507 508 func (h *commithook) ExecuteForWorkingSets() bool { 509 return true 510 }