Compare commits

...
Author SHA1 Message Date
César Arroba 5be514a991 fix(api): fail migrations fast when blocked on a lock
Set lock_timeout on the migration session so a metadata-only migration
blocked waiting for ACCESS EXCLUSIVE on a busy table fails loudly instead
of queueing every later reader behind it. Configurable via
DJANGO_MIGRATION_LOCK_TIMEOUT (default 5s, 0 disables it).
2026-07-15 18:49:48 +02:00
Prowler Botandprowler-bot 0d899b3076 chore(release): Bump versions to v5.35.0 (#12004)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-07-15 18:16:32 +02:00
12 changed files with 218 additions and 9 deletions
+1 -1
View File
@@ -158,7 +158,7 @@ SENTRY_RELEASE=local
# REO_DEV_CLIENT_ID=
#### Prowler release version ####
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.34.0
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.35.0
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
@@ -0,0 +1 @@
Database migrations now give up after a few seconds when another query holds the table lock, instead of stalling every request that touches that table
+1 -1
View File
@@ -71,7 +71,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.35.0"
version = "1.36.0"
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
# target-version tracks this project's lowest supported Python.
@@ -0,0 +1,28 @@
from config.env import env
from django.core.management.commands.migrate import Command as MigrateCommand
from django.db import connections
# Any value Postgres accepts for lock_timeout ("5s", "500ms", ...). "0" disables
# it, the escape hatch for a release whose DDL is expected to wait.
MIGRATION_LOCK_TIMEOUT = env.str("DJANGO_MIGRATION_LOCK_TIMEOUT", default="5s")
SET_LOCK_TIMEOUT_QUERY = "SELECT set_config('lock_timeout', %s, FALSE);"
class Command(MigrateCommand):
help = (
f"{MigrateCommand.help} Applies lock_timeout to the migration session so DDL "
"blocked on a lock fails instead of queueing every later reader behind it."
)
def handle(self, *args, **options):
connection = connections[options["database"]]
# is_local=FALSE keeps the setting alive across each migration's own
# transaction. Unlike the RLS tenant variable this is safe to leave on the
# session: migrate owns its connection for the life of the process and
# never returns it to a pool.
with connection.cursor() as cursor:
cursor.execute(SET_LOCK_TIMEOUT_QUERY, [MIGRATION_LOCK_TIMEOUT])
return super().handle(*args, **options)
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.35.0
version: 1.36.0
description: |-
Prowler API specification.
@@ -0,0 +1,179 @@
from unittest.mock import Mock, patch
import psycopg2
import pytest
from django.core.management import call_command
from django.core.management.commands.migrate import Command as DjangoMigrateCommand
from django.db import DEFAULT_DB_ALIAS, OperationalError, connections
from django.db.migrations import Migration
from django.db.migrations.operations.special import RunSQL
from django.db.migrations.state import ProjectState
def _show_lock_timeout(alias: str = DEFAULT_DB_ALIAS) -> str:
with connections[alias].cursor() as cursor:
cursor.execute("SHOW lock_timeout;")
return cursor.fetchone()[0]
@pytest.mark.django_db
class TestMigrateLockTimeout:
def test_api_migrate_command_shadows_django_builtin(self):
from django.core.management import get_commands
assert get_commands()["migrate"] == "api"
def test_lock_timeout_is_set_before_django_migrates(self):
observed = {}
def fake_handle(_self, *args, **options):
observed["lock_timeout"] = _show_lock_timeout()
with patch.object(DjangoMigrateCommand, "handle", fake_handle):
call_command("migrate")
assert observed["lock_timeout"] == "5s"
def test_lock_timeout_value_is_configurable(self):
observed = {}
def fake_handle(_self, *args, **options):
observed["lock_timeout"] = _show_lock_timeout()
with (
patch(
"api.management.commands.migrate.MIGRATION_LOCK_TIMEOUT",
"250ms",
),
patch.object(DjangoMigrateCommand, "handle", fake_handle),
):
call_command("migrate")
assert observed["lock_timeout"] == "250ms"
def test_lock_timeout_zero_disables_the_timeout(self):
observed = {}
def fake_handle(_self, *args, **options):
observed["lock_timeout"] = _show_lock_timeout()
with (
patch("api.management.commands.migrate.MIGRATION_LOCK_TIMEOUT", "0"),
patch.object(DjangoMigrateCommand, "handle", fake_handle),
):
call_command("migrate")
assert observed["lock_timeout"] == "0"
def test_lock_timeout_applies_to_the_requested_database(self):
with patch.object(DjangoMigrateCommand, "handle", Mock(return_value=None)):
call_command("migrate", database=DEFAULT_DB_ALIAS)
assert _show_lock_timeout() == "5s"
@pytest.mark.django_db(transaction=True)
class TestMigrateLockTimeoutFailureMode:
"""
Exercises the real rollback semantics against Postgres: a migration blocked on
a lock must fail fast and leave nothing half-applied.
"""
table = "lock_timeout_probe"
@pytest.fixture
def blocked_table(self, settings):
with connections[DEFAULT_DB_ALIAS].cursor() as cursor:
cursor.execute(f"DROP TABLE IF EXISTS {self.table};")
cursor.execute(f"CREATE TABLE {self.table} (id integer);")
db = settings.DATABASES[DEFAULT_DB_ALIAS]
blocker = psycopg2.connect(
dbname=db["NAME"],
user=db["USER"],
password=db["PASSWORD"],
host=db["HOST"],
port=db["PORT"],
)
with blocker.cursor() as cursor:
cursor.execute(f"LOCK TABLE {self.table} IN ACCESS EXCLUSIVE MODE;")
yield
blocker.rollback()
blocker.close()
with connections[DEFAULT_DB_ALIAS].cursor() as cursor:
cursor.execute(f"DROP TABLE IF EXISTS {self.table};")
def _column_exists(self, column: str) -> bool:
with connections[DEFAULT_DB_ALIAS].cursor() as cursor:
cursor.execute(
"SELECT 1 FROM information_schema.columns "
"WHERE table_name = %s AND column_name = %s;",
[self.table, column],
)
return cursor.fetchone() is not None
def _apply(self, atomic: bool):
connection = connections[DEFAULT_DB_ALIAS]
with connection.cursor() as cursor:
cursor.execute("SELECT set_config('lock_timeout', '250ms', FALSE);")
migration = type(
"ProbeMigration",
(Migration,),
{
"atomic": atomic,
"operations": [
RunSQL(f"CREATE TABLE {self.table}_first (id integer);"),
RunSQL(f"ALTER TABLE {self.table} ADD COLUMN blocked integer;"),
],
},
)("probe", "api")
try:
with connection.schema_editor(atomic=migration.atomic) as schema_editor:
migration.apply(ProjectState(), schema_editor, collect_sql=False)
finally:
with connection.cursor() as cursor:
cursor.execute("SELECT set_config('lock_timeout', '0', FALSE);")
def _first_table_exists(self) -> bool:
with connections[DEFAULT_DB_ALIAS].cursor() as cursor:
cursor.execute("SELECT to_regclass(%s);", [f"{self.table}_first"])
return cursor.fetchone()[0] is not None
def _drop_first_table(self):
with connections[DEFAULT_DB_ALIAS].cursor() as cursor:
cursor.execute(f"DROP TABLE IF EXISTS {self.table}_first;")
def test_atomic_migration_rolls_back_entirely(self, blocked_table):
# Fixture holds the lock the migration blocks on; pytest injects it by
# parameter name, so we reference it explicitly to keep static
# analysers from flagging it as unused.
del blocked_table
try:
with pytest.raises(OperationalError, match="lock timeout"):
self._apply(atomic=True)
assert not self._column_exists("blocked")
# The whole migration is one transaction, so the operation that ran
# before the blocked one is rolled back too: nothing half-applied.
assert not self._first_table_exists()
finally:
self._drop_first_table()
def test_non_atomic_migration_keeps_earlier_operations(self, blocked_table):
del blocked_table
try:
with pytest.raises(OperationalError, match="lock timeout"):
self._apply(atomic=False)
assert not self._column_exists("blocked")
# atomic = False has no surrounding transaction, so earlier operations
# survive while the migration stays unrecorded. Re-running it replays
# them. This is inherent to atomic = False, not to lock_timeout, but
# lock_timeout makes it reachable more often.
assert self._first_table_exists()
finally:
self._drop_first_table()
Generated
+1 -1
View File
@@ -4762,7 +4762,7 @@ dependencies = [
[[package]]
name = "prowler-api"
version = "1.35.0"
version = "1.36.0"
source = { virtual = "." }
dependencies = [
{ name = "cartography" },
@@ -128,8 +128,8 @@ To update the environment file:
Edit the `.env` file and change version values:
```env
PROWLER_UI_VERSION="5.33.0"
PROWLER_API_VERSION="5.33.0"
PROWLER_UI_VERSION="5.34.0"
PROWLER_API_VERSION="5.34.0"
```
<Note>
+1 -1
View File
@@ -49,7 +49,7 @@ class _MutableTimestamp:
timestamp = _MutableTimestamp(datetime.today())
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
prowler_version = "5.34.0"
prowler_version = "5.35.0"
html_logo_url = "https://github.com/prowler-cloud/prowler/"
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
+1 -1
View File
@@ -125,7 +125,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
name = "prowler"
readme = "README.md"
requires-python = ">=3.10,<3.14"
version = "5.34.0"
version = "5.35.0"
[project.scripts]
prowler = "prowler.__main__:prowler"
@@ -223,6 +223,7 @@ FINDINGS_TABLE_PARTITION_MAX_AGE_MONTHS = env.int("...", None) # Optional clean
| `DJANGO_DELETION_BATCH_SIZE` | `5000` | Batch size for deletions |
| `DJANGO_LOGGING_LEVEL` | `INFO` | Log level |
| `DJANGO_LOGGING_FORMATTER` | `ndjson` | Log format (`ndjson` or `human_readable`) |
| `DJANGO_MIGRATION_LOCK_TIMEOUT` | `5s` | `lock_timeout` for the `migrate` command only; `0` disables it |
---
Generated
+1 -1
View File
@@ -3553,7 +3553,7 @@ wheels = [
[[package]]
name = "prowler"
version = "5.34.0"
version = "5.35.0"
source = { editable = "." }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },