Add new check for indexing with atomic=True

Briefly, this check helps prevent a long-running index creation from
preventing all reads and writes to a table.

This supplements an existing check
==================================
There is an existing check, `CREATE_INDEX` which warns about creating
indices without the `CONCURRENTLY` argument. It's a helpful check!

However, there are some valid reasons to prefer non-concurrent index
creation. For example, concurrent index creation may struggle to
complete when applied to write-heavy tables (since Postgres will have to
make repeated passes on the table to accommodate the new reads since the
last round).

The danger of `EXCLUSIVE` locks
===============================
If you *do* want to create indices nonconcurrently, there is still a
**huge footgun** to be aware of -- locks which are held in a transaction
are not released until the transaction closes.

Dangerous Django defaults
=========================
By default, any time you add a new Django field with `db_index=True` or
`unique=True`, the automatically generated `Migration` has:

- implicit setting of `atomic=True` (transactions are on)
- an `ALTER TABLE...ADD COLUMN` call (exclusively locks the table)
- a `CREATE INDEX` invocation (*hopefully* quick, due to nullable
  columns)

It's even more dangerous if you combine an `ALTER TABLE` call with
`CREATE INDEX` of an existing, non-nullable column.

Favor `atomic=False` if you must index
======================================
Index-creating migrations should have few `operations`. Since index
creation does not modify row-level data (and only requires locking
against writes), there's no reason to use transactions.

It's prudent to bypass the default `atomic=True` for index creation.
This commit is contained in:
David Cain authored and David Wobrock committed 2023-07-09 14:42:35 +02:00
1 parent 9fdfa06845
commit 0c5519d9dc
11 files changed
+119

No files matched your search

+1
View File
@@ -15,3 +15,4 @@ ADD_MANYTOMANY_FIELD = "app_add_manytomany_field"
DATA_MIGRATIONS = "app_data_migrations"
MAKE_NOT_NULL_WITH_DJANGO_DEFAULT = "app_make_not_null_with_django_default"
MAKE_NOT_NULL_WITH_LIB_DEFAULT = "app_make_not_null_with_lib_default"
CREATE_INDEX_EXCLUSIVE = "app_create_index_exclusive"
@@ -148,3 +148,7 @@ class PostgresqlBackwardCompatibilityDetectionTestCase(
def test_detect_alter_column(self):
app = fixtures.ALTER_COLUMN
self._test_linter_finds_errors(app)
def test_create_index_exclusive(self):
app = fixtures.CREATE_INDEX_EXCLUSIVE
self._test_linter_finds_errors(app)
Whitespace-only changes.
@@ -0,0 +1,30 @@
# Generated by Django 4.1.7 on 2023-03-09 21:46
from __future__ import annotations
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="User",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.TextField()),
],
),
]
@@ -0,0 +1,20 @@
# Generated by Django 4.1.7 on 2023-03-09 21:47
from __future__ import annotations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("app_create_index_exclusive", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="user",
name="email",
field=models.EmailField(db_index=True, max_length=254, null=True),
),
]
Whitespace-only changes.
@@ -0,0 +1,8 @@
from __future__ import annotations
from django.db import models
class User(models.Model):
name = models.TextField()
email = models.EmailField(db_index=True, null=True)
+1
View File
@@ -56,6 +56,7 @@ INSTALLED_APPS = [
"tests.test_project.app_data_migrations",
"tests.test_project.app_make_not_null_with_django_default",
"tests.test_project.app_make_not_null_with_lib_default",
"tests.test_project.app_create_index_exclusive",
]
MIDDLEWARE = [
+17
View File
@@ -315,6 +315,23 @@ class PostgresqlAnalyserTestCase(SqlAnalyserTestCase):
sql = "REINDEX TABLE my_table;"
self.assertWarningSql(sql)
def test_create_index_exclusive(self):
sql = [
"BEGIN;",
'ALTER TABLE "users" ADD COLUMN "email" varchar(254) NULL;',
'CREATE INDEX "user_email" ON "users" ("email");',
"COMMIT;",
]
self.assertBackwardIncompatibleSql(sql, code="CREATE_INDEX_EXCLUSIVE")
def test_create_index_exclusive_no_lock(self):
sql = [
'ALTER TABLE "users" ADD COLUMN "email" varchar(254) NULL;',
'CREATE INDEX "user_email" ON "users" ("email");',
]
# Will warn about `CREATE_INDEX`, but *not* `CREATE_INDEX_EXCLUSIVE`
self.assertValidSql(sql, allow_warnings=True)
class SqlUtilsTestCase(unittest.TestCase):
def test_unknown_analyser_string(self):