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  }