From 39464dd7a7f4cd91aa334d9e7e7261965cc622a8 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Sun, 26 Jul 2026 11:26:42 +0200 Subject: [PATCH] fix revision --- ...3b49_add_mint_url_to_lightning_invoices.py | 37 ++++++++++++ ...ad83_add_mint_url_to_lightning_invoices.py | 37 ++++++++++++ ...8599_add_mint_url_to_lightning_invoices.py | 19 ++++-- ..._replaced_mint_revision_and_repair_fees.py | 46 +++++++++++++++ routstr/core/db.py | 43 ++------------ tests/unit/test_fee_payout_migration.py | 59 +++++++++++++++++-- 6 files changed, 196 insertions(+), 45 deletions(-) create mode 100644 migrations/versions/11eaab843b49_add_mint_url_to_lightning_invoices.py create mode 100644 migrations/versions/21c84cd5ad83_add_mint_url_to_lightning_invoices.py create mode 100644 migrations/versions/d8e6f974960a_merge_replaced_mint_revision_and_repair_fees.py diff --git a/migrations/versions/11eaab843b49_add_mint_url_to_lightning_invoices.py b/migrations/versions/11eaab843b49_add_mint_url_to_lightning_invoices.py new file mode 100644 index 00000000..6e60988e --- /dev/null +++ b/migrations/versions/11eaab843b49_add_mint_url_to_lightning_invoices.py @@ -0,0 +1,37 @@ +"""add mint url to lightning invoices + +Revision ID: 11eaab843b49 +Revises: d7e8f9a0b1c2 +Create Date: 2026-07-22 22:25:45.278261 +""" + +import sqlalchemy as sa +from alembic import op + +# This revision was deployed from the pre-merge branch. Keep it in the graph so +# those databases can migrate forward instead of being stamped past migrations. +revision = "11eaab843b49" +down_revision = "d7e8f9a0b1c2" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + columns = { + column["name"] + for column in sa.inspect(op.get_bind()).get_columns("lightning_invoices") + } + if "mint_url" not in columns: + op.add_column( + "lightning_invoices", + sa.Column("mint_url", sa.String(), nullable=True), + ) + + +def downgrade() -> None: + columns = { + column["name"] + for column in sa.inspect(op.get_bind()).get_columns("lightning_invoices") + } + if "mint_url" in columns: + op.drop_column("lightning_invoices", "mint_url") diff --git a/migrations/versions/21c84cd5ad83_add_mint_url_to_lightning_invoices.py b/migrations/versions/21c84cd5ad83_add_mint_url_to_lightning_invoices.py new file mode 100644 index 00000000..7cb0fac8 --- /dev/null +++ b/migrations/versions/21c84cd5ad83_add_mint_url_to_lightning_invoices.py @@ -0,0 +1,37 @@ +"""add mint url to lightning invoices + +Revision ID: 21c84cd5ad83 +Revises: c6d7e8f9a0b1 +Create Date: 2026-07-12 15:04:01.675455 +""" + +import sqlalchemy as sa +from alembic import op + +# This revision was deployed before the migration was recreated. Retain it so +# databases stamped with it remain connected to the migration graph. +revision = "21c84cd5ad83" +down_revision = "c6d7e8f9a0b1" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + columns = { + column["name"] + for column in sa.inspect(op.get_bind()).get_columns("lightning_invoices") + } + if "mint_url" not in columns: + op.add_column( + "lightning_invoices", + sa.Column("mint_url", sa.String(), nullable=True), + ) + + +def downgrade() -> None: + columns = { + column["name"] + for column in sa.inspect(op.get_bind()).get_columns("lightning_invoices") + } + if "mint_url" in columns: + op.drop_column("lightning_invoices", "mint_url") diff --git a/migrations/versions/c7d5f8638599_add_mint_url_to_lightning_invoices.py b/migrations/versions/c7d5f8638599_add_mint_url_to_lightning_invoices.py index 2fc7cd22..49c47665 100644 --- a/migrations/versions/c7d5f8638599_add_mint_url_to_lightning_invoices.py +++ b/migrations/versions/c7d5f8638599_add_mint_url_to_lightning_invoices.py @@ -16,10 +16,21 @@ depends_on = None def upgrade() -> None: - op.add_column( - "lightning_invoices", sa.Column("mint_url", sa.String(), nullable=True) - ) + columns = { + column["name"] + for column in sa.inspect(op.get_bind()).get_columns("lightning_invoices") + } + if "mint_url" not in columns: + op.add_column( + "lightning_invoices", + sa.Column("mint_url", sa.String(), nullable=True), + ) def downgrade() -> None: - op.drop_column("lightning_invoices", "mint_url") + columns = { + column["name"] + for column in sa.inspect(op.get_bind()).get_columns("lightning_invoices") + } + if "mint_url" in columns: + op.drop_column("lightning_invoices", "mint_url") diff --git a/migrations/versions/d8e6f974960a_merge_replaced_mint_revision_and_repair_fees.py b/migrations/versions/d8e6f974960a_merge_replaced_mint_revision_and_repair_fees.py new file mode 100644 index 00000000..3c8a090c --- /dev/null +++ b/migrations/versions/d8e6f974960a_merge_replaced_mint_revision_and_repair_fees.py @@ -0,0 +1,46 @@ +"""merge replaced mint revision and repair fee checkpoint + +Revision ID: d8e6f974960a +Revises: c7d5f8638599, 11eaab843b49, 21c84cd5ad83 +Create Date: 2026-07-26 00:00:00.000000 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "d8e6f974960a" +down_revision = ("c7d5f8638599", "11eaab843b49", "21c84cd5ad83") +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Repair nodes previously stamped past the fee checkpoint migration.""" + columns = { + column["name"] + for column in sa.inspect(op.get_bind()).get_columns("routstr_fees") + } + + if "payout_in_progress_msats" not in columns: + op.add_column( + "routstr_fees", + sa.Column( + "payout_in_progress_msats", + sa.Integer(), + nullable=False, + server_default="0", + ), + ) + + if "payout_started_at" not in columns: + op.add_column( + "routstr_fees", + sa.Column("payout_started_at", sa.Integer(), nullable=True), + ) + + +def downgrade() -> None: + # Both parent branches expect these columns when their histories are intact. + pass diff --git a/routstr/core/db.py b/routstr/core/db.py index 31fd3fe5..dda208c1 100644 --- a/routstr/core/db.py +++ b/routstr/core/db.py @@ -11,9 +11,8 @@ from typing import AsyncGenerator from alembic import command from alembic.config import Config -from alembic.util.exc import CommandError from sqlalchemy import Index, UniqueConstraint, case, delete, or_ -from sqlalchemy.exc import IntegrityError, OperationalError +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio.engine import create_async_engine from sqlalchemy.orm import aliased from sqlmodel import Field, Relationship, SQLModel, col, func, select, update @@ -790,17 +789,6 @@ def fix_cashu_migrations() -> None: logger.warning(f"Could not check/fix Cashu database {db_file}: {e}") -def _clear_alembic_version() -> None: - """Clear the alembic_version table so stamp/upgrade can proceed.""" - sync_url = DATABASE_URL.replace("+aiosqlite", "") - from sqlalchemy import create_engine, text - - eng = create_engine(sync_url) - with eng.begin() as conn: - conn.execute(text("DELETE FROM alembic_version")) - eng.dispose() - - def run_migrations() -> None: """Run Alembic migrations programmatically.""" try: @@ -822,30 +810,11 @@ def run_migrations() -> None: # Set the database URL in the config alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL) - try: - command.upgrade(alembic_cfg, "head") - except CommandError as e: - if "Can't locate revision" in str(e): - logger.warning( - "Database stamped with unknown revision (likely from another branch). " - "Re-stamping to current head.", - extra={"error": str(e)}, - ) - _clear_alembic_version() - command.stamp(alembic_cfg, "head") - else: - raise - except OperationalError as e: - if "duplicate column name" in str(e).lower(): - logger.warning( - "Migration hit a column that already exists (likely added via " - "create_all on another branch). Stamping to current head.", - extra={"error": str(e)}, - ) - _clear_alembic_version() - command.stamp(alembic_cfg, "head") - else: - raise + # Never stamp past a migration failure. Doing so makes Alembic's version + # metadata claim schema changes were applied when the physical schema may + # still be missing them. Unknown revisions must be restored or merged into + # the revision graph; other migration errors must fail startup visibly. + command.upgrade(alembic_cfg, "head") logger.info("Database migrations completed successfully") diff --git a/tests/unit/test_fee_payout_migration.py b/tests/unit/test_fee_payout_migration.py index afe4a868..a0b6f884 100644 --- a/tests/unit/test_fee_payout_migration.py +++ b/tests/unit/test_fee_payout_migration.py @@ -4,6 +4,8 @@ import subprocess import sys from pathlib import Path +import pytest + def _run_alembic(root: Path, database_url: str, revision: str) -> None: env = os.environ.copy() @@ -18,6 +20,23 @@ def _run_alembic(root: Path, database_url: str, revision: str) -> None: ) +def _run_routstr_startup_migrations(root: Path, database_url: str) -> None: + env = os.environ.copy() + env["DATABASE_URL"] = database_url + subprocess.run( + [ + sys.executable, + "-c", + "from routstr.core.db import run_migrations; run_migrations()", + ], + cwd=root, + env=env, + check=True, + capture_output=True, + text=True, + ) + + def test_fresh_node_migrates_fee_payout_schema_to_head(tmp_path: Path) -> None: root = Path(__file__).resolve().parents[2] database_path = tmp_path / "fresh-node.db" @@ -37,7 +56,7 @@ def test_fresh_node_migrates_fee_payout_schema_to_head(tmp_path: Path) -> None: "payout_in_progress_msats, payout_started_at FROM routstr_fees" ).fetchone() - assert version == ("c7d5f8638599",) + assert version == ("d8e6f974960a",) assert { "id", "accumulated_msats", @@ -77,14 +96,46 @@ def test_fee_payout_checkpoint_migration_preserves_existing_row( assert row == (5000, 1000, 123, 0, None) -def test_fee_payout_checkpoint_repair_restores_columns_missing_at_old_head( +@pytest.mark.parametrize("replaced_revision", ["21c84cd5ad83", "11eaab843b49"]) +def test_branch_update_repairs_database_stamped_with_replaced_revision( + tmp_path: Path, + replaced_revision: str, +) -> None: + root = Path(__file__).resolve().parents[2] + database_path = tmp_path / "replaced-revision.db" + database_url = f"sqlite+aiosqlite:///{database_path}" + _run_alembic(root, database_url, "c6d7e8f9a0b1") + + with sqlite3.connect(database_path) as connection: + connection.execute("ALTER TABLE lightning_invoices ADD COLUMN mint_url VARCHAR") + connection.execute( + "UPDATE alembic_version SET version_num = ?", + (replaced_revision,), + ) + connection.commit() + + _run_routstr_startup_migrations(root, database_url) + + with sqlite3.connect(database_path) as connection: + version = connection.execute( + "SELECT version_num FROM alembic_version" + ).fetchone() + columns = { + row[1] for row in connection.execute("PRAGMA table_info(routstr_fees)") + } + + assert version == ("d8e6f974960a",) + assert {"payout_in_progress_msats", "payout_started_at"} <= columns + + +def test_fee_payout_checkpoint_repair_restores_columns_missing_at_previous_head( tmp_path: Path, ) -> None: root = Path(__file__).resolve().parents[2] database_path = tmp_path / "migration.db" database_url = f"sqlite+aiosqlite:///{database_path}" - old_head = "7f2843d3f4e4" - _run_alembic(root, database_url, old_head) + previous_head = "c7d5f8638599" + _run_alembic(root, database_url, previous_head) # Reproduce a database that was stamped to head after a duplicate-column or # unknown-revision recovery skipped part of the migration chain.