diff --git a/.env.example b/.env.example index f0bd407..0d7a4b9 100644 --- a/.env.example +++ b/.env.example @@ -54,6 +54,17 @@ QUERY_LIST_CACHE_MAX_ENTRIES=4 GLOBAL_TREND_CACHE_MAX_ENTRIES=64 QUERY_LIST_CACHE_MAX_ROWS=100000 QUERY_LIST_CACHE_MAX_BYTES=67108864 +# Kalici query-metrics materialized snapshotlarini ayri worker yeniler. API +# request yolunda advisor.query_metrics() calismaz. Concurrent refresh boyunca +# onceki tamamlanmis snapshot servis edilmeye devam eder. +QUERY_METRICS_SNAPSHOT_POLL_SECONDS=15 +QUERY_METRICS_SNAPSHOT_1H_REFRESH_SECONDS=900 +QUERY_METRICS_SNAPSHOT_24H_REFRESH_SECONDS=3600 +QUERY_METRICS_SNAPSHOT_7D_REFRESH_SECONDS=21600 +QUERY_METRICS_SNAPSHOT_30D_REFRESH_SECONDS=43200 +QUERY_METRICS_SNAPSHOT_STATEMENT_TIMEOUT_SECONDS=1800 +QUERY_METRICS_SNAPSHOT_RETRY_SECONDS=60 +QUERY_METRICS_SNAPSHOT_WORKER_MEMORY_LIMIT=256m # Tam snapshotlarin Python nesne ek yükü ayrıca container seviyesinde sınırlıdır. API_MEMORY_LIMIT=1g # Eszamanli repository migration runner'larinin advisory lock bekleme siniri. @@ -91,6 +102,10 @@ WORKLOAD_PROFILE=normal WORKLOAD_DURATION_SECONDS=0 WORKLOAD_WORKERS=6 WORKLOAD_INTERVAL_SECONDS=0.25 +WORKLOAD_INTERVAL_JITTER_RATIO=0 +WORKLOAD_TRAFFIC_PHASE_SECONDS=0 +WORKLOAD_TRAFFIC_MIN_INTERVAL_MULTIPLIER=1 +WORKLOAD_TRAFFIC_MAX_INTERVAL_MULTIPLIER=1 WORKLOAD_REPORT_INTERVAL_SECONDS=10 WORKLOAD_RANDOM_SEED=20260725 WORKLOAD_STATEMENT_TIMEOUT_MS=15000 diff --git a/.github/workflows/erp-load-acceptance.yml b/.github/workflows/erp-load-acceptance.yml deleted file mode 100644 index 5d14de6..0000000 --- a/.github/workflows/erp-load-acceptance.yml +++ /dev/null @@ -1,104 +0,0 @@ -name: ERP full load acceptance - -on: - workflow_dispatch: - schedule: - - cron: "41 2 * * 0" - -permissions: - contents: read - -concurrency: - group: erp-load-acceptance - cancel-in-progress: false - -jobs: - full-erp: - name: Dedicated 500-table concurrent acceptance - # This label must point only to an isolated >=12 vCPU, >=16 GiB Docker, - # >=30 GiB free runner. Never attach a production PostgreSQL source. - runs-on: [self-hosted, advisor-erp-load] - timeout-minutes: 120 - env: - COMPOSE_PROJECT_NAME: advisor-erp-${{ github.run_id }}-${{ github.run_attempt }} - REGISTER_DEMO_SOURCE: "true" - DEMO_SOURCE_FREQUENCY: "60" - ERP_FULL_ACCEPTANCE: "true" - ERP_BENCHMARK_PREPARE_FIRST: "true" - POSTGRES_ADMIN_PASSWORD: ephemeral-admin-${{ github.run_id }}-${{ github.run_attempt }} - POWA_COLLECTOR_PASSWORD: ephemeral-collector-${{ github.run_id }}-${{ github.run_attempt }} - ADVISOR_API_PASSWORD: ephemeral-api-${{ github.run_id }}-${{ github.run_attempt }} - ADVISOR_EVALUATOR_PASSWORD: ephemeral-evaluator-${{ github.run_id }}-${{ github.run_attempt }} - ADVISOR_JOIN_SOURCE_PASSWORD: ephemeral-join-source-${{ github.run_id }}-${{ github.run_attempt }} - ADVISOR_JOIN_REPOSITORY_PASSWORD: ephemeral-join-repository-${{ github.run_id }}-${{ github.run_attempt }} - WORKLOAD_DB_PASSWORD: ephemeral-workload-${{ github.run_id }}-${{ github.run_attempt }} - steps: - - name: Check out repository - uses: actions/checkout@v4 - - - name: Refuse non-isolated or undersized runner - shell: bash - run: | - set -Eeuo pipefail - echo "DESTRUCTIVE SYNTHETIC FIXTURE ONLY; NEVER RUN AGAINST A PRODUCTION SOURCE" - [[ "$COMPOSE_PROJECT_NAME" =~ ^advisor-erp-[0-9]+-[0-9]+$ ]] - if docker volume ls --format '{{.Name}}' | grep -q "^${COMPOSE_PROJECT_NAME}_"; then - echo "Fresh isolated project volumes already exist" >&2 - exit 1 - fi - test "$(getconf _NPROCESSORS_ONLN)" -ge 12 - python3 - <<'PY' - import shutil - from pathlib import Path - memory_kib = int(next(line.split()[1] for line in Path('/proc/meminfo').read_text().splitlines() if line.startswith('MemTotal:'))) - assert memory_kib >= 16 * 1024 * 1024, memory_kib - assert shutil.disk_usage('.').free >= 30 * 1024**3, shutil.disk_usage('.') - PY - - - name: Generate in-memory principal, boot, and run full acceptance - shell: bash - run: | - set -Eeuo pipefail - set +x - mapfile -t generated < <(python3 - <<'PY' - import base64, hashlib, json, secrets - token = "adv_pat_v1_" + base64.urlsafe_b64encode(secrets.token_bytes(32)).decode().rstrip("=") - registry = json.dumps([{ - "credential_id": "erp-load-ci", - "subject": "ci:erp-load-acceptance", - "token_sha256": hashlib.sha256(token.encode()).hexdigest(), - "roles": ["admin"], - }], separators=(",", ":")) - print(token) - print(registry) - PY - ) - test "${#generated[@]}" -eq 2 - ADVISOR_API_TOKEN="${generated[0]}" - ADVISOR_AUTH_PRINCIPALS="${generated[1]}" - echo "::add-mask::${ADVISOR_API_TOKEN}" - export ADVISOR_API_TOKEN ADVISOR_AUTH_PRINCIPALS - unset generated - docker compose build - docker compose up -d --wait --wait-timeout 300 - bash scripts/benchmark-erp-stack.sh run erp 600 32 - - - name: Upload benchmark reports and workload logs - if: always() - uses: actions/upload-artifact@v4 - with: - name: erp-load-${{ github.run_id }}-${{ github.run_attempt }} - path: runtime/load-reports/ - if-no-files-found: warn - retention-days: 14 - - - name: Show service logs on failure - if: failure() - run: docker compose logs --no-color --tail=500 - - - name: Remove only this run's disposable volumes - if: always() - run: | - echo "Removing disposable project: $COMPOSE_PROJECT_NAME" - [[ "$COMPOSE_PROJECT_NAME" =~ ^advisor-erp-[0-9]+-[0-9]+$ ]] - docker compose --profile realistic-load down --volumes --remove-orphans diff --git a/.github/workflows/postgres-integration.yml b/.github/workflows/postgres-integration.yml index dc82e39..9404f83 100644 --- a/.github/workflows/postgres-integration.yml +++ b/.github/workflows/postgres-integration.yml @@ -95,15 +95,18 @@ jobs: - name: Verify health and migration idempotency run: | - curl --fail --silent --show-error \ - http://127.0.0.1:8000/api/v1/health \ - | python -c ' - import json, sys - health = json.load(sys.stdin) - assert health["status"] == "healthy", health - assert health["repository"] == "healthy", health - assert health["collector"] == "healthy", health - ' + health_ready=false + for attempt in $(seq 1 18); do + if curl --fail --silent --show-error \ + http://127.0.0.1:8000/api/v1/health \ + | python -c 'import json, sys; health = json.load(sys.stdin); assert health["status"] == "healthy", health; assert health["repository"] == "healthy", health; assert health["collector"] == "healthy", health'; then + health_ready=true + break + fi + printf 'collector health attempt=%s is not ready yet\n' "$attempt" + sleep 10 + done + test "$health_ready" = true docker compose run --rm repository-migrate bash scripts/verify-temporal-reliability.sh docker compose exec -T repository-db \ @@ -118,7 +121,7 @@ jobs: psql -X --set=ON_ERROR_STOP=1 --username postgres --dbname powa --file=- \ < sql/tests/join_outbox_guardrail_integration.sql - - name: Verify a persisted 0013 database upgrades to 0014 + - name: Verify a persisted 0013 database upgrades through 0016 shell: bash run: | set -Eeuo pipefail @@ -170,7 +173,7 @@ jobs: release record; BEGIN SELECT * INTO STRICT release FROM advisor.release_info(); - IF release.current_migration <> '0014' OR release.applied_count <> 14 THEN + IF release.current_migration <> '0016' OR release.applied_count <> 16 THEN RAISE EXCEPTION 'unexpected upgraded release state: %', row_to_json(release); END IF; IF NOT has_function_privilege( @@ -207,6 +210,27 @@ jobs: - name: Prepare the deterministic runtime candidate run: bash scripts/verify.sh + - name: Refresh the disposable 30d runtime replay snapshot + run: | + docker compose exec -T query-metrics-snapshot-worker python - <<'PY' + import time + + from app.config import get_settings + from app.snapshot_worker import open_connection, refresh_snapshot + + + settings = get_settings() + with open_connection(settings) as connection: + for _ in range(30): + if refresh_snapshot(connection, "30d"): + break + time.sleep(2) + else: + raise SystemExit( + "30d query metrics snapshot advisory lock could not be acquired" + ) + PY + - name: Boot disposable clone services run: | docker compose --profile real-validation up -d --wait \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 20e6a04..b7949ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ Bu proje [Semantic Versioning](https://semver.org/) kullanır. Repository migration'ları ileri yönlüdür; uygulama sürümü ile şema uyumluluğu yükseltme runbook'unda belirtilir. +## 1.1.1 — 2026-07-30 + +- 1h/24h/7d/30d query-metrics sonuçları kalıcı materialized snapshot olarak + request yolundan önce hesaplanır. +- Ayrı worker snapshot'ları sırayla ve atomik olarak yeniler; hesaplama sürerken + önceki tamamlanmış sonuç servis edilir. +- Repository migration hedefi `0016`; query-metrics snapshot'larına ek olarak + global/server/database overview trendleri de önceden hesaplanır. + ## 1.1.0 — 2026-07-26 - Ana kaynakta yalnız persisted, salt-okunur sorgular için gerçek `EXPLAIN ANALYZE`. diff --git a/README.md b/README.md index 9a3f2be..d87d235 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # PostgreSQL Sorgu Performansı ve Öneri Motoru -Güncel sürüm: `1.1.0`. Değişiklikler [CHANGELOG.md](CHANGELOG.md), güvenli +Güncel sürüm: `1.1.1`. Değişiklikler [CHANGELOG.md](CHANGELOG.md), güvenli yükseltme ve geri dönüş adımları [upgrade/rollback runbook'unda](docs/UPGRADE_ROLLBACK.md). -PDF v1.1'de tarif edilen ilk iterasyonun çalışan referans uygulamasıdır. Tek bir Docker/OrbStack hostu üzerinde **iki ayrı PostgreSQL sunucu süreci** çalışır: demo kaynak instance `5432`, PoWA repository instance `5433`. PoWA Collector istatistikleri kaynaktan repository'ye taşır; FastAPI yalnız repository'yi okur ve React arayüzü sonuçları gösterir. Aynı repository/collector, `scripts/register-source.sh` ile birden fazla gerçek PostgreSQL kaynağı izleyebilir. +Tek bir Docker/OrbStack hostu üzerinde **iki ayrı PostgreSQL sunucu süreci** çalışır: demo kaynak instance `5432`, PoWA repository instance `5433`. PoWA Collector istatistikleri kaynaktan repository'ye taşır; FastAPI yalnız repository'yi okur ve React arayüzü sonuçları gösterir. Aynı repository/collector, `scripts/register-source.sh` ile birden fazla gerçek PostgreSQL kaynağı izleyebilir. > Bu sürüm önce hangi sorguya bakılması gerektiğini gösterir; gerçek CPU ile sampled wait profilini ayırır, JOIN ilişkilerini düşük yetkili snapshotter ile taşır ve iki kolonlu adayları HypoPG ile doğrular. Gerçek `CREATE INDEX` yalnız isteğe bağlı disposable clone profilinde çalışır; izlenen kaynakta otomatik DDL, SQL rewrite veya müdahale yapılmaz. @@ -442,9 +442,6 @@ docker compose down - [Kurulum ve işletim rehberi](docs/INSTALLATION.md) - [Mimari ve güvenlik sınırları](docs/ARCHITECTURE.md) -- [PDF v1.1 uygunluk ve düzeltme incelemesi](docs/PDF_REVIEW.md) -- [Güncel veri kullanımı ve İterasyon 1.1 raporu](docs/postgresql_dashboard_veri_kullanimi_raporu.pdf) -- [İterasyon 1.1 belge boşluk analizi](docs/ITERATION_1_1_GAP_ANALYSIS.md) - [İterasyon 2.1-B `pg_qualstats` durum ve işletim notu](docs/ITERATION_2_1_B_PG_QUALSTATS.md) - [İterasyon 2.2 HypoPG plan doğrulaması ve dış kaynak runbook'u](docs/ITERATION_2_2_HYPOPG.md) - [İterasyon 2.3 `pg_stat_kcache` CPU telemetrisi](docs/ITERATION_2_3_PG_STAT_KCACHE.md) diff --git a/VERSION b/VERSION index 9084fa2..524cb55 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.0 +1.1.1 diff --git a/backend/app/config.py b/backend/app/config.py index a8292b6..e2b0496 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -24,6 +24,20 @@ "30d": "1 day", } +QUERY_METRICS_SNAPSHOT_VIEWS: dict[str, str] = { + "1h": "query_metrics_snapshot_1h", + "24h": "query_metrics_snapshot_24h", + "7d": "query_metrics_snapshot_7d", + "30d": "query_metrics_snapshot_30d", +} + +GLOBAL_TREND_SNAPSHOT_VIEWS: dict[str, str] = { + "1h": "global_trend_snapshot_1h", + "24h": "global_trend_snapshot_24h", + "7d": "global_trend_snapshot_7d", + "30d": "global_trend_snapshot_30d", +} + PrincipalRole = Literal["analyst", "annotator", "admin"] @@ -91,6 +105,26 @@ class Settings(BaseSettings): ge=1024 * 1024, le=1024 * 1024 * 1024, ) + # A dedicated worker refreshes persistent materialized snapshots. API + # requests only read those snapshots and therefore never execute the + # expensive query_metrics function on a cold process-local cache. + query_metrics_snapshot_poll_seconds: float = Field(default=15.0, ge=1, le=300) + query_metrics_snapshot_1h_refresh_seconds: int = Field( + default=15 * 60, ge=60, le=86_400 + ) + query_metrics_snapshot_24h_refresh_seconds: int = Field( + default=60 * 60, ge=60, le=7 * 86_400 + ) + query_metrics_snapshot_7d_refresh_seconds: int = Field( + default=6 * 60 * 60, ge=60, le=30 * 86_400 + ) + query_metrics_snapshot_30d_refresh_seconds: int = Field( + default=12 * 60 * 60, ge=60, le=30 * 86_400 + ) + query_metrics_snapshot_statement_timeout_seconds: int = Field( + default=30 * 60, ge=60, le=6 * 60 * 60 + ) + query_metrics_snapshot_retry_seconds: int = Field(default=60, ge=5, le=3_600) sql_text_visibility: str = "authorized" retention_days: int = 90 log_level: str = "INFO" diff --git a/backend/app/main.py b/backend/app/main.py index 67019ad..31ffadf 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,12 +7,21 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from prometheus_client import CONTENT_TYPE_LATEST, generate_latest -from starlette.responses import Response +from starlette import status +from starlette.requests import Request +from starlette.responses import JSONResponse, Response from app.api.router import router from app.config import get_settings from app.db import close_pool, open_pool -from app.repositories.powa import repository +from app.repositories.powa import ( + GlobalTrendRefreshBackoff, + GlobalTrendSnapshotTooLarge, + QueryMetricsRefreshBackoff, + QueryMetricsSnapshotTooLarge, + QueryMetricsSnapshotWarming, + repository, +) from app.version import APPLICATION_VERSION @@ -58,6 +67,50 @@ async def lifespan(_: FastAPI): app.include_router(router) +@app.exception_handler(QueryMetricsSnapshotWarming) +async def snapshot_warming( + _: Request, + __: QueryMetricsSnapshotWarming, +) -> JSONResponse: + return JSONResponse( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + content={"detail": "Dashboard verileri ilk kez hazirlaniyor; kisa sure sonra deneyin."}, + headers={"Retry-After": "30"}, + ) + + +@app.exception_handler(QueryMetricsRefreshBackoff) +@app.exception_handler(GlobalTrendRefreshBackoff) +async def snapshot_refresh_backoff( + _: Request, + __: QueryMetricsRefreshBackoff | GlobalTrendRefreshBackoff, +) -> JSONResponse: + return JSONResponse( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + content={"detail": "Onceki yenileme basarisiz oldu; kisa bir sure sonra deneyin."}, + headers={"Retry-After": "30"}, + ) + + +@app.exception_handler(QueryMetricsSnapshotTooLarge) +@app.exception_handler(GlobalTrendSnapshotTooLarge) +async def snapshot_too_large( + _: Request, + exc: QueryMetricsSnapshotTooLarge | GlobalTrendSnapshotTooLarge, +) -> JSONResponse: + # Kapasite asimi yeniden denemeyle duzelmez; Retry-After bilinctli olarak yok. + logger.error("snapshot_capacity_exceeded", reason=str(exc)) + return JSONResponse( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + content={ + "detail": ( + "Snapshot yapilandirilan kapasite sinirini asti; " + "QUERY_LIST_CACHE_MAX_ROWS/QUERY_LIST_CACHE_MAX_BYTES degerlerini gozden gecirin." + ) + }, + ) + + @app.get("/", include_in_schema=False) async def root() -> dict[str, str]: return {"name": app.title, "docs": "/docs", "health": "/api/v1/health"} diff --git a/backend/app/repositories/powa.py b/backend/app/repositories/powa.py index 215d2ca..6219d14 100644 --- a/backend/app/repositories/powa.py +++ b/backend/app/repositories/powa.py @@ -13,7 +13,15 @@ from typing import Any from uuid import NAMESPACE_URL, uuid5 -from app.config import WINDOW_BUCKETS, WINDOW_INTERVALS, get_settings +from psycopg.errors import ObjectNotInPrerequisiteState + +from app.config import ( + GLOBAL_TREND_SNAPSHOT_VIEWS, + QUERY_METRICS_SNAPSHOT_VIEWS, + WINDOW_BUCKETS, + WINDOW_INTERVALS, + get_settings, +) from app.db import pool @@ -44,6 +52,14 @@ class QueryMetricsRefreshBackoff(RuntimeError): """A failed refresh is inside its bounded repository-protection backoff.""" +class QueryMetricsSnapshotWarming(RuntimeError): + """The persistent snapshot has not completed its first refresh yet.""" + + +class GlobalTrendSnapshotWarming(QueryMetricsSnapshotWarming): + """The persistent overview trend has not completed its first refresh yet.""" + + class GlobalTrendRefreshBackoff(RuntimeError): """A failed global-trend refresh is inside its bounded retry backoff.""" @@ -580,12 +596,11 @@ def __init__( self._query_metrics_cache_generation = 0 self._query_metrics_retry: dict[str, _QueryMetricsRetryState] = {} self._query_metrics_cache_lock = asyncio.Lock() - # Deliberately shared by query_metrics and global query_trend across - # every window. The repository must never run two expensive dashboard - # refresh scans concurrently. + # Global trends still require expensive live telemetry scans and share + # this lock across windows. Query-metrics reloads read precomputed + # materialized snapshots and intentionally do not queue behind it. self._repository_refresh_lock = asyncio.Lock() - # Retain the old private name for compatibility with diagnostics while - # making its repository-wide scope explicit above. + # Retain the old private name for compatibility with diagnostics. self._query_metrics_refresh_lock = self._repository_refresh_lock self._query_metrics_refresh_tasks: dict[ tuple[int, str], asyncio.Task[list[dict[str, Any]]] @@ -604,39 +619,47 @@ def __init__( self._clock = clock async def _load_query_metrics_snapshot(self, window: str) -> list[dict[str, Any]]: - interval = interval_for(window) + interval_for(window) + view_name = QUERY_METRICS_SNAPSHOT_VIEWS[window] async with pool.connection() as connection: - # A transaction-local application name remains visible while the - # named cursor executes FETCH statements. This lets the benchmark - # include stale-while-revalidate work in its measurement boundary. + # This is a bounded read of an already-computed materialized + # snapshot. Live annotations are overlaid so an annotation mutation + # does not have to trigger another full telemetry computation. async with connection.cursor() as control_cursor: await control_cursor.execute( "SET LOCAL application_name = " - "'advisor-query-metrics-cache-refresh'" + "'advisor-query-metrics-snapshot-read'" ) - # LIMIT max+1 detects an oversized row set without truncating it - # silently. The named cursor keeps libpq from buffering the whole - # result, while pg_column_size enforces a second payload-byte cap. async with connection.cursor( - name="advisor_query_metrics_cache_refresh" + name="advisor_query_metrics_snapshot_read" ) as cursor: - await cursor.execute( - """ - /* advisor-query-metrics-cache-refresh */ + try: + await cursor.execute( + f""" + /* advisor-query-metrics-snapshot-read */ SELECT metrics.*, - servers.alias AS server_alias, + COALESCE(annotation.status, 'NEW') + AS _live_review_status, + annotation.note AS _live_note, + annotation.updated_by AS _live_updated_by, + annotation.updated_at AS _live_updated_at, pg_column_size(metrics) - + COALESCE(pg_column_size(servers.alias), 0) AS _cache_row_bytes - FROM advisor.query_metrics(%s::interval) AS metrics - LEFT JOIN "PoWA".powa_servers AS servers - ON servers.id = metrics.server_id + FROM advisor.{view_name} AS metrics + LEFT JOIN advisor.query_annotations AS annotation + ON annotation.server_id = metrics.server_id + AND annotation.database_id = metrics.database_id + AND annotation.query_id = metrics.query_id LIMIT %s - /* advisor-query-metrics-cache-refresh */ + /* advisor-query-metrics-snapshot-read */ """, - (interval, self._query_list_cache_max_rows + 1), - ) + (self._query_list_cache_max_rows + 1,), + ) + except ObjectNotInPrerequisiteState as error: + raise QueryMetricsSnapshotWarming( + f"query metrics snapshot {window} is warming" + ) from error rows: list[dict[str, Any]] = [] payload_bytes = 0 while True: @@ -658,6 +681,16 @@ async def _load_query_metrics_snapshot(self, window: str) -> list[dict[str, Any] raise QueryMetricsSnapshotTooLarge( "query metrics snapshot configured byte limitini asti" ) + row["review_status"] = row.pop( + "_live_review_status", row.get("review_status") or "NEW" + ) + row["note"] = row.pop("_live_note", row.get("note")) + row["updated_by"] = row.pop( + "_live_updated_by", row.get("updated_by") + ) + row["updated_at"] = row.pop( + "_live_updated_at", row.get("updated_at") + ) rows.append(row) if len(rows) > self._query_list_cache_max_rows: raise QueryMetricsSnapshotTooLarge( @@ -710,8 +743,7 @@ async def _refresh_query_metrics_snapshot( ) -> list[dict[str, Any]]: task_key = (generation, window) try: - async with self._repository_refresh_lock: - loaded_rows = await self._load_query_metrics_snapshot(window) + loaded_rows = await self._load_query_metrics_snapshot(window) if len(loaded_rows) > self._query_list_cache_max_rows: # Defense in depth for tests/custom repository subclasses that # override the bounded server-cursor loader. @@ -738,6 +770,10 @@ async def _refresh_query_metrics_snapshot( return loaded_rows except asyncio.CancelledError: raise + except QueryMetricsSnapshotWarming: + # Bos snapshot beklenen ilk acilis durumudur; repository hatasi + # gibi backoff'u tetiklememelidir. + raise except Exception: async with self._query_metrics_cache_lock: if generation == self._query_metrics_cache_generation: @@ -813,49 +849,40 @@ async def _load_global_trend_snapshot( server_id: int | None = None, database_id: int | None = None, ) -> list[dict[str, Any]]: - interval = interval_for(window) - bucket = WINDOW_BUCKETS[window] - if server_id is None: - function_sql = "advisor.query_trend(now() - %s::interval, %s::interval)" - params: list[Any] = [ - interval, - bucket, - self._query_list_cache_max_rows + 1, - ] - else: - function_sql = ( - "advisor.query_trend(" - "now() - %s::interval, %s::interval, %s, %s)" - ) - params = [ - interval, - bucket, - server_id, - database_id, - self._query_list_cache_max_rows + 1, - ] + interval_for(window) + view_name = GLOBAL_TREND_SNAPSHOT_VIEWS[window] async with pool.connection() as connection: - # Keep background SWR work visible to the benchmark so its - # repository cost cannot leak beyond the measurement boundary. async with connection.cursor() as control_cursor: await control_cursor.execute( "SET LOCAL application_name = " - "'advisor-global-trend-cache-refresh'", + "'advisor-global-trend-snapshot-read'", [], ) async with connection.cursor() as cursor: - await cursor.execute( - f""" - /* advisor-global-trend-cache-refresh */ - SELECT bucket_at AS timestamp, + try: + await cursor.execute( + f""" + /* advisor-global-trend-snapshot-read */ + SELECT timestamp, total_exec_time_ms, calls - FROM {function_sql} + FROM advisor.{view_name} + WHERE server_id IS NOT DISTINCT FROM %s + AND database_id IS NOT DISTINCT FROM %s + ORDER BY timestamp LIMIT %s - /* advisor-global-trend-cache-refresh */ + /* advisor-global-trend-snapshot-read */ """, - params, - ) + ( + server_id, + database_id, + self._query_list_cache_max_rows + 1, + ), + ) + except ObjectNotInPrerequisiteState as error: + raise GlobalTrendSnapshotWarming( + f"global trend snapshot {window} is warming" + ) from error rows = [dict(row) for row in await cursor.fetchall()] if len(rows) > self._query_list_cache_max_rows: raise GlobalTrendSnapshotTooLarge( @@ -923,15 +950,14 @@ async def _refresh_global_trend_snapshot( ) task_key = (generation, cache_key) try: - async with self._repository_refresh_lock: - if server_id is None: - loaded_rows = await self._load_global_trend_snapshot(window) - else: - loaded_rows = await self._load_global_trend_snapshot( - window, - server_id=server_id, - database_id=database_id, - ) + if server_id is None: + loaded_rows = await self._load_global_trend_snapshot(window) + else: + loaded_rows = await self._load_global_trend_snapshot( + window, + server_id=server_id, + database_id=database_id, + ) if len(loaded_rows) > self._query_list_cache_max_rows: # Defense in depth for tests/custom subclasses that override # the bounded loader. @@ -958,6 +984,9 @@ async def _refresh_global_trend_snapshot( return cached_rows except asyncio.CancelledError: raise + except QueryMetricsSnapshotWarming: + # GlobalTrendSnapshotWarming bu sinifi genisletir. + raise except Exception: async with self._global_trend_cache_lock: if generation == self._global_trend_cache_generation: diff --git a/backend/app/snapshot_worker.py b/backend/app/snapshot_worker.py new file mode 100644 index 0000000..62c697c --- /dev/null +++ b/backend/app/snapshot_worker.py @@ -0,0 +1,369 @@ +from __future__ import annotations + +import logging +import signal +from datetime import datetime, timezone +from threading import Event +from time import monotonic +from typing import Final + +import psycopg +from psycopg import Connection, sql +from psycopg.rows import dict_row + +from app.config import ( + GLOBAL_TREND_SNAPSHOT_VIEWS, + QUERY_METRICS_SNAPSHOT_VIEWS, + Settings, + get_settings, +) + + +logger = logging.getLogger("advisor.snapshot_worker") + +WINDOW_ORDER: Final[tuple[str, ...]] = ("1h", "24h", "7d", "30d") +QUERY_METRICS_STATE_TABLE: Final = "query_metrics_snapshot_state" +GLOBAL_TREND_STATE_TABLE: Final = "global_trend_snapshot_state" +QUERY_METRICS_LOCK_NAME: Final = ( + "postgresql-advisor:query-metrics-snapshot-refresh" +) +GLOBAL_TREND_LOCK_NAME: Final = "postgresql-advisor:global-trend-snapshot-refresh" + + +def refresh_intervals(settings: Settings) -> dict[str, int]: + return { + "1h": settings.query_metrics_snapshot_1h_refresh_seconds, + "24h": settings.query_metrics_snapshot_24h_refresh_seconds, + "7d": settings.query_metrics_snapshot_7d_refresh_seconds, + "30d": settings.query_metrics_snapshot_30d_refresh_seconds, + } + + +def open_connection(settings: Settings) -> Connection[dict[str, object]]: + connection = psycopg.connect( + settings.database_conninfo, + autocommit=True, + row_factory=dict_row, + application_name="advisor-dashboard-snapshot-worker", + ) + connection.execute( + "SELECT set_config('statement_timeout', %s, false)", + (f"{settings.query_metrics_snapshot_statement_timeout_seconds}s",), + ) + return connection + + +def _set_refreshing( + connection: Connection[dict[str, object]], + window: str, + *, + state_table: str, +) -> None: + connection.execute( + f""" + UPDATE advisor.{state_table} + SET status = 'refreshing', + refresh_started_at = clock_timestamp(), + last_error = NULL, + updated_at = clock_timestamp() + WHERE window_key = %s + """, + (window,), + ) + + +def _set_ready( + connection: Connection[dict[str, object]], + window: str, + *, + state_table: str, + duration_ms: int, + row_count: int, +) -> None: + connection.execute( + f""" + UPDATE advisor.{state_table} + SET status = 'ready', + refreshed_at = clock_timestamp(), + refresh_duration_ms = %s, + row_count = %s, + last_error = NULL, + updated_at = clock_timestamp() + WHERE window_key = %s + """, + (duration_ms, row_count, window), + ) + + +def _set_failed( + connection: Connection[dict[str, object]], + window: str, + error: BaseException, + *, + state_table: str, +) -> None: + sqlstate = getattr(error, "sqlstate", None) + diagnostic = type(error).__name__ + if sqlstate: + diagnostic = f"{diagnostic} (SQLSTATE {sqlstate})" + connection.execute( + f""" + UPDATE advisor.{state_table} + SET status = 'failed', + last_error = %s, + updated_at = clock_timestamp() + WHERE window_key = %s + """, + (diagnostic[:500], window), + ) + + +def _refresh_snapshot( + connection: Connection[dict[str, object]], + window: str, + *, + views: dict[str, str], + state_table: str, + lock_name: str, + family: str, +) -> bool: + view_name = views[window] + lock_row = connection.execute( + "SELECT pg_try_advisory_lock(hashtextextended(%s, 0)) AS acquired", + (lock_name,), + ).fetchone() + if not lock_row or not lock_row["acquired"]: + return False + + started_at = monotonic() + try: + _set_refreshing(connection, window, state_table=state_table) + populated_row = connection.execute( + """ + SELECT ispopulated + FROM pg_matviews + WHERE schemaname = 'advisor' + AND matviewname = %s + """, + (view_name,), + ).fetchone() + if populated_row is None: + raise RuntimeError(f"{family} materialized snapshot is missing") + + concurrently = ( + sql.SQL("CONCURRENTLY ") if populated_row["ispopulated"] else sql.SQL("") + ) + connection.execute( + sql.SQL("REFRESH MATERIALIZED VIEW {}{}.{}").format( + concurrently, + sql.Identifier("advisor"), + sql.Identifier(view_name), + ) + ) + count_row = connection.execute( + sql.SQL("SELECT count(*) AS row_count FROM {}.{}").format( + sql.Identifier("advisor"), + sql.Identifier(view_name), + ) + ).fetchone() + duration_ms = max(0, round((monotonic() - started_at) * 1000)) + _set_ready( + connection, + window, + state_table=state_table, + duration_ms=duration_ms, + row_count=int(count_row["row_count"]) if count_row else 0, + ) + logger.info( + "snapshot ready family=%s window=%s rows=%s duration_ms=%s", + family, + window, + count_row["row_count"] if count_row else 0, + duration_ms, + ) + return True + except BaseException as error: + try: + _set_failed( + connection, + window, + error, + state_table=state_table, + ) + except Exception: + logger.exception( + "could not persist snapshot failure family=%s window=%s", + family, + window, + ) + raise + finally: + try: + connection.execute( + "SELECT pg_advisory_unlock(hashtextextended(%s, 0))", + (lock_name,), + ) + except Exception: + logger.exception("could not release snapshot advisory lock") + + +def refresh_snapshot( + connection: Connection[dict[str, object]], + window: str, +) -> bool: + return _refresh_snapshot( + connection, + window, + views=QUERY_METRICS_SNAPSHOT_VIEWS, + state_table=QUERY_METRICS_STATE_TABLE, + lock_name=QUERY_METRICS_LOCK_NAME, + family="query_metrics", + ) + + +def refresh_global_trend_snapshot( + connection: Connection[dict[str, object]], + window: str, +) -> bool: + return _refresh_snapshot( + connection, + window, + views=GLOBAL_TREND_SNAPSHOT_VIEWS, + state_table=GLOBAL_TREND_STATE_TABLE, + lock_name=GLOBAL_TREND_LOCK_NAME, + family="global_trend", + ) + + +def _due_windows( + connection: Connection[dict[str, object]], + settings: Settings, + *, + state_table: str, + now: datetime | None = None, +) -> list[str]: + rows = connection.execute( + f""" + SELECT window_key, refreshed_at + FROM advisor.{state_table} + ORDER BY CASE window_key + WHEN '1h' THEN 1 + WHEN '24h' THEN 2 + WHEN '7d' THEN 3 + WHEN '30d' THEN 4 + END + """ + ).fetchall() + refreshed = {str(row["window_key"]): row["refreshed_at"] for row in rows} + observed_at = now or datetime.now(timezone.utc) + intervals = refresh_intervals(settings) + due: list[str] = [] + for window in WINDOW_ORDER: + refreshed_at = refreshed.get(window) + if not isinstance(refreshed_at, datetime): + due.append(window) + continue + age_seconds = max(0.0, (observed_at - refreshed_at).total_seconds()) + if age_seconds >= intervals[window]: + due.append(window) + return due + + +def due_windows( + connection: Connection[dict[str, object]], + settings: Settings, + *, + now: datetime | None = None, +) -> list[str]: + return _due_windows( + connection, + settings, + state_table=QUERY_METRICS_STATE_TABLE, + now=now, + ) + + +def global_trend_due_windows( + connection: Connection[dict[str, object]], + settings: Settings, + *, + now: datetime | None = None, +) -> list[str]: + return _due_windows( + connection, + settings, + state_table=GLOBAL_TREND_STATE_TABLE, + now=now, + ) + + +def run_worker(settings: Settings, stop: Event) -> None: + retry_not_before: dict[tuple[str, str], float] = {} + while not stop.is_set(): + try: + with open_connection(settings) as connection: + while not stop.is_set(): + jobs = [ + ("query_metrics", window, refresh_snapshot) + for window in due_windows(connection, settings) + ] + jobs.extend( + ( + "global_trend", + window, + refresh_global_trend_snapshot, + ) + for window in global_trend_due_windows(connection, settings) + ) + attempted = False + for family, window, refresh in jobs: + if stop.is_set(): + break + retry_key = (family, window) + if monotonic() < retry_not_before.get(retry_key, 0): + continue + attempted = True + try: + refreshed = refresh(connection, window) + if refreshed: + retry_not_before.pop(retry_key, None) + else: + retry_not_before[retry_key] = ( + monotonic() + + settings.query_metrics_snapshot_poll_seconds + ) + except Exception: + retry_not_before[retry_key] = ( + monotonic() + + settings.query_metrics_snapshot_retry_seconds + ) + logger.exception( + "snapshot refresh failed family=%s window=%s", + family, + window, + ) + if not attempted: + stop.wait(settings.query_metrics_snapshot_poll_seconds) + except Exception: + logger.exception("snapshot worker connection loop failed") + stop.wait(settings.query_metrics_snapshot_retry_seconds) + + +def main() -> None: + logging.basicConfig( + level=getattr(logging, get_settings().log_level.upper(), logging.INFO), + format="%(asctime)s %(levelname)s %(name)s %(message)s", + ) + settings = get_settings() + stop = Event() + + def request_stop(_: int, __: object) -> None: + stop.set() + + signal.signal(signal.SIGTERM, request_stop) + signal.signal(signal.SIGINT, request_stop) + run_worker(settings, stop) + + +if __name__ == "__main__": + main() diff --git a/backend/app/version.py b/backend/app/version.py index f896033..735615f 100644 --- a/backend/app/version.py +++ b/backend/app/version.py @@ -1,4 +1,4 @@ """Release identifiers shared by every backend process.""" -APPLICATION_VERSION = "1.1.0" -EXPECTED_MIGRATION = "0014" +APPLICATION_VERSION = "1.1.1" +EXPECTED_MIGRATION = "0016" diff --git a/backend/tests/test_global_trend_cache.py b/backend/tests/test_global_trend_cache.py index 774ce42..de2c15e 100644 --- a/backend/tests/test_global_trend_cache.py +++ b/backend/tests/test_global_trend_cache.py @@ -66,6 +66,67 @@ async def wait_until(predicate: Any) -> None: raise AssertionError("asynchronous cache condition was not reached") +@pytest.mark.asyncio +async def test_global_trend_loader_reads_persistent_scoped_snapshot( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class Cursor: + def __init__(self) -> None: + self.executions: list[tuple[str, list[Any] | tuple[Any, ...]]] = [] + + async def __aenter__(self) -> Cursor: + return self + + async def __aexit__(self, *_: object) -> None: + return None + + async def execute( + self, query: str, params: list[Any] | tuple[Any, ...] + ) -> None: + self.executions.append((query, params)) + + async def fetchall(self) -> list[dict[str, Any]]: + return [trend_row(1)] + + class Connection: + def __init__(self) -> None: + self.control = Cursor() + self.data = Cursor() + self.cursor_count = 0 + + async def __aenter__(self) -> Connection: + return self + + async def __aexit__(self, *_: object) -> None: + return None + + def cursor(self) -> Cursor: + self.cursor_count += 1 + return self.control if self.cursor_count == 1 else self.data + + class Pool: + def __init__(self) -> None: + self.connection_instance = Connection() + + def connection(self) -> Connection: + return self.connection_instance + + fake_pool = Pool() + monkeypatch.setattr(powa_module, "pool", fake_pool) + repository = cache_repository(FakeClock()) + + rows = await repository._load_global_trend_snapshot( + "24h", server_id=7, database_id=16_384 + ) + + query, params = fake_pool.connection_instance.data.executions[0] + assert rows == [trend_row(1)] + assert "advisor.global_trend_snapshot_24h" in query + assert "advisor.query_trend(" not in query + assert "IS NOT DISTINCT FROM" in query + assert params == (7, 16_384, 100_001) + + @pytest.mark.asyncio async def test_global_trend_cold_singleflight_is_shielded_and_deep_copy_safe( monkeypatch: pytest.MonkeyPatch, @@ -220,7 +281,7 @@ async def load(_: str) -> list[dict[str, Any]]: @pytest.mark.asyncio -async def test_query_metrics_and_global_trend_share_repository_refresh_lock( +async def test_precomputed_metrics_and_trend_snapshots_read_concurrently( monkeypatch: pytest.MonkeyPatch, ) -> None: repository = cache_repository(FakeClock()) @@ -243,8 +304,8 @@ async def load_trend(_: str) -> list[dict[str, Any]]: metrics_request = asyncio.create_task(repository.query_rows(window="24h")) await metrics_started.wait() trend_request = asyncio.create_task(repository.trend(window="24h")) - await asyncio.sleep(0) - assert not trend_started.is_set() + await wait_until(trend_started.is_set) + assert trend_started.is_set() assert repository._query_metrics_refresh_lock is repository._repository_refresh_lock metrics_release.set() diff --git a/backend/tests/test_product_api.py b/backend/tests/test_product_api.py index 0e29aac..c031aaa 100644 --- a/backend/tests/test_product_api.py +++ b/backend/tests/test_product_api.py @@ -488,17 +488,17 @@ async def rows(**_: object): def test_release_contract_reports_expected_migration() -> None: payload = _release_payload({ - "current_migration": "0014", - "applied_count": 14, - "latest_applied_at": datetime(2026, 7, 26, tzinfo=timezone.utc), + "current_migration": "0016", + "applied_count": 16, + "latest_applied_at": datetime(2026, 7, 30, tzinfo=timezone.utc), }) - assert payload["applicationVersion"] == "1.1.0" - assert payload["migration"]["expected"] == "0014" + assert payload["applicationVersion"] == "1.1.1" + assert payload["migration"]["expected"] == "0016" assert payload["migration"]["upToDate"] is True def test_backend_versions_and_scoped_openapi_contract_are_aligned() -> None: - assert api_app.version == evaluator_app.version == clone_app.version == "1.1.0" + assert api_app.version == evaluator_app.version == clone_app.version == "1.1.1" schema = api_app.openapi() for path in ("/api/v1/system-health", "/api/v1/operations"): parameters = { diff --git a/backend/tests/test_query_rows_cache.py b/backend/tests/test_query_rows_cache.py index 4de00af..befb7bb 100644 --- a/backend/tests/test_query_rows_cache.py +++ b/backend/tests/test_query_rows_cache.py @@ -303,7 +303,7 @@ async def load(_: str) -> list[dict[str, Any]]: @pytest.mark.asyncio -async def test_different_windows_do_not_scan_repository_concurrently( +async def test_different_windows_read_precomputed_snapshots_concurrently( monkeypatch: pytest.MonkeyPatch, ) -> None: repository = cache_repository(FakeClock()) @@ -322,8 +322,8 @@ async def load(window: str) -> list[dict[str, Any]]: first = asyncio.create_task(repository.query_rows(window="1h")) await first_started.wait() second = asyncio.create_task(repository.query_rows(window="24h")) - await asyncio.sleep(0) - assert started_windows == ["1h"] + await wait_until(lambda: started_windows == ["1h", "24h"]) + assert started_windows == ["1h", "24h"] first_release.set() await asyncio.gather(first, second) @@ -641,12 +641,15 @@ def connection(self) -> Connection: with pytest.raises(QueryMetricsSnapshotTooLarge): await repository._load_query_metrics_snapshot("1h") - assert connection.cursor_names == [None, "advisor_query_metrics_cache_refresh"] + assert connection.cursor_names == [None, "advisor_query_metrics_snapshot_read"] assert "SET LOCAL application_name" in connection.control_cursor.query - assert "advisor-query-metrics-cache-refresh" in cursor.query + assert "advisor-query-metrics-snapshot-read" in cursor.query + assert "advisor.query_metrics_snapshot_1h" in cursor.query + assert "advisor.query_metrics(" not in cursor.query + assert "advisor.query_annotations" in cursor.query assert "pg_column_size(metrics)" in cursor.query assert "LIMIT %s" in cursor.query - assert cursor.params == ("1 hour", 3) + assert cursor.params == (3,) assert cursor.fetch_sizes == [3, 1] diff --git a/backend/tests/test_snapshot_worker.py b/backend/tests/test_snapshot_worker.py new file mode 100644 index 0000000..ba987c9 --- /dev/null +++ b/backend/tests/test_snapshot_worker.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any + +from app.config import ( + GLOBAL_TREND_SNAPSHOT_VIEWS, + QUERY_METRICS_SNAPSHOT_VIEWS, + Settings, + WINDOW_INTERVALS, +) +from app.snapshot_worker import ( + WINDOW_ORDER, + due_windows, + global_trend_due_windows, + refresh_intervals, +) + + +class Result: + def __init__(self, rows: list[dict[str, Any]]) -> None: + self.rows = rows + + def fetchall(self) -> list[dict[str, Any]]: + return self.rows + + +class Connection: + def __init__(self, rows: list[dict[str, Any]]) -> None: + self.rows = rows + + def execute(self, _: str) -> Result: + return Result(self.rows) + + +def test_snapshot_view_mapping_covers_every_supported_window() -> None: + assert tuple(QUERY_METRICS_SNAPSHOT_VIEWS) == WINDOW_ORDER + assert tuple(GLOBAL_TREND_SNAPSHOT_VIEWS) == WINDOW_ORDER + assert set(QUERY_METRICS_SNAPSHOT_VIEWS) == set(WINDOW_INTERVALS) + assert set(GLOBAL_TREND_SNAPSHOT_VIEWS) == set(WINDOW_INTERVALS) + + +def test_due_windows_prioritize_short_windows_and_respect_cadence() -> None: + observed_at = datetime(2026, 7, 30, 12, tzinfo=timezone.utc) + settings = Settings( + query_metrics_snapshot_1h_refresh_seconds=15 * 60, + query_metrics_snapshot_24h_refresh_seconds=60 * 60, + query_metrics_snapshot_7d_refresh_seconds=6 * 60 * 60, + query_metrics_snapshot_30d_refresh_seconds=12 * 60 * 60, + ) + connection = Connection( + [ + { + "window_key": "1h", + "refreshed_at": observed_at - timedelta(minutes=20), + }, + { + "window_key": "24h", + "refreshed_at": observed_at - timedelta(minutes=30), + }, + {"window_key": "7d", "refreshed_at": None}, + { + "window_key": "30d", + "refreshed_at": observed_at - timedelta(hours=1), + }, + ] + ) + + assert due_windows(connection, settings, now=observed_at) == ["1h", "7d"] + assert global_trend_due_windows( + connection, settings, now=observed_at + ) == ["1h", "7d"] + assert refresh_intervals(settings) == { + "1h": 900, + "24h": 3600, + "7d": 21600, + "30d": 43200, + } diff --git a/backend/tests/test_trend_repository.py b/backend/tests/test_trend_repository.py index 585dd0a..e7bf664 100644 --- a/backend/tests/test_trend_repository.py +++ b/backend/tests/test_trend_repository.py @@ -54,7 +54,7 @@ def connection(self) -> FakeConnection: @pytest.mark.asyncio -async def test_global_trend_uses_preaggregated_two_argument_helper( +async def test_global_trend_reads_precomputed_fleet_snapshot( monkeypatch: pytest.MonkeyPatch, ) -> None: cursor = FakeCursor() @@ -66,14 +66,13 @@ async def test_global_trend_uses_preaggregated_two_argument_helper( assert rows[0]["calls"] == 3 tag_query, tag_params = cursor.executions[0] assert "SET LOCAL application_name" in tag_query - assert "advisor-global-trend-cache-refresh" in tag_query + assert "advisor-global-trend-snapshot-read" in tag_query assert tag_params == [] query, params = cursor.executions[1] - assert "advisor.query_trend(" in query - assert "now() - %s::interval" in query - assert "advisor.query_deltas" not in query - assert "advisor-global-trend-cache-refresh" in query - assert params == ["1 hour", "5 minutes", 100_001] + assert "advisor.global_trend_snapshot_1h" in query + assert "advisor.query_trend(" not in query + assert "advisor-global-trend-snapshot-read" in query + assert params == [None, None, 100_001] @pytest.mark.asyncio @@ -100,7 +99,7 @@ async def test_query_trend_pushes_complete_scope_into_five_argument_helper( @pytest.mark.asyncio -async def test_server_trend_uses_four_argument_scope_helper( +async def test_server_trend_reads_precomputed_server_scope( monkeypatch: pytest.MonkeyPatch, ) -> None: cursor = FakeCursor() @@ -110,11 +109,12 @@ async def test_server_trend_uses_four_argument_scope_helper( await repository.trend(window="1h", server_id=7) tag_query, tag_params = cursor.executions[0] - assert "advisor-global-trend-cache-refresh" in tag_query + assert "advisor-global-trend-snapshot-read" in tag_query assert tag_params == [] query, params = cursor.executions[1] - assert "now() - %s::interval, %s::interval, %s, %s)" in query - assert params == ["1 hour", "5 minutes", 7, None, 100_001] + assert "advisor.global_trend_snapshot_1h" in query + assert "advisor.query_trend(" not in query + assert params == [7, None, 100_001] @pytest.mark.asyncio diff --git a/compose.production.yaml b/compose.production.yaml new file mode 100644 index 0000000..6b47241 --- /dev/null +++ b/compose.production.yaml @@ -0,0 +1,35 @@ +services: + caddy: + image: caddy:2.10.2-alpine + environment: + DASHBOARD_DOMAIN: ${DASHBOARD_DOMAIN:?Set DASHBOARD_DOMAIN in .env} + ACME_EMAIL: ${ACME_EMAIL:?Set ACME_EMAIL in .env} + DASHBOARD_BASIC_AUTH_USER: ${DASHBOARD_BASIC_AUTH_USER:?Set DASHBOARD_BASIC_AUTH_USER in .env} + DASHBOARD_BASIC_AUTH_HASH: ${DASHBOARD_BASIC_AUTH_HASH:?Set DASHBOARD_BASIC_AUTH_HASH in .env} + ports: + - "80:80" + - "443:443" + - "443:443/udp" + volumes: + - ./deployment/production/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + depends_on: + web: + condition: service_healthy + restart: unless-stopped + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,size=32m + - /run:rw,noexec,nosuid,size=8m + cap_drop: [ALL] + cap_add: [NET_BIND_SERVICE] + security_opt: + - no-new-privileges:true + pids_limit: 128 + mem_limit: 256m + networks: [advisor] + +volumes: + caddy_data: + caddy_config: diff --git a/compose.yaml b/compose.yaml index f726d74..914778c 100644 --- a/compose.yaml +++ b/compose.yaml @@ -138,6 +138,44 @@ services: restart: "no" networks: [advisor] + query-metrics-snapshot-worker: + build: + context: ./backend + image: postgresql-advisor/api:iteration-2.7 + command: ["python", "-m", "app.snapshot_worker"] + environment: + DATABASE_URL: ${ADVISOR_API_DATABASE_URL:-} + DATABASE_HOST: ${ADVISOR_API_DATABASE_HOST:-repository-db} + DATABASE_PORT: ${ADVISOR_API_DATABASE_PORT:-5433} + DATABASE_NAME: ${ADVISOR_API_DATABASE_NAME:-powa_repository} + DATABASE_USER: ${ADVISOR_API_DATABASE_USER:-advisor_api} + DATABASE_PASSWORD: ${ADVISOR_API_PASSWORD:?Set ADVISOR_API_PASSWORD in .env} + DATABASE_SSLMODE: ${ADVISOR_API_DATABASE_SSLMODE:-disable} + QUERY_METRICS_SNAPSHOT_POLL_SECONDS: ${QUERY_METRICS_SNAPSHOT_POLL_SECONDS:-15} + QUERY_METRICS_SNAPSHOT_1H_REFRESH_SECONDS: ${QUERY_METRICS_SNAPSHOT_1H_REFRESH_SECONDS:-900} + QUERY_METRICS_SNAPSHOT_24H_REFRESH_SECONDS: ${QUERY_METRICS_SNAPSHOT_24H_REFRESH_SECONDS:-3600} + QUERY_METRICS_SNAPSHOT_7D_REFRESH_SECONDS: ${QUERY_METRICS_SNAPSHOT_7D_REFRESH_SECONDS:-21600} + QUERY_METRICS_SNAPSHOT_30D_REFRESH_SECONDS: ${QUERY_METRICS_SNAPSHOT_30D_REFRESH_SECONDS:-43200} + QUERY_METRICS_SNAPSHOT_STATEMENT_TIMEOUT_SECONDS: ${QUERY_METRICS_SNAPSHOT_STATEMENT_TIMEOUT_SECONDS:-1800} + QUERY_METRICS_SNAPSHOT_RETRY_SECONDS: ${QUERY_METRICS_SNAPSHOT_RETRY_SECONDS:-60} + LOG_LEVEL: ${LOG_LEVEL:-INFO} + depends_on: + repository-db: + condition: service_healthy + repository-migrate: + condition: service_completed_successfully + init: true + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,size=16m + cap_drop: [ALL] + security_opt: + - no-new-privileges:true + pids_limit: 32 + mem_limit: ${QUERY_METRICS_SNAPSHOT_WORKER_MEMORY_LIMIT:-256m} + restart: unless-stopped + networks: [advisor] + collector: build: context: . @@ -166,6 +204,9 @@ services: condition: service_healthy repository-migrate: condition: service_completed_successfully + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,size=16m,mode=1777 healthcheck: test: ["CMD-SHELL", "python -c \"import os,signal; os.kill(1, signal.SIGCONT)\""] interval: 15s @@ -275,6 +316,8 @@ services: condition: service_healthy repository-migrate: condition: service_completed_successfully + query-metrics-snapshot-worker: + condition: service_started collector: condition: service_started evaluator: @@ -448,6 +491,10 @@ services: WORKLOAD_PROFILE: ${WORKLOAD_PROFILE:-normal} WORKLOAD_DURATION_SECONDS: ${WORKLOAD_DURATION_SECONDS:-0} WORKLOAD_INTERVAL_SECONDS: ${WORKLOAD_INTERVAL_SECONDS:-0.25} + WORKLOAD_INTERVAL_JITTER_RATIO: ${WORKLOAD_INTERVAL_JITTER_RATIO:-0} + WORKLOAD_TRAFFIC_PHASE_SECONDS: ${WORKLOAD_TRAFFIC_PHASE_SECONDS:-0} + WORKLOAD_TRAFFIC_MIN_INTERVAL_MULTIPLIER: ${WORKLOAD_TRAFFIC_MIN_INTERVAL_MULTIPLIER:-1} + WORKLOAD_TRAFFIC_MAX_INTERVAL_MULTIPLIER: ${WORKLOAD_TRAFFIC_MAX_INTERVAL_MULTIPLIER:-1} WORKLOAD_WORKERS: ${WORKLOAD_WORKERS:-6} WORKLOAD_REPORT_INTERVAL_SECONDS: ${WORKLOAD_REPORT_INTERVAL_SECONDS:-10} WORKLOAD_RANDOM_SEED: ${WORKLOAD_RANDOM_SEED:-20260725} diff --git a/deployment/production/Caddyfile b/deployment/production/Caddyfile new file mode 100644 index 0000000..4ae57f7 --- /dev/null +++ b/deployment/production/Caddyfile @@ -0,0 +1,23 @@ +{ + email {$ACME_EMAIL} + admin off +} + +{$DASHBOARD_DOMAIN} { + encode zstd gzip + + basic_auth { + {$DASHBOARD_BASIC_AUTH_USER} {$DASHBOARD_BASIC_AUTH_HASH} + } + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + Referrer-Policy "strict-origin-when-cross-origin" + Permissions-Policy "camera=(), geolocation=(), microphone=()" + -Server + } + + reverse_proxy web:80 +} diff --git a/deployment/production/bootstrap-env.sh b/deployment/production/bootstrap-env.sh new file mode 100755 index 0000000..e50525a --- /dev/null +++ b/deployment/production/bootstrap-env.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +project_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P)" +cd "$project_dir" + +dashboard_domain="${DASHBOARD_DOMAIN:-}" +acme_email="${ACME_EMAIL:-}" +basic_auth_user="${DASHBOARD_BASIC_AUTH_USER:-admin}" +compose_project_name="${COMPOSE_PROJECT_NAME:-postgresql-advisor}" +credentials_path="${CREDENTIALS_PATH:-${HOME}/.postgresql-advisor-credentials}" + +if [[ -z "$dashboard_domain" || -z "$acme_email" ]]; then + printf 'DASHBOARD_DOMAIN and ACME_EMAIL are required\n' >&2 + printf 'usage: DASHBOARD_DOMAIN=dashboard.example.com ACME_EMAIL=ops@example.com %s\n' \ + "${BASH_SOURCE[0]}" >&2 + printf 'optional: DASHBOARD_BASIC_AUTH_USER, COMPOSE_PROJECT_NAME, CREDENTIALS_PATH\n' >&2 + exit 1 +fi + +if [[ -e .env ]]; then + printf 'Refusing to overwrite existing %s/.env\n' "$project_dir" >&2 + exit 1 +fi + +command -v docker >/dev/null 2>&1 || { + printf 'docker is required\n' >&2 + exit 1 +} +command -v openssl >/dev/null 2>&1 || { + printf 'openssl is required\n' >&2 + exit 1 +} + +# Checked before .env is written: failing later would leave an .env that the +# guard above refuses to regenerate, losing the admin token permanently. +credentials_dir="$(dirname -- "$credentials_path")" +if [[ ! -d "$credentials_dir" || ! -w "$credentials_dir" ]]; then + printf 'credentials directory %s must exist and be writable\n' "$credentials_dir" >&2 + exit 1 +fi + +umask 077 + +random_hex() { + openssl rand -hex 32 +} + +basic_auth_password="$(openssl rand -base64 24 | tr -d '\n' | tr '/+' '_-')" +# Must satisfy BEARER_TOKEN_PATTERN in backend/app/security.py, which is +# fullmatched before any hash comparison. +admin_token="adv_pat_v1_$(openssl rand -base64 32 | tr -d '\n=' | tr '/+' '_-' | cut -c1-43)" +if [[ ! "$admin_token" =~ ^adv_pat_v1_[A-Za-z0-9_-]{43}$ ]]; then + printf 'generated admin token does not match the API bearer format\n' >&2 + exit 1 +fi +admin_token_sha256="$(printf '%s' "$admin_token" | openssl dgst -sha256 -r | awk '{print $1}')" +basic_auth_hash="$( + printf '%s\n' "$basic_auth_password" | + docker run --rm -i caddy:2.10.2-alpine \ + caddy hash-password +)" +principal_json="$( + printf '[{"credential_id":"prod-admin","subject":"production-admin","token_sha256":"%s","roles":["analyst","annotator","admin"]}]' \ + "$admin_token_sha256" +)" + +{ + printf 'COMPOSE_PROJECT_NAME=%s\n' "$compose_project_name" + printf 'COMPOSE_FILE=compose.yaml:compose.production.yaml\n' + printf 'DASHBOARD_DOMAIN=%s\n' "$dashboard_domain" + printf 'ACME_EMAIL=%s\n' "$acme_email" + printf 'POSTGRES_ADMIN_PASSWORD=%s\n' "$(random_hex)" + printf 'POWA_COLLECTOR_PASSWORD=%s\n' "$(random_hex)" + printf 'ADVISOR_API_PASSWORD=%s\n' "$(random_hex)" + printf 'ADVISOR_EVALUATOR_PASSWORD=%s\n' "$(random_hex)" + printf 'ADVISOR_EVALUATOR_READ_SCHEMAS=public\n' + printf 'EVALUATOR_TOKEN=%s\n' "$(random_hex)" + printf 'ADVISOR_JOIN_SOURCE_PASSWORD=%s\n' "$(random_hex)" + printf 'ADVISOR_JOIN_REPOSITORY_PASSWORD=%s\n' "$(random_hex)" + printf 'WORKLOAD_DB_PASSWORD=%s\n' "$(random_hex)" + printf 'CLONE_ADMIN_PASSWORD=%s\n' "$(random_hex)" + printf 'CLONE_RUNNER_PASSWORD=%s\n' "$(random_hex)" + printf 'CLONE_EVALUATOR_TOKEN=%s\n' "$(random_hex)" + printf "ADVISOR_AUTH_PRINCIPALS='%s'\n" "$principal_json" + printf 'SOURCE_DB_BIND=127.0.0.1\n' + printf 'SOURCE_DB_PORT=15432\n' + printf 'REPOSITORY_DB_BIND=127.0.0.1\n' + printf 'REPOSITORY_DB_PORT=15433\n' + printf 'API_BIND=127.0.0.1\n' + printf 'API_PORT=8000\n' + printf 'WEB_BIND=127.0.0.1\n' + printf 'WEB_PORT=5173\n' + printf 'DEFAULT_WINDOW=24h\n' + printf 'RETENTION_DAYS=90\n' + printf 'LOG_LEVEL=INFO\n' + printf 'API_MEMORY_LIMIT=1g\n' + printf 'QUERY_METRICS_SNAPSHOT_POLL_SECONDS=15\n' + printf 'QUERY_METRICS_SNAPSHOT_1H_REFRESH_SECONDS=900\n' + printf 'QUERY_METRICS_SNAPSHOT_24H_REFRESH_SECONDS=3600\n' + printf 'QUERY_METRICS_SNAPSHOT_7D_REFRESH_SECONDS=21600\n' + printf 'QUERY_METRICS_SNAPSHOT_30D_REFRESH_SECONDS=43200\n' + printf 'QUERY_METRICS_SNAPSHOT_STATEMENT_TIMEOUT_SECONDS=1800\n' + printf 'QUERY_METRICS_SNAPSHOT_RETRY_SECONDS=60\n' + printf 'QUERY_METRICS_SNAPSHOT_WORKER_MEMORY_LIMIT=256m\n' + printf 'SOURCE_DB_SHM_SIZE=512mb\n' + printf 'REPOSITORY_DB_SHM_SIZE=256mb\n' + printf 'REGISTER_DEMO_SOURCE=false\n' + printf 'POWA_SOURCE_SSLMODE=prefer\n' + printf 'DASHBOARD_BASIC_AUTH_USER=%s\n' "$basic_auth_user" + printf "DASHBOARD_BASIC_AUTH_HASH='%s'\n" "$basic_auth_hash" +} > .env +chmod 0600 .env + +{ + printf 'Dashboard URL: https://%s\n' "$dashboard_domain" + printf 'Dashboard basic-auth user: %s\n' "$basic_auth_user" + printf 'Dashboard basic-auth password: %s\n' "$basic_auth_password" + printf 'Advisor API admin bearer token: %s\n' "$admin_token" +} > "$credentials_path" +chmod 0600 "$credentials_path" + +docker compose config --quiet +printf 'Created %s/.env and %s\n' "$project_dir" "$credentials_path" diff --git a/deployment/workload/test_workload.py b/deployment/workload/test_workload.py index 48aff82..faf43a8 100644 --- a/deployment/workload/test_workload.py +++ b/deployment/workload/test_workload.py @@ -137,6 +137,10 @@ def test_documented_environment_overrides_are_applied(self) -> None: WORKLOAD_DURATION_SECONDS="60", WORKLOAD_WORKERS="12", WORKLOAD_INTERVAL_SECONDS="0.125", + WORKLOAD_INTERVAL_JITTER_RATIO="0.4", + WORKLOAD_TRAFFIC_PHASE_SECONDS="30", + WORKLOAD_TRAFFIC_MIN_INTERVAL_MULTIPLIER="0.5", + WORKLOAD_TRAFFIC_MAX_INTERVAL_MULTIPLIER="2.5", WORKLOAD_REPORT_INTERVAL_SECONDS="4", WORKLOAD_RANDOM_SEED="42", WORKLOAD_STATEMENT_TIMEOUT_MS="7000", @@ -146,6 +150,10 @@ def test_documented_environment_overrides_are_applied(self) -> None: self.assertEqual(config.duration_seconds, 60) self.assertEqual(config.workers, 12) self.assertEqual(config.interval_seconds, 0.125) + self.assertEqual(config.interval_jitter_ratio, 0.4) + self.assertEqual(config.traffic_phase_seconds, 30) + self.assertEqual(config.traffic_min_interval_multiplier, 0.5) + self.assertEqual(config.traffic_max_interval_multiplier, 2.5) self.assertEqual(config.report_interval_seconds, 4) self.assertEqual(config.random_seed, 42) self.assertEqual(config.statement_timeout_ms, 7000) @@ -159,6 +167,12 @@ def test_invalid_configuration_fails_closed(self) -> None: {"WORKLOAD_WORKERS": "2"}, {"WORKLOAD_DURATION_SECONDS": "-1"}, {"WORKLOAD_INTERVAL_SECONDS": "nan"}, + {"WORKLOAD_INTERVAL_JITTER_RATIO": "1"}, + {"WORKLOAD_TRAFFIC_PHASE_SECONDS": "-1"}, + { + "WORKLOAD_TRAFFIC_MIN_INTERVAL_MULTIPLIER": "3", + "WORKLOAD_TRAFFIC_MAX_INTERVAL_MULTIPLIER": "2", + }, {"WORKLOAD_ERP_TABLE_COUNT": "501"}, {"WORKLOAD_ERP_QUERY_VARIANTS_PER_TABLE": "9"}, {"WORKLOAD_ERP_ROWS_PER_TABLE": "5001"}, @@ -329,6 +343,31 @@ def test_weighted_operation_selection_never_crosses_role(self) -> None: } self.assertEqual(selected, {role}) + def test_variable_traffic_interval_is_bounded_and_phase_based(self) -> None: + config = test_config( + WORKLOAD_INTERVAL_SECONDS="0.2", + WORKLOAD_INTERVAL_JITTER_RATIO="0.25", + WORKLOAD_TRAFFIC_PHASE_SECONDS="30", + WORKLOAD_TRAFFIC_MIN_INTERVAL_MULTIPLIER="0.5", + WORKLOAD_TRAFFIC_MAX_INTERVAL_MULTIPLIER="2", + ) + first_phase = workload.traffic_phase_interval_multiplier(config, 5) + self.assertEqual( + first_phase, + workload.traffic_phase_interval_multiplier(config, 29.9), + ) + self.assertNotEqual( + first_phase, + workload.traffic_phase_interval_multiplier(config, 30), + ) + + intervals = [ + workload.traffic_interval_seconds(config, random.Random(seed), 5) + for seed in range(20) + ] + self.assertGreater(len(set(intervals)), 1) + self.assertTrue(all(0.075 <= interval <= 0.5 for interval in intervals)) + class OperationTests(unittest.TestCase): def test_mutation_write_uses_bounded_cleanup(self) -> None: @@ -528,7 +567,7 @@ def test_timeout_operational_errors_are_not_connection_failures(self) -> None: workload._is_connection_failure(connection_error, connection) ) - def test_start_and_final_use_utc_timestamps_without_changing_heartbeat(self) -> None: + def test_start_final_and_heartbeat_have_stable_metadata(self) -> None: emitted: list[dict[str, object]] = [] config = test_config() @@ -574,10 +613,20 @@ def test_start_and_final_use_utc_timestamps_without_changing_heartbeat(self) -> "profile", "elapsedSeconds", "remainingSeconds", + "traffic", "totals", "categories", }, ) + self.assertEqual( + heartbeat["traffic"], + { + "baseIntervalSeconds": config.interval_seconds, + "intervalJitterRatio": config.interval_jitter_ratio, + "phaseSeconds": config.traffic_phase_seconds, + "phaseIntervalMultiplier": 1.0, + }, + ) failed_emissions: list[dict[str, object]] = [] with ( diff --git a/deployment/workload/workload.py b/deployment/workload/workload.py index 3dccde7..696cc92 100644 --- a/deployment/workload/workload.py +++ b/deployment/workload/workload.py @@ -558,6 +558,10 @@ class WorkloadConfig: duration_seconds: int workers: int interval_seconds: float + interval_jitter_ratio: float + traffic_phase_seconds: int + traffic_min_interval_multiplier: float + traffic_max_interval_multiplier: float report_interval_seconds: int random_seed: int statement_timeout_ms: int @@ -598,6 +602,39 @@ def from_env(cls, environ: Mapping[str, str] | None = None) -> WorkloadConfig: interval_seconds = _number( values, defaults, "WORKLOAD_INTERVAL_SECONDS", 0, 60 ) + interval_jitter_ratio = _number( + values, + {"WORKLOAD_INTERVAL_JITTER_RATIO": "0"}, + "WORKLOAD_INTERVAL_JITTER_RATIO", + 0, + 0.95, + ) + traffic_phase_seconds = _integer( + values, + {"WORKLOAD_TRAFFIC_PHASE_SECONDS": "0"}, + "WORKLOAD_TRAFFIC_PHASE_SECONDS", + 0, + 3_600, + ) + traffic_min_interval_multiplier = _number( + values, + {"WORKLOAD_TRAFFIC_MIN_INTERVAL_MULTIPLIER": "1"}, + "WORKLOAD_TRAFFIC_MIN_INTERVAL_MULTIPLIER", + 0.1, + 5, + ) + traffic_max_interval_multiplier = _number( + values, + {"WORKLOAD_TRAFFIC_MAX_INTERVAL_MULTIPLIER": "1"}, + "WORKLOAD_TRAFFIC_MAX_INTERVAL_MULTIPLIER", + 0.1, + 5, + ) + if traffic_min_interval_multiplier > traffic_max_interval_multiplier: + raise ValueError( + "WORKLOAD_TRAFFIC_MIN_INTERVAL_MULTIPLIER cannot exceed " + "WORKLOAD_TRAFFIC_MAX_INTERVAL_MULTIPLIER" + ) report_interval_seconds = _integer( values, defaults, "WORKLOAD_REPORT_INTERVAL_SECONDS", 1, 300 ) @@ -703,6 +740,10 @@ def from_env(cls, environ: Mapping[str, str] | None = None) -> WorkloadConfig: duration_seconds=duration_seconds, workers=workers, interval_seconds=interval_seconds, + interval_jitter_ratio=interval_jitter_ratio, + traffic_phase_seconds=traffic_phase_seconds, + traffic_min_interval_multiplier=traffic_min_interval_multiplier, + traffic_max_interval_multiplier=traffic_max_interval_multiplier, report_interval_seconds=report_interval_seconds, random_seed=random_seed, statement_timeout_ms=statement_timeout_ms, @@ -1332,6 +1373,34 @@ def worker_seed(base_seed: int, worker_id: int) -> int: return (base_seed + worker_id * 1_000_003) % (2**63) +def traffic_phase_interval_multiplier( + config: WorkloadConfig, elapsed_seconds: float +) -> float: + if config.traffic_phase_seconds == 0: + return 1.0 + phase_index = max(0, int(elapsed_seconds // config.traffic_phase_seconds)) + phase_seed = ( + config.random_seed ^ ((phase_index + 1) * 0x9E37_79B9_7F4A_7C15) + ) % (2**63) + return random.Random(phase_seed).uniform( + config.traffic_min_interval_multiplier, + config.traffic_max_interval_multiplier, + ) + + +def traffic_interval_seconds( + config: WorkloadConfig, rng: random.Random, elapsed_seconds: float +) -> float: + if config.interval_seconds == 0: + return 0.0 + jitter_multiplier = rng.uniform( + 1 - config.interval_jitter_ratio, + 1 + config.interval_jitter_ratio, + ) + phase_multiplier = traffic_phase_interval_multiplier(config, elapsed_seconds) + return min(60.0, config.interval_seconds * jitter_multiplier * phase_multiplier) + + ROLE_SHARES: dict[str, dict[str, float]] = { "quick": {READER_ROLE: 0.50, REPORTER_ROLE: 0.30, WRITER_ROLE: 0.20}, "normal": {READER_ROLE: 0.50, REPORTER_ROLE: 0.30, WRITER_ROLE: 0.20}, @@ -1699,6 +1768,7 @@ def run_worker( deadline: float, ) -> None: rng = random.Random(worker_seed(config.random_seed, worker_id)) + traffic_started_at = time.monotonic() reconnect_delay = 0.25 while not stop_event.is_set() and time.monotonic() < deadline: try: @@ -1774,8 +1844,13 @@ def run_worker( metrics.record_erp_fingerprint(erp_ordinal) remaining = deadline - time.monotonic() - if config.interval_seconds > 0 and remaining > 0: - stop_event.wait(min(config.interval_seconds, remaining)) + interval_seconds = traffic_interval_seconds( + config, + rng, + max(0.0, time.monotonic() - traffic_started_at), + ) + if interval_seconds > 0 and remaining > 0: + stop_event.wait(min(interval_seconds, remaining)) except (psycopg.OperationalError, OSError) as exc: metrics.record_connection_error(getattr(exc, "sqlstate", None)) remaining = deadline - time.monotonic() @@ -1817,6 +1892,14 @@ def _heartbeat_payload( if math.isinf(remaining_seconds) else round(max(0, remaining_seconds), 3) ), + "traffic": { + "baseIntervalSeconds": config.interval_seconds, + "intervalJitterRatio": config.interval_jitter_ratio, + "phaseSeconds": config.traffic_phase_seconds, + "phaseIntervalMultiplier": round( + traffic_phase_interval_multiplier(config, elapsed_seconds), 3 + ), + }, "totals": snapshot["totals"], "categories": snapshot["categories"], } @@ -1862,6 +1945,15 @@ def request_stop(signum: int, _frame: object) -> None: "workers": config.workers, "roleWorkers": role_workers, "randomSeed": config.random_seed, + "intervalSeconds": config.interval_seconds, + "intervalJitterRatio": config.interval_jitter_ratio, + "trafficPhaseSeconds": config.traffic_phase_seconds, + "trafficMinIntervalMultiplier": ( + config.traffic_min_interval_multiplier + ), + "trafficMaxIntervalMultiplier": ( + config.traffic_max_interval_multiplier + ), "dataBounds": bounds.__dict__, "sqlTemplateCount": len(SQL_TEMPLATES), "erpTableCount": config.erp_table_count, @@ -1962,6 +2054,15 @@ def request_stop(signum: int, _frame: object) -> None: "workers": config.workers, "roleWorkers": role_workers, "randomSeed": config.random_seed, + "intervalSeconds": config.interval_seconds, + "intervalJitterRatio": config.interval_jitter_ratio, + "trafficPhaseSeconds": config.traffic_phase_seconds, + "trafficMinIntervalMultiplier": ( + config.traffic_min_interval_multiplier + ), + "trafficMaxIntervalMultiplier": ( + config.traffic_max_interval_multiplier + ), "dataBounds": bounds.__dict__, "sqlTemplateCount": len(SQL_TEMPLATES), "erpTableCount": config.erp_table_count, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f664977..eb73d74 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -250,6 +250,8 @@ sabit subject'tir; `X-Advisor-Actor`, `X-Advisor-Admin-Token` ve body - Fresh demo snapshot frekansı üretim-temsili olarak 60 saniyedir. Yalnız kısa yerel acceptance için yeni volume'de 5 saniyeye indirilebilir; canlıda ölçülen yük ve ihtiyaç doğrultusunda seçilmelidir. - Kaynakta PoWA ile `pg_stat_statements`, `pg_qualstats`, `pg_stat_kcache` ve `pg_wait_sampling` binary/preload hazırlığı gerekir; salt bağlantı parolası vanilla PostgreSQL'ü izlenebilir hale getirmez. - Raw JOIN predicate'leri kaynakta yakalanır ancak PoWA 5.2 standart remote qualstats datasource'u bunları repository geçmişine taşımaz. +- PoWA 5.2 statement geçmişi `min_exec_time`, `max_exec_time`, `stddev_exec_time`, `pg_stat_statements_info.stats_reset`/`dealloc`, query düzeyinde `wal_buffers_full` ve PostgreSQL 18 parallel-worker alanlarını taşımaz. Bu metrikler repository SQL'i genişletilerek üretilemez; kaynakta periyodik snapshot alan ayrı yetkili bir datasource gerekir. Tek seferlik canlı kaynak okuması geçmiş pencere metriği gibi sunulmamalıdır. +- `powa_statements_history_record` tipi `shared_blks_dirtied`/`shared_blks_written`, `temp_blks_read`/`temp_blks_written`, shared/local/temp I/O süreleri, `plans`/`total_plan_time`, `wal_records`/`wal_fpi` ve JIT sayaçlarını repository'ye taşır; `advisor.v_query_samples` bunların çoğunu henüz seçmez. - Bütün dış kaynaklar bir collector deployment'ında ortak libpq `PGSSLMODE` kullanır. Kaynak başına farklı client certificate/SSL profili gerekirse ayrı collector deployment'ı gerekir. - Analyst header modeli, TLS terminasyonu, SSO, rate limit ve merkezi secret store üretim öncesi tamamlanmalıdır. - Server-side admin token tarayıcının kendi kendine yetki vermesini engeller ama kullanıcı kimliği/SSO değildir. `real-validation` profili güvenilir kullanıcı sınırı, rate limit ve kimlik doğrulayan proxy olmadan internet erişimine açılmamalıdır. diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index eb6c08d..fb061c9 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -207,6 +207,12 @@ cp .env.example .env chmod 600 .env ``` +TLS'li tek hostluk bir üretim kurulumu için `.env`'i elle doldurmak yerine +`DASHBOARD_DOMAIN=... ACME_EMAIL=... bash deployment/production/bootstrap-env.sh` +çalıştırabilirsiniz: bütün parolaları ve API admin bearer token'ını üretir, +Caddy overlay'ini (`compose.production.yaml`) seçer ve kimlik bilgilerini +`CREDENTIALS_PATH` altına 0600 izinle yazar. Token yalnız o dosyada durur. + Aynı Docker hostunda birden fazla kurulum varsa her `.env` için benzersiz `COMPOSE_PROJECT_NAME` seçin. Normal kurulumda Docker'ın çakışmayan subnet atamasını kullanın; deterministik subnet gerçekten gerekiyorsa diff --git a/docs/ITERATION_1_1_GAP_ANALYSIS.md b/docs/ITERATION_1_1_GAP_ANALYSIS.md deleted file mode 100644 index eb2131f..0000000 --- a/docs/ITERATION_1_1_GAP_ANALYSIS.md +++ /dev/null @@ -1,113 +0,0 @@ -# İterasyon 1.1 belge boşluk analizi - -İncelenen güncel belge: [postgresql_dashboard_veri_kullanimi_raporu.pdf](postgresql_dashboard_veri_kullanimi_raporu.pdf) - -- Belge başlığı: **PostgreSQL SQL Dashboard — Mevcut Veri Kullanımı ve İterasyon 1.1 Geliştirme Değerlendirme Raporu** -- Rapor tarihi: 23 Temmuz 2026 -- Sayfa sayısı: 19 -- İndirilen dosya SHA-256: `81DAF49678516515F2B15B2487A7B588F37542413C9BB468C92870E064F4E800` -- İnceleme tarihi: 24 Temmuz 2026 - -## Sonuç - -Belge doğrudan İterasyon 2 uygulama şartnamesi değildir. İterasyon 1 ile index/JOIN önerilerine odaklanan İterasyon 2 arasına bir **İterasyon 1.1 — Gözlemlenebilirlik Zenginleştirme** aşaması koyar. Mevcut repo bu rapor hazırlandıktan sonra rows-per-call, dürüst p95-unavailable sunumu, sunucu düzeyi I/O/WAL/checkpoint, index geçmişi, kontrollü DML workload ve WHERE/filter predicate gözlem paneli gibi alanlarda ilerlemiştir; ancak belgedeki kabul kriterlerinin tamamını karşılamaz. - -En önemli teknik ayrım şudur: - -1. Bazı veriler PoWA repository'de zaten vardır ve yalnız adapter/API/UI tarafından kullanılmamaktadır. -2. Bazı veriler kaynak PostgreSQL'de vardır ama PoWA 5.2 statement geçmişine taşınmamaktadır. -3. Bazı veriler PostgreSQL 18.4'te, PoWA 5.2 geçmiş tipinde veya mevcut ayarlarda yoktur ve capability olarak raporlanmalıdır. - -## Mevcut altyapıda doğrulanan veri sınırları - -Kaynak PostgreSQL 18.4 `pg_stat_statements` görünümü `min_exec_time`, `max_exec_time`, `stddev_exec_time`, query-level blok yazma/kirletme, temp read/write ve süreleri, planning toplamları, WAL record/FPI, JIT ve parallel-worker alanlarını sağlar. `track_io_timing=on`, `pg_stat_statements.track_planning=off` durumundadır. - -PoWA 5.2 `powa_statements_history_record` tipi şu yüksek değerli alanları repository'ye taşır, fakat mevcut `advisor.v_query_samples` bunların çoğunu seçmez: - -- `shared_blks_dirtied`, `shared_blks_written` -- `temp_blks_read`, `temp_blks_written` -- shared/local/temp read ve write süreleri -- `plans`, `total_plan_time` -- `wal_records`, `wal_fpi`, `wal_bytes` -- JIT sayaç ve süreleri - -PoWA 5.2 statement geçmişi şu alanları taşımaz: - -- `min_exec_time`, `max_exec_time`, `stddev_exec_time` -- `pg_stat_statements_info.stats_reset` ve `dealloc` -- `wal_buffers_full` query düzeyinde -- PostgreSQL 18'deki parallel-worker statement alanları - -Bu nedenle min/max/stddev ve pg_stat_statements veri kalitesi geçmişi yalnız repository SQL'i genişletilerek üretilemez. Kaynakta periyodik özel snapshot alan, ayrı yetkili bir servis/datasource gerekir. Tek seferlik canlı kaynak okuması geçmiş pencere metriği gibi sunulmamalıdır. - -Denetim anında kaynak `pg_stat_statements_info.dealloc=150` değerindeydi. Bu, kapasite nedeniyle statement kayıtlarının çıkarıldığını ve belgedeki veri kalitesi kartının teorik değil, mevcut demo için de gerekli olduğunu gösterir. - -## Belge maddelerinin mevcut durumu - -| Belge alanı | Durum | Mevcut davranış / boşluk | Önerilen aşama | -|---|---|---|---| -| Normalize SQL, calls, total/mean süre | Var | PoWA deltalarından sorgu listesi ve detayda kullanılıyor | Koru | -| Eş dönem karşılaştırması | Kısmi | Çalışıyor; küçük pozitif değişimler fazla puanlanıyor, geçmiş kapsamı görünmüyor | `2.0` kalibrasyon | -| Rows / call | Var | Sorgu detayında gösteriliyor | Koru | -| Süre / satır ve blok / satır | Yok | SELECT ve DML semantiği ayrılarak eklenmeli | `1.1-P1` | -| Query cache hit | Kısmi | Hit/read ve oran gösteriliyor; minimum blok hacmi/güven etiketi yok | `1.1-P0` | -| Query I/O timing | Yok | PoWA repository'de mevcut, adapter atıyor | `1.1-P0` | -| Dirtied/written block | Yok | PoWA repository'de mevcut, adapter atıyor | `1.1-P1` | -| Temp read/write, byte, per-call ve süre | Kısmi | Yalnız temp written block puanlamada/listede var | `1.1-P0` | -| Query WAL record/FPI/per-call | Kısmi | Query-level yalnız toplam byte var; server-level ayrıntı Operasyonlar ekranında mevcut | `1.1-P1` | -| Min/max/stddev kararlılık | Yok / repository'de desteklenmiyor | Kaynak görünümde var, PoWA statement geçmişinde yok | Özel source snapshot datasource | -| Gerçek p95/p99 | Bilinçli olarak yok | UI güvenilir dağılım olmadığını açıkça söylüyor | Doğru davranış, koru | -| `stats_reset`, `dealloc`, snapshot boşluğu ve veri güveni | Yok | Bazı I/O datasource'ları reset-aware; pg_stat_statements kalitesi UI/API'de yok | `1.1-P0` + source snapshot | -| Top movers | Yok | Yalnız current/previous mean karşılaştırması var | `1.1-P0` | -| Yeni ağır sorgu | Yok | Önceki dönemde yoktan yüksek yüke çıkış ayrı sınıflanmıyor | `1.1-P0` | -| Saatlik/günlük heatmap | Yok | Trend var, davranış heatmap'i yok | `1.1-P1` | -| Deployment öncesi/sonrası | Yok | Deployment marker veri modeli/API/UI yok | `1.1-P1` | -| Tablo/index dönemsel delta | Büyük ölçüde var | Reset-safe delta ve güvenli index sinyalleri mevcut; 1h coverage toleransı düzeltilmeli | `2.0` doğruluk | -| Planning toplamı | Destekleniyor fakat kapalı | PoWA alanı var; source `track_planning=off` | `1.1-P2`, overhead testi sonrası | -| Planning min/max/stddev | Yok | PoWA geçmiş tipi yalnız plans ve total_plan_time taşıyor | Capability / özel snapshot | -| JIT | Veri var, ürün kullanmıyor | PoWA geçmişinde alanlar mevcut | `1.1-P2` | -| Parallel-worker görünürlüğü | Kaynakta var, üründe unavailable | PostgreSQL 18.4 görünümünde alanlar var; PoWA 5.2 statement geçmişi bunları taşımıyor | Capability `available=false` / özel source snapshot | -| WHERE/filter predicate ve index-adayı gözlemi | Var | Repository adapter, predicate API ve sorgu detay paneli uygulanmış; otomatik DDL yok | `2.1-B` tamamlandı | -| JOIN predicate geçmişi | Unavailable | Raw kaynak yakalayabilir; stok PoWA 5.2 hattı repository'ye taşımıyor | Düşük yetkili source snapshotter | -| Kullanıcı tanımlı eşikler | Yok | Sabit SQL eşikleri kullanılıyor | Daha sonraki bağımsız ürün işi | - -## Belgeye göre eksik kabul kriterleri - -İterasyon 1.1 henüz tamamlanmış sayılamaz. Eksik ana kabul maddeleri: - -- Kararlılık, query-level I/O, temp, WAL, geçmiş ve veri kalitesini ayrı ve açıklanabilir bölümlerde sunmak -- Reset/dealloc/snapshot boşluklarını görünür kılmak -- Top movers ve yeni ağır sorguları üretmek -- Düşük örnekli sorgularda kararlılık ve oran bulgularını bastırmak -- Desteklenmeyen alanları sıfır yerine `unsupported/unavailable` olarak döndürmek -- Query-level temp ve WAL metriklerini per-call değerlerle tamamlamak -- 90 günlük veri hacminde yeni endpointleri ölçmek - -## İterasyon 2.1 kararı: `pg_qualstats` - -İlk yeni araç **`pg_qualstats`** olarak seçildi. Kaynakta WHERE/JOIN predicate görünürlüğü ve güvenilir index adayı üretme temeli bu araçla başlar. - -`2.1-B` altyapı adımının güncel durumu: - -1. **Tamamlandı:** Sürüm `2.1.4` ve kaynak SHA-256 değeri sabitlenerek PostgreSQL image içinde derlendi. -2. **Tamamlandı:** Kaynak `shared_preload_libraries` listesine eklendi; restart ve mevcut-volume geçişi belgelendi. -3. **Tamamlandı:** Dedicated `powa` database içinde extension oluşturuldu ve local PoWA datasource etkinleştirildi. -4. **Tamamlandı:** Yeni remote kayıt `extensions => ARRAY['pg_qualstats']` ile açılıyor; gerçek etkinlik PoWA 5.2 `PoWA.powa_extension_config` tablosunda tutuluyor. -5. **Tamamlandı:** Collector snapshot/reset yetkisi, repository history, staging temizliği ve katalog kolon eşlemesi kabul testine bağlandı. Repository database içinde ayrıca `CREATE EXTENSION pg_qualstats` gerekmez; PoWA repository tabloları/fonksiyonları kullanılır. -6. **Tamamlandı:** `track_constants=off`, `sample_rate=0.1`, `max=10000` ve container shared-memory sınırı tanımlandı. Sabit `2.1.4` kaynağındaki `track_constants=off` bellek hesabı eksikliği sürümle sınırlı hedefli patch ile düzeltildi. -7. **Tamamlandı:** Repository adapter, capability modeli, `GET /api/v1/queries/{query_id}/predicates` endpoint'i ve predicate/index-adayı sorgu detay paneli eklendi. -8. **Tamamlandı:** Çıktı “index adayı gözlemi” olarak sunuluyor; `ddlGenerated=false`, otomatik `CREATE INDEX` ve SQL rewrite yok. -9. **İterasyon 2.2'de tamamlandı:** Uygun SELECT/tek kolonlu B-tree sinyalleri ayrı salt-okunur evaluator ile aynı kaynak oturumunda HypoPG/plain-EXPLAIN kullanılarak doğrulanıyor; yalnız `VALIDATED` sonuç kopyalanabilir SQL taşıyor. Gerçek veri/overhead kalibrasyonu, JOIN geçmişi ve çoklu dış kaynak routing'i sıradadır. - -Kaynak `pg_qualstats()` kolon-kolon JOIN predicate'lerini de yakalasa da PoWA 5.2 standart `powa_qualstats_src` hattı yalnız tek tarafında kolon bulunan WHERE/filter predicate'lerini repository'ye taşır. Bu nedenle altyapının repository kabulü WHERE/filter geçmişini kapsar; JOIN tarihçesi için ayrı, düşük yetkili source snapshotter gerekir. Ayrıntı [İterasyon 2.1-B notundadır](ITERATION_2_1_B_PG_QUALSTATS.md). - -Predicate endpoint'i bu sınırı makine tarafından okunabilir biçimde `coverage=WHERE_FILTER_ONLY`, `joinsAvailable=false` ve `ddlGenerated=false` alanlarıyla bildirir. `occurrences`, PoWA'daki `occurences` değerinden gelen örneklenmiş predicate çalışma sayısıdır; statement çağrı sayısı değildir. `rowsProcessed` kaynak `execution_count`, `rowsFiltered` kaynak `nbfiltered` sayacıdır; `filterRatio` ikincisinin birincisine oranıdır. Örnek azsa güçlü index sinyali yerine `INSUFFICIENT_DATA` döner. - -## Repository-only sınırının güncellenmesi - -İterasyon 2'de repository-only yaklaşımı mutlak ürün sınırı olmaktan çıkarılabilir. Yine de ana FastAPI sürecine geniş source credential vermek yerine iki yol ayrılmalıdır: - -- Tarihsel ve PoWA tarafından taşınabilen metrikler ana API'ye repository üzerinden gelmeye devam eder. `pg_qualstats` bu yolu WHERE/filter predicate'leri için kullanır; stok PoWA hattı kolon-kolon JOIN'leri taşımaz. -- HypoPG/EXPLAIN için ayrı `evaluator` servisi İterasyon 2.2'de yapılandırılmış `test-source/appdb` hedefinde uygulandı; kaynak secret'ı ana API'ye verilmez, read-only transaction ve kısa timeout kullanılır. PoWA'nın taşımadığı özel JOIN snapshotları için ayrı `source-snapshotter` hâlâ gerekir. - -Böylece kaynak erişimi mümkün olur fakat dashboard API'sinin güvenlik sınırı gereksiz yere genişletilmez. Source erişimi/HypoPG hazırlığı olmayan veya tek evaluator allowlist'inde bulunmayan gerçek kaynaklarda doğrulama `UNAVAILABLE` döner; geri kalan repository ekranları çalışmaya devam eder. Ayrıntı [İterasyon 2.2 belgesindedir](ITERATION_2_2_HYPOPG.md). diff --git a/docs/PDF_REVIEW.md b/docs/PDF_REVIEW.md deleted file mode 100644 index 5f5f065..0000000 --- a/docs/PDF_REVIEW.md +++ /dev/null @@ -1,177 +0,0 @@ -# PDF v1.1 incelemesi - -İncelenen belge: **PostgreSQL Sorgu Performansı ve Öneri Motoru — İlk İterasyon Teknik Mimari, Kurulum ve Uygulama Dokümantasyonu**, sürüm 1.1, 22 Temmuz 2026, 32 sayfa. Belgenin kopyası repoda: [swl_dashboard.pdf](swl_dashboard.pdf). - -## Sonuç - -PDF'nin ana mimari kararı doğrudur ve bu repoda uygulanmıştır: tek test hostunda iki bağımsız PostgreSQL instance, PoWA remote mode collector, ayrı repository, repository-only FastAPI ve özel web arayüzü. İlk iterasyon hedefinin otomatik düzeltme üretmek yerine yüksek etkili sorguları sıralamak olması da doğru kapsam seçimidir. - -Belge doğrudan uygulanabilir bir temel sağlıyor; fakat container çalışması ve güncel PoWA davranışı için aşağıdaki düzeltmeler gereklidir. Repo bu düzeltmeleri uygular. - -## Doğru bulunan kararlar - -| PDF kararı | Değerlendirme | Repodaki karşılığı | -|---|---|---| -| Tek test hostu, iki PostgreSQL instance | Doğru | `source-db:5432`, `repository-db:5433` | -| PoWA remote mode ve hazır collector | Doğru | `collector` servisi, sürüm 1.3.2 | -| API yalnız repository'yi okur | Kritik ve doğru güvenlik sınırı | API'de yalnız `repository-db:5433`; source DSN doğrulamayla reddedilir | -| PoWA tabloları değiştirilmez | Doğru upgrade sınırı | Ürün nesneleri ayrı `advisor` şemasında | -| 1h/24h/7d/30d rapor pencereleri | Doğru | API ve SQL adapter katmanında destekli | -| Impact Score ile önceliklendirme | Doğru ilk iterasyon çıktısı | Ağırlıklı, açıklanabilir skor ve priority eşikleri | -| Eş dönem regresyonu | Doğru | Önceki aynı süreli pencere karşılaştırması | -| Annotation + audit | Doğru | `query_annotations`, trigger ve `audit_log` | -| Otomatik DDL/rewrite yok | Doğru güvenlik/kapsam kararı | Operations API'de de `false` olarak raporlanır | -| 90 günlük başlangıç hedefi | Uygun, kapasiteyle izlenmeli | Remote server kaydında açık `90 days` | -| React/TypeScript ve FastAPI | Uygun | Nginx arkasında UI, repository adapter'lı FastAPI | - -## Uygulamada yapılan zorunlu düzeltmeler - -### 1. Sürümler sabitlendi - -Belge tarihindeki güncel sürümler PoWA Archivist **5.2.0** (`REL_5_2_0`) ve PoWA Collector **1.3.2**'dir. Repo: - -- PoWA release arşivini belirli tag'den indirir ve SHA-256 doğrular. -- Collector wheel'ini `1.3.2` olarak ve SHA-256 ile sabitler. -- `main/master` veya kontrolsüz `latest` kaynak koduna dayanmaz. - -### 2. Container içinde `localhost` kullanılmaz - -PDF'nin native tek-host örneklerinde `localhost` doğrudur; Docker'da değildir. Her container'ın `localhost` adresi kendisini gösterir. Bu nedenle: - -- Kaynak hostname: `source-db` -- Repository hostname: `repository-db` -- Collector repository DSN: `repository-db:5433/powa_repository` -- API DSN: `repository-db:5433/powa_repository` - -Hosttan kullanıcı erişimi için varsayılan `127.0.0.1:15432/15433/8000` ayrı port publish katmanıdır; container içi PostgreSQL portları `5432/5433` olarak kalır. - -### 3. `.pgpass` kaynak database alanı wildcard olmalıdır - -Collector yalnız dedicated `powa` veritabanına değil, keşfedilen uygulama database'lerine de bağlanarak database-bazlı snapshot alır. Kaynak satırında database alanı `*` olmalıdır: - -```text -source-db:5432:*:powa_collector:PAROLA -``` - -Kaynak uygulama database'lerinde collector'ın etkin `CONNECT` izni de gereklidir. Demo bootstrap'ı `appdb` için bu izni `powa_collector` rolüne açıkça verir. Sertleştirilmiş bir üretim ortamında her izlenecek database için aynı izin verilmelidir; aksi halde collector repository'ye erişse bile per-database veri toplayamaz. - -### 4. Retention açıkça kayda yazılmalıdır - -“Hedef 90 gün” demek tek başına PoWA ayarını değiştirmez. Varsayılan değer sürüme göre hedeflenenden kısa olabilir. Repo `powa_register_server(... retention => interval '90 days')` çağrısında değeri açıkça verir ve kabul testinde repository kaydını doğrular. - -`.env` içindeki `RETENTION_DAYS` yalnız API/operasyon sunum ayarıdır; mevcut PoWA kaydının yerine geçmez. - -### 5. Demo snapshot frekansı üretim frekansından ayrıldı - -PDF'deki 300 saniye örneğinde sorgunun iki snapshot arasında görünmesi yaklaşık 10 dakika sürebilir. İlk iterasyon kurulumu hızlı doğrulamak için: - -- Demo frequency: **5 saniye** -- Beklenen minimum: **iki snapshot**, yaklaşık 10 saniye -- Production: ölçülen overhead ve tazelik ihtiyacına göre daha yüksek değer - -Bu yüzden 5 saniye üretim önerisi değildir; kabul testini hızlandıran bilinçli ayardır. - -### 6. `pg_stat_statements.track` demo ve üretimde farklı ele alınmalıdır - -PDF production için `track=top` önerir; bu düşük sürprizli başlangıç ayarıdır. Repodaki test fonksiyonu PL/pgSQL içinden sorgular çalıştırdığı için inner statement'ları görünür yapmak amacıyla demo kaynağında `track=all` kullanılır. - -- Demo/acceptance: `all` -- Production başlangıcı: çoğunlukla `top` -- `all` seçimi: ek görünürlük/overhead ölçülerek - -Bu fark belgede açık olmalıdır; aksi halde test fonksiyonu çalıştığı halde beklenen sorgular dashboardda görünmeyebilir. - -### 7. PostgreSQL 18.4'te tarihsel lock zinciri vaat edilmemelidir - -PDF sistem sağlığına lock bekleme/blocker sinyali ekliyor. PoWA 5.2'nin `pg_stat_lock` veri kaynağı PostgreSQL **19** gerektirir. Stack PostgreSQL 18.4'e yükseltilmiş olsa da bu gereksinim hâlâ karşılanmaz; ürün güvenilir geçmiş lock/blocker zinciri varmış gibi davranmaz. - -Uygulama: - -- Seq scan, dead tuple, autovacuum: kullanılabilir -- Uzun transaction: PoWA `pg_stat_activity` geçmişinden kullanılabilir -- Lock wait/blocker chain: capability `available=false`, gerekçe PostgreSQL 19 gereksinimi - -Canlı anlık `pg_locks` sorgusu eklemek API'nin repository-only sınırını bozacağı için bu iterasyonda tercih edilmemiştir. - -### 8. Repository'de preload gerekmiyor - -Remote mode repository kendi performansını local background worker ile toplamıyorsa `powa` veya stats extension preload/restart zorunlu değildir. İterasyon 2.1-B sonrasında kaynak instance'ta `pg_stat_statements,pg_qualstats` preload zorunludur; repository command'ı preload ayarı taşımaz. - -### 9. PoWA'nın hazır rollerinden yararlanıldı - -Genel `GRANT ALL TABLES` yaklaşımı yerine PoWA 5.2'nin rol modeli kullanılır: - -- `advisor_api`: `powa_read_all_data` -- `powa_collector`: `powa_read_all_data`, `powa_write_all_data`, `powa_snapshot` -- Kaynakta collector: `pg_read_all_stats`, `powa_snapshot`, gerekli PoWA function execute ve `pg_qualstats()`/`pg_qualstats_reset()` execute - -Repository kaynak kaydında düz metin parola yoktur; libpq `.pgpass` kullanılır. Gerçek kaynaklar alias bazlı `0600` secret dosyalarıyla eklenir ve collector açılışında tek geçici pgpass içinde birleştirilir. - -### 10. Çoklu gerçek kaynak kaydı idempotent hale getirildi - -PDF'deki tek hard-coded source örneği demo için yeterlidir, fakat ürün entegrasyonu için yeterli değildir. Repo `scripts/register-source.sh` ile host, port, monitoring DB, collector rolü, frequency ve retention'ı config/CLI üzerinden alır; aynı alias tekrar verildiğinde server id ve geçmiş korunarak kayıt güncellenir. Hazırlık SQL'i opsiyonel olarak uygulanabilir, parola repository'ye yazılmaz ve ilk snapshot ayrıca doğrulanır. Küçük trafik `run-test-workload.sh` ile, hacimli karma trafik ise seed zorunlu `realistic-load` akışıyla üretilir. - -### 11. Predicate/index-adayı gözlem katmanı eklendi - -İterasyon 2.1-B ile `pg_qualstats` WHERE/filter geçmişi repository adapter'ına, `GET /api/v1/queries/{query_id}/predicates` endpoint'ine ve sorgu detay paneline bağlandı. Sözleşme kapsamı `WHERE_FILTER_ONLY`, `joinsAvailable=false` ve `ddlGenerated=false` olarak açıkça bildirir. Stok PoWA 5.2 repository hattında JOIN kaydı bulunmaması sorguda JOIN olmadığı anlamına gelmez. - -Sayaç adları da statement metriklerinden ayrılmıştır: `occurrences`, pg_qualstats/PoWA `occurences` alanından gelen örneklenmiş predicate çalışma sayısıdır; `pg_stat_statements.calls` değildir. `rowsProcessed` kaynak `execution_count`, `rowsFiltered` kaynak `nbfiltered` değeridir; `filterRatio` bu iki satır sayacının oranıdır. Predicate endpoint'i yalnız inceleme sinyali üretir; otomatik `CREATE INDEX`, index silme veya SQL rewrite yapmaz. - -### 12. HypoPG doğrulaması ayrı ve düşük yetkili tutuldu - -İterasyon 2.2, uygun SELECT/tek kolonlu B-tree adaylarını ayrı `evaluator` servisinde, aynı kaynak oturumundaki baseline ve sanal-index plain `EXPLAIN` planlarıyla doğrular. Yalnız sanal index planda seçilir ve planner-cost eşiği aşılırsa kopyalanabilir `CREATE INDEX CONCURRENTLY` taslağı gösterilir. Uygulama gerçek DDL çalıştırmaz; `ddlExecuted=false` değişmezi korunur. Referans kapsam tek yapılandırılmış `test-source/appdb` hedefidir; collector'a eklenen dış kaynaklar otomatik evaluator kapsamına girmez. - -## PDF kapsamıyla repo davranışının karşılaştırması - -| Alan | PDF beklentisi | İlk iterasyon durumu | -|---|---|---| -| Normalize SQL ve metrik listesi | Var | Uygulandı | -| Impact Score | Var | Uygulandı; score breakdown API'de | -| Regresyon | Var | Uygulandı | -| Sorgu detay/trend | Var | Uygulandı | -| Sistem sağlığı | Var | PG18.4'te mümkün sinyaller uygulandı; lock açıkça sınırlı ve PG19 gereksinimi korunuyor | -| Annotation/audit | Var | API'de uygulandı ve acceptance testte doğrulanır; tek kullanıcılı kurulumda takım iş akışı UI'dan kaldırıldı | -| Collector/retention ekranı | Var | Operasyonlar ekranında repository kapasitesi, collector/retention, I/O, WAL/checkpoint ve index telemetrisiyle uygulandı | -| Kurulum anlatımı | Kullanıcı ek talebi | Dashboard'dan çıkarıldı; platform ve dış kaynak adımları `docs/INSTALLATION.md` içinde | -| Predicate/index adayı | PDF ilk iterasyonunda kapsam dışı | WHERE/filter kanıtı ve dar kapsamlı HypoPG doğrulaması uygulandı; JOIN unavailable, yalnız `VALIDATED` sonuç kopyalanabilir SQL taşır | -| Otomatik index/SQL rewrite | Kapsam dışı | Uygulanmadı; predicate paneli `ddlGenerated=false`, evaluator `ddlExecuted=false` döner; gösterilen SQL yalnız DBA taslağıdır | -| Native distro kurulumu | Ayrıntılı runbook | Tasarım referansı; doğrulanmış rota Compose | - -## Güvenlik açısından ek notlar - -PDF'nin repository-only API, ayrı roller, secret saklama ve SQL metni görünürlüğü kararları yerindedir. Repoda bunların temel davranışı uygulanmıştır. Annotation, CSV ve runtime işlemlerinde body/header actor güveni kaldırılmış; server-side SHA-256 registry'de doğrulanan Bearer subject audit actor yapılmıştır. Bununla birlikte şu parçalar tam üretim kimlik doğrulaması değildir: - -- `X-Advisor-Role` header'ı istemci tarafından seçilebilir. -- Referans web istemcisi tam sorgu ekranı için `analyst` header'ını varsayılan gönderir. -- Yerel PAT registry gerçek API kimliği sağlar fakat OIDC/SSO değildir. -- `.env` secret manager değildir. -- Web varsayılan olarak HTTP `5173` sunar; TLS terminasyonu yoktur. - -Dolayısıyla dış/üretim erişiminden önce HTTPS, OIDC/SSO, güvenilir actor propagation, rate limit, merkezi secret yönetimi ve network policy gerekir. - -## İlk iterasyon kabul kararı - -Aşağıdakiler sağlanırsa PDF'deki ilk iterasyon amacı karşılanır: - -1. İki PostgreSQL instance `5432` ve `5433` üzerinde bağımsız çalışır. -2. Collector en az iki snapshot'ı hatasız yazar. -3. Repository remote kaydı 90 gün retention ve `NULL` parola taşır. -4. Test fonksiyonu kontrollü sorgu yükü üretir. -5. API yalnız repository bağlantısına sahiptir. -6. Sorgular gerçek PoWA fark metrikleriyle sıralanır ve SQL yetkisiz rolde maskelenir. -7. Doğrulanmış `annotator|admin` principal annotation değişikliği yapar; response, satır sahibi ve audit actor aynı server-side subject olur. -8. UI Genel Bakış, Sorgular, Sistem Sağlığı ve Operasyonlar ekranlarını açar. Takım iş akışı tek kullanıcılı kurulum kararıyla UI kapsamı dışında kalır; annotation API'leri backend'de durur. -9. Predicate endpoint'i ve sorgu detay paneli WHERE/filter kanıtını gösterir; JOIN ve DDL capability sınırlarını açıkça bildirir. -10. 24 saatlik sorgu listesi kabul ortamında 2 saniyenin altında yanıt verir. - -Bu kontroller `bash scripts/verify.sh` ile otomatikleştirilmiştir. Sonuç olarak PDF'nin ilk iterasyon mimarisi **doğru**, yukarıdaki sürüm/container/capability düzeltmeleri ise uygulama için **gerekli**dir. - -## Dayanaklar - -- [PoWA remote setup](https://powa.readthedocs.io/en/latest/remote_setup.html) -- [PoWA mimarisi](https://powa.readthedocs.io/en/latest/architecture.html) -- [PoWA güvenlik ve remote roller](https://powa.readthedocs.io/en/latest/security.html) -- [PoWA Archivist kurulumu](https://powa.readthedocs.io/en/latest/components/powa-archivist/installation.html) -- [PoWA Collector kurulum ve yapılandırması](https://powa.readthedocs.io/en/latest/components/powa-collector/index.html#configuration) -- [PoWA Archivist 5.2.0 release](https://github.com/powa-team/powa-archivist/releases/tag/REL_5_2_0) -- [PoWA Collector 1.3.2 release](https://github.com/powa-team/powa-collector/releases/tag/1.3.2) diff --git a/docs/REPOSITORY_MIGRATIONS.md b/docs/REPOSITORY_MIGRATIONS.md index 818fd56..b2a9af8 100644 --- a/docs/REPOSITORY_MIGRATIONS.md +++ b/docs/REPOSITORY_MIGRATIONS.md @@ -87,6 +87,17 @@ release bilgisini ve tablo yazma maliyeti sinyalini ekler. Bu migration uygulama `1.1.0` ile birlikte yükseltilir; geri dönüş sınırı [upgrade/rollback runbook'undadır](UPGRADE_ROLLBACK.md). +`0015`, dört sabit dashboard penceresi için kalıcı query-metrics materialized +snapshot'ları ve yenileme durum tablosunu ekler. İlk population worker tarafından +migration dışında yapılır. Sonraki yenilemeler `CONCURRENTLY` çalıştığından +hesaplama tamamlanana kadar önceki atomik sonuç okunur; API request yolu +`advisor.query_metrics(interval)` çağırmaz. + +`0016`, overview trendlerini database scope'ta bir kez hesaplayan ve aynı bucket +satırlarından server/global toplamlarını türeten dört kalıcı materialized +snapshot ekler. Böylece global, server ve database overview yolları cold API +başlangıcında dahi `advisor.query_trend(...)` çalıştırmaz. + ## Yeni migration ekleme 1. Mevcut migration dosyalarını değiştirmeyin. Özellikle diff --git a/docs/UPGRADE_ROLLBACK.md b/docs/UPGRADE_ROLLBACK.md index 2d405ad..f69bc00 100644 --- a/docs/UPGRADE_ROLLBACK.md +++ b/docs/UPGRADE_ROLLBACK.md @@ -1,7 +1,7 @@ # Upgrade ve rollback runbook'u -Bu runbook aynı PostgreSQL major sürümünde SQL Dashboard `1.1.0` ve repository -şema `0014` için güvenli yükseltme sınırını tanımlar. Üretimde image'ları sürüm +Bu runbook aynı PostgreSQL major sürümünde SQL Dashboard `1.1.1` ve repository +şema `0016` için güvenli yükseltme sınırını tanımlar. Üretimde image'ları sürüm ve digest ile sabitleyin. Önce staging restore provası yapın. > **Uyarı:** `dropdb`, `docker compose down --volumes` ve yanlış Compose proje @@ -53,18 +53,20 @@ docker compose exec -T repository-db \ -c "SELECT max(version),count(*) FROM advisor_migrations.schema_migrations" ``` -`1.1.0` için beklenen son sürüm `0014` olmalıdır. +`1.1.1` için beklenen son sürüm `0016` olmalıdır. ## 3. Servisleri aç, doğrula ve cutover yap ```bash -docker compose up -d collector join-snapshotter evaluator api web +docker compose up -d collector join-snapshotter evaluator query-metrics-snapshot-worker +# 1h ve 24h state satırları ready olduktan sonra: +docker compose up -d api web docker compose ps curl -fsS http://127.0.0.1:8000/api/v1/health bash scripts/verify.sh ``` -Health yanıtında uygulama `1.1.0`, migration `current=expected=0014` ve +Health yanıtında uygulama `1.1.1`, migration `current=expected=0016` ve `upToDate=true` bekleyin. Collector için yeni snapshot zamanı ilerlemeden ve web/API smoke testi geçmeden trafiği açmayın. Dış source bağlantılarını alias, database OID ve capability kapsamıyla yeniden doğrulayın. diff --git a/docs/postgresql_dashboard_veri_kullanimi_raporu.pdf b/docs/postgresql_dashboard_veri_kullanimi_raporu.pdf deleted file mode 100644 index e1549bd..0000000 Binary files a/docs/postgresql_dashboard_veri_kullanimi_raporu.pdf and /dev/null differ diff --git a/docs/swl_dashboard.pdf b/docs/swl_dashboard.pdf deleted file mode 100644 index 467ddf3..0000000 Binary files a/docs/swl_dashboard.pdf and /dev/null differ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 22a3c8a..a9a7e87 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "postgresql-advisor-web", - "version": "1.1.0", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "postgresql-advisor-web", - "version": "1.1.0", + "version": "1.1.1", "dependencies": { "@dagrejs/dagre": "3.0.0", "@vitejs/plugin-react": "6.0.4", diff --git a/frontend/package.json b/frontend/package.json index 2029c52..1319d36 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "postgresql-advisor-web", "private": true, - "version": "1.1.0", + "version": "1.1.1", "type": "module", "scripts": { "dev": "vite --host 127.0.0.1", diff --git a/scripts/register-source.sh b/scripts/register-source.sh index 6fd3583..1ea3ab4 100755 --- a/scripts/register-source.sh +++ b/scripts/register-source.sh @@ -272,11 +272,11 @@ probe="$({ printf '%s\n' "$source_password"; } | docker compose exec -T reposito AND current_setting('\''pg_wait_sampling.profile_queries'\'') = '\''top'\'' AND current_setting('\''pg_wait_sampling.sample_cpu'\'') = '\''off'\'', COALESCE(has_function_privilege( - current_user, 'advisor_join.capture_and_reset()', 'EXECUTE' + current_user, '\''advisor_join.capture_and_reset()'\'', '\''EXECUTE'\'' ), false), - to_regprocedure('advisor_join.assert_outbox_within_limits()') IS NOT NULL, + to_regprocedure('\''advisor_join.assert_outbox_within_limits()'\'') IS NOT NULL, NOT COALESCE(has_function_privilege( - current_user, 'advisor_join.assert_outbox_within_limits()', 'EXECUTE' + current_user, '\''advisor_join.assert_outbox_within_limits()'\'', '\''EXECUTE'\'' ), false), NOT COALESCE(( SELECT has_function_privilege( diff --git a/scripts/verify.sh b/scripts/verify.sh index f829caa..89e0e16 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -978,6 +978,27 @@ collector_state="$(docker compose exec -T repository-db psql -U postgres -p 5433 [[ "$collector_state" == "HEALTHY|0" ]] || fail "Collector sagligi beklenmiyor: ${collector_state}" pass "Collector gecikmesi ve hata durumu saglikli" +docker compose exec -T query-metrics-snapshot-worker python - <<'PY' +import time + +from app.config import get_settings +from app.snapshot_worker import open_connection, refresh_snapshot + + +settings = get_settings() +with open_connection(settings) as connection: + for window in ("1h", "24h"): + for _ in range(30): + if refresh_snapshot(connection, window): + break + time.sleep(2) + else: + raise SystemExit( + f"{window} query metrics snapshot advisory lock could not be acquired" + ) +PY +pass "Dashboard 1h/24h persistent query snapshot'lari test deltasi sonrasi yenilendi" + curl -fsS -H 'X-Advisor-Role: analyst' \ "${api_url}/api/v1/queries?window=1h&pageSize=10" >"${verify_tmp_dir}/queries-authorized.json" curl -fsS "${api_url}/api/v1/queries?window=1h&pageSize=10" >"${verify_tmp_dir}/queries-viewer.json" diff --git a/sql/017_query_metrics_snapshots.sql b/sql/017_query_metrics_snapshots.sql new file mode 100644 index 0000000..c636c19 --- /dev/null +++ b/sql/017_query_metrics_snapshots.sql @@ -0,0 +1,80 @@ +\set ON_ERROR_STOP on + +-- Query metrics are expensive at ERP fingerprint cardinalities. Keep one +-- persistent, atomically replaceable materialized snapshot per supported +-- dashboard window. The refresh worker populates these outside the migration +-- transaction and uses REFRESH MATERIALIZED VIEW CONCURRENTLY after the first +-- population, so readers retain the previous complete generation. + +CREATE MATERIALIZED VIEW advisor.query_metrics_snapshot_1h AS +SELECT metrics.*, servers.alias AS server_alias +FROM advisor.query_metrics(interval '1 hour') AS metrics +LEFT JOIN "PoWA".powa_servers AS servers + ON servers.id = metrics.server_id +WITH NO DATA; + +CREATE MATERIALIZED VIEW advisor.query_metrics_snapshot_24h AS +SELECT metrics.*, servers.alias AS server_alias +FROM advisor.query_metrics(interval '24 hours') AS metrics +LEFT JOIN "PoWA".powa_servers AS servers + ON servers.id = metrics.server_id +WITH NO DATA; + +CREATE MATERIALIZED VIEW advisor.query_metrics_snapshot_7d AS +SELECT metrics.*, servers.alias AS server_alias +FROM advisor.query_metrics(interval '7 days') AS metrics +LEFT JOIN "PoWA".powa_servers AS servers + ON servers.id = metrics.server_id +WITH NO DATA; + +CREATE MATERIALIZED VIEW advisor.query_metrics_snapshot_30d AS +SELECT metrics.*, servers.alias AS server_alias +FROM advisor.query_metrics(interval '30 days') AS metrics +LEFT JOIN "PoWA".powa_servers AS servers + ON servers.id = metrics.server_id +WITH NO DATA; + +CREATE UNIQUE INDEX query_metrics_snapshot_1h_identity_idx + ON advisor.query_metrics_snapshot_1h + (server_id, database_id, query_id, user_id) NULLS NOT DISTINCT; +CREATE UNIQUE INDEX query_metrics_snapshot_24h_identity_idx + ON advisor.query_metrics_snapshot_24h + (server_id, database_id, query_id, user_id) NULLS NOT DISTINCT; +CREATE UNIQUE INDEX query_metrics_snapshot_7d_identity_idx + ON advisor.query_metrics_snapshot_7d + (server_id, database_id, query_id, user_id) NULLS NOT DISTINCT; +CREATE UNIQUE INDEX query_metrics_snapshot_30d_identity_idx + ON advisor.query_metrics_snapshot_30d + (server_id, database_id, query_id, user_id) NULLS NOT DISTINCT; + +CREATE TABLE advisor.query_metrics_snapshot_state ( + window_key text PRIMARY KEY + CHECK (window_key IN ('1h', '24h', '7d', '30d')), + status text NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'refreshing', 'ready', 'failed')), + refresh_started_at timestamptz, + refreshed_at timestamptz, + refresh_duration_ms bigint + CHECK (refresh_duration_ms IS NULL OR refresh_duration_ms >= 0), + row_count bigint + CHECK (row_count IS NULL OR row_count >= 0), + last_error text, + updated_at timestamptz NOT NULL DEFAULT clock_timestamp() +); + +INSERT INTO advisor.query_metrics_snapshot_state (window_key) +VALUES ('1h'), ('24h'), ('7d'), ('30d'); + +REVOKE ALL ON advisor.query_metrics_snapshot_state FROM PUBLIC; +GRANT SELECT, INSERT, UPDATE ON advisor.query_metrics_snapshot_state + TO advisor_api; + +GRANT SELECT, MAINTAIN ON + advisor.query_metrics_snapshot_1h, + advisor.query_metrics_snapshot_24h, + advisor.query_metrics_snapshot_7d, + advisor.query_metrics_snapshot_30d +TO advisor_api; + +COMMENT ON TABLE advisor.query_metrics_snapshot_state IS + 'Persistent refresh status for precomputed dashboard query-metrics windows.'; diff --git a/sql/018_global_trend_snapshots.sql b/sql/018_global_trend_snapshots.sql new file mode 100644 index 0000000..a0410cd --- /dev/null +++ b/sql/018_global_trend_snapshots.sql @@ -0,0 +1,211 @@ +\set ON_ERROR_STOP on + +-- Overview trend aggregation is also expensive at ERP cardinalities. Compute +-- database-level trends once, then derive server and fleet totals from those +-- small bucket sets. This avoids rescanning the same query histories for every +-- dashboard scope. + +CREATE MATERIALIZED VIEW advisor.global_trend_snapshot_1h AS +WITH database_trends AS MATERIALIZED ( + SELECT + database.srvid AS server_id, + database.oid AS database_id, + trend.bucket_at AS timestamp, + trend.total_exec_time_ms, + trend.calls + FROM "PoWA".powa_databases AS database + CROSS JOIN LATERAL advisor.query_trend( + now() - interval '1 hour', + interval '5 minutes', + database.srvid, + database.oid + ) AS trend + WHERE database.datname <> 'powa' +), all_scopes AS ( + SELECT * FROM database_trends + UNION ALL + SELECT + server_id, + NULL::oid, + timestamp, + sum(total_exec_time_ms)::double precision, + sum(calls)::bigint + FROM database_trends + GROUP BY server_id, timestamp + UNION ALL + SELECT + NULL::integer, + NULL::oid, + timestamp, + sum(total_exec_time_ms)::double precision, + sum(calls)::bigint + FROM database_trends + GROUP BY timestamp +) +SELECT * FROM all_scopes +WITH NO DATA; + +CREATE MATERIALIZED VIEW advisor.global_trend_snapshot_24h AS +WITH database_trends AS MATERIALIZED ( + SELECT + database.srvid AS server_id, + database.oid AS database_id, + trend.bucket_at AS timestamp, + trend.total_exec_time_ms, + trend.calls + FROM "PoWA".powa_databases AS database + CROSS JOIN LATERAL advisor.query_trend( + now() - interval '24 hours', + interval '1 hour', + database.srvid, + database.oid + ) AS trend + WHERE database.datname <> 'powa' +), all_scopes AS ( + SELECT * FROM database_trends + UNION ALL + SELECT + server_id, + NULL::oid, + timestamp, + sum(total_exec_time_ms)::double precision, + sum(calls)::bigint + FROM database_trends + GROUP BY server_id, timestamp + UNION ALL + SELECT + NULL::integer, + NULL::oid, + timestamp, + sum(total_exec_time_ms)::double precision, + sum(calls)::bigint + FROM database_trends + GROUP BY timestamp +) +SELECT * FROM all_scopes +WITH NO DATA; + +CREATE MATERIALIZED VIEW advisor.global_trend_snapshot_7d AS +WITH database_trends AS MATERIALIZED ( + SELECT + database.srvid AS server_id, + database.oid AS database_id, + trend.bucket_at AS timestamp, + trend.total_exec_time_ms, + trend.calls + FROM "PoWA".powa_databases AS database + CROSS JOIN LATERAL advisor.query_trend( + now() - interval '7 days', + interval '6 hours', + database.srvid, + database.oid + ) AS trend + WHERE database.datname <> 'powa' +), all_scopes AS ( + SELECT * FROM database_trends + UNION ALL + SELECT + server_id, + NULL::oid, + timestamp, + sum(total_exec_time_ms)::double precision, + sum(calls)::bigint + FROM database_trends + GROUP BY server_id, timestamp + UNION ALL + SELECT + NULL::integer, + NULL::oid, + timestamp, + sum(total_exec_time_ms)::double precision, + sum(calls)::bigint + FROM database_trends + GROUP BY timestamp +) +SELECT * FROM all_scopes +WITH NO DATA; + +CREATE MATERIALIZED VIEW advisor.global_trend_snapshot_30d AS +WITH database_trends AS MATERIALIZED ( + SELECT + database.srvid AS server_id, + database.oid AS database_id, + trend.bucket_at AS timestamp, + trend.total_exec_time_ms, + trend.calls + FROM "PoWA".powa_databases AS database + CROSS JOIN LATERAL advisor.query_trend( + now() - interval '30 days', + interval '1 day', + database.srvid, + database.oid + ) AS trend + WHERE database.datname <> 'powa' +), all_scopes AS ( + SELECT * FROM database_trends + UNION ALL + SELECT + server_id, + NULL::oid, + timestamp, + sum(total_exec_time_ms)::double precision, + sum(calls)::bigint + FROM database_trends + GROUP BY server_id, timestamp + UNION ALL + SELECT + NULL::integer, + NULL::oid, + timestamp, + sum(total_exec_time_ms)::double precision, + sum(calls)::bigint + FROM database_trends + GROUP BY timestamp +) +SELECT * FROM all_scopes +WITH NO DATA; + +CREATE UNIQUE INDEX global_trend_snapshot_1h_identity_idx + ON advisor.global_trend_snapshot_1h + (server_id, database_id, timestamp) NULLS NOT DISTINCT; +CREATE UNIQUE INDEX global_trend_snapshot_24h_identity_idx + ON advisor.global_trend_snapshot_24h + (server_id, database_id, timestamp) NULLS NOT DISTINCT; +CREATE UNIQUE INDEX global_trend_snapshot_7d_identity_idx + ON advisor.global_trend_snapshot_7d + (server_id, database_id, timestamp) NULLS NOT DISTINCT; +CREATE UNIQUE INDEX global_trend_snapshot_30d_identity_idx + ON advisor.global_trend_snapshot_30d + (server_id, database_id, timestamp) NULLS NOT DISTINCT; + +CREATE TABLE advisor.global_trend_snapshot_state ( + window_key text PRIMARY KEY + CHECK (window_key IN ('1h', '24h', '7d', '30d')), + status text NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'refreshing', 'ready', 'failed')), + refresh_started_at timestamptz, + refreshed_at timestamptz, + refresh_duration_ms bigint + CHECK (refresh_duration_ms IS NULL OR refresh_duration_ms >= 0), + row_count bigint + CHECK (row_count IS NULL OR row_count >= 0), + last_error text, + updated_at timestamptz NOT NULL DEFAULT clock_timestamp() +); + +INSERT INTO advisor.global_trend_snapshot_state (window_key) +VALUES ('1h'), ('24h'), ('7d'), ('30d'); + +REVOKE ALL ON advisor.global_trend_snapshot_state FROM PUBLIC; +GRANT SELECT, INSERT, UPDATE ON advisor.global_trend_snapshot_state + TO advisor_api; + +GRANT SELECT, MAINTAIN ON + advisor.global_trend_snapshot_1h, + advisor.global_trend_snapshot_24h, + advisor.global_trend_snapshot_7d, + advisor.global_trend_snapshot_30d +TO advisor_api; + +COMMENT ON TABLE advisor.global_trend_snapshot_state IS + 'Persistent refresh status for precomputed overview trends and scopes.'; diff --git a/sql/repository-migrations.manifest b/sql/repository-migrations.manifest index 5876ac4..7438b52 100644 --- a/sql/repository-migrations.manifest +++ b/sql/repository-migrations.manifest @@ -13,3 +13,5 @@ 0012|query_metrics_scale_guard|014_query_metrics_scale_guard.sql|a4c1484f00a8d2881e25f8f7fa6b2616bee475ed375a32782b9940b6ae2495ec 0013|query_trend_performance|015_query_trend_performance.sql|c600e3bffb01c4f7efe19b693039e8b17b338526e00ae5f429cf23fbcc377309 0014|product_scope_optimize_release|016_product_scope_optimize_release.sql|cf33caad1d4c5e39eb1144b47eaed5cf53448763636f79a069ef40035dcf76c1 +0015|query_metrics_snapshots|017_query_metrics_snapshots.sql|c6473663fdb41e9e77c25ce365fe4c3fa8f763cb65b9292b063b4aa910e87ea5 +0016|global_trend_snapshots|018_global_trend_snapshots.sql|99af76159349658100894ab320ad7b6c59e5ccb53290642a8a819f8a80d73320