Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d64242c
feat(alembic): safely create alembic_version table and add the correc…
wgresshoff Jan 14, 2026
a2a030f
docs(copyright): add university of Münster copyright information
wgresshoff Jan 14, 2026
8c7f6d8
test: replace cli tests by sqlalchemy/alembic tests
wgresshoff Jan 16, 2026
5226790
shared: add UTCDateTime column type
utnapischtim Dec 16, 2024
bf6dc41
fix: docs reference target not found
utnapischtim Dec 16, 2024
7ee0ed8
db: fix warning
utnapischtim Jan 7, 2025
1707318
UTCDateTime: handle more cases
utnapischtim Jan 7, 2025
4d70c89
db: add Timestamp class
utnapischtim Jan 7, 2025
cdd0c21
fix: docs reference target not found
utnapischtim Jan 7, 2025
7785403
change(utc): use always timezone.utc
utnapischtim Sep 24, 2025
6b64cf4
fix: str of datetime
utnapischtim Jan 22, 2026
6984d01
chore(black): apply changes for black>=26
utnapischtim Jan 22, 2026
ccf9117
release: v2.2.0
utnapischtim Jan 27, 2026
af41b77
fix(setup): pin sqlalchemy-continuum
utnapischtim Jan 30, 2026
12e628c
release: v2.2.1
utnapischtim Jan 30, 2026
e8e15d0
fix(config): use UTC for PostgreSQL
palkerecsenyi Feb 23, 2026
8f8267a
feat(alembic): set lock_timeout with retry on migration connections
slint Feb 18, 2026
6f70902
docs(sphinx): ignore unresolved Flask-Alembic type refs
slint Feb 18, 2026
52f0e00
tests(utc): add unit tests for UTCDateTime and Postgres configured ti…
palkerecsenyi Feb 24, 2026
5efacd1
release: v2.3.0
palkerecsenyi Feb 24, 2026
5787af8
docs(copyright): add university of Münster copyright information
wgresshoff Jan 14, 2026
61217c0
fix: fix copyright statement
wgresshoff Feb 26, 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
26 changes: 25 additions & 1 deletion CHANGES.rst
Original file line number Diff line number Diff line change
@@ -1,14 +1,38 @@
..
This file is part of Invenio.
Copyright (C) 2015-2024 CERN.
Copyright (C) 2024-2025 Graz University of Technology.
Copyright (C) 2024-2026 Graz University of Technology.

Invenio is free software; you can redistribute it and/or modify it
under the terms of the MIT License; see LICENSE file for more details.

Changes
=======

Version v2.3.0 (released 2026-02-24)

- fix(config): use UTC for PostgreSQL
- feat(alembic): set lock_timeout with retry on migration connections
- docs(sphinx): ignore unresolved Flask-Alembic type refs
- tests(utc): add unit tests for UTCDateTime

Version v2.2.1 (released 2026-01-30)

- fix(setup): pin sqlalchemy-continuum

Version v2.2.0 (released 2026-01-27)

- chore(black): apply changes for black>=26
- fix: str of datetime
- change(utc): use always timezone.utc
- fix: docs reference target not found
- db: add Timestamp class
- UTCDateTime: handle more cases
- db: fix warning
- fix: docs reference target not found
- shared: add UTCDateTime column type
- chore: add nitpick_ignore to fix CI

Version v2.1.2 (released 2025-11-17)

- fix(setup): pin Flask-Alembic due to breaking changes in v3.2.0 causing
Expand Down
11 changes: 8 additions & 3 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@

"""Sphinx configuration."""


from invenio_db import __version__

# -- General configuration ------------------------------------------------
Expand Down Expand Up @@ -308,12 +307,14 @@


# Example configuration for intersphinx: refer to the Python standard library.
intersphinx_mapping = {"python": ("https://docs.python.org/3", None)}
intersphinx_mapping = {
"python": ("https://docs.python.org/3", None),
"sqlalchemy": ("https://docs.sqlalchemy.org/en/latest/", None),
}

# Autodoc configuraton.
autoclass_content = "both"


# To address <unknown>:1:py:class reference target not found
# (better ideas welcomed)
nitpick_ignore = [
Expand All @@ -323,4 +324,8 @@
("py:class", "dict[str"),
("py:class", "flask_sqlalchemy.query.Query"),
("py:class", "flask_sqlalchemy.extension._FSA_MCT"),
("py:class", "TypeDecorator"),
("py:class", "sqlalchemy.sql.sqltypes.DateTime"),
("py:class", "ExternalType"),
("py:class", "UserDefinedType"),
]
4 changes: 2 additions & 2 deletions invenio_db/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
#
# This file is part of Invenio.
# Copyright (C) 2015-2024 CERN.
# Copyright (C) 2024 Graz University of Technology.
# Copyright (C) 2024-2026 Graz University of Technology.
#
# Invenio is free software; you can redistribute it and/or modify it
# under the terms of the MIT License; see LICENSE file for more details.
Expand Down Expand Up @@ -92,7 +92,7 @@ class User(db.Model):
from .ext import InvenioDB
from .shared import db

__version__ = "2.1.2"
__version__ = "2.3.0"

__all__ = (
"__version__",
Expand Down
81 changes: 79 additions & 2 deletions invenio_db/ext.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,104 @@
# This file is part of Invenio.
# Copyright (C) 2015-2018 CERN.
# Copyright (C) 2022 RERO.
# Copyright (C) 2022 Graz University of Technology.
# Copyright (C) 2022-2026 Graz University of Technology.
#
# Invenio is free software; you can redistribute it and/or modify it
# under the terms of the MIT License; see LICENSE file for more details.

"""Database management for Invenio."""

import logging
import os
import random
import time
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as package_version
from importlib.resources import files

import sqlalchemy as sa
from flask import current_app
from flask_alembic import Alembic
from invenio_base.utils import entry_points
from sqlalchemy.exc import OperationalError
from sqlalchemy_utils.functions import get_class_by_table

from .cli import db as db_cmd
from .shared import db
from .utils import versioning_models_registered

logger = logging.getLogger(__name__)


class InvenioAlembic(Alembic):
"""Alembic with PostgreSQL lock_timeout and retry for safe migrations.

Sets lock_timeout on migration connections so DDL statements fail fast
instead of blocking reads indefinitely while waiting for exclusive locks.
On lock timeout, retries with exponential backoff. Already-applied
migrations are skipped on retry (transaction_per_migration stamps each
step).

See https://postgres.ai/blog/20210923-zero-downtime-postgres-schema-migrations-lock-timeout-and-retries

Configuration:

- ``DB_MIGRATION_LOCK_TIMEOUT``: PostgreSQL lock_timeout value
(default ``"1s"``). Set to ``"0"`` to disable.
- ``DB_MIGRATION_LOCK_TIMEOUT_RETRIES``: number of retries on lock
timeout (default ``5``).
"""

def __init__(self, *args, **kwargs):
"""Initialize InvenioAlembic."""
super().__init__(*args, **kwargs)

def _set_lock_timeout(self):
"""Set lock_timeout on all PostgreSQL migration connections."""
for ctx in self.migration_contexts.values():
if (
ctx.connection is not None
and ctx.connection.dialect.name == "postgresql"
):
timeout = current_app.config.get("DB_MIGRATION_LOCK_TIMEOUT", "1s")
ctx.connection.execute(
sa.text("SELECT set_config('lock_timeout', :val, false)"),
{"val": timeout},
)

def run_migrations(self, fn, **kwargs):
"""Run migrations with lock_timeout and retry on lock failure."""
max_retries = current_app.config.get("DB_MIGRATION_LOCK_TIMEOUT_RETRIES", 5)

for attempt in range(max_retries + 1):
self._set_lock_timeout()
try:
super().run_migrations(fn, **kwargs)
return
except OperationalError as e:
is_lock_timeout = hasattr(e.orig, "pgcode") and e.orig.pgcode == "55P03"
if not is_lock_timeout or attempt >= max_retries:
raise
# Exponential backoff with jitter
delay = min(30, 0.5 * 2**attempt) * (0.5 + random.random() * 0.5)
logger.warning(
"Migration lock timeout, retrying in %.1fs (%d/%d)",
delay,
attempt + 1,
max_retries,
)
time.sleep(delay)
# Clear cached contexts — connection is dead after the error.
# Next access to migration_contexts creates fresh connections.
self._get_cache().clear()


class InvenioDB(object):
"""Invenio database extension."""

def __init__(self, app=None, **kwargs):
"""Extension initialization."""
self.alembic = Alembic(run_mkdir=False, command_name="alembic")
self.alembic = InvenioAlembic(run_mkdir=False, command_name="alembic")
if app:
self.init_app(app, **kwargs)

Expand Down Expand Up @@ -76,6 +146,13 @@ def init_db(self, app, entry_point_group="invenio_db.models", **kwargs):
app.config.setdefault("SQLALCHEMY_ECHO", False)
# Needed for before/after_flush/commit/rollback events
app.config.setdefault("SQLALCHEMY_TRACK_MODIFICATIONS", True)
app.config.setdefault(
"SQLALCHEMY_ENGINE_OPTIONS",
# Ensure the database is using the UTC timezone for interpreting timestamps (Postgres only).
# This overrides any default setting (e.g. in postgresql.conf). Invenio expects the DB to receive
# and provide UTC timestamps in all cases, so it's important that this doesn't get changed.
{"connect_args": {"options": "-c timezone=UTC"}},
)

# Initialize Flask-SQLAlchemy extension.
database = kwargs.get("db", db)
Expand Down
3 changes: 1 addition & 2 deletions invenio_db/proxies.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
# -*- coding: utf-8 -*-
#
# This file is part of Invenio.
# Copyright (C) 2022 Graz University of Technology.
# Copyright (C) 2022-2026 Graz University of Technology.
#
# Invenio is free software; you can redistribute it and/or modify it
# under the terms of the MIT License; see LICENSE file for more details.

"""Helper proxy to the state object."""


from flask import current_app
from werkzeug.local import LocalProxy

Expand Down
99 changes: 98 additions & 1 deletion invenio_db/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,20 @@
#
# This file is part of Invenio.
# Copyright (C) 2015-2018 CERN.
# Copyright (C) 2024-2026 Graz University of Technology.
#
# Invenio is free software; you can redistribute it and/or modify it
# under the terms of the MIT License; see LICENSE file for more details.

"""Shared database object for Invenio."""

from datetime import datetime, timezone

from flask_sqlalchemy import SQLAlchemy as FlaskSQLAlchemy
from sqlalchemy import MetaData, event, util
from sqlalchemy import Column, MetaData, event, util
from sqlalchemy.engine import Engine
from sqlalchemy.sql import text
from sqlalchemy.types import DateTime, TypeDecorator
from werkzeug.local import LocalProxy

NAMING_CONVENTION = util.immutabledict(
Expand All @@ -29,9 +33,102 @@
"""Default database metadata object holding associated schema constructs."""


class UTCDateTime(TypeDecorator):
"""Custom UTC datetime type."""

impl = DateTime(timezone=True)

# todo: should be discussed, but has to be set explicitly to remove warning
cache_ok = False

def process_bind_param(self, value, dialect):
"""Process value storing into database."""
if value is None:
return value

if isinstance(value, str):
if " " in value:
value = value.replace(" ", "T")
value = datetime.strptime(value[0:19], "%Y-%m-%dT%H:%M:%S")

if not isinstance(value, datetime):
msg = f"ERROR: value: {value} is not of type datetime, instead of type: {type(value)}"
raise ValueError(msg)

if value.tzinfo not in (None, timezone.utc):
msg = f"Error: value: {value}, tzinfo: {value.tzinfo} doesn't have a tzinfo of None or timezone.utc."
raise ValueError(msg)

return value.replace(tzinfo=timezone.utc)

def process_result_value(self, value, dialect):
"""Process value retrieving from database."""
if value is None:
return None

if not isinstance(value, datetime):
msg = f"ERROR: value: {value} is not of type datetime."
raise ValueError(msg)

if value.tzinfo not in (None, timezone.utc):
msg = (
f"Error: value: {value} doesn't have a tzinfo of None or timezone.utc."
)
raise ValueError(msg)

return value.replace(tzinfo=timezone.utc)


class Timestamp:
"""Adds `created` and `updated` columns to a derived declarative model.

The `created` column is handled through a default and the `updated`
column is handled through a `before_update` event that propagates
for all derived declarative models.

::

from invenio_db import db
class SomeModel(Base, db.Timestamp):
__tablename__ = "somemodel"
id = sa.Column(sa.Integer, primary_key=True)
"""

created = Column(
UTCDateTime,
default=lambda: datetime.now(tz=timezone.utc),
nullable=False,
)
updated = Column(
UTCDateTime,
default=lambda: datetime.now(tz=timezone.utc),
nullable=False,
)


@event.listens_for(Timestamp, "before_update", propagate=True)
def timestamp_before_update(mapper, connection, target):
"""Update timestamp on before_update event.

When a model with a timestamp is updated; force update the updated
timestamp.
"""
target.updated = datetime.now(tz=timezone.utc)


class SQLAlchemy(FlaskSQLAlchemy):
"""Implement or overide extension methods."""

def __getattr__(self, name):
"""Get attr."""
if name == "UTCDateTime":
return UTCDateTime

if name == "Timestamp":
return Timestamp

return super().__getattr__(name)

def apply_driver_hacks(self, app, sa_url, options):
"""Call before engine creation."""
# Don't forget to apply hacks defined on parent object.
Expand Down
Loading