diff --git a/.github/workflows/performance-report.yml b/.github/workflows/performance-report.yml new file mode 100644 index 000000000..e867e6663 --- /dev/null +++ b/.github/workflows/performance-report.yml @@ -0,0 +1,158 @@ +name: Update PR performance comment +on: + workflow_call: + inputs: + section: + required: true + type: string + artifact-name: + required: true + type: string + measurement-result: + required: true + type: string +permissions: + contents: read + pull-requests: write +jobs: + publish: + name: Update existing performance comment + if: >- + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-24.04 + timeout-minutes: 5 + # Both performance workflows read and update the same comment. Serialize the + # entire job so each merge sees the other publisher's completed update. + concurrency: + group: pr-performance-comment-${{ github.repository }}-${{ github.event.pull_request.number }} + cancel-in-progress: false + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + id: download + continue-on-error: true + with: + name: ${{ inputs.artifact-name }} + path: report + # This write-enabled job never checks out the PR or executes artifact code. + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + REPORT_SECTION: ${{ inputs.section }} + MEASUREMENT_RESULT: ${{ inputs.measurement-result }} + DOWNLOAD_OUTCOME: ${{ steps.download.outcome }} + with: + script: | + const fs = require('fs'); + const marker = ''; + const legacyToken = ''; + const sections = ['token-usage', 'reader-api']; + const titles = {'token-usage': 'Token usage benchmark', 'reader-api': 'Reader API performance'}; + const section = process.env.REPORT_SECTION; + if (!sections.includes(section)) throw new Error('Unknown performance report section'); + const pr = context.payload.pull_request; + const sha = pr.head.sha; + const run = String(context.runId); + const attempt = process.env.GITHUB_RUN_ATTEMPT; + if (!/^[a-f0-9]{40}$/.test(sha) || !/^[1-9]\d*$/.test(run) || !/^[1-9]\d*$/.test(attempt)) { + throw new Error('Invalid performance report provenance'); + } + const current = await github.rest.pulls.get({...context.repo, pull_number: pr.number}); + if (current.data.head.sha !== sha) { + core.info('Skipping results for a superseded PR head.'); + return; + } + const url = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${run}`; + let summary; + let missing = false; + try { + if (process.env.DOWNLOAD_OUTCOME !== 'success') throw new Error('Artifact unavailable'); + const stat = fs.lstatSync('report/summary.md'); + if (!stat.isFile() || stat.size > 25000) throw new Error('Invalid summary file'); + summary = fs.readFileSync('report/summary.md', 'utf8'); + const valid = section === 'token-usage' + ? /^\*\*(PASS|FAIL|INCOMPLETE) — Token usage benchmark:\*\*/.test(summary) + : /^(# PR reader API performance|\*\*ERROR\*\*)/.test(summary); + if (!valid) throw new Error('Invalid summary verdict'); + } catch { + missing = true; + summary = `**INCOMPLETE — ${titles[section]}:** no readable results artifact was produced. Inspect the job logs; no performance pass can be claimed.`; + } + const outcome = process.env.MEASUREMENT_RESULT; + if (outcome !== 'success') { + const verdict = outcome === 'failure' ? 'FAIL' : 'INCOMPLETE'; + if (section === 'token-usage') { + summary = summary.replace(/^\*\*(PASS|FAIL|INCOMPLETE)/, `**${verdict}`); + } else { + summary = `**${verdict} — Reader API performance:** the comparison job did not complete successfully.\n\n${summary}`; + } + summary += `\n\nMeasurement job outcome: **${['failure', 'cancelled', 'skipped'].includes(outcome) ? outcome : 'unknown'}**. The run did not complete successfully.`; + } + // Artifact text is inert Markdown. Neutralize HTML comment delimiters + // so it cannot forge the publisher's section/provenance boundaries. + summary = summary.replace(/\n([\s\S]*?)\n$/gm; + const matches = [...comment.body.matchAll(expression)]; + if (matches.length) { + for (const match of matches) { + preserve(match[1], {head: match[2], run: match[3], attempt: match[4], text: match[5]}); + } + continue; + } + // Adopt both older standalone performance comment formats once, + // keeping their original content and provenance during migration. + const key = comment.body.startsWith(legacyToken) ? 'token-usage' : 'reader-api'; + const oldHead = comment.body.match(/(?:Compared head:|→ PR)\s*`?([a-f0-9]{40})`?/i)?.[1] ?? 'unknown'; + const tokenRun = comment.body.match(//); + const oldRun = tokenRun?.[1] ?? [...comment.body.matchAll(/\/actions\/runs\/([1-9]\d*)/g)].at(-1)?.[1] ?? '0'; + const oldAttempt = tokenRun?.[2] ?? [...comment.body.matchAll(/\battempt\s+([1-9]\d*)\b/gi)].at(-1)?.[1] ?? '1'; + const text = comment.body.replace(/^\n/gm, '').trim().replace(/\n${report.text}\n`; + }); + const body = `${marker}\n# PR performance results\n\nCurrent PR head: \`${sha}\`\n\n${rendered.join('\n\n')}`; + if (Buffer.byteLength(body, 'utf8') > 60000) { + core.setFailed('Combined performance report exceeds the comment limit; existing results were preserved.'); + return; + } + const latest = await github.rest.pulls.get({...context.repo, pull_number: pr.number}); + if (latest.data.head.sha !== sha) return; + if (previous.length) { + await github.rest.issues.updateComment({...context.repo, comment_id: previous[0].id, body}); + // Remove only duplicate, explicitly marked Actions performance + // comments after their sections have been saved in the oldest one. + for (const duplicate of previous.slice(1)) { + await github.rest.issues.deleteComment({...context.repo, comment_id: duplicate.id}); + } + } else { + await github.rest.issues.createComment({...context.repo, issue_number: pr.number, body}); + } + if (missing) core.setFailed('Published incomplete results: the summary artifact was unavailable or invalid.'); diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml index f30ceea08..d01a3bbe4 100644 --- a/.github/workflows/performance.yml +++ b/.github/workflows/performance.yml @@ -153,30 +153,11 @@ jobs: always() && !cancelled() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'renovate[bot]' - runs-on: ubuntu-24.04 + uses: ./.github/workflows/performance-report.yml permissions: contents: read pull-requests: write - steps: - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: pr-performance-${{ github.run_attempt }} - path: report - - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - const fs = require('fs'); - const marker = ''; - const pr = context.payload.pull_request; - const current = await github.rest.pulls.get({...context.repo, pull_number: pr.number}); - if (current.data.head.sha !== pr.head.sha) return; - const url = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; - const summary = fs.readFileSync('report/summary.md', 'utf8').slice(0, 50000); - const body = `${marker}\n${summary}\n[Full HTML/CSV reports and logs](${url}) · attempt ${process.env.GITHUB_RUN_ATTEMPT}`; - const comments = await github.paginate(github.rest.issues.listComments, {...context.repo, issue_number: pr.number}); - const previous = comments.find(c => c.user.type === 'Bot' && c.body.startsWith(marker)); - if (previous) { - await github.rest.issues.updateComment({...context.repo, comment_id: previous.id, body}); - } else { - await github.rest.issues.createComment({...context.repo, issue_number: pr.number, body}); - } + with: + section: reader-api + artifact-name: pr-performance-${{ github.run_attempt }} + measurement-result: ${{ needs.compare.result }} diff --git a/.github/workflows/token-usage-performance.yml b/.github/workflows/token-usage-performance.yml new file mode 100644 index 000000000..8fc3f2439 --- /dev/null +++ b/.github/workflows/token-usage-performance.yml @@ -0,0 +1,101 @@ +name: Token usage performance +on: + pull_request: + paths: + - apps/admin-api/src/automation.py + - scripts/profile_token_usage.py + - scripts/ci/token-usage-summary.py + - tests/tooling/ci/test_token_usage_comment.py + - tests/tooling/profiling/test_token_usage_summary.py + - .github/workflows/performance-report.yml + - .github/workflows/token-usage-performance.yml + workflow_dispatch: +concurrency: + group: token-usage-performance-${{ github.ref }} + cancel-in-progress: true +permissions: + contents: read +jobs: + benchmark: + name: Native ARM64 PostgreSQL 17 + runs-on: ubuntu-24.04-arm + timeout-minutes: 30 + services: + postgres: + image: postgres:17.11-bookworm + env: + POSTGRES_USER: devfeed + POSTGRES_HOST_AUTH_METHOD: trust + POSTGRES_DB: devfeed_token_profile_test + ports: + - 127.0.0.1:5432:5432 + options: >- + --health-cmd "pg_isready -U devfeed -d devfeed_token_profile_test" + --health-interval 5s --health-timeout 5s --health-retries 12 + env: + DEVFEED_DATABASE_URL: postgresql+psycopg://devfeed@127.0.0.1:5432/devfeed_token_profile_test + DEVFEED_TEST_DATABASE_URL: postgresql+psycopg://devfeed@127.0.0.1:5432/devfeed_token_profile_test + DEVFEED_REDIS_URL: redis://127.0.0.1:6379/15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + version: "0.13.5" + enable-cache: true + cache-dependency-glob: uv.lock + cache-suffix: token-usage-profile + - name: Install locked application + run: sh scripts/ci/python-bootstrap.sh . --editable --all-packages + - name: Migrate disposable database + run: uv run --locked --no-sync --no-build devfeed db upgrade + - name: Compare production-sized and larger histories + id: measure + env: + POSTGRES_CONTAINER: ${{ job.services.postgres.id }} + run: | + test "$(uname -m)" = aarch64 + uv run --locked --no-sync --no-build python scripts/profile_token_usage.py --seed --articles 100000 300000 \ + --postgres-container "$POSTGRES_CONTAINER" + - name: Summarize before and after measurements + if: always() + env: + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + MEASUREMENT_OUTCOME: ${{ steps.measure.outcome }} + run: | + set +e + python3 scripts/ci/token-usage-summary.py \ + --head-sha "$HEAD_SHA" --run-url "$RUN_URL" --outcome "$MEASUREMENT_OUTCOME" + summary_status=$? + set -e + if [ -f reports/token-usage/summary.md ]; then + cat reports/token-usage/summary.md >> "$GITHUB_STEP_SUMMARY" + fi + exit "$summary_status" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: always() + with: + name: token-usage-native-arm64-${{ github.run_attempt }} + path: reports/token-usage/ + if-no-files-found: warn + retention-days: 30 + report: + name: Publish token usage results + needs: benchmark + if: >- + always() && !cancelled() && github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository + uses: ./.github/workflows/performance-report.yml + permissions: + contents: read + pull-requests: write + with: + section: token-usage + artifact-name: token-usage-native-arm64-${{ github.run_attempt }} + measurement-result: ${{ needs.benchmark.result }} diff --git a/apps/admin-api/README.md b/apps/admin-api/README.md index 9392e4806..411abc23c 100644 --- a/apps/admin-api/README.md +++ b/apps/admin-api/README.md @@ -64,3 +64,70 @@ profiler refuses non-local hosts and database names without the `_test` suffix. complete 30/90-day report payloads and prints timings plus activity-query buffer statistics; writer samples roll back their rows and temporary trigger changes. These local synthetic measurements do not establish production latency. + +## Completed-job token usage + +The automation report aggregates each job's cumulative `usage.totalTokens` once on its +UTC completion date. Two streaming subqueries extract token text and validate its numeric +value once per job. Literal `OFFSET 0` fences prevent PostgreSQL from pulling those +expressions into every aggregate, while avoiding a full-window materialized working set. +Projecting the date before grouping also gives PostgreSQL 17 a small daily group estimate +instead of inheriting the near-unique completion timestamp distribution. This preserves +inclusive time boundaries, succeeded/failed jobs, missing or malformed telemetry, +durations, and the eighteen-digit token validity rule. It requires no migration or +additional writes; rolling back the application restores the previous query. + +PostgreSQL maintains `OFFSET 0` as an explicit optimization fence; see the +[upstream planner change](https://www.postgresql.org/message-id/E1pK7VD-0059mE-Jc%40gemulon.postgresql.org). +Actual execution plans verify the extraction and validation projections, daily aggregation, +and temporary writes in both custom and generic plans. + +To reproduce the before/after comparison on a migrated disposable local PostgreSQL 17: + +```bash +DEVFEED_TEST_DATABASE_URL=postgresql+psycopg://.../devfeed_token_profile_test \ + uv run --locked --no-sync --no-build python scripts/profile_token_usage.py \ + --seed --articles 100000 300000 +``` + +The profiler writes `reports/token-usage/profile.json` in the checkout. The summary +renderer reads that artifact and writes `reports/token-usage/summary.md`; neither tool +accepts arbitrary file paths, and report paths must not be symlinks. + +`--seed` replaces articles, proposals and their dependent histories. Remote hosts, +connection routing overrides and database names without `_test` are refused. The tool +uses actual migrated tables and indexes, two retained article/topic jobs per article, +180 days of skewed completion history, failed/incomplete jobs, mixed telemetry and +incompressible 4 KiB provider metadata on every fifth job. The 100,000-article fixture +has approximately 60,000 article and 160,000 topic completions in a month, plus a +recent article burst. Research histories contain 73,000 and 219,000 jobs. Exact +read-only production counts at 10:05 UTC on 24 October 2026 were 92,598 articles, +156,021 article jobs, 159,575 topic jobs and 67,014 research jobs; planner and live-tuple +statistics are not exact population counts. The profiler records this comparison and +requires every 100,000+ article fixture to cover production's total histories and +qualifying 1/7/30-day counts for each job type. Smaller fixtures remain useful for +checking the full benchmark lifecycle locally. + +For 1/7/30 UTC calendar-day windows, the tool checks exact result equality, alternating +warm samples, fully parameterized custom/generic plans, aggregate group estimates, +temporary writes and runtime with a noise allowance. It retains raw `EXPLAIN (ANALYZE, +BUFFERS, SETTINGS)` plans at 4 MiB `work_mem`, JIT enabled and up to two parallel workers. +Concurrent ingestion inserts articles and starts/finishes jobs after the fixed report +cutoff, preserving comparison results; samples report actual write overlap and latency. +Reporting-thread CPU is recorded separately from elapsed time. On Linux cgroup v2, +`--postgres-container CONTAINER` also records database-container CPU, including the +concurrent writer. Use an isolated container for that measurement. + +The `Token usage performance` PR workflow runs the complete comparison on native +ARM64 PostgreSQL 17 and retains its JSON evidence and Markdown summary for 30 days. +The job summary shows before/after tables with signed improvements; same-repository +PRs also update one existing performance comment shared with the reader API comparison. +The two jobs preserve each other's sections and serialize comment updates. Positive +percentages in the token benchmark mean improvement, +negative percentages mean regression, and missing or failed evidence cannot report a +pass. The comment includes validation totals and writer-latency caveats, with resource +and production-volume details available to expand. Forks retain the artifact and job +summary without granting comment permissions. Synthetic measurements +establish the query behavior at those fixture sizes; after deployment, compare fresh +production query statistics under matched traffic and watch report freshness, database +CPU and ingestion latency before attributing an improvement to the change. diff --git a/apps/admin-api/src/automation.py b/apps/admin-api/src/automation.py index ef4dc25f7..b70571a19 100644 --- a/apps/admin-api/src/automation.py +++ b/apps/admin-api/src/automation.py @@ -112,13 +112,15 @@ def analysis_token_activity(session, start: datetime, now: datetime): ("topic_analysis", TopicAnalysisJob), ("research_verification", ResearchVerificationJob), ): - # Keep a narrow materialization boundary: otherwise PostgreSQL inlines - # JSON extraction into every SUM/COUNT/CASE and repeatedly detoasts the - # same potentially large job usage. Nineteen characters preserve the - # eighteen-digit validity rule even for oversized malformed telemetry. - usage = ( + # Two OFFSET 0 fences keep JSON extraction and numeric validation once + # per row while streaming values directly into the daily aggregates. + # Project the UTC date before grouping so its estimate does not inherit + # near-unique completion timestamps. Literal offsets also let generic + # plans remove the runtime Limit nodes. Nineteen characters preserve + # the eighteen-digit validity rule for oversized malformed telemetry. + raw_usage = ( select( - model.finished_at, + func.date(func.timezone("UTC", model.finished_at)).label("date"), model.duration_ms, func.left(model.usage["totalTokens"].astext, 19).label("raw_tokens"), ) @@ -127,12 +129,18 @@ def analysis_token_activity(session, start: datetime, now: datetime): model.finished_at <= now, model.status.in_(("succeeded", "failed")), ) - .cte(f"{kind}_usage") - .prefix_with("MATERIALIZED") + .offset(literal_column("0")) + .subquery(f"{kind}_raw_usage") + ) + raw = raw_usage.c.raw_tokens + validated = case((raw.op("~")(r"^[0-9]{1,18}$"), cast(raw, BigInteger)), else_=None) + usage = ( + select(raw_usage.c.date, raw_usage.c.duration_ms, validated.label("tokens")) + .offset(literal_column("0")) + .subquery(f"{kind}_usage") ) - day = func.date(func.timezone("UTC", usage.c.finished_at)) - raw = usage.c.raw_tokens - tokens = case((raw.op("~")(r"^[0-9]{1,18}$"), cast(raw, BigInteger)), else_=None) + day = usage.c.date + tokens = usage.c.tokens statements.append( select( day.label("date"), diff --git a/apps/extensions/tests/preview-retention-fixture.test.mjs b/apps/extensions/tests/preview-retention-fixture.test.mjs new file mode 100644 index 000000000..aa4d72c60 --- /dev/null +++ b/apps/extensions/tests/preview-retention-fixture.test.mjs @@ -0,0 +1,102 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { runInNewContext } from "node:vm"; +import { JSDOM } from "jsdom"; +import { + retentionBaselineReady, + stableRetentionPosition, +} from "../../../scripts/testing/preview-feed-retention.mjs"; + +function ready({ + count = 25, + unhydrated = -1, + placeholder = false, + fonts = "loaded", + layout = "stacked", + guest = false, + missingBookmark = false, +} = {}) { + const compact = layout === "compact"; + const rows = Array.from({ length: count }, (_, index) => { + const bookmark = missingBookmark + ? "" + : guest + ? 'Sign in to save' + : ``; + if (!compact) + return `
${index === unhydrated ? "" : '0'}${bookmark}
`; + return `${bookmark}`; + }).join(""); + const dom = new JSDOM( + `${compact ? `${rows}
` : rows}${placeholder ? '
' : ""}`, + ); + dom.window.Element.prototype.checkVisibility = () => true; + Object.defineProperty(dom.window.document, "fonts", { value: { status: fonts } }); + try { + return runInNewContext(`(${retentionBaselineReady})(".article-row")`, { + document: dom.window.document, + }); + } finally { + dom.window.close(); + } +} + +test("retention measurements wait for every paginated row's engagement hydration", () => { + assert.equal(ready(), true); + assert.equal(ready({ count: 24 }), false); + assert.equal(ready({ unhydrated: 24 }), false); +}); + +test("retention measurements wait for placeholder exit and downloaded fonts", () => { + assert.equal(ready({ placeholder: true }), false); + assert.equal(ready({ fonts: "loading" }), false); + assert.equal(ready(), true); +}); + +test("compact guest rows are ready with bookmark links and no engagement counters", () => { + assert.equal(ready({ layout: "compact", guest: true }), true); + assert.equal(ready({ layout: "compact", guest: true, missingBookmark: true }), false); +}); + +test("compact member rows wait until every bookmark button finishes hydrating", () => { + assert.equal(ready({ layout: "compact" }), true); + assert.equal(ready({ layout: "compact", unhydrated: 24 }), false); + assert.equal(ready({ layout: "compact", missingBookmark: true }), false); +}); + +for (const layout of ["stacked", "cards"]) + test(`${layout} guest bookmark links do not bypass counter hydration`, () => { + assert.equal(ready({ layout, guest: true }), true); + assert.equal(ready({ layout, guest: true, unhydrated: 24 }), false); + }); + +function stablePosition(positionAtFrame) { + let now = 0; + let frame = 0; + const callbacks = []; + const promise = runInNewContext(`(${stableRetentionPosition})(node)`, { + node: { getBoundingClientRect: () => ({ y: positionAtFrame(frame), height: 24 }) }, + document: { documentElement: { scrollHeight: 5000 } }, + scrollY: 2500, + performance: { now: () => now }, + requestAnimationFrame: (callback) => callbacks.push(callback), + }); + while (callbacks.length) { + now += 16; + frame += 1; + callbacks.shift()(); + } + return { promise, elapsed: now }; +} + +test("the baseline follows a late layout change before taking a reading-position sample", async () => { + const { promise, elapsed } = stablePosition((frame) => (frame < 7 ? 365.25 : 338.25)); + assert.deepEqual(Array.from(await promise), [338.25, 24, 2500, 5000]); + assert.ok(elapsed >= 7 * 16 + 200); +}); + +test("an unsettled baseline fails instead of hiding continued layout motion", async () => { + const { promise, elapsed } = stablePosition((frame) => frame); + await assert.rejects(promise, /Retention baseline did not settle/); + assert.ok(elapsed >= 5000); +}); diff --git a/scripts/ci/token-usage-summary.py b/scripts/ci/token-usage-summary.py new file mode 100644 index 000000000..2e0e5ccf6 --- /dev/null +++ b/scripts/ci/token-usage-summary.py @@ -0,0 +1,516 @@ +"""Render a token-report benchmark artifact as a concise, fail-closed PR comment.""" + +import argparse +import html +import json +import math +import os +import re +import stat +import statistics +from contextlib import ExitStack, suppress +from typing import Any +from urllib.parse import urlparse + +SCALES = (100000, 300000) +WINDOWS = (1, 7, 30) +MODES = ("custom", "generic") +KINDS = ("article_analysis", "topic_analysis", "research_verification") +FIELDS = ("total_rows", "one_day_rows", "seven_day_rows", "thirty_day_rows") +CHECKS = ( + "grouping_estimates_not_worse", + "grouping_estimates_bounded", + "aggregate_disk_usage_zero", + "temporary_writes_zero", + "no_sort_spills", + "buffer_accesses_not_amplified", + "runtime_within_noise_allowance", +) +MAX_PROFILE_BYTES = 5 * 1024 * 1024 + + +def mapping(value: Any) -> dict: + return value if isinstance(value, dict) else {} + + +def sequence(value: Any) -> list: + return value if isinstance(value, list) else [] + + +def number(value: Any) -> float | None: + if isinstance(value, (int, float)) and not isinstance(value, bool): + try: + if math.isfinite(value) and value >= 0: + return float(value) + except OverflowError: + pass + return None + + +def display(value: Any, *, integer: bool = False) -> str: + valid = number(value) + return "n/a" if valid is None else f"{valid:,.0f}" if integer else f"{valid:,.2f}" + + +def improvement(before: Any, after: Any) -> str: + first, second = number(before), number(after) + if first is None or second is None or first == 0: + return "n/a" + return f"{(first - second) / first * 100:+.1f}%" + + +def safe_text(value: Any) -> str: + value = html.escape(str(value), quote=True).replace("\n", " ").replace("\r", " ") + return re.sub(r"([\\`|\[\]])", r"\\\1", value)[:200] + + +def samples(strategy: dict, field: str) -> float | None: + values = [number(mapping(sample).get(field)) for sample in sequence(strategy.get("samples"))] + if not values or any(value is None for value in values): + return None + return statistics.median(value for value in values if value is not None) + + +def peak_memory(strategy: dict) -> float | None: + # Sorted aggregates do not report hash memory. Do not invent zero for them. + values = [ + number(mapping(aggregate).get("Peak Memory Usage")) + for aggregate in sequence(strategy.get("aggregates")) + if "Peak Memory Usage" in mapping(aggregate) + ] + if not values or any(value is None for value in values): + return None + return max(value for value in values if value is not None) + + +def writer_samples(strategy: dict) -> list[float] | None: + batches = sequence(strategy.get("ingestion")) + values = [ + number(value) for batch in batches for value in sequence(mapping(batch).get("latency_ms")) + ] + if not batches or not values or any(value is None for value in values): + return None + for batch in batches: + batch = mapping(batch) + count, overlapping = ( + number(batch.get("insert_and_finish")), + number(batch.get("completed_during_report")), + ) + if ( + count != len(sequence(batch.get("latency_ms"))) + or overlapping is None + or count is None + or overlapping > count + ): + return None + return [value for value in values if value is not None] + + +def p95(values: list[float] | None) -> float | None: + return sorted(values)[math.ceil(len(values) * 0.95) - 1] if values else None + + +def table(headers: tuple[str, ...], rows: list[list[str]]) -> list[str]: + return [ + "| " + " | ".join(headers) + " |", + "| " + " | ".join("---" for _ in headers) + " |", + *("| " + " | ".join(row) + " |" for row in rows), + ] + + +def build_summary( + data: Any, *, head_sha: str, run_url: str, outcome: str, error: str | None = None +) -> str: + """Missing measurements are n/a; missing cases or gates can never produce PASS.""" + missing, failures = [], [] + if error: + missing.append(error) + if not isinstance(data, dict): + missing.append("Profile root must be a JSON object.") + data = mapping(data) + scales = {} + for scale in sequence(data.get("scales")): + scale = mapping(scale) + size = number(scale.get("articles")) + if size not in SCALES or size in scales: + missing.append("Unexpected or duplicate fixture scale.") + continue + scales[size] = scale + reports = {} + for size, scale in scales.items(): + for report in sequence(scale.get("reports")): + report = mapping(report) + days, mode = number(report.get("days")), report.get("plan_mode") + report_key = (size, days, mode) if isinstance(mode, str) else None + if days not in WINDOWS or mode not in MODES or report_key in reports: + missing.append("Unexpected or duplicate report window/plan.") + continue + reports[report_key] = report + + passed_checks, equal, complete = 0, 0, 0 + runtime_rows, resource_rows, writer_rows = [], [], [] + for size in SCALES: + for days in WINDOWS: + for mode in MODES: + context = f"{size:,} articles / {days}d / {mode}" + report = reports.get((size, days, mode), {}) + strategies = mapping(report.get("strategies")) + before, after = (mapping(strategies.get(name)) for name in ("original", "current")) + first, second = number(before.get("median_ms")), number(after.get("median_ms")) + runtime_rows.append( + [ + f"{size:,}", + f"{days}d", + mode, + display(first), + display(second), + improvement(first, second), + ] + ) + if first is None or second is None: + missing.append(f"{context}: runtime measurement missing or invalid.") + else: + complete += 1 + equality = report.get("result_equality") + if equality is True: + equal += 1 + elif equality is False: + failures.append(f"{context}: token accounting differs.") + else: + missing.append(f"{context}: accounting comparison missing.") + checks = mapping(report.get("checks")) + for name in CHECKS: + if checks.get(name) is True: + passed_checks += 1 + elif checks.get(name) is False: + failures.append(f"{context}: {name}.") + else: + missing.append(f"{context}: {name} missing or invalid.") + for name, strategy in (("before", before), ("after", after)): + sample_values = sequence(strategy.get("samples")) + if any( + samples(strategy, field) is None + for field in ( + "elapsed_ms", + "application_cpu_ms", + "database_container_cpu_ms", + ) + ): + missing.append(f"{context}: {name} CPU/runtime samples missing or invalid.") + aggregates = sequence(strategy.get("aggregates")) + buffers = mapping(strategy.get("buffers")) + if ( + not aggregates + or any( + number(mapping(group).get("Plan Rows")) is None for group in aggregates + ) + or any( + "Peak Memory Usage" in mapping(group) + and number(group["Peak Memory Usage"]) is None + for group in aggregates + ) + or any( + number(buffers.get(field)) is None + for field in ("Temp Read Blocks", "Temp Written Blocks") + ) + ): + missing.append(f"{context}: {name} plan measurements missing or invalid.") + if ( + len(sequence(strategy.get("ingestion"))) != len(sample_values) + or writer_samples(strategy) is None + ): + missing.append(f"{context}: {name} writer measurements missing or invalid.") + if days == 30: + for metric, getter in ( + ( + "DB container CPU (ms)", + lambda strategy: samples(strategy, "database_container_cpu_ms"), + ), + ("App CPU (ms)", lambda strategy: samples(strategy, "application_cpu_ms")), + ( + "Temp writes (blocks)", + lambda strategy: mapping(strategy.get("buffers")).get( + "Temp Written Blocks" + ), + ), + ("Largest hash aggregate (KiB)", peak_memory), + ): + first, second = getter(before), getter(after) + resource_rows.append( + [ + f"{size:,}", + mode, + metric, + display(first), + display(second), + improvement(first, second), + ] + ) + old_writes, new_writes = writer_samples(before), writer_samples(after) + old_p95, new_p95 = p95(old_writes), p95(new_writes) + if ( + old_p95 is not None + and new_p95 is not None + and old_writes is not None + and new_writes is not None + and new_p95 > old_p95 * 1.2 + ): + writer_rows.append( + [ + f"{size:,} / {days}d / {mode}", + display(old_p95), + display(new_p95), + improvement(old_p95, new_p95), + f"{len(old_writes)} / {len(new_writes)}", + ] + ) + + baseline = mapping(data.get("production_volume")) + coverage_rows, volume_passed = [], 0 + volume_fields = [("Articles", "articles", None)] + [ + ( + f"{kind.replace('_', ' ')} / {field.replace('_rows', '').replace('_', ' ')}", + f"{kind}.{field}", + (kind, field), + ) + for kind in KINDS + for field in FIELDS + ] + for label, key, history in volume_fields: + production = ( + baseline.get("articles") + if history is None + else mapping(mapping(baseline.get("histories")).get(history[0])).get(history[1]) + ) + row = [label, display(production, integer=True)] + for size in SCALES: + scale = scales.get(size, {}) + actual = ( + scale.get("articles") + if history is None + else mapping(mapping(scale.get("histories")).get(history[0])).get(history[1]) + ) + first, second = number(production), number(actual) + recorded = mapping(scale.get("production_volume_checks")).get(key) + if first is None or second is None or not isinstance(recorded, bool): + missing.append(f"{size:,} articles: production volume evidence missing for {key}.") + status = "n/a" + elif second < first or recorded is False: + failures.append(f"{size:,} articles: production volume not covered for {key}.") + status = "FAIL" + else: + volume_passed += 1 + status = "PASS" + row.append(f"{display(actual, integer=True)} ({status})") + coverage_rows.append(row) + + if outcome == "failure": + failures.append( + "Benchmark measurement step failed; " + "partial passing measurements do not establish success." + ) + elif outcome != "success": + missing.append(f"Benchmark measurement step outcome: {safe_text(outcome)}.") + verdict = "FAIL" if failures else "INCOMPLETE" if missing else "PASS" + reason = { + "PASS": "all 12 comparisons, accounting checks and production-volume gates passed", + "FAIL": "benchmark failed; review the failed checks below", + "INCOMPLETE": "benchmark evidence is incomplete; no performance pass can be claimed", + }[verdict] + parsed_url = urlparse(run_url) + run = ( + f"[Benchmark run]({run_url})" + if parsed_url.scheme == "https" + and parsed_url.netloc == "github.com" + and re.fullmatch(r"/[A-Za-z0-9_./-]+", parsed_url.path) + and not parsed_url.query + and not parsed_url.fragment + else "Benchmark run link unavailable" + ) + architecture = data.get("client_architecture") + architecture = architecture if architecture in ("aarch64", "arm64", "x86_64") else "unknown" + lines = [ + f"**{verdict} — Token usage benchmark:** {reason}.", + "", + f"Compared head: `{safe_text(head_sha)}` · {run} · Architecture: `{architecture}`.", + "", + "Before = frozen pre-fix SQL; after = application SQL at the compared head. " + "Improvement = `(before − after) / before × 100`: **+ means better, − means worse**; " + "`n/a` means missing evidence or a zero baseline. " + "Lower is better for every performance metric.", + "", + "**Query runtime — median milliseconds**", + "", + *table( + ("Articles", "Window", "Plan", "Before (ms)", "After (ms)", "Improvement (+/−)"), + runtime_rows, + ), + "", + "**Validation**", + "", + *table( + ("Check", "Passed / expected"), + [ + ["Runtime comparisons", f"{complete} / 12"], + ["Identical token accounting", f"{equal} / 12"], + ["Runtime, plan and spill gates", f"{passed_checks} / 84"], + ["Production-volume gates", f"{volume_passed} / 26"], + ["Measurement step", safe_text(outcome)], + ], + ), + "", + "Runtime gate allows up to 20% or 20 ms noise; PASS does not mean every case got faster. " + "Writer latency is observational and has no pass/fail gate. " + "Its samples include writer startup and reports may finish " + "before a subsequent write completes.", + ] + if writer_rows: + lines.extend( + [ + "", + "**Writer latency caveats — p95 regressions over 20%**", + "", + *table( + ( + "Fixture / window / plan", + "Before p95 (ms)", + "After p95 (ms)", + "Improvement (+/−)", + "Samples before / after", + ), + writer_rows, + ), + "", + "p95 uses the nearest-rank sample. Small sample counts and startup outliers " + "prevent a sustained-load latency conclusion.", + ] + ) + for title, entries in (("Failed checks", failures), ("Missing or invalid evidence", missing)): + if entries: + lines.extend(["", f"**{title}**", "", *(f"- {entry}" for entry in entries[:20])]) + if len(entries) > 20: + lines.append( + f"- {len(entries) - 20} more; inspect the JSON artifact in the benchmark run." + ) + lines.extend( + [ + "", + "
", + "30-day CPU, temporary writes and hash memory", + "", + *table( + ("Articles", "Plan", "Metric", "Before", "After", "Improvement (+/−)"), + resource_rows, + ), + "", + "DB CPU is the container CPU median, including concurrent ingestion; " + "elapsed time is measured separately. " + "Hash memory is the largest reported aggregate node, not total process memory. " + "Temporary writes are PostgreSQL blocks from EXPLAIN.", + "", + "
", + "", + "
", + "Production-volume coverage: retained history and 1/7/30-day counts", + "", + *table( + ("Fixture", "Production snapshot", "100,000 articles", "300,000 articles"), + coverage_rows, + ), + "", + *( + [f"Production snapshot observed at: `{safe_text(baseline['observed_at'])}`.", ""] + if baseline.get("observed_at") + else [] + ), + "Production counts are the captured snapshot, not a live production benchmark.", + "", + "
", + ] + ) + return "\n".join(lines) + "\n" + + +def render(contents: str | None, *, head_sha: str, run_url: str, outcome: str) -> str: + try: + data = json.loads(contents) if contents is not None else None + if contents is None: + raise ValueError("Profile unavailable") + error = None + except ValueError: + data, error = {}, "Profile artifact is missing, unreadable or malformed." + return build_summary(data, head_sha=head_sha, run_url=run_url, outcome=outcome, error=error) + + +def read_profile(directory: int) -> str | None: + """Read only the regular profile file inside the already-open report directory.""" + try: + descriptor = os.open( + "profile.json", os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory + ) + with os.fdopen(descriptor, "rb") as profile: + metadata = os.fstat(profile.fileno()) + if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > MAX_PROFILE_BYTES: + return None + contents = profile.read(MAX_PROFILE_BYTES + 1) + if len(contents) > MAX_PROFILE_BYTES: + return None + return contents.decode("utf-8") + except (OSError, UnicodeError): + return None + + +def write_summary(directory: int, contents: str) -> None: + # Check before truncating: a preexisting symlink or hard link must not let + # an artifact redirect the output into a different file. + descriptor = os.open( + "summary.md", + os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, + 0o600, + dir_fd=directory, + ) + with os.fdopen(descriptor, "w", encoding="utf-8") as output: + metadata = os.fstat(output.fileno()) + if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: + raise ValueError("Summary output must be a regular file with one link") + os.fchmod(output.fileno(), 0o600) + output.truncate(0) + output.write(contents) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--head-sha", required=True) + parser.add_argument("--run-url", required=True) + parser.add_argument( + "--outcome", choices=("success", "failure", "skipped", "cancelled"), required=True + ) + args = parser.parse_args() + try: + # Every component and filename is fixed. Directory descriptors and + # O_NOFOLLOW keep artifact symlinks from escaping reports/token-usage. + with ExitStack() as files: + directory = os.open(".", os.O_RDONLY | os.O_DIRECTORY) + files.callback(os.close, directory) + for component in ("reports", "token-usage"): + with suppress(FileExistsError): + os.mkdir(component, dir_fd=directory) + directory = os.open( + component, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=directory + ) + files.callback(os.close, directory) + summary = render( + read_profile(directory), + head_sha=args.head_sha, + run_url=args.run_url, + outcome=args.outcome, + ) + write_summary(directory, summary) + except (OSError, ValueError) as error: + parser.exit(2, f"Cannot safely access reports/token-usage: {error}\n") + if not summary.startswith("**PASS"): + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/profile_token_usage.py b/scripts/profile_token_usage.py new file mode 100644 index 000000000..719a7b063 --- /dev/null +++ b/scripts/profile_token_usage.py @@ -0,0 +1,614 @@ +"""Compare the original token report and current application SQL on disposable PostgreSQL. + +Requires a migrated, explicitly local DEVFEED_TEST_DATABASE_URL ending in _test. +--seed replaces articles and topic proposals, including their dependent job histories. +Never point this tool at shared development data. Raw EXPLAIN plans accompany the report. +""" + +import argparse +import hashlib +import json +import os +import platform +import stat +import statistics +import subprocess +import threading +import time +from contextlib import ExitStack, contextmanager, suppress +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +from devfeed_admin_api.automation import analysis_token_activity +from devfeed_core.models import ArticleAnalysisJob, ResearchVerificationJob, TopicAnalysisJob +from sqlalchemy import BigInteger, case, cast, create_engine, func, literal, select, text, union_all +from sqlalchemy.dialects.postgresql.psycopg import PGDialect_psycopg +from sqlalchemy.engine import make_url + +NOW = datetime(2026, 10, 24, 12, tzinfo=UTC) +MODELS = ( + ("article_analysis", ArticleAnalysisJob), + ("topic_analysis", TopicAnalysisJob), + ("research_verification", ResearchVerificationJob), +) +REPORT_NAME = "reports/token-usage/profile.json" +ROOT = Path(__file__).resolve().parents[1] +PRODUCTION_VOLUME: dict[str, Any] = { + "observed_at": "2026-10-24T10:05:40Z", + "articles": 92598, + "histories": { + "article_analysis": { + "total_rows": 156021, + "one_day_rows": 3034, + "seven_day_rows": 16692, + "thirty_day_rows": 50402, + }, + "topic_analysis": { + "total_rows": 159575, + "one_day_rows": 2381, + "seven_day_rows": 30257, + "thirty_day_rows": 128657, + }, + "research_verification": { + "total_rows": 67014, + "one_day_rows": 190, + "seven_day_rows": 3142, + "thirty_day_rows": 45316, + }, + }, +} + + +def original_statement(start, end): + """Frozen pre-#295 SQL: unique completion timestamps cross the CTE boundary.""" + statements = [] + for kind, model in MODELS: + usage = ( + select( + model.finished_at, + model.duration_ms, + func.left(model.usage["totalTokens"].astext, 19).label("raw_tokens"), + ) + .where( + model.finished_at >= start, + model.finished_at <= end, + model.status.in_(("succeeded", "failed")), + ) + .cte(f"{kind}_usage") + .prefix_with("MATERIALIZED") + ) + day = func.date(func.timezone("UTC", usage.c.finished_at)) + tokens = case( + (usage.c.raw_tokens.op("~")(r"^[0-9]{1,18}$"), cast(usage.c.raw_tokens, BigInteger)), + else_=None, + ) + statements.append( + select( + day.label("date"), + literal(kind).label("kind"), + func.coalesce(func.sum(tokens), 0).label("tokens"), + func.count(tokens).label("reported"), + (func.count() - func.count(tokens)).label("unreported"), + func.coalesce(func.sum(usage.c.duration_ms), 0).label("duration"), + ).group_by(day) + ) + return union_all(*statements) + + +class Capture: + """Obtain the exact production statement without running the reporting query.""" + + def execute(self, statement): + self.statement = statement + return self + + def mappings(self): + return () + + +def current_statement(start, end): + capture = Capture() + analysis_token_activity(capture, start, end) + return capture.statement + + +def prepared_sql(statement): + # Retain every application bind, including statuses/UTC/regex/JSON keys. + # EXPLAIN EXECUTE describes actual cached generic/custom parameterized SQL. + compiled = statement.compile( + dialect=PGDialect_psycopg(paramstyle="numeric_dollar"), + compile_kwargs={"render_postcompile": True}, + ) + arguments = ",".join( + str( + literal(compiled.params[name]).compile( + dialect=PGDialect_psycopg(), compile_kwargs={"literal_binds": True} + ) + ) + for name in compiled.positiontup + ) + return str(compiled), arguments + + +def configure(connection, mode): + for setting in ( + "SET work_mem='4MB'", + "SET jit=on", + "SET max_parallel_workers_per_gather=2", + "SET track_io_timing=on", + "SET statement_timeout='90s'", + "SET timezone='Asia/Kolkata'", + f"SET plan_cache_mode='force_{mode}_plan'", + ): + connection.exec_driver_sql(setting) + connection.commit() + + +def seed(engine, articles): + """Actual migrated tables/indexes, skewed 180-day history and incompressible TOAST.""" + with engine.begin() as connection: + # A pooled measurement connection retains its bounded report timeout. + # Fixture construction has a separate budget and keeps all ordinary triggers. + connection.exec_driver_sql("SET LOCAL statement_timeout='10min'") + connection.exec_driver_sql("TRUNCATE articles,topic_proposals CASCADE") + connection.execute( + text("""INSERT INTO articles + (id,canonical_url,url_hash,title,slug,summary,content_type,feed_at,discovered_at) + SELECT md5('token-article-'||n)::uuid,'https://example.invalid/'||n, + md5('token-article-'||n),'Token profile '||n,'token-profile-'||n, + 'Disposable token reporting fixture','article',:now,:now + FROM generate_series(1,:articles) n"""), + {"now": NOW, "articles": articles}, + ) + connection.execute( + text("""INSERT INTO topic_proposals + (id,batch_id,slug,action,origin,source_name,proposed,evidence,status,created_at,created_by) + SELECT md5('token-proposal-'||n)::uuid,md5('token-profile')::uuid, + 'token-proposal-'||n,'create','import','Token profile','{}','[]','pending',:now,'{}' + FROM generate_series(1,10000) n"""), + {"now": NOW}, + ) + # Every fifth job has >4 KiB of incompressible provider/step telemetry. + # Build a bounded pool once, rather than hide seed CPU in measured samples. + connection.exec_driver_sql("""CREATE TEMP TABLE token_profile_payloads ON COMMIT DROP AS + SELECT p,string_agg(md5(p::text||'-'||n::text),'') payload + FROM generate_series(0,255) p CROSS JOIN generate_series(1,128) n GROUP BY p""") + for kind, model in MODELS[:2]: + article = kind == "article_analysis" + target = "article_id" if article else "proposal_id" + target_id = ( + "md5('token-article-'||(1+(n-1)%:articles))::uuid" + if article + else "md5('token-proposal-'||(1+(n-1)%10000))::uuid" + ) + columns = ( + "input_snapshot,catalog_snapshot,result,prompt_version" + if article + else "input_hash,input_snapshot,requested_by,result,prompt_version" + ) + values = ( + "'{}','{}','{}','profile'" if article else "repeat('a',64),'{}','{}','{}','profile'" + ) + recent = 30 if article else 80 + burst = ( + "WHEN n%100<3 THEN ((n*7919::bigint)%43200)*interval '1 second'" if article else "" + ) + connection.execute( + text(f"""INSERT INTO {model.__tablename__} + (id,{target},status,attempts,available_at,created_at,finished_at,duration_ms,usage, + {columns}) + SELECT md5('{kind}-'||n)::uuid,{target_id}, + CASE WHEN n%13=0 THEN 'failed' ELSE 'succeeded' END,1,:now,:now, + :now-CASE {burst} WHEN n%100<{recent} + THEN ((n*7919::bigint)%2592000)*interval '1 second' + ELSE interval '30 days' + +((n*15485863::bigint)%12960000)*interval '1 second' END, + n%60000, + (CASE n%11 + WHEN 0 THEN '{{}}'::jsonb + WHEN 1 THEN '{{"totalTokens": null}}'::jsonb + WHEN 2 THEN '{{"totalTokens":"broken"}}'::jsonb + WHEN 3 THEN '{{"totalTokens":-1}}'::jsonb + WHEN 4 THEN '{{"totalTokens": 1.5}}'::jsonb + WHEN 5 THEN '{{"totalTokens":"1234567890123456789"}}'::jsonb + WHEN 6 THEN '{{"totalTokens":"999999999999999999"}}'::jsonb + WHEN 7 THEN '{{"totalTokens": 0}}'::jsonb + WHEN 8 THEN jsonb_build_object('totalTokens',(n%100000)::text) + ELSE jsonb_build_object('totalTokens',n%100000) END) + || CASE WHEN n%5=0 THEN jsonb_build_object('provider_metadata',p.payload) + ELSE '{{}}'::jsonb END,{values} + FROM generate_series(1,:jobs) n + JOIN token_profile_payloads p ON p.p=n%256"""), + {"now": NOW, "articles": articles, "jobs": 2 * articles}, + ) + connection.execute( + text("""INSERT INTO research_verification_jobs + (id,status,attempts,available_at,created_at,finished_at,relationships,usage,duration_ms) + SELECT id,status,attempts,available_at,created_at,finished_at,false,usage,duration_ms + FROM topic_analysis_jobs WHERE id IN ( + SELECT md5('topic_analysis-'||n)::uuid FROM generate_series(1,:research) n) + """), + {"research": max(1, articles * 73 // 100)}, + ) + connection.execute( + text("""INSERT INTO article_analysis_jobs + (id,article_id,status,attempts,available_at,created_at,input_snapshot,catalog_snapshot,result) + SELECT md5('unfinished-'||n)::uuid,md5('token-article-'||n)::uuid, + CASE WHEN n%2=0 THEN 'running' ELSE 'queued' END,0,:now,:now,'{}','{}','{}' + FROM generate_series(1,:articles) n WHERE n%97=0"""), + {"now": NOW, "articles": articles}, + ) + # Commit before ANALYZE so every profiling connection sees identical statistics. + with engine.begin() as connection: + for table in ("articles", "topic_proposals", *(m.__tablename__ for _, m in MODELS)): + connection.exec_driver_sql(f"ANALYZE {table}") + + +def relation_sizes(connection): + return [ + dict(row) + for row in connection.execute( + text("""SELECT c.relname,c.reltuples::bigint estimated_rows, + pg_relation_size(c.oid) heap_bytes,pg_total_relation_size(c.oid) total_bytes, + CASE WHEN c.reltoastrelid=0 THEN 0 + ELSE pg_total_relation_size(c.reltoastrelid) END toast_bytes + FROM pg_class c WHERE c.oid IN ('articles'::regclass, + 'article_analysis_jobs'::regclass,'topic_analysis_jobs'::regclass, + 'research_verification_jobs'::regclass) ORDER BY c.relname""") + ).mappings() + ] + + +def history_counts(connection): + histories = {} + for kind, model in MODELS: + row = ( + connection.execute( + text(f"""SELECT count(*) total_rows, + min(finished_at) oldest_completion,max(finished_at) newest_completion, + count(DISTINCT finished_at) distinct_completions, + count(*) FILTER (WHERE finished_at>=:now-interval '12 hours') one_day_rows, + count(*) FILTER (WHERE finished_at>=:now-interval '6 days 12 hours') seven_day_rows, + count(*) FILTER (WHERE finished_at>=:now-interval '29 days 12 hours') thirty_day_rows + FROM {model.__tablename__} WHERE status IN ('succeeded','failed') + AND finished_at<=:now"""), + {"now": NOW}, + ) + .mappings() + .one() + ) + histories[kind] = dict(row) + return histories + + +def postgres_cpu(container): + """Cgroup CPU is actual database container CPU, not SQL elapsed time.""" + if not container: + return None + pid = subprocess.check_output( + ["docker", "inspect", "--format", "{{.State.Pid}}", container], text=True + ).strip() + for entry in Path(f"/proc/{pid}/cgroup").read_text().splitlines(): + if entry.startswith("0::"): + path = Path("/sys/fs/cgroup") / entry.split("::", 1)[1].lstrip("/") / "cpu.stat" + return int(dict(line.split() for line in path.read_text().splitlines())["usage_usec"]) + raise RuntimeError("Database CPU measurement requires cgroup v2") + + +class Ingestion: + def __init__(self, engine, rate): + self.engine, self.rate = engine, rate + self.stop = threading.Event() + self.ready = threading.Event() + self.count, self.errors, self.samples = 0, [], [] + self.thread = threading.Thread(target=self.run, daemon=True) + + def run(self): + try: + with self.engine.connect() as connection: + while not self.stop.is_set(): + started = time.perf_counter() + identifier = str(time.time_ns()) + connection.execute( + text("""WITH article AS ( + INSERT INTO articles + (id,canonical_url,url_hash,title,slug,summary,content_type,feed_at,discovered_at) + VALUES (gen_random_uuid(),'https://example.invalid/ingest/'||:n, + md5('ingest-'||:n),'Concurrent ingestion','ingest-'||:n, + '','article',:now,:now) + RETURNING id) + INSERT INTO article_analysis_jobs + (id,article_id,status,attempts,available_at,created_at,input_snapshot, + catalog_snapshot,result,usage) + SELECT md5('ingest-'||:n)::uuid,id,'running',1,:now,:now,'{}','{}','{}','{}' + FROM article"""), + {"n": identifier, "now": NOW + timedelta(days=1)}, + ) + connection.execute( + text("""UPDATE article_analysis_jobs + SET status='succeeded',finished_at=:now, + duration_ms=100,usage='{"totalTokens": 1000}' + WHERE id=md5('ingest-'||:n)::uuid"""), + {"n": identifier, "now": NOW + timedelta(days=1)}, + ) + connection.commit() + self.count += 1 + self.samples.append((time.perf_counter() - started) * 1000) + self.ready.set() + self.stop.wait(max(0, 1 / self.rate - (time.perf_counter() - started))) + except Exception as error: + self.errors.append(str(error)) + self.ready.set() + + def __enter__(self): + self.thread.start() + assert self.ready.wait(15), "Concurrent ingestion did not start" + assert not self.errors, self.errors + return self + + def __exit__(self, *_): + self.stop.set() + self.thread.join(timeout=15) + assert not self.thread.is_alive(), "Concurrent ingestion did not stop" + assert not self.errors, self.errors + + +def nodes(plan): + yield plan + for child in plan.get("Plans", ()): + yield from nodes(child) + + +def measure(engine, days, mode, repeats, container, rate): + start = NOW.replace(hour=0) - timedelta(days=days - 1) + results, plans = {}, {} + samples: dict[str, list[dict[str, Any]]] = {} + ingestion: dict[str, list[dict[str, Any]]] = {} + with engine.connect() as connection: + configure(connection, mode) + statements = {} + for name, builder in (("original", original_statement), ("current", current_statement)): + query, arguments = prepared_sql(builder(start, NOW)) + connection.exec_driver_sql(f"PREPARE token_{name} AS {query}") + statements[name] = f"EXECUTE token_{name}({arguments})" + for repeat in range(repeats + 1): + for name in ("original", "current") if repeat % 2 == 0 else ("current", "original"): + execute = statements[name] + # A separate writer for each strategy gives matched requested load. + with Ingestion(engine, rate) as writer: + db_cpu = postgres_cpu(container) + writes_before = writer.count + app_cpu, started = time.thread_time(), time.perf_counter() + rows = sorted(tuple(row) for row in connection.exec_driver_sql(execute)) + elapsed = (time.perf_counter() - started) * 1000 + cpu = (time.thread_time() - app_cpu) * 1000 + db_after = postgres_cpu(container) + overlapping_writes = writer.count - writes_before + results[name] = rows + if len(results) == 2: + assert results["original"] == results["current"], "Token accounting changed" + if repeat: # First pass warms both paths without dropping shared caches. + samples.setdefault(name, []).append( + { + "elapsed_ms": elapsed, + "application_cpu_ms": cpu, + "database_container_cpu_ms": None + if db_cpu is None + else (db_after - db_cpu) / 1000, + } + ) + ingestion.setdefault(name, []).append( + { + "insert_and_finish": writer.count, + "completed_during_report": overlapping_writes, + "latency_ms": writer.samples, + } + ) + if repeat == repeats: + plans[name] = connection.exec_driver_sql( + "EXPLAIN (ANALYZE,BUFFERS,VERBOSE,SETTINGS,FORMAT JSON) " + execute + ).scalar_one()[0] + connection.exec_driver_sql("DEALLOCATE ALL") + connection.commit() + summaries = {} + for name in ("original", "current"): + plan = plans[name] + groups = [node for node in nodes(plan["Plan"]) if node["Node Type"] == "Aggregate"] + summaries[name] = { + "samples": samples[name], + "median_ms": statistics.median(sample["elapsed_ms"] for sample in samples[name]), + "ingestion": ingestion[name], + "execution_ms": plan["Execution Time"], + "buffers": {key: value for key, value in plan["Plan"].items() if "Blocks" in key}, + "aggregates": [ + { + key: node[key] + for key in ( + "Node Type", + "Strategy", + "Plan Rows", + "Actual Rows", + "Peak Memory Usage", + "Disk Usage", + "HashAgg Batches", + ) + if key in node + } + for node in groups + ], + "plan": plan, + } + original_groups = sum(g["Plan Rows"] for g in summaries["original"]["aggregates"]) + current_groups = sum(g["Plan Rows"] for g in summaries["current"]["aggregates"]) + accesses = { + name: sum( + summary["buffers"].get(key, 0) for key in ("Shared Hit Blocks", "Shared Read Blocks") + ) + for name, summary in summaries.items() + } + checks = { + # Each projected date may use PostgreSQL's 200-distinct fallback even + # for tiny smoke fixtures with fewer input rows across the three kinds. + "grouping_estimates_not_worse": current_groups <= max(original_groups, 600), + "grouping_estimates_bounded": all( + g["Plan Rows"] <= 200 for g in summaries["current"]["aggregates"] + ), + "aggregate_disk_usage_zero": all( + g.get("Disk Usage", 0) == 0 for g in summaries["current"]["aggregates"] + ), + "temporary_writes_zero": summaries["current"]["buffers"].get("Temp Written Blocks", 0) == 0, + "no_sort_spills": all( + node.get("Sort Space Type") != "Disk" for node in nodes(plans["current"]["Plan"]) + ), + "buffer_accesses_not_amplified": accesses["current"] <= 1.05 * accesses["original"] + 16, + "runtime_within_noise_allowance": summaries["current"]["median_ms"] + <= max(1.2 * summaries["original"]["median_ms"], summaries["original"]["median_ms"] + 20), + } + canonical = json.dumps(results["current"], default=str, separators=(",", ":")) + return { + "days": days, + "plan_mode": mode, + "result_equality": True, + "result_sha256": hashlib.sha256(canonical.encode()).hexdigest(), + "returned_groups": len(results["current"]), + "checks": checks, + "strategies": summaries, + } + + +@contextmanager +def report_directory(): + """Open checkout-owned directories without following links, including rename races.""" + with ExitStack() as descriptors: + directory = os.open(ROOT, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + descriptors.callback(os.close, directory) + for component in ("reports", "token-usage"): + with suppress(FileExistsError): + os.mkdir(component, mode=0o700, dir_fd=directory) + directory = os.open( + component, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=directory + ) + descriptors.callback(os.close, directory) + yield directory + + +def write_report(output): + with report_directory() as directory: + descriptor = os.open( + "profile.json", + os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, + 0o600, + dir_fd=directory, + ) + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + info = os.fstat(stream.fileno()) + if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1: + raise ValueError("Report output must be a regular file with one link") + os.fchmod(stream.fileno(), 0o600) + stream.truncate(0) + stream.write(json.dumps(output, indent=2, default=str) + "\n") + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--seed", action="store_true") + parser.add_argument("--articles", nargs="+", type=int, default=[100000, 300000]) + parser.add_argument("--repeats", type=int, default=3) + parser.add_argument("--ingestion-rate", type=int, default=20) + parser.add_argument("--postgres-container") + args = parser.parse_args(argv) + url = make_url(os.environ["DEVFEED_TEST_DATABASE_URL"]) + if ( + url.get_backend_name() != "postgresql" + or url.host not in {"127.0.0.1", "localhost"} + or not (url.database or "").endswith("_test") + or url.query + or any(os.environ.get(key) for key in ("PGHOSTADDR", "PGSERVICE", "PGSERVICEFILE")) + ): + parser.error("Use an explicitly disposable local database ending in _test") + if not ( + all(100 <= count <= 1000000 for count in args.articles) + and 1 <= args.repeats <= 10 + and 1 <= args.ingestion_rate <= 100 + ): + parser.error("Fixture or sampling sizes exceed supported profiling bounds") + if len(args.articles) > 1 and not args.seed: + parser.error("Multiple fixture sizes require explicit --seed") + engine = create_engine(url, connect_args={"prepare_threshold": None}) + output: dict[str, Any] = { + "client_architecture": platform.machine(), + "production_volume": PRODUCTION_VOLUME, + "scales": [], + } + try: + write_report(output) + with engine.connect() as connection: + assert ( + connection.exec_driver_sql("SELECT current_database()").scalar_one() == url.database + ) + output["server_version"] = connection.exec_driver_sql("SELECT version()").scalar_one() + assert "PostgreSQL 17." in output["server_version"] + for count in args.articles: + if args.seed: + print(f"Seeding {count} articles and retained job histories", flush=True) + seed(engine, count) + with engine.connect() as connection: + scale = { + "articles": count, + "relations": relation_sizes(connection), + "histories": history_counts(connection), + "reports": [], + } + output["scales"].append(scale) + scale["production_volume_checks"] = { + "articles": count >= PRODUCTION_VOLUME["articles"], + **{ + f"{kind}.{field}": scale["histories"][kind][field] >= minimum + for kind, fields in PRODUCTION_VOLUME["histories"].items() + for field, minimum in fields.items() + }, + } + for mode in ("custom", "generic"): + for days in (1, 7, 30): + scale["reports"].append( + measure( + engine, + days, + mode, + args.repeats, + args.postgres_container, + args.ingestion_rate, + ) + ) + write_report(output) + print( + f"{count} articles / {days} days / {mode}: " + + " / ".join( + f"{name} {value['median_ms']:.1f} ms" + for name, value in scale["reports"][-1]["strategies"].items() + ), + flush=True, + ) + finally: + engine.dispose() + failures = [ + f"{scale['articles']} articles/{report['days']} days/{report['plan_mode']}: {name}" + for scale in output["scales"] + for report in scale["reports"] + for name, passed in report["checks"].items() + if not passed + ] + failures.extend( + f"{scale['articles']} articles: fixture below production {name}" + for scale in output["scales"] + if scale["articles"] >= 100000 + for name, passed in scale["production_volume_checks"].items() + if not passed + ) + if failures: + raise SystemExit("Benchmark checks failed (full evidence saved):\n" + "\n".join(failures)) + + +if __name__ == "__main__": + main() diff --git a/scripts/testing/preview-feed-retention.mjs b/scripts/testing/preview-feed-retention.mjs index 16f23d6bf..6fe05c2b7 100644 --- a/scripts/testing/preview-feed-retention.mjs +++ b/scripts/testing/preview-feed-retention.mjs @@ -71,6 +71,47 @@ function requestCounts(requests) { return counts; } +export function retentionBaselineReady(selector) { + const rows = [...document.querySelectorAll(selector)].filter((row) => row.checkVisibility()); + return ( + rows.length === 25 && + rows.every((row) => { + // Compact rows render bookmarks without the like/open counters used by + // Stacked and Grid. Guest bookmarks are links; member buttons enable only + // after their engagement record has loaded. + if (!row.matches(".article-list-row")) return row.querySelector(".engagement-count"); + const bookmark = row.querySelector(".bookmark-button"); + return bookmark?.matches("a[href]") || (bookmark?.matches("button") && !bookmark.disabled); + }) && + !document.querySelector(".reader-loading-placeholder") && + document.fonts.status === "loaded" + ); +} + +/** Capture the baseline after hydration and native scrolling have stopped moving it. */ +export function stableRetentionPosition(node) { + return new Promise((resolve, reject) => { + const started = performance.now(); + let stableSince = started; + let previous; + const measure = () => { + const bounds = node.getBoundingClientRect(); + const position = [bounds.y, bounds.height, scrollY, document.documentElement.scrollHeight]; + const key = JSON.stringify(position); + const now = performance.now(); + if (key !== previous) { + previous = key; + stableSince = now; + } + if (now - stableSince >= 200) resolve(position); + else if (now - started >= 5000) + reject(new Error(`Retention baseline did not settle: ${key}`)); + else requestAnimationFrame(measure); + }; + requestAnimationFrame(measure); + }); +} + async function previewDuringNativeScroll(page, title, target) { await page.bringToFront(); // Activate the actual link in the first moving browser frame. A driver roundtrip @@ -185,11 +226,15 @@ export async function checkPreviewFeedRetention( // Keep the trigger clear of the sticky header before recording scroll; Playwright otherwise // legitimately scrolls an obscured Stacked title while preparing its click. await title.evaluate((node) => node.scrollIntoView({ block: "center", behavior: "instant" })); - // Wait for engagement hydration and the initial placeholder's exit transition. - await page.waitForTimeout(250); + // A fixed delay can record the baseline while a late engagement response or + // placeholder exit is still changing row heights on a busy browser runner. + await page.waitForFunction(retentionBaselineReady, rowSelector); + await title.evaluate(stableRetentionPosition); if (resumeOnly) { const resumedRows = await rows.elementHandles(); const resumeBounds = await title.boundingBox(); + const beforeScroll = await page.evaluate(() => scrollY); + const resumeRequestStart = requests().length; const resumeTime = await page.evaluate(() => Date.now() + 120_000); await page.clock.setFixedTime(resumeTime); const resumedAuth = page.waitForResponse((response) => @@ -201,27 +246,38 @@ export async function checkPreviewFeedRetention( }); await resumedAuth; await page.waitForTimeout(300); - assert.equal(await rows.count(), 25, "Tab resume retains every paginated article"); + await page.waitForFunction(retentionBaselineReady, rowSelector); + const rowCount = await rows.count(); + const connected = []; for (const row of resumedRows) { - assert.equal( - await row.evaluate((node) => node.isConnected), - true, - "Tab resume retains the original article DOM", - ); + connected.push(await row.evaluate((node) => node.isConnected)); await row.dispose(); } + const resumedBounds = await title.boundingBox(); + results.push({ + view, + retained: connected.filter(Boolean).length, + rowCount, + readingPositionDelta: resumedBounds ? resumedBounds.y - resumeBounds.y : null, + before: { bounds: resumeBounds, scroll: beforeScroll }, + after: { bounds: resumedBounds, scroll: await page.evaluate(() => scrollY) }, + requests: requestCounts(requests().slice(resumeRequestStart)), + }); + // Preserve the failing scenario too; assertions previously left no artifact + // for the layout that moved, making a race indistinguishable from a regression. + if (output) { + await mkdir(dirname(output), { recursive: true }); + await writeFile(output, JSON.stringify(results, null, 2)); + } + assert.equal(rowCount, 25, "Tab resume retains every paginated article"); + assert.ok(connected.every(Boolean), "Tab resume retains the original article DOM"); + assert.ok(resumedBounds, "Tab resume retains a visible article title"); // Engagement controls can change line height during hydration. Retain the // same article within one text line rather than requiring an identical pixel. - const resumedBounds = await title.boundingBox(); assert.ok( Math.abs(resumedBounds.y - resumeBounds.y) <= 24, `Tab resume retains the reading position (${target}, ${view}, before=${resumeBounds.y}, after=${resumedBounds.y})`, ); - results.push({ - view, - retained: resumedRows.length, - readingPositionDelta: resumedBounds.y - resumeBounds.y, - }); continue; } const requestStart = requests().length; diff --git a/tests/admin/operations/test_automation_policy_unit.py b/tests/admin/operations/test_automation_policy_unit.py index 644f181e4..7b699b981 100644 --- a/tests/admin/operations/test_automation_policy_unit.py +++ b/tests/admin/operations/test_automation_policy_unit.py @@ -233,7 +233,7 @@ def test_completed_token_series_keeps_idle_days_and_combines_all_three_job_kinds assert points[2].reported_runs == 2 and duration == 275 -def test_completed_token_query_reads_each_usage_once_into_bounded_materialized_values(): +def test_completed_token_query_streams_bounded_usage_with_once_per_row_validation(): from sqlalchemy.dialects import postgresql statements = [] @@ -246,14 +246,29 @@ def execute(statement): SimpleNamespace(execute=execute), NOW - timedelta(days=7), NOW ) assert len(statements) == 1 + for branch in statements[0].selects: + usage = branch.get_final_froms()[0] + raw_usage = usage.element.get_final_froms()[0] + # Keep the computed date visible across both projection boundaries; + # grouping a timestamp expression loses its daily cardinality estimate. + projected_day = branch.selected_columns["date"].element + assert getattr(projected_day, "table", None) is usage + assert getattr(usage.element.selected_columns["date"], "table", None) is raw_usage + assert len(usage.c) == len(raw_usage.c) == 3 + assert "finished_at" not in usage.c and "finished_at" not in raw_usage.c + assert "raw_tokens" not in usage.c sql = str(statements[0].compile(dialect=postgresql.dialect())) - assert sql.count("AS MATERIALIZED") == 3 + # Literal OFFSET 0 fences preserve both streaming projections in generic + # plans, without introducing a materialized working set or runtime limits. + assert sql.count("OFFSET 0") == 6 + assert "MATERIALIZED" not in sql for table in ("article_analysis_jobs", "topic_analysis_jobs", "research_verification_jobs"): assert sql.count(f"{table}.usage ->>") == 1 assert f"{table}.finished_at >=" in sql assert f"{table}.finished_at <=" in sql assert f"{table}.status IN" in sql assert sql.count("left(") == 3 + assert sql.count("CASE WHEN") == 3 @pytest.mark.parametrize( diff --git a/tests/admin/reporting/test_overview.py b/tests/admin/reporting/test_overview.py index edfd6e13a..acf28b81d 100644 --- a/tests/admin/reporting/test_overview.py +++ b/tests/admin/reporting/test_overview.py @@ -155,19 +155,138 @@ def test_token_activity_counts_all_recorded_job_types_on_utc_finish_day(database assert data.token_activity[-1].unreported_runs == 2 +@pytest.mark.parametrize("timezone", ["Pacific/Auckland", "Pacific/Honolulu"]) +def test_token_activity_keeps_exact_completion_boundaries_statuses_and_durations( + database, timezone +): + start = START + timedelta(hours=12) + midnight = START + timedelta(days=1) + end = START + timedelta(days=3) + # The middle idle day and midnight endpoint must survive UTC bucketing even + # when the database session's local date differs from the completion date. + cases = [ + ("succeeded", start - timedelta(microseconds=1), {"totalTokens": 999}, 1000), + ("succeeded", start, {"totalTokens": 10}, 1), + ("failed", midnight - timedelta(microseconds=1), {"totalTokens": 20}, 2), + ("succeeded", midnight, {"totalTokens": 30}, 3), + ("failed", midnight, {"attempts": {"1": {"totalTokens": 999}}}, 4), + ("succeeded", end, {"totalTokens": 0}, 5), + ("failed", end, {"totalTokens": "invalid"}, 6), + ("succeeded", end + timedelta(microseconds=1), {"totalTokens": 999}, 1000), + ("queued", midnight, {"totalTokens": 999}, 1000), + ("running", midnight, {"totalTokens": 999}, 1000), + ("succeeded", None, {"totalTokens": 999}, 1000), + ("failed", None, {"totalTokens": 999}, 1000), + ] + with database.begin() as session: + for kind, scale in (("article", 1), ("topic", 10), ("research", 100)): + for status, finished, usage, duration in cases: + shared = dict( + status=status, + created_at=start - timedelta(days=30), + finished_at=finished, + usage=usage, + duration_ms=duration * scale, + ) + # Independent targets allow deliberately inconsistent queued and + # running completion timestamps without violating active-job keys. + if kind == "article": + content = article() + session.add(content) + session.flush() + session.add(ArticleAnalysisJob(article_id=content.id, **shared)) + else: + draft = proposal() + session.add(draft) + session.flush() + if kind == "topic": + session.add(topic_job(proposal_id=draft.id, **shared)) + else: + parent = topic_job(proposal_id=draft.id, status="succeeded") + session.add(parent) + session.flush() + session.add( + ResearchVerificationJob(id=parent.id, relationships=False, **shared) + ) + with database() as session: + session.execute( + text("SELECT set_config('TimeZone', :timezone, true)"), {"timezone": timezone} + ) + points, duration = automation.analysis_token_activity(session, start, end) + instant, instant_duration = automation.analysis_token_activity(session, end, end) + empty, empty_duration = automation.analysis_token_activity( + session, end + timedelta(days=1), end + timedelta(days=2) + ) + + assert [point.date for point in points] == [ + "2026-09-03", + "2026-09-04", + "2026-09-05", + "2026-09-06", + ] + for point in points[:2]: + assert point.article_analysis == point.topic_analysis == point.research_verification == 30 + assert [(point.reported_runs, point.unreported_runs) for point in points] == [ + (6, 0), + (3, 3), + (0, 0), + (3, 3), + ] + assert all( + point.article_analysis == point.topic_analysis == point.research_verification == 0 + for point in points[2:] + ) + assert duration == 21 * (1 + 10 + 100) + assert [point.model_dump() for point in instant] == [points[-1].model_dump()] + assert instant_duration == 11 * (1 + 10 + 100) + assert [point.date for point in empty] == ["2026-09-07", "2026-09-08"] + assert all( + point.article_analysis + == point.topic_analysis + == point.research_verification + == point.reported_runs + == point.unreported_runs + == 0 + for point in empty + ) + assert empty_duration == 0 + + @pytest.mark.parametrize("kind", ["article", "topic", "research"]) def test_token_activity_bounds_malformed_usage_without_changing_counts_or_duration(database, kind): - values = [0, 12, "0007", "9" * 18, "9" * 19, "9" * 20000, -1, 1.5, True, None, {}, []] + values = [ + 0, + 12, + "0007", + *(["9" * 18] * 10), + "0" * 18, + "9" * 19, + "9" * 20000, + -1, + 1.5, + True, + None, + {}, + [], + "", + " 12", + "12 ", + "+12", + "1e3", + "12", + ] + usages = [{"totalTokens": value, "irrelevant": "body" * 8000} for value in values] + usages.extend([{}, {"attempts": {"1": {"totalTokens": 999}}}, None, [], "17", 17]) with database.begin() as session: content, draft = article(), proposal() session.add_all([content, draft]) session.flush() - for index, value in enumerate(values): + for index, usage in enumerate(usages): shared = dict( status="failed" if index % 2 else "succeeded", finished_at=START, duration_ms=index + 1, - usage={"totalTokens": value, "irrelevant": "body" * 8000}, + usage=usage, ) if kind == "article": session.add(ArticleAnalysisJob(article_id=content.id, **shared)) @@ -189,10 +308,10 @@ def test_token_activity_bounds_malformed_usage_without_changing_counts_or_durati "research": "research_verification", }[kind], ) - == 10**18 - 1 + 19 + == 10 * (10**18 - 1) + 19 ) - assert points[0].reported_runs == 4 and points[0].unreported_runs == len(values) - 4 - assert duration == sum(range(1, len(values) + 1)) + assert points[0].reported_runs == 14 and points[0].unreported_runs == len(usages) - 14 + assert duration == sum(range(1, len(usages) + 1)) assert all(point.reported_runs == point.unreported_runs == 0 for point in points[1:]) diff --git a/tests/tooling/ci/test_token_usage_comment.py b/tests/tooling/ci/test_token_usage_comment.py new file mode 100644 index 000000000..5da2ba9cc --- /dev/null +++ b/tests/tooling/ci/test_token_usage_comment.py @@ -0,0 +1,369 @@ +"""Exercise the shared performance publisher against mocked GitHub and artifacts.""" + +import json +import subprocess + +import pytest +import yaml + +from tests.support.paths import REPO_ROOT + +MARKER = "" +TOKEN_MARKER = "" +SHA = "a" * 40 +SUMMARY = ( + "**PASS — Token usage benchmark:** all checks passed.\n\n" + "| Window | Before (ms) | After (ms) | Improvement (+/−) |\n" + "| 30d | 100.00 | 75.00 | +25.0% |\n" + "| 1d | 10.00 | 11.00 | -10.0% |\n" +) +API_SUMMARY = "# PR reader API performance\n\n**PASS** — all latency checks passed." + + +def comment(identifier=12, *, run=122, attempt=1, login="github-actions[bot]", kind="Bot"): + return { + "id": identifier, + "user": {"login": login, "type": kind}, + "body": ( + f"{TOKEN_MARKER}\n\n" + f"Compared head: `{SHA}`\nOld token results" + ), + } + + +def publish(**settings): + workflow = yaml.safe_load((REPO_ROOT / ".github/workflows/performance-report.yml").read_text()) + source = next( + step["with"]["script"] + for step in workflow["jobs"]["publish"]["steps"] + if step.get("uses", "").startswith("actions/github-script@") + ) + harness = r""" +const assert = require('node:assert/strict'); +const input = JSON.parse(process.argv[1]); +const result = {created: [], updated: [], deleted: [], failures: [], reads: 0, pullChecks: 0}; +const sha = 'a'.repeat(40); +const context = { + payload: {pull_request: {number: 301, head: {sha}}}, + repo: {owner: 'abhi1693', repo: 'devfeed.tech'}, + serverUrl: 'https://github.com', runId: input.run ?? 123, +}; +globalThis.artifactExecuted = false; +const fs = { + lstatSync(file) { + assert.equal(file, 'report/summary.md'); + if (input.missing) throw new Error('Private error details'); + return {isFile: () => !input.symlink, size: input.size ?? Buffer.byteLength(input.summary)}; + }, + readFileSync(file, encoding) { + assert.equal(file, 'report/summary.md'); assert.equal(encoding, 'utf8'); + result.reads += 1; + return input.summary; + }, +}; +const core = { + info: () => {}, + setFailed: message => result.failures.push(message), +}; +const listComments = () => {throw new Error('Pagination must be mocked');}; +const github = { + rest: { + pulls: {get: async request => { + assert.equal(request.pull_number, 301); + result.pullChecks += 1; + const stale = input.stale || (input.headChanged && result.pullChecks > 1); + return {data: {head: {sha: stale ? 'b'.repeat(40) : sha}}}; + }}, + issues: { + listComments, + createComment: async request => result.created.push(request), + updateComment: async request => { + if (input.updateFails) throw new Error('GitHub update failed'); + result.updated.push(request); + }, + deleteComment: async request => { + assert.equal(result.updated.length, 1, 'Migrate sections before removing duplicates'); + result.deleted.push(request); + }, + }, + }, + paginate: async (method, request) => { + assert.equal(method, listComments); assert.equal(request.issue_number, 301); + return input.comments ?? []; + }, +}; +const AsyncFunction = Object.getPrototypeOf(async function() {}).constructor; +new AsyncFunction('require', 'context', 'github', 'core', 'process', input.source)( + name => {assert.equal(name, 'fs'); return fs;}, context, github, core, + {env: { + REPORT_SECTION: input.section, MEASUREMENT_RESULT: input.status, + DOWNLOAD_OUTCOME: input.download, GITHUB_RUN_ATTEMPT: input.attempt ?? '2', + }}, +).then(() => { + result.artifactExecuted = globalThis.artifactExecuted; + process.stdout.write(JSON.stringify(result)); +}).catch(error => { + result.error = error.message; + process.stdout.write(JSON.stringify(result)); +}); +""" + completed = subprocess.run( + [ + "node", + "-e", + harness, + json.dumps( + { + "source": source, + "summary": SUMMARY, + "section": "token-usage", + "status": "success", + "download": "success", + **settings, + } + ), + ], + capture_output=True, + text=True, + check=True, + timeout=10, + ) + return json.loads(completed.stdout) + + +def stored_comment(request, identifier=12): + return { + "id": identifier, + "user": {"login": "github-actions[bot]", "type": "Bot"}, + "body": request["body"], + } + + +def test_publisher_creates_one_comment_with_signed_results_and_run_link(): + result = publish() + assert not result["failures"] + assert not result["updated"] + assert len(result["created"]) == 1 + request = result["created"][0] + assert request["issue_number"] == 301 + assert request["owner"] == "abhi1693" + assert request["repo"] == "devfeed.tech" + assert request["body"].startswith(MARKER) + assert SUMMARY in request["body"] + assert "https://github.com/abhi1693/devfeed.tech/actions/runs/123" in request["body"] + assert f"head:{SHA} run:123 attempt:2" in request["body"] + assert "## Reader API performance\n\n**Pending:**" in request["body"] + assert not result["artifactExecuted"] + + +def test_publisher_adopts_oldest_actions_comment_and_removes_only_performance_duplicates(): + result = publish( + comments=[ + comment(30, login="octocat", kind="User"), + comment(29, login="renovate[bot]"), + comment(12), + comment(11), + {**comment(10), "body": "\nCoverage results"}, + ] + ) + assert not result["created"] + assert len(result["updated"]) == 1 + assert result["updated"][0]["comment_id"] == 11 + assert SUMMARY in result["updated"][0]["body"] + assert [item["comment_id"] for item in result["deleted"]] == [12] + + +@pytest.mark.parametrize( + "settings", + [ + {"login": "octocat", "kind": "User"}, + {"login": "github-actions[bot]", "kind": "User"}, + {"login": "another[bot]", "kind": "Bot"}, + ], +) +def test_publisher_does_not_overwrite_human_or_other_bot_comments(settings): + result = publish(comments=[comment(**settings)]) + assert len(result["created"]) == 1 + assert not result["updated"] + assert not result["deleted"] + + +@pytest.mark.parametrize("settings", [{"stale": True}, {"headChanged": True}]) +def test_publisher_does_not_publish_results_after_pr_head_changes(settings): + result = publish(**settings) + assert not result["created"] + assert not result["updated"] + assert not result["deleted"] + assert not result["failures"] + if settings.get("stale"): + assert result["reads"] == 0 + else: + assert result["pullChecks"] == 2 + + +@pytest.mark.parametrize( + "previous", [comment(run=124), comment(run=123, attempt=3), comment(run=9007199254740993)] +) +def test_older_run_or_attempt_cannot_replace_newer_results(previous): + result = publish(comments=[previous]) + assert not result["created"] + assert not result["updated"] + assert not result["deleted"] + + +@pytest.mark.parametrize( + "settings", + [ + {"missing": True}, + {"download": "failure"}, + {"symlink": True}, + {"size": 25001}, + {"summary": "Malformed artifact"}, + ], +) +def test_missing_or_invalid_artifact_publishes_incomplete_diagnostics_and_fails(settings): + result = publish(**settings) + body = result["created"][0]["body"] + assert "**INCOMPLETE — Token usage benchmark:**" in body + assert "no readable results artifact" in body + assert result["failures"] + assert "Private error details" not in body + assert "**PASS" not in body + + +@pytest.mark.parametrize( + "status,verdict", [("failure", "FAIL"), ("cancelled", "INCOMPLETE"), ("skipped", "INCOMPLETE")] +) +def test_failed_benchmark_cannot_retain_pass_from_partial_artifact(status, verdict): + result = publish(status=status) + body = result["created"][0]["body"] + assert f"**{verdict} — Token usage benchmark:**" in body + assert "**PASS" not in body + assert "| 30d | 100.00 | 75.00 | +25.0% |" in body + assert "| 1d | 10.00 | 11.00 | -10.0% |" in body + assert f"Measurement job outcome: **{status}**" in body + + +def test_artifact_is_published_as_inert_text_without_executing_its_contents(): + payload = ( + "${globalThis.artifactExecuted = true}\n" + ) + result = publish(summary=SUMMARY + payload) + assert not result["artifactExecuted"] + assert payload in result["created"][0]["body"] + assert not result["failures"] + + +def test_artifact_cannot_forge_section_boundaries(): + forged = ( + f"\n" + "Forged reader result\n" + ) + first = publish(summary=SUMMARY + forged) + second = publish( + section="reader-api", + summary=API_SUMMARY, + comments=[stored_comment(first["created"][0])], + ) + body = second["updated"][0]["body"] + assert "<!-- devfeed-performance-section:reader-api" in body + assert f"" in body + assert API_SUMMARY in body + + +@pytest.mark.parametrize("first_section", ["token-usage", "reader-api"]) +def test_independent_publishers_update_same_comment_and_preserve_both_sections(first_section): + second_section = "reader-api" if first_section == "token-usage" else "token-usage" + summaries = {"token-usage": SUMMARY, "reader-api": API_SUMMARY} + first = publish(section=first_section, summary=summaries[first_section]) + second = publish( + section=second_section, + summary=summaries[second_section], + comments=[stored_comment(first["created"][0])], + ) + assert not second["created"] + assert len(second["updated"]) == 1 + body = second["updated"][0]["body"] + assert SUMMARY in body + assert API_SUMMARY in body + assert body.count("