スタディサプリ Product Team Blog

株式会社リクルートが開発するスタディサプリのプロダクトチームのブログです

Prisma の Interactive Transactions でロジック中の複数クエリを 1 トランザクションにまとめる

おばんです、Web Engineer の @ravelll です。

Prisma の Interactive Transactions を利用して複数の Prisma のクエリを含む処理をアトミックにするような変更を最近行ったので、それについての話を書きます。

Prisma とは?

Prisma を触ったことがない人も多いのではと思うので、少しだけ Prisma について説明します。

https://prisma.io

Prisma は Node.js (TypeScript) 向けの ORM の 1 つです。(Go 向けのクライアントも開発が進んでいます)
type safe なクエリビルダーである Prisma Client、DB migration の機能を提供する Prisma Migration の他、DB の閲覧・操作のためのデスクトップアプリケーションである Prisma Studio、Prisma により定義した DB の閲覧・操作を行う Prisma Data Platform (Early Access) のプロダクトから構成されています。

Prisma を利用して開発を行う際には、以下のようなステップを踏みます。

  1. DB の schema を定義する Prisma schema を書く
  2. prisma migrate dev コマンドもしくは prisma migrate deploy コマンドで DB に Prisma schema を migrate する
  3. prisma generate コマンドで DB の schema をもとにクライアントのコードを生成する(node_modules 以下に生成される)
  4. (生成されたコードによる補完の恩恵を受けつつ)Prisma Client を利用するコードを書く

以下に Prisma を利用した DB schema の定義(Prisma schema)と、クエリを投げるコードの例を示します。

model Student {
  id        Int        @id @default(autoincrement())
  name      String
  homeworks Homework[]
}

model Homework {
  id        Int     @id @default(autoincrement())
  studentId Int
  student   Student @relation(fields: studentId, references: id)
  title     String
  note      String? // ? means optional
  submitted Boolean @default(false)
}
  • コード
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

const run = async () => {
  // Student とそれに紐づく Homework を作成
  const student = await prisma.student.create({
    data: {
      name: 'ravelll',
      homeworks: {
        create: [
          {
            title: 'English: read the textbook 15p ~ 20p',
            note: 'Deadline: 2021-10-07'
          },
          {
            title: 'Math: read the textbook 40p ~ 50p',
          },
        ]
      },
    },
    include: { // 関連モデルのデータをオブジェクトに含める
      homeworks: true
    }
  })

  // Student に紐づく Homework のうち 1 つ目の submitted を true に更新
  await prisma.homework.update({
    where: {
      id: student.homeworks[0].id
    },
    data: {
      submitted: true
    },
  })

  // submitted が true な Homework の集合を取得
  const submittedHomeworks = await prisma.homework.findMany({
    where: { submitted: true }
  })

  console.log(JSON.stringify(submittedHomeworks, null, 2))
  //=> [
  //     {
  //       "id": 1,
  //       "studentId": 1,
  //       "title": "English: read the textbook 15p ~ 20p",
  //       "note": "Deadline: 2021-10-07",
  //       "submitted": true
  //     }
  //   ]
}

run().finally(() => prisma.$disconnect())

雰囲気が掴めたでしょうか。
Prisma schema やクエリを行うクライアントの詳細については、以下のドキュメントに多くが記載されています。

Interactive Transactions でロジック中の複数クエリを 1 トランザクションにまとめる

本題です。
まず今回変更を加えたのは、マスターデータを DB から全削除し、CSV からデータを読み込んで全投入し直す、サービスの運用で利用するスクリプトでした。以下に雰囲気を示します。

const deleteAll = async () => {
  await prisma.textbook.deleteMany()
  await prisma.publisher.deleteMany()
  // await prisma.xxx.deleteMany()
  // ...
}

const insertAll = async () => {
  const publishersCsvLines = parseCSVFile('./csv/publishers.csv')
  const textbooksCsvLines = parseCSVFile('./csv/textbooks.csv')
  // const xxxCsvLines = parseCSVFile('./csv/xxx.csv')
  // ...

  await prisma.publisher.createMany({
    data: publishersCsvLines
  })
  await prisma.textbook.createMany({
    data: textbooksCsvLines
  })
  // await prisma.xxx.createMany({
  //   data: xxxCsvLines
  // })
  // await prisma.yyy.createMany({
  //   data: ここで csv の内容を validation したり処理を分岐したりしているものもある
  // })
  // ...
}

const run = async() => {
  await deleteAll()
  await insertAll()
}

このスクリプトの問題として、削除や更新を行う処理がアトミックになっておらず、マスターデータが閲覧できない時間が生まれてしまうという問題がありました。
サービスは目下開発中で、ユーザーテストを除くと社内にしか利用者がいない状況ではありますが、開発中にサービスを触る際に想定外のエラーがでてしまうときがあったり、ユーザーテスト中にマスターデータの更新が行いづらかったりと、少なからず開発の足かせになっていました。

ここで、このスクリプトがやっているような、互いに関連を持たないモデルについての複数の更新処理を 1 トランザクションにまとめる方法として、Prisma ではまず $transaction API を利用する方法があります。

Transactions - $transaction API | Prisma Docs

これは複数の Prisma のクエリ(以下 Prisma クエリと呼びます)を配列にまとめ prisma.$transaction() に渡すことで、それらの Prisma クエリを 1 トランザクション内で実行するというものです。
以下は 2 つのモデルのレコードを削除するクエリを 1 トランザクション内で行う例です。

await prisma.$transaction([
  prisma.textbook.deleteMany(),
  prisma.publisher.deleteMany(),
])

これを利用することも可能でしたが、データの挿入を行う処理にデータの前処理を行うロジックと DB にクエリするロジックが混在しており、Prisma クエリのみを抽出して prisma.$transaction() に渡すようにするためには小さくないリファクタリングが必要でした。

そんな中、Prisma 2.29.0 で preview feature として登場したのが Interactive Transactions です。(ちなみに 2021-09-29 時点の最新バージョンである 3.1.1 においても preview feature)

Transactions - Interactive Transactions (in Preview) | Prisma Docs

Interactive Transactions を利用することで、prisma.$transaction() は async function を受け取るようになり、その中で呼び出されたロジック中に存在する Prisma クエリ全てを 1 トランザクションにまとめることができます。
元のスクリプトにこれを適用すると、以下のように insert を行う処理の内部に手を入れることなく全てのクエリを 1 トランザクションにまとめることができます。

+export type PrismaInnerTransaction = Parameters<
+  Parameters<typeof prisma.$transaction>[0]
+>[0]

-const deleteAll = async () => {
+const deleteAll = async (prisma: PrismaInnerTransaction) => {
   await prisma.textbook.deleteMany()
   await prisma.publisher.deleteMany()
   // await prisma.xxx.deleteMany()
   // await prisma.yyy.deleteMany()
   // ...
 }

 const insertAll = async () => {
+const insertAll = async (prisma: PrismaInnerTransaction) => {
   await prisma.textbook.deleteMany()
   const publishersCsvLines = parseCSVFile('./csv/publishers.csv')
   const textbooksCsvLines = parseCSVFile('./csv/textbooks.csv')
   // const xxxCsvLines = parseCSVFile('./csv/xxx.csv')
   // ...

   await prisma.publisher.createMany({
     data: publishersCsvLines
   })
   await prisma.textbook.createMany({
     data: textbooksCsvLines
   })
   // await prisma.xxx.createMany({
   //   data: xxxCsvLines
   // })
   // await prisma.yyy.createMany({
   //   data: ここで csv の内容を validation したり処理を分岐したりしているものもある
   // })
   // ...
 }

 const run = async() => {
-  await deleteAll()
-  await insertAll()
+  await prisma.$transaction(async (prisma) => {
+    await deleteAll(prisma)
+    await insertAll(prisma)
+  },
+  {
+    timeout: 600000 // 10 min. デフォルトは 5000
+  })
 }

 run().finally(() => prisma.$disconnect())

この変更により、ひとまずデータの全削除 -> 全投入を 1 トランザクションにまとめることができました。

しかし、ドキュメントに注意書きがあるように、長時間トランザクションを開きっぱなしにすることは DB のパフォーマンスを悪化させる可能性があります。安易な利用は避け、DB の利用状況や処理にかかる時間等を考慮した上で利用するのが良いでしょう。
今回はスクリプトが接続する DB は基本的に参照しか行われず update 時の lock 取得待ちが起きないこと、また Prisma クエリを含む処理にかかる時間が現状 5 分前後とさほど長くないことから一旦の解決策として Interactive Transactions を利用することにしましたが、マスターデータのデータ量が増加するに連れて処理時間も増加し、ある日突然トランザクションタイムアウトするようになることが予想できます。
今回の変更はあくまでも急場を凌ぐ対応として捉え、最終的には素の $transaction API を利用する形に改修したいと考えています。

おわりに

今回は Prisma の Interactive Transactions を利用した事例を紹介しました。今回の変更を通じて Prisma におけるトランザクションについて知識が深まっただけでなく、Ruby on RailsActiveRecord::Base.transaction を使う際にもその中の処理にかかる時間や接続する DB がどう利用されるのかについてあまり気を配れてなかったなと気づくことができたのは個人的な収穫でした。

$transaction API のドキュメントに As the Prisma Client evolves, use cases for the $transaction API will increasingly be replaced by more specialized bulk operations (such as createMany) and nested writes. とあるように、Prismaトランザクションは今後も活発に手が入っていく領域かと思うので、引き続き動向を追っていこうと思います。