feat(api): store provider as varchar and drop the enum type

- Promote the synced shadow column into provider, dropping the enum
- Rebuild the partial unique index concurrently after the swap
- Keep provider input validation at the serializer layer
This commit is contained in:
StylusFrost
2026-05-31 22:16:43 +02:00
parent 7dc0895581
commit 64fdea2954
5 changed files with 92 additions and 66 deletions
@@ -0,0 +1,54 @@
from django.db import migrations, models
class Migration(migrations.Migration):
"""Contract step of the zero-downtime migration of Provider.provider from a
native PostgreSQL enum to varchar.
The shadow column added in 0094 has been kept in sync by the trigger and
backfilled in 0095, so it now holds the value for every row. This migration
promotes it into place: drop the trigger and the enum column, rename the
shadow column to `provider`, and drop the orphaned enum type. The column
name is preserved throughout, and varchar accepts the same string values
the enum held, so app instances running the previous release keep working
against the swapped column.
The drop/rename runs in this migration's transaction so `provider` never
disappears for readers. The partial unique index is dropped here and
rebuilt concurrently in the next migration to avoid a long write lock.
"""
dependencies = [
("api", "0095_backfill_provider_str"),
]
operations = [
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.RemoveField(
model_name="provider",
name="provider_str",
),
migrations.AlterField(
model_name="provider",
name="provider",
field=models.CharField(default="aws", max_length=50),
),
],
database_operations=[
migrations.RunSQL(
sql=(
"DROP TRIGGER IF EXISTS providers_sync_provider_str ON providers;\n"
"DROP FUNCTION IF EXISTS sync_provider_str();\n"
"DROP INDEX IF EXISTS unique_provider_uids;\n"
"ALTER TABLE providers DROP COLUMN provider;\n"
"ALTER TABLE providers RENAME COLUMN provider_str TO provider;\n"
"ALTER TABLE providers ALTER COLUMN provider SET DEFAULT 'aws';\n"
"ALTER TABLE providers ALTER COLUMN provider SET NOT NULL;\n"
"DROP TYPE provider;"
),
reverse_sql=migrations.RunSQL.noop,
),
],
),
]
@@ -0,0 +1,28 @@
from django.db import migrations
class Migration(migrations.Migration):
"""Rebuild the partial unique index on `providers` after the enum-to-varchar
contract in 0096 dropped it along with the old enum column.
Built with CREATE INDEX CONCURRENTLY (hence `atomic = False`) so the rebuild
holds no long write lock on a large table. The index keeps the name and
predicate Django expects for the existing `unique_provider_uids` constraint,
which stays in the model state untouched, so no state operation is needed.
"""
atomic = False
dependencies = [
("api", "0096_provider_enum_to_varchar_contract"),
]
operations = [
migrations.RunSQL(
sql=(
"CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS unique_provider_uids "
"ON providers (tenant_id, provider, uid) WHERE NOT is_deleted;"
),
reverse_sql="DROP INDEX CONCURRENTLY IF EXISTS unique_provider_uids;",
),
]
+4 -9
View File
@@ -40,7 +40,6 @@ from api.db_utils import (
InvitationStateEnumField,
MemberRoleEnumField,
ProcessorTypeEnumField,
ProviderEnumField,
ProviderSecretTypeEnumField,
ScanTriggerEnumField,
SeverityEnumField,
@@ -482,14 +481,10 @@ class Provider(RowLevelSecurityProtectedModel):
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
updated_at = models.DateTimeField(auto_now=True, editable=False)
is_deleted = models.BooleanField(default=False)
provider = ProviderEnumField(
choices=ProviderChoices.choices, default=ProviderChoices.AWS
)
# Transitional shadow column for the zero-downtime migration of `provider`
# from a native PostgreSQL enum to varchar. Kept in sync with `provider` by
# a DB trigger; a later migration drops the enum column and renames this one
# to take its place.
provider_str = models.CharField(max_length=50, null=True, blank=True)
# Stored as a plain varchar: the SDK is the source of truth for which
# providers are valid, so the column accepts any provider name without a
# database-level enum to keep in sync.
provider = models.CharField(max_length=50, default=ProviderChoices.AWS)
uid = models.CharField(
"Unique identifier for the provider, set by the provider",
max_length=250,
-57
View File
@@ -525,60 +525,3 @@ class TestTenantComplianceSummaryModel:
assert summary1.id != summary2.id
assert summary1.requirements_passed != summary2.requirements_passed
@pytest.mark.django_db
class TestProviderShadowColumn:
"""The provider_str shadow column is kept in sync with the provider enum
column by a database trigger, on both INSERT and UPDATE."""
def test_provider_str_populated_on_insert(self, providers_fixture):
for provider in providers_fixture:
provider.refresh_from_db()
assert provider.provider_str == provider.provider
def test_provider_str_synced_on_update(self, providers_fixture):
provider = providers_fixture[0]
provider.alias = "renamed-alias"
provider.save()
provider.refresh_from_db()
assert provider.provider_str == provider.provider
def test_backfill_populates_pre_trigger_rows(self, providers_fixture):
"""Rows created before the sync trigger existed have a NULL shadow
column; the backfill copies `provider::text` into them in batches."""
from django.db import connection
from tasks.jobs.backfill import backfill_provider_str
tenant_id = str(providers_fixture[0].tenant_id)
# Simulate rows that predate the trigger: bypass triggers for the
# session, null the shadow column, then restore normal replication so
# subsequent writes resync as in production.
with connection.cursor() as cursor:
cursor.execute("SET session_replication_role = replica")
cursor.execute("UPDATE providers SET provider_str = NULL")
cursor.execute("SET session_replication_role = origin")
for provider in providers_fixture:
provider.refresh_from_db()
assert provider.provider_str is None
result = backfill_provider_str(tenant_id, batch_size=2)
assert result["updated"] == len(providers_fixture)
for provider in providers_fixture:
provider.refresh_from_db()
assert provider.provider_str == provider.provider
def test_backfill_is_idempotent(self, providers_fixture):
"""Re-running the backfill touches no rows once the column is populated
(the trigger already keeps it in sync), so it is safe to retry."""
from tasks.jobs.backfill import backfill_provider_str
tenant_id = str(providers_fixture[0].tenant_id)
result = backfill_provider_str(tenant_id)
assert result["updated"] == 0
+6
View File
@@ -940,6 +940,12 @@ class ProviderIncludeSerializer(RLSSerializer):
class ProviderCreateSerializer(RLSSerializer, BaseWriteSerializer):
# Declared explicitly so provider validation stays at the serializer layer:
# the model column is now a plain varchar with no choices, so without this
# an unknown provider would slip through to Provider.clean() instead of
# being rejected here with `invalid_choice`.
provider = ProviderEnumSerializerField()
class Meta:
model = Provider
fields = [