🛠️

SpannerのTransactionでアプリケーションからAbortedを返してはいけない

2022/09/27に公開約5,200字

はじめに

先日とあるバッチでSpannerのトランザクションを無限にリトライし続けるという不可解な挙動に遭遇しました。直接の原因としてはタイトルの通りだったのですが動作検証と併せて周知できればと思い筆を取りました。

検証

以下のコードをベースに動作を確認してみます。

package main

import (
	"context"
	"fmt"
	"os"

	"cloud.google.com/go/spanner"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
)

func main() {
	if err := run(); err != nil {
		fmt.Fprintln(os.Stderr, err.Error())
		os.Exit(1)
	}
	os.Exit(0)
}

func run() error {
	ctx := context.Background()
	dsn := fmt.Sprintf("projects/%s/instances/%s/databases/%s", os.Getenv("SPANNER_PROJECT_ID"), os.Getenv("SPANNER_INSTANCE_ID"), os.Getenv("SPANNER_DATABASE_ID"))
	client, err := spanner.NewClient(ctx, dsn)
	if err != nil {
		return fmt.Errorf("failed to init spanner client: %w, dsn: %s", err, dsn)
	}

	_, err = client.ReadWriteTransaction(ctx, func(ctx context.Context, rwt *spanner.ReadWriteTransaction) error {
		fmt.Println("start transaction")
		return status.New(codes.NotFound, "sample error").Err()
	})
	if err != nil {
		return fmt.Errorf("failed Spanner ReadWriteTransaction: %w", err)
	}

	return nil
}

このまま実行するとNotFoundが発生し想定通り処理が終了します。

$ go run ./...
start transaction
failed Spanner ReadWriteTransaction: rpc error: code = NotFound desc = sample error
exit status 1
$ 

直接Abortedを返す

前述の通りトランザクションは失敗するもライブラリ側で無限リトライが発生するため終了しなくなります。

-		return status.New(codes.NotFound, "sample error").Err()
+		return status.New(codes.Aborted, "sample error").Err()
$ go run ./...
start transaction
start transaction
start transaction
...

Unwrap可能なAbortedが含まれるerrorを返却した場合

Unwrap()メソッドを実装したエラー

ライブラリのコードを参照するとわかりますが、少なくともこの記事を執筆している2022/09/26現在はspanner.Error型もしくは後述するinterfaceについてしか判別していないため、単純なUnwrap()メソッドを実装したerrorを返却してもリトライはかかりません。

https://github.com/googleapis/google-cloud-go/blob/0ffce1d69d3540c52ff499983e106f78ef27c69a/spanner/retry.go#L88
-		return status.New(codes.NotFound, "sample error").Err()
+		return fmt.Errorf("failed to XXX :%w", status.New(codes.Aborted, "sample error").Err())
$ go run ./...
start transaction
failed Spanner ReadWriteTransaction: failed to XXX :rpc error: code = Aborted desc = sample error
exit status 1
$ 

GRPCStatus() *status.Status interfaceを実装した独自error型

一方で下記interfaceを実装しているerrorで返却した場合は内部でgRPCのステータスを識別するためリトライがかかります。

GRPCStatus() *status.Status
type MyError struct {
	err  error
	code codes.Code
	msg  string
}

func (e *MyError) Error() string {
	return e.msg
}

func (e *MyError) Unwrap() error {
	return e.err
}

func (e *MyError) GRPCStatus() *status.Status {
	return status.New(e.code, e.msg)
}
-		return status.New(codes.NotFound, "sample error").Err()
+		return &MyError{err: fmt.Errorf("some error"), code: codes.Aborted, msg: "my error"}
$ go run ./...
start transaction
start transaction
start transaction
...

なぜ無限リトライなのか

Spannerは楽観的並行制御によるトランザクション処理を行います。そのため並行するトランザクションが発生した場合でもブロックせず後からコミットを試みた方が失敗しAbortedが返却されます。

大量のトランザクションが同じ行を変更する場合等で短時間に何度もAbortedが発生する可能性があり、この場合具体的な時間や回数による制限では不都合が生じるケースがあるためライブラリとしては特に制限を設けず無限にリトライをし続けるという仕様になっているようです。

https://pkg.go.dev/cloud.google.com/go/spanner#hdr-Aborted_transactions

対策

そもそもトランザクション中にAbortedが発生し得ない実装にする

今回私が遭遇したケースとしてはあるデータをSpanner書き込み後外部マイクロサービスに対しリクエストを行い失敗した場合はロールバックするという振る舞いを実現したく、トランザクション中でgRPCクライアントによるリクエストを送っていました。

そしてこのリクエスト先マイクロサービスがAbortedを返却した結果発生した事象です。ドキュメントにはAbortedを返すケースの記載があったのでこれはリクエスト側の実装不備といってよいでしょう。

可能であればAbortedが発生し得る処理をトランザクション外に移動するのが望ましいです。

Abortをハンドリングしてリトライされないようにする

他の方法としてはSpannerライブラリがAbortedを識別できない形にしてreturnしてしまう方法があります。下記例では %s で生成したerrorを返しているため、もし将来的にライブラリがUnwrap()を試みるような形に実装変更された場合でも無限リトライは発生しないはずです。

	_, err = client.ReadWriteTransaction(ctx, func(ctx context.Context, rwt *spanner.ReadWriteTransaction) error {
		fmt.Println("start transaction")
		if err := returnAborted(); err != nil {
			code := status.Code(err)
			if code == codes.Aborted {
				return fmt.Errorf("got aborted: %s", err.Error())
			}
		}
		return nil
	})
}

func returnAborted() error {
	return status.New(codes.Aborted, "sample error").Err()
}
$ go run ./...
start transaction
failed Spanner ReadWriteTransaction: got aborted: rpc error: code = Aborted desc = sample error
exit status 1
$ 

Contextでcancelをかける

ReadWriteTransactionは内部でContextのDone()をハンドリングしているためWithTimeout等でcancelするとその時点で中断されます。Contextの根本かもしくはAbortedが懸念されるトランザクションの直前でタイムアウトを設定してやるのがよいでしょう。

今回は試していませんが、時間ではなくカウンターを設けて回数によりcancelをかけることも可能かと思います。

	ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
	defer cancel()
	_, err = client.ReadWriteTransaction(ctx, func(ctx context.Context, rwt *spanner.ReadWriteTransaction) error {
		fmt.Println("start transaction")
		return status.New(codes.Aborted, "sample error").Err()
	})
$ go run ./...
start transaction
start transaction
start transaction
(中略)
start transaction
start transaction
start transaction
failed Spanner ReadWriteTransaction: context deadline exceeded
exit status 1
$ 

まとめ

  • GoのSpannerライブラリはSpannerからのレスポンスがAbortedだと無限にリトライする
  • トランザクション関数内部からAbortedを返却した場合でも同様の振る舞いになる
  • アプリケーション側でAbortedの発生が予想される場合はエラーハンドリングしてリトライされない形に握り潰すか、もしくはContextでcancelする

Discussion

ログインするとコメントできます