FS2 Stream の並列化処理の落とし穴
これは2025のScalaアドベントカレンダーの14日目の記事です。
昨日は xuweiさんの
でした
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 つの方針があります。
- どこかでエラーが発生したら処理を止める。出力が中途半端でも問題なければ、サンプルコードのままでよい。
- エラーが発生しても並列に動作している箇所の処理を継続してよいなら、
.attemptで最後まで実行する。List の要素が Either[Throw, A]で、1 つでも Left があれば、最後は RailsError へ再度落とすなどを行う。 - どこかでエラーが発生したら、すべての処理内容をロールバックしたい(処理で S3 に PUT したものも消したい)場合など、条件がさまざまであれば対応も多様に検討する必要がある。
今回の結論
今回はログの見やすさなどから対応案 2 での対応を行いました。(対応案1でも並列数の設定を変えればパフォーマンス上は対応案2と同等の処理速度が出せます)
あとがき
この問題はいままでは該当の処理の速度が問題になったときに並列数の設定をあげるのと同時に CPU 割り当ても一定量増やすという対応を行なっていました。その結果 CPU 割り当てが増えたことによる影響で CPU スロットリングが解消されて処理能力が向上しておりそれを「並列数を増やしたから性能が向上した」と誤認していたようです。
私がこの担当になったとき CPU 割り当てを増やして、パフォーマンスが向上することを確認した後、処理速度が遅いなぁと感じていたので更なるチューニングのために色々設定を変えて動作確認をしていました。 その中で並列数によって処理時間は変わらないことに気がついて(色々遠回りしましたが)この問題を見つけました。協調性のない行動(?)がたまたま実装の不具合を見つけることもあるんですね。
コード
Scala で serena MCP を 利用する方法
この記事は FOLIO Advent Calendar 2025 - Adventar 12 日目です。
昨日は tabe さんの記事でした。
serena MCP が scala に対応したので社内で「serena MCP for scala ハンズオン」を実施しました。その時の記事を社外公開向けに修正して共有します。
前半は背景解説なので、ハンズオンの内容だけを見たい人は後半まで飛ばしてください。
LLM(Large Language Model)とは
- 自然言語処理に特化した生成 AI
- 多数のパラメータを持つニューラルネットワークで構成されるコンピュータ大規模言語モデル
- 代表的なもの: ChatGPT, Gemini, Claude, Claude Code など
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 クライアントにコンテキストを提供するプログラム
- 例: SequentialThinking MCP、Rovo MCP、Figma MCP など
- MCP サーバは Prompts / Resources / Tools の 3 つの要素で構成されている
Prompts
Resources
- コンテキストデータを提供する
- いわゆるプロンプトエンジニアリングでの Prompt を MCP の単位で提供する
Tools
- LLM に公開するアクションを実行する関数を提供する
LSP(Language Server Protocol)とは
エディタ/IDE と言語サーバの間で、補完・定義ジャンプ・リファレンス検索などの言語機能をやり取りするためのプロトコルです。
- 仕様: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/
- 概観(この記事がとてもわかりやすかったです): https://zenn.dev/mtshiba/books/language_server_protocol/viewer/03_basics#fnref-103c-3
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 の設定を追加する
- scala を利用している repository に対して serena を利用できるように設定する
- 実際に serena MCP で scala を利用している 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-cli の MCP に 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 の設定
toolskey の 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 アクセス/検索ができるか確認してみよう!
例として CsvRecordDecoder の decodeElems 関数を呼び出している箇所を取得してみます。
- 使用 tool:
serena.find_referencing_symbolsnamepath: fastcsv4s3.fastCsvDecoderInstance/CsvRecordDecoder/decodeElemsrelative_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 MCP は scala の LSP の MCP。 v1.5.3 で導入されました。
- overview や symbol のキャッシュ機能などがあるので現時点では、serena 経由で使うほうが有用
- 将来的にどうなるのかは(成長の早い業界なので)不明
tool 名がわかりにくい
- 日本語で
「アクティベートして」「オンボーディングして」「〜という調査をおこなって」「〜のナレッジを元に実装をして」
などでも指示できる - 今回は(ハンズオンで想定外の動作をしないように)厳密に tool 名や argument を指定している
- (日本語指示の画像略)
- memory の抜粋(画像略)
既存の問題点/デメリットは?
- LSP 未対応の言語には対応できない
- 例えば(LSP 未実装の)自作言語や古い言語の一部は serena で対応できない
- serena の機能として
overview,write_memoryなどの平文で repository の情報や実施タスクについて保存する機能がある - 指示するタスクによっては symbol 検索をうまく利用できないことがある
- (scala の話に限定すると)Metals 側の LSP 対応が一部追いついておらず、symbol 検索で一部検索対象にならないものがある
例えばcase classの field など それらは Metals 側が対応するまでは利用できない- 2025/12/11 追記: metals v 1.8.4 で対応した improvement: Include constructor params in document symbol #7953
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 の言語拡張として cur や pie がある。
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(...)
Scala3の依存型の構文レベルの実装
Scala3 には現時点では依存型の実装はない。 しかしテストケースに Deptype があるので依存型自体もそのうち Scala3 でネイティブに対応されそう。 但し SIP にも開発計画を探しても出てこないのでいつ実装されるのかは不明。
- https://github.com/scala/scala3/blob/f4847cca6e10886e4ca3658b81fcf5797fe088d8/tests/neg/deptypes.scala#L4
- https://github.com/scala/scala3/blob/f4847cca6e10886e4ca3658b81fcf5797fe088d8/compiler/src/dotty/tools/dotc/typer/Typer.scala#L2693
NOTE: 5 月 4 日 追記
TypedAppliedType は次の MR で実装されていた。
https://github.com/scala/scala3/pull/22543
TermLambdaType が実装されれば上記の Matrix や Tensor2 も利用可能になるはず。
まとめ
Scala3 で導入された新しい機能を利用することによって Vec, DPair, Pi などの依存型をサポートする言語で利用できる型の作成と値をそれらの型で型付けることができた。
Vec では型引数で指定した添字の要素数しか要素が無い List 構造を作成し、値に依存した型の具体例を Scala 上で示すことができた。
行列やテンソル積など複雑な型は TypedAppliedType や TermLambdaType が未実装の影響で定義できなかった。
また Scala3 のテストコードに依存型をテストするコードがあるので将来的に依存型の構文レベルでの対応が進む可能性がある。
簡易的なConventional Commitsを実現するためのzshプラグインの設計および実装
はじめに
これはFolioアドベントカレンダー2024の14日目の記事です。
今回は、簡易的な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
Angular Commit Guidelines
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するツールです
2.2.2 opencommit
AIを活用し、コミットメッセージを自動生成を行うツールです
2.2.3 gitmoji
絵文字を活用してコミットメッセージに視覚的な情報を付加するツールです
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. 作成したツールの紹介
作成したツール「zsh-select-commit-prefix」は以下の機能を持ちます。
-
Sheldonなどのプラグインマネージャ経由で導入可能です。
Prefixの選択
pecoを使用してPrefixを選択し、その結果をコミットメッセージに反映します。
統合性
Prefixを選択後、git.default.editor で指定したエディタが開きcommit messageの編集ができます。
動作例
コマンドを実行し prefixを 選択すると git commit 時と同様の git.default.editor で指定したエディタが表示され、通常の編集操作が可能となります。
スクショ








6. 技術的な工夫
以下のコマンドにより、指定したメッセージを渡しつつ、エディタを開くことができます。
$ git commit -e -m "${prefix}: "
7. 今後の展望
以下の機能追加を検討しています。
opencommit対応
AI生成メッセージを組み込む機能の追加
$git commit -e -m "${prefix}: ${generatedMessage}"
のように指定すれば 統合可能です。
- tig対応
git-commit--select-prefix を tig で利用するユーザ定義関数を作成すれば統合可能です。
8. 結論
本稿では、Conventional Commitsを簡易化するためのzshプラグインの作成について話しました。
このツールは、個人利用に適した柔軟性を持ちつつ、既存ツールとの干渉を最小限に抑える設計となっています。
9. おわりに
もう少しまともな内容の記事を書こうとしていたのですが諸々でぽしゃったために、代案でこの記事を執筆しました。
去年は大遅刻をしたので今年は日付通りに投稿できてホッとしています。
Node.jsで画像を生成するAWS Lambdaの開発環境改善
これは、FOLIO Advent Calendar 2023 の xx 日目の記事として投稿しようと思ったものです。。。。(忙しくて気がついたら 2 月になっていました)
今回は私がメンテナンスを引き継いだグラフを png 出力する AWS lambda の改善例を共有します。
背景
昨年の弊社のアドベントカレンダーに該当のリポジトリの紹介があるので、今回の記事では次のブログの内容を前提として内容を進めていきます。
この記事で紹介するコードは次になります。
この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" } ]
- 出力されるグラフの例

課題
このリポジトリの引き継ぎを受けた際に、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 の解決を行えるように対応を行いました。
html element をカタログとして利用する方法
reander 関数が呼ばれた際に
という関数を渡すことで storybook でライフサイクルも含め UI カタログとして利用できるようになります
サンプルコードは以下になります
リファクタリング
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 しているので是非 json の value を変えて、グラフが再描画される事を確認してみて下さい。業務ではもう少し複雑なグラフを生成しているので、エッジケースの確認などに便利に利用できます。
しかしこの改善を進めながら検証を進めた結果、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" } } } ] }
実行方法
次の方法で動作を確認できます。
- lambda の docker を build する
- lambda と localstack の docker を起動する
- s3 の bucket を作成する
- json ファイルを s3 に入稿する
- lambda を実行する
- 実行結果生成された 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日目の記事です。
営業日カレンダーモデル
この記事で提案されている営業日カレンダーモデルの 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になるのか疑問に思い調査してみました。
環境
調査
再現する例を示す
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に_という初期値を代入している箇所を見つけた
この_は
に書いてあるとおり
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
突然の便乗したScala 3紹介としては、 _ の使い過ぎを多少反省したのか、専用のhttps://t.co/2L98tUJQjK
— Kenji Yoshida (@xuwei_k) February 24, 2021
uninitialized
という特別扱いメソッドが導入されています
追記1
nullになる場合はlazy valなどを使って回避しましょう
追記2
記事中のScalaとJavaの比較の箇所が正しく比較できていないようです
ちょっと僕には難しすぎて正しく解説できないので吉田さんのツイートをそのまま貼っておきます
https://t.co/m0WgVJFDom
— Kenji Yoshida (@xuwei_k) March 12, 2021
セマンティクス的にも、少なくともこの例では動作が同じになるという点でも、そもそもこうやって捉えれば、結局JavaもScalaも同じでは?https://t.co/hcec7KtXDV
とまで書いて気がついたけれど、fieldをfinalにしたらJavaではnullにならない?ので同じではなかった・・・
https://gist.github.com/xuwei-k/8c8baf19f4067ca465023c44254b6973