| 1 | # Nightly batch on a Cloud Run Job |
| 2 | |
| 3 | A Cloud Run Job that rolls the last 24 hours of orders in BigQuery up into a JSON summary in Cloud Storage. Cloud Scheduler runs it every night, and a public admin route runs it on demand. |
| 4 | |
| 5 | - [`Summarize`](./src/Summarize.ts): a `GCP.Run.Job` with an Effect-native `run` entry. It queries the `orders` table grouped by region and writes `daily/<YYYY-MM-DD>.json` with per-region and overall order counts and revenue. Re-running on the same day overwrites that day's object. |
| 6 | - [`Nightly`](./src/Nightly.ts): a `GCP.CloudScheduler.Job` that `POST`s to the Cloud Run Admin API's `jobs:run` endpoint for `Summarize` at 02:00 UTC. It authenticates with an OAuth token for the job's own runtime service account, which a `GCP.IAM.Member` grants `roles/run.invoker` on the job. |
| 7 | - [`Admin`](./src/Admin.ts): a public `GCP.Function` (Cloud Run service). `POST /run` starts an execution and returns its operation name. |
| 8 | - [`Orders`, `Reports`](./src/resources.ts): the BigQuery table (in the `Warehouse` dataset) and the bucket the summaries land in. |
| 9 | |
| 10 | A summary looks like this: |
| 11 | |
| 12 | ```json |
| 13 | { |
| 14 | "from": "2026-09-26T02:00:04.120Z", |
| 15 | "to": "2026-09-27T02:00:04.120Z", |
| 16 | "execution": "summarize-abcde", |
| 17 | "orders": 5, |
| 18 | "revenueCents": 5999, |
| 19 | "regions": [ |
| 20 | { "region": "east", "orders": 3, "revenueCents": 3999 }, |
| 21 | { "region": "west", "orders": 2, "revenueCents": 2000 } |
| 22 | ] |
| 23 | } |
| 24 | ``` |
| 25 | |
| 26 | ## Bindings and IAM |
| 27 | |
| 28 | | Binding / resource | Grants | |
| 29 | | --- | --- | |
| 30 | | `GCP.BigQuery.ReadTable` + `ReadTableHttp` (on `Summarize`) | the job's runtime account `roles/bigquery.dataViewer` on the table and `roles/bigquery.jobUser` on the project, because query jobs can only be granted at project level | |
| 31 | | `GCP.Storage.WriteBucket` + `WriteBucketHttp` (on `Summarize`) | the job's runtime account `roles/storage.objectUser` on the bucket | |
| 32 | | `GCP.Run.RunJob` + `RunJobHttp` (on `Admin`) | the service's runtime account `roles/run.jobsExecutorWithOverrides` on the job | |
| 33 | | `GCP.IAM.Member("NightlyRunner")` | the job's runtime account `roles/run.invoker` on the job, which includes `run.jobs.run`. Cloud Scheduler mints the OAuth token for that account. | |
| 34 | |
| 35 | Cloud Scheduler uses an OAuth token, not OIDC, because the target is a `*.googleapis.com` API rather than a Cloud Run URL. `Admin` sets `invokerIamDisabled: true` so the example can be driven with `curl`; in production, drop it and call `/run` with an identity token. |
| 36 | |
| 37 | ## Deploy |
| 38 | |
| 39 | From the repository root: |
| 40 | |
| 41 | ```sh |
| 42 | pnpm install |
| 43 | cd examples/gcp-scheduled-job |
| 44 | pnpm deploy --profile <name> |
| 45 | ``` |
| 46 | |
| 47 | This needs a local Docker daemon to build the images. Then start a run with the printed `url`: |
| 48 | |
| 49 | ```sh |
| 50 | curl -X POST "$URL/run" |
| 51 | ``` |
| 52 | |
| 53 | A Cloud Run Job execution can take a couple of minutes to start. To run the schedule itself, use `gcloud scheduler jobs run <schedulerJobName> --location <region>`. |
| 54 | |
| 55 | ## Live test |
| 56 | |
| 57 | ```sh |
| 58 | ALCHEMY_PROFILE=<name> bun test |
| 59 | ``` |
| 60 | |
| 61 | The test deploys the stack and seeds five orders with BigQuery `insertAll`. It calls `POST /run`, polls the bucket until a summary with the seeded regions appears, and checks the counts and totals. It then forces the Cloud Scheduler job with `cloudscheduler.runProjectsLocationsJobs` and waits for a new execution of the job. Finally it destroys the stack and verifies the job, the scheduler job, and the bucket are gone. |
| 62 | |
| 63 | ## Destroy |
| 64 | |
| 65 | ```sh |
| 66 | pnpm destroy --profile <name> |
| 67 | ``` |