maybe daily dev notes

私の開発日誌

TIL: ソフトウェア文脈における modal behavior とは

最近話題になっている AWS Fault Isolation Boundaries を読んでいると、こんな言葉が目に入った:

Allowing individual workloads to make their own failover decisions makes the coordination less complex, but introduces modal behavior through the significant difference in latency that occurs across Regions compared to inside a single Region.

抄訳: 個々のワークロードにフェイルオーバーを任せれば〜〜複雑性は下がる一方で、リージョン間とリージョン内のレイテンシーの違いにより modal behavior を生じさせます。

ということで modal behavior が生じるデメリットがあるらしいが、あまりピンとこない。いろいろ調べたり社内で聞いてみたりしたが、以下のような意味の言葉らしい。

まずはmodal - モーダルとは

アプリケーションの中にはその動作に「モード」が存在することもある。「モード」は日本語でも想像しやすい意味の通りで、これによりアプリケーションの動作が変わるもの。

例えば vim には入力モードやコマンドモードがあり、モードによりキー入力などに対する挙動が変化する。あるいはUI文脈でモーダルウィンドウというものがあるが、これも語源はモードである。モーダルウィンドウが表示されることである種アプリケーションの動作・モードが変わるのでそのように呼ばれている。

モーダルウィンドウの例。このときAWSマネコンは削除確認モードに入っていると言える。

モーダルというのはモードを形容詞にしたものであるので、modal behavior は 「モードがあることによる振る舞い」 というような意味になるだろう。モード自体は良いことでも悪いことでもないように思うが、元の文章では明らかに悪いこととして捉えられている (複雑性が下がることとの対比。) これはなぜだろうか。

まず、この文章における modal behavior とは具体的にどのようなモードによるものなのか考えたい。ヒントは リージョン間とリージョン内のレイテンシーの違い個々のワークロードにフェイルオーバーを任せる ことにより引き起こされるものということである。なおここでいうフェイルオーバーとは、リージョンAがダウンしたのでリージョンBに切り替えるというような動作が想定されている。

あるマルチリージョンシステムのフェイルオーバーにおける主要な動作モードは、リージョンAとリージョンBのどちらを使うかということだと考えられる。特に元文脈のマイクロサービスを考慮すれば、それぞれのサービスは下流*1 のマイクロサービスを呼び出しており、下流に接続する際にどちらのリージョンのサービスに繋ぐかのモードを持つことになる。

AWSリージョンの配置図。遠いものは遠い

このモードがどのような問題を引き起こすだろうか。ここで出てくるのが、元文章でも言及されているレイテンシーの違いである。実際リージョン内の通信は1msオーダーの遅延だが、リージョンをまたぐ際は条件により100ms以上の遅延が発生する場合もあるはずだ。

フェイルオーバー前後のモードで下流サービスのレイテンシーが100倍も違うと、様々な問題のある挙動を誘発する可能性がある。例えば:

  • 多数の下流APIを呼び出す機能は、顕著に処理時間が増大する
  • リクエストが想定時間を超えてタイムアウトする
  • タイムアウトの結果、リトライが頻発して下流の負荷が増大する
  • レスポンス速度の低下によるユーザー体験への影響

私の思いつくのはこれくらいだが、他にも色々あるんじゃないだろうか。元文章では、これらの悪しき副作用・振る舞いをひっくるめて modal behavior とまとめたのだと思われる。なかなかハイコンテキストだった。

まとめ

modal behaviorはアプリケーションのモードによる振る舞いのこと。コレ自体は良いものでも悪いものでもない。文脈により意図するところが大きく変わりうる言葉なので、注意したい。

それはそうと元文章を読んでたら、マルチリージョンで可用性を確保するシステムの設計はつらそうとしか思えなくなってきた。事例見てみたい。

*1:ちなみに下流・上流も2つの真逆な定義があるが、今回は呼び出される側を下流と呼ぶ。

AWS CDK Tips: スタックの分け方について

AWS CDK TIpsシリーズの記事です。

AWS CDKのスタック、まとめてますか?分けてますか?分けている方はどういう基準で分けていますか?

この議論は人によって割と意見の分かれることも多く、最高の飲み会ネタになるでしょう。今日は私見も交えながら、CDKのスタック分割法についてまとめてみたいと思います。

自己紹介

私見を語る上で自己紹介は必要だと思うので、私自身のCDK経験を簡潔に:

CDKは2020年の頭に出会い、以下のようにかれこれ3年ほど使っています。

  • 前職ではそれなり規模のmBaaSをCDKを使ってAWSに移行・運用していました
  • 現職では大小様々な規模のプロトタイプ開発にCDKを使っています。数えるとこれまで15個くらいシステムのプロトタイプを作ったようです。
  • 現職のサイドプロジェクトとして、グローバル規模の社内システムの開発運用もCDKでやってました (お手伝い程度)。AWSアカウント管理用サービスで、ユーザーがアカウントを作るたびに26+リージョン全てにCDKスタックをデプロイするみたいな面白い使い方をしてました。
  • AWS CDK本体へのコントリビューションもたまに取り組んでいます。

元々バックエンド開発が生業だったので CloudFormationよりもすんなりと入れた記憶です。今日はこれまでの経験も踏まえながら、考えをまとめてみます。

早速、個人的にベストと思うスタックの分け方

必要がないなら分けない! これが基本ルールだと考えます。

理由: スタックを分けると、大抵の場合スタック間に依存関係が生じます。スタック間でリソースを参照することで発生するスタック間参照によるものです。そしてCDK開発ではあるあるですが、この依存関係によって開発・運用上面倒が生じることが多いです (後述)。このデメリットが通常大きいので、必要ない限りは単一スタックに保つのが良いと考えています。

「必要がないなら」ということは、どういうときに分ける必要があるかが問題です。これはCloudFormationの制約に引っかかるときだと考えていて、具体的には以下の状況です:

  1. リソース数が500を超えるとき
    • CloudFormation のクォータによる制限です
    • 例えばこんなとき:
      • 非常に大規模なシステム
      • サーバーレスのAPIでルートごとにLambdaを分けているとき
  2. マルチアカウント、マルチリージョン
    • 1つのスタックはAWSアカウントやリージョンを跨げないためです
    • 例えばこんなとき:
      • 社内ポリシーなどのためログだけ別AWSアカウントに分けないといけない
      • WAFv2のWebAcl (us-east-1のみにデプロイ可能) を使うとき
      • 開発環境・本番環境など、異なる環境にデプロイするとき
  3. リソースをデプロイする間にCloudFormation外の操作が必要なとき
    • リソースAを作成 → リソースAに依存する手作業 → 手作業に依存するリソースBを作成 のような状況です
    • 例えばこんなとき:
      • マルチクラウドや外部SaaSを使うワークロード
      • バックエンドとフロントエンドがあるシステム
        1. バックエンドAPIをデプロイ
        2. デプロイしたAPIのURL (デプロイするまで決まらない) をフロントエンドに埋め込んでビルド
        3. ビルドした静的ファイルをデプロイ」
    • Custom resourceを使えば無理やりスタックをまとめることもできますが、どこまでやるかは状況によるでしょう

上記の状況にあたらない場合は、スタックを分ける必要はないので、単一スタックにまとめた方が良いと考えています。

スタックを分けることで生じる問題

さきほどスタックを分けると面倒が生じがちと書きましたが、具体的にはどういう問題でしょうか。これは主に以下の点です:

1. リソースの変更・削除時に新たに考慮事項が生じる

これはCDKではよく知られたハマリポイントです。例えば以下の状況を考えましょう。ParentStackとChildStackがあり、ParentStackの中のDynamoDBテーブルをChildStackのLambda関数が Fn::ImportValue で参照しているとします。

この状況でChildStackからLambda関数を削除します。するとCDKは同時に、参照されなくなったDynamoDBテーブルのStack exportをParentStackから削除しますね。この合成されたテンプレートをデプロイしてみましょう。

CDKはスタック間に依存関係がある場合、依存関係の親のスタックからデプロイします。これは多くの場合都合が良い挙動です。従って今回はParentStackからデプロイされますが、この時点ではChildStackはまだLambda関数が残っているので、Stack exportも利用されています。すると使用中のStack exportは削除できないので、デプロイに失敗してしまうのです。

この問題はよく知られた問題なので、対処方法はいくつかあります:

対処できるとはいえ、煩雑なのは変わりありません。必要がない限り意識したいものではなく、スタックを分けない大きな理由になるでしょう。

2. スタック間の循環依存を回避する必要がある

CloudFormationはスタック間の循環依存を許可しないため、循環依存が発生しないように注意してCDKを書く必要があります。循環依存とは、例えば以下が同時に成立する状況です:

  • スタックA内のリソース1aがスタックB内のリソース1bに依存
  • スタックB内のリソース2bがスタックA内のリソース1aに依存

この時スタックAとBは循環依存となり、CDKが合成時に検知してエラーとなります。注意して実装すれば大抵回避できるのですが、まれにL2コンストラクトの実装が原因で回避が難しいことがありました。例えば、こちらのIssueは好例です。

CDKのL2実装が成熟するにつれてこうした問題は減ってきているとは思いますが、いずれにせよスタックを分けることで新たに考慮が必要になる問題ではあるので、デメリットとして挙げました。

3. デプロイが遅くなる

CDKによるデプロイでは、依存関係のあるスタックは並列にデプロイすることができません。代わりに、直列に1スタックずつデプロイすることになります。一方単一スタック内のデプロイは、CloudFormationがリソース間の依存関係を見て互いに依存しないリソースは並列デプロイされます。

このために、単一スタックで全てデプロイする場合と比べてデプロイの並列度が下がり、トータルではより長い時間がかかるようになります。

特に開発環境では変更をすぐにデプロイしてより高速にイテレーションを回したいことが多く、デプロイ時間は短いほど良いことが多いでしょう。デプロイを俊敏にするという意味でも、スタックの不必要な分割は避けたいものです。

4. 適切な分け方を考えるのが大変

そもそもですが、上記のような問題も考慮に入れながら適切なスタックの分割方法を考えるのは非常に大変です。まして、明確に分ける必要が無い状況下ではなおさらです。必要がないのにどういう基準で分けるというのでしょうか?

KISSの原則というものもありますが、必要ない限り単純に保つのというのは多くの場合無難な選択肢でしょう。スタックの数が少ないほど複雑度が低いというのはCDK開発者の共通認識だと思います。複雑度はできるだけ低く保ちたいですよね。

以上、スタックを分けると生じがちな厄介事でした。分けることで上記のデメリットを上回るメリットがあるのなら、分けましょう。とはいえ個人的な経験から言えば、冒頭に挙げたスタックを分けざるを得ない状況以外では、分けるメリットが上回ることは少ないのではないかと思います。

分けるときはどう分けると良いか

というわけで 基本的には分けない というのが私の考える基本ルールです。とはいえ、上述の制限に引っかかるような状況ではスタックを分けざるを得ないこともあります。この時にどう分けるのが良いのかも考えてみましょう。

スタックを分けるデメリットを先ほどまとめましたが、これらのデメリットができるだけ顕出しないような分け方が良い分け方だと言えるでしょう。4は不可避なので、特に1~3の観点で考えます。

スタックを分けるデメリット (再掲)
1. リソースの変更・削除時に新たに考慮事項が生じる
2. スタック間の循環依存
3. デプロイが遅くなる
4. 適切な分け方を考えるのが大変

1については、抑制するためにはできるだけスタック間参照の数を減らすことが重要になりそうです。リソースの依存がスタックをまたがないようにすることで、リソース追加・削除時の考慮を減らしましょう。2も同様にスタック間参照が減れば良いでしょう。3については、スタック間の依存関係を考えて、直列に依存するスタックをできるだけ減らすのが重要でしょう。

では具体例として、以下のようなシステムを考えてみます。青い四角形がシステム内に存在するAWSリソース、矢印がリソース間の依存関係と考えてください。左側はECSやALB、SQSによるサービス、右側はDDB, Lambdaなどによる別のサービス、VPCやRDSが共有リソースとして存在するような構成です。

この時、例えば以下2つのスタック分割法を検討してみましょう:

  1. AWSサービスのカテゴリによる分け方
    • ネットワークレイヤー、永続化レイヤー、ステートレスレイヤーなど、AWSサービスのカテゴリで分ける方法です
      • 私も昔S3 Stackという名前でシステムのすべてのS3バケットが定義されるスタックを作ったことがありましたが、そのようなものです
  2. システム内における責務による分け方
    • 共通部分とサービス固有部分に分け、それぞれでスタックを分ける方法です

この2つを比べると、後者の方がより良い分け方だと考えられます。理由を説明するため、スタック間の依存グラフやスタック間参照の数を具体的に図示してみましょう。

数字はスタック間参照の数

前者の分け方はすべてのスタックが直列に依存しており、デプロイの並列度は低いです。また、スタック間参照の数も多く、例えばSQSやDynamoDBのリソースを変更しようとした時に、デプロイが失敗してしまう可能性があります。

一方後者の分け方は、少なくともServiceA/Bスタックは互いに依存しないため、並列デプロイが可能です。また、こちらはSQSやDynamoDBなどのリソースへの依存はスタック内に閉じているので、変更時のデプロイが比較的容易です (もちろんステートフルゆえの考慮事項はありますが。)

上記は極めて単純化したケースで、実際はCDKのL2コンストラクトが思わぬスタック間参照を作ることもありすべてを見越して分割するのはなかなか大変ではあるのですが、基本的には上のようなことを考えながらスタックを分けるとより良くなっていくと思います。また同時に、この辺りの議論はあまり成熟しておらず人によって意見が分かれる部分なのではとも思います。同意という方もここはこうしているという方も、ぜひご意見お聞かせください!

まとめ

  • AWS CDKにおけるスタックの分け方について考え方をまとめました。
  • 必要のない限り分けない が原則だと考えています!
  • 分けるときはできるだけデメリットがないような分け方をしましょう

FAQ

ついでによく耳にするツッコミについても考えてみました。以下にまとめます。

ライフサイクルによりスタックを分けるべき?

CloudFormationのベストプラクティス では、スタックをライフサイクルやオーナーシップで分けることが推奨されています。ここはCDKだと若干異なる部分だと考えています。CloudFormationを手で書いている場合だと、スタックの依存関係も手動管理になるはずなので、あまり上で挙げたようなデメリットを感じづらいのかもしれません。

Organize your stacks by lifecycle and ownership

まずオーナーシップでスタックを分けるという点については、CDKだとむしろAppリポジトリレベルで分けるのが良いでしょう。異なるチームが同じCDKのレポジトリを触りデプロイも一緒に行うのは、多くの場合得策ではないためです。結果的には、Appが分かれるのでスタックも自ずと分かれます。

ライフサイクルで分けるという点については、CDKだとやはり上述の分けることによるデメリットがあるので、必ず分ける理由にはならないと考えます。更新頻度が異なるリソースはスタックを分けるべきという方もいますが、めったに更新されないリソースと頻繁に更新されるリソースが同一スタック内に同居していても特に問題はありません。重要なリソースに意図しない変更が反映される可能性を減らしたいという方もいますが、それはそもそもCIで差分管理すべきですし、いずれにせよ同じApp内であれば依存関係のあるスタックはすべてデプロイされてしまいます。

とりあえずライフサイクルの違いでスタックを分けているという方は、今一度それによりどういうメリットがあるのかを再考してみても良いかもしれませんね。

Nested stackはどうなの?

個人的にあまり使った経験がないので多くを語れないのですが、基本的にはスタックと同じ制約を課されるはずなので、これも分けない(=使わない)のが良いのではと思います。ただし一部のL2コンストラクト (EKSなど) では標準的に使われているので、うまく使えるなら良いのかもしれません。Nested stack派の方の意見も伺いたいものです。

スタックを一つにまとめるとコードが見づらくならない?

CDK開発者の中には、Stackクラスのコンストラクタにリソースをベタ書きする派の方もいるようです。この場合、スタック内のリソースが増えるにつれてコンストラクタのコードが長大になり、可読性が下がると行った問題が生じる可能性があります。

これを回避するためには、CDKのコンストラクトを使ってコードを構造化しましょう。一例としてはこのようなコードです: コード例。 ここについてはいろいろなノウハウがあるので、また追加で記事を書きたいと思います。

Today I Learned の記事を書いていきたい

ブログ、元々日次で更新するぞ!という気概で始めたが、結局月1更新が関の山となっている。どうしたものかと思っていたところで、つい先程以下のブログを読んだ。

simonwillison.net

継続的にブログを書くことに成功しているエンジニア Simon Willson による、継続のコツを説いた記事。これによれば、以下のテーマは心理的ハードルを生まず記事を書きやすいとのこと:

  • Today I Learned (TIL, 今日の小さな学び)
  • 今取り組んでいる趣味プロジェクトのこと

趣味プロジェクトは、いくつかアイデアはあるのだが時間を取れていない状況。以前投稿したこの記事はそれの前準備だったりするのだが…

C# 開発を始める on Visual Studio Code on Mac - maybe daily dev notes

一方TILはたしかにちょろっと書けば良い話なので、気軽に投稿できる気がする。多分「今日の」という副詞が大事で、学んだことをその日の内に出力する手軽さのようなものを感じる。なんなら技術以外の話でも良さそう。

ということで、今後はTIL軸の記事をもう少し頻度高く投稿することを目指します。

AWS CDKのおもしろいバグを直した (Unhandled rejection編)

先日自分が修正してマージされたAWS CDKのバグが個人的には結構面白かったので、ざっくりとまとめます。

github.com

どういうバグか

CDKのhotswapデプロイ*1をするとき、hotswapできそうでできない変更が2つ以上あると、CDKがクラッシュするという現象が起きていました。できそうでできない変更と言うと曖昧ですが、例えば下記のような変更です:

import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as cf from 'aws-cdk-lib/aws-cloudfront';

// ...
    const distribution = new cf.Distribution(this, 'Distribution', {
      defaultBehavior: {
        origin: new S3Origin(bucket),
      },
    });

    new lambda.Function(this, 'Function1', {
      code: Code.fromInline('exports.handler=()=>{}'),
      runtime: Runtime.NODEJS_16_X,
      handler: 'index.handler',
      environment: {
        // ここを '2' に変えると、hotswapできそうでできない
        literal: '1', 
        // distribution.domainNameを含む環境変数はHotswap不可のため
        token: distribution.domainName,
    });

Lambdaの環境変数は基本的にはhotswap可能ですが、今回のように非対応のトークンを含む環境変数はhotswapできません。これを「できそうでできない変更」と呼んでいます (この記事だけの呼び方)。 より厳密には、hotswap可否の評価中に CfnEvaluationException などの例外を発生させる変更ということになりますが、細かい話なので単純化しておきます。

このような変更が2つ以上あるとCDKがクラッシュするというのが、今回のバグです。詳細な再現コードはこちら

なぜこの現象が起きるか

問題の特定にはそれなりの時間を要しましたが、結論としてはNode.jsのUnhandled rejectionによる挙動でした。CDKではhotswap可否の評価をする際に、概ね以下のような処理を行っていました (実コード)。

  const promises: Array<Array<Promise<ChangeHotswapResult>>> = [];
  // 変更ごとにHotswap可否判定するPromiseを作成
  for (const 変更 of すべての変更のリスト) {
    const resourceHotswapEvaluation = isCandidateForHotswapping(change);

    if (リソースが確実にhotswapできないなら)
      continue
    } else { // hotswapできそうなら
      promises.push([
        isHotswappableLambdaFunctionChange(...),
        isHotswappableStateMachineChange(...),
        isHotswappableEcsServiceChange(...),
        // 他にも色々...
      ]);
    }
  }

  // 各Promiseの結果を見ていく
  const changesDetectionResults: Array<Array<ChangeHotswapResult>> = [];
  for (const detectorResultPromises of promises) {
    const hotswapDetectionResults = await Promise.all(detectorResultPromises);
    changesDetectionResults.push(hotswapDetectionResults);
  }

isHotswappable* 関数群はそれぞれPromiseを返す関数で、場合によっては例外を投げます (例えばHotswap未対応のトークンを含んでいる場合など)。

export async function isHotswappableLambdaFunctionChange(...): Promise<ChangeHotswapResult> {
  // 非同期処理
  if (...) {
    throw new CfnEvaluationException();
  }
}

ポイントは、1つ目のforループでpromiseの配列の配列を作成し、次のforループで各配列のpromiseをawaitしている点です。Unhandled rejectionは、Promise内で発生した例外をその時点で誰もcatchしなければ発生します。まさに handle されていないrejection (Promise内のエラー) というわけです。 具体的な発生条件は、次の記事に非常に詳しいです。 PromiseのUnhandled Rejectionを完全に理解する

今回のCDKコードを本質を損ねない範囲で簡略化すると、以下のようになります。まず最初のforループで、promises に2つのpromiseの配列を格納しています。それぞれのpromiseの中身はただ例外を投げる処理です。次のforループでは promises の各配列を await Promise.all します。この時、1つ目の配列をawaitした時点で例外が送出され、try/catch により大域脱出します。すると、2つ目の配列はawaitされません。つまり2つ目の配列で生じる例外は処理されないので、unhandled rejectionが起きるのです。

const throwError = async () => {
  throw new Error('Error!');
};

const main = async () => {
  const promises = [];
  try {
    // 最初のforループ promiseの配列の配列を作成
    for (let i=0; i< 2; i++){
      promises.push([throwError()])
    }

    // 次のforループ promiseの配列ごとに結果を取得
    for (const results of promises){
      await Promise.all(results);
    }
  } catch (e) {
    console.log(`gotcha ${e.message}`);
  }
};

main();

元のコードに戻りましょう。今回は promises.push でpromiseの配列の配列を作っていますが、この配列を作った時点でpromiseも作成されていることに注意してください。一方でこの promise たちが await されるのはしばらく後です。await されるまでに例外が発生した場合は、unhandled rejection が発生します。今回のクラッシュの根本原因もここでした。

変更が2つ以上という条件はここに絡んでいます。変更が1つだけであればpromisesの配列の長さは1なので、実質すぐに await されることとなり問題ありません。2つ以上で、かつ例外を投げる変更が2つ目以降に存在する場合に、この問題が生じるという仕組みだったのでした。

この因果関係を見出すのが苦労したポイントで、Unhandled rejection のことを全く知らないと難しかったのではと思います。頭の片隅に置いておいて良かったです。Node.js 16ではUnhandled rejectionが生じた場合、ただ例外とともにプロセスがクラッシュするので、一見何が起きているのか分かりづらいためです。これは --unhandled-rejections=warn というオプションを付けて実行することでマシになります。デバッグに役立つので覚えておくと良いでしょう。

$ node --unhandled-rejections=warn main.js
(node:16075) UnhandledPromiseRejectionWarning: Error: Error!
    at raiseErrorInMs (/Users/xxx/main.js:7:9)
(Use `node --trace-warnings ...` to show where the warning was created)
(node:16075) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). To terminate the node process on unhandled promise rejection, use the CLI flag `--unhandled-rejections=strict` (see https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). (rejection id: 1)
gotcha Error!

どう修正するか

根本原因は分かりました。これを修正するには、いくつかの方法が考えられます。

1. promiseたちに catch を付けて、await せずとも例外が処理されるようにする

これは一つのやり方です。こうすれば、awaitする前に例外が投げられた場合でもcatchにより処理されるため、unhandled rejectionは生じません。しかしながら、実は今回は投げられた例外を大域脱出のために使っています (コード)。このため、例外をここでキャッチしてしまうのは不都合でした。大域脱出の仕組みを維持するには実装が複雑になりそうなので、この方法は使いません。

  promises.push([
     isHotswappableLambdaFunctionChange(...).catch(...), 
     isHotswappableStateMachineChange(...).catch(...),
     isHotswappableEcsServiceChange(...).catch(...),
  ]);

2. Promise.allSettled で promiseの配列をくるむ

Promise.allSettled を使うと、個々のpromiseはrejectされなくなります。代わりに、allSettledの戻り値で各promiseの結果を取得することができます ()。1と同様に、個々の例外を明示的にhandleする必要がなくなるので、unhandled rejectionを防ぐことができますが、同じく大域脱出に対応するため追加のコードが必要になります。

  promises.push(Promise.allSettled([
     isHotswappableLambdaFunctionChange(...), 
     isHotswappableStateMachineChange(...),
     isHotswappableEcsServiceChange(...),
  ]));

3. promise たちを await する直前に作成する

await する前に例外が発生するのを避けたいのなら、awaitする直前にpromiseを作れば良いというアイデアです。この時 promises は、promiseの配列を返す関数の配列になります。その関数を await する時に呼べば、awaitされていないpromiseが裏で例外を投げる状況を防ぐことができます。

-  const promises: Array<Array<Promise<ChangeHotswapResult>>> = [];
+  const promises: Array<Array<()=>Promise<ChangeHotswapResult>>> = [];

-  promises.push([
+  promises.push(() => ([
     isHotswappableLambdaFunctionChange(...), 
     isHotswappableStateMachineChange(...),
     isHotswappableEcsServiceChange(...),
  ]);

  for (const detectorResultPromises of promises) {
-    const hotswapDetectionResults = await Promise.all(detectorResultPromises);
+    const hotswapDetectionResults = await Promise.all(detectorResultPromises());
    changesDetectionResults.push(hotswapDetectionResults);
  }

これは非常にシンプルでありながら、大域脱出の機能もそのまま維持されて良さそうです。1つ考えられる問題は、同時に実行される非同期関数が減ることで従来よりも並列度が下がり、処理時間が長くなりえることでした。これについては、コードを見ると実は isHotswappable* 関数は非同期関数でありながらほぼ非同期処理は行われていないため、async/await による速度向上効果は薄いと判断しています。今後の拡張で万が一速度面の問題が生じた場合に、また最適化を検討すれば十分でしょう。

上記の考察を経て、3番の実装でPRを投げ、無事マージされました

このバグの元となった実装、知らないと結構やってしまいそうな気がします。単体テストで拾えるものでもないので、初回実装時に気づくのは難しそうです。現実的には有識者レビューでたまたま気づいてもらうか、気づかずにリリースして後から修正するかということになるんでしょうね〜。

まとめ

Unhandled rejectionについてはNode.js 14の時代に何度か遭遇し存在は知っていたのですが、今回始めて深堀りすることができました。こうした機会を得られるのもOSS貢献の良い側面だと考えているので、今後も続けていきたいと思います。

AWS CDKは最近謎の階級制 (下記) が導入されたりしてアツいOSSなので、興味を持った方はぜひ見てみてください。 CONTRIBUTING.md

github.com

*1:hotswapはLambdaやECS、Step Functionsなどの変更をすこぶる高速にデプロイできるCDKの目玉機能です!ぜひお試しください: cdk deploy --hotswap

リモート会議で 「今声聞こえてますか」 の確認、いる?

問題提議

リモート会議などで良く耳にする、初めて発話する人の第一声 「今私の声聞こえてますか」、これが必要なのか考えてみた。

この一言は次のようなシチュエーションでしばしば聞かれる。

司会 「… それでは、次はXXさんどうぞ。」
XX 「はい、よろしくお願いします。今私の声聞こえてますか?」
司会 「はい、大丈夫です 🙆」
XX 「ありがとうございます。それでは、話を始めたいと思います。…」

このやり取りの欠点は、あまりにも冗長なことだ。体感的にはほとんどの場合問題なく声が聞こえるので、わざわざ確認しなくて良いのではと感じる人も多いだろう。この記事では、所要時間の観点からこの確認の要不要を検討する。

他のやり方と比べてみる

試しに、以下のダイレクトに話を始めるケースと所要時間を比較してみたい。なお、以下では↑の方を 慎重版 、 ↓の方を 直接版 と呼ぶ。

司会 「… それでは、次はXXさんどうぞ。」 
XX 「ありがとうございます。それでは、話を始めたいと思います。…」

この時、XXが話を始めるまでのパターンは以下の4つが考えられる。 なお、ここでは簡単のため以下の仮定を置く:

  • 正常系は、XXの環境に異常がなく、正常に通信できる場合とする。
  • 異常系は、XXのマイクにのみ不調がある場合とする (XXは声は通らないが司会の声は聞こえる)。それ以外の異常は考えない。
  • 司会者は10秒間XXさんの声が聞こえなければ異常とみなし、その旨をXXに伝える
  • XXは3秒返事がないかあるいは司会から伝えられたときに、自分の環境に異常があることに気づく
  • XXは環境の異常に気づいたら、10秒後に必ず修正できる
  • その他の時間は適当に仮定しているが、パターン間で一貫していれば結論に影響はない。
# パターン1: 慎重版 正常系 合計8秒
司会 「… それでは、次はXXさんどうぞ。」 (T=0)
XX 「はい、よろしくお願いします。今私の声聞こえてますか?」 (T+5秒)
司会 「はい、大丈夫です 🙆」 (T+8秒)
XX 「ありがとうございます。それでは、話を始めたいと思います。…」

# パターン2: 慎重版 異常系 合計21秒
司会 「… それでは、次はXXさんどうぞ。」 (T=0)
XX 「はい、よろしくお願いします。今私の声聞こえてますか?」 (T+5秒)
XX 返事がないため環境の異常に気づく (T+8秒)
司会 「XXさん、声が聞こえないようです🙅」 (T+10秒)
XX (環境を修正して) 「今私の声聞こえてますか?」 (T+18秒)
司会 「はい、大丈夫です 🙆」 (T+21秒)
XX 「ありがとうございます。それでは、話を始めたいと思います。…」

# パターン3: 直接版 正常系 合計0秒
司会 「… それでは、次はXXさんどうぞ。」 (T=0)
XX 「ありがとうございます。それでは、話を始めたいと思います。…」

# パターン4: 直接版 異常系 合計23秒
司会 「… それでは、次はXXさんどうぞ。」 (T=)
XX 「ありがとうございます。それでは、話を始めたいと思います。…」 (T+5秒)
司会 「XXさん、声が聞こえないようです🙅」 (T+10秒)
XX (環境を修正して) 「今私の声聞こえてますか?」 (T+20秒)
司会 「はい、大丈夫です 🙆」 (T+23秒)
XX 「ありがとうございます。それでは、話を始めたいと思います。…」

慎重版の時間的な利点は、異常系の際に発揮される (パターン2と4に注目)。慎重版ではより早期に異常に気付けるので、対応が早まるのだ。この効果はどれほどだろうか。

ここでは、異常系に遷移する確率をpとしよう。このとき慎重版と直接版それぞれで、話を始めるまでにかかる時間の期待値は下式となる:

慎重版: 8*(1-p) + 21*p = 9 + 13p
異常版: 0*(1-p) + 23*p = 23p

このため、慎重版の方が期待値が短いのは 9+13p < 23p 、つまり p > 0.9 の時となる。90%以上の確率でマイクに不調が発生する限界環境では、慎重版のやり取りを使おう!それ以外のありふれた環境では直接版の方がお得である。

ちなみにパラメータは恣意的に慎重版が有利になるように決めている。実際は司会者はXXの声が聞こえるまで10秒も待たないはず。今回の設定では、司会者が8秒以内に異常に気づける場合は、慎重版でも直接版でも異常系にかかる時間が等しくなるため、慎重版の時間的メリットはなくなる。

結論

話し始めるまでにかかる時間の期待値だけを考えると、「今聞こえていますか?」の確認をするメリットがある状況は考えづらい。確認せずに本題を話し始めよう。


こういう話ってよくあるよね

ということでこの記事は一旦結論がついたのだが、これだけで終わるのも何なので、ついでに他の話との関連性を無理やりもたせてみる。

この話の要点は、何らかの分岐処理をする際はそれぞれの分岐先に遷移する確率を考えれば最適化できるんじゃないという点。こういう状況はよくあって、例えば:

楽観ロックと悲観ロック 楽観ロックでは、基本的に処理の競合が起こることはないだろうと見積もって排他処理を行う。このためもし競合が起きた場合に解決するコストは高いが、競合が起きない状況では比較的低コストで処理することができる。悲観ロックはその逆で、競合が頻繁に起こる状況で利用する。

CPUの分岐予測 私がつい先日学んだ話。コードを書く際に if else のどちらに処理が飛びがちかをコンパイラに教えることで、CPUに処理を最適化させることができる。こちらの記事も参照。

tmokmss.hatenablog.com

シャワーを浴びながらふと思いついた題材だったが、意外と面白い話だったので記事にしてみた。おわり

P.S.

とはいえ慎重版のやり取りにも効用はあり、例えば以下が挙げられる。

  1. 定型句のやり取りをすることで、話者の緊張を和らげる
  2. 第一声として無難な発言をして、話者の喉を整える
  3. この言葉に紐付けて、マイクミュートの確認を習慣化できる

他にも比較の観点があれば、ぜひ教えて下さい。

MariaDBコントリビューション録その6 (完) - どのOSSにコントリビュートするのか

前回のあらすじ

MDEV-18873に取り組み中。これはクエリ内で指定されたperiodの名前が空文字列 (``) であるときに、MariaDBがクラッシュするというバグだ。

ALTER TABLE t ADD PERIOD IF NOT EXISTS FOR `` (s,e);

一旦自力で考察してPRを作成したが、レビュワーの指摘を受け推論に粗があることに気づく。さらに考察を深めよう。

tmokmss.hatenablog.com

考察する go deeper

これは2ヶ月前に取り組んだため、正直詳細は忘れてしまった。ざっくりとまとめる。

もともとは過程で呼び出される末端の関数にNULLチェックを入れていた。これはクエリの処理の中ではかなり後段での対処となるため、あまりにも対症療法的で、実は多くのバグを取りこぼすことになる。

より良い方法は、できる限り処理の前段でエラーにしてしまうことだ。結局の所根本的な問題はクエリ内の period name が想定しない文字列になっていることなわけで、これは period name として扱われるトークンを取り出せた時点で検証可能である。最終的にはクエリを字句解析する時に、period name となる文字列をバリデーションする形となった。

この考察をするにあたり、メンテナのニキータさん (@FooBarrior) からは以下の助言をいただいた (雰囲気で意訳):

  • 現象の根幹に当たれ。木を見て森を見ずのような状態に陥るな。
  • 他の類似処理 (今回はcolumn nameやtable name) でどうやっているのかを探れ。同じ解決策が適用できることも多いはずだ。
  • 既存のコードベースが長大だからといって読まない言い訳にはならない。適切な問題解決のためには徹底的なコードリーディングと深い考察が必要だ。

大変身に染みるアドバイスだった。今後同じような活動をしようとしている読者の皆様にもぜひ意識してみてほしい!ちなみに今回は、報告されているケース以外にも問題となるクエリを作ってみて、さらにそれらがなぜ問題となるのかをコードを追いながら考えることで、根本原因の吸い上げに成功した。いわゆる帰納法に近い考え方だろう。おそらく他のトラブルシューティングでも普遍的に有効な方法と思われる。

squash必要?

こぼれ話。MariaDBではPRを出す際にすべてのコミットを1つにまとめて都度 force push しながら修正していくことが求められている。 最後にGitHubでSquash mergeすれば良くない?と思うが、これはマージ前にメンテナが別ブランチでテストするときに1コミットだと都合が良いということ。

オールウェイズforce push

案外この規則を採るプロジェクトは他にもあるようで、例えば Ruby on Rails なんかもそう。rails/rails

force push運用は過去の作業・レビューログが消えるなどなかなか受け入れがたいが、素直に郷のしきたりに従うことをオススメする。 よほど今後もそのOSSにコントリビュートしたく、自分が現状を改善してやるんだという気概を持っている場合はその限りではないかもしれない。ただし、それを変えるには技術的なブロッカーがあるかもしれないし人的なブロッカーもあるだろう。茨の道であることは覚悟しなければならない。

完結

上記のようなやり取りをしつつ、途中2ヶ月ほど期間が空いてしまうこともあったが、無事 マージされたのだった 🎉

ちなみにこの検証中に関連する別のバグを見つけたが、それはレビュワーと話して今回のPRのスコープ外としてもらった。(ちょっとSQLの知識が足りなすぎてキツくなってきたのが本音… そもそも period 自体MySQLにはない機能なので、未だにあまりピンときていない) 直したいという方はこちら!

以上が今回のIssueの顛末。割と区切りも良いので、このシリーズは一旦ここで締めようと思う。最後にこれまでの(わずかばかりの)MariaDBへの貢献で考えたことをまとめる。

MariaDBにコントリビュートしてみて

今回MariaDBにコントリビュートしてみて良かったことは沢山ある。例えば

  • バグ修正はパズル的な要素があり面白い
  • C++の大規模コードに触れることが普段ないので新鮮
  • 普段触っているコードよりははるかに複雑なので、良い頭の体操になる
  • RDBMSの裏側をすこーーしだけコードレベルで覗けて、やや心理的な抵抗が下がった

ただ、少しつらいところもあって、こんな感じ。

  • SQLの標準に関する知識がないと自力で修正するのがキツい。そもそもどう直すべきかを決められないことも (今回だとperiod nameに求められる具体的な形式など。)
  • あまり知識のない自分に対してメンテナのレビューコストを割いてもらう申し訳無さ。パズルみたいで楽しー😆 なんて遊んでいる場合ではない。
  • 今の仕事とはやや縁遠い知識にはなるので、より趣味的な学習となる

ここらへんのPros Consは人によって全然変わるので、Prosが圧倒的に上回る人も多いはず!私にはMariaDBが最適というわけでもなさそうだなと感じたという話。

そんなわけで最近はめっきり普段使うソフトウェアにばかり手を付けている。特にAWS CDKは、以下の点でちょうど良い感じ。

  • 自分が慣れた技術スタックなのでメンテナに初歩的な手間を掛ける事は稀
  • 毎日使うツールなので、機能追加やバグ修正に直接利益がある
  • AWSOSSなので、業務時間に堂々と作業できる
  • CONTRIBUTING.md がよく整備されているので初心者でも迷わない
  • コミュニティ (cdk.dev) が活発で教え合いの交流が楽しい

github.com

色々なものに触れてみて初めてそれぞれの良し悪しが分かるという点もあるので、そういう意味でも今回の経験は良かった。ということで、MariaDBシリーズ完!引き続き趣味の開発は続けたいので、その一環で何かOSSにも関わる機会があればと思う。 おしまい

ISUCON12予選通過しそうでした

ISUCON12予選に参加しました!結果、スコア自体は予選ボーダー通過していましたが追試で失格でした 😭😭😭

チームいすもなで参加 もなちゃんすまん…

通過した気満々で参加記事書いてましたが、供養のため投稿します。 なお過去にはISUCON9, 11に参加していずれも上位30~50%tileくらいに留まったので、今回はその反省も踏まえつつ準備しました。

今回の方針

Go使う

大きな変化はこれです。ISUCONで使う言語は実質RubyとGoの二択だと思ってます (他の言語はISUCON向けの情報が充実してない印象) が、これまでは業務でGoを使ったことがないのでRubyを選択してました。ただしRubyは以下のつらみが見過ごせませんでした:

  • 諸々のツール (profilerなど) が古く使いづらい
  • unicorn/pumaの設定などチューニング項目も増える
  • 静的解析が弱いゆえにデプロイしてからエラーに気づくことも多い

またこれは曖昧な情報なのですが、Goの方がやはり処理能力は高い気がします。ISUCON11予選をRubyとGoで解いたのですが、Rubyでかなり苦労して達成したスコアをGoだと軽々と乗り越えられるなあという感じでした。

結局Goは業務で使ってなくても2〜3日でなんとなくいじれるようになりましたし、上記Rubyのつらみが全て克服できるので、最高でした。

ソロ参加

今回は準備がしっかりできるか怪しかったので、迷惑がかからないよう1人で参加しました。後述しますが、これはこれで良い点も多かったです。

準備

ISUCON11予選をGoで解き直しました。解き直す時はこれを見ながら取り組んだのですが、とても良かったです。

isucon.net

ツワモノの思考法がよく分かりますし、あまり突拍子もない事はしなくても予選通過できるのだということを示してくれています。今回12予選を解いているときもこの思考法はめちゃくちゃ参考になりました。

11予選を解くとISUCONに必要なGoの知識は大体インプットできました。ISUCONはN+1解消とバルクインサートとオンメモリキャッシュの書き方だけ抑えておけば、なんとかなる気がします (フラグ)。

前日はお菓子と食料を用意した上でよく寝ました。

当日

8:00 起床

興奮して早めに起きてしまいました。人の頭は起きてから2時間後くらいからフル稼働始めるものらしいので、ちょうど良かったのかもしれません。

10:00 開始

開始後1時間は割と定形作業なので、マニュアルを作っておきました。おかげで落ち着いて作業できるので良かったです。コマンドも事前にここにまとめています。(とはいえ今回はdocker-composeで動くので準備していたデプロイスクリプトが使えなかったりsqliteの対策は何もしてなかったりなど、やはり想定外はありますね…)

ソロISUCON 道標 · GitHub

やや特異な点としては、初手決め打ちでMySQLサーバーとアプリサーバーを分離する点です。過去の傾向からこれは確実に必要になる施策のため、またサーバーを分けることで htop でそれぞれの負荷を見やすくなるため、何も考えずに初手で実施しています。

ちなみに一点困ったのは、マニュアルに記載されていた方法ではポート443のSSHポートフォワーディングができなかったことです。社用Macbookで参加していたので、何か制限があったのかもしれません。解決に時間がかかりそうなので、フロントが必要になったら深堀りしようと後回しにしました。しかし、今回は結局最後までフロントエンドを見ずに終わってしまい、これが良くなかったです(後述)。

諸々のログも取れるようにして、この時点でのスコアが4117です。

11:00 コードやログを読み始める

ログを見ながら、取り組みやすい改善を入れていきます。今回の目標はそれなりに良いスコアを取ることだったので、実装が難しい大それたことは全て避けました。結果的には、たまたまこれが功を奏したようです。(MySQL載せ替えで苦労した人の話を伺う限り)

11:05 MySQLにインデックスはる

MySQLのスロークエリログに出ていたクエリに全てインデックスをはりました。sqliteはログを取れてないので、一旦無視です。

なお従来のISUCONだと01_Schema.sql のようなファイルがあって、それが POST /initialize のたびに反映されていたのですが、今回はありませんでした(!)。このためMySQLスキーマはコード管理せず、直接DBをいじってます。変更取り消したいときダルそうだなと思いましたが、結果的にはMySQLにあまり触れなかったので問題なかったです。

11:40 まとめて採番できるように

スロークエリログを見るとダントツで採番クエリが発行されていたので、とりあえず手を付けました。

採番は前職の時によく考えた問題です。現状では1リクエストの中で複数の採番をするために1つずつ採番クエリを発行していたので、まとめて採番するようにします。この記事の方法を丸パクリしました。 MySQLで採番機能(シーケンス)を実装する方法を整理する - Qiita ストレージエンジンは特にデメリットもないのでMyISAMにしましたが、今回は全体的にトランザクションが使われてないので、InnoDBでも大差ない気はします。

とりあえずまとめて採番できるインターフェースにしつつ、これだけでは何も性能は変わらないのですが、呼び出し側の改善はn+1の解消なども関わりそうだったので後ほど実施することにしました。

12:20 SQLiteにインデックスはる

SQLite初見だし実装力にも自信がないので、SQLiteはそのままにしておこうと考えました。付け焼き刃の対策として、インデックスだけは貼りましたSQLiteは全く計測できてないので、発行される全てのクエリに対して必要なインデックスを張った形になります (計測せよとは…)。/initialize も十分間に合ってました (約10秒)。これでスコア5107になりました。

さらなる最適化としてtmpfsを考えましたが、ググった限り意外と導入に時間掛かりそうなのと、負荷上がったときにもしメモリ溢れたら詰むなあと考えて止めました。

後、ビューワーに DB Browser for SQLite というツールを使いました。初めて使う道具でCLIは難しく、やはりGUIが良いですね。

12:53 retrievePlayerのバルク化, competitionScoreHandler のN+1解消

プレイヤーの詳細をsqliteから取得する retrievePlayer 関数ですが、これがあちこちでN+1問題を引き起こしていました。このため、複数プレイヤーを一括取得する retrievePlayers 関数を作り導入していきます。

ちなみに実はここで実装した関数がバグっていて、後々不穏な挙動を引き起こします。

ひとまずはそこそこリクエストが来ていて実装も単純な competitionScoreHandler 関数に導入して、N+1問題を一つ潰しましたこれでスコア6994

13:13 competitionRankingHandler のN+1解消

同様のアプローチでN+1を潰します。Go + Golandで挑んでますが、やはりコーディングはしやすいです。コンパイルが通ったら大体ちゃんと動くので、今回は全体的にほとんど詰まることなく実装を勧められました。

これでスコア8040。

13:41 CSV入稿をバルクインサートに

そこまでアクセスがないエンドポイントなのでスコアに効くかな?と思いつつ、わかりやすい改善ポイントなので着手しました。思いの外スコア上がった印象です。ルールをよく見ると、このエンドポイントは他のものに比べて10倍のスコアがあるので、実はかなり重要だということに後半から気づきました。

なお、ここで冒頭に作ったバルク採番の仕組みが有効に使えていて、採番クエリもかなり削減できました。

ここ以降勢いづいたのでスコアの記録があまりありません。ベンチマーク履歴は試合終了後も見えるかなと思ってましたがそんなことはなかったです><

14:36 プレイヤーのオンメモリキャッシュ

現状いろいろなエンドポイントが retrievePlayer を呼んでいるので、おそらくキャッシュが有効だろうと考えました。SQLiteの計測ができていないので割と勘ベースではあります。{tenantId}#{playerId} の文字列でキャッシュすることにしました

disqualified の状態がたまに変化するので、その時はキャッシュをクリアする必要があります。

14:45 コンペティションのオンメモリキャッシュ

脳がオンメモリキャッシュに慣れたので、ついでに retrieveCompetition もキャッシュ化しました。Go力が全くないので、めちゃくちゃクソみたいな実装です!

10分くらいでできそうだったので何も考えずに実施しましたが、実はこれがかなり効きました。今考えるとコンペティションはユーザー間で共有されるのでキャッシュが有効なのは当然な気もします。

これでスコアが16056になりました。 この辺りでリーダーボードを見て、これ予選突破できるんじゃねと妄想してドキドキしました。

15:04 competitionScoreHandlerの採番を改善

またまた採番のバルク化です。シンプルに効きます。

ちなみに講評で知りましたが、UUID化しても問題なかったらしいです。APIレスポンスの形式は変えちゃだめだと思ってたのですが、IDのフォーマットは何でも良かったようですね。ただしUUIDは長い文字列なので他の処理が重くなる可能性もあるとのこと。

15:16 プレイヤーのスコアを重複保存しない

CSV入稿時にプレイヤースコアを保存しますが、実は必要なのはプレイヤーごとの最高スコア (=CSVで最後に出てくる行) だけでした。重複排除して保存するようにします

これだけだとバルクインサートの数が少し減るだけなのであまり効かないのですが、これにより次のN+1問題を解消することができます。

15:26 playerHandlerのN+1解消

プレイヤーがコンペティションごとにスコアを1行しか持たなくなったので、プレイヤーの全スコアをまとめて取ってこれるようになりました

15:54 ランクのソートをSQLite側で実行する

こうなるとソート処理もアプリ側でやるほど複雑ではなくなるので、Sqliteでインデックスも使いながら実行します

だいぶ実装がシンプルになり気持ちが良いですね!

16:15 減点要因を直す

ベンチの結果を見て、やたら減点されている (-20%とか) ことに疑問を感じていました。

Score Breakdown: base=19344, deduction=3288

当初は負荷が高いからロック競合起きているのかな―と思いましたが、さすがにここまで解消しないということはアプリのバグのようです。エラーが起きるエンドポイントはベンチマーカーが教えてくれるので、アプリのログを見ながら原因を探します。

結果的には、 プレイヤーをバルク取得する retrievePlayers 関数の実装がだめだったようです。クエリのIN句が長すぎる場合に落ちるようでした。とりあえず1000件ごとにクエリを分けるように修正してみたところ、エラーが解消されました。SQliteのクエリ長に上限があるようですね。

アプリのログで write: broken pipe という旨のエラーが多発していたので、これも疑ったのですが、減点数の割にログ件数が多すぎたので無関係と判断しました。クライアントがレスポンスを受け取る前に切断すると起きる現象のようですが、ベンチマーカーがそのような実装になっていたんでしょうか?

16:28 billingReportByCompetition の改善

このエンドポイントも10倍のスコアを持つので、何かしら改善しようと思いました。とはいえ結構複雑な実装なので、簡単に手を入れられることは少なそうです。とりあえずコンペティションが終わってないときは何の計算もしないことが分かったので、早期リターンするようにしました。テナントDBの中を見る限り finished_at がNULLの行は半分くらいあったので、そこそこ効いたはずです(スコア差分うろ覚え)。

ここまでで22593です

16:40 3台目を使う

今までのサーバー利用状況は、1台目: nginx+app 2台目: mysql で3台目が完全に遊んでました。かわいそうだったので、 1台目からappを切り離して3台目に渡します。

今回は nginx の負荷がかなり軽微 (CPU 5%程度) なので今度は1台目が遊びがちになりますが、それでも多少スコアが上がりました。23227 もしかすると誤差の範囲だったかもしれません。

仕事を与えられて生き生きとするisu3

17:00 flock の見直し

正直ここまででできることが尽きたので、最後に今まで見て見ぬ振りしていたflockをなんとかしようと考えました。よく見るとこれまでのN+1解消などにより、多くの箇所でflockを取る意味がなくなっているようです。不要そうなところを消していきました。

未だに解せないのは、消すことでスコアが下がる場合があった (減点ではなく元スコアが下がる) ことです。どちらかというと整合性エラーが起きるのかなと予想していたので、これは意外でした。ここまで17:10くらいで時間が余っていたので、flockをつけたり消したりしてベンチマークを取り、最高のflockパターンを残しました。

講評を聞いて、トランザクションの代わりにflockを使っていたのだと意図を知りました。ISUCONは設定上の開発者がヌケている前提があるので、それを意識して解法をメタ読みするのが重要かもしれないですね。

ベンチガチャ

終盤のリーダーボードで8位だったので、ワンチャンあるなと欲がでました。

ベンチマークを回すたびにスコアが ±10% くらい変動するので、終盤30分くらいひたすらベンチマークを回し続けました (もうこれ以上は簡単にできる施策がなさそうだった)。17:50くらいにSSRくらいのスコアを引けて、最終スコア 28957 でフィニッシュです。SQLiteをそのまま残すパターンとしてはできる限りやれたんではと思うんですが、どうなんでしょう。

結果

運営チームの追試によりブラウザチェックで失格…!失格!!!

原因はこれです:

// キャッシュの変数を宣言
var playerCache *cacheSlice
var competitionCache *cacheSliceCompetition

// POST /initialize でキャッシュを初期化
func initializeHandler(c echo.Context) error {
    playerCache = NewPlayerCacheSlice()
    competitionCache = NewCompetitionCacheSlice()
    // ...
}

おわかりいただけたでしょうか。

これでは POST /initialize を実行せずに各エンドポイントが叩かれた場合に各キャッシュがnull pointer referenceでpanicします。ベンチマークでは起こり得ない挙動だったので、気づけませんでした。

フロントエンドを無視したのが仇となったと思います。最近はnull安全なTypeScript ばかり書いていたので、私のヌルポセンサーが劣化していたというのも原因の一つでしょう。また、そもそも追試の項目をよく確認しておらず再起動後にベンチマークを回すだけに留まっていたという詰めの甘さも問題でした。

ここはチェックリストで改善できるはずなので、次回は気をつけます。

振り返り

結果は残念でしたが、ソロ参加は次の点で意外と良かったです。

  1. デプロイの競合もマージコンフリクトもない
  2. このため開発フローがかなり単純化できる
  3. 一切会話しなくて良いので集中できる
  4. ダメ元感があるのでプレッシャーも減る

ただし絶対的にできる作業量は減るので、限界は感じます。よく連携できた3人チームにはまず勝てないと思います。

Goも最高でした。今思えばRubyはある種のしばりプレイだったんだなと思います。欲を言えばNULL安全ならもっと良かった…………。来年参加するときはもう少し慣れておきます。

今回失格に終わったのはメッチャクチャめちゃくちゃ残念ですが、これを戒めとして今後も慢心せず精進していきます!運営の皆さまありがとうございました!!