Skip to content

Address follow-up review feedback on the perf test pipeline - #4497

Draft
mdaigle wants to merge 1 commit into
mainfrom
dev/automation/perf-pipeline-followup-fixes
Draft

Address follow-up review feedback on the perf test pipeline#4497
mdaigle wants to merge 1 commit into
mainfrom
dev/automation/perf-pipeline-followup-fixes

Conversation

@mdaigle

@mdaigle mdaigle commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #4473, which was merged with six non-blocking review comments outstanding. This picks those up, plus the one Copilot comment on that PR that was never resolved.

Nothing here changes what the pipeline measures or how it reports — it's hardening of the harness, the credentials it handles, and the logic the regression gate depends on.

Security / supply chain

Pin the Kusto SDK install (sqlclient-perf-pipeline.yml) — pip install --upgrade azure-kusto-data azure-kusto-ingest ran unpinned from public PyPI inside the AzureCLI@2 task, i.e. in a context already holding a token for the ADX cluster. Any newly published version of those packages or their transitive dependencies would have executed with those credentials, and perf runs would silently change whenever upstream published. Now pinned to 6.0.4 via a single KustoSdkVersion variable, with --upgrade dropped.

There's also a TODO to repoint this at the internal governed Python feed. Direct access to public PyPI is expected to be withdrawn for the org before long, following npm and NuGet — pinning now means that migration is a feed swap rather than a feed swap plus an unbounded version jump.

Keep the sa password out of the process table (run-perf-tests.sh, run-perf-tests.ps1) — sqlcmd -P <password> puts the password in the command line, which is readable by any other user on the box (/proc/<pid>/cmdline on Linux; Win32_Process / Process Explorer on Windows). Another process's environment block is not readable, and sqlcmd reads SQLCMDPASSWORD natively, so all six call sites now use that.

Pipeline reliability

Ephemeral port range (run-perf-tests.sh) — net.ipv4.ip_local_port_range=1024 65535 let the connection-churn benchmarks grab 1433 or 22 as an outbound source port, on a VM where SQL Server and sshd are both listening. That shows up as an intermittent connection failure that reads as a perf anomaly. Raised the low bound to 10000.

Python interpreter resolution (run-perf-tests.ps1) — the Windows harness called python3, which is a Unix-ism; on Windows the interpreter is python.exe or the py launcher, and a stock image ships App Execution Alias stubs that resolve via Get-Command but only open the Store. The failure was also misleading: Get-BenchmarkResultCount swallows a failed python call and returns 0, so a missing interpreter surfaced as "the run produced no benchmark results". Now resolved once up front (python3pythonpy -3), probed for real rather than trusted, and failing fast with an accurate message.

Distinguish "unknown" from "no ingestion failures" (ingest_kusto.py) — this is Copilot's unresolved comment. _dump_failures() returns None when the .show ingestion failures diagnostic itself can't run (typically a missing monitoring role), but if failures: treated that identically to a confirmed zero and printed "no ingestion failures were reported... the rows will land shortly". That's factually wrong in the None case and hides a repeatable permissions problem behind a soft, reassuring warning. The step still doesn't fail (it genuinely doesn't know), but it now says so and points at the fix.

Benchmark methodology

Pin CPU affinity before the process starts, not after (run-perf-tests.ps1, interleave_perf.py) — both harnesses started the benchmark process and then assigned affinity, leaving process startup, assembly loading, JIT and BenchmarkDotNet's own setup running on arbitrary cores, including the ones reserved for SQL Server. That's exactly the cross-talk the pinning exists to remove.

  • Windows: narrow the launching PowerShell process's affinity across Process.Start() so the child inherits it from its first instruction, then restore it immediately (the child already has the mask, and leaving the harness pinned would constrain the build and collection work that follows). The explicit assignment on the child is kept as a belt-and-braces re-assert and for the log line.
  • Linux: use preexec_fn so the pin lands between fork() and exec(), with a fallback to the old post-start path if that fails. Worth noting this was the interleaved mode, which is the default — so it was less isolated than the legacy sequential path, which correctly wraps the process in taskset.

Don't silently drop an unmappable regression (interleave_perf.py) — if a regressed benchmark Type can't be mapped back to a unit, it's never re-run, so its tally is stuck at 1. Scored against reps=3, the strict-majority test count * 2 > total_reps could then never pass, so a real regression was quietly downgraded to "unconfirmed" and slipped through the gate — a false negative caused by bookkeeping rather than by the measurement. It's now scored against the number of reps actually performed for it (so it's reported rather than discarded) and logged loudly, since it means the unit list and the reported Type names have drifted apart.

Tests

Added eng/pipelines/perf/scripts/tests/test_perf_scripts.py — 20 stdlib unittest tests over the comparison and best-of-N confirmation logic that failOnRegression depends on. No SQL Server, no BenchmarkDotNet, no ADX cluster required:

python3 -m unittest discover -s eng/pipelines/perf/scripts/tests -v

Coverage: _pct zero-baseline handling, every build_comparison status boundary (including that the threshold is exclusive, and that a 0-byte baseline allocation isn't dropped), unparseable-report tolerance, and the confirmation verdict — persistent vs. flaky regressions, strict majority, only-candidates-are-re-run, single-rep, improvements never counting as regressions, and the unmappable-regression case above.

Validation

Since this is mostly pipeline code that can't be exercised locally, I verified what I could:

  • python3 -m unittest discover — 20/20 pass, no skips
  • bash -n run-perf-tests.sh, python3 -m py_compile on all perf scripts — clean
  • PowerShell AST parse of run-perf-tests.ps1 — clean
  • Extracted Resolve-Python3 from the file via AST and executed it for real: resolves the interpreter, and both call-site invocation styles (piped stdin and argument array) work. Also verified with a fake Store-stub python3 on PATH that the stub is rejected and the chain falls through to a working python.
  • Pipeline YAML parses, KustoSdkVersion resolves

Deliberately not included

Checklist

  • Tests added or updated
  • Public API changes documented — n/a, no public API surface is touched
  • Verified against customer repro (if applicable) — n/a
  • Ensure no breaking changes introduced

Suggested release note

None — this is test-infrastructure and pipeline-only, with no effect on the shipped driver.

Follow-up to #4473. Picks up the six non-blocking review comments left on that
PR plus the one Copilot comment that was never resolved.

Security / supply chain:
- Pin the azure-kusto-data / azure-kusto-ingest install to an exact version and
  drop --upgrade. That install runs inside an authenticated AzureCLI@2 context
  holding an ADX token, so a floating install would execute newly published code
  with those credentials. Also leaves a TODO for the move to the internal
  governed feed, since direct public PyPI access is expected to go away.
- Pass the sa password to sqlcmd via SQLCMDPASSWORD instead of -P, on both the
  Linux and Windows harnesses. Process command lines are readable by other users
  on the box; environment blocks are not.

Pipeline reliability:
- Raise the ephemeral port range low bound from 1024 to 10000. SQL Server (1433)
  and sshd (22) live on the same VM, so the connection-churn benchmarks could
  take a service port as an outbound source port.
- Resolve the Python interpreter up front (python3 -> python -> py -3) and fail
  fast with an accurate message. Windows has no python3, and the Store alias
  stubs resolve but do not work. Previously this surfaced as "produced no
  benchmark results" because Get-BenchmarkResultCount swallows the failure.
- Report an "unknown" ingestion-failure query result distinctly from "no
  failures". _dump_failures returns None when the diagnostic query cannot run,
  which was being reported with reassuring "no failures were reported" wording
  and hid a repeatable permissions problem.

Benchmark methodology:
- Pin CPU affinity before the benchmark process starts rather than after.
  On Windows, narrow the launcher's affinity across Process.Start() so the child
  inherits it; on Linux, use preexec_fn so the pin lands between fork() and
  exec(). Setting affinity after the process is running leaves startup, assembly
  loading and JIT on arbitrary cores, including those reserved for SQL Server.
- Stop silently discarding a regression whose benchmark Type cannot be mapped
  back to a unit. Such a key is never re-run, so its tally is stuck at 1 and the
  strict-majority test could never confirm it, dropping a real regression. It is
  now scored against the reps actually performed and logged loudly.

Tests:
- Add stdlib unit tests for the comparison and best-of-N confirmation logic that
  the failOnRegression gate depends on. 20 tests, no SQL Server, no
  BenchmarkDotNet, no ADX required:
  python3 -m unittest discover -s eng/pipelines/perf/scripts/tests

Also adds a removal marker to the baseline-specific csproj workaround, which is
otherwise dead weight the moment the baseline version moves.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 3, 2026 17:57
@github-project-automation github-project-automation Bot moved this to To triage in SqlClient Board Aug 3, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Hardens the SqlClient perf pipeline harness under eng/pipelines/perf/ by tightening supply-chain inputs, reducing secret exposure, improving reliability of the regression gate, and adding isolated unit tests for the logic that determines pass/fail—without changing what the benchmarks measure or how results are reported.

Changes:

  • Pins Azure Data Explorer (Kusto) Python SDK versions in the pipeline and improves Kusto ingestion verification messaging for “unknown” vs. “no failures”.
  • Removes sqlcmd -P password usage (switches to SQLCMDPASSWORD), fixes Windows Python interpreter resolution, and improves CPU affinity pinning timing (pre-start).
  • Adds stdlib-only Python unittest coverage for comparison + best-of-N confirmation/gating logic, plus fixes “unmappable regression” bookkeeping in interleaved mode.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/Microsoft.Data.SqlClient/tests/PerformanceTests/Microsoft.Data.SqlClient.PerformanceTests.csproj Adds explicit TODO commentary documenting a baseline-specific dependency workaround and its removal criteria.
eng/pipelines/perf/sqlclient-perf-pipeline.yml Pins Kusto SDK install via KustoSdkVersion to improve reproducibility and reduce supply-chain risk.
eng/pipelines/perf/scripts/tests/test_perf_scripts.py Adds isolated unit tests for the perf comparison and regression-confirmation logic.
eng/pipelines/perf/scripts/run-perf-tests.sh Avoids leaking sa password via process args and fixes Linux ephemeral port range to reduce intermittent failures.
eng/pipelines/perf/scripts/run-perf-tests.ps1 Resolves a usable Python 3 interpreter on Windows, avoids password-in-args for sqlcmd, and pins CPU affinity before process start.
eng/pipelines/perf/scripts/interleave_perf.py Pins CPU affinity pre-exec on POSIX; fixes unmappable regression scoring so real regressions aren’t silently downgraded.
eng/pipelines/perf/scripts/ingest_kusto.py Distinguishes “unknown” ingestion failures (diagnostic can’t run) from confirmed “no failures” when verification times out.

Comment on lines +132 to +145
try:
proc = subprocess.Popen(cmd, cwd=cwd, env=env, stdout=log,
stderr=subprocess.STDOUT, preexec_fn=preexec)
except Exception as exc: # noqa: BLE001 - preexec_fn failure must not abort the run
if preexec is None:
raise
print(f"WARNING: could not pin CPUs {cpus} before exec ({exc}); falling back to "
f"post-start pinning.", file=sys.stderr)
proc = subprocess.Popen(cmd, cwd=cwd, env=env, stdout=log,
stderr=subprocess.STDOUT)
apply_affinity(proc, cpus)
else:
if preexec is None:
apply_affinity(proc, cpus)
@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 62.64%. Comparing base (f3db5d4) to head (3224f3a).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4497      +/-   ##
==========================================
- Coverage   64.56%   62.64%   -1.92%     
==========================================
  Files         288      283       -5     
  Lines       44046    66979   +22933     
==========================================
+ Hits        28439    41961   +13522     
- Misses      15607    25018    +9411     
Flag Coverage Δ
CI-SqlClient ?
PR-SqlClient-Project 62.64% <ø> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: To triage

Development

Successfully merging this pull request may close these issues.

2 participants