FS2 Stream の並列化処理の落とし穴

これは2025のScalaアドベントカレンダーの14日目の記事です。

昨日は xuweiさんの

xuwei-k.hatenablog.com

でした

FS2 Stream の並列化処理の落とし穴

この記事で扱うこと

FS2 の Stream を chunk 単位で処理して、進捗がわかるログも出して さらにパフォーマンス改善のために並列化もされていてる実装があり、そこにさらに修正を行うときに見つけた不具合について共有します。

並列化されているはずなのに想定より明らかに処理に時間がかかるという問題に遭遇しました。

この問題が発生した原因と解決案、またその時の注意点について共有します。

環境

  • scala "3.7.1"
  • lib "co.fs2::fs2-io:2.5.12"

前提

  • 入力:Stream[IO, A](今回は 1 to 100 が流れる)
  • chunkN(chunkSize) で chunk 化し、chunk 毎に処理する
  • 各アイテムの処理に 1 秒かかる想定
  • StreamProgressLogger という基盤コードを利用する

次のような stream 利用して検証を行います

val stream1 = Stream.emits((1 to 100).toList).covary[IO]

StreamProgressLogger

Stream の処理の進捗を確認するために chunk ごとに分割して index を付与し処理の前後でログを出力する基盤コードがあります。

object StreamProgressLogger {
  def progress[A, B](
    chunkSize: Int,
    stream: => Stream[IO, A]
  )(
    execute: Pipe[IO, Chunk[A], B]
  ): Stream[IO, B] =
    stream
      .chunkN(chunkSize)
      .zipWithIndex
      .flatMap { case (chunk, index) =>
        for {
          _ <- Stream.eval(IO.delay(println(s"index: $index start polling stream")))
          result <- execute(Stream.emit(chunk))
          _ <- Stream.eval(IO.delay(println(s"index: $index end polling stream")))
        } yield result
      }
}

※今回の問題が発現する最低限の実装にしています

普段の実装

弊社で Stream を利用した処理を利用する場合は次のような StreamProgressLogger を利用した実装をしています。

val chunkSize = 10
StreamProgressLogger.progress(chunkSize, stream1)(
  _.evalMap(_.toList.pure[IO])
  .evalMap(
    _.traverse{ value =>
       // 1件の処理時間が1sかかるとする
       IO.sleep(1.second) *> IO.delay{
         val th = Thread.currentThread.getName
          println(s"[${Instant.now}] [$th] processed value: ${value}")
      }
    }
  )
)

実行結果

index: 0 start polling stream
[2025-12-14T00:47:56.632678Z] [scala-execution-context-global-15] processed value: 1
[2025-12-14T00:47:57.639903Z] [scala-execution-context-global-15] processed value: 2
[2025-12-14T00:47:58.643818Z] [scala-execution-context-global-15] processed value: 3
[2025-12-14T00:47:59.648911Z] [scala-execution-context-global-15] processed value: 4
[2025-12-14T00:48:00.654425Z] [scala-execution-context-global-15] processed value: 5
[2025-12-14T00:48:01.658471Z] [scala-execution-context-global-15] processed value: 6
[2025-12-14T00:48:02.664106Z] [scala-execution-context-global-15] processed value: 7
[2025-12-14T00:48:03.668296Z] [scala-execution-context-global-15] processed value: 8
[2025-12-14T00:48:04.672399Z] [scala-execution-context-global-15] processed value: 9
[2025-12-14T00:48:05.677061Z] [scala-execution-context-global-15] processed value: 10
index: 0 end polling stream
index: 1 start polling stream
[2025-12-14T00:48:06.684323Z] [scala-execution-context-global-15] processed value: 11
[2025-12-14T00:48:07.686396Z] [scala-execution-context-global-15] processed value: 12
[2025-12-14T00:48:08.690626Z] [scala-execution-context-global-15] processed value: 13
[2025-12-14T00:48:09.692465Z] [scala-execution-context-global-15] processed value: 14
[2025-12-14T00:48:10.696527Z] [scala-execution-context-global-15] processed value: 15
[2025-12-14T00:48:11.702045Z] [scala-execution-context-global-15] processed value: 16
[2025-12-14T00:48:12.707277Z] [scala-execution-context-global-15] processed value: 17
[2025-12-14T00:48:13.712269Z] [scala-execution-context-global-15] processed value: 18
[2025-12-14T00:48:14.717250Z] [scala-execution-context-global-15] processed value: 19
[2025-12-14T00:48:15.719001Z] [scala-execution-context-global-15] processed value: 20
index: 1 end polling stream
index: 2 start polling stream
[2025-12-14T00:48:16.720437Z] [scala-execution-context-global-15] processed value: 21
[2025-12-14T00:48:17.725311Z] [scala-execution-context-global-15] processed value: 22
[2025-12-14T00:48:18.729721Z] [scala-execution-context-global-15] processed value: 23
[2025-12-14T00:48:19.735382Z] [scala-execution-context-global-15] processed value: 24
[2025-12-14T00:48:20.740723Z] [scala-execution-context-global-15] processed value: 25
[2025-12-14T00:48:21.746114Z] [scala-execution-context-global-15] processed value: 26
[2025-12-14T00:48:22.749048Z] [scala-execution-context-global-15] processed value: 27
[2025-12-14T00:48:23.751993Z] [scala-execution-context-global-15] processed value: 28
[2025-12-14T00:48:24.754909Z] [scala-execution-context-global-15] processed value: 29
[2025-12-14T00:48:25.760454Z] [scala-execution-context-global-15] processed value: 30
index: 2 end polling stream
index: 3 start polling stream
[2025-12-14T00:48:26.767155Z] [scala-execution-context-global-15] processed value: 31
[2025-12-14T00:48:27.770670Z] [scala-execution-context-global-15] processed value: 32
[2025-12-14T00:48:28.771865Z] [scala-execution-context-global-15] processed value: 33
[2025-12-14T00:48:29.777610Z] [scala-execution-context-global-15] processed value: 34
[2025-12-14T00:48:30.781722Z] [scala-execution-context-global-15] processed value: 35
[2025-12-14T00:48:31.783052Z] [scala-execution-context-global-15] processed value: 36
[2025-12-14T00:48:32.786116Z] [scala-execution-context-global-15] processed value: 37
[2025-12-14T00:48:33.789667Z] [scala-execution-context-global-15] processed value: 38
[2025-12-14T00:48:34.795086Z] [scala-execution-context-global-15] processed value: 39
[2025-12-14T00:48:35.798654Z] [scala-execution-context-global-15] processed value: 40
index: 3 end polling stream
index: 4 start polling stream
[2025-12-14T00:48:36.806019Z] [scala-execution-context-global-15] processed value: 41
[2025-12-14T00:48:37.810381Z] [scala-execution-context-global-15] processed value: 42
[2025-12-14T00:48:38.811492Z] [scala-execution-context-global-15] processed value: 43
[2025-12-14T00:48:39.816896Z] [scala-execution-context-global-15] processed value: 44
[2025-12-14T00:48:40.818793Z] [scala-execution-context-global-15] processed value: 45
[2025-12-14T00:48:41.822896Z] [scala-execution-context-global-15] processed value: 46
[2025-12-14T00:48:42.828527Z] [scala-execution-context-global-15] processed value: 47
[2025-12-14T00:48:43.833709Z] [scala-execution-context-global-15] processed value: 48
[2025-12-14T00:48:44.837558Z] [scala-execution-context-global-15] processed value: 49
[2025-12-14T00:48:45.843282Z] [scala-execution-context-global-15] processed value: 50
index: 4 end polling stream
index: 5 start polling stream
[2025-12-14T00:48:46.845557Z] [scala-execution-context-global-15] processed value: 51
[2025-12-14T00:48:47.851207Z] [scala-execution-context-global-15] processed value: 52
[2025-12-14T00:48:48.853295Z] [scala-execution-context-global-15] processed value: 53
[2025-12-14T00:48:49.859490Z] [scala-execution-context-global-15] processed value: 54
[2025-12-14T00:48:50.863081Z] [scala-execution-context-global-15] processed value: 55
[2025-12-14T00:48:51.866392Z] [scala-execution-context-global-15] processed value: 56
[2025-12-14T00:48:52.869021Z] [scala-execution-context-global-15] processed value: 57
[2025-12-14T00:48:53.874905Z] [scala-execution-context-global-15] processed value: 58
[2025-12-14T00:48:54.877553Z] [scala-execution-context-global-15] processed value: 59
[2025-12-14T00:48:55.880214Z] [scala-execution-context-global-15] processed value: 60
index: 5 end polling stream
index: 6 start polling stream
[2025-12-14T00:48:56.884569Z] [scala-execution-context-global-15] processed value: 61
[2025-12-14T00:48:57.886311Z] [scala-execution-context-global-15] processed value: 62
[2025-12-14T00:48:58.891856Z] [scala-execution-context-global-15] processed value: 63
[2025-12-14T00:48:59.893330Z] [scala-execution-context-global-15] processed value: 64
[2025-12-14T00:49:00.898849Z] [scala-execution-context-global-15] processed value: 65
[2025-12-14T00:49:01.902743Z] [scala-execution-context-global-15] processed value: 66
[2025-12-14T00:49:02.908172Z] [scala-execution-context-global-15] processed value: 67
[2025-12-14T00:49:03.909374Z] [scala-execution-context-global-15] processed value: 68
[2025-12-14T00:49:04.909938Z] [scala-execution-context-global-15] processed value: 69
[2025-12-14T00:49:05.913690Z] [scala-execution-context-global-15] processed value: 70
index: 6 end polling stream
index: 7 start polling stream
[2025-12-14T00:49:06.918547Z] [scala-execution-context-global-15] processed value: 71
[2025-12-14T00:49:07.924102Z] [scala-execution-context-global-15] processed value: 72
[2025-12-14T00:49:08.924991Z] [scala-execution-context-global-15] processed value: 73
[2025-12-14T00:49:09.930485Z] [scala-execution-context-global-15] processed value: 74
[2025-12-14T00:49:10.932433Z] [scala-execution-context-global-15] processed value: 75
[2025-12-14T00:49:11.934621Z] [scala-execution-context-global-15] processed value: 76
[2025-12-14T00:49:12.939041Z] [scala-execution-context-global-15] processed value: 77
[2025-12-14T00:49:13.941173Z] [scala-execution-context-global-15] processed value: 78
[2025-12-14T00:49:14.946023Z] [scala-execution-context-global-15] processed value: 79
[2025-12-14T00:49:15.951248Z] [scala-execution-context-global-15] processed value: 80
index: 7 end polling stream
index: 8 start polling stream
[2025-12-14T00:49:16.957514Z] [scala-execution-context-global-15] processed value: 81
[2025-12-14T00:49:17.962589Z] [scala-execution-context-global-15] processed value: 82
[2025-12-14T00:49:18.964295Z] [scala-execution-context-global-15] processed value: 83
[2025-12-14T00:49:19.968126Z] [scala-execution-context-global-15] processed value: 84
[2025-12-14T00:49:20.973614Z] [scala-execution-context-global-15] processed value: 85
[2025-12-14T00:49:21.996175Z] [scala-execution-context-global-15] processed value: 86
[2025-12-14T00:49:23.001488Z] [scala-execution-context-global-15] processed value: 87
[2025-12-14T00:49:24.003584Z] [scala-execution-context-global-15] processed value: 88
[2025-12-14T00:49:25.007103Z] [scala-execution-context-global-15] processed value: 89
[2025-12-14T00:49:26.012730Z] [scala-execution-context-global-15] processed value: 90
index: 8 end polling stream
index: 9 start polling stream
[2025-12-14T00:49:27.017581Z] [scala-execution-context-global-15] processed value: 91
[2025-12-14T00:49:28.020723Z] [scala-execution-context-global-15] processed value: 92
[2025-12-14T00:49:29.025231Z] [scala-execution-context-global-15] processed value: 93
[2025-12-14T00:49:30.026422Z] [scala-execution-context-global-15] processed value: 94
[2025-12-14T00:49:31.028912Z] [scala-execution-context-global-15] processed value: 95
[2025-12-14T00:49:32.029908Z] [scala-execution-context-global-15] processed value: 96
[2025-12-14T00:49:33.033808Z] [scala-execution-context-global-15] processed value: 97
[2025-12-14T00:49:34.037280Z] [scala-execution-context-global-15] processed value: 98
[2025-12-14T00:49:35.038440Z] [scala-execution-context-global-15] processed value: 99
[2025-12-14T00:49:36.041779Z] [scala-execution-context-global-15] processed value: 100
index: 9 end polling stream

47:56 ~ 49:36 => 処理に 100s かかっている (ログの出し方とか sleep の位置の問題などで正確には 101s のはずだが便宜上 100s とする)

今回問題になった実装

パフォーマンス改善として、次のように execute(Pipe)の中で parEvalMapUnordered を利用して非同期化する実装になっていました。

val chunkSize = 10
val parallelSize = 2
StreamProgressLogger.progress(chunkSize, stream1)(
  _.evalMap(_.toList.pure[IO])
   .parEvalMapUnordered(parallelSize)(
     _.traverse { value =>
       IO.sleep(1.second) *> IO.delay(println(s"processed: $value"))
     }
   )
)

ところが、今回更なるパフォーマンス改善のために調査を行なった際、ここの処理が実態として並列化されていないことがわかりました。

実行結果

index: 0 start polling stream
[2025-12-14T00:51:55.801422Z] [scala-execution-context-global-15] processed value: 1
[2025-12-14T00:51:56.810910Z] [scala-execution-context-global-15] processed value: 2
[2025-12-14T00:51:57.814442Z] [scala-execution-context-global-15] processed value: 3
[2025-12-14T00:51:58.819894Z] [scala-execution-context-global-15] processed value: 4
[2025-12-14T00:51:59.824292Z] [scala-execution-context-global-15] processed value: 5
[2025-12-14T00:52:00.828281Z] [scala-execution-context-global-15] processed value: 6
[2025-12-14T00:52:01.834113Z] [scala-execution-context-global-15] processed value: 7
[2025-12-14T00:52:02.839476Z] [scala-execution-context-global-15] processed value: 8
[2025-12-14T00:52:03.844230Z] [scala-execution-context-global-15] processed value: 9
[2025-12-14T00:52:04.849574Z] [scala-execution-context-global-15] processed value: 10
index: 0 end polling stream
index: 1 start polling stream
[2025-12-14T00:52:05.877181Z] [scala-execution-context-global-20] processed value: 11
[2025-12-14T00:52:06.879170Z] [scala-execution-context-global-20] processed value: 12
[2025-12-14T00:52:07.880962Z] [scala-execution-context-global-20] processed value: 13
[2025-12-14T00:52:08.886544Z] [scala-execution-context-global-20] processed value: 14
[2025-12-14T00:52:09.890149Z] [scala-execution-context-global-20] processed value: 15
[2025-12-14T00:52:10.895794Z] [scala-execution-context-global-20] processed value: 16
[2025-12-14T00:52:11.901045Z] [scala-execution-context-global-20] processed value: 17
[2025-12-14T00:52:12.906595Z] [scala-execution-context-global-20] processed value: 18
[2025-12-14T00:52:13.909442Z] [scala-execution-context-global-20] processed value: 19
[2025-12-14T00:52:14.915007Z] [scala-execution-context-global-20] processed value: 20
index: 1 end polling stream
index: 2 start polling stream
[2025-12-14T00:52:15.938972Z] [scala-execution-context-global-21] processed value: 21
[2025-12-14T00:52:16.944180Z] [scala-execution-context-global-21] processed value: 22
[2025-12-14T00:52:17.950109Z] [scala-execution-context-global-21] processed value: 23
[2025-12-14T00:52:18.955897Z] [scala-execution-context-global-21] processed value: 24
[2025-12-14T00:52:19.958521Z] [scala-execution-context-global-21] processed value: 25
[2025-12-14T00:52:20.961081Z] [scala-execution-context-global-21] processed value: 26
[2025-12-14T00:52:21.962554Z] [scala-execution-context-global-21] processed value: 27
[2025-12-14T00:52:22.972440Z] [scala-execution-context-global-21] processed value: 28
[2025-12-14T00:52:23.978286Z] [scala-execution-context-global-21] processed value: 29
[2025-12-14T00:52:24.979712Z] [scala-execution-context-global-21] processed value: 30
index: 2 end polling stream
index: 3 start polling stream
[2025-12-14T00:52:25.999360Z] [scala-execution-context-global-20] processed value: 31
[2025-12-14T00:52:27.003159Z] [scala-execution-context-global-20] processed value: 32
[2025-12-14T00:52:28.005511Z] [scala-execution-context-global-20] processed value: 33
[2025-12-14T00:52:29.011158Z] [scala-execution-context-global-20] processed value: 34
[2025-12-14T00:52:30.015882Z] [scala-execution-context-global-20] processed value: 35
[2025-12-14T00:52:31.021436Z] [scala-execution-context-global-20] processed value: 36
[2025-12-14T00:52:32.025126Z] [scala-execution-context-global-20] processed value: 37
[2025-12-14T00:52:33.026332Z] [scala-execution-context-global-20] processed value: 38
[2025-12-14T00:52:34.028756Z] [scala-execution-context-global-20] processed value: 39
[2025-12-14T00:52:35.034212Z] [scala-execution-context-global-20] processed value: 40
index: 3 end polling stream
index: 4 start polling stream
[2025-12-14T00:52:36.053724Z] [scala-execution-context-global-20] processed value: 41
[2025-12-14T00:52:37.058206Z] [scala-execution-context-global-20] processed value: 42
[2025-12-14T00:52:38.063105Z] [scala-execution-context-global-20] processed value: 43
[2025-12-14T00:52:39.068519Z] [scala-execution-context-global-20] processed value: 44
[2025-12-14T00:52:40.074621Z] [scala-execution-context-global-20] processed value: 45
[2025-12-14T00:52:41.079046Z] [scala-execution-context-global-20] processed value: 46
[2025-12-14T00:52:42.081744Z] [scala-execution-context-global-20] processed value: 47
[2025-12-14T00:52:43.087293Z] [scala-execution-context-global-20] processed value: 48
[2025-12-14T00:52:44.088005Z] [scala-execution-context-global-20] processed value: 49
[2025-12-14T00:52:45.093640Z] [scala-execution-context-global-20] processed value: 50
index: 4 end polling stream
index: 5 start polling stream
[2025-12-14T00:52:46.111686Z] [scala-execution-context-global-16] processed value: 51
[2025-12-14T00:52:47.116672Z] [scala-execution-context-global-16] processed value: 52
[2025-12-14T00:52:48.119541Z] [scala-execution-context-global-16] processed value: 53
[2025-12-14T00:52:49.123085Z] [scala-execution-context-global-16] processed value: 54
[2025-12-14T00:52:50.127176Z] [scala-execution-context-global-16] processed value: 55
[2025-12-14T00:52:51.130441Z] [scala-execution-context-global-16] processed value: 56
[2025-12-14T00:52:52.135865Z] [scala-execution-context-global-16] processed value: 57
[2025-12-14T00:52:53.141538Z] [scala-execution-context-global-16] processed value: 58
[2025-12-14T00:52:54.147132Z] [scala-execution-context-global-16] processed value: 59
[2025-12-14T00:52:55.152529Z] [scala-execution-context-global-16] processed value: 60
index: 5 end polling stream
index: 6 start polling stream
[2025-12-14T00:52:56.163081Z] [scala-execution-context-global-14] processed value: 61
[2025-12-14T00:52:57.168704Z] [scala-execution-context-global-14] processed value: 62
[2025-12-14T00:52:58.171894Z] [scala-execution-context-global-14] processed value: 63
[2025-12-14T00:52:59.177378Z] [scala-execution-context-global-14] processed value: 64
[2025-12-14T00:53:00.182079Z] [scala-execution-context-global-14] processed value: 65
[2025-12-14T00:53:01.185572Z] [scala-execution-context-global-14] processed value: 66
[2025-12-14T00:53:02.191089Z] [scala-execution-context-global-14] processed value: 67
[2025-12-14T00:53:03.191960Z] [scala-execution-context-global-14] processed value: 68
[2025-12-14T00:53:04.195922Z] [scala-execution-context-global-14] processed value: 69
[2025-12-14T00:53:05.200906Z] [scala-execution-context-global-14] processed value: 70
index: 6 end polling stream
index: 7 start polling stream
[2025-12-14T00:53:06.216053Z] [scala-execution-context-global-21] processed value: 71
[2025-12-14T00:53:07.221029Z] [scala-execution-context-global-21] processed value: 72
[2025-12-14T00:53:08.225747Z] [scala-execution-context-global-21] processed value: 73
[2025-12-14T00:53:09.231110Z] [scala-execution-context-global-21] processed value: 74
[2025-12-14T00:53:10.235844Z] [scala-execution-context-global-21] processed value: 75
[2025-12-14T00:53:11.240934Z] [scala-execution-context-global-21] processed value: 76
[2025-12-14T00:53:12.245597Z] [scala-execution-context-global-21] processed value: 77
[2025-12-14T00:53:13.246839Z] [scala-execution-context-global-21] processed value: 78
[2025-12-14T00:53:14.251959Z] [scala-execution-context-global-21] processed value: 79
[2025-12-14T00:53:15.254538Z] [scala-execution-context-global-21] processed value: 80
index: 7 end polling stream
index: 8 start polling stream
[2025-12-14T00:53:16.268225Z] [scala-execution-context-global-14] processed value: 81
[2025-12-14T00:53:17.269104Z] [scala-execution-context-global-14] processed value: 82
[2025-12-14T00:53:18.274049Z] [scala-execution-context-global-14] processed value: 83
[2025-12-14T00:53:19.276544Z] [scala-execution-context-global-14] processed value: 84
[2025-12-14T00:53:20.282046Z] [scala-execution-context-global-14] processed value: 85
[2025-12-14T00:53:21.286924Z] [scala-execution-context-global-14] processed value: 86
[2025-12-14T00:53:22.291442Z] [scala-execution-context-global-14] processed value: 87
[2025-12-14T00:53:23.294064Z] [scala-execution-context-global-14] processed value: 88
[2025-12-14T00:53:24.297071Z] [scala-execution-context-global-14] processed value: 89
[2025-12-14T00:53:25.300914Z] [scala-execution-context-global-14] processed value: 90
index: 8 end polling stream
index: 9 start polling stream
[2025-12-14T00:53:26.312594Z] [scala-execution-context-global-17] processed value: 91
[2025-12-14T00:53:27.318190Z] [scala-execution-context-global-17] processed value: 92
[2025-12-14T00:53:28.323902Z] [scala-execution-context-global-17] processed value: 93
[2025-12-14T00:53:29.329175Z] [scala-execution-context-global-17] processed value: 94
[2025-12-14T00:53:30.331477Z] [scala-execution-context-global-17] processed value: 95
[2025-12-14T00:53:31.332847Z] [scala-execution-context-global-17] processed value: 96
[2025-12-14T00:53:32.338876Z] [scala-execution-context-global-17] processed value: 97
[2025-12-14T00:53:33.344368Z] [scala-execution-context-global-17] processed value: 98
[2025-12-14T00:53:34.349795Z] [scala-execution-context-global-17] processed value: 99
[2025-12-14T00:53:35.351482Z] [scala-execution-context-global-17] processed value: 100
index: 9 end polling stream

51:55 ~ 53:35 => 処理に 100s かかっている → 並列化していないものと処理時間が変わっていない

原因

この実装は execute に流れている要素数が「常に 1」になっていて、 progress は execute(Stream.emit(chunk)) を呼んでいます。つまり execute が受け取る入力は次のとおりです。

  • Stream[IO, Chunk[A]]

  • その Stream は chunk を 1 要素だけ emit して終わる

その結果、Pipe 内の parEvalMapUnordered(parallelSize) は次のような状態になります。

  • “並列化したい Stream 要素”が 1 個しか無い

  • よって並列度を上げても同時実行される単位が増えない

つまり「並列化の演算子を入れた場所は正しそうに見えるが、並列化する対象(Stream 要素)が 1 個しかない」ため、実態として並列数=1 となり速度に効きません。

対応案1:chunk 単位で並列化する

chunk 化の直後に parEvalMapUnordered を適用します。

def parallelProgress[A, B](
  chunkSize: Int,
  parallelSize: Int,
  stream: => Stream[IO, A]
)(
  execute: Chunk[A] => IO[B]
): Stream[IO, B] =
  stream
    .chunkN(chunkSize)
    .zipWithIndex
    .parEvalMapUnordered(parallelSize) { case (chunk, index) =>
      for {
        _ <- IO.delay(println(s"index: $index start polling stream"))
        result <- execute(chunk)
        _ <- IO.delay(println(s"index: $index end polling stream"))
      } yield result
    }

実行結果

index: 0 start polling stream
index: 1 start polling stream
[2025-12-14T00:54:37.561205Z] [scala-execution-context-global-17] processed value: 1
[2025-12-14T00:54:37.561204Z] [scala-execution-context-global-16] processed value: 11
[2025-12-14T00:54:38.563054Z] [scala-execution-context-global-17] processed value: 2
[2025-12-14T00:54:38.563054Z] [scala-execution-context-global-16] processed value: 12
[2025-12-14T00:54:39.564030Z] [scala-execution-context-global-16] processed value: 13
[2025-12-14T00:54:39.564036Z] [scala-execution-context-global-17] processed value: 3
[2025-12-14T00:54:40.569457Z] [scala-execution-context-global-17] processed value: 4
[2025-12-14T00:54:40.569460Z] [scala-execution-context-global-16] processed value: 14
[2025-12-14T00:54:41.572006Z] [scala-execution-context-global-16] processed value: 5
[2025-12-14T00:54:41.571998Z] [scala-execution-context-global-17] processed value: 15
[2025-12-14T00:54:42.577257Z] [scala-execution-context-global-17] processed value: 6
[2025-12-14T00:54:42.577272Z] [scala-execution-context-global-16] processed value: 16
[2025-12-14T00:54:43.581273Z] [scala-execution-context-global-16] processed value: 7
[2025-12-14T00:54:43.581273Z] [scala-execution-context-global-17] processed value: 17
[2025-12-14T00:54:44.586609Z] [scala-execution-context-global-16] processed value: 18
[2025-12-14T00:54:44.586604Z] [scala-execution-context-global-17] processed value: 8
[2025-12-14T00:54:45.592229Z] [scala-execution-context-global-17] processed value: 19
[2025-12-14T00:54:45.592269Z] [scala-execution-context-global-16] processed value: 9
[2025-12-14T00:54:46.597164Z] [scala-execution-context-global-17] processed value: 20
[2025-12-14T00:54:46.597164Z] [scala-execution-context-global-16] processed value: 10
index: 0 end polling stream
index: 1 end polling stream
index: 2 start polling stream
index: 3 start polling stream
[2025-12-14T00:54:47.624894Z] [scala-execution-context-global-17] processed value: 21
[2025-12-14T00:54:47.624941Z] [scala-execution-context-global-18] processed value: 31
[2025-12-14T00:54:48.630583Z] [scala-execution-context-global-18] processed value: 32
[2025-12-14T00:54:48.630583Z] [scala-execution-context-global-17] processed value: 22
[2025-12-14T00:54:49.631715Z] [scala-execution-context-global-17] processed value: 23
[2025-12-14T00:54:49.631715Z] [scala-execution-context-global-18] processed value: 33
[2025-12-14T00:54:50.633388Z] [scala-execution-context-global-18] processed value: 34
[2025-12-14T00:54:50.633446Z] [scala-execution-context-global-17] processed value: 24
[2025-12-14T00:54:51.637520Z] [scala-execution-context-global-17] processed value: 35
[2025-12-14T00:54:51.637520Z] [scala-execution-context-global-18] processed value: 25
[2025-12-14T00:54:52.638957Z] [scala-execution-context-global-18] processed value: 36
[2025-12-14T00:54:52.638962Z] [scala-execution-context-global-17] processed value: 26
[2025-12-14T00:54:53.640003Z] [scala-execution-context-global-18] processed value: 27
[2025-12-14T00:54:53.640115Z] [scala-execution-context-global-17] processed value: 37
[2025-12-14T00:54:54.645701Z] [scala-execution-context-global-17] processed value: 38
[2025-12-14T00:54:54.645701Z] [scala-execution-context-global-18] processed value: 28
[2025-12-14T00:54:55.649485Z] [scala-execution-context-global-18] processed value: 39
[2025-12-14T00:54:55.649480Z] [scala-execution-context-global-17] processed value: 29
[2025-12-14T00:54:56.653413Z] [scala-execution-context-global-17] processed value: 40
[2025-12-14T00:54:56.653852Z] [scala-execution-context-global-18] processed value: 30
index: 3 end polling stream
index: 2 end polling stream
index: 4 start polling stream
index: 5 start polling stream
[2025-12-14T00:54:57.668565Z] [scala-execution-context-global-15] processed value: 51
[2025-12-14T00:54:57.668598Z] [scala-execution-context-global-19] processed value: 41
[2025-12-14T00:54:58.671105Z] [scala-execution-context-global-19] processed value: 52
[2025-12-14T00:54:58.671105Z] [scala-execution-context-global-15] processed value: 42
[2025-12-14T00:54:59.673673Z] [scala-execution-context-global-19] processed value: 43
[2025-12-14T00:54:59.673672Z] [scala-execution-context-global-15] processed value: 53
[2025-12-14T00:55:00.677369Z] [scala-execution-context-global-19] processed value: 54
[2025-12-14T00:55:00.677369Z] [scala-execution-context-global-15] processed value: 44
[2025-12-14T00:55:01.682723Z] [scala-execution-context-global-19] processed value: 55
[2025-12-14T00:55:01.683075Z] [scala-execution-context-global-19] processed value: 45
[2025-12-14T00:55:02.688268Z] [scala-execution-context-global-19] processed value: 56
[2025-12-14T00:55:02.688385Z] [scala-execution-context-global-15] processed value: 46
[2025-12-14T00:55:03.692659Z] [scala-execution-context-global-19] processed value: 47
[2025-12-14T00:55:03.692659Z] [scala-execution-context-global-15] processed value: 57
[2025-12-14T00:55:04.698204Z] [scala-execution-context-global-15] processed value: 58
[2025-12-14T00:55:04.698204Z] [scala-execution-context-global-19] processed value: 48
[2025-12-14T00:55:05.704070Z] [scala-execution-context-global-19] processed value: 59
[2025-12-14T00:55:05.704087Z] [scala-execution-context-global-15] processed value: 49
[2025-12-14T00:55:06.709624Z] [scala-execution-context-global-15] processed value: 60
[2025-12-14T00:55:06.709624Z] [scala-execution-context-global-19] processed value: 50
index: 5 end polling stream
index: 4 end polling stream
index: 6 start polling stream
index: 7 start polling stream
[2025-12-14T00:55:07.720479Z] [scala-execution-context-global-19] processed value: 71
[2025-12-14T00:55:07.720440Z] [scala-execution-context-global-18] processed value: 61
[2025-12-14T00:55:08.723131Z] [scala-execution-context-global-19] processed value: 62
[2025-12-14T00:55:08.723131Z] [scala-execution-context-global-18] processed value: 72
[2025-12-14T00:55:09.728250Z] [scala-execution-context-global-18] processed value: 73
[2025-12-14T00:55:09.728250Z] [scala-execution-context-global-19] processed value: 63
[2025-12-14T00:55:10.731018Z] [scala-execution-context-global-18] processed value: 74
[2025-12-14T00:55:10.731018Z] [scala-execution-context-global-19] processed value: 64
[2025-12-14T00:55:11.736225Z] [scala-execution-context-global-19] processed value: 75
[2025-12-14T00:55:11.736229Z] [scala-execution-context-global-18] processed value: 65
[2025-12-14T00:55:12.740471Z] [scala-execution-context-global-19] processed value: 66
[2025-12-14T00:55:12.740971Z] [scala-execution-context-global-18] processed value: 76
[2025-12-14T00:55:13.746137Z] [scala-execution-context-global-18] processed value: 67
[2025-12-14T00:55:13.746160Z] [scala-execution-context-global-19] processed value: 77
[2025-12-14T00:55:14.749593Z] [scala-execution-context-global-18] processed value: 68
[2025-12-14T00:55:14.749564Z] [scala-execution-context-global-19] processed value: 78
[2025-12-14T00:55:15.753434Z] [scala-execution-context-global-19] processed value: 69
[2025-12-14T00:55:15.753434Z] [scala-execution-context-global-18] processed value: 79
[2025-12-14T00:55:16.756966Z] [scala-execution-context-global-18] processed value: 80
[2025-12-14T00:55:16.756931Z] [scala-execution-context-global-19] processed value: 70
index: 7 end polling stream
index: 6 end polling stream
index: 8 start polling stream
index: 9 start polling stream
[2025-12-14T00:55:17.768614Z] [scala-execution-context-global-16] processed value: 91
[2025-12-14T00:55:17.768614Z] [scala-execution-context-global-23] processed value: 81
[2025-12-14T00:55:18.773515Z] [scala-execution-context-global-23] processed value: 92
[2025-12-14T00:55:18.773515Z] [scala-execution-context-global-16] processed value: 82
[2025-12-14T00:55:19.778257Z] [scala-execution-context-global-16] processed value: 93
[2025-12-14T00:55:19.778257Z] [scala-execution-context-global-23] processed value: 83
[2025-12-14T00:55:20.779737Z] [scala-execution-context-global-23] processed value: 94
[2025-12-14T00:55:20.779655Z] [scala-execution-context-global-16] processed value: 84
[2025-12-14T00:55:21.785096Z] [scala-execution-context-global-16] processed value: 85
[2025-12-14T00:55:21.785078Z] [scala-execution-context-global-23] processed value: 95
[2025-12-14T00:55:22.790370Z] [scala-execution-context-global-16] processed value: 96
[2025-12-14T00:55:22.790370Z] [scala-execution-context-global-23] processed value: 86
[2025-12-14T00:55:23.794793Z] [scala-execution-context-global-16] processed value: 87
[2025-12-14T00:55:23.794786Z] [scala-execution-context-global-23] processed value: 97
[2025-12-14T00:55:24.798454Z] [scala-execution-context-global-23] processed value: 88
[2025-12-14T00:55:24.798454Z] [scala-execution-context-global-16] processed value: 98
[2025-12-14T00:55:25.803290Z] [scala-execution-context-global-16] processed value: 89
[2025-12-14T00:55:25.803291Z] [scala-execution-context-global-23] processed value: 99
[2025-12-14T00:55:26.809916Z] [scala-execution-context-global-23] processed value: 90
index: 8 end polling stream
[2025-12-14T00:55:26.810701Z] [scala-execution-context-global-16] processed value: 100
index: 9 end polling stream

54:37~55:26 49s ≒ 50s → 2 並列で chunk ごとに処理しているので並列数 2 で動作しているので並列数 1 の時の 2 倍で処理されていて正しい。

Pros

  • 並列度 = parallelSize がそのまま意味を持つ(理解しやすい)

  • chunk を「ジョブ単位」にして並列実行できる(運用上のスループット改善が読みやすい)

Cons

  • execute の型が Pipe から Chunk[A]=> IO[B]へ変わる→ 既存の Pipe の実装の修正が必要

  • ログが unordered になる(並列なので index 順に終わらない)ので少し見にくい

2025/12/19 追記 関数のシグネチャを同じにしたまま並列化対応させる方法

  def progressPar[G[x] >: F[x]: Concurrent, A, B](stream: => Stream[G, A], chunkSize: Int, paralleSize: Int)(
    execute: Pipe[G, Chunk[A], B]
  )(using monoid: Monoid[B]): Stream[G, B] = {
    stream
      .chunkN(chunkSize)
      .zipWithIndex
      .parEvalMap(paralleSize) { case (chunk, index) =>
        Concurrent[G].flatMap(
          Concurrent[G].flatMap(logger.info(s"$prefix[$index] start polling stream"))(_ =>
            execute(Stream.emit(chunk)).compile.fold(monoid.empty)(monoid.combine)
          )
        )(result => Concurrent[G].map(logger.info(s"$prefix[$index] end polling stream"))(_ => result))
      }
  }

対応案2:chunk 内側で並列化する(chunk 内の要素を並列)

progress は維持して execute の中で List 化した後に parTraverse します。

StreamProgressLogger.progress(chunkSize, stream1)(
  _.evalMap(_.toList.pure[IO])
   .evalMap(
     _.parTraverse { value =>
       IO.sleep(1.second) *> IO.delay(println(s"processed: $value"))
     }
   )
)

実行結果

index: 0 start polling stream
[2025-12-14T00:55:55.252780Z] [scala-execution-context-global-23] processed value: 7
[2025-12-14T00:55:55.252727Z] [scala-execution-context-global-21] processed value: 6
[2025-12-14T00:55:55.252207Z] [scala-execution-context-global-17] processed value: 1
[2025-12-14T00:55:55.255154Z] [scala-execution-context-global-17] processed value: 10
[2025-12-14T00:55:55.252435Z] [scala-execution-context-global-18] processed value: 4
[2025-12-14T00:55:55.252634Z] [scala-execution-context-global-20] processed value: 3
[2025-12-14T00:55:55.251469Z] [scala-execution-context-global-15] processed value: 2
[2025-12-14T00:55:55.252536Z] [scala-execution-context-global-19] processed value: 5
[2025-12-14T00:55:55.252701Z] [scala-execution-context-global-22] processed value: 8
[2025-12-14T00:55:55.255301Z] [scala-execution-context-global-23] processed value: 9
index: 0 end polling stream
index: 1 start polling stream
[2025-12-14T00:55:56.263448Z] [scala-execution-context-global-22] processed value: 11
[2025-12-14T00:55:56.263868Z] [scala-execution-context-global-19] processed value: 14
[2025-12-14T00:55:56.264018Z] [scala-execution-context-global-20] processed value: 16
[2025-12-14T00:55:56.264178Z] [scala-execution-context-global-17] processed value: 19
[2025-12-14T00:55:56.263622Z] [scala-execution-context-global-23] processed value: 12
[2025-12-14T00:55:56.264197Z] [scala-execution-context-global-20] processed value: 20
[2025-12-14T00:55:56.264054Z] [scala-execution-context-global-18] processed value: 17
[2025-12-14T00:55:56.264030Z] [scala-execution-context-global-19] processed value: 18
[2025-12-14T00:55:56.263915Z] [scala-execution-context-global-15] processed value: 15
[2025-12-14T00:55:56.263893Z] [scala-execution-context-global-22] processed value: 13
index: 1 end polling stream
index: 2 start polling stream
[2025-12-14T00:55:57.270632Z] [scala-execution-context-global-22] processed value: 21
[2025-12-14T00:55:57.270780Z] [scala-execution-context-global-18] processed value: 24
[2025-12-14T00:55:57.270926Z] [scala-execution-context-global-18] processed value: 28
[2025-12-14T00:55:57.270746Z] [scala-execution-context-global-19] processed value: 23
[2025-12-14T00:55:57.270737Z] [scala-execution-context-global-15] processed value: 22
[2025-12-14T00:55:57.270912Z] [scala-execution-context-global-21] processed value: 29
[2025-12-14T00:55:57.270933Z] [scala-execution-context-global-17] processed value: 30
[2025-12-14T00:55:57.270832Z] [scala-execution-context-global-22] processed value: 27
[2025-12-14T00:55:57.270830Z] [scala-execution-context-global-23] processed value: 25
[2025-12-14T00:55:57.270831Z] [scala-execution-context-global-20] processed value: 26
index: 2 end polling stream
index: 3 start polling stream
[2025-12-14T00:55:58.277849Z] [scala-execution-context-global-20] processed value: 31
[2025-12-14T00:55:58.278199Z] [scala-execution-context-global-22] processed value: 33
[2025-12-14T00:55:58.278039Z] [scala-execution-context-global-23] processed value: 32
[2025-12-14T00:55:58.278439Z] [scala-execution-context-global-23] processed value: 34
[2025-12-14T00:55:58.278462Z] [scala-execution-context-global-15] processed value: 36
[2025-12-14T00:55:58.278737Z] [scala-execution-context-global-23] processed value: 39
[2025-12-14T00:55:58.278340Z] [scala-execution-context-global-22] processed value: 40
[2025-12-14T00:55:58.278314Z] [scala-execution-context-global-17] processed value: 35
[2025-12-14T00:55:58.278223Z] [scala-execution-context-global-20] processed value: 37
[2025-12-14T00:55:58.278476Z] [scala-execution-context-global-21] processed value: 38
index: 3 end polling stream
index: 4 start polling stream
[2025-12-14T00:55:59.285761Z] [scala-execution-context-global-21] processed value: 42
[2025-12-14T00:55:59.285800Z] [scala-execution-context-global-23] processed value: 45
[2025-12-14T00:55:59.285788Z] [scala-execution-context-global-17] processed value: 44
[2025-12-14T00:55:59.285761Z] [scala-execution-context-global-20] processed value: 41
[2025-12-14T00:55:59.285890Z] [scala-execution-context-global-23] processed value: 49
[2025-12-14T00:55:59.285809Z] [scala-execution-context-global-15] processed value: 47
[2025-12-14T00:55:59.285844Z] [scala-execution-context-global-21] processed value: 50
[2025-12-14T00:55:59.285800Z] [scala-execution-context-global-18] processed value: 46
[2025-12-14T00:55:59.285788Z] [scala-execution-context-global-22] processed value: 43
[2025-12-14T00:55:59.285810Z] [scala-execution-context-global-19] processed value: 48
index: 4 end polling stream
index: 5 start polling stream
[2025-12-14T00:56:00.288362Z] [scala-execution-context-global-22] processed value: 54
[2025-12-14T00:56:00.288494Z] [scala-execution-context-global-23] processed value: 53
[2025-12-14T00:56:00.288490Z] [scala-execution-context-global-15] processed value: 58
[2025-12-14T00:56:00.288481Z] [scala-execution-context-global-21] processed value: 56
[2025-12-14T00:56:00.288414Z] [scala-execution-context-global-18] processed value: 51
[2025-12-14T00:56:00.288348Z] [scala-execution-context-global-19] processed value: 52
[2025-12-14T00:56:00.288605Z] [scala-execution-context-global-23] processed value: 59
[2025-12-14T00:56:00.288553Z] [scala-execution-context-global-22] processed value: 57
[2025-12-14T00:56:00.288532Z] [scala-execution-context-global-20] processed value: 60
[2025-12-14T00:56:00.288535Z] [scala-execution-context-global-17] processed value: 55
index: 5 end polling stream
index: 6 start polling stream
[2025-12-14T00:56:01.293653Z] [scala-execution-context-global-22] processed value: 64
[2025-12-14T00:56:01.293777Z] [scala-execution-context-global-20] processed value: 61
[2025-12-14T00:56:01.293866Z] [scala-execution-context-global-19] processed value: 66
[2025-12-14T00:56:01.293946Z] [scala-execution-context-global-22] processed value: 65
[2025-12-14T00:56:01.294009Z] [scala-execution-context-global-19] processed value: 69
[2025-12-14T00:56:01.294072Z] [scala-execution-context-global-21] processed value: 70
[2025-12-14T00:56:01.293630Z] [scala-execution-context-global-17] processed value: 62
[2025-12-14T00:56:01.293679Z] [scala-execution-context-global-23] processed value: 63
[2025-12-14T00:56:01.293959Z] [scala-execution-context-global-20] processed value: 68
[2025-12-14T00:56:01.293899Z] [scala-execution-context-global-18] processed value: 67
index: 6 end polling stream
index: 7 start polling stream
[2025-12-14T00:56:02.300519Z] [scala-execution-context-global-18] processed value: 72
[2025-12-14T00:56:02.300737Z] [scala-execution-context-global-17] processed value: 75
[2025-12-14T00:56:02.300866Z] [scala-execution-context-global-19] processed value: 78
[2025-12-14T00:56:02.300967Z] [scala-execution-context-global-22] processed value: 79
[2025-12-14T00:56:02.300604Z] [scala-execution-context-global-23] processed value: 73
[2025-12-14T00:56:02.300594Z] [scala-execution-context-global-20] processed value: 71
[2025-12-14T00:56:02.300876Z] [scala-execution-context-global-17] processed value: 80
[2025-12-14T00:56:02.300873Z] [scala-execution-context-global-15] processed value: 76
[2025-12-14T00:56:02.300781Z] [scala-execution-context-global-18] processed value: 77
[2025-12-14T00:56:02.300743Z] [scala-execution-context-global-21] processed value: 74
index: 7 end polling stream
index: 8 start polling stream
[2025-12-14T00:56:03.304572Z] [scala-execution-context-global-15] processed value: 81
[2025-12-14T00:56:03.305256Z] [scala-execution-context-global-15] processed value: 84
[2025-12-14T00:56:03.305392Z] [scala-execution-context-global-20] processed value: 86
[2025-12-14T00:56:03.305477Z] [scala-execution-context-global-15] processed value: 87
[2025-12-14T00:56:03.305594Z] [scala-execution-context-global-23] processed value: 88
[2025-12-14T00:56:03.304662Z] [scala-execution-context-global-21] processed value: 83
[2025-12-14T00:56:03.304609Z] [scala-execution-context-global-18] processed value: 82
[2025-12-14T00:56:03.305615Z] [scala-execution-context-global-22] processed value: 89
[2025-12-14T00:56:03.305542Z] [scala-execution-context-global-20] processed value: 90
[2025-12-14T00:56:03.305367Z] [scala-execution-context-global-17] processed value: 85
index: 8 end polling stream
index: 9 start polling stream
[2025-12-14T00:56:04.308024Z] [scala-execution-context-global-17] processed value: 91
[2025-12-14T00:56:04.308024Z] [scala-execution-context-global-20] processed value: 92
[2025-12-14T00:56:04.308123Z] [scala-execution-context-global-17] processed value: 93
[2025-12-14T00:56:04.308180Z] [scala-execution-context-global-20] processed value: 94
[2025-12-14T00:56:04.308285Z] [scala-execution-context-global-20] processed value: 95
[2025-12-14T00:56:04.308389Z] [scala-execution-context-global-20] processed value: 97
[2025-12-14T00:56:04.308397Z] [scala-execution-context-global-17] processed value: 96
[2025-12-14T00:56:04.308492Z] [scala-execution-context-global-17] processed value: 98
[2025-12-14T00:56:04.308519Z] [scala-execution-context-global-17] processed value: 99
[2025-12-14T00:56:04.308560Z] [scala-execution-context-global-17] processed value: 100
index: 9 end polling stream

55:54 ~ 56:04 → 10s で動作している。chunk 内の要素は 10 個でその要素を並列で処理、つまり 10 並列で動作しているので 1 並列で動作しているものと比べて 10 倍で処理していて正しい 。

Pros

  • progress の設計(chunk 単位で開始/終了ログ)が活きる → 進捗ログが読みやすい

  • chunkSize が 10 なら最大 10 並列(chunk 内)で処理できる

  • progress の API を変えずに済む

Cons(重要)

  • parTraverse は基本的に fail-fast なので「失敗時にどこまで処理したいか」の方針を決めないと事故りやすい

  • 途中キャンセルにより、副作用(ログ出力・外部 I/O)が途中まで出たり出なかったりする

具体的に parTraverse の動作で困った例を共有します。

問題なく動作する例

val io1 = IO.pure(println("a")) *> IO.delay(println("b"))
val io2 = IO.raiseError(new RuntimeException("exception!"))

val listIO = List(io1, io2)
val ioList = listIO.parTraverse(identity).void
ioList.unsafeRunSync()

result

a
b
Exception in thread "main" java.lang.RuntimeException: exception!
        at fs2$minusparallel$minustest$_.<init>(fs2-parallel-test.sc:145)
        at fs2$minusparallel$minustest_sc$.script$lzyINIT1(fs2-parallel-test.sc:164)
        at fs2$minusparallel$minustest_sc$.script(fs2-parallel-test.sc:164)
        at fs2$minusparallel$minustest_sc$.main(fs2-parallel-test.sc:168)
        at fs2$minusparallel$minustest_sc.main(fs2-parallel-test.sc)
        at parTraverse$extension @ fs2$minusparallel$minustest$_.<init>(fs2-parallel-test.sc:148)
        at unsafeRunSync @ fs2$minusparallel$minustest$_.<init>(fs2-parallel-test.sc:150)

a と b が出力され、exception が発生します。

想定外の動作をする例

val io2 = IO.raiseError(new RuntimeException("exception!"))
val io3 = IO.sleep(1.second) *> IO.pure(println("c")) *> IO.delay(println("d")) //最初に時間のかかる処理を行う想定

result

c
Exception in thread "main" java.lang.RuntimeException: exception!
        at fs2$minusparallel$minustest$_.<init>(fs2-parallel-test.sc:145)
        at fs2$minusparallel$minustest_sc$.script$lzyINIT1(fs2-parallel-test.sc:181)
        at fs2$minusparallel$minustest_sc$.script(fs2-parallel-test.sc:181)
        at fs2$minusparallel$minustest_sc$.main(fs2-parallel-test.sc:185)
        at fs2$minusparallel$minustest_sc.main(fs2-parallel-test.sc)
        at parTraverse$extension @ fs2$minusparallel$minustest$_.<init>(fs2-parallel-test.sc:165)
        at unsafeRunSync @ fs2$minusparallel$minustest$_.<init>(fs2-parallel-test.sc:167)

c は出力されるが、 d は出力されません。

このような現象が起きるのは IO.pure のため、定義時に評価され c は即時に出力されるのに対し、 d は IO の評価で sleep している間にエラーが発生し、IO がキャンセルされるため d はタイミングによっては処理されず出力されません。

対応方法は要件によって異なりますが、次の 3 つの方針があります。

  1. どこかでエラーが発生したら処理を止める。出力が中途半端でも問題なければ、サンプルコードのままでよい。
  2. エラーが発生しても並列に動作している箇所の処理を継続してよいなら、.attempt で最後まで実行する。List の要素が Either[Throw, A]で、1 つでも Left があれば、最後は RailsError へ再度落とすなどを行う。
  3. どこかでエラーが発生したら、すべての処理内容をロールバックしたい(処理で S3 に PUT したものも消したい)場合など、条件がさまざまであれば対応も多様に検討する必要がある。

今回の結論

今回はログの見やすさなどから対応案 2 での対応を行いました。(対応案1でも並列数の設定を変えればパフォーマンス上は対応案2と同等の処理速度が出せます)

あとがき

この問題はいままでは該当の処理の速度が問題になったときに並列数の設定をあげるのと同時に CPU 割り当ても一定量増やすという対応を行なっていました。その結果 CPU 割り当てが増えたことによる影響で CPU スロットリングが解消されて処理能力が向上しておりそれを「並列数を増やしたから性能が向上した」と誤認していたようです。

私がこの担当になったとき CPU 割り当てを増やして、パフォーマンスが向上することを確認した後、処理速度が遅いなぁと感じていたので更なるチューニングのために色々設定を変えて動作確認をしていました。 その中で並列数によって処理時間は変わらないことに気がついて(色々遠回りしましたが)この問題を見つけました。協調性のない行動(?)がたまたま実装の不具合を見つけることもあるんですね。

コード

github.com

Scala で serena MCP を 利用する方法

この記事は FOLIO Advent Calendar 2025 - Adventar 12 日目です。

昨日は tabe さんの記事でした。

自分の思考に合わせて設定した開発環境の紹介 in 2025

serena MCPscala に対応したので社内で「serena MCP for scala ハンズオン」を実施しました。その時の記事を社外公開向けに修正して共有します。

前半は背景解説なので、ハンズオンの内容だけを見たい人は後半まで飛ばしてください。


LLM(Large Language Model)とは


MCP(Model Context Protocol)とは

https://modelcontextprotocol.io/docs/getting-started/intro

  • AI モデルが外部ツールやサービスとインタラクションするための標準規格
  • クライアント / サーバ構成で、JSON-RPC や SSE を用いてツールやデータにアクセスできる
  • MCP アーキテクチャは 3 つのコンポーネントで構成されている

MCP ホスト

  • 1 つまたは複数の MCP クライアントを調整および管理する AI アプリケーション
    例: Claude Code, Codex, Cursor, Gemini, DeepSeek など

MCP クライアント

  • MCP サーバへの接続を維持し、MCP ホストが使用するために MCP サーバからコンテキストを取得するコンポーネント

MCP サーバ

  • MCP クライアントにコンテキストを提供するプログラム
  • 例: SequentialThinking MCPRovo MCPFigma MCP など
  • MCP サーバは Prompts / Resources / Tools の 3 つの要素で構成されている

Prompts

Resources

  • コンテキストデータを提供する
  • いわゆるプロンプトエンジニアリングでの Prompt を MCP の単位で提供する

Tools

  • LLM に公開するアクションを実行する関数を提供する

LSP(Language Server Protocol)とは

エディタ/IDE と言語サーバの間で、補完・定義ジャンプ・リファレンス検索などの言語機能をやり取りするためのプロトコルです。

scala なら Metals が scala の Language Server になります。
今回は Serena が LSP client, Metals が LSP server となります。


Serena とは

https://github.com/oraios/serena

  • セマンティック検索および編集機能を提供する強力なコーディングエージェントツールキット
  • LSP を利用したコード検索/編集機能を持つ
  • 単に LSP を利用するのではなく symbol の検索結果をキャッシュしたり指示を保存したりすることで 大幅な token 削減 が行える。また、副次的効果として LLM の思考が賢くなる
  • Cursor や Windsurf などの支援ツールが VS Code を fork して実現していることと類似の体験を、Serena は MCP として実現している


serena の tool

主要なもの(ユーザーが直接知っておくと良いもの)

  • activate_project
    プロジェクト名またはパスに基づいてプロジェクトをアクティブ化する
  • onboarding
    オンボーディングを実行する(プロジェクトの構造と重要なタスク(テストやビルドなど)を特定する)
  • summarize_changes
    コードベースに加えられた変更を要約するための手順を示す
  • write_memory
    名前付きメモリ(将来の参照用)を Serena のプロジェクト固有のメモリストアに書き込む

全体(tool 一覧)

詳細 - activate_project
プロジェクト名またはパスに基づいてプロジェクトをアクティブ化する - check_onboarding_performed
プロジェクトのオンボーディングがすでに実行されているかどうかを確認する - create_text_file
プロジェクトディレクトリにファイルを作成/上書きする - delete_lines
ファイル内の行の範囲を削除する - delete_memory
Serena のプロジェクト固有のメモリストアからメモリを削除する - execute_shell_command
シェルコマンドを実行する - find_file
指定された相対パス内のファイルを検索する - find_referencing_symbols
指定された場所にあるシンボルを参照するシンボルを検索する(オプションでタイプ別にフィルタリング) - find_symbol
指定された名前/部分文字列を持つ/含むシンボルのグローバル(またはローカル)検索を実行する(オプションでタイプ別にフィルタリング) - get_current_config
アクティブで使用可能なプロジェクト、ツール、コンテキスト、モードなど、エージェントの現在の構成を出力する - get_symbols_overview
指定されたファイルで定義されている最上位レベルのシンボルの概要を取得する - initial_instructions
Serena ツールボックスの使用方法について説明する - insert_after_symbol
指定されたシンボルの定義の末尾にコンテンツを挿入する - insert_at_line
ファイル内の指定された行にコンテンツを挿入する - insert_before_symbol
指定されたシンボルの定義の先頭の前にコンテンツを挿入する - jet_brains_find_referencing_symbols
指定されたシンボルを参照するシンボルを検索する - jet_brains_find_symbol
指定された名前/部分文字列を持つ/含むシンボルのグローバル(またはローカル)検索を実行する(オプションでタイプ別にフィルタリング) - jet_brains_get_symbols_overview
指定されたファイル内のトップレベルシンボルの概要を取得する - list_dir
指定されたディレクトリ内のファイルとディレクトリを一覧表示する(オプションで再帰) - list_memories
Serena のプロジェクト固有のメモリストア内のメモリを一覧表示する - onboarding
オンボーディングを実行する(プロジェクトの構造と重要なタスク(テストやビルドなど)を特定する) - prepare_for_new_conversation
新しい会話を準備するための手順を示す(必要なコンテキストを続行するため) - read_file
プロジェクトディレクトリ内のファイルを読み取る - read_memory
Serena のプロジェクト固有のメモリストアから指定された名前のメモリを読み取る - remove_project
Serena 構成からプロジェクトを削除する - rename_symbol
言語サーバーのリファクタリング機能を使用して、コードベース全体でシンボルの名前を変更する - replace_lines
ファイル内の一定範囲の行を新しい内容に置き換える - replace_regex
正規表現を使用してファイル内の内容を置き換える - replace_symbol_body
シンボルの完全な定義を置き換える - restart_language_server
言語サーバーを再起動する(Serena を経由しない編集が行われた場合に必要になることがある) - search_for_pattern
プロジェクト内のパターンの検索を実行する - summarize_changes
コードベースに加えられた変更を要約するための手順を示す - switch_modes
モード名のリストを提供してモードをアクティブにする - think_about_collected_information
収集した情報の完全性を熟考するための思考ツール - think_about_task_adherence
エージェントが現在のタスクを順調に進めているかどうかを判断するための思考ツール - think_about_whether_you_are_done
タスクが本当に完了したかどうかを判断するための思考ツール - write_memory
名前付きメモリ(将来の参照用)を Serena のプロジェクト固有のメモリストアに書き込む


serena の template

色々存在するが、これが一番有効に作用していると推察されます。

https://github.com/oraios/serena/blob/main/src/serena/resources/config/modes/editing.yml

(一部抜粋)

  • 「Use symbolic editing tools whenever possible for precise code modifications.」
    → 正確なコード変更をするには、可能な限りシンボリック編集ツールを使用してください
  • 「If no editing task has yet been provided, wait for the user to provide one.」
    → 編集タスクがまだ提供されていない場合は、ユーザーが提供してくれるまで待機してください

他にも、正規表現での置換をするときの具体的な戦略などが記載されています。


serena の利用がマッチするユースケース

  • 既存実装の仕様理解
  • 新規実装/実装修正

逆に、次のようなものにはあまり向いていないように推察されます。(serena の特性を活かせない)

  • コードレビュー

Serena ハンズオン

前提: kiro-cli が利用できる環境であること。

依存関係の install

$ brew tap sdkman/tap
$ brew install ghq uv coursier sdkman-cli

$ sdk install java 11.0.28-amzn
$ sdk use java 11.0.28-amzn
$ coursier setup  # install sbt and scala

ハンズオンのゴール

  • お手持ちの LLM に serena MCP を設定して利用できること
  • serena MCP の有効な使い方を理解すること

ハンズオンの概要

  • LLM に serena MCP の設定を追加する
  • scala を利用している repository に対して serena を利用できるように設定する
  • 実際に serena MCPscala を利用している repository に対して symbol 検索を行う

1. kiro-cli と serena の設定

※ 社内でのハンズオンは 2025/11/21 に実施したのですが、直前に amazonq => kiro に変わっていて、気が付かずハンズオンを実施したら amazonq が install できないという問題に出くわしました。 Amazon Q Developer の IDE プラグインから Kiro に乗り換える準備

1.1 serena repository を clone

$ brew install uv

$ ghq get https://github.com/oraios/serena.git

1.2 kiro-cli に serena 用の agent を作成

amazonq の導入方法:

kiro-cliの導入方法: https://aws.amazon.com/jp/blogs/news/introducing-kiro-cli/

$ kiro-cli agent create --name serena-agent

1.3 kiro-cliMCP に serena を登録

※ repository を clone せずに uvx で常に master を利用する方法もありますが、現状 release TAG などで stable なバージョンがわからないので、現状のような固定の commit を利用する形で良いと思います。

※ serena の起動には最大 17 秒ほどかかるので、kiro-cli デフォルトの timeout = 12秒 の設定だと起動せずエラーになる場合があるため、timeout は長めに設定している。

$ kiro-cli mcp add --agent serena-agent --timeout 20000 --name serena --command uv --args "[\"run\", \"--directory\", \"/Users/$USER/ghq/github.com/oraios/serena\", \"serena\", \"start-mcp-server\", \"--context\", \"agent\"]"
✓ Added MCP server 'serena' to agent serena-agent

serena-agent.json の編集

$ vim ~/.kiro/agents/serena-agent.json
{
  "mcpServers": {
    "serena": {
      "type": "stdio",
      "url": "",
      "headers": {},
      "command": "uv",
      "args": [
        "run",
        "--directory",
        "/Users/xxxx/ghq/github.com/oraios/serena",
        "serena",
        "start-mcp-server",
        "--context",
        "agent"
      ],
      "env": {
        "timeout": 200000
      },
      "disabled": false
    }
  }
}

1.4 kiro-cli の agent の設定

  • tools key の value を ["*"]["@serena"] に変更する
  • kiro-cli のデフォルトの tool を無効化して、serena の機能だけ利用する


2. serena の動作設定

参考:
https://github.com/oraios/serena/blob/main/docs/03-special-guides/scala_setup_guide_for_serena.md

2.1 coursier を install する

$ brew install coursier

2.2 serena MCP を利用する scala repository を clone して移動する

$ brew tap sdkman/tap
$ brew install sdkman-cli
$ sdk install java 11.0.28-amzn
$ sdk use java 11.0.28-amzn
$ coursier setup  # install sbt and scala
$ ghq get git@github.com:YuMuuu/fastcsv4s3.git # ※社内のハンズオンでは社内ルールの範囲で実際にプロダクトで利用しているrepositoryを利用したが、外部公開できないため、筆者が昔作成した repository を代わりに利用する。コード量が少なくオンボーディング tool が物足りないので、コード量が多い repository を指定して試すことをおすすめする
$ cd /Users/$USER/ghq/github.com/YuMuuu/fastcsv4s3

2.3 plugins.sbt に sbt-bloop を追加する

// project/plugins.sbt
addSbtPlugin("ch.epfl.scala" % "sbt-bloop" % "2.0.16")

2.4 bloop install を行う

$ sbt -Dbloop.export-jar-classifiers=sources bloopInstall

2.5 compile する

$ sbt compile

2.6 kiro-cli を起動する

$ kiro-cli --agent serena-agent

実行した際に、ブラウザで serena の dashboard が開けば正しく MCP が登録できています。

2.7 project をアクティベートする

# kiro-cli 内で実行
$ execute serena.activate_project /Users/xxxx/ghq/github.com/YuMuuu/fastcsv4s3

2.8 project のオンボーディングを実施する

# kiro-cli 内で実行
$ execute serena.onboarding fastcsv4s3

いくつか tool の使用を許可していくと、最終的に project_overview.md を含むいくつかの project の概要が作成されます。

2.9 serena で symbol アクセス/検索ができるか確認してみよう!

例として CsvRecordDecoderdecodeElems 関数を呼び出している箇所を取得してみます。

  • 使用 tool: serena.find_referencing_symbols
    • namepath: fastcsv4s3.fastCsvDecoderInstance/CsvRecordDecoder/decodeElems
    • relative_path: core/src/main/scala/fastcsv4s3/fastCsvDecoderInstance/CsvRecordDecoder.scala
execute serena.find_referencing_symbols "name_path": "fastcsv4s3.fastCsvDecoderInstance/CsvRecordDecoder/decodeElems", "relative_path": "core/src/main/scala/fastcsv4s3/fastCsvDecoderInstance/CsvRecordDecoder.scala"

これにより、

  • 同一ファイル内の25行目、31行目で利用されていること
  • 利用箇所のシグネチャ

などの情報が取得できます。

LSP 経由で symbol 検索しているので token の利用量が少ない
(serena 無しで kiro-cli の LLM をそのまま利用した場合、 それっぽいファイルを全部 cat して read_file するのを見つけるまで繰り返すため token の使用量が多くなります。
token の使用量が多くなるということはコンテキストが大きくなって LLM の性能が落ちることが推察されます。

2.10 チケットを実装してみよう!

2.10.1 実際のチケットを実装してみよう

※公開用の記事では記載できないが、社内で実施したハンズオンでは、Rovo MCP を追加して知見がない別チームの reposiotry と別チームのチケットを指定し、ある程度の精度の成果物ができることを確認した。


3. Q&A

※ 事前にこの項目を作成したが、実際に質問されることはなかった

metals MCP を直接使えば良いのでは?

※ metals MCPscala の LSP の MCP。 v1.5.3 で導入されました。

  • overview や symbol のキャッシュ機能などがあるので現時点では、serena 経由で使うほうが有用
    • ※と記事執筆時点では思ったが serena MCP の symbol のキャッシュ機能相当のことは semanticdb を利用しているで metals MCP でも同等の事ができるので優位性はないかも
  • 将来的にどうなるのかは(成長の早い業界なので)不明

tool 名がわかりにくい

  • 日本語で 「アクティベートして」「オンボーディングして」「〜という調査をおこなって」「〜のナレッジを元に実装をして」
    などでも指示できる
  • 今回は(ハンズオンで想定外の動作をしないように)厳密に tool 名や argument を指定している
  • (日本語指示の画像略)
  • memory の抜粋(画像略)

既存の問題点/デメリットは?

  • LSP 未対応の言語には対応できない
    • 例えば(LSP 未実装の)自作言語や古い言語の一部は serena で対応できない
  • serena の機能として overview, write_memory などの平文で repository の情報や実施タスクについて保存する機能がある
    • この機能が他の SequentialThinking MCP やそれに類似したものと競合してしまい、やりたいことがあべこべになることがある
    • この辺りの連携をうまくすると、他の MCP との連携や、現時点で相性が微妙な LLM への対応などが可能になって serena の有用性がより向上すると推察される
  • 指示するタスクによっては symbol 検索をうまく利用できないことがある
    • symbol 検索が利用できないと serena MCP を利用する大きなモチベーションの 1 つの token 削減が望めないので旨みがなくなる
    • これは「人間が IDE の補完を利用しながらコードを書く」みたいな行動そのものをまだエミュレートできていないのが原因だと推察される
    • serene MCP に LSP の CompletionInlayHintsHover API に対応する tool が存在しないので、その機能を追加して tool に対応した専用の template を作成すればより内部的に LSP を利用した動作をすると考えられる(時間があったら試してみます)
  • scala の話に限定すると)Metals 側の LSP 対応が一部追いついておらず、symbol 検索で一部検索対象にならないものがある
    例えばcase class の field など それらは Metals 側が対応するまでは利用できない

serena MCP の設定を削除したい

$ kiro-cli mcp remove --name serena

✓ Removed MCP server 'serena' from global config (path ...

Reference

Scala 3 による依存型の実装の試み

Scala 3 による依存型の実装の試み

環境

ThisBuild / scalaVersion := "3.6.4"
scalacOptions ++= Seq("-language:experimental.dependent")

依存型とは

依存型とは値に依存する型(値によって変化する型)である。

依存型があると型の表現力が高くなり、定理証明支援系で行うような型での証明が行うことができる。

依存型をサポートしている言語には idris2, coq, lean、また Racket の言語拡張として curpie がある。

Scalaで実装されている依存型

Scala には依存型と名のつくものに経路依存型や依存型関数などがある。また、リテラル型/リテラルベースシングルトン型(SIP-23)を指して依存型と呼ぶこともある。

但し idris2 のように素直に依存型をサポートしているという状況ではない。

本記事では現状の Scala3 言語機能を利用し依存型の定義と実用的な利用ができるかを検証する。

また実装の比較の為に idris2 のコードを記載する。

Lists With Length

依存型で実装される標準的なデータ型として長さを持つ List 型があり、慣例的に Vector 型と呼ぶ。

ここでは実装の簡易さの為に内部実装は Tuple を利用する。

Idrisでの定義

vec : (n : Nat ** Vect n Int)
vec = (2 ** [3, 4])

Scalaでの定義

import scala.compiletime.ops.int.-
type Vec[R, N <: Int] = N match {
  case 0 => EmptyTuple
  case _ => R *: Vec[R, N - 1]
}

NOTE: TypeLambdaコンパイル時オペレータなど Scala3 で追加された機能を利用しており楽しい

使用例

val v0: Vec[Int, 0] = EmptyTuple
val v1: Vec[Int, 1] = 1 *: EmptyTuple
val v2: Vec[Int, 2] = 1 *: 2 *: EmptyTuple
val v3: Vec[Int, 3] = Tuple3(1, 2, 3)
val v4: Vec[Int, 4] = (1, 2, 3, 4)
val vm1: Vec[Int, -1] = ??? //長さが0未満のVectorの値は作成できない

ただし上記の例は singleton types に対しての primitive operations を利用した依存型に過ぎず、全ての値を対象とする依存型を実現できることは示せていない。(他の言語でも依存型の限定的なサポートのみにとどまっているものは多いし、現状の Scala では本当の依存型をサポートするのは難しいと思う)

Π型とΣ型

依存型にはΠ型とΣ型というものがある。

https://ja.wikipedia.org/wiki/%E4%BE%9D%E5%AD%98%E5%9E%8B

Σ型とは二番目の型 P が一番目の型 A によって変化する Pair の型

Idrisでの定義

data DPair : (a : Type) -> (p : a -> Type) -> Type where
  MkDPair : {p : a -> Type} -> (x : a) -> p x -> DPair a p

Scalaでの定義

case class DPair[A, P[_ <: A], X <: A](x: X, px: P[X])

使用例

type MyVecPair[X <: Int] = DPair[Int, [n <: Int] =>> Vec[Int, n], X]
val mvp0: MyVecPair[0] = DPair(0, EmptyTuple)
val mvp1: MyVecPair[1] = DPair(1, Tuple1(1))
val mvp2: MyVecPair[2] = DPair(2, (1, 2))
val mvp3: MyVecPair[3] = DPair(3, (1, 2, 3))

Π型とは戻り値の型が引数の値に依存する型

type Pi[A, P[_ <: A]] = (x: A) => P[x.type]

使用例

type VecBuilder = Pi[Int, [n <: Int] =>> Vec[Int, n]]
val buildVec: VecBuilder = (n: Int) => n match {
  case 0 => EmptyTuple.asInstanceOf[Vec[Int, n.type ]]
  case 1 => Tuple1(1).asInstanceOf[Vec[Int, n.type ]]
  case 2 => (1, 2).asInstanceOf[Vec[Int, n.type ]]
  case 3 => (1, 2, 3).asInstanceOf[Vec[Int, n.type ]]
}

行列とテンソル

次数付きVectorが定義できることによって、行列、テンソル積を要素数や次元数に依存して制約する型を定義することができる

行列の定義

type Matrix = (m: Int, n: Int) => Vec[Vec[Int, m.type], n.type]

テンソル積の定義

// type Tensor2[T] = (m: Int, n: Int) => Matrix[T](m, n)

依存型がうまく利用できる例として本来は以下の例を紹介したかったが、コンパイラの未実装部分を呼び出してしまうようでコンパイルが通らなかった

// val t3x2: Tensor2[Int](3, 2) = Tuple2(Tuple3(1, 2, 3), Tuple3(4, 5, 6))
// type Tensor2[T] = (m: Int, n: Int) => Matrix[T](m, n)
// [error] 50 |  type Tensor2[T] = (m: Int, n: Int) => Matrix[T](m, n)
// [error]    |                                        ^^^^^^^^^^^^^^
// [error]    |                                        Not yet implemented: T(...)

https://github.com/scala/scala3/blob/f4847cca6e10886e4ca3658b81fcf5797fe088d8/compiler/src/dotty/tools/dotc/typer/Typer.scala#L2563

Scala3の依存型の構文レベルの実装

Scala3 には現時点では依存型の実装はない。 しかしテストケースに Deptype があるので依存型自体もそのうち Scala3 でネイティブに対応されそう。 但し SIP にも開発計画を探しても出てこないのでいつ実装されるのかは不明。

NOTE: 5 月 4 日 追記

TypedAppliedType は次の MR で実装されていた。

https://github.com/scala/scala3/pull/22543

TermLambdaType が実装されれば上記の Matrix や Tensor2 も利用可能になるはず。

まとめ

Scala3 で導入された新しい機能を利用することによって Vec, DPair, Pi などの依存型をサポートする言語で利用できる型の作成と値をそれらの型で型付けることができた。

Vec では型引数で指定した添字の要素数しか要素が無い List 構造を作成し、値に依存した型の具体例を Scala 上で示すことができた。

行列やテンソル積など複雑な型は TypedAppliedTypeTermLambdaType が未実装の影響で定義できなかった。

また Scala3 のテストコードに依存型をテストするコードがあるので将来的に依存型の構文レベルでの対応が進む可能性がある。

簡易的なConventional Commitsを実現するためのzshプラグインの設計および実装

はじめに

これはFolioアドベントカレンダー2024の14日目の記事です。

adventar.org

今回は、簡易的なConventional Commitsを実現するための zsh-select-commit-prefix というzshプラグインについて話します。

アブストラク

本稿では、ソフトウェア開発におけるコミットメッセージ仕様「Conventional Commits」を簡素化し、日常的な利用での効率向上を図るzshプラグインの設計と実装について述べる。既存のConventional Commits関連ツールは、詳細な構文やリポジトリ単位の設定を求めるものが多く、個人利用や簡易なプロジェクトには過剰となり得る。本研究では、zsh プラグインとして軽量なツールを開発し、1行形式のメッセージ作成、Node.jsランタイムの排除、他ツールとの競合回避を実現した。提案ツールは、簡潔かつ直感的な操作性を備えており、特に個人プロジェクトや小規模チームでの利用に適している。また、拡張可能性を考慮した設計により、今後のAI生成メッセージ対応や他ツールとの統合も見据えている。本稿では、設計背景、技術的工夫、実装方法、そして今後の展望について詳細に解説する。 generated by ChatGPT

1.ConventionalCommitsとは

Conventional Commitsは、人間と機械が読みやすく、意味のあるコミットメッセージにするための仕様 です。この仕様は、Angularプロジェクトのコミットメッセージ規約 Angular Commit Guidelines に基づいて設計されています。

この仕様にはそれぞれ MUST/MUST NOT/REQUIRED/SHALL/SHALL NOT/SHOULD/SHOULD NOT/RECOMMENDED/MAY/OPTIONALの各キーワードが指定されており、その仕様の要件レベルが定義されています。

ここでは具体的な仕様についての説明は省きますが Conventional Commits の具体的な利用方法や適用はO2氏のこちらのスライドが分かりやすいです

コミットメッセージ規約 「Conventional Commits」を導入してみよう! / Let's use Conventional Commits - Speaker Deck

Conventional Commitsは、開発者間でコミットの目的や内容を明確に伝えるために重要です。

Conventional Commits 1.0.0

www.conventionalcommits.org

Angular Commit Guidelines

github.com

Git Commit Message Conventions · GitHub

2.Conventional Commitsに対応したツール

2.1 専用ツール

2.1.1 conventional-commits

PHPライブラリであり、コミットメッセージの作成および検証を行います

https://github.com/ramsey/conventional-commits

2.1.2 cz-cli

czコマンドを通じて、対話形式でコミットメッセージを生成します

https://github.com/commitizen/cz-cli

2.2 commit message の check・lint tool

その他commit messageに関連するツールを紹介します。

2.2.1 commitlint

commitlintは、コミットメッセージが規約に準拠しているかをLintするツールです

commitlint.js.org

www.npmjs.com

www.npmjs.com

2.2.2 opencommit

AIを活用し、コミットメッセージを自動生成を行うツールです

github.com

2.2.3 gitmoji

絵文字を活用してコミットメッセージに視覚的な情報を付加するツールです

gitmoji.dev

3.現状のツールの課題

現在使用されているConventional Commits関連ツールには、以下のような課題が存在します。

1. ランタイム依存性

多くのツールがNode.jsを導入する必要があり、Node.js を利用しないRepositoryでもツールのためにNode.jsの設定をする必要があります。

2. リポジトリ単位の設定

toolの設定がリポジトリに紐づくことが多く、個人レベルでのカスタマイズが難しいです。

3. pre-commitフックへの依存

lintを含むツールの場合pre-commit時にlintを行う場合が多く、意図的に規約外のコミットを行う際にgit commit --no-verify などでlintを回避する使用する必要があり手間がかかります。

4. コミット規約の厳格さ

業務や趣味の開発では、3行構成の詳細なコミットメッセージは過剰であり、1行構成の簡易な形式が望ましい場合があります。業務だとPRのdiscriptionや、社内wikiに詳細仕様をまとめるなどcommit message 以外に情報集約を行うことも多いです。

5. 他ツールとの競合

対話式のツールの場合に他のGit関連ツール(例: tigやscm_breeze)と競合し、利用が制約されるケースがあります。またopencommit、gitmojiなどのcommit関連のツールを利用すると規約に合わなくなってしまう問題があります。

4.改善案と実現方法

これらの課題を解決するために、以下の改善案を提案します。

1. 個人設定に紐づくツール

リポジトリ設定に依存しない設計にすることで Repositoryでの設定を不要にします。

2. 1行形式のコミットメッセージ

Conventional Commitsの仕様でMUSTであるPrefixのみを指定するようにし、必要に応じてメッセージを追加する形式を採用します。

3. ランタイムの簡略化

zshプラグインとして実装し、Node.jsランタイムの依存を排除します。

4. 他ツールとの競合回避

任意利用可能な設計とし、既存のツールやエイリアス設定と干渉しないようにする。

5. 作成したツールの紹介

github.com

作成したツール「zsh-select-commit-prefix」は以下の機能を持ちます。

  • zshプラグイン 形式

    Sheldonなどのプラグインマネージャ経由で導入可能です。

  • Prefixの選択

    pecoを使用してPrefixを選択し、その結果をコミットメッセージに反映します。

  • 統合性

    Prefixを選択後、git.default.editor で指定したエディタが開きcommit messageの編集ができます。

動作例

コマンドを実行し prefixを 選択すると git commit 時と同様の git.default.editor で指定したエディタが表示され、通常の編集操作が可能となります。

スクショ

6. 技術的な工夫

以下のコマンドにより、指定したメッセージを渡しつつ、エディタを開くことができます。

$ git commit -e -m "${prefix}: "

7. 今後の展望

以下の機能追加を検討しています。

  1. opencommit対応

  2. AI生成メッセージを組み込む機能の追加

$git commit -e -m "${prefix}: ${generatedMessage}"

のように指定すれば 統合可能です。

  1. tig対応

git-commit--select-prefixtig で利用するユーザ定義関数を作成すれば統合可能です。

8. 結論

本稿では、Conventional Commitsを簡易化するためのzshプラグインの作成について話しました。

このツールは、個人利用に適した柔軟性を持ちつつ、既存ツールとの干渉を最小限に抑える設計となっています。

9. おわりに

もう少しまともな内容の記事を書こうとしていたのですが諸々でぽしゃったために、代案でこの記事を執筆しました。

去年は大遅刻をしたので今年は日付通りに投稿できてホッとしています。

Node.jsで画像を生成するAWS Lambdaの開発環境改善

これは、FOLIO Advent Calendar 2023 の xx 日目の記事として投稿しようと思ったものです。。。。(忙しくて気がついたら 2 月になっていました)

今回は私がメンテナンスを引き継いだグラフを png 出力する AWS lambda の改善例を共有します。

背景

昨年の弊社のアドベントカレンダーに該当のリポジトリの紹介があるので、今回の記事では次のブログの内容を前提として内容を進めていきます。

zenn.dev

この記事で紹介するコードは次になります。

github.com

このlambdaは json を s3 に put した場合それをトリガーし lambda を発火させ png 画像が生成させるものになります。

  • s3 に put する json の例
[
  { "label": "20代", "proportion": "0.15", "color": "#d53e4f" },
  { "label": "30代", "proportion": "0.23", "color": "#fc8d59" },
  { "label": "40代", "proportion": "0.21", "color": "#fee08b" },
  { "label": "50代", "proportion": "0.18", "color": "#ffffbf" },
  { "label": "60代", "proportion": "0.15", "color": "#e6f598" },
  { "label": "70代以上", "proportion": "0.08", "color": "#99d594" }
]
  • 出力されるグラフの例 graph

課題

このリポジトリの引き継ぎを受けた際に、node-canvas が jest と相性が悪く、描画関連の自動テストが一部整備できていないこと、またローカルで描画の動作確認ができない(毎回開発環境にデプロイしないと動作確認できない)という課題の共有がありました。

また、node-canvas というライブラリは内部的にネイティブライブラリを利用しているため、CI で TypeScript のトランスパイルは成功しても、lambda の実行時にエラーが発生することもあります。そのため、CI での lambda の実行テストも必要という話になりました。

つまりこのリポジトリを運用するにあたり、次の手段を確立する必要がありました。

  • ローカルでグラフの表示の動作確認を行う手段
  • ローカルおよび CI 環境で lambda を実行し、成果物の PNG ファイルを確認する手段

上記の課題を解決するために、開発環境改善のために Storybook の導入と、lambda の動作確認のために Docker で AWS Lambda と LocalStack の構築を行いました。

環境

  • nodejs v16
  • storybook v7.0.9

1. storybook(+webpack)の導入

UI カタログツール

storybook とは

storybook は web アプリケーションでページと UI コンポーネントを分離して、コンポーネントを独立した状態で開発できるようにする UI カタログツールです。

storybook for HTML とは

静的サイトを UI カタログとして管理できるものです。今回は lambda のグラフ生成部分(html element の段階)を UI コンポーネントとみなして storybook を利用します。 本来は html ファイルをカタログとして利用するプラグインですが、今回は html ファイルではなく、ts ファイル内の html element をカタログとして利用する方法を紹介します。

storybook の導入

現状のコードベースとして、esmodule と typescript が利用されています。しかし esmodule と typescript と今回追加したい webpack は相性が悪いという問題がありました。 これはwebpack の module の解決方法と esm の import の書き方が合わないのが原因です。issue にある次の対応を行い、module の解決を行えるように対応を行いました。

github.com

html element をカタログとして利用する方法

reander 関数が呼ばれた際に

  1. 2 で追加される element を削除する(再レンダリング時に 2 だけだとどんどん要素が追加されてしまう)
  2. 該当の tsxコンポーネントを document.body の子要素に追加する

という関数を渡すことで storybook でライフサイクルも含め UI カタログとして利用できるようになります

サンプルコードは以下になります

github.com

リファクタリング

lambda では jsdom という Node.js 環境に HTML DOM API を追加するライブラリを利用しています。その DOM の canvas に node-canvas という Web Canvas API 互換のあるAPIを利用できるようにするライブラリを利用して グラフを描画しています。 しかし StoryBook(Webpack) はブラウザ上で動作するものなので Node.js でしか動作しない jsdom や node-canvas を利用している ts ファイルをそのまま利用することは出来ません。 グラフ生成部分を引数を渡すと element が返ってくる箇所を関数として切り出し、Node.js 上だと node-canvas、StoryBook だとグローバルな html element の子要素としてそれぞれ利用できるように修正を行いました。

※実際には module の top level に関数を定義しているため Abstract Classでは無いですがmoduleの依存関係のイメージはこのようになっています

また syorybook の text の欄にある json を編集するとグラフを再レンダリングするようにし、値によってグラフがどのように表示されるかもわかるようにしました。

実際の動作

実際に生成した storybook は以下になります。

また GithubPages に deploy しているので是非 jsonvalue を変えて、グラフが再描画される事を確認してみて下さい。業務ではもう少し複雑なグラフを生成しているので、エッジケースの確認などに便利に利用できます。

yumuuu.github.io

しかしこの改善を進めながら検証を進めた結果、jsdomと webpack(chromeで見ているので多分Blink?)は HTML のレンダリングエンジンが異なるため、storybook で表示されるものと実際に生成される PNG ファイルには細かいですが差分が存在する事がわかりました。そのため、storybook 上での表示内容を比較してのビジュアルリグレッションテストを行うことはできないという結論に至りました。

ローカルで chart の表示を確認できるようになり、開発はスムーズに行えるようになりました。しかし、ローカルでの表示内容が本番環境での表示内容と異なる場合、storybook の表示内容の差分を見て変更内容が正しいと判断できません。なので、lambda で生成される画像を確認する別の方法を確立する必要がありました。

2. local での lambda の動作確認

lambda が docker image で作成されているので local で lambda の docker container を起動し、同じく local で起動させた localstack の s3 に接続することで確認を行うことができます。

localstackのs3に接続する方法

localstack の s3 にアクセスするには path-style でアクセスするしかななく、path-style でアクセスするには S3Client のインスタンスを生成する際に forcePathStyle: true, を設定する必要があります。

new S3Client({
  credentials: fromIni({ profile: "local" }),
  region: "ap-northeast-1",
  endpoint: "http://localstack:4566",
  forcePathStyle: true,
});

また今回は s3 への put をトリガーにして lambda を発火させるわけではなく、s3 の put 時の notification event を lambda の引数にして lambda を実行します。今回は次の json を lambda の入力とします。

https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/with-s3.html

notification eventの例

{
    "Records": [
      {
        "eventVersion": "2.1",
        "eventSource": "aws:s3",
        "awsRegion": "us-east-2",
        "eventTime": "2019-09-03T19:37:27.192Z",
        "eventName": "ObjectCreated:Put",
        "userIdentity": {
          "principalId": "AWS:AIDAINPONIXQXHT3IKHL2"
        },
        "requestParameters": {
          "sourceIPAddress": "205.255.255.255"
        },
        "responseElements": {
          "x-amz-request-id": "D82B88E5F771F645",
          "x-amz-id-2": "vlR7PnpV2Ce81l0PRw6jlUpck7Jo5ZsQjryTjKlc5aLWGVHPZLj5NeC6qMa0emYBDXOo6QBU0Wo="
        },
        "s3": {
          "s3SchemaVersion": "1.0",
          "configurationId": "828aa6fc-f7b5-4305-8584-487c791949c1",
          "bucket": {
            "name": "local-bucket",
            "ownerIdentity": {
              "principalId": "A3I5XTEXAMAI3E"
            },
            "arn": "arn:aws:s3:::lambda-artifacts-deafc19498e3f2df"
          },
          "object": {
            "key": "graph/sample.json",
            "size": 1305107,
            "eTag": "b21b84d653bb07b05b1e6b33684dc11b",
            "sequencer": "0C0F6F405D6ED209E1"
          }
        }
      }
    ]
  }

実行方法

次の方法で動作を確認できます。

  1. lambda の docker を build する
  2. lambda と localstack の docker を起動する
  3. s3 の bucket を作成する
  4. json ファイルを s3 に入稿する
  5. lambda を実行する
  6. 実行結果生成された png ファイルを local にコピーする

具体的は次の script を実行します。

#!/bin/bash
set -e

LOCALSTACK_HOST="0.0.0.0"
LOCALSTACK_PORT="4566"
export AWS_ACCESS_KEY_ID=dummy 
export AWS_SECRET_ACCESS_KEY=dummy 


docker-compose build
docker-compose up -d 
pwd
sh ./setup/s3.sh


# lambda で読み込む file を予め削除する
echo "delete file"
aws --region=ap-northeast-1 --endpoint-url="https://$LOCALSTACK_HOST:$LOCALSTACK_PORT" --no-verify-ssl s3 rm s3://local-bucket/graph--recursive

# json file の入稿
echo "upload file"
aws --region=ap-northeast-1 --endpoint-url="https://$LOCALSTACK_HOST:$LOCALSTACK_PORT" --no-verify-ssl --profile localstack s3 cp ./../sample/sample.json s3://local-bucket/graph/sample.json

# lambda の実行
echo "running lambda"
curl -XPOST http://localhost:9000/2015-03-31/functions/function/invocations -d  @./test/sample_event_valid.json


# png file のダウンロード
aws --region=ap-northeast-1 --endpoint-url="https://$LOCALSTACK_HOST:$LOCALSTACK_PORT" --no-verify-ssl --profile localstack s3 cp s3://local-bucket/graph/sample.png ./sample.png
open ./sample.png

改善を行ったことでできるようになったこと

改善を行った結果、ローカル環境でグラフ生成ロジックの確認や、Lambda を実行してグラフを簡単に確認できるようになりました。また、Lambda のリポジトリや Node.js に詳しくないメンバーでも、動作確認や修正が容易に行えるようになりました。

さらに、副次的な効果として、画像を簡単に生成できるようになったため、デザインチームとのコミュニケーション(細かい修正の検討など)が容易に行えるようになりました。さらに、グラフを Storybook で確認できるため、「デザイン仕様とグラフのロジックの生成結果が 1px ずれている」などの細かい差分も確認できるようになりました。

注意点

上記の localstack を利用した local での動作確認方法は nodejs 16 の image だと実行できるのですが nodejs18 だと実行できません。(node.js16 の EOL は 2023.09 なのでこの記事は賞味期限切れになる前に出したかったという事情があります。)

具体的には node-canvas が依存しているネイティブライブラリのバージョンが lambda/nodejs:18(amazonlinux2)とは合わず実行時エラーになります。node18 で利用するには ric を利用して custom image を作成する必要があります。

その他

引き継ぎドキュメントや実際の引き継ぎの際に現状の課題などを共有があり、実際の開発/運用方法がにわかったので当時と事情が変わった際にどのように改善を行うと効果的かがすぐわかりました。前任者の丁寧な仕事にとても感謝しています。

また去年のアドベントカレンダーに引き継ぎ前の状態を公開出来るように加工した記事とサンプルコードがあったのでこの記事もとても書きやすかったです。

まとめ

  • AWS lambda で画像を生成するリポジトリに UI カタログを導入しました
  • AWS lambda を local で動作確認できるように localstack などの docker 環境を構築しました
  • 開発環境改善を行ったことで開発速度が向上しました

リファレンス

営業日カレンダーモデルの実装

これは、FOLIO Advent Calendar 2022 の11日目の記事です。

営業日カレンダーモデル

zenn.dev

この記事で提案されている営業日カレンダーモデルの parser を実装してみました。

*趣味で作っただけなので業務利用は行ってません

営業日カレンダーモデルとは

営業日が2つ以上存在するドメインで利用できるデータモデルです。

あるA市場の営業日からn日後の日付にもっとも近いB市場の営業日からn日後の日付、のような複雑な日付を表現することができます。詳しくは元記事を参照して下さい。

環境

sacla と scala-parser-combinators を利用します。

scalaVersion := "2.13.8"
libraryDependencies += "org.scala-lang.modules" %% "scala-parser-combinators" % "2.1.1"

元の規則の営業日カレンダーモデルの実装

オリジナルのEBNF

binop    ::= '+'
           | '-'
castop   ::= '_'
           | '^'
cal      ::= 'jp'
           | 'us'
           | 'c'
           | 'jp & us'
           | 'jp | us'
expression
         ::= ( ( "T" | expression ) cast_op ) cal
           | ( "(" expression ")" )
           | expression binop num

まずはこの EBNF の規則に従って lexer を実装してみます。以下が実装例です。

object BusinessCalendarParser extends JavaTokenParsers with RegexParsers {
  private def binop = "+" | "-"
  private def castop = "_" | "^"
  private def cal: Parser[String] = "jp&us" | "jp|us" | "jp" | "us" | "c"
  private def integer = wholeNumber
  private def T: Parser[String] = """[0-9]{4}/[0-9]{2}/[0-9]{2}""".r ^^ { _.toString } //
  private def expr: Parser[Any] = ((T | expr) ~ castop ~cal) | ("(" ~ expr ~ ")") | (expr ~ binop ~ integer)

  def apply(input: String): Either[String, Any] = parseAll(expr, input) match {
    case Success(c, n) => Right(c)
    case Failure(e, _) => Left("FAILURE: " + e.toString)
    case Error(e, _) => Left("ERROR: " + e.toString _)
  }
}

この実装では入力によっては簡単にスタックオーバーフローになってしまいます。

scala-parser-combinators は LL(1)の構文しか解析できず、元の定義が LL(1)文法ではないからです。

例えば (2022/12/11_jp&us+1) を入力するとスタックーオーバーフローになります。

新しい規則の営業日カレンダーモデルの実装

元の定義は LL(1)ではないので、実装するには元の定義を変更する必要があります。

以下が修正した新しい規則です。

新規則のEBNF

 binop    ::= '+'
            | '-'
 castop   ::= '_'
            | '^'
 cal      ::= 'jp'
            | 'us'
            | 'c'
            | 'jp&us'
            | 'jp|us'
 expr     ::= term cast_op cal [ binop num ]
 term     ::= "(" expr ")" | "T"

また元記事だと表記ゆれがあったので jp & us -> jp&us, jp | us -> jp|us としています。記事の最後の例題には半角スペースが無かったのでそちらに合わせました。

この規則だとバックトラックが必要になる例を回避できます。

新規則のBNFに基づいた実装

以下が新規則の BNF に基づいた lexer、parser の実装です。(parser の実装も一緒に行っています)

以下が修正した EBNF の定義通りの lexer を実装です。

object BusinessCalendarParser extends JavaTokenParsers with RegexParsers {
  private def binop = "+" | "-"
  private def castop = "_" | "^"
  private def cal: Parser[String] = "jp&us" | "jp|us" | "jp" | "us" | "c"
  private def integer = wholeNumber
  private def T: Parser[String] = """[0-9]{4}/[0-9]{2}/[0-9]{2}""".r ^^ { _.toString }
  private def expr: Parser[Any] = term ~! castop ~! cal ~! (binop ~! integer).?
  private def term: Parser[Any] = ("(" ~! expr ~! ")") | T

  def apply(input: String): Either[String, Any] = parseAll(expr, input) match {
    case Success(c, n) => Right(c)
    case Failure(e, _) => Left("FAILURE: " + e.toString)
    case Error(e, _) => Left("ERROR: " + e.toString _)
  }
}

case classへの変換

規則に従ってパースができることがわかったのでパース結果を扱いやすいように case class に変換します。

まずは Token を定義します。

object Token {
  sealed trait BinOp
  case object Plus extends BinOp
  case object Minus extends BinOp

  sealed trait CastOp
  case object Hat extends CastOp
  case object UnderBar extends CastOp

  sealed trait Cal
  case object JP extends Cal
  case object EN extends Cal
  case object Center extends Cal //cはCenterだと思っていたらCalendarのcだった。。。
  case object JPAndEN extends Cal
  case object JPOrEn extends Cal

  case class Num(int: Int)

  sealed trait Expr
  case class BusinessDayCalendar(
      expr: Expr,
      castOp: CastOp,
      cal: Cal,
      maybeBinOp: Option[BinOp],
      maybeInt: Option[Num]
  ) extends Expr
  
    case class Calendar(localDate: LocalDate) extends Expr
  object Calendar {
    def fromString(date: String): Calendar = Calendar(
      LocalDate.parse(
        date,
        DateTimeFormatter
          .ofPattern("uuuu/MM/dd")
          .withResolverStyle(ResolverStyle.STRICT)
      )
    )

以下が parser の実装です。

import businesscalendarparser.Token._
object BusinessCalendarParser extends JavaTokenParsers with RegexParsers {
  private def binop: Parser[BinOp] = ("+" | "-") ^^ {
    case "+" => Plus
    case "-" => Minus
  }

  private def castop: Parser[CastOp] = ("_" | "^") ^^ {
    case "_" => UnderBar
    case "^" => Hat
  }

  private def cal: Parser[Cal] = ("jp&us" | "jp|us" | "jp" | "us" | "c") ^^ {
    case "jp&us" => JPAndEN
    case "jp|us" => JPOrEn
    case "jp"    => JP
    case "us"    => EN
    case "c"     => Center
  }

  private def integer = wholeNumber ^^ { s => Num(s.toInt) }

  private def T: Parser[Calendar] =
    """[0-9]{4}/(0[1-9]|1[0-2])/(0[1-9]|[12][0-9]|3[01])""".r.filter(
      Calendar.canCreateCalendarInstance
    ) ^^ Calendar.fromString

  private def expr: Parser[BusinessDayCalendar] =
    term ~! castop ~! cal ~! (binop ~! integer).? ^^ {
      case (t: Expr) ~ (cas: CastOp) ~ (cl: Cal) ~ Some(
            (b: BinOp) ~ (i: Num)
          ) =>
        BusinessDayCalendar(t, cas, cl, Some(b), Some(i))
      case (t: Expr) ~ (cas: CastOp) ~ (cl: Cal) ~ None =>
        BusinessDayCalendar(t, cas, cl, None, None)
    }

  private def term: Parser[Expr] = ("(" ~>! expr <~! ")") | T

  def apply(input: String): Either[String, BusinessDayCalendar] =
    parseAll(expr, input) match {
      case Success(c, n) => Right(c)
      case Failure(e, _) => Left("FAILURE: " + e.toString)
      case Error(e, _)   => Left("ERROR: " + e.toString)
   

この実装で元記事で紹介されていた"((2020/12/26_c+1)jp+1)_us-1"のパースできることがわかります。

以下が該当のテストです。

  it should "((2020/12/26_c+1)^jp+1)_us-1" in {
      val bcp = BusinessCalendarParser("((2020/12/26_c+1)^jp+1)_us-1")
      bcp shouldBe a [Right[_, _]]
      bcp.right.get shouldBe BusinessDayCalendar(BusinessDayCalendar(BusinessDayCalendar(Calendar(LocalDate.of(2020, 12, 26)), UnderBar, Center, Some(Plus), Some(Num(1))), Hat, JP, Some(Plus), Some(Num(1))), UnderBar, EN, Some(Minus), Some(Num(1)))
  }  

((2020/12/26_c+1)^jp+1)_us-1 が((2020/12/26 の翌日)から翌日以降の JPX 営業日の翌日の営業日)から翌日以前の EN 営業日の前日の営業日) と表現できているのがわかります。

オリジナルのBNFでできて新規則のBNFでできないことはあるか?

  • あります。互換性が無い規則になっています。

具体的に言うと "(" "T" cast_op cal ")"は元の規則では評価可能ですが、変更した新しい規則では評価できません。

  // "(" "T" cast_op cal ")" は評価できない
  it should "left (2020/12/26^jp)" in {
      val bcp = BusinessCalendarParser("(2020/12/26^jp)")
      bcp shouldBe a [Left[_, _]]
      bcp.left.get shouldBe "ERROR: '^' expected but end of source found" 
  }

ただ "T" cast_op cal をわざわざ "(" "T" cast_op cal ")" と書く必要はないので実用上問題はないです。

評価機の実装

lexer と parser は作りましたが、実際に評価して日付を出力しないとなんとも面白くないです。

かなり適当な実装ですがインタプリタも作ってみました。

ダサいのでコードを載せるのは勘弁..。

デモ

では実際にT=2020/12/26としたときに、上のEBNFに従って書いた ((T_c+1)jp+1)_us-1 が何日になるかみてみましょう。

元記事の上記の演習を解いてみます。

sbt:BusinessDayModelBot> run "((2020/12/26_c+1)^jp+1)_us-1"
[info] running BusinessDayModelBot.Main ((2020/12/26_c+1)^jp+1)_us-1
input: ((2020/12/26_c+1)^jp+1)_us-1
token: Right(BusinessDayCalendar(BusinessDayCalendar(BusinessDayCalendar(Calendar(2020-12-26),UnderBar,Center,Some(Plus),Some(Num(1))),Hat,JP,Some(Plus),Some(Num(1))),UnderBar,EN,Some(Minus),Some(Num(1))))
result: Some(2020-12-24)

12/24 になっているので正しく解答できています。

まとめ

  • ”複数の営業日カレンダーを扱う業務のための日付モデル”のlexer 、 parser 、interpreter を実装しました
  • 既存の定義では parser-combinators で扱いにくいので修正した新たな規則を作りました
  • 演習課題が解けることを確認しました

reference

Scalaの定数の初期化失敗時にnullになる理由

初期化失敗時にnullになるとは??

cake patternを利用したDI時などで定数が束縛される前にその定数を呼び出すとnullになります。1

これはScalaを始めた頃によく陥る罠2 で私も昔これでよく悩みました。

最近友人とこの話題になり、なぜnullになるのか疑問に思い調査してみました。

環境

  • Scala: 2.13.4
  • Java: openjdk 11.0.10 2021-01-19

調査

再現する例を示す

class Example {
  println(s)
  val s = "hello world"
  println(s)
}

classインスタンスを生成したいのでREPLで実行する

$ scala
Welcome to Scala 2.13.4 (Eclipse OpenJ9 VM, Java 1.8.0_282).
Type in expressions for evaluation. Or try :help.

scala> class Example {
     |   println(s)
     |   val s = "hello world"
     |   println(s)
     | }
     |
         println(s)
                 ^
On line 2: warning: Reference to uninitialized value s
class Example

scala> new Example
null
hello world
val res0: Example = Example@9a44ab10

定数sを束縛する前に呼び出すと確かにnullになる

束縛する前に値を呼び出せるのがそもそもよくわからないが、インスタンスが作られていない状態で呼び出すとnullになるという挙動がよくわからない

たとえば当たり前だがjavaだとコンパイル時にcannot find symbolでエラーになる

class Example {
  Example(){
    System.out.println(s);
    final String s = "hello java";
    System.out.println(s);        
  }
}
$ javac example.java
example.java:3: エラー: シンボルを見つけられません
    System.out.println(s);
                       ^
  シンボル:   変数 s
  場所: クラス Example
エラー1個

つまり String型の挙動ではない

Scalaコンパイルフェーズでconstructorsというものがあり、フィールド定義をコンストラクタへ移動するということを行う

このコンパイラフェーズでコンストラクタに持ち上げる時にnullを入れているのだと予想した

scalaコンパイラはこのコンパイルフェーズの途中のものを出力することができる

$ scalac example.scala -Xprint:constructors
example.scala:2: warning: Reference to uninitialized value s
  println(s)
          ^
[[syntax trees at end of              constructors]] // example.scala
package  {
  class Example extends Object {
    private[this] val s: String = _;
      def s(): String = Example.this.s;
    def (): Example = {
      Example.super.();
      scala.Predef.println(Example.this.s());
      Example.this.s = "hello world";
      scala.Predef.println(Example.this.s());
      ()
    }
  }
}

1 warning

private[this] val s: String = _;

予想したとおりsに_という初期値を代入している箇所を見つけた

この_

https://scala-lang.org/files/archive/spec/2.13/04-basic-declarations-and-definitions.html#variable-declarations-and-definitions

に書いてあるとおり

var かつ 型アノテーションがついている場合のみ利用できる

_はTTの型によって値が変わり、AnyRefを継承していた場合はnull、AnyValを継承していた場合は0っぽい値が代入される

結果

AnyRefを継承している型をインスタンス化せずに呼び出そうとするとコンパイルフェーズ18のconstructorsの際に初期値として代入された_が呼び出され、その値のnullになる

感想

業務や趣味でScalaのコンパイルフェーズを一つづつ表示していたのでコンパイルフェーズ18でなにかやっているのではないか?`という仮定にたどり着けたのだと思う

またDIなどをするときはほとんどが独自型なのでnullになるが、AnyVal(Int, Long, etc..)を束縛前に利用すると0っぽい値が入り、実行時にも中々気が付けない場合があると考えると結構怖い

scala3(dotty)の話

Scala3ではuninitializedというメソッドに変えられている(コンパイルフェーズ中にしか利用できないメソッドになっている)

https://dotty.epfl.ch/api/scala/compiletime.html#uninitialized-0

追記1

nullになる場合はlazy valなどを使って回避しましょう

追記2

記事中のScalaとJavaの比較の箇所が正しく比較できていないようです

ちょっと僕には難しすぎて正しく解説できないので吉田さんのツイートをそのまま貼っておきます

https://gist.github.com/xuwei-k/8c8baf19f4067ca465023c44254b6973

ref