Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
5b22c3d
make_full_schema: Use `--nosys` to avoid dumping `sqlite_sequence`
reivilibre Jul 29, 2026
27271f3
make_full_schema: don't rely on pip
reivilibre Jul 30, 2026
e37d487
make_full_schema: tweak example to use new minimum postgres version
reivilibre Jul 30, 2026
3186073
CI: add schema diff 'sticky comment'
reivilibre Jul 30, 2026
ecc249d
make_full_schema: improve documentation
reivilibre Jul 31, 2026
6b3e076
update_synapse_database: allow updating to an arbitrary schema version
reivilibre Jul 30, 2026
f27255d
make_full_schema: don't update past the requested schema version
reivilibre Jul 30, 2026
2fd7060
Revert "update_synapse_database: allow updating to an arbitrary schem…
reivilibre Jul 31, 2026
95c1e52
Revert "make_full_schema: don't update past the requested schema vers…
reivilibre Jul 31, 2026
f7cf1ed
Newsfile
reivilibre Jul 31, 2026
b7235d9
make_full_schema: remove \(un)restrict psql meta-commands
reivilibre Aug 6, 2026
871b9cb
fixup! CI: add schema diff 'sticky comment'
reivilibre Aug 6, 2026
22adfd4
CI: Use faketime on Python for schema stability
reivilibre Aug 6, 2026
d029c42
CI: Use a faketime Postgres database
reivilibre Aug 6, 2026
8ab5389
Revert "CI: Use faketime on Python for schema stability"
reivilibre Aug 6, 2026
a4d9bc8
Comment restrict-key
reivilibre Aug 10, 2026
4b0b8a2
Comment caller is to use `poetry run`
reivilibre Aug 10, 2026
457c885
ignore_empty is unnecessary
reivilibre Aug 10, 2026
0fcd622
Comment why we do the comment
reivilibre Aug 10, 2026
f9fdfa4
Signpost to `poetry run`
reivilibre Aug 10, 2026
2cb1487
Reword comment: if you want to build..
reivilibre Aug 10, 2026
9444ecc
Add instruction to Schema Diff comment
reivilibre Aug 11, 2026
8cf84c5
Expand --nosys comment
reivilibre Aug 11, 2026
6465da9
Cosmetic tweaks
reivilibre Aug 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
227 changes: 227 additions & 0 deletions .ci/scripts/schema_diff.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
#!/usr/bin/env python3

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This was mostly LLM'd with a few minor tweaks. Since it's not production code, I don't think it's too problematic.

It looks 'fine' to me, I'm sure it could be better-structured, but other than the few tweaks I have made I'm not sure there's anything I'm rushing to tweak.

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.

(didn't review)

#
# 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()
101 changes: 101 additions & 0 deletions .github/workflows/schema_diff.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
name: Schema Diff

on:
pull_request:
paths:
- synapse/storage/schema/*/delta/**
- synapse/storage/schema/*/full_schemas/**
- .github/workflows/schema_diff.yml

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
# Posts a GitHub PR comment that shows what the effective change to the schema is.

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.

Why do we care about having this?

@reivilibre reivilibre Aug 10, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Two things really:

  1. an excuse to run make_full_schema regularly so we hopefully spot it breaking
  2. as a review aid for schema changes — particularly when we make schema changes in a background update, or more weird ones where we have to do a complex rewrite for SQLite (example) — it's nice to see the diff of the end result and have another opportunity to spot a problem

@reivilibre reivilibre Aug 10, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Lol, I didn't intend for this, but this schema diff review aid would also have spotted that the following schema file does nothing:

https://github.com/element-hq/synapse/blob/release-v1.157/synapse/storage/schema/main/delta/78/03_remove_unused_indexes_user_filters.py

Comment thread
reivilibre marked this conversation as resolved.
# Provides an excuse to run the `make_full_schema.sh` script in CI (so we keep it working)
# and can act as a review aid for schema changes, letting you easily see the diff of the
# end result, even when background updates or complex schema deltas are present.
show-schema-diff:
name: Show schema diff
runs-on: ubuntu-latest

permissions:
pull-requests: write

steps:
- name: Start postgres with a faked clock
background: true
id: postgres
run: |
# Build a docker image with faketime
mkdir /tmp/postgres-faketime
cat > /tmp/postgres-faketime/Dockerfile <<'EOF'
FROM postgres:14-alpine
RUN apk add --no-cache libfaketime

# It seems like it could be harmful to fake the monotonic timer
# as it might prevent deadlock detection, etc.
# But not sure, just doing out of precaution.
ENV FAKETIME_DONT_FAKE_MONOTONIC=1
ENTRYPOINT ["faketime", "-f", "2001-05-25 12:42:42", "docker-entrypoint.sh"]
CMD ["postgres"]
EOF
docker build -t localhost/postgres-faketime /tmp/postgres-faketime

# Run it in the background
docker run -d --name postgres -p 5432:5432 \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_INITDB_ARGS="--lc-collate C --lc-ctype C --encoding UTF8" \
--health-cmd pg_isready --health-interval 10s \
--health-timeout 5s --health-retries 5 \
localhost/postgres-faketime

- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0

- name: Install PostgreSQL client
run: sudo apt-get -qq install postgresql-client

- uses: matrix-org/setup-python-poetry@5bbf6603c5c930615ec8a29f1b5d7d258d905aa4 # v2.0.0
with:
poetry-version: "2.4.1"
extras: "postgres"
python-version: "3.x"

- name: Wait for Postgres to be up
run: |
until [ "$(docker inspect -f '{{.State.Health.Status}}' postgres)" = healthy ]; do sleep 2; done

- name: Generate schema diff
id: schema_diff
env:
PGHOST: localhost
PGUSER: postgres
PGPASSWORD: postgres
run: |
poetry run python .ci/scripts/schema_diff.py \
--base origin/develop \
> "${{ runner.temp }}/schema_diff.md"

- name: Stop postgres
cancel: postgres

# If the generation step failed, write an error message so the sticky
# comment step still has a file to read.
- name: Ensure output file exists on failure
if: always() && steps.schema_diff.outcome == 'failure'
run: |
echo "⚠️ Schema diff generation failed. See job logs for details." \
> "${{ runner.temp }}/schema_diff.md"

- name: Post sticky PR comment
uses: marocchino/sticky-pull-request-comment@3d7b8546315c63df45a03981d50a43ec19237f80 # v3
if: always()
with:
header: schema-diff
path: ${{ runner.temp }}/schema_diff.md
1 change: 1 addition & 0 deletions changelog.d/20027.misc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Dust off `make_full_schema` and add CI using it to show schema diffs.
14 changes: 4 additions & 10 deletions docs/development/database_schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,17 +84,11 @@ reference only.
If you want to recreate these schemas, they need to be made from a database that
has had all background updates run.

To do so, use `scripts-dev/make_full_schema.sh`. This will produce new
`full.sql.postgres` and `full.sql.sqlite` files.
To do so, use the `make_full_schema.sh` script. This will produce new
`full.sql.postgres` and `full.sql.sqlite` files, after having applied ALL
schema deltas and background updates.

Ensure postgres is installed, then run:

```sh
./scripts-dev/make_full_schema.sh -p postgres_username -o output_dir/
```

NB at the time of writing, this script predates the split into separate `state`/`main`
databases so will require updates to handle that correctly.
Comment on lines -96 to -97

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.

Were these updates made so this is no longer relevant?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The comment was stale: this was addressed in matrix-org/synapse#13792

Run `poetry run scripts-dev/make_full_schema.sh -h` for usage instructions.

## Delta files

Expand Down
Loading
Loading