Files
synapse/.ci/scripts/schema_diff.py
T
Olivier 'reivilibreandGitHub d80a4e69da Dust off make_full_schema and add CI using it to show schema diffs. (#20027)
It seems a lot of time in our trial tests goes towards setting up the
database. (The same is probably true of Complement too)

We haven't done a full schema for about 20 schema versions, so no
surprise!

As a result, I want to produce a full schema soon.

In this PR I dust off `make_full_schema.sh` (which seems to have broken
after some SQLite changes)
and add a CI workflow that runs it (producing a diff) when someone
changes the schema.

The CI workflow also adds a sticky comment showing the diff on the
schema, so you can better appreciate the final effect of a change.

---

**Dead changes:**

I wanted to make it possible to generate a versioned full schema without
the manual work,
but you can't run the background updates without essentially starting up
a homeserver,
at which point it might fail because you haven't run all the deltas yet.

There's no actual good way to do this, short of deleting the latest
deltas (+ tweaking code to not crash without them)
or rolling back in the git history.

Backed out those changes, but they're preserved on the PR if interesting.

---------

Signed-off-by: Olivier 'reivilibre <oliverw@matrix.org>
2026-08-11 12:55:59 +01:00

228 lines
7.0 KiB
Python
Executable File

#!/usr/bin/env python3
#
# Get a diff showing the change in the database schema.
#
# Usage:
# # Compare against develop (default):
# PGUSER=postgres PGPASSWORD=postgres scripts-dev/schema_diff.py
#
# # Compare against a specific branch/commit:
# PGUSER=postgres PGPASSWORD=postgres scripts-dev/schema_diff.py --base origin/release-v1.100
import argparse
import os
import subprocess
import sys
import tempfile
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
SCHEMA_DIR = "synapse/storage/schema"
MAKE_FULL_SCHEMA_SCRIPT = REPO_ROOT / "scripts-dev" / "make_full_schema.sh"
def run_make_full_schema(output_dir: Path) -> None:
"""Run make_full_schema.sh, piping the password via stdin."""
pg_user = os.environ.get("PGUSER", "")
pg_password = os.environ.get("PGPASSWORD", "")
if not pg_user:
print("ERROR: PGUSER environment variable not set.", file=sys.stderr)
sys.exit(1)
if not pg_password:
print("ERROR: PGPASSWORD environment variable not set.", file=sys.stderr)
sys.exit(1)
cmd: list[str] = [
"poetry",
"run",
str(MAKE_FULL_SCHEMA_SCRIPT),
"-p",
pg_user,
"-o",
str(output_dir),
"-c",
"-n",
"9999",
]
print(f"Running: {' '.join(cmd)}", file=sys.stderr)
proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
cwd=REPO_ROOT,
text=True,
)
stdout, _ = proc.communicate(input=pg_password + "\n")
# Forward script output to stderr so stdout stays clean for markdown
if stdout:
print(stdout, file=sys.stderr, end="")
if proc.returncode != 0:
print(
f"ERROR: make_full_schema.sh failed with exit code {proc.returncode}",
file=sys.stderr,
)
sys.exit(proc.returncode)
def diff_schemas(
before_dir: Path, after_dir: Path, before_ref: str, after_ref: str
) -> str:
"""Diff SQLite and Postgres full schemas, return a Markdown report."""
parts: list[str] = [
"## Schema Diff",
"",
"Please check that this looks as expected!",
"",
]
for db in ["common", "main", "state"]:
for engine in ["sqlite", "postgres"]:
filename = f"full.sql.{engine}"
before_file = before_dir / db / "full_schemas" / "9999" / filename
after_file = after_dir / db / "full_schemas" / "9999" / filename
if not before_file.exists():
raise RuntimeError(f"No before file found for {db = }, {engine = }")
if not after_file.exists():
raise RuntimeError(f"No after file found for {db = }, {engine = }")
result = subprocess.run(
["diff", "-U", "10", str(before_file), str(after_file)],
capture_output=True,
text=True,
)
if result.returncode == 0:
parts.append(f"### {db} ({engine})\n\nUnchanged\n")
else:
parts.append(f"### {db} ({engine})\n\n```diff\n{result.stdout}\n```\n")
return "\n".join(parts)
def main() -> None:
parser = argparse.ArgumentParser(description="Show database schema changes")
parser.add_argument(
"--base",
default="develop",
help="Base commit/branch to compare against (default: develop)",
)
args = parser.parse_args()
# Create temp output directory with before/after subdirectories
with tempfile.TemporaryDirectory(prefix="schema_diff_") as tmpdir:
after_dir = Path(tmpdir) / "after"
before_dir = Path(tmpdir) / "before"
after_dir.mkdir()
before_dir.mkdir()
print("\n--- Running make_full_schema.sh (after) ---", file=sys.stderr)
run_make_full_schema(after_dir)
# Checkout base and run make_full_schema.sh
print(
f"\n--- Checking out {args.base} and running make_full_schema.sh (before) ---",
file=sys.stderr,
)
# Save current ref so we can return to it without detaching.
# (Not useful in CI, but is useful for local development.)
# If we are on a named branch, use the branch name; otherwise use the SHA.
head_ref = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True,
text=True,
cwd=REPO_ROOT,
check=True,
).stdout.strip()
before_sha = subprocess.run(
["git", "rev-parse", args.base],
capture_output=True,
text=True,
cwd=REPO_ROOT,
check=True,
).stdout.strip()
after_sha = subprocess.run(
["git", "rev-parse", "HEAD"],
capture_output=True,
text=True,
cwd=REPO_ROOT,
check=True,
).stdout.strip()
# Check if working tree is dirty before potentially stashing
status = subprocess.run(
[
"git",
"status",
# Machine-readable output for easy parsing
"--porcelain",
],
capture_output=True,
text=True,
cwd=REPO_ROOT,
check=True,
).stdout.strip()
did_stash = False
if status:
print("Stashing local changes before checkout...", file=sys.stderr)
subprocess.run(
[
"git",
"stash",
"push",
"--include-untracked",
"-m",
"schema_diff temporary stash",
],
cwd=REPO_ROOT,
check=True,
)
did_stash = True
try:
subprocess.run(["git", "checkout", args.base], cwd=REPO_ROOT, check=True)
# Refresh dependencies
print("Installing dependencies for base commit...", file=sys.stderr)
subprocess.run(
["poetry", "install", "--no-root", "--extras", "postgres"],
cwd=REPO_ROOT,
check=True,
# Poetry install is noisy, so pipe its stdout to stderr
stdout=sys.stderr,
)
run_make_full_schema(before_dir)
finally:
print("Returning to HEAD...", file=sys.stderr)
subprocess.run(
[
"git",
"checkout",
head_ref,
],
cwd=REPO_ROOT,
check=True,
)
if did_stash:
subprocess.run(["git", "stash", "pop"], cwd=REPO_ROOT, check=True)
print("✓ Restored stashed changes.", file=sys.stderr)
# Diff
print("\n--- Diffing schemas ---", file=sys.stderr)
markdown = diff_schemas(before_dir, after_dir, before_sha, after_sha)
print(markdown)
if __name__ == "__main__":
main()