From 4b183ed71a98268eae508463576a854d6ffc6789 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 1 Jul 2026 13:18:10 +0000 Subject: [PATCH] =?UTF-8?q?feat(go-endpoints):=20=E3=82=B9=E3=82=B1?= =?UTF-8?q?=E3=82=B8=E3=83=A5=E3=83=BC=E3=83=AB=E9=96=A2=E6=95=B04?= =?UTF-8?q?=E6=9C=AC=E3=82=92Go=E3=81=B8=E7=A7=BB=E6=A4=8D(rankingUpdate/r?= =?UTF-8?q?ankingCache/statusCacheBackfill/scheduledOgpDelete)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rankingUpdateGo/rankingCacheGo/statusCacheBackfillGo/scheduledOgpDeleteGoを Pub/Sub(Cloud Scheduler)トリガーのCloud Run functions(Go)として追加。 Node版とは別関数名・別トピック・別スケジューラジョブで完全に独立してデプロイし、 安定稼働を確認してからNode版を停止する方針。 - rankingUpdateGo: Node版の`sort`再呼び出し(battlePointのタイプミスで実質no-op)を 再現せず、Firestoreクエリの結果順をそのまま使用(観測可能な出力は同一)。 - rankingCacheGo: Node版のforEach内fire-and-forgetな非同期処理を、 各ユーザーの処理を確実に完了させる実装に変更(書き込み内容自体は同一)。 - statusCacheBackfillGo: status/statusGoと同じ集計ロジックを再利用。 - scheduledOgpDeleteGo: Cloud Storageの`ogps/`プレフィックス配下を削除。 個別ファイルの削除失敗をログに残しつつ他のファイルの削除は継続する。 - CI(dev-deploy.yml)にPub/Subトリガーのデプロイ・Cloud Schedulerジョブの 作成/更新ステップを追加。Cloud SchedulerのlocationはApp Engineアプリの ロケーションと一致させる必要があるため動的に取得し、レガシー表記 (us-central等)をCloud Scheduler形式に変換する処理を追加。 - CIのgo testをFirestoreエミュレータ起動状態(firebase emulators:exec)で 実行するよう変更し、既存のFirestore統合テスト(sanpai_test.go等)も 含めて実際にCIで検証されるようにした。 - 第三者視点のレビューを実施し、指摘事項(Cloud Schedulerジョブの更新ロジック、 日付演算の差異の文書化、Storageバケット名未設定時の早期エラー化等)に対応済み。 Co-authored-by: kojira --- .github/workflows/dev-deploy.yml | 173 +++++++++++++++++- app/functions-go/README.md | 82 ++++++++- app/functions-go/go.mod | 22 +-- app/functions-go/go.sum | 50 ++--- app/functions-go/ranking_cache.go | 108 +++++++++++ app/functions-go/ranking_cache_test.go | 80 ++++++++ app/functions-go/ranking_update.go | 115 ++++++++++++ app/functions-go/ranking_update_test.go | 86 +++++++++ app/functions-go/scheduled_ogp_delete.go | 111 +++++++++++ app/functions-go/scheduled_ogp_delete_test.go | 74 ++++++++ app/functions-go/status_cache_backfill.go | 112 ++++++++++++ .../status_cache_backfill_test.go | 154 ++++++++++++++++ docs/backend.md | 69 +++++-- 13 files changed, 1179 insertions(+), 57 deletions(-) create mode 100644 app/functions-go/ranking_cache.go create mode 100644 app/functions-go/ranking_cache_test.go create mode 100644 app/functions-go/ranking_update.go create mode 100644 app/functions-go/ranking_update_test.go create mode 100644 app/functions-go/scheduled_ogp_delete.go create mode 100644 app/functions-go/scheduled_ogp_delete_test.go create mode 100644 app/functions-go/status_cache_backfill.go create mode 100644 app/functions-go/status_cache_backfill_test.go diff --git a/.github/workflows/dev-deploy.yml b/.github/workflows/dev-deploy.yml index f40dc54..b99a4d2 100644 --- a/.github/workflows/dev-deploy.yml +++ b/.github/workflows/dev-deploy.yml @@ -88,9 +88,19 @@ jobs: run: | go build ./... go vet ./... - go test ./... working-directory: ./app/functions-go + # sanpai/ranking/register/statusCacheBackfill等のFirestore統合テストは + # FIRESTORE_EMULATOR_HOST未設定時は自動スキップされる設計(README参照)。 + # CIでも実際に検証されるよう、Firestoreエミュレータを起動した状態で + # go test を実行する(firebase emulators:exec が終了時に自動でエミュレータを + # 落とすため、後続ステップに影響しない)。 + - name: go test (functions-go, with Firestore emulator) + run: | + firebase emulators:exec --project d-shrine-dev --only firestore \ + 'cd functions-go && go test ./...' + working-directory: ./app + # Go版Cloud Run functions(statusGo)のデプロイ。既存のstatus(Node)とは別関数として # 共存させ、フロントエンドの切替タイミングを制御できるようにしている # (詳細は docs/backend.md「status エンドポイントのGo移植」を参照)。 @@ -216,3 +226,164 @@ jobs: --timeout=30s \ --set-env-vars=FUNC_BASE_URL=${{ env.OGP_FUNC_BASE_URL }},OGP_PROJECT_ID=d-shrine-dev working-directory: ./app/functions-go + + # スケジュール関数(Pub/Sub トリガー)のGo移植分。既存のNode版 + # (rankingUpdate/rankingCache/statusCacheBackfill/scheduledOgpDelete)とは + # 別のCloud Scheduler ジョブ・Pub/Subトピック・関数名で完全に独立してデプロイし、 + # 安定稼働を確認してからNode版を止める(詳細は docs/backend.md + # 「スケジュール関数のGo移植」を参照)。 + # + # --trigger-topic に指定したPub/SubトピックはCloud Functions側が + # 存在しなければ自動作成するため、事前のトピック作成手順は不要。 + # Cloud SchedulerジョブはApp Engineのロケーションに紐づくため、 + # 既存プロジェクトの設定値を動的に取得して使う。 + - name: enable required GCP services for scheduled Go functions + run: | + gcloud services enable \ + pubsub.googleapis.com \ + cloudscheduler.googleapis.com \ + eventarc.googleapis.com \ + --project=d-shrine-dev + + # Cloud SchedulerジョブのlocationはプロジェクトのApp Engineアプリと + # 同一でなければならない(App Engineが存在するプロジェクトでは、それ以外の + # リージョンを指定すると `Location must equal ` エラーになる)。 + # `gcloud app describe` が返す locationId はレガシーなApp Engine表記 + # (例: "us-central")の場合があり、Cloud Scheduler/Cloud Functions等の + # 通常のCloudリージョン表記(例: "us-central1")とは異なることがあるため、 + # 既知の対応表で変換する。 + - name: resolve App Engine location for Cloud Scheduler + run: | + set -eo pipefail + RAW_LOCATION=$(gcloud app describe --project=d-shrine-dev --format='value(locationId)') + case "$RAW_LOCATION" in + us-central) LOCATION=us-central1 ;; + europe-west) LOCATION=europe-west1 ;; + *) LOCATION="$RAW_LOCATION" ;; + esac + echo "resolved App Engine location: $RAW_LOCATION -> $LOCATION" + echo "APP_ENGINE_LOCATION=$LOCATION" >> "$GITHUB_ENV" + + # timeout=300s: Node版はrunWith()を使わずデフォルト(60s)のままだが、 + # 全ユーザーをスキャンする処理のため安全側に長めの値を明示指定する + # (実行時間そのものはNode版と同等の想定で、上限を厳しくしすぎて + # ユーザー数増加時にタイムアウトする事故を避けるのが目的)。 + - name: deploy rankingUpdateGo (Cloud Run function, Go, Pub/Sub trigger) + run: | + gcloud functions deploy rankingUpdateGo \ + --project=d-shrine-dev \ + --gen2 \ + --runtime=go125 \ + --region=us-central1 \ + --source=. \ + --entry-point=RankingUpdateGo \ + --trigger-topic=ranking-update-go \ + --memory=256Mi \ + --timeout=300s + working-directory: ./app/functions-go + + - name: create/update Cloud Scheduler job for rankingUpdateGo + run: | + gcloud scheduler jobs update pubsub ranking-update-go \ + --project=d-shrine-dev --location=${{ env.APP_ENGINE_LOCATION }} \ + --schedule="0 * * * *" \ + --topic=ranking-update-go \ + --message-body="{}" \ + --time-zone=Etc/UTC \ + || gcloud scheduler jobs create pubsub ranking-update-go \ + --project=d-shrine-dev --location=${{ env.APP_ENGINE_LOCATION }} \ + --schedule="0 * * * *" \ + --topic=ranking-update-go \ + --message-body="{}" \ + --time-zone=Etc/UTC + + # timeout=300s: rankingUpdateGoと同様の理由(全ユーザースキャンのため安全側)。 + - name: deploy rankingCacheGo (Cloud Run function, Go, Pub/Sub trigger) + run: | + gcloud functions deploy rankingCacheGo \ + --project=d-shrine-dev \ + --gen2 \ + --runtime=go125 \ + --region=us-central1 \ + --source=. \ + --entry-point=RankingCacheGo \ + --trigger-topic=ranking-cache-go \ + --memory=256Mi \ + --timeout=300s + working-directory: ./app/functions-go + + - name: create/update Cloud Scheduler job for rankingCacheGo + run: | + gcloud scheduler jobs update pubsub ranking-cache-go \ + --project=d-shrine-dev --location=${{ env.APP_ENGINE_LOCATION }} \ + --schedule="0 */2 * * *" \ + --topic=ranking-cache-go \ + --message-body="{}" \ + --time-zone=Etc/UTC \ + || gcloud scheduler jobs create pubsub ranking-cache-go \ + --project=d-shrine-dev --location=${{ env.APP_ENGINE_LOCATION }} \ + --schedule="0 */2 * * *" \ + --topic=ranking-cache-go \ + --message-body="{}" \ + --time-zone=Etc/UTC + + - name: deploy statusCacheBackfillGo (Cloud Run function, Go, Pub/Sub trigger) + run: | + gcloud functions deploy statusCacheBackfillGo \ + --project=d-shrine-dev \ + --gen2 \ + --runtime=go125 \ + --region=us-central1 \ + --source=. \ + --entry-point=StatusCacheBackfillGo \ + --trigger-topic=status-cache-backfill-go \ + --memory=512Mi \ + --timeout=300s + working-directory: ./app/functions-go + + - name: create/update Cloud Scheduler job for statusCacheBackfillGo + run: | + gcloud scheduler jobs update pubsub status-cache-backfill-go \ + --project=d-shrine-dev --location=${{ env.APP_ENGINE_LOCATION }} \ + --schedule="*/30 * * * *" \ + --topic=status-cache-backfill-go \ + --message-body="{}" \ + --time-zone=Etc/UTC \ + || gcloud scheduler jobs create pubsub status-cache-backfill-go \ + --project=d-shrine-dev --location=${{ env.APP_ENGINE_LOCATION }} \ + --schedule="*/30 * * * *" \ + --topic=status-cache-backfill-go \ + --message-body="{}" \ + --time-zone=Etc/UTC + + # timeout=300s: OGPキャッシュファイル数が多い場合でも一覧・削除が + # 途中で打ち切られないよう安全側に長めの値を明示指定する。 + - name: deploy scheduledOgpDeleteGo (Cloud Run function, Go, Pub/Sub trigger) + run: | + gcloud functions deploy scheduledOgpDeleteGo \ + --project=d-shrine-dev \ + --gen2 \ + --runtime=go125 \ + --region=us-central1 \ + --source=. \ + --entry-point=ScheduledOgpDeleteGo \ + --trigger-topic=scheduled-ogp-delete-go \ + --memory=256Mi \ + --timeout=300s \ + --set-env-vars=STORAGE_BUCKET_NAME=d-shrine-dev.appspot.com + working-directory: ./app/functions-go + + - name: create/update Cloud Scheduler job for scheduledOgpDeleteGo + run: | + gcloud scheduler jobs update pubsub scheduled-ogp-delete-go \ + --project=d-shrine-dev --location=${{ env.APP_ENGINE_LOCATION }} \ + --schedule="0 */1 * * *" \ + --topic=scheduled-ogp-delete-go \ + --message-body="{}" \ + --time-zone=Etc/UTC \ + || gcloud scheduler jobs create pubsub scheduled-ogp-delete-go \ + --project=d-shrine-dev --location=${{ env.APP_ENGINE_LOCATION }} \ + --schedule="0 */1 * * *" \ + --topic=scheduled-ogp-delete-go \ + --message-body="{}" \ + --time-zone=Etc/UTC diff --git a/app/functions-go/README.md b/app/functions-go/README.md index 96db46d..8cd52fe 100644 --- a/app/functions-go/README.md +++ b/app/functions-go/README.md @@ -38,6 +38,14 @@ functions-go/ register_test.go ogp_rewrite.go # ogpRewriteGo エンドポイント ogp_rewrite_test.go + ranking_update.go # rankingUpdateGo (Pub/Subトリガー、スケジュール関数) + ranking_update_test.go + ranking_cache.go # rankingCacheGo (Pub/Subトリガー、スケジュール関数) + ranking_cache_test.go + status_cache_backfill.go # statusCacheBackfillGo (Pub/Subトリガー、スケジュール関数) + status_cache_backfill_test.go + scheduled_ogp_delete.go # scheduledOgpDeleteGo (Pub/Subトリガー、スケジュール関数) + scheduled_ogp_delete_test.go cmd/ main.go # ローカル動作確認専用(デプロイでは使わない) internal/ @@ -46,9 +54,7 @@ functions-go/ performance_test.go # performance.test.js と同一の入出力を検証 ``` -`userOGP`(OGP画像生成)とPub/Subスケジュール関数(`rankingUpdate`/ -`rankingCache`/`statusCacheBackfill`/`scheduledOgpDelete`)は対象外 -(理由は `docs/backend.md`「Go移植を見送った機能」を参照)。 +`userOGP`(OGP画像生成)は対象外(理由は `docs/backend.md`「Go移植を見送った機能」を参照)。 ## 関数の命名規則(既存Node関数との共存) @@ -81,10 +87,12 @@ PORT=8090 \ go run ./cmd ``` -`sanpai_test.go` は `FIRESTORE_EMULATOR_HOST` が未設定の場合は自動的にスキップする -ため、通常のCI(`go test ./...`)には影響しない。GitHub Events APIはテスト用の -`httptest` モックサーバーに差し替えており(`githubAPIBaseURL` 変数)、実際の -GitHub APIやFirebase Authへの通信は発生しない。 +Firestoreエミュレータ統合テスト(`sanpai_test.go`等)は `FIRESTORE_EMULATOR_HOST` +が未設定の場合は自動的にスキップするが、CI(`dev-deploy.yml`)では +`firebase emulators:exec --only firestore 'go test ./...'` の形でFirestore +エミュレータを起動した状態で実行しているため、実際にスキップされず検証される。 +GitHub Events APIはテスト用の `httptest` モックサーバーに差し替えており +(`githubAPIBaseURL` 変数)、実際のGitHub APIやFirebase Authへの通信は発生しない。 ## デプロイ @@ -123,6 +131,66 @@ gcloud functions deploy statusGo \ `functions.config().func.base_url` 相当)と `OGP_PROJECT_ID` を `--set-env-vars` で渡す必要がある。 +## スケジュール関数(Pub/Subトリガー)のデプロイ + +`rankingUpdateGo`/`rankingCacheGo`/`statusCacheBackfillGo`/`scheduledOgpDeleteGo` +はHTTPトリガーではなくPub/Sub(Cloud Scheduler経由)トリガーのため、 +デプロイ方法が異なる。 + +```bash +gcloud functions deploy rankingUpdateGo \ + --project=d-shrine-dev \ + --gen2 \ + --runtime=go125 \ + --region=us-central1 \ + --source=. \ + --entry-point=RankingUpdateGo \ + --trigger-topic=ranking-update-go \ + --memory=256Mi \ + --timeout=300s +``` + +`--trigger-topic` に指定したPub/Subトピックは存在しなければCloud Functions側が +自動作成するため、事前のトピック作成は不要。ただし実際に定期実行するには +別途Cloud Schedulerジョブが必要で、CIでは「更新を試みて、ジョブが無ければ +作成する」形で冪等にセットアップしている(`update`は対象ジョブが存在しない場合 +失敗するため、`||`で`create`にフォールバックする)。 + +Cloud SchedulerジョブのlocationはプロジェクトのApp Engineアプリと同一の +リージョンでなければならない制約があるため、`gcloud app describe`から動的に +取得している。ただし `gcloud app describe` が返す `locationId` はレガシーな +App Engine表記(例: `us-central`)の場合があり、Cloud Scheduler側が要求する +通常のCloudリージョン表記(例: `us-central1`)と異なることがあるため、 +既知のパターンを変換してから使用する(詳細はCIのコメント参照)。 + +```bash +RAW_LOCATION=$(gcloud app describe --project=d-shrine-dev --format='value(locationId)') +# us-central -> us-central1 等、既知の表記ゆれを変換した値をLOCATIONとする +gcloud scheduler jobs update pubsub ranking-update-go \ + --project=d-shrine-dev --location="$LOCATION" \ + --schedule="0 * * * *" \ + --topic=ranking-update-go \ + --message-body="{}" \ + --time-zone=Etc/UTC \ +|| gcloud scheduler jobs create pubsub ranking-update-go \ + --project=d-shrine-dev --location="$LOCATION" \ + --schedule="0 * * * *" \ + --topic=ranking-update-go \ + --message-body="{}" \ + --time-zone=Etc/UTC +``` + +`statusCacheBackfillGo` はNode版の `runWith({timeoutSeconds:300, memory:"512MB"})` +に合わせて `--memory=512Mi --timeout=300s` を指定している。`scheduledOgpDeleteGo` +はCloud Storageを操作するため `STORAGE_BUCKET_NAME`(Node版の +`${projectID}.appspot.com` 相当)を `--set-env-vars` で渡している。 + +各スケジュールの間隔・タイムゾーンはNode版と揃えているが、固定間隔 +(`every N minutes`)の実行に対してタイムゾーンの選択は実行時刻そのものには +影響しない(例えば「毎時0分」はUTCでもAsia/Tokyoでも同じ実時刻に発火する。 +Asia/Tokyoは夏時間の無い固定オフセットのため)ため、CI側は全て `Etc/UTC` を +明示指定している。 + ## Node版との等価性の確認方法 新しく移植する際は、Firestoreエミュレータに同一のテストデータを投入し、 diff --git a/app/functions-go/go.mod b/app/functions-go/go.mod index 20871de..d4f26ff 100644 --- a/app/functions-go/go.mod +++ b/app/functions-go/go.mod @@ -4,8 +4,10 @@ go 1.25.8 require ( cloud.google.com/go/firestore v1.22.0 + cloud.google.com/go/storage v1.63.0 firebase.google.com/go/v4 v4.20.0 github.com/GoogleCloudPlatform/functions-framework-go v1.9.2 + github.com/cloudevents/sdk-go/v2 v2.15.2 google.golang.org/api v0.287.0 google.golang.org/grpc v1.81.1 ) @@ -20,13 +22,11 @@ require ( cloud.google.com/go/iam v1.11.0 // indirect cloud.google.com/go/longrunning v1.0.0 // indirect cloud.google.com/go/monitoring v1.29.0 // indirect - cloud.google.com/go/storage v1.62.1 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect github.com/MicahParks/keyfunc v1.9.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/cloudevents/sdk-go/v2 v2.15.2 // indirect github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect @@ -49,11 +49,11 @@ require ( go.opentelemetry.io/contrib/detectors/gcp v1.43.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect - go.opentelemetry.io/otel v1.43.0 // indirect - go.opentelemetry.io/otel/metric v1.43.0 // indirect - go.opentelemetry.io/otel/sdk v1.43.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect - go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/sdk v1.44.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect go.uber.org/atomic v1.4.0 // indirect go.uber.org/multierr v1.1.0 // indirect go.uber.org/zap v1.10.0 // indirect @@ -65,8 +65,8 @@ require ( golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.15.0 // indirect google.golang.org/appengine/v2 v2.0.6 // indirect - google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 // indirect + google.golang.org/genproto v0.0.0-20260519071638-aa98bba5eb94 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/app/functions-go/go.sum b/app/functions-go/go.sum index c46955d..bcffb63 100644 --- a/app/functions-go/go.sum +++ b/app/functions-go/go.sum @@ -20,8 +20,8 @@ cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMh cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM= cloud.google.com/go/monitoring v1.29.0 h1:AHhDsFaSax1/4k+qlIDX/SDGe6hggnfXJ9dkgD9qBPY= cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM= -cloud.google.com/go/storage v1.62.1 h1:Os0G3XbUbjZumkpDUf2Y0rLoXJTCF1kU2kWUujKYXD8= -cloud.google.com/go/storage v1.62.1/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA= +cloud.google.com/go/storage v1.63.0 h1:hvXF2xfg9I32bjujggxgkEZn/Ej6sJ9pieFgeueBLrQ= +cloud.google.com/go/storage v1.63.0/go.mod h1:tirWVptrFNo5GEX2DQ47JooF7yaweJdAJ1hYAVMvKzE= cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E= cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0= firebase.google.com/go/v4 v4.20.0 h1:ighpjeAC45rY/95cUQ+ojIKlKcTnz2YC0ldam56z2YU= @@ -30,12 +30,12 @@ github.com/GoogleCloudPlatform/functions-framework-go v1.9.2 h1:Cev/PdoxY86bJjGw github.com/GoogleCloudPlatform/functions-framework-go v1.9.2/go.mod h1:wLEV4uSJztSBI+QyUy2fkHBuGFjRIAEDOqcEQ2hwmgE= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0 h1:O2sXMyJh8b7devAGdE+163xtRurt0RVpB6DIzX5vGfg= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0/go.mod h1:hEpiGU18xf70qb3jbTcIggWAiEfX/cOIVc2OTe4OegA= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.56.0 h1:ZIT85vKP7LBS84XJ0WdJ3dPOX3iz4j3c0+lpajGQMyo= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.56.0/go.mod h1:rqP9UEhOXv9WhQ7Gjz+G5y/pf8+BJZW5/Ts0AhE0PwE= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0 h1:0YP0+/ixwu+Uqeu/FGiBZNQ19huiUxxiPXIc9WsLKuQ= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0/go.mod h1:6ZZMQhZKDvUvkJw2rc+oDP90tMMzuU/J+5HG1ZmPOmE= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 h1:jLdiS1vO+XJFyDSWRHBx56r4s/NNtcl5J6KyCcWUX/w= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0/go.mod h1:8lmpHY+1VRoteiOwyrQMDt1YGXOrFKCz+1wJW7n3ODY= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0 h1:cSjUzZ7KU8hicTgzaSv9NmSyM9fTVK3y5lsBUl3wOis= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0/go.mod h1:dzcEjy1WJ0Q4u9twNR3LcLhNoYMRCrMCMafpxa0TjPQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk= github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o= github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -115,18 +115,20 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0/go.mod h1:Sje3i3MjSPKTSPvVWCaL8ugBzJwik3u4smCjUeuupqg= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= +go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.uber.org/atomic v1.4.0 h1:cxzIVoETapQEqDhQu3QfnvXAV4AlzcvUCxkVUFw3+EU= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/multierr v1.1.0 h1:HoEmRHQPVSqub6w2z2d2EOVs2fjyFRGyofhKuyDq0QI= @@ -177,10 +179,10 @@ google.golang.org/api v0.287.0 h1:CQDMqUiqZZ0U/Yge3zyjAhNQ0OSYEH0PaA7l4xtEen4= google.golang.org/api v0.287.0/go.mod h1:pPW85yt3Iuc3unkpaMhFtMmOqnTdCwCqEOaUlnuxRlQ= google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw= google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI= -google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60 h1:rhBdfmsOlOZIvz3Y5/BdUzPg2CkO8L7QQPKj96B8554= -google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60/go.mod h1:8xo2Pj1b20ZOCpzlU3B9qieMwVIAXx1QVZWLMlPL6sM= -google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 h1:3WsB1FAbiRIf2tOxscWKs3pQBD9he1NsrnbhMuWfekc= -google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60/go.mod h1:7yoXV7RIh5gblj/xVYoogxAWvA9wUeVbpsK/M694l00= +google.golang.org/genproto v0.0.0-20260519071638-aa98bba5eb94 h1:YJjbgu+dkp5kUJLfpMyCLfBIWZb/FcJyuLeo1gVBOuo= +google.golang.org/genproto v0.0.0-20260519071638-aa98bba5eb94/go.mod h1:RRHjglSYABVCWpQ7USCpdfhcd9t4PkajvVwyynZizTc= +google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324 h1:g0RAkxK/smSu/iRwC/KIX1mwUoVJtk2OjbgaeS4DmUM= +google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324/go.mod h1:Z4WJ5pJOYWFWcHEQUelD5QaZDknIQkpIL/+fyJOT9+A= google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d h1:mpAgMyM9vQHxycBlDq50y1VHpfSfVwzXvrQKtYbXuUY= google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= diff --git a/app/functions-go/ranking_cache.go b/app/functions-go/ranking_cache.go new file mode 100644 index 0000000..fe97c5a --- /dev/null +++ b/app/functions-go/ranking_cache.go @@ -0,0 +1,108 @@ +// ポイントランキング初期値作成(rankingCache)スケジュール関数のGo実装。 +// +// Node版(app/functions/index.js の exports.rankingCache、120分毎)からの移植。 +// 既に point_ranking/{id} が存在するユーザーはスキップし、無いユーザーには +// 初期値(rank:0)を作成する。 +// +// 挙動をNode版と揃えつつ、以下の1点は構造的に変更している(詳細は +// docs/backend.md「スケジュール関数のGo移植」を参照): +// +// - Node版は `snapshot.forEach(async (item) => {...})` を使っており、 +// forEachはコールバックのPromiseを待たない(fire-and-forget)。そのため +// 理論上は全ユーザー分の書き込みが完了する前に関数の実行が終了したと +// みなされ得る(Cloud Functionsがコンテナを凍結した場合、書き込みが +// 欠落するリスクがある)。これは意図された仕様ではなく実装上の不備と +// 判断し、Go版では各ユーザーの処理を順番にawaitして確実に完了させる。 +// 結果として書き込まれるデータの内容(既存ユーザーはスキップ、無い +// ユーザーはrank:0で作成)自体はNode版と同一。 +package gofunctions + +import ( + "context" + "log" + + "cloud.google.com/go/firestore" + "github.com/GoogleCloudPlatform/functions-framework-go/functions" + cloudevents "github.com/cloudevents/sdk-go/v2" + "google.golang.org/api/iterator" + "google.golang.org/grpc/codes" + grpcstatus "google.golang.org/grpc/status" +) + +func init() { + functions.CloudEvent("RankingCacheGo", rankingCacheHandler) +} + +func rankingCacheHandler(ctx context.Context, _ cloudevents.Event) error { + client, err := getFirestoreClient(ctx) + if err != nil { + log.Printf("rankingCache: getFirestoreClient error: %v", err) + return err + } + return runRankingCache(ctx, client) +} + +type rankingCacheUserDoc struct { + DisplayName string `firestore:"display_name"` + ScreenName string `firestore:"screen_name"` + Status *struct { + Total int64 `firestore:"total"` + } `firestore:"status"` +} + +func runRankingCache(ctx context.Context, client *firestore.Client) error { + iter := client.Collection("users").Documents(ctx) + defer iter.Stop() + + for { + doc, err := iter.Next() + if err == iterator.Done { + break + } + if err != nil { + return err + } + + var u rankingCacheUserDoc + if err := doc.DataTo(&u); err != nil { + return err + } + if u.Status == nil { + // Node版は `item.data().status.total` で undefined 参照の例外になり、 + // forEach内の非同期コールバックなので他ユーザーの処理には影響しない + // (このユーザーの処理だけが失敗して次のユーザーに進む)。Go版も同様に + // このユーザーをスキップして処理を続ける。 + // + // 注意: `status` は存在するが `status.total` だけが欠けている + // (通常運用では起こらないはずの壊れたデータ)場合、Node版は + // 同様にundefined参照で例外になりスキップされるが、Go版は + // u.Status.Total がゼロ値の0になり `battle_point:0` として + // 書き込まれてしまう。user_formatted_performance/UserFormattedPerformance + // は必ず total を設定するため実運用では発生しない想定だが、 + // 手動編集や移行中データ等で万一 total が欠けた場合の挙動は + // Node版と厳密には一致しない。 + log.Printf("rankingCache: skip user %q: status not computed yet", doc.Ref.ID) + continue + } + + rankingRef := client.Collection("point_ranking").Doc(doc.Ref.ID) + _, err = rankingRef.Get(ctx) + if err == nil { + // 既にpoint_rankingドキュメントが存在する: Node版と同様にスキップ。 + continue + } + if grpcstatus.Code(err) != codes.NotFound { + return err + } + + if _, err := rankingRef.Set(ctx, map[string]interface{}{ + "display_name": u.DisplayName, + "screen_name": u.ScreenName, + "battle_point": u.Status.Total, + "rank": 0, + }, firestore.MergeAll); err != nil { + return err + } + } + return nil +} diff --git a/app/functions-go/ranking_cache_test.go b/app/functions-go/ranking_cache_test.go new file mode 100644 index 0000000..affb187 --- /dev/null +++ b/app/functions-go/ranking_cache_test.go @@ -0,0 +1,80 @@ +package gofunctions + +import ( + "context" + "testing" +) + +func TestRankingCache_CreatesMissingEntriesAndSkipsExisting(t *testing.T) { + client := emulatorClient(t) + ctx := context.Background() + + newUserID := "TestRankingCache_new_user" + if _, err := client.Collection("users").Doc(newUserID).Set(ctx, map[string]interface{}{ + "display_name": "new user", + "screen_name": "new_user", + "status": map[string]interface{}{"total": int64(42)}, + }); err != nil { + t.Fatalf("failed to seed new user: %v", err) + } + + existingUserID := "TestRankingCache_existing_user" + if _, err := client.Collection("users").Doc(existingUserID).Set(ctx, map[string]interface{}{ + "display_name": "existing user", + "screen_name": "existing_user", + "status": map[string]interface{}{"total": int64(999)}, + }); err != nil { + t.Fatalf("failed to seed existing user: %v", err) + } + if _, err := client.Collection("point_ranking").Doc(existingUserID).Set(ctx, map[string]interface{}{ + "display_name": "existing user", + "screen_name": "existing_user", + "battle_point": int64(1), // 既存の値。上書きされないことを確認する。 + "rank": int64(5), + }); err != nil { + t.Fatalf("failed to seed existing point_ranking: %v", err) + } + + noStatusUserID := "TestRankingCache_no_status_user" + if _, err := client.Collection("users").Doc(noStatusUserID).Set(ctx, map[string]interface{}{ + "display_name": "no status", + "screen_name": "no_status_user", + }); err != nil { + t.Fatalf("failed to seed no-status user: %v", err) + } + + if err := runRankingCache(ctx, client); err != nil { + t.Fatalf("runRankingCache: %v", err) + } + + newDoc, err := client.Collection("point_ranking").Doc(newUserID).Get(ctx) + if err != nil { + t.Fatalf("failed to read new point_ranking doc: %v", err) + } + var newData map[string]interface{} + if err := newDoc.DataTo(&newData); err != nil { + t.Fatalf("DataTo: %v", err) + } + if newData["battle_point"] != int64(42) { + t.Errorf("new point_ranking battle_point = %v, want 42", newData["battle_point"]) + } + if newData["rank"] != int64(0) { + t.Errorf("new point_ranking rank = %v, want 0", newData["rank"]) + } + + existingDoc, err := client.Collection("point_ranking").Doc(existingUserID).Get(ctx) + if err != nil { + t.Fatalf("failed to read existing point_ranking doc: %v", err) + } + var existingData map[string]interface{} + if err := existingDoc.DataTo(&existingData); err != nil { + t.Fatalf("DataTo: %v", err) + } + if existingData["battle_point"] != int64(1) { + t.Errorf("existing point_ranking should be untouched, battle_point = %v, want 1", existingData["battle_point"]) + } + + if _, err := client.Collection("point_ranking").Doc(noStatusUserID).Get(ctx); err == nil { + t.Error("status未計算のユーザーに対してpoint_rankingドキュメントが作成されるべきではない") + } +} diff --git a/app/functions-go/ranking_update.go b/app/functions-go/ranking_update.go new file mode 100644 index 0000000..67fef3b --- /dev/null +++ b/app/functions-go/ranking_update.go @@ -0,0 +1,115 @@ +// ランキングキャッシュ更新(rankingUpdate)スケジュール関数のGo実装。 +// +// Node版(app/functions/index.js の exports.rankingUpdate、60分毎)からの移植。 +// ユーザーが待つHTTPエンドポイントではないためコールドスタート短縮の恩恵は +// 無いが、実行時間短縮による課金削減とコード基盤の統一を目的に移植する +// (詳細は docs/backend.md「スケジュール関数のGo移植」を参照)。 +// +// Pub/Sub(Cloud Scheduler経由)トリガーのためHTTPトリガーの他エンドポイントとは +// デプロイ方法が異なる(--trigger-topic。functions-go/README.md参照)。 +package gofunctions + +import ( + "context" + "log" + + "cloud.google.com/go/firestore" + "github.com/GoogleCloudPlatform/functions-framework-go/functions" + cloudevents "github.com/cloudevents/sdk-go/v2" + "google.golang.org/api/iterator" +) + +func init() { + functions.CloudEvent("RankingUpdateGo", rankingUpdateHandler) +} + +func rankingUpdateHandler(ctx context.Context, _ cloudevents.Event) error { + client, err := getFirestoreClient(ctx) + if err != nil { + log.Printf("rankingUpdate: getFirestoreClient error: %v", err) + return err + } + return runRankingUpdate(ctx, client) +} + +type rankingUpdateUserDoc struct { + DisplayName string `firestore:"display_name"` + ScreenName string `firestore:"screen_name"` + ImagePath string `firestore:"image_path"` + Status struct { + Total int64 `firestore:"total"` + } `firestore:"status"` +} + +func runRankingUpdate(ctx context.Context, client *firestore.Client) error { + // Node版は `.orderBy("status.total","desc")` の後にもう一度 + // `.sort((a,b) => b.battlePoint - a.battlePoint)` しているが、 + // `battlePoint`(camelCase)は存在しないフィールド名のタイプミスで、 + // 常に `undefined - undefined = NaN` を返す。V8の配列ソートは + // 比較関数がNaNを返す場合、実質的に要素の順序を変えない(既存の並び= + // Firestoreクエリのorderby順をそのまま維持する)ため、この再ソートは + // 事実上のno-opになっている。Go版は最初から不要な再ソートを行わず、 + // Firestoreクエリの結果順(status.total降順)をそのまま使う。 + // + // また `orderBy("status.total", ...)` は Firestore の仕様上、対象フィールド + // (`status.total`)を持たないドキュメントを自動的に除外するため、 + // `status` 未計算のユーザーによる例外(Node版で言う `.status.total` の + // undefined参照)は発生し得ない。 + iter := client.Collection("users").OrderBy("status.total", firestore.Desc).Documents(ctx) + defer iter.Stop() + + type rankingEntryMutable struct { + DisplayName string + ScreenName string + ImagePath string + BattlePoint int64 + Rank int64 + } + var table []rankingEntryMutable + for { + doc, err := iter.Next() + if err == iterator.Done { + break + } + if err != nil { + return err + } + var u rankingUpdateUserDoc + if err := doc.DataTo(&u); err != nil { + return err + } + table = append(table, rankingEntryMutable{ + DisplayName: u.DisplayName, + ScreenName: u.ScreenName, + ImagePath: u.ImagePath, + BattlePoint: u.Status.Total, + }) + } + + tempRank := int64(1) + tempPoint := int64(-1) + for i := range table { + if tempPoint != table[i].BattlePoint { + tempRank = int64(i) + 1 + tempPoint = table[i].BattlePoint + } + table[i].Rank = tempRank + } + + ranking := make([]rankingEntry, len(table)) + for i, t := range table { + ranking[i] = rankingEntry{ + DisplayName: t.DisplayName, + ScreenName: t.ScreenName, + ImagePath: t.ImagePath, + BattlePoint: t.BattlePoint, + Rank: t.Rank, + } + } + + _, err := client.Collection("cache_data").Doc("ranking_cache").Set(ctx, map[string]interface{}{ + "ranking": ranking, + "latest_update": firestore.ServerTimestamp, + }, firestore.MergeAll) + return err +} diff --git a/app/functions-go/ranking_update_test.go b/app/functions-go/ranking_update_test.go new file mode 100644 index 0000000..149bb33 --- /dev/null +++ b/app/functions-go/ranking_update_test.go @@ -0,0 +1,86 @@ +package gofunctions + +import ( + "context" + "strconv" + "testing" +) + +func TestRankingUpdate_WritesSortedRankingCache(t *testing.T) { + client := emulatorClient(t) + ctx := context.Background() + + // このテスト専用のプレフィックスでユーザーを作成し、他テストと衝突しないようにする。 + prefix := "TestRankingUpdate_" + points := []int64{300, 300, 100, 500} + for i, p := range points { + id := prefix + strconv.Itoa(i) + if _, err := client.Collection("users").Doc(id).Set(ctx, map[string]interface{}{ + "display_name": "name" + id, + "screen_name": "screen" + id, + "image_path": "https://example.com/" + id + ".png", + "status": map[string]interface{}{"total": p}, + }); err != nil { + t.Fatalf("failed to seed user %s: %v", id, err) + } + } + // status未計算のユーザーはNode版同様、orderByのフィールド欠落除外でスキップされるはず。 + noStatusID := prefix + "no_status" + if _, err := client.Collection("users").Doc(noStatusID).Set(ctx, map[string]interface{}{ + "display_name": "no status", + "screen_name": "no_status", + }); err != nil { + t.Fatalf("failed to seed no-status user: %v", err) + } + + if err := runRankingUpdate(ctx, client); err != nil { + t.Fatalf("runRankingUpdate: %v", err) + } + + doc, err := client.Collection("cache_data").Doc("ranking_cache").Get(ctx) + if err != nil { + t.Fatalf("failed to read ranking_cache: %v", err) + } + var out struct { + Ranking []rankingEntry `firestore:"ranking"` + } + if err := doc.DataTo(&out); err != nil { + t.Fatalf("DataTo: %v", err) + } + + // このテストで作成した4ユーザーだけを screen_name -> entry で抽出する + // (実運用データ・他テストのユーザーが同居する共有Firestoreを使うテストのため、 + // rankの絶対値や同点ユーザー間の並び順(Firestoreの仕様上不定)には依存しない)。 + byScreenName := map[string]rankingEntry{} + for _, e := range out.Ranking { + for i := range points { + if e.ScreenName == "screen"+prefix+strconv.Itoa(i) { + byScreenName[e.ScreenName] = e + } + } + } + if len(byScreenName) != len(points) { + t.Fatalf("filtered ranking length = %d, want %d (got %+v)", len(byScreenName), len(points), byScreenName) + } + + e0 := byScreenName["screen"+prefix+"0"] // 300pt + e1 := byScreenName["screen"+prefix+"1"] // 300pt (e0と同点) + e2 := byScreenName["screen"+prefix+"2"] // 100pt (最下位) + e3 := byScreenName["screen"+prefix+"3"] // 500pt (最上位) + + if e0.Rank != e1.Rank { + t.Errorf("同点(300pt)のユーザーは同じrankになるべき: e0.Rank=%d, e1.Rank=%d", e0.Rank, e1.Rank) + } + if !(e3.Rank < e0.Rank) { + t.Errorf("500ptのrank(%d)は300ptのrank(%d)より良い(小さい)べき", e3.Rank, e0.Rank) + } + if !(e0.Rank < e2.Rank) { + t.Errorf("300ptのrank(%d)は100ptのrank(%d)より良い(小さい)べき", e0.Rank, e2.Rank) + } + + for _, e := range out.Ranking { + if e.ScreenName == "no_status" { + t.Errorf("status未計算のユーザーがrankingに含まれるべきではない: %+v", e) + } + } +} diff --git a/app/functions-go/scheduled_ogp_delete.go b/app/functions-go/scheduled_ogp_delete.go new file mode 100644 index 0000000..e463ae6 --- /dev/null +++ b/app/functions-go/scheduled_ogp_delete.go @@ -0,0 +1,111 @@ +// OGP画像キャッシュの定期削除(scheduledOgpDelete)スケジュール関数のGo実装。 +// +// Node版(app/functions/index.js の exports.scheduledOgpDelete、毎時)からの移植。 +// Cloud Storageの `ogps/` プレフィックス配下のファイルを全て削除する +// (userOGPが生成するOGP画像キャッシュを定期的に作り直させるための削除処理)。 +// +// 挙動はNode版と同一にすることを優先し、独自の改善は入れていない +// (詳細は docs/backend.md「スケジュール関数のGo移植」を参照)。 +package gofunctions + +import ( + "context" + "errors" + "log" + "os" + "sync" + + "cloud.google.com/go/storage" + "github.com/GoogleCloudPlatform/functions-framework-go/functions" + cloudevents "github.com/cloudevents/sdk-go/v2" + "google.golang.org/api/iterator" +) + +func init() { + functions.CloudEvent("ScheduledOgpDeleteGo", scheduledOgpDeleteHandler) +} + +var ( + storageClientOnce sync.Once + storageClient *storage.Client + storageClientErr error +) + +func getStorageClient(ctx context.Context) (*storage.Client, error) { + storageClientOnce.Do(func() { + storageClient, storageClientErr = storage.NewClient(ctx) + }) + return storageClient, storageClientErr +} + +func scheduledOgpDeleteHandler(ctx context.Context, _ cloudevents.Event) error { + bucketName := os.Getenv("STORAGE_BUCKET_NAME") + if bucketName == "" { + // デプロイ時の --set-env-vars 漏れ等の設定ミスに早期に気づけるよう、 + // 空のバケット名でAPI呼び出しに進んでしまう前に明示的にエラーにする。 + return errors.New("scheduledOgpDelete: STORAGE_BUCKET_NAME is not set") + } + client, err := getStorageClient(ctx) + if err != nil { + log.Printf("scheduledOgpDelete: getStorageClient error: %v", err) + return err + } + return runScheduledOgpDelete(ctx, storageOgpBucket{bucket: client.Bucket(bucketName)}, "ogps/") +} + +// ogpBucket は Cloud Storage 操作を抽象化する(*storage.BucketHandle は具象型で +// モック差し替えができないため、テスト時にフェイク実装へ差し替えるための境界)。 +type ogpBucket interface { + listObjectNames(ctx context.Context, prefix string) ([]string, error) + deleteObject(ctx context.Context, name string) error +} + +type storageOgpBucket struct { + bucket *storage.BucketHandle +} + +func (b storageOgpBucket) listObjectNames(ctx context.Context, prefix string) ([]string, error) { + it := b.bucket.Objects(ctx, &storage.Query{Prefix: prefix}) + var names []string + for { + attrs, err := it.Next() + if err == iterator.Done { + break + } + if err != nil { + return nil, err + } + names = append(names, attrs.Name) + } + return names, nil +} + +func (b storageOgpBucket) deleteObject(ctx context.Context, name string) error { + return b.bucket.Object(name).Delete(ctx) +} + +func runScheduledOgpDelete(ctx context.Context, bucket ogpBucket, prefix string) error { + names, err := bucket.listObjectNames(ctx, prefix) + if err != nil { + return err + } + deleted := 0 + for _, name := range names { + if err := bucket.deleteObject(ctx, name); err != nil { + // Node版は `bucket.deleteFiles({prefix:"ogps/"})` の戻り値(Promise)を + // onRun内でawait/returnしておらず、削除失敗時の挙動は実質的に + // 「ログにも残らず不可視のunhandled rejectionになるだけ」という + // fire-and-forgetな状態(force未指定なのでNode内部的には最初の + // エラーで残りの削除処理は打ち切られるが、それを検知する仕組みが無い)。 + // この処理は1時間毎に再実行される冪等なクリーンアップジョブであり、 + // 削除し損ねたファイルは次回実行で再試行されるだけなので実害は無い。 + // Go版は挙動を不可視にせず、1ファイルの削除失敗をログに残して + // 他のファイルの削除は続行する(ジョブ全体を失敗させない)。 + log.Printf("scheduledOgpDelete: failed to delete %q: %v", name, err) + continue + } + deleted++ + } + log.Printf("scheduledOgpDelete: deleted %d files", deleted) + return nil +} diff --git a/app/functions-go/scheduled_ogp_delete_test.go b/app/functions-go/scheduled_ogp_delete_test.go new file mode 100644 index 0000000..f6ee031 --- /dev/null +++ b/app/functions-go/scheduled_ogp_delete_test.go @@ -0,0 +1,74 @@ +package gofunctions + +import ( + "context" + "errors" + "testing" +) + +type fakeOgpBucket struct { + objects map[string]bool + failNames map[string]bool +} + +func newFakeOgpBucket(names ...string) *fakeOgpBucket { + objects := map[string]bool{} + for _, n := range names { + objects[n] = true + } + return &fakeOgpBucket{objects: objects, failNames: map[string]bool{}} +} + +func (b *fakeOgpBucket) listObjectNames(_ context.Context, prefix string) ([]string, error) { + var names []string + for n := range b.objects { + if len(n) >= len(prefix) && n[:len(prefix)] == prefix { + names = append(names, n) + } + } + return names, nil +} + +func (b *fakeOgpBucket) deleteObject(_ context.Context, name string) error { + if b.failNames[name] { + return errors.New("simulated delete failure") + } + delete(b.objects, name) + return nil +} + +func TestScheduledOgpDelete_DeletesOnlyMatchingPrefix(t *testing.T) { + bucket := newFakeOgpBucket("ogps/user1.png", "ogps/user2.png", "base.png") + ctx := context.Background() + + if err := runScheduledOgpDelete(ctx, bucket, "ogps/"); err != nil { + t.Fatalf("runScheduledOgpDelete: %v", err) + } + + if _, ok := bucket.objects["ogps/user1.png"]; ok { + t.Error("ogps/user1.png should have been deleted") + } + if _, ok := bucket.objects["ogps/user2.png"]; ok { + t.Error("ogps/user2.png should have been deleted") + } + if _, ok := bucket.objects["base.png"]; !ok { + t.Error("base.png (prefix対象外) should not have been deleted") + } +} + +func TestScheduledOgpDelete_ContinuesAfterIndividualDeleteFailure(t *testing.T) { + bucket := newFakeOgpBucket("ogps/user1.png", "ogps/user2.png") + bucket.failNames["ogps/user1.png"] = true + ctx := context.Background() + + if err := runScheduledOgpDelete(ctx, bucket, "ogps/"); err != nil { + t.Fatalf("runScheduledOgpDelete should not fail the whole job on a single delete error: %v", err) + } + + if _, ok := bucket.objects["ogps/user1.png"]; !ok { + t.Error("delete失敗したファイルはそのまま残るはず") + } + if _, ok := bucket.objects["ogps/user2.png"]; ok { + t.Error("他のファイルの削除は継続されるべき") + } +} diff --git a/app/functions-go/status_cache_backfill.go b/app/functions-go/status_cache_backfill.go new file mode 100644 index 0000000..20f2990 --- /dev/null +++ b/app/functions-go/status_cache_backfill.go @@ -0,0 +1,112 @@ +// マイページ表示キャッシュの事前計算(statusCacheBackfill)スケジュール関数のGo実装。 +// +// Node版(app/functions/index.js の exports.statusCacheBackfill、30分毎)からの +// 移植。直近6ヶ月以内に参拝したユーザーのうち status キャッシュが未計算の +// ユーザーを1回の実行につき最大10件だけ計算してキャッシュする。 +// +// status(statusGo)エンドポイントと同じ計算ロジック・Firestore読み込み +// (loadActivities/performance.UserPerformance等)を再利用しているため、 +// 挙動はNode版と同一(詳細は docs/backend.md「スケジュール関数のGo移植」を参照)。 +package gofunctions + +import ( + "context" + "log" + "time" + + "cloud.google.com/go/firestore" + "github.com/GoogleCloudPlatform/functions-framework-go/functions" + cloudevents "github.com/cloudevents/sdk-go/v2" + "google.golang.org/api/iterator" + + "github.com/428lab/debug-shrine/functions-go/internal/performance" +) + +func init() { + functions.CloudEvent("StatusCacheBackfillGo", statusCacheBackfillHandler) +} + +const statusCacheBackfillMaxPerRun = 10 + +func statusCacheBackfillHandler(ctx context.Context, _ cloudevents.Event) error { + client, err := getFirestoreClient(ctx) + if err != nil { + log.Printf("statusCacheBackfill: getFirestoreClient error: %v", err) + return err + } + return runStatusCacheBackfill(ctx, client, time.Now()) +} + +type backfillUserDoc struct { + DisplayName string `firestore:"display_name"` + ScreenName string `firestore:"screen_name"` + ImagePath string `firestore:"image_path"` + Exp int64 `firestore:"exp"` + Status *firestoreStatus `firestore:"status"` +} + +func runStatusCacheBackfill(ctx context.Context, client *firestore.Client, now time.Time) error { + // 既知の差異: Node版は moment().subtract(6,"months") を使っており、 + // 月末日(29〜31日)を起点にすると対象月の末日にクランプされる + // (例: 8/31 の6ヶ月前は 2/28)。Go の time.AddDate は日付をオーバーフロー + // させて翌月に繰り越す(例: 8/31 の6ヶ月前は 3/3 になり得る)。この差は + // カットオフ日が最大数日ずれるだけで、「直近6ヶ月アクティブなユーザーを + // 対象にする」というこのジョブの目的(荒い足切り)には実害が無いため、 + // 追加の補正は行わない。 + activeSince := now.AddDate(0, -6, 0) + iter := client.Collection("users").Where("last_sanpai", ">=", activeSince).Documents(ctx) + defer iter.Stop() + + processed := 0 + for { + if processed >= statusCacheBackfillMaxPerRun { + break + } + doc, err := iter.Next() + if err == iterator.Done { + break + } + if err != nil { + return err + } + + var u backfillUserDoc + if err := doc.DataTo(&u); err != nil { + return err + } + if u.Status != nil { + continue + } + + activities, err := loadActivities(ctx, doc.Ref) + if err != nil { + return err + } + raw := performance.UserPerformance(activities, u.ScreenName) + formatted := performance.UserFormattedPerformance(raw, performance.AppendData{ + Exp: int(u.Exp), + User: performance.UserInfo{ + DisplayName: u.DisplayName, + ScreenName: u.ScreenName, + GithubImagePath: u.ImagePath, + }, + }) + lastActivityCreatedAt := performance.LatestActivityCreatedAt(activities) + + // Node版はここで status.last_sanpai を設定しない(フィールド自体が + // 存在しない状態でキャッシュされる)。Go版は toFirestoreStatus の都合上 + // last_sanpai="" を明示的に書き込むが、status/statusGo の読み出し側は + // どちらの場合も users/{id}.last_sanpai (トップレベル)の値で必ず + // 上書きするため、観測できる挙動に差は無い。 + if _, err := doc.Ref.Update(ctx, []firestore.Update{ + {Path: "status", Value: toFirestoreStatus(formatted, "")}, + {Path: "last_activity_created_at", Value: lastActivityCreatedAt}, + }); err != nil { + return err + } + processed++ + log.Printf("statusCacheBackfill: backfilled status for %s", u.ScreenName) + } + log.Printf("statusCacheBackfill: done, processed=%d", processed) + return nil +} diff --git a/app/functions-go/status_cache_backfill_test.go b/app/functions-go/status_cache_backfill_test.go new file mode 100644 index 0000000..1b8c37a --- /dev/null +++ b/app/functions-go/status_cache_backfill_test.go @@ -0,0 +1,154 @@ +package gofunctions + +import ( + "context" + "testing" + "time" +) + +func TestStatusCacheBackfill_BackfillsRecentlyActiveUsersWithoutStatus(t *testing.T) { + client := emulatorClient(t) + ctx := context.Background() + now := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + + targetID := "TestStatusCacheBackfill_target" + if _, err := client.Collection("users").Doc(targetID).Set(ctx, map[string]interface{}{ + "display_name": "target user", + "screen_name": "backfill_target", + "image_path": "https://example.com/target.png", + "last_sanpai": now.AddDate(0, 0, -1), + }); err != nil { + t.Fatalf("failed to seed target user: %v", err) + } + if _, err := client.Collection("users").Doc(targetID).Collection("github_activities").Doc("1").Set(ctx, map[string]interface{}{ + "raw": `{"id":"1","type":"PushEvent","created_at":"2026-05-31T00:00:00Z","payload":{"commits":[{"sha":"a"}]}}`, + }); err != nil { + t.Fatalf("failed to seed activity: %v", err) + } + + alreadyCachedID := "TestStatusCacheBackfill_already_cached" + if _, err := client.Collection("users").Doc(alreadyCachedID).Set(ctx, map[string]interface{}{ + "display_name": "cached user", + "screen_name": "backfill_cached", + "last_sanpai": now.AddDate(0, 0, -1), + "status": map[string]interface{}{"total": int64(1)}, + }); err != nil { + t.Fatalf("failed to seed already-cached user: %v", err) + } + + dormantID := "TestStatusCacheBackfill_dormant" + if _, err := client.Collection("users").Doc(dormantID).Set(ctx, map[string]interface{}{ + "display_name": "dormant user", + "screen_name": "backfill_dormant", + "last_sanpai": now.AddDate(0, -7, 0), + }); err != nil { + t.Fatalf("failed to seed dormant user: %v", err) + } + + if err := runStatusCacheBackfill(ctx, client, now); err != nil { + t.Fatalf("runStatusCacheBackfill: %v", err) + } + + targetDoc, err := client.Collection("users").Doc(targetID).Get(ctx) + if err != nil { + t.Fatalf("failed to read target user: %v", err) + } + var targetData backfillUserDoc + if err := targetDoc.DataTo(&targetData); err != nil { + t.Fatalf("DataTo: %v", err) + } + if targetData.Status == nil { + t.Fatal("target user should have status cached after backfill") + } + if targetData.Status.User.ScreenName != "backfill_target" { + t.Errorf("cached status.user.screen_name = %q, want backfill_target", targetData.Status.User.ScreenName) + } + + cachedDoc, err := client.Collection("users").Doc(alreadyCachedID).Get(ctx) + if err != nil { + t.Fatalf("failed to read already-cached user: %v", err) + } + var cachedData backfillUserDoc + if err := cachedDoc.DataTo(&cachedData); err != nil { + t.Fatalf("DataTo: %v", err) + } + if cachedData.Status == nil || cachedData.Status.Total != 1 { + t.Errorf("already-cached user's status should be untouched, got %+v", cachedData.Status) + } + + dormantDoc, err := client.Collection("users").Doc(dormantID).Get(ctx) + if err != nil { + t.Fatalf("failed to read dormant user: %v", err) + } + var dormantData backfillUserDoc + if err := dormantDoc.DataTo(&dormantData); err != nil { + t.Fatalf("DataTo: %v", err) + } + if dormantData.Status != nil { + t.Errorf("dormant user (last_sanpai > 6 months ago) should not be backfilled, got status=%+v", dormantData.Status) + } +} + +// TestStatusCacheBackfill_RespectsMaxPerRunLimit は、対象ユーザーがMAX_PER_RUNを +// 超えて存在する場合に「1回の実行では処理しきらない(=次回に持ち越す)」ことを検証する。 +// 同一Firestoreを共有する他テストの影響を受けても壊れないよう、厳密な処理件数の +// 一致ではなく「1回目で全件は終わらない」「複数回実行すれば最終的に全件終わる」 +// という不変条件で検証する。 +func TestStatusCacheBackfill_RespectsMaxPerRunLimit(t *testing.T) { + client := emulatorClient(t) + ctx := context.Background() + now := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + + prefix := "TestStatusCacheBackfill_MaxPerRun_" + total := statusCacheBackfillMaxPerRun + 3 + ids := make([]string, total) + for i := 0; i < total; i++ { + id := prefix + string(rune('a'+i)) + ids[i] = id + if _, err := client.Collection("users").Doc(id).Set(ctx, map[string]interface{}{ + "display_name": "user " + id, + "screen_name": id, + "last_sanpai": now.AddDate(0, 0, -1), + }); err != nil { + t.Fatalf("failed to seed user %s: %v", id, err) + } + } + + countBackfilled := func() int { + n := 0 + for _, id := range ids { + doc, err := client.Collection("users").Doc(id).Get(ctx) + if err != nil { + t.Fatalf("failed to read user %s: %v", id, err) + } + var data backfillUserDoc + if err := doc.DataTo(&data); err != nil { + t.Fatalf("DataTo: %v", err) + } + if data.Status != nil { + n++ + } + } + return n + } + + if err := runStatusCacheBackfill(ctx, client, now); err != nil { + t.Fatalf("runStatusCacheBackfill (1st run): %v", err) + } + afterFirstRun := countBackfilled() + if afterFirstRun >= total { + t.Errorf("1回目の実行で%d件全て処理されてしまった(MAX_PER_RUN=%dの上限が効いていない)", total, statusCacheBackfillMaxPerRun) + } + + // 他ユーザーとの共有状態による揺れを許容しつつ、複数回実行すれば + // 最終的に全員処理されることを確認する(上限が処理漏れを起こしていないこと)。 + const maxRuns = 5 + for i := 0; i < maxRuns && countBackfilled() < total; i++ { + if err := runStatusCacheBackfill(ctx, client, now); err != nil { + t.Fatalf("runStatusCacheBackfill (retry): %v", err) + } + } + if got := countBackfilled(); got != total { + t.Errorf("複数回実行後の処理済み件数 = %d, want %d", got, total) + } +} diff --git a/docs/backend.md b/docs/backend.md index fe34992..cdf75e2 100644 --- a/docs/backend.md +++ b/docs/backend.md @@ -110,7 +110,9 @@ Go(Cloud Run functions)に移植し、`sanpaiGo` という別関数名でデプ - Firestoreへの書き込み(アクティビティのバッチ登録、`sanpai_logs` 追記、 `last_sanpai`/`exp`/`status`/`last_activity_created_at` の更新)は、Firestore エミュレータ + モックGitHub APIサーバーを使ったGoテスト(`sanpai_test.go`。 - `FIRESTORE_EMULATOR_HOST` 未設定時は自動スキップし通常のCIには影響しない)で、 + ローカルでは `FIRESTORE_EMULATOR_HOST` 未設定時は自動スキップされるが、 + CIでは `firebase emulators:exec` でFirestoreエミュレータを起動した状態で + `go test ./...` を実行しているためスキップされず検証される)で、 初回参拝・増分参拝・クールダウン中・新着なし・未登録の各分岐を確認済み。 ### 既知の挙動(Node側からそのまま引き継いだ、あえて修正していない点) @@ -202,24 +204,63 @@ OGP/Twitterカード用のメタタグを書き換える。 - `userOGP` で生成するOGP画像そのもの(Canvas/Chart.jsによる画像合成)は 今回のGo移植の対象外(下記「Go移植を見送った機能」参照)。 +## スケジュール関数のGo移植 + +`rankingUpdate`/`rankingCache`/`statusCacheBackfill`/`scheduledOgpDelete` は +いずれも Pub/Sub(Cloud Scheduler)トリガーで、ユーザーがブラウザで結果を +待つものではないためコールドスタート短縮による体感速度の改善効果は無いが、 +実行時間短縮による課金削減とコード基盤の統一を目的にGoへ移植した +(`rankingUpdateGo`/`rankingCacheGo`/`statusCacheBackfillGo`/`scheduledOgpDeleteGo`)。 +他のGo移植と同様、Node版とは別関数名・別Pub/Subトピック・別Cloud Schedulerジョブで +完全に独立してデプロイしており、安定稼働を確認してからNode版を停止する。 + +- **`rankingUpdateGo`**: 全ユーザーを `status.total` 降順で取得し、同点は同順位に + なるよう順位を付け直して `cache_data/ranking_cache` に書き込む。 + Node版は `orderBy` 済みの配列に対して `sort((a,b)=>b.battlePoint-a.battlePoint)` + を再度呼んでいるが、`battlePoint`(camelCase)は存在しないフィールド名の + タイプミスで常に `NaN` を返す比較関数になっており、V8の配列ソートの挙動上 + 実質的に無意味な処理(既存のFirestoreクエリ順をそのまま維持するno-op)に + なっている。Go版はこの無意味な再ソートを行わず、Firestoreクエリの結果順を + そのまま使う(観測できる出力はNode版と同一)。 + また `orderBy("status.total", ...)` はFirestoreの仕様上、対象フィールドを + 持たないドキュメント(status未計算のユーザー)を自動的に除外するため、 + Node版で起こり得る `.status.total` のundefined参照は発生しない。 +- **`rankingCacheGo`**: 全ユーザーのうち `point_ranking/{id}` が未作成のものに + 初期値(`rank:0`)を作成する。Node版は `snapshot.forEach(async (item) => {...})` + という非同期コールバックをawaitしないfire-and-forgetな実装になっており、 + 理論上は全ユーザー分の書き込みが完了する前に関数の実行が終了したとみなされ得る + (書き込み欠落のリスクがある)。これは意図された仕様ではなく実装上の不備と + 判断し、Go版では各ユーザーの処理を順番に確実に完了させる。書き込まれる + データの内容自体(既存ユーザーはスキップ、無いユーザーはrank:0で作成)は同一。 +- **`statusCacheBackfillGo`**: `status`/`statusGo` と同じ集計ロジック + (`loadActivities`/`performance.UserPerformance`等)を再利用しており、 + 直近6ヶ月以内に参拝したユーザーのうちstatus未計算のものを1回の実行につき + 最大10件だけ計算してキャッシュする、という挙動はNode版と同一。 +- **`scheduledOgpDeleteGo`**: Cloud Storageの `ogps/` プレフィックス配下の + ファイルを全て削除する。Node版は `bucket.deleteFiles({prefix:"ogps/"})` の + 戻り値をawait/returnしておらず、削除失敗時の挙動は実質的に不可視な + fire-and-forgetになっている(このジョブ自体は1時間毎に再実行される + 冪等なクリーンアップ処理のため実害は無い)。Go版は1ファイルの削除失敗を + ログに残しつつ他のファイルの削除は継続する(ジョブ全体を失敗させない)。 +- デプロイ方法(Pub/Subトリガー・Cloud Scheduler設定)は + `app/functions-go/README.md` を参照。 + ## Go移植を見送った機能 - **`userOGP`(OGP画像生成)**: `canvas`/`chartjs-node-canvas` によるサーバーサイド 画像合成(ユーザーアイコンの切り抜き・レーダーチャート描画・PNG合成)を行っており、 Node.jsの画像処理ライブラリに強く依存している。Goで同等の結果を得るには 画像合成・チャート描画を別のライブラリで一から実装し直す必要があり、 - 他のHTTPエンドポイント(`status`/`sanpai`/`ranking`/`register`/`ogpRewrite`)とは - 技術的な難易度・作業量が大きく異なる。また `userOGP` は生成結果をStorageに - キャッシュし、ブラウザがOGP画像を非同期に取得する経路(SNSクローラー向け)で - あり、`ogpRewrite`のようにページの初期表示をブロックしないため、レイテンシー - 改善の優先度は他のエンドポイントより低い。そのため今回は移植を見送り、 + 他のエンドポイントとは技術的な難易度・作業量が大きく異なる。加えて、 + Node版はCanvas(Cairo)とChart.jsが担う画像のレンダリング(アンチエイリアス・ + フォント描画・チャート描画アルゴリズム)に依存しており、Go側で別の描画 + ライブラリを使う場合、ピクセル単位で完全に同一の画像を出力することは + 現実的に不可能(見た目が近い画像は作れても「Node版と挙動が同一」とは + 言えない)。これは本移植プロジェクト全体の設計原則(挙動をNode版と + 同一にすることを優先する)と相性が悪く、他のエンドポイントより + 慎重な検討が必要と判断し、今回は見送った。また `userOGP` は生成結果を + Storageにキャッシュし、ブラウザがOGP画像を非同期に取得する経路 + (SNSクローラー向け)であり、`ogpRewrite`のようにページの初期表示を + ブロックしないため、レイテンシー改善の優先度も他のエンドポイントより低い。 必要になった場合に改めて設計を提案する。 -- **スケジュール関数(`rankingUpdate`/`rankingCache`/`statusCacheBackfill`/ - `scheduledOgpDelete`)**: いずれも Pub/Sub(Cloud Scheduler)トリガーで、 - ユーザーがブラウザで結果を待つものではないためコールドスタート短縮の - 恩恵(体感速度の改善)が無い。Go化しても実行時間短縮によるわずかな - 課金削減効果はあり得るが、Pub/SubトリガーのCloud Run functionsをGoで - デプロイするには専用のCloudEventハンドラ実装とCloud Scheduler/Pub/Sub - トピックの追加設定が必要で、HTTPトリガー関数とは異なるインフラ変更を伴う。 - 今回はユーザーが直接待つHTTPエンドポイントを優先したため対象外とした。