🌏

Prismaのタイムゾーン問題とバージョンをあげる時にハマったこと

2024/12/05に公開

はじめに

この記事は「Medley(メドレー) Advent Calendar 2024」5 日目の記事です!

https://qiita.com/advent-calendar/2024/medley

こんにちは、メドレーの人材プラットフォーム本部でエンジニアをしている手嶋です。
私が開発を担当しているジョブメドレーアカデミーでは、ORM に Prisma を使用しています。今回はその Prisma をバージョンアップした際に直面した問題やハマりポイントを共有したいと思います。

Prisma のタイムゾーン問題について

今回のバージョンアップでは、Prisma のタイムゾーン問題に対応するための実装が意図しない挙動になっていました。そのため、まずは Prisma のタイムゾーン問題について説明します。

Prisma では、日時データは アプリケーションサーバや DB のタイムゾーン設定に関係なく常に UTC 時間で保存されます。この仕様に関する問題は、約 4 年前から issue が上がっていますが、2024 年 12 月現在も解決されていません。

https://github.com/prisma/prisma/issues/5051#issue-785386942

上記の問題については、本来であれば DB に UTC 時間で保存すれば解決します。
しかし、ジョブメドレーアカデミーは別会社から運営を引き継いだ歴史的背景があり、DB 内の日時データがすでに JST 時間で保存されている状態だったので、JST での保存を継続して運用する必要がありました。

どのように対応しているか

タイムゾーン問題に対しては、 UTC と JST の時差を調整する Prisma Middleware を実装して対応しています。これは、よく使われているワークアラウンドの一つだと思います。

以下のコードは先ほど紹介した issue 内のコメントから引用しています。

middleware の実装例
// Subtract 9 hours from all the Date objects recursively
function subtract9Hours(obj: Record<string, unknown>) {
  if (!obj) return

  for (const key of Object.keys(obj)) {
    const val = obj[key]

    if (val instanceof Date) {
      obj[key] = dayjs(val).subtract(9, 'hour').toDate()
    } else if (!isPrimitive(val)) {
      subtract9Hours(val as any)
    }
  }
}

function prismaTimeMod<T>(value: T): T {
  if (value instanceof Date) {
    return dayjs(value).subtract(9, 'hour').toDate() as any
  }

  if (isPrimitive(value)) {
    return value
  }

  subtract9Hours(value as any)

  return value
}

// Create a prisma client instance with timemod
const prisma = new PrismaClient()

prisma.$use(async (params, next) => {
    const result = await next(params)

    return prismaTimeMod(result)
})

私たちは上記のコードを参考に、DB に渡すデータ(wheredata など)に Date 型のものが含まれていれば、それに対して 9 時間プラスし、DB から出力されたデータに Date 型のものが含まれていれば、それに対して 9 時間マイナスするように、実装しています。

以下の図のようなイメージです。

これによって、Prisma を使った DB 操作の際に UTC/JST のズレを気にする必要がない状態にしています。

ただ、この手法では $queryRaw をはじめとする SQL を直接記述する形式の API をカバーできないため、時差を考慮した処理を別途行う必要がある、とバージョンアップをするまで思っていました。

バージョンアップで起きた事象

Prisma のバージョンを 5.9.1 から 5.19.1 にアップデートすると、$queryRaw で取得した日時データの値に差が生じるようになりました。

const result = await prisma.$queryRaw`
  SELECT CAST('2024-10-01 10:00:00' AS timestamp);
`;
console.log(result);

// 9時間マイナスされるようになった
[ { timestamp: 2024-10-01T10:00:00.000Z } ]  // before (5.9.1)
[ { timestamp: 2024-10-01T01:00:00.000Z } ]  // after (5.19.1)

バージョンアップ後は、$queryRaw の実行結果も、middleware の意図通りに UTC/JST のズレを解消してくれるようになっています!

やったこと

リリースノートを確認する

以下の事実から、バージョンアップによって $queryRaw や middleware の挙動が変更された可能性があると考え、入念にリリースノートを確認していきました。しかし、該当する変更点は見つかりませんでした。

  • $queryRaw 以外(findManyなど)で取得した Date 型の値は変化がなかった
  • $queryRaw は middleware を介して Prisma のクエリを実行できない認識だった
    • したがって DB の日時データを取得した際にマイナス 9 時間されていない
  • $queryRaw の結果として返される値に、ちょうど 9 時間のズレが見られた

$queryRaw の挙動確認

リリースノートから収穫はありませんでしたが、$queryRaw の挙動に問題がある可能性が高いと考えたため、呼び出しから実行結果が返るまでの流れを整理しました。呼び出し元や DB の変更はしていないので、それ以外の箇所にログを追加して調査を進めました。

すると、上記の図の(3)部分に該当するクエリ実行結果のログが、Prisma のバージョンによって以下のように変化していることが分かりました。

// 以下を実行
const result = await prisma.$queryRaw`
  SELECT CAST('2024-10-01 10:00:00' AS timestamp);
`;

// (3)部分のログを確認
// before: Prismaの独自オブジェクト
[
  {
    timestamp: {
      prisma__type: 'datetime',
      prisma__value: '2024-10-01T10:00:00+00:00'
    }
  }
]
// after: Dateオブジェクトになっている
[ { timestamp: 2024-10-01T10:00:00.000Z } ]

原因

今回の事象は、Prisma のバージョンアップにより、$queryRaw で日時データを取得するクエリの返り値が Prisma の独自オブジェクトから Date オブジェクトに変化した ことが原因でした。その結果、middleware 内の 9 時間マイナス処理が有効になり、データにズレが生じました。

さらに、これまで $queryRaw は middleware を介さないと認識していたのは、返り値が Prisma の独自オブジェクトであったため、middleware の処理が適用されていなかったことが理由でした。

対応したこと

原因がわかったので、プロダクションコード内の $queryRaw を使用して日時データを取得している箇所を修正しました。

また、今回のバージョンアップで挙動が変わった箇所を今後も検知できるよう、以下のようなテストコードを追加しました。

// Prismaのアップデートによって $queryRaw実行時の middleware の挙動が変化したことがあったため、
// 今後変更に気付けるようにテストを追加
describe('$queryRaw実行時のUTC/JSTのズレを調整するmiddlewareの挙動確認', () => {
  describe("パラメータとして `new Date('2024-10-01T00:00:00.000Z')` (= JSTでの2024-10-01 09:00:00)を渡す場合", () => {
    it("DB上で '2024-10-01 00:00:00' と記録されているTIMESTAMP型の値と比較すると、一致しないこと", async () => {
        const jsDateObject = new Date('2024-10-01T00:00:00.000Z');
        const [{ result }] = await service.$queryRaw<
          { result: boolean }[]
        >`SELECT CAST('2024-10-01 00:00:00' AS TIMESTAMP) = ${jsDateObject} AS result`;

        expect(result).toBe(false);
    });
  });

  // ...
});

おわりに

紆余曲折ありましたが、無事 Prisma のバージョンアップができました。
今回ハマったポイントは Prisma のタイムゾーン問題を middleware で解消している場合にのみ起こる事象ですが、参考になれば嬉しいです。

明日の Medley(メドレー) Advent Calendar 2024 は@m-dさんです。お楽しみに!

GitHubで編集を提案
株式会社メドレー

Discussion