From 99bf58a35b63ec9c1ed76534eab81dacfdc2a659 Mon Sep 17 00:00:00 2001 From: sdairs Date: Fri, 2 Oct 2026 14:01:15 +0100 Subject: [PATCH 1/3] Add Kotlin usage meter with transactional quotas and ClickPipes reports --- .github/workflows/usage-meter.yml | 24 ++ applications/usage-meter/.env.example | 13 + applications/usage-meter/.gitattributes | 1 + applications/usage-meter/.gitignore | 5 + applications/usage-meter/README.md | 212 +++++++++++++++ applications/usage-meter/build.gradle.kts | 31 +++ applications/usage-meter/gradle.lockfile | 157 +++++++++++ applications/usage-meter/gradle.properties | 3 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 47623 bytes .../gradle/wrapper/gradle-wrapper.properties | 10 + applications/usage-meter/gradlew | 248 ++++++++++++++++++ applications/usage-meter/gradlew.bat | 112 ++++++++ .../usage-meter/infra/clickpipe-events.json | 11 + .../usage-meter/migrations/001_down.sql | 1 + .../usage-meter/migrations/001_up.sql | 33 +++ .../usage-meter/scripts/cloud-convergence.py | 54 ++++ .../usage-meter/scripts/cloud-core.py | 135 ++++++++++ applications/usage-meter/scripts/cloud-lag.py | 42 +++ .../usage-meter/scripts/process-restart.sh | 32 +++ .../usage-meter/scripts/start-runtime.sh | 31 +++ applications/usage-meter/settings.gradle.kts | 1 + applications/usage-meter/sql/bootstrap.sql | 16 ++ applications/usage-meter/sql/migrate.sql | 11 + applications/usage-meter/sql/publication.sql | 9 + applications/usage-meter/sql/revert.sql | 7 + applications/usage-meter/sql/seed.sql | 8 + .../src/main/kotlin/meter/Analytics.kt | 69 +++++ .../src/main/kotlin/meter/Config.kt | 63 +++++ .../usage-meter/src/main/kotlin/meter/Main.kt | 78 ++++++ .../src/main/kotlin/meter/Models.kt | 52 ++++ .../src/main/kotlin/meter/Store.kt | 91 +++++++ .../src/main/resources/logback.xml | 7 + .../src/test/kotlin/meter/CloudClockTest.kt | 45 ++++ .../src/test/kotlin/meter/CloudTlsTest.kt | 28 ++ .../src/test/kotlin/meter/ValidationTest.kt | 36 +++ 35 files changed, 1676 insertions(+) create mode 100644 .github/workflows/usage-meter.yml create mode 100644 applications/usage-meter/.env.example create mode 100644 applications/usage-meter/.gitattributes create mode 100644 applications/usage-meter/.gitignore create mode 100644 applications/usage-meter/README.md create mode 100644 applications/usage-meter/build.gradle.kts create mode 100644 applications/usage-meter/gradle.lockfile create mode 100644 applications/usage-meter/gradle.properties create mode 100644 applications/usage-meter/gradle/wrapper/gradle-wrapper.jar create mode 100644 applications/usage-meter/gradle/wrapper/gradle-wrapper.properties create mode 100755 applications/usage-meter/gradlew create mode 100644 applications/usage-meter/gradlew.bat create mode 100644 applications/usage-meter/infra/clickpipe-events.json create mode 100644 applications/usage-meter/migrations/001_down.sql create mode 100644 applications/usage-meter/migrations/001_up.sql create mode 100644 applications/usage-meter/scripts/cloud-convergence.py create mode 100644 applications/usage-meter/scripts/cloud-core.py create mode 100644 applications/usage-meter/scripts/cloud-lag.py create mode 100755 applications/usage-meter/scripts/process-restart.sh create mode 100755 applications/usage-meter/scripts/start-runtime.sh create mode 100644 applications/usage-meter/settings.gradle.kts create mode 100644 applications/usage-meter/sql/bootstrap.sql create mode 100644 applications/usage-meter/sql/migrate.sql create mode 100644 applications/usage-meter/sql/publication.sql create mode 100644 applications/usage-meter/sql/revert.sql create mode 100644 applications/usage-meter/sql/seed.sql create mode 100644 applications/usage-meter/src/main/kotlin/meter/Analytics.kt create mode 100644 applications/usage-meter/src/main/kotlin/meter/Config.kt create mode 100644 applications/usage-meter/src/main/kotlin/meter/Main.kt create mode 100644 applications/usage-meter/src/main/kotlin/meter/Models.kt create mode 100644 applications/usage-meter/src/main/kotlin/meter/Store.kt create mode 100644 applications/usage-meter/src/main/resources/logback.xml create mode 100644 applications/usage-meter/src/test/kotlin/meter/CloudClockTest.kt create mode 100644 applications/usage-meter/src/test/kotlin/meter/CloudTlsTest.kt create mode 100644 applications/usage-meter/src/test/kotlin/meter/ValidationTest.kt diff --git a/.github/workflows/usage-meter.yml b/.github/workflows/usage-meter.yml new file mode 100644 index 00000000..efa360bf --- /dev/null +++ b/.github/workflows/usage-meter.yml @@ -0,0 +1,24 @@ +name: Usage meter checks +on: + pull_request: + paths: ['applications/usage-meter/**', '.github/workflows/usage-meter.yml'] + push: + branches: [main] + paths: ['applications/usage-meter/**', '.github/workflows/usage-meter.yml'] + workflow_dispatch: +permissions: + contents: read +jobs: + check: + runs-on: ubuntu-latest + defaults: + run: + working-directory: applications/usage-meter + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + - run: ./gradlew --no-daemon clean test installDist +# Real Cloud acceptance is explicit and does not use CI credentials. diff --git a/applications/usage-meter/.env.example b/applications/usage-meter/.env.example new file mode 100644 index 00000000..627dd0a6 --- /dev/null +++ b/applications/usage-meter/.env.example @@ -0,0 +1,13 @@ +PGHOST=YOUR_DIRECT_CLOUD_POSTGRES_HOSTNAME +PGPORT=5432 +PGDATABASE=postgres +PGUSER=usage_app +PGPASSWORD=YOUR_RUNTIME_PASSWORD +PGSSLROOTCERT=/absolute/path/to/postgres-ca.pem +CLICKHOUSE_URL=https://YOUR_CLICKHOUSE_HOSTNAME:8443 +CLICKHOUSE_USER=usage_reports +CLICKHOUSE_PASSWORD=YOUR_REPORT_PASSWORD +ACCOUNT_TOKENS='{"00000000-0000-4000-8000-000000000001":"YOUR_RANDOM_TOKEN_AT_LEAST_32_CHARACTERS","00000000-0000-4000-8000-000000000002":"ANOTHER_RANDOM_TOKEN_AT_LEAST_32_CHARACTERS"}' +PORT=3000 +# Tests only: RUN_CLOUD_TESTS=1 and TEST_WRONG_CA=/absolute/path/to/unrelated-ca.pem +# Keep migration, publication/CDC and administrator passwords out of this file. diff --git a/applications/usage-meter/.gitattributes b/applications/usage-meter/.gitattributes new file mode 100644 index 00000000..f663f18c --- /dev/null +++ b/applications/usage-meter/.gitattributes @@ -0,0 +1 @@ +gradlew.bat text eol=crlf diff --git a/applications/usage-meter/.gitignore b/applications/usage-meter/.gitignore new file mode 100644 index 00000000..8843db02 --- /dev/null +++ b/applications/usage-meter/.gitignore @@ -0,0 +1,5 @@ +.gradle/ +build/ +.deployment/ +.env +*.log diff --git a/applications/usage-meter/README.md b/applications/usage-meter/README.md new file mode 100644 index 00000000..f330f080 --- /dev/null +++ b/applications/usage-meter/README.md @@ -0,0 +1,212 @@ +# Kotlin usage meter + +An authenticated Ktor API accepts integer usage against an account's daily quota. ClickHouse Managed Postgres (public beta) stores accounts, counters and accepted events. ClickPipes copies events to an analytical ClickHouse service; the official Java client reads daily and feature totals. All three services run in ClickHouse Cloud. This is a software quota example, not a billing system. + +The API binds to localhost. A server-configured bearer token determines the account; clients cannot supply an account ID. `POST /usage` locks that account, checks a retained request ID, then updates its UTC daily counter and inserts an event in one Exposed transaction. A matching retry returns the original result, even after quota exhaustion or UTC midnight. A changed feature or units returns 409. The application never writes to ClickHouse. + +## Behavior and limits + +| Route | Result | +|---|---| +| `POST /usage` |201 for a newly accepted event,200 for a matching retained retry,409 for changed data,429 for exhausted quota | +| `GET /events/{requestId}` | Authenticated account's original acceptance result or 404 | +| `GET /quota` | Current UTC day's authoritative Postgres counter and remaining quota | +| `GET /reports?from=YYYY-MM-DD&to=YYYY-MM-DD` | Eventually consistent daily totals and feature breakdowns for that account | + +Request IDs contain 1–64 ASCII letters, digits, `.`, `_` or `-`. Units are 1–1,000; features are `api`, `export`, `storage`. Bodies are limited to 4 KiB; unknown JSON fields are rejected. Seed accounts have quotas 100 and 50. The account/request-ID unique constraint spans all dates: IDs are retained with their events indefinitely in this example. Implement coordinated retention before treating this as a production service; deleting an event releases its retry protection. + +Reports default to the last seven UTC days and permit only dates within the last 31 UTC days, including today. One account and three fixed features bound the result to 93 feature rows. Dates/account are query parameters, never SQL interpolation. Every successful report explicitly includes `consistency: "eventual"` and `quotaAuthority: "postgres"`. Missing pipeline data is not spare quota. The remaining amount on an acceptance response describes that acceptance, not the current counter. + +The shared Postgres runtime role is trusted application code, not a database tenant boundary. HTTP authentication supplies account scope. It cannot update/delete events, perform DDL or access migration history, but it can insert events and update counters; `UPDATE(id)` on accounts permits row locking. Do not expose these credentials to clients. Tokens are not stored in the replicated table. Use a real identity provider and per-account access policy before public deployment. + +A lost response can leave a committed event: retry the same ID and data to obtain the retained result, potentially 200 without ever observing 201. Temporary database/report failures return 503. Different accounts can progress concurrently; requests for one account serialize. This is deliberately a small example, with no throughput claim. + +## Requirements and build + +Use JDK 21, `psql`, Python 3, OpenSSL, curl, jq and [clickhousectl](https://clickhouse.com/docs/concepts/features/interfaces/cli). Configure the CLI's organization credentials without putting them in application runtime files. All commands below run from this directory in a Linux environment. Build/test dependency installation needs no database credentials: + +```sh +./gradlew --no-daemon clean test installDist +``` + +The wrapper pins Gradle 9.8.0 with its distribution checksum. Dependency locks pin Kotlin 2.4.20, Ktor 3.6.0, Exposed 1.5.0, pgJDBC 42.7.13 and `client-v2` 0.10.0, plus transitive versions. Four unit tests run normally; two real Cloud tests are opt-in. SQL migrations are explicit files, rather than schema generation during startup. `Store.kt` uses Exposed's JDBC transactions on a five-worker IO dispatcher; the pool has five connections. Postgres connect/pool acquisition/statement/socket limits are 5/15/20/25 seconds. These are layer limits, not an HTTP response-time promise. + +## Create the Cloud services + +These resources incur charges. Use a dedicated test name, save receipts privately, and delete your resources after validation. The demonstrated small Postgres shape is `c6gd.large` in AWS `us-east-1`, PostgreSQL 18 without HA. Check current availability and cost before reusing it. The analytical service uses the CLI's current minimum 8 GiB per replica and two replicas for a new warehouse. + +```sh +umask 077 +mkdir -p .deployment +export ORG_ID='your-organization-id' +export DEV_EGRESS_IP='your-development-public-egress-ip' +clickhousectl cloud postgres create --org-id "$ORG_ID" \ + --name usage-meter-pg --provider aws --region us-east-1 \ + --size c6gd.large --pg-version 18 --ha-type none --json \ + > .deployment/postgres-create.json +export PG_ID=$(jq -er .id .deployment/postgres-create.json) +clickhousectl cloud service create --org-id "$ORG_ID" \ + --name usage-meter-analytics --provider aws --region us-east-1 \ + --min-replica-memory-gb 8 --max-replica-memory-gb 8 --num-replicas 2 \ + --idle-scaling false --ip-allow "$DEV_EGRESS_IP/32=usage-meter-dev" --json \ + > .deployment/clickhouse-create.json +export CH_ID=$(jq -er .service.id .deployment/clickhouse-create.json) +``` + +Poll `cloud postgres get "$PG_ID"` and `cloud service get "$CH_ID"` with `--org-id "$ORG_ID" --json` until both states are `running`; use a finite deadline (for example ten minutes). Do not continue on failure. The create receipts contain passwords that `get` does not return. Restrict the analytical endpoint allowlist to your development egress, and update it when that address changes. ClickPipes manages its destination connectivity. Keep source credentials restricted to publication replication. + +```sh +clickhousectl cloud postgres certs get "$PG_ID" --org-id "$ORG_ID" \ + --output .deployment/postgres-ca.pem +export PGHOST=$(jq -er .hostname .deployment/postgres-create.json) +export PGPORT=5432 PGDATABASE=postgres PGSSLMODE=verify-full +export PGSSLROOTCERT="$PWD/.deployment/postgres-ca.pem" +export PGADMIN=$(jq -er .username .deployment/postgres-create.json) +export PGPASSWORD=$(jq -er .password .deployment/postgres-create.json) +export USAGE_MIGRATOR_PASSWORD="Aa1_$(openssl rand -hex 24)" +export USAGE_APP_PASSWORD="Aa1_$(openssl rand -hex 24)" +export USAGE_CDC_PASSWORD="Aa1_$(openssl rand -hex 24)" +psql -X -v ON_ERROR_STOP=1 -U "$PGADMIN" -f sql/bootstrap.sql +PGUSER=usage_migrator PGPASSWORD="$USAGE_MIGRATOR_PASSWORD" \ + psql -X -v ON_ERROR_STOP=1 -f sql/migrate.sql +PGUSER=usage_migrator PGPASSWORD="$USAGE_MIGRATOR_PASSWORD" \ + psql -X -v ON_ERROR_STOP=1 -f sql/seed.sql +psql -X -v ON_ERROR_STOP=1 -U "$PGADMIN" -f sql/publication.sql +``` + +Bootstrap creates a non-login owner, a migration login, an application login and a replication login. Migration 1 is recorded under an advisory lock; repeating migrate/seed is safe. `sql/revert.sql` drops application tables and their data; for a lifecycle check use it **before** accepting events/creating the pipe, then migrate/seed again. Bootstrap creates roles once. Publication setup runs with admin credentials and requires logical replication. + +The source's NOT NULL unique index `(account_id,usage_day,event_id)` is its replica identity. This includes every [custom ClickPipes ordering column](https://clickhouse.com/docs/integrations/clickpipes/postgres/ordering-keys), so privileged deletion tombstones retain their full deduplication key. These columns are immutable in normal operation. Only `usage.events` belongs to the publication; accounts/counters/tokens are not replicated. + +Create the analytical reporting user through the service's trusted HTTPS endpoint. Keep its password separate from the admin password: + +```sh +export CLICKHOUSE_URL="https://$(jq -er '.service.endpoints[] | select(.protocol=="https") | .host' .deployment/clickhouse-create.json):$(jq -er '.service.endpoints[] | select(.protocol=="https") | .port' .deployment/clickhouse-create.json)" +export CH_REPORT_PASSWORD="Aa1_$(openssl rand -hex 24)" +printf 'url="%s/?max_execution_time=10&max_rows_to_read=1000000&max_bytes_to_read=100000000"\nuser="default:%s"\nfail-with-body\nsilent\nshow-error\n' \ + "$CLICKHOUSE_URL" "$(jq -er .password .deployment/clickhouse-create.json)" \ + > .deployment/ch-admin.conf +printf "CREATE USER usage_reports IDENTIFIED BY '%s' SETTINGS readonly=2, max_execution_time=5, max_rows_to_read=1000000, max_bytes_to_read=100000000, max_result_rows=93, result_overflow_mode='throw', max_threads=2;" \ + "$CH_REPORT_PASSWORD" > .deployment/report-user.sql +curl --config .deployment/ch-admin.conf --data-binary @.deployment/report-user.sql +export TOKEN_A=$(openssl rand -hex 32) TOKEN_B=$(openssl rand -hex 32) +export ACCOUNT_TOKENS=$(jq -cn --arg a "$TOKEN_A" --arg b "$TOKEN_B" \ + '{"00000000-0000-4000-8000-000000000001":$a,"00000000-0000-4000-8000-000000000002":$b}') +{ + printf 'export PGHOST=%q PGPORT=5432 PGDATABASE=postgres PGUSER=usage_app\n' "$PGHOST" + printf 'export PGPASSWORD=%q PGSSLROOTCERT=%q\n' "$USAGE_APP_PASSWORD" "$PGSSLROOTCERT" + printf 'export CLICKHOUSE_URL=%q CLICKHOUSE_USER=usage_reports CLICKHOUSE_PASSWORD=%q\n' "$CLICKHOUSE_URL" "$CH_REPORT_PASSWORD" + printf 'export ACCOUNT_TOKENS=%q\n' "$ACCOUNT_TOKENS" +} > .deployment/app.env +{ + printf 'export USAGE_MIGRATOR_PASSWORD=%q\n' "$USAGE_MIGRATOR_PASSWORD" + printf 'export USAGE_CDC_PASSWORD=%q\n' "$USAGE_CDC_PASSWORD" +} > .deployment/test-secrets.env +``` + +Never use `default` as the application client. `readonly=2` allows bounded query settings but prohibits writes; the following table-only grant is installed after the pipe creates its table. These direct HTTPS administration commands create no Cloud Query API credential. If instead using `cloud service query`, inventory and later remove any automatically created Query API credentials/endpoints; preserve your pre-existing organization API key. + +## Run and create snapshot fixtures + +Start a **fresh shell**, then load only runtime values: + +```sh +source .deployment/app.env +PORT=3300 bash scripts/start-runtime.sh +export TOKEN_A=$(jq -er '."00000000-0000-4000-8000-000000000001"' <<< "$ACCOUNT_TOKENS") +export TOKEN_B=$(jq -er '."00000000-0000-4000-8000-000000000002"' <<< "$ACCOUNT_TOKENS") +curl -sS --fail-with-body -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \ + -d '{"requestId":"snapshot-api","feature":"api","units":5}' http://127.0.0.1:3300/usage +curl -sS --fail-with-body -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \ + -d '{"requestId":"snapshot-export","feature":"export","units":3}' http://127.0.0.1:3300/usage +curl -sS --fail-with-body -H "Authorization: Bearer $TOKEN_B" -H 'Content-Type: application/json' \ + -d '{"requestId":"snapshot-b","feature":"api","units":4}' http://127.0.0.1:3300/usage +``` + +The helper whitelists only runtime fields for the child process, excluding setup credentials even if the invoking shell still has them. It checks authenticated quota readiness with a 90-second deadline. The JVM binds to localhost. Postgres verifies the downloaded CA and hostname with pgJDBC `verify-full`; ClickHouse uses normal trusted HTTPS, with no permissive trust manager. Logs/errors omit credentials and database connection strings. + +Before creating the pipe, run the optional real Cloud controls. Generate a different CA and pass its path; these tests do not use a local Postgres substitute: + +```sh +source .deployment/app.env +openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj /CN=wrong-ca \ + -keyout .deployment/wrong-ca.key -out .deployment/wrong-ca.pem +RUN_CLOUD_TESTS=1 TEST_WRONG_CA="$PWD/.deployment/wrong-ca.pem" \ + ./gradlew --no-daemon test --rerun-tasks +``` + +This runs four unit tests plus verified TLS/wrong-CA/wrong-hostname and fixed-clock UTC-midnight retry tests. The clock test adds one event to accountB's preceding-day counter while leaving its current-day counter unchanged. + +## Create ClickPipes and read reports + +In the original **setup shell**, whose `PGHOST`, IDs and replication password are still set (or explicitly restore those values from the private receipts/test-secrets file): + +```sh +clickhousectl cloud clickpipe create postgres "$CH_ID" --org-id "$ORG_ID" \ + --name usage-meter-events --host "$PGHOST" --port 5432 --pg-database postgres \ + --username usage_cdc --password "$USAGE_CDC_PASSWORD" \ + --ca-certificate .deployment/postgres-ca.pem --publication-name usage_events_clickpipe \ + --replication-mode cdc --sync-interval-seconds 10 --pull-batch-size 10000 \ + --initial-load-parallelism 1 --snapshot-parallel-tables 1 --delete-on-merge false \ + --table-mapping-json "$(cat infra/clickpipe-events.json)" --json \ + > .deployment/clickpipe-create.json +export PIPE_ID=$(jq -er .id .deployment/clickpipe-create.json) +clickhousectl cloud clickpipe get "$CH_ID" "$PIPE_ID" --org-id "$ORG_ID" --json +``` + +Wait with a finite deadline for `Running` and for `default.cdc_usage_events` to exist. If the analytical service was idled, wake it and wait for `running` before pipe creation. The mapping asks ClickPipes to create a ReplacingMergeTree destination, no partition and custom `(account_id,usage_day,event_id)` sorting. Do not manually create or modify this managed table. Inspect databases/tables, columns/comments, `SHOW CREATE TABLE`, indices, a limited sample and `EXPLAIN` before relying on its layout: + +```sh +curl --config .deployment/ch-admin.conf --data-binary 'SHOW CREATE TABLE default.cdc_usage_events' +curl --config .deployment/ch-admin.conf --data-binary \ + 'GRANT SELECT ON default.cdc_usage_events TO usage_reports' +``` + +The validated Cloud mapping produced `SharedReplacingMergeTree(...,_peerdb_version)` with UUIDs, `Date32`, `Int16` units, `Int32` remaining and `DateTime64(6)` timestamps. Feature is `String`: source/API checks bound it to three values; this example leaves the managed mapping intact rather than altering it to Enum/LowCardinality. There is no lifecycle requirement for partitioning and no materialized view over the CDC version stream. + +`Analytics.kt` applies `FINAL` and filters `_peerdb_is_deleted=0` **before** aggregation, following [ClickPipes deduplication guidance](https://clickhouse.com/docs/integrations/clickpipes/postgres/deduplication). Background merges are not assumed complete. No `OPTIMIZE FINAL` is issued. Reports filter on the ordering prefix and have 5-second execution,1 million scanned rows,100 MB scanned bytes and 93 result-row caps; cap violations fail instead of returning partial totals. + +The synchronous Java client runs on its own two-worker IO dispatcher, uses two connections, zero automatic retries, 5-second connect/pool-acquisition and 10-second socket limits. The additional 15-second Future wait is not an end-to-end request deadline. Slow/unavailable analytics returns 503 without being consulted for quota. The startup check consults Postgres accounts; it does not require reports to be ready. + +```sh +# Runtime shell +curl -sS --fail-with-body -H "Authorization: Bearer $TOKEN_A" http://127.0.0.1:3300/quota +curl -sS --fail-with-body -H "Authorization: Bearer $TOKEN_A" http://127.0.0.1:3300/reports +source .deployment/test-secrets.env # Test runner only; never added to app.env. +python3 scripts/cloud-core.py +REPORT_EVIDENCE=.deployment/converged.json python3 scripts/cloud-convergence.py +bash scripts/process-restart.sh +``` + +Run `cloud-core.py` once on the fresh snapshot fixture (Aused 8/Bused 4). Twelve clients retry 503 only with the original ID/data and a bounded retry deadline; output distinguishes clients from actual attempts. It asserts one retained event/debit, changed retry 409, exact quota 100 with nine accepted ten-unit requests and three denied, exhausted matching retry 200, cross-account 404, auth/body/date controls, role denials and rollback after a counter mutation. Its temporary owner-created trigger forces event insertion to fail, then is removed in `finally`. The convergence check compares exact day/feature sums **and counts** to Postgres, with a 180-second polling deadline. Stop new writes while performing that fixed expected-snapshot comparison. + +To demonstrate lag, use `clickpipe stop "$CH_ID" "$PIPE_ID" --org-id "$ORG_ID"`, wait for `Paused`, accept additional usage for account B, and observe that `/quota` advances while `/reports` retains its earlier totals. Resume with `clickpipe start`, wait for `Running`, then repeat convergence. The supplied `cloud-lag.py` makes this check explicit. Run `python3 scripts/cloud-lag.py prepare` after convergence, pause/wait, then run `python3 scripts/cloud-lag.py paused`; the latter accepts six units for account B and checks unchanged reports. To test an analytical access outage, temporarily use the admin HTTPS config to `REVOKE SELECT ON default.cdc_usage_events FROM usage_reports`, run `python3 scripts/cloud-lag.py outage`, then restore `GRANT SELECT ON default.cdc_usage_events TO usage_reports` even if the test fails. `python3 scripts/cloud-lag.py recover` checks both report capacity slots in the same process. Resume the pipe and run convergence again. These controls require the dedicated fixture; they change its accepted-event totals. + +Ten seconds is a configured sync interval, not an ingestion-latency promise. Privileged source maintenance and `clickpipe resync` are separate controls; they are not normal runtime event mutations. + +## Cleanup + +Stop each locally started process using its `.deployment/server-PORT.pid`. In the setup shell restore resource IDs from receipts if needed. Delete the pipe before databases and verify each ID is absent from its corresponding list. Remove only ancillary Query API resources created by your own optional administration route; none are created by the direct HTTPS procedure above. + +```sh +clickhousectl cloud clickpipe delete "$CH_ID" "$PIPE_ID" --org-id "$ORG_ID" +clickhousectl cloud clickpipe list "$CH_ID" --org-id "$ORG_ID" --json +clickhousectl cloud postgres delete "$PG_ID" --org-id "$ORG_ID" +clickhousectl cloud service delete "$CH_ID" --org-id "$ORG_ID" --force +clickhousectl cloud postgres list --org-id "$ORG_ID" --json +clickhousectl cloud service list --org-id "$ORG_ID" --json +``` + +If retaining the databases but removing this fixture, first delete the pipe, then use the admin receipt rather than runtime credentials: + +```sh +source .deployment/app.env +export PGSSLMODE=verify-full +export PGUSER=$(jq -er .username .deployment/postgres-create.json) +export PGPASSWORD=$(jq -er .password .deployment/postgres-create.json) +psql -X -v ON_ERROR_STOP=1 -c 'DROP PUBLICATION usage_events_clickpipe; DROP SCHEMA usage CASCADE; DROP ROLE usage_app, usage_cdc, usage_migrator; DROP ROLE usage_owner;' +curl --config .deployment/ch-admin.conf --data-binary 'DROP USER usage_reports' +curl --config .deployment/ch-admin.conf --data-binary 'DROP TABLE IF EXISTS default.cdc_usage_events' +``` + +Remove private receipts/runtime files after your own retention needs are met. See [ClickHouse Managed Postgres documentation](https://clickhouse.com/docs/products/managed-postgres/overview), [Ktor authentication](https://ktor.io/docs/server-auth.html), [Exposed transactions](https://www.jetbrains.com/help/exposed/transactions.html), [pgJDBC TLS](https://jdbc.postgresql.org/documentation/ssl/) and the [Java client](https://clickhouse.com/docs/integrations/language-clients/java/client). diff --git a/applications/usage-meter/build.gradle.kts b/applications/usage-meter/build.gradle.kts new file mode 100644 index 00000000..1e546bb2 --- /dev/null +++ b/applications/usage-meter/build.gradle.kts @@ -0,0 +1,31 @@ +plugins { + kotlin("jvm") version "2.4.20" + kotlin("plugin.serialization") version "2.4.20" + application +} +repositories { mavenCentral() } +kotlin { jvmToolchain(21) } +application { mainClass.set("meter.MainKt") } +dependencyLocking { lockAllConfigurations() } +dependencies { + val ktor = "3.6.0" + implementation("io.ktor:ktor-server-netty-jvm:$ktor") + implementation("io.ktor:ktor-server-auth-jvm:$ktor") + implementation("io.ktor:ktor-server-content-negotiation-jvm:$ktor") + implementation("io.ktor:ktor-server-status-pages-jvm:$ktor") + implementation("io.ktor:ktor-server-body-limit-jvm:$ktor") + implementation("io.ktor:ktor-serialization-kotlinx-json-jvm:$ktor") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0") + implementation("org.jetbrains.exposed:exposed-core:1.5.0") + implementation("org.jetbrains.exposed:exposed-jdbc:1.5.0") + implementation("org.jetbrains.exposed:exposed-java-time:1.5.0") + implementation("org.postgresql:postgresql:42.7.13") + implementation("com.zaxxer:HikariCP:7.1.0") + implementation("com.clickhouse:client-v2:0.10.0") + implementation("ch.qos.logback:logback-classic:1.6.5") + testImplementation(kotlin("test-junit5")) + testImplementation("org.junit.jupiter:junit-jupiter:6.0.3") + testRuntimeOnly("org.junit.platform:junit-platform-launcher:6.0.3") +} +tasks.test { useJUnitPlatform() } diff --git a/applications/usage-meter/gradle.lockfile b/applications/usage-meter/gradle.lockfile new file mode 100644 index 00000000..24a59e12 --- /dev/null +++ b/applications/usage-meter/gradle.lockfile @@ -0,0 +1,157 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +# To regenerate this file, run: ./gradlew :dependencies --write-locks +at.yawk.lz4:lz4-java:1.10.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-classic:1.6.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.6.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.charleskorn.kaml:kaml-jvm:0.79.0=runtimeClasspath,testRuntimeClasspath +com.charleskorn.kaml:kaml:0.79.0=runtimeClasspath,testRuntimeClasspath +com.clickhouse:clickhouse-client:0.10.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.clickhouse:clickhouse-data:0.10.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.clickhouse:client-v2:0.10.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.36.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.guava:failureaccess:1.0.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.guava:guava:33.4.6-jre=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.j2objc:j2objc-annotations:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio-jvm:3.12.0=runtimeClasspath,testRuntimeClasspath +com.squareup.okio:okio:3.12.0=runtimeClasspath,testRuntimeClasspath +com.typesafe:config:1.4.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.zaxxer:HikariCP:7.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-codec:commons-codec:1.19.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-io:commons-io:2.20.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-client-core-jvm:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-client-core:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-events-jvm:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-events:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-http-cio-jvm:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-http-cio:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-http-jvm:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-http:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-io-jvm:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-io:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-network-jvm:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-network:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-openapi-schema-jvm:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-openapi-schema:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-serialization-jvm:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-serialization-kotlinx-json-jvm:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-serialization-kotlinx-jvm:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-serialization-kotlinx:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-serialization:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-server-auth-api-key-jvm:3.6.0=runtimeClasspath,testRuntimeClasspath +io.ktor:ktor-server-auth-api-key:3.6.0=runtimeClasspath,testRuntimeClasspath +io.ktor:ktor-server-auth-jvm:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-server-auth:3.6.0=runtimeClasspath,testRuntimeClasspath +io.ktor:ktor-server-body-limit-jvm:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-server-content-negotiation-jvm:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-server-core-jvm:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-server-core:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-server-csrf-jvm:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-server-csrf:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-server-netty-jvm:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-server-routing-openapi-jvm:3.6.0=runtimeClasspath,testRuntimeClasspath +io.ktor:ktor-server-routing-openapi:3.6.0=runtimeClasspath,testRuntimeClasspath +io.ktor:ktor-server-sessions-jvm:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-server-sessions:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-server-status-pages-jvm:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-sse-jvm:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-sse:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-utils-jvm:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-utils:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-websocket-serialization-jvm:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-websocket-serialization:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-websockets-jvm:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.ktor:ktor-websockets:3.6.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-buffer:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-codec-base:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-codec-classes-quic:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-codec-compression:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-codec-http2:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-codec-http3:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-codec-http:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-codec-marshalling:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-codec-native-quic:4.2.17.Final=runtimeClasspath,testRuntimeClasspath +io.netty:netty-codec-protobuf:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-codec:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-common:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-handler:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-resolver:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-transport-classes-epoll:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-transport-classes-kqueue:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-transport-native-epoll:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-transport-native-kqueue:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-transport-native-unix-common:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-transport:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +it.krzeminski:snakeyaml-engine-kmp-jvm:3.1.1=runtimeClasspath,testRuntimeClasspath +it.krzeminski:snakeyaml-engine-kmp:3.1.1=runtimeClasspath,testRuntimeClasspath +net.thauvin.erik.urlencoder:urlencoder-lib-jvm:1.6.0=runtimeClasspath,testRuntimeClasspath +net.thauvin.erik.urlencoder:urlencoder-lib:1.6.0=runtimeClasspath,testRuntimeClasspath +org.apache.commons:commons-compress:1.28.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.commons:commons-lang3:3.20.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.httpcomponents.client5:httpclient5:5.4.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.httpcomponents.core5:httpcore5-h2:5.3.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.httpcomponents.core5:httpcore5:5.3.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.checkerframework:checker-qual:3.55.1=runtimeClasspath,testRuntimeClasspath +org.eclipse.jetty.alpn:alpn-api:1.1.3.v20160715=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains.exposed:exposed-core:1.5.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains.exposed:exposed-java-time:1.5.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains.exposed:exposed-jdbc:1.5.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-build-tools-api:2.4.20=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-build-tools-compat:2.4.20=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.20=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.20=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.20=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-compiler-runner:2.4.20=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-daemon-client:2.4.20=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.20=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-reflect:1.6.10=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-reflect:2.3.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-script-runtime:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest +org.jetbrains.kotlin:kotlin-scripting-common:2.4.20=kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest +org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:2.4.20=kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest +org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:2.4.20=kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest +org.jetbrains.kotlin:kotlin-scripting-jvm:2.4.20=kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest +org.jetbrains.kotlin:kotlin-serialization-compiler-plugin-embeddable:2.4.20=kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest +org.jetbrains.kotlin:kotlin-stdlib:2.4.20=compileClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-test-junit5:2.4.20=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-test:2.4.20=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-tooling-core:2.4.20=kotlinBuildToolsApiClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.11.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.11.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinBuildToolsApiClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-slf4j:1.11.0=runtimeClasspath,testRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.7.1-0.6.x-compat=compileClasspath,testCompileClasspath +org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.8.0-0.6.x-compat=runtimeClasspath,testRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-datetime:0.8.0-0.6.x-compat=runtimeClasspath,testRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-io-bytestring-jvm:0.9.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-io-bytestring:0.9.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-io-core-jvm:0.9.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-io-core:0.9.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.11.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.11.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core:1.11.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json-io-jvm:1.11.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json-io:1.11.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.11.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains:annotations:13.0=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest +org.jetbrains:annotations:23.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm:9.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.postgresql:postgresql:42.7.13=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.roaringbitmap:RoaringBitmap:1.0.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.19=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +empty=kotlinScriptDefExtensions,testKotlinScriptDefExtensions diff --git a/applications/usage-meter/gradle.properties b/applications/usage-meter/gradle.properties new file mode 100644 index 00000000..7300b86a --- /dev/null +++ b/applications/usage-meter/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx768m -Dfile.encoding=UTF-8 +org.gradle.workers.max=1 +kotlin.compiler.execution.strategy=in-process diff --git a/applications/usage-meter/gradle/wrapper/gradle-wrapper.jar b/applications/usage-meter/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..5097068a8d375f5bf0d15693fb5b3615c972a801 GIT binary patch literal 47623 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 literal 0 HcmV?d00001 diff --git a/applications/usage-meter/gradle/wrapper/gradle-wrapper.properties b/applications/usage-meter/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..51c23ce4 --- /dev/null +++ b/applications/usage-meter/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,10 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=bafd5ce9cfaea0fbccfdc8439a1ac42fbd4cd9c89dc9a988228d8a2639a58e6c +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip +networkTimeout=30000 +retries=2 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/applications/usage-meter/gradlew b/applications/usage-meter/gradlew new file mode 100755 index 00000000..249efbb0 --- /dev/null +++ b/applications/usage-meter/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/applications/usage-meter/gradlew.bat b/applications/usage-meter/gradlew.bat new file mode 100644 index 00000000..3185a43f --- /dev/null +++ b/applications/usage-meter/gradlew.bat @@ -0,0 +1,112 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. + +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. + +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel + +@rem This label must not be changed. We rely on old scripts being able to jump to this point. +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/applications/usage-meter/infra/clickpipe-events.json b/applications/usage-meter/infra/clickpipe-events.json new file mode 100644 index 00000000..a3b7e29f --- /dev/null +++ b/applications/usage-meter/infra/clickpipe-events.json @@ -0,0 +1,11 @@ +{ + "sourceSchemaName": "usage", + "sourceTable": "events", + "targetTable": "cdc_usage_events", + "excludedColumns": [], + "sortingKeys": ["account_id", "usage_day", "event_id"], + "useCustomSortingKey": true, + "partitionByExpr": "", + "partitionKey": "", + "tableEngine": "ReplacingMergeTree" +} diff --git a/applications/usage-meter/migrations/001_down.sql b/applications/usage-meter/migrations/001_down.sql new file mode 100644 index 00000000..e8d7e8af --- /dev/null +++ b/applications/usage-meter/migrations/001_down.sql @@ -0,0 +1 @@ +DROP TABLE usage.events, usage.daily_counters, usage.accounts; diff --git a/applications/usage-meter/migrations/001_up.sql b/applications/usage-meter/migrations/001_up.sql new file mode 100644 index 00000000..4a0cf4a9 --- /dev/null +++ b/applications/usage-meter/migrations/001_up.sql @@ -0,0 +1,33 @@ +CREATE TABLE usage.accounts ( + id UUID PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + daily_quota INTEGER NOT NULL CHECK (daily_quota BETWEEN 1 AND 1000000) +); +CREATE TABLE usage.daily_counters ( + account_id UUID NOT NULL REFERENCES usage.accounts(id), + usage_day DATE NOT NULL, + used_units INTEGER NOT NULL CHECK (used_units BETWEEN 0 AND 1000000), + PRIMARY KEY (account_id, usage_day) +); +CREATE TABLE usage.events ( + event_id UUID PRIMARY KEY, + account_id UUID NOT NULL REFERENCES usage.accounts(id), + request_id TEXT NOT NULL CHECK (request_id ~ '^[A-Za-z0-9._-]{1,64}$'), + usage_day DATE NOT NULL, + feature TEXT NOT NULL CHECK (feature IN ('api', 'export', 'storage')), + units SMALLINT NOT NULL CHECK (units BETWEEN 1 AND 1000), + remaining_units INTEGER NOT NULL CHECK (remaining_units BETWEEN 0 AND 1000000), + created_at TIMESTAMPTZ NOT NULL, + UNIQUE (account_id, request_id) +); +-- Every custom ClickHouse ordering column is carried by DELETE tombstones. +CREATE UNIQUE INDEX events_replica_identity ON usage.events(account_id, usage_day, event_id); +ALTER TABLE usage.events REPLICA IDENTITY USING INDEX events_replica_identity; +GRANT SELECT ON usage.accounts, usage.daily_counters, usage.events TO usage_app; +-- SELECT FOR UPDATE needs UPDATE privilege; only the ID column is grantable. +-- The API never changes it. Operators with this shared role remain trusted. +GRANT UPDATE (id) ON usage.accounts TO usage_app; +GRANT INSERT ON usage.daily_counters TO usage_app; +GRANT UPDATE (used_units) ON usage.daily_counters TO usage_app; +GRANT INSERT ON usage.events TO usage_app; +GRANT SELECT ON usage.events TO usage_cdc; diff --git a/applications/usage-meter/scripts/cloud-convergence.py b/applications/usage-meter/scripts/cloud-convergence.py new file mode 100644 index 00000000..8d509ece --- /dev/null +++ b/applications/usage-meter/scripts/cloud-convergence.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Bounded equality check: PostgreSQL accepted events versus account-scoped reports.""" +import datetime +import json +import os +import runpy +import time +from pathlib import Path + +core = runpy.run_path(str(Path(__file__).with_name("cloud-core.py"))) +today = datetime.datetime.now(datetime.timezone.utc).date() +start = today - datetime.timedelta(days=30) +expected = {} +for account in core["TOKENS"]: + query = f""" + SELECT coalesce(json_agg(row_to_json(t)), '[]'::json) FROM ( + SELECT usage_day::text AS "usageDay", feature, sum(units)::bigint AS units, count(*) AS events + FROM usage.events WHERE account_id='{account}'::uuid + AND usage_day BETWEEN '{start}'::date AND '{today}'::date + GROUP BY usage_day, feature ORDER BY usage_day, feature + ) t + """ + expected[account] = json.loads(core["rows"](query)) + +deadline = time.monotonic() + 180 +began = time.monotonic() +while True: + reports = {} + matched = True + for account, rows in expected.items(): + status, report = core["http"](account, f"/reports?from={start}&to={today}") + if status != 200: + matched = False + continue + assert report["accountId"] == account and report["consistency"] == "eventual" + assert report["quotaAuthority"] == "postgres" and len(report["features"]) <= 93 + reports[account] = report + matched = matched and report["features"] == rows + daily = {} + for row in rows: + daily.setdefault(row["usageDay"], {"usageDay": row["usageDay"], "units": 0, "events": 0}) + daily[row["usageDay"]]["units"] += row["units"] + daily[row["usageDay"]]["events"] += row["events"] + matched = matched and report["daily"] == list(daily.values()) + if matched: + print(f"PASS: exact day/feature sum and count equality for both accounts; observed wait{time.monotonic()-began:.1f}s") + output = os.environ.get("REPORT_EVIDENCE") + if output: + Path(output).write_text(json.dumps({"expected": expected, "reports": reports}, indent=2)) + break + if time.monotonic() >= deadline: + raise RuntimeError("Report convergence deadline180s exceeded") + print(f"Waiting for CDC convergence; elapsed{time.monotonic()-began:.1f}s", flush=True) + time.sleep(5) diff --git a/applications/usage-meter/scripts/cloud-core.py b/applications/usage-meter/scripts/cloud-core.py new file mode 100644 index 00000000..1959c959 --- /dev/null +++ b/applications/usage-meter/scripts/cloud-core.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Run once on the documented fresh Cloud fixture; no local database substitute.""" +import concurrent.futures +import json +import os +import subprocess +import time +import threading +import urllib.error +import urllib.request + +ROOT = os.environ.get("API_URL", "http://127.0.0.1:3300") +TOKENS = json.loads(os.environ["ACCOUNT_TOKENS"]) +A, B = sorted(TOKENS) + + +def http(account, path, body=None, raw=None): + payload = raw if raw is not None else None if body is None else json.dumps(body).encode() + request = urllib.request.Request(ROOT + path, data=payload, headers={ + "Authorization": "Bearer " + TOKENS[account], "Content-Type": "application/json"}) + try: + response = urllib.request.urlopen(request, timeout=60) + except urllib.error.HTTPError as error: + response = error + with response: + data = response.read() + return response.status, json.loads(data) if data else None + + +def sql(statement, role="usage_migrator", password=None): + env = dict(os.environ, PGUSER=role, PGSSLMODE="verify-full", + PGPASSWORD=password or os.environ["USAGE_MIGRATOR_PASSWORD"]) + return subprocess.run(["psql", "-X", "-At", "-v", "ON_ERROR_STOP=1", "-c", statement], + env=env, capture_output=True, text=True, timeout=30) + + +def rows(statement): + result = sql(statement) + assert result.returncode == 0, result.stderr + return result.stdout.strip() + + +def parallel(body_factory, count=12): + statuses = [] + lock = threading.Lock() + def attempt(index): + body = body_factory(index) + deadline = time.monotonic() + 30 + while True: + result = http(A, "/usage", body) + with lock: + statuses.append(result[0]) + if result[0] != 503 or time.monotonic() >= deadline: + return result + # A bounded pool can reject a transient connection failure. Preserve ID. + time.sleep(0.25) + with concurrent.futures.ThreadPoolExecutor(max_workers=count) as executor: + results = list(executor.map(attempt, range(count))) + print(f"HTTP burst: {count} clients; {len(statuses)} attempts; statuses " + str({status: statuses.count(status) for status in sorted(set(statuses))}), flush=True) + return results + + +def main(): + assert http(A, "/quota")[1]["used"] == 8, "Requires fresh snapshot fixtures" + assert http(B, "/quota")[1]["used"] == 4 + replay = {"requestId": "race-one-id", "feature": "api", "units": 2} + same = parallel(lambda _: replay) + assert sorted(status for status, _ in same) == [200] * 11 + [201] + assert len({event["eventId"] for _, event in same}) == 1 + assert http(A, "/quota")[1]["used"] == 10 + assert http(A, "/usage", dict(replay, units=3))[0] == 409 + print("PASS: 12 matching requests, one event/debit; mismatched retry409") + + capped = parallel(lambda index: {"requestId": f"race-quota-{index}", "feature": "export", "units": 10}) + assert sorted(status for status, _ in capped) == [201] * 9 + [429] * 3 + assert http(A, "/quota")[1]["remaining"] == 0 + assert rows(f"SELECT sum(units) FROM usage.events WHERE account_id='{A}' AND usage_day=current_date") == "100" + assert http(A, "/usage", replay)[0] == 200 # Retry still wins over exhausted quota. + assert http(A, "/usage", dict(replay, feature="storage"))[0] == 409 + print("PASS: contended quota reached exactly100; three denied; exhausted replay preserved") + + assert http(B, "/events/race-one-id")[0] == 404 + assert http(A, "/usage", {"requestId": "invalid", "feature": "api", "units": 1, "accountId": B})[0] == 400 + assert http(A, "/usage", raw=b" " * 5000)[0] == 413 + assert http(A, "/reports?accountId=" + B)[0] == 400 + assert http(A, "/reports?from=2000-01-01")[0] == 400 + saved = TOKENS[A] + TOKENS[A] = "not-a-token" + assert http(A, "/quota")[0] == 401 + TOKENS[A] = saved + print("PASS: account isolation, body/date bounds and authentication") + + before = http(B, "/quota")[1] + setup = """ + SET ROLE usage_owner; + CREATE FUNCTION usage.test_reject_event() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF NEW.request_id = 'force-rollback' THEN RAISE EXCEPTION 'test rollback'; END IF; + RETURN NEW; + END $$; + CREATE TRIGGER test_reject_event BEFORE INSERT ON usage.events + FOR EACH ROW EXECUTE FUNCTION usage.test_reject_event(); + """ + assert sql(setup).returncode == 0 + try: + assert http(B, "/usage", {"requestId": "force-rollback", "feature": "api", "units": 1})[0] == 503 + assert http(B, "/quota")[1] == before + assert http(B, "/events/force-rollback")[0] == 404 + finally: + result = sql("SET ROLE usage_owner; DROP TRIGGER test_reject_event ON usage.events; DROP FUNCTION usage.test_reject_event();") + assert result.returncode == 0, result.stderr + print("PASS: database failure after counter mutation rolled back counter and event") + + for statement in ["UPDATE usage.events SET units=1", "DELETE FROM usage.events", "CREATE TABLE usage.forbidden(id int)", "SELECT * FROM usage.schema_migrations"]: + result = sql(statement, "usage_app", os.environ["PGPASSWORD"]) + assert result.returncode != 0 and "permission denied" in result.stderr.lower() + result = sql("SELECT * FROM usage.accounts", "usage_cdc", os.environ["USAGE_CDC_PASSWORD"]) + assert result.returncode != 0 and "permission denied" in result.stderr.lower() + assert sql("SELECT count(*) FROM usage.events", "usage_cdc", os.environ["USAGE_CDC_PASSWORD"]).returncode == 0 + print("PASS: runtime mutation/schema denials; CDC reader limited to events") + modest() + + +def modest(): + before = http(B, "/quota")[1]["used"] + with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor: + results = list(executor.map(lambda index: http(B, "/usage", { + "requestId": f"modest-{index}", "feature": "storage", "units": 1}), range(3))) + assert [status for status, _ in results] == [201] * 3 + assert http(B, "/quota")[1]["used"] == before + 3 + print("PASS: three concurrent clients, three HTTP attempts, three201, zero503; exactly three debits") + + +if __name__ == "__main__": + main() diff --git a/applications/usage-meter/scripts/cloud-lag.py b/applications/usage-meter/scripts/cloud-lag.py new file mode 100644 index 00000000..512ef0df --- /dev/null +++ b/applications/usage-meter/scripts/cloud-lag.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Run prepare; pause pipe; paused; revoke report SELECT; outage; restore SELECT; recover.""" +import concurrent.futures +import json +import runpy +import sys +from pathlib import Path + +core = runpy.run_path(str(Path(__file__).with_name("cloud-core.py"))) +http, account = core["http"], core["B"] +state_path = Path(".deployment/lag-before.json") +mode = sys.argv[1] +if mode == "prepare": + status, report = http(account, "/reports") + assert status == 200 + quota = http(account, "/quota")[1] + state_path.write_text(json.dumps({"report": report, "quota": quota})) + print("Saved converged report and authoritative counter before pause") +elif mode == "paused": + before = json.loads(state_path.read_text()) + status, event = http(account, "/usage", {"requestId": "while-paused", "feature": "export", "units": 6}) + assert status == 201 + quota = http(account, "/quota")[1] + assert quota["used"] == before["quota"]["used"] + 6 + status, report = http(account, "/reports") + assert status == 200 and report == before["report"] + print(f"PASS: paused CDC; PostgreSQL used{quota['used']}, report still reflects used{before['quota']['used']}; event persisted") +elif mode == "outage": + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: + results = list(executor.map(lambda target: http(target, "/reports"), core["TOKENS"])) + assert [status for status, _ in results] == [503, 503] + before = http(account, "/quota")[1] + status, _ = http(account, "/usage", {"requestId": "while-analytics-denied", "feature": "api", "units": 1}) + assert status == 201 and http(account, "/quota")[1]["used"] == before["used"] + 1 + print("PASS: both report capacity slots failed503; operational quota and accepted write succeeded") +elif mode == "recover": + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: + results = list(executor.map(lambda target: http(target, "/reports"), core["TOKENS"])) + assert [status for status, _ in results] == [200, 200] + print("PASS: both report capacity slots recovered200 in the same API process/client") +else: + raise ValueError("Expected prepare, paused, outage or recover") diff --git a/applications/usage-meter/scripts/process-restart.sh b/applications/usage-meter/scripts/process-restart.sh new file mode 100755 index 00000000..fb4aaaba --- /dev/null +++ b/applications/usage-meter/scripts/process-restart.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +export PORT=${RESTART_PORT:-3302} +export API_URL="http://127.0.0.1:$PORT" +trap '[[ -f ".deployment/server-$PORT.pid" ]] && kill "$(cat ".deployment/server-$PORT.pid")" 2>/dev/null || true' EXIT +bash scripts/start-runtime.sh +first=$(cat ".deployment/server-$PORT.pid") +python3 - <<'PY' +import runpy,json +c=runpy.run_path('scripts/cloud-core.py') +result=c['http'](c['B'],'/events/snapshot-b') +assert result[0]==200 +quota=c['http'](c['B'],'/quota')[1] +open('.deployment/restart-before.json','w').write(json.dumps({'event':result[1],'quota':quota})) +PY +kill "$first" +for attempt in $(seq 1 50); do kill -0 "$first" 2>/dev/null || break; sleep 0.1; done +bash scripts/start-runtime.sh +second=$(cat ".deployment/server-$PORT.pid") +[[ "$first" != "$second" ]] +python3 - <<'PY' +import runpy,json +c=runpy.run_path('scripts/cloud-core.py') +before=json.load(open('.deployment/restart-before.json')) +status,event=c['http'](c['B'],'/usage',{'requestId':'snapshot-b','feature':'api','units':4}) +assert status==200 and event==before['event'] +assert c['http'](c['B'],'/quota')[1]==before['quota'] +assert c['http'](c['B'],'/reports')[0]==200 +print('PASS: matching persisted retry200, unchanged quota and report after new OS process') +PY +printf 'External runtime-only process restart: %s -> %s\n' "$first" "$second" diff --git a/applications/usage-meter/scripts/start-runtime.sh b/applications/usage-meter/scripts/start-runtime.sh new file mode 100755 index 00000000..a31ab61a --- /dev/null +++ b/applications/usage-meter/scripts/start-runtime.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +umask 077 +mkdir -p .deployment +port=${PORT:-3000} +# Whitelist runtime values; no Cloud API, migration, replication or admin credentials. +nohup env -i PATH="$PATH" PGHOST="$PGHOST" PGPORT="${PGPORT:-5432}" \ + PGDATABASE="$PGDATABASE" PGUSER="$PGUSER" PGPASSWORD="$PGPASSWORD" \ + PGSSLROOTCERT="$PGSSLROOTCERT" ACCOUNT_TOKENS="$ACCOUNT_TOKENS" \ + CLICKHOUSE_URL="$CLICKHOUSE_URL" CLICKHOUSE_USER="$CLICKHOUSE_USER" \ + CLICKHOUSE_PASSWORD="$CLICKHOUSE_PASSWORD" PORT="$port" \ + build/install/usage-meter/bin/usage-meter > ".deployment/server-$port.log" 2>&1 < /dev/null & +pid=$! +printf '%s\n' "$pid" > ".deployment/server-$port.pid" +export API_URL="http://127.0.0.1:$port" +python3 - <<'PY' +import json,os,time,urllib.request,urllib.error +url=os.environ['API_URL']+'/quota' +token=next(iter(json.loads(os.environ['ACCOUNT_TOKENS']).values())) +deadline=time.monotonic()+90 +while time.monotonic() { + val today = LocalDate.now(clock) + val start = try { from?.let(LocalDate::parse) ?: today.minusDays(6) } catch (_: Exception) { throw ApiError(400, "Invalid report dates") } + val end = try { to?.let(LocalDate::parse) ?: today } catch (_: Exception) { throw ApiError(400, "Invalid report dates") } + if (start > end || start < today.minusDays(30) || end > today) + throw ApiError(400, "Reports cover at most the last 31 UTC days") + return start to end +} +class Analytics : AutoCloseable { + private val client = Client.Builder() + .addEndpoint(checkedEndpoint(required("CLICKHOUSE_URL"))) + .setUsername(required("CLICKHOUSE_USER").also { require(it == "usage_reports") }) + .setPassword(required("CLICKHOUSE_PASSWORD")) + .setMaxConnections(2) + .useAsyncRequests(false) + .setMaxRetries(0) + .setConnectionRequestTimeout(5, ChronoUnit.SECONDS) + .setConnectTimeout(5, ChronoUnit.SECONDS) + .setSocketTimeout(10, ChronoUnit.SECONDS) + .build() + private val work = Dispatchers.IO.limitedParallelism(2) + suspend fun report(account: UUID, from: LocalDate, to: LocalDate): Report = withContext(work) { + val sql = """ + SELECT usage_day, feature, sum(units) AS units, count() AS events + FROM default.cdc_usage_events FINAL + WHERE account_id = {account:UUID} + AND usage_day BETWEEN {from:Date32} AND {to:Date32} + AND _peerdb_is_deleted = 0 + GROUP BY usage_day, feature + ORDER BY usage_day, feature + LIMIT 93 + """.trimIndent() + val settings = QuerySettings().setMaxExecutionTime(5) + .serverSetting("max_rows_to_read", "1000000") + .serverSetting("max_bytes_to_read", "100000000") + .serverSetting("max_result_rows", "93") + .serverSetting("result_overflow_mode", "throw") + .serverSetting("timeout_before_checking_execution_speed", "0") + val params = mapOf("account" to account.toString(), "from" to from, "to" to to) + val rows = mutableListOf() + // The client executes synchronously on this bounded IO dispatcher. + // Future.get is an additional wait cap, not an end-to-end HTTP deadline. + client.query(sql, params, settings).get(15, TimeUnit.SECONDS).use { response -> + val reader = client.newBinaryFormatReader(response) + while (reader.hasNext()) { + reader.next() + rows += FeatureTotal(reader.getLocalDate("usage_day").toString(), reader.getString("feature"), + reader.getLong("units"), reader.getLong("events")) + } + } + val daily = rows.groupBy { it.usageDay }.map { (day, features) -> + DailyTotal(day, features.sumOf { it.units }, features.sumOf { it.events }) + } + Report(account.toString(), from.toString(), to.toString(), daily, rows) + } + override fun close() = client.close() +} diff --git a/applications/usage-meter/src/main/kotlin/meter/Config.kt b/applications/usage-meter/src/main/kotlin/meter/Config.kt new file mode 100644 index 00000000..4158895c --- /dev/null +++ b/applications/usage-meter/src/main/kotlin/meter/Config.kt @@ -0,0 +1,63 @@ +package meter + +import com.zaxxer.hikari.HikariConfig +import com.zaxxer.hikari.HikariDataSource +import kotlinx.serialization.json.Json +import org.postgresql.ds.PGSimpleDataSource +import java.net.URI +import java.security.MessageDigest +import java.util.UUID + +fun required(name: String): String = System.getenv(name)?.takeIf { it.isNotBlank() } + ?: error("Missing $name") + +fun pgSource(host: String = required("PGHOST"), ca: String = required("PGSSLROOTCERT")): PGSimpleDataSource { + require(required("PGUSER") == "usage_app") { "Runtime requires usage_app" } + return PGSimpleDataSource().apply { + serverNames = arrayOf(host) + portNumbers = intArrayOf((System.getenv("PGPORT") ?: "5432").toInt().also { require(it in 1..65535) }) + databaseName = required("PGDATABASE") + user = required("PGUSER") + password = required("PGPASSWORD") + sslMode = "verify-full" + sslRootCert = ca + connectTimeout = 5 + socketTimeout = 25 + options = "-csearch_path=usage -ctimezone=UTC -cstatement_timeout=20000" + } +} +fun pgPool() = HikariDataSource(HikariConfig().apply { + dataSource = pgSource() + maximumPoolSize = 5 + minimumIdle = 0 + connectionTimeout = 15_000 + validationTimeout = 5_000 + poolName = "usage-postgres" +}) +fun checkedEndpoint(value: String): String { + val uri = URI(value) + require(uri.scheme == "https" && uri.host != null && uri.rawUserInfo == null) + require(uri.rawQuery == null && uri.rawFragment == null && uri.path in listOf("", "/")) + return value +} +class TokenScopes(raw: String) { + private val entries: List> + init { + val parsed = Json.decodeFromString>(raw) + require(parsed.size in 1..20) + require(parsed.values.toSet().size == parsed.size) + entries = parsed.map { (id, token) -> + require(token.length in 32..256 && token.none { it.isWhitespace() }) + hash(token) to UUID.fromString(id) + } + } + val accountIds: List get() = entries.map { it.second } + fun account(token: String): UUID? { + if (token.length > 256) return null + val provided = hash(token) + var found: UUID? = null + entries.forEach { (expected, id) -> if (MessageDigest.isEqual(expected, provided)) found = id } + return found + } + private fun hash(value: String) = MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8)) +} diff --git a/applications/usage-meter/src/main/kotlin/meter/Main.kt b/applications/usage-meter/src/main/kotlin/meter/Main.kt new file mode 100644 index 00000000..d6e776ae --- /dev/null +++ b/applications/usage-meter/src/main/kotlin/meter/Main.kt @@ -0,0 +1,78 @@ +package meter + +import io.ktor.http.HttpStatusCode +import io.ktor.serialization.kotlinx.json.json +import io.ktor.server.application.* +import io.ktor.server.auth.* +import io.ktor.server.engine.embeddedServer +import io.ktor.server.netty.Netty +import io.ktor.server.plugins.BadRequestException +import io.ktor.server.plugins.PayloadTooLargeException +import io.ktor.server.plugins.bodylimit.RequestBodyLimit +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation +import io.ktor.server.plugins.statuspages.StatusPages +import io.ktor.server.request.receive +import io.ktor.server.response.respond +import io.ktor.server.routing.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json + +fun main() { + try { + val pool = pgPool() + val store = Store(pool) + val tokens = TokenScopes(required("ACCOUNT_TOKENS")) + runBlocking { store.verifyAccounts(tokens.accountIds) } + val analytics = Analytics() + val port = (System.getenv("PORT") ?: "3000").toInt().also { require(it in 1..65535) } + val server = embeddedServer(Netty, host = "127.0.0.1", port = port) { + install(ContentNegotiation) { json(Json { ignoreUnknownKeys = false; encodeDefaults = true }) } + install(RequestBodyLimit) { bodyLimit { 4096L } } + install(StatusPages) { + exception { call, cause -> call.respond(HttpStatusCode.fromValue(cause.status), ErrorResponse(cause.message!!)) } + exception { call, _ -> call.respond(HttpStatusCode.PayloadTooLarge, ErrorResponse("Body exceeds 4096 bytes")) } + exception { call, _ -> call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid request")) } + exception { call, _ -> call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid JSON")) } + exception { call, cause -> + if (cause is CancellationException) throw cause + call.respond(HttpStatusCode.ServiceUnavailable, ErrorResponse("Service unavailable; operational quota still comes from Postgres")) + } + } + install(Authentication) { + bearer("account") { + authenticate { credential -> tokens.account(credential.token)?.let { UserIdPrincipal(it.toString()) } } + } + } + routing { + authenticate("account") { + post("/usage") { + val account = java.util.UUID.fromString(call.principal()!!.name) + val (created, result) = store.consume(account, call.receive()) + call.respond(if (created) HttpStatusCode.Created else HttpStatusCode.OK, result) + } + get("/events/{requestId}") { + val account = java.util.UUID.fromString(call.principal()!!.name) + call.respond(store.inspect(account, call.parameters["requestId"]!!)) + } + get("/quota") { + val account = java.util.UUID.fromString(call.principal()!!.name) + call.respond(store.quota(account)) + } + get("/reports") { + if (call.request.queryParameters.names().any { it !in setOf("from", "to") }) throw ApiError(400, "Unknown report parameter") + val account = java.util.UUID.fromString(call.principal()!!.name) + val (from, to) = reportDates(call.request.queryParameters["from"], call.request.queryParameters["to"]) + call.respond(analytics.report(account, from, to)) + } + } + } + } + Runtime.getRuntime().addShutdownHook(Thread { server.stop(500, 3000); analytics.close(); pool.close() }) + server.start(wait = true) + } catch (_: Exception) { + System.err.println("Usage meter failed to start; check configuration, role, TLS and database readiness") + kotlin.system.exitProcess(1) + } +} diff --git a/applications/usage-meter/src/main/kotlin/meter/Models.kt b/applications/usage-meter/src/main/kotlin/meter/Models.kt new file mode 100644 index 00000000..e54ca484 --- /dev/null +++ b/applications/usage-meter/src/main/kotlin/meter/Models.kt @@ -0,0 +1,52 @@ +package meter + +import kotlinx.serialization.Serializable +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.java.javaUUID +import org.jetbrains.exposed.v1.javatime.date +import org.jetbrains.exposed.v1.javatime.timestampWithTimeZone + +object Accounts : Table("usage.accounts") { + val id = javaUUID("id") + val name = text("name") + val dailyQuota = integer("daily_quota") + override val primaryKey = PrimaryKey(id) +} +object Counters : Table("usage.daily_counters") { + val accountId = javaUUID("account_id") + val day = date("usage_day") + val used = integer("used_units") + override val primaryKey = PrimaryKey(accountId, day) +} +object Events : Table("usage.events") { + val id = javaUUID("event_id") + val accountId = javaUUID("account_id") + val requestId = text("request_id") + val day = date("usage_day") + val feature = text("feature") + val units = short("units") + val remaining = integer("remaining_units") + val created = timestampWithTimeZone("created_at") + override val primaryKey = PrimaryKey(id) +} +@Serializable data class Consumption(val requestId: String, val feature: String, val units: Int) +@Serializable data class Accepted( + val eventId: String, val requestId: String, val usageDay: String, + val feature: String, val units: Int, val remainingAtAcceptance: Int, +) +@Serializable data class Quota(val accountId: String, val usageDay: String, val quota: Int, val used: Int, val remaining: Int) +@Serializable data class FeatureTotal(val usageDay: String, val feature: String, val units: Long, val events: Long) +@Serializable data class DailyTotal(val usageDay: String, val units: Long, val events: Long) +@Serializable data class Report( + val accountId: String, val from: String, val to: String, + val daily: List, val features: List, + val consistency: String = "eventual", val quotaAuthority: String = "postgres", +) +@Serializable data class ErrorResponse(val error: String) +class ApiError(val status: Int, message: String) : RuntimeException(message) + +fun validate(input: Consumption) { + if (!input.requestId.matches(Regex("[A-Za-z0-9._-]{1,64}"))) throw ApiError(400, "Invalid request ID") + if (input.feature !in setOf("api", "export", "storage")) throw ApiError(400, "Unknown feature") + if (input.units !in 1..1000) throw ApiError(400, "Units must be between 1 and 1000") +} diff --git a/applications/usage-meter/src/main/kotlin/meter/Store.kt b/applications/usage-meter/src/main/kotlin/meter/Store.kt new file mode 100644 index 00000000..e52cd34f --- /dev/null +++ b/applications/usage-meter/src/main/kotlin/meter/Store.kt @@ -0,0 +1,91 @@ +package meter + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jetbrains.exposed.v1.core.* +import org.jetbrains.exposed.v1.jdbc.* +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import org.jetbrains.exposed.v1.core.ResultRow +import java.time.Clock +import java.time.LocalDate +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.util.UUID +import javax.sql.DataSource + +class Store(source: DataSource, private val clock: Clock = Clock.systemUTC()) { + private val db = Database.connect(source) + private val work = Dispatchers.IO.limitedParallelism(5) + + suspend fun verifyAccounts(ids: List) = withContext(work) { + transaction(db) { + ids.forEach { id -> require(Accounts.selectAll().where { Accounts.id eq id }.count() == 1L) } + } + } + suspend fun consume(account: UUID, input: Consumption): Pair = withContext(work) { + validate(input) + transaction(db) { + maxAttempts = 1 + // One lock serializes quota decisions and request-ID replay for this account, + // including two requests spanning UTC midnight. No ClickHouse dependency. + val owner = Accounts.selectAll().where { Accounts.id eq account }.forUpdate().single() + val existing = Events.selectAll().where { + (Events.accountId eq account) and (Events.requestId eq input.requestId) + }.singleOrNull() + if (existing != null) { + if (existing[Events.feature] != input.feature || existing[Events.units].toInt() != input.units) + throw ApiError(409, "Request ID already has different data") + return@transaction false to accepted(existing) + } + val acceptedAt = clock.instant().atOffset(ZoneOffset.UTC) + val day = acceptedAt.toLocalDate() + val counter = Counters.selectAll().where { + (Counters.accountId eq account) and (Counters.day eq day) + }.singleOrNull() + val used = counter?.get(Counters.used) ?: 0 + val quota = owner[Accounts.dailyQuota] + if (input.units > quota - used) throw ApiError(429, "Daily quota exhausted") + if (counter == null) { + Counters.insert { + it[accountId] = account; it[Counters.day] = day; it[Counters.used] = input.units + } + } else { + Counters.update({ (Counters.accountId eq account) and (Counters.day eq day) }) { + it[Counters.used] = used + input.units + } + } + val id = UUID.randomUUID() + val remaining = quota - used - input.units + Events.insert { + it[Events.id] = id; it[accountId] = account; it[requestId] = input.requestId + it[Events.day] = day; it[feature] = input.feature; it[units] = input.units.toShort() + it[Events.remaining] = remaining; it[created] = acceptedAt + } + true to Accepted(id.toString(), input.requestId, day.toString(), input.feature, input.units, remaining) + } + } + suspend fun inspect(account: UUID, requestId: String): Accepted = withContext(work) { + transaction(db) { + val row = Events.selectAll().where { + (Events.accountId eq account) and (Events.requestId eq requestId) + }.singleOrNull() ?: throw ApiError(404, "Event not found") + accepted(row) + } + } + suspend fun quota(account: UUID): Quota = withContext(work) { + transaction(db) { + // One SQL statement observes quota and counter consistently. + val day = LocalDate.now(clock) + val row = Accounts.join(Counters, JoinType.LEFT, Accounts.id, Counters.accountId, + additionalConstraint = { Counters.day eq day }) + .selectAll().where { Accounts.id eq account }.single() + val quota = row[Accounts.dailyQuota] + val used = row.getOrNull(Counters.used) ?: 0 + Quota(account.toString(), day.toString(), quota, used, quota - used) + } + } + private fun accepted(row: ResultRow) = Accepted( + row[Events.id].toString(), row[Events.requestId], row[Events.day].toString(), + row[Events.feature], row[Events.units].toInt(), row[Events.remaining], + ) +} diff --git a/applications/usage-meter/src/main/resources/logback.xml b/applications/usage-meter/src/main/resources/logback.xml new file mode 100644 index 00000000..4a517705 --- /dev/null +++ b/applications/usage-meter/src/main/resources/logback.xml @@ -0,0 +1,7 @@ + + + System.err + %level %logger{36}: %msg%n + + + diff --git a/applications/usage-meter/src/test/kotlin/meter/CloudClockTest.kt b/applications/usage-meter/src/test/kotlin/meter/CloudClockTest.kt new file mode 100644 index 00000000..42ee4919 --- /dev/null +++ b/applications/usage-meter/src/test/kotlin/meter/CloudClockTest.kt @@ -0,0 +1,45 @@ +package meter + +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable +import java.time.Clock +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneOffset +import java.util.UUID +import kotlin.test.* + +@EnabledIfEnvironmentVariable(named = "RUN_CLOUD_TESTS", matches = "1") +class CloudClockTest { + @Test fun replayAfterUtcMidnightKeepsOriginalDayAndDebitsOnce() = runBlocking { + val account = UUID.fromString("00000000-0000-4000-8000-000000000002") + val yesterday = LocalDate.now(Clock.systemUTC()).minusDays(1) + val before = yesterday.atTime(23, 59, 59).toInstant(ZoneOffset.UTC) + val after = yesterday.plusDays(1).atStartOfDay().toInstant(ZoneOffset.UTC) + val input = Consumption("clock-${UUID.randomUUID()}", "storage", 2) + pgPool().use { pool -> + val original = Store(pool, Clock.fixed(before, ZoneOffset.UTC)) + val nextDay = Store(pool, Clock.fixed(after, ZoneOffset.UTC)) + val currentBefore = nextDay.quota(account) + val (created, first) = original.consume(account, input) + assertTrue(created) + assertEquals(yesterday.toString(), first.usageDay) + val (recreated, replay) = nextDay.consume(account, input) + assertFalse(recreated) + assertEquals(first, replay) + assertEquals(currentBefore, nextDay.quota(account)) + pool.connection.use { connection -> + connection.prepareStatement("SELECT usage_day, created_at FROM usage.events WHERE event_id = ?").use { + it.setObject(1, UUID.fromString(first.eventId)) + it.executeQuery().use { row -> + assertTrue(row.next()) + assertEquals(yesterday, row.getObject(1, LocalDate::class.java)) + assertEquals(before, row.getTimestamp(2).toInstant()) + } + } + } + println("UTC-midnight replay retained original day, timestamp, ID and one debit; current-day counter unchanged") + } + } +} diff --git a/applications/usage-meter/src/test/kotlin/meter/CloudTlsTest.kt b/applications/usage-meter/src/test/kotlin/meter/CloudTlsTest.kt new file mode 100644 index 00000000..0e2b8e72 --- /dev/null +++ b/applications/usage-meter/src/test/kotlin/meter/CloudTlsTest.kt @@ -0,0 +1,28 @@ +package meter + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable +import java.net.InetAddress +import java.sql.SQLException +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +@EnabledIfEnvironmentVariable(named = "RUN_CLOUD_TESTS", matches = "1") +class CloudTlsTest { + @Test fun postgresUsesTlsAndRejectsWrongCaAndHostname() { + pgSource().connection.use { connection -> + connection.createStatement().use { statement -> + statement.executeQuery("SELECT ssl FROM pg_stat_ssl WHERE pid = pg_backend_pid()").use { + assertTrue(it.next() && it.getBoolean(1)) + } + } + } + val address = InetAddress.getByName(required("PGHOST")).hostAddress + for (source in listOf(pgSource(ca = required("TEST_WRONG_CA")), pgSource(host = address))) { + val failure = assertFailsWith { source.connection.use {} } + val descriptions = generateSequence(failure) { it.cause }.joinToString(" ") { it.message ?: "" }.lowercase() + assertTrue(descriptions.contains("certificate") || descriptions.contains("hostname")) + } + println("Verified Cloud Postgres TLS; unrelated CA and wrong hostname rejected") + } +} diff --git a/applications/usage-meter/src/test/kotlin/meter/ValidationTest.kt b/applications/usage-meter/src/test/kotlin/meter/ValidationTest.kt new file mode 100644 index 00000000..916f5ce2 --- /dev/null +++ b/applications/usage-meter/src/test/kotlin/meter/ValidationTest.kt @@ -0,0 +1,36 @@ +package meter + +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Test +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import kotlin.test.* + +class ValidationTest { + @Test fun boundedConsumptionAndIdentity() { + validate(Consumption("request-1", "api", 1000)) + listOf(Consumption("has spaces", "api", 1), Consumption("nul\u0000", "api", 1), + Consumption("valid", "unknown", 1), Consumption("valid", "api", 0), + Consumption("valid", "api", 1001)).forEach { assertFailsWith { validate(it) } } + assertFails { Json.decodeFromString("""{"requestId":"r","feature":"api","units":1,"accountId":"foreign"}""") } + } + @Test fun reportsCannotWidenTheDateWindow() { + val clock = Clock.fixed(Instant.parse("2026-10-02T00:00:00Z"), ZoneOffset.UTC) + assertEquals("2026-09-02", reportDates("2026-09-02", "2026-10-02", clock).first.toString()) + for ((from, to) in listOf("2026-09-01" to "2026-10-02", "2026-10-02" to "2026-10-03", "bad" to "2026-10-02")) + assertFailsWith { reportDates(from, to, clock) } + } + @Test fun tokensCannotBeSharedAcrossAccounts() { + val token = "a".repeat(32) + assertFails { TokenScopes("""{"00000000-0000-4000-8000-000000000001":"$token","00000000-0000-4000-8000-000000000002":"$token"}""") } + val scopes = TokenScopes("""{"00000000-0000-4000-8000-000000000001":"$token"}""") + assertEquals("00000000-0000-4000-8000-000000000001", scopes.account(token).toString()) + assertNull(scopes.account("wrong")) + } + @Test fun analyticalEndpointRequiresTrustedHttps() { + assertEquals("https://example.com:8443", checkedEndpoint("https://example.com:8443")) + for (url in listOf("http://example.com", "https://user:password@example.com", "https://example.com/?query=x")) + assertFails { checkedEndpoint(url) } + } +} From bfe783554d33fd82a2f4e1310f3a39e66a1eb752 Mon Sep 17 00:00:00 2001 From: sdairs Date: Fri, 2 Oct 2026 14:06:00 +0100 Subject: [PATCH 2/3] Require first process exit before restart verification --- applications/usage-meter/scripts/process-restart.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/applications/usage-meter/scripts/process-restart.sh b/applications/usage-meter/scripts/process-restart.sh index fb4aaaba..328c81bb 100755 --- a/applications/usage-meter/scripts/process-restart.sh +++ b/applications/usage-meter/scripts/process-restart.sh @@ -16,6 +16,10 @@ open('.deployment/restart-before.json','w').write(json.dumps({'event':result[1], PY kill "$first" for attempt in $(seq 1 50); do kill -0 "$first" 2>/dev/null || break; sleep 0.1; done +if kill -0 "$first" 2>/dev/null; then + echo 'First process did not exit within shutdown deadline' >&2 + exit 1 +fi bash scripts/start-runtime.sh second=$(cat ".deployment/server-$PORT.pid") [[ "$first" != "$second" ]] From 61cd6a5f84e4680a3ce801f50a0699f9b1a05bd5 Mon Sep 17 00:00:00 2001 From: sdairs Date: Fri, 2 Oct 2026 21:31:17 +0100 Subject: [PATCH 3/3] docs: remove public beta wording --- applications/usage-meter/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/usage-meter/README.md b/applications/usage-meter/README.md index f330f080..7ae33f8e 100644 --- a/applications/usage-meter/README.md +++ b/applications/usage-meter/README.md @@ -1,6 +1,6 @@ # Kotlin usage meter -An authenticated Ktor API accepts integer usage against an account's daily quota. ClickHouse Managed Postgres (public beta) stores accounts, counters and accepted events. ClickPipes copies events to an analytical ClickHouse service; the official Java client reads daily and feature totals. All three services run in ClickHouse Cloud. This is a software quota example, not a billing system. +An authenticated Ktor API accepts integer usage against an account's daily quota. ClickHouse Managed Postgres stores accounts, counters and accepted events. ClickPipes copies events to an analytical ClickHouse service; the official Java client reads daily and feature totals. All three services run in ClickHouse Cloud. This is a software quota example, not a billing system. The API binds to localhost. A server-configured bearer token determines the account; clients cannot supply an account ID. `POST /usage` locks that account, checks a retained request ID, then updates its UTC daily counter and inserts an event in one Exposed transaction. A matching retry returns the original result, even after quota exhaustion or UTC midnight. A changed feature or units returns 409. The application never writes to ClickHouse.