Files
pulse/.github/workflows/memory-regression.yml
T
rcourtman 44af307878 ci: compare memory with the latest stable release every day
Memory grew release after release with nothing comparing it (#1803):
a 256 MB LXC that ran v5 for a year needed 1 GB by v6.3. The existing
memory test only checks growth within one run.

The daily watch builds the latest stable release and main from source,
runs both for 15 minutes against the same small mock estate, and splits
steady-state resident memory into the Go heap, memory outside Go and
file pages. It fails past both 15% and 16 MiB, uploads both heap
profiles with a per-function growth list, and keeps one issue open
while it fails. It runs on a schedule rather than in the pull request
benchmarks job because a 15-minute whole-server run is too slow and
noisy to gate every PR. Mock fixtures live in the heap, so the summary
says to check internal/mock first.

Checked end to end on pulse-dev: v6.5.0 against this branch measured
123.5 and 117.2 MiB, no regression; a synthetic 40% growth exits 1.

Related to #1803
2026-10-21 14:16:15 +01:00

137 lines
5.5 KiB
YAML

name: Memory Regression Watch
# Pulse's resident memory rose release after release without anything noticing
# (#1803): a 256 MB LXC that ran v5 for a year needed 1 GB by v6.3. Nothing
# compared memory across versions, and part of the growth sat outside the Go
# heap, where heap-only tests cannot see it. This watch builds the latest stable
# release and main from source, runs both against the same small mock estate,
# and compares resident memory split into the Go heap, memory outside Go and
# file pages. Both heap profiles and their difference are uploaded, so a failure
# names what grew. SQLite page caches only fill on large databases, which a
# short mock run never builds; pkg/db's page cache tests guard those instead.
on:
schedule:
- cron: '41 4 * * *'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: memory-regression
cancel-in-progress: false
jobs:
compare:
name: Compare memory with the latest stable release
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- name: Checkout candidate
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
path: candidate
persist-credentials: false
- name: Resolve the latest stable release
id: stable
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
tag="$(gh release view --json tagName,isPrerelease --jq 'select(.isPrerelease | not) | .tagName')"
[[ "${tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "No stable release tag found: '${tag}'" >&2; exit 1; }
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
echo "Baseline: ${tag}"
- name: Checkout baseline
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ steps.stable.outputs.tag }}
path: baseline
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: candidate/go.mod
cache: false
- name: Measure both builds
env:
# Each tree builds with the toolchain its own go.mod names.
GOTOOLCHAIN: auto
PULSE_MEMORY_DURATION: '900'
run: bash candidate/scripts/memory/compare_release_memory.sh baseline candidate "$RUNNER_TEMP/memory"
- name: Publish the comparison
if: ${{ !cancelled() }}
run: |
if [[ -f "$RUNNER_TEMP/memory/summary.md" ]]; then
cat "$RUNNER_TEMP/memory/summary.md" >> "$GITHUB_STEP_SUMMARY"
else
echo "The measurement did not finish; see the run logs." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Upload memory evidence
if: ${{ !cancelled() }}
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: memory-regression
path: |
${{ runner.temp }}/memory/*.tsv
${{ runner.temp }}/memory/*.pb.gz
${{ runner.temp }}/memory/heap-growth.txt
${{ runner.temp }}/memory/summary.md
${{ runner.temp }}/memory/result.json
${{ runner.temp }}/memory/run-*.log
if-no-files-found: ignore
retention-days: 30
# Scheduled runs have no caller watching them. Keep one open issue while the
# watch fails and close it when memory is back within tolerance.
report-scheduled-result:
name: Report scheduled result
needs: compare
if: ${{ always() && github.event_name == 'schedule' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
issues: write
steps:
- name: Open, update or close the memory regression issue
shell: bash
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
RESULT: ${{ needs.compare.result }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
title="Pulse memory grew against the last stable release"
number="$(gh issue list --state open --search "\"${title}\" in:title" \
--limit 100 --json number,title \
--jq "map(select(.title == \"${title}\")) | first | .number // empty")"
case "${RESULT}" in
success)
if [[ -n "${number}" ]]; then
gh issue close "${number}" --comment "Memory is back within tolerance: ${RUN_URL}"
fi
;;
failure)
if [[ -n "${number}" ]]; then
gh issue comment "${number}" --body "It failed again: ${RUN_URL}"
else
gh issue create --title "${title}" --body "The daily memory comparison failed: ${RUN_URL}
The run summary compares main with the latest stable release on the same small mock estate, split into the Go heap, memory outside Go and file pages. The memory-regression artifact holds both heap profiles and heap-growth.txt, which lists what grew by function. Check that list for internal/mock first: mock fixtures live in the heap and are not production memory. A failure with no samples means a build or startup failed, not a memory change.
This issue closes itself when the comparison passes again."
fi
;;
*)
echo "The comparison ended as ${RESULT}; nothing to report."
;;
esac