【続】tcpの仕様上、接続先がコネクションをcloseしているかはパケットを一度は実際に送るまでわからないよという話

# はじめに
以前、[tcpの仕様上、接続先がコネクションをcloseしているかはパケットを一度は実際に送るまでわからないよという話](https://qiita.com/behiron/items/3719430e12cb770980f3)という記事をかいたのですが、そのきっかけは

> 以前、アプリからDBにSQLを投げたところ、コネクションがinvalidだというエラーが起きました。この原因自体はとても簡単でサーバ側(DB側)のコネクションを保持するタイムアウト設定がクライアントよりも短かったというだけなのですが、「これってクライアントライブラリ側でソケットにwriteした時点でエラーになるんだからハンドリングしてコネクションプールに保持している他のコネクションをよしなに使ってよ！！」と思ったのでした。

でした。

これは[goのmysqlドライバー](https://github.com/go-sql-driver/mysql)を利用した場合に発生していたのですが、まさにこの問題をGitHubの中の人が去年に修正しており、それをテーマに[ブログ](https://github.blog/2020-05-20-three-bugs-in-the-go-mysql-driver/)を書かれていました。

それが非常に勉強になったので、わかりずらい部分を補足しつつ紹介したいと思います。

# ブログを読む
## 背景
[Three bugs in the Go MySQL Driver](https://github.blog/2020-05-20-three-bugs-in-the-go-mysql-driver/)です。

背景とかの話も非常に興味深かったので若干主旨とずれる部分も紹介します。

GitHubのサービスはRailsのモノリスだったのを、ここ数年で少しずつ、速度や信頼性が必要な部分を中心に切り出していってGolangで書き換えていっているようです。

その中の一つのサービスが2019年に稼働した`authzd`というサービスで、GitHub社のGoで書かれたWebアプリでMySQLに接続する初めてのサービスだったようです。

そのブログはその際に経験したバグに対してGitHub社が修正した３つのPRをもとに修正が紹介されています。今回は最初に紹介されている`The crash`の部分を紹介します。

ちなみに`resulting in our first “9” of availability for the service`と書いてあったので`The crash`の修正によってサービス可用性90％突破したようです。

業務でサービス可用性目標をあげるところもあると思いますが、OSSがボトルネックになっていたのでOSSを修正するというのは素晴らしいですね！！

ちなみにブログに添付されているスクリーンショットはDatadogのmonitorのようなので、GitHub社もDatadogを利用しているようですね（どうでもいい）

## The crash
どんな話なのかを先にざっくり書いてしまうと、MySQLのサーバ側の`idle timeout`がクライアントのそれよりも短い場合、クライアントからクエリを送ろうとしたとき実はそのコネクションはサーバ側がcloseしていたということがおこりえます。その場合にクライアントとしては強制的にエラーになることを経験します。

この問題への対応としては簡単で`(*DB).SetConnMaxLifetime`をサーバの`idle timeout`より小さくすればいいだけです。
ただ、`SetConnMaxLifetime`であって`SetIdleConnMaxLifetime`ではないので、idleではなくactiveなコネクションも不必要にcloseされてしまい、イケていないです。これは全てのDBサーバのコネクションに`idle`という概念があるわけではないため、`database/sql`側が用意していないという背景があるようです。

自分はまさに上記の対応を行って(参考までにDBの`idle timeout`はAWSのAuroraの場合、デフォルトで`8h`に設定されているようです。GitHub社では`30s`に設定しているようです。短い！！）おり、そのときにmysqlドライバー側でよしなにできないの？と思い、調査したことを以前記事にしたわけですが、修正してくれたようです。

さて、詳細に入っていきます。

記事の序盤は[tcpの仕様上、接続先がコネクションをcloseしているかはパケットを一度は実際に送るまでわからないよという話](https://qiita.com/behiron/items/3719430e12cb770980f3)とほぼ同じことがTCPの遷移図とともに書かれています。

TCPの仕様上、サーバがFINパケットを送ってもそれはあくまでサーバ側がwriteしないことのみを意味し、クライアントからはサーバへwriteし、サーバがreadし処理をするのはありえます。そして、サーバがwriteもreadも全くなにもしない（例えばソケットをcloseするなど）ことを安全にクライアントへ伝える方法はtcpのプロトコルに存在しません。

わかりやすいので以下引用しますが、TCPの上記のような特性はほとんどのプロトコルの場合は問題にならないようですが、MySQLのプロトコルは「クライアントが送りサーバがそれに返答する」という流れが決まっており、クライアントは`write`するまで`read`することがないようです。

> In most network protocols on top of TCP, this isn’t an issue. The client is performing reads from the server, and as soon as it receives a [SYN, ACK], the next read returns an EOF error, because the Kernel knows that the server won’t write more data to this connection. However, as discussed earlier, once a MySQL connection is in its Command Phase, the MySQL protocol is client-managed. The client only reads from the server after it sends a request, because the server only sends data in response to requests from the client.

そういえば、この特性はHTTP/1.x(pipeliningは除く)も同様かと思いますが、以前、[Goのhttp.Requestのキャンセルの仕組みを理解する](https://qiita.com/behiron/items/9b6975de6ff470c71e06)という記事で書いたように[Goのhttpサーバの実装ではリクエストボディを読み切ったタイミングでソケットをReadするgoルーチンが作成され、サーバ処理中にクライアント側のcloseに気づける](https://github.com/golang/go/blob/f92337422ef2ca27464c198bb3426d2dc4661653/src/net/http/server.go#L675-L727)ようになっています。こちらはサーバ側の話ですが。


ここまで話を聞いて、エラーだったらretryしてくれよと思う方もいるかもしれません。
実は、retryの仕組みは`database/sql`に用意されており、`ErrBadConn`を返却するようにすれば、[maxBadConnRetries(2回)リトライし、**それでもエラーになればコネクションプールを利用せずに新規のコネクションを作成する**実装](https://github.com/golang/go/blob/3036b76df0ae748856e3e0008b67241cc580e263/src/database/sql/sql.go#L1601-L1614)になっています。

以下は`QueryContext`の例ですが、`database/sql`のあらゆる処理に同じようなリトライの処理があり、また、driver側([goのmysqlドライバー](https://github.com/go-sql-driver/mysql)も)でも`database/sql/driver`を`import`して、`driver.ErrBadConn`を返却しているケースがあるようです。


```database/sql/driver/driver.go
// ErrBadConn should be returned by a driver to signal to the sql
// package that a driver.Conn is in a bad state (such as the server
// having earlier closed the connection) and the sql package should
// retry on a new connection.
//
// To prevent duplicate operations, ErrBadConn should NOT be returned
// if there's a possibility that the database server might have
// performed the operation. Even if the server sends back an error,
// you shouldn't return ErrBadConn.
var ErrBadConn = errors.New("driver: bad connection")
```

```database/sql/sql.go
// QueryContext executes a query that returns rows, typically a SELECT.
// The args are for any placeholder parameters in the query.
func (db *DB) QueryContext(ctx context.Context, query string, args ...interface{}) (*Rows, error) {
	var rows *Rows
	var err error
	for i := 0; i < maxBadConnRetries; i++ {
		rows, err = db.query(ctx, query, args, cachedOrNewConn)
		if err != driver.ErrBadConn {
			break
		}
	}
	if err == driver.ErrBadConn {
		return db.query(ctx, query, args, alwaysNewConn)
	}
	return rows, err
}
```

今回のも同じように`ErrBadConn`を返却するようにしていればそもそも問題にならない（リトライに失敗しても最後にはコネクションプールを必ず使わないから）のですが、エラーが発覚する箇所が`write`である(Goのhttpserver実装のようになんらかの仕組みを用意しない限りwriteで初めてサーバのcloseに気付く)ので、常に安全にリトライできないという事情があるようです。

ブログに紹介されている以下のケースは、まさに`ErrBadConn`のコメントにある`To prevent duplicate operations, ErrBadConn should NOT be returned if there's a possibility that the database server might have performed the operation`のケースなので、`ErrBadConn`は返却してはいけないということになります。

>What would happen if we performed an UPDATE in a perfectly healthy connection, MySQL executed it, and then our network went down before it could reply to us? The Go MySQL driver would also receive an EOF after a valid write. But if it were to return driver.ErrBadConn, database/sql would

では、`write`するまえにnon-blockingで`read`してEOFであれば`ErrBadConn`をなげればいいのでは？

と思うかもしれませんが、まさにそれが[PR](https://github.com/go-sql-driver/mysql/pull/934)で対応されていることです！

いやー、事情が複雑ですね。。


# PRを読む
[packets: Check connection liveness before writing query](https://github.com/go-sql-driver/mysql/pull/934)を実際によんでいきましょう。
修正方針を前章で把握するのだけでもお腹いっぱいですが、PRも100行ほどの小さいPRにもかかわらず、なかなか勉強になりました。

勉強になった点を３つ紹介します。

## チェックを行うときには生のファイルディスクリプタを参照する
やることは前章で整理したように`write`する直前にソケットをnon-blockingで`read`してすでにサーバがclose済みであれば`ErrBadConn`を返すだけです。

が、[Goのネットワーク処理はAPIとしては同期的なAPIを提供しているが、実は内部ではnon-blockingな処理がされています。](https://qiita.com/takc923/items/de68671ea889d8df6904#%E3%83%8D%E3%83%83%E3%83%88%E3%83%AF%E3%83%BC%E3%82%AF%E5%87%A6%E7%90%86%E3%81%97%E3%81%9F%E6%99%82)

簡単に説明すると、[netpoller](https://morsmachine.dk/netpoller)と呼ばれる仕組みでネットワークの待ちになった際に、goroutineが元処理から切り離されepollなどのシステムコールで非同期にソケットに対するイベントを把握し、処理可能になったら再度goroutineを割り当てる仕組みがgoのランタイムには備わっています（といっても自分は該当部分のソースを読んだことがないです）

これは本当に素晴らしい仕組みだと思うのですが、今回のようにブロックされることがないことが確定している場合には生のファイルディスクリタを利用したシステムコールを使った方が好ましいです。というわけで以下のような実装がされています。

明示的にnon-blockingにしていないのは、生のファイルディスクリタはGoのランタイム側ですでに`O_NONBLOCK`指定されているためだと思います。


```conncheck.go
	sconn, ok := c.(syscall.Conn)
	if !ok {
		return nil
	}
	rc, err := sconn.SyscallConn()
	if err != nil {
		return err
	}
	rerr := rc.Read(func(fd uintptr) bool {
		n, err = syscall.Read(int(fd), buff[:])
		return true
	})
	switch {
	case rerr != nil:
		return rerr
	case n == 0 && err == nil:
		return io.EOF
	case n > 0:
		return errUnexpectedRead
	case err == syscall.EAGAIN || err == syscall.EWOULDBLOCK:
		return nil
	default:
		return err
	}
```


## チェックを行う回数をできるだけ少なくする
`ResetSession`が`sql/driver`側でinterfaceで定義されており、この処理は処理済みのコネクションをコネクションプールに戻す時に`sql/driver`が呼びます。これにより実装するdriver側が処理を行う機会を得ます。

今回のPRではこのinterface実装でコネクションに設けたフラグをonにし、`write`時にこのフラグがあるときのみチェックを行い、チェック後フラグをoffにするという工夫がされています。

これにより、コネクションプールから取得したコネクションが最初に通信する時のみにチェックがされることになります。すごい！！

```database/sql/driver/driver.go
// SessionResetter may be implemented by Conn to allow drivers to reset the
// session state associated with the connection and to signal a bad connection.
type SessionResetter interface {
	// ResetSession is called while a connection is in the connection
	// pool. No queries will run on this connection until this method returns.
	//
	// If the connection is bad this should return driver.ErrBadConn to prevent
	// the connection from being returned to the connection pool. Any other
	// error will be discarded.
	ResetSession(ctx context.Context) error
}
```


## windowsでは何もしない
PRではwindowsで動作確認がとれておらず、CIも存在しない。どうやって動作確認しようか
と議論がつまりかけたのですが、`// +build !windows`が指定されている`conncheck.go`と`conncheck_windows.go`の両方のファイルで`connCheck`関数を実装し、`conncheck_windows.go`側では`nil`を返すというだけという技を使って議論を進めていました。これによってwindows側には何の変更もなしに修正したことになります。

すごい！！


# おわりに
PRを確認すると最初にあげる時点でかなり詳しく説明し、パフォーマンス遅延などへの影響等も検証されていてすごいと思いました。OSSでPRあげる時はどうしても低姿勢になりがちな気がしますが、自分の修正内容に自信をもっていて、フローが遅いぞと圧をかけたり、こんな大変なissueも残っているんだと言われた場合も[okそっちは来週PR作るね](https://github.com/go-sql-driver/mysql/pull/941#issuecomment-478059994)といって実際にmergeされているのでやばい

内容が素晴らしく、自分も努力せねばと思ったので紹介させていただきました。