#27054 closed Bug (fixed)
makemigrations tries to create django_migrations in external database
| Reported by: | Jim Nicholls | Owned by: | nobody |
|---|---|---|---|
| Component: | Migrations | Version: | 1.10 |
| Severity: | Release blocker | Keywords: | |
| Cc: | Triage Stage: | Accepted | |
| Has patch: | yes | Needs documentation: | no |
| Needs tests: | yes | Patch needs improvement: | no |
| Easy pickings: | no | UI/UX: | no |
Description
I have 2 databases set up. A database for Django to use and a read-only database connection to an external system that is outside of Django's control. I am using a database router to select between the two databases. My models for the external database all have managed = False.
In Django 1.9, I was successfully running makemigrations. In Django 1.10, makemigrations raises an error.
django.db.migrations.exceptions.MigrationSchemaMissing: Unable to create the django_migrations table (permission denied for schema external_db)
Attachments (1)
Change History (19)
by , 9 years ago
| Attachment: | traceback.txt added |
|---|
comment:1 by , 9 years ago
| Summary: | makemigrations tries to create django_migrations in legacy database → makemigrations tries to create django_migrations in external database |
|---|
comment:2 by , 9 years ago
| Has patch: | set |
|---|
follow-up: 4 comment:3 by , 9 years ago
| Needs tests: | set |
|---|---|
| Severity: | Normal → Release blocker |
| Triage Stage: | Unreviewed → Accepted |
I guess the regression might be due to 02ae5fd31a56ffb42feadb49c1f3870ba0a24869. Have you thought about how to test your patch?
comment:4 by , 9 years ago
Replying to timgraham:
I guess the regression might be due to 02ae5fd31a56ffb42feadb49c1f3870ba0a24869. Have you thought about how to test your patch?
This is first time I've touched the code of Django itself. I'm not familiar with Django's tests. I'm happy to write tests.
Any guidance? Is testing the new behaviour of applied_migrations enough, or do I have to look bigger at the whole of makemigrations?
comment:5 by , 9 years ago
I'm not sure if the fix is at the appropriate level. Catching the exception in recorder.py might hide it too much. It might be more appropriate to catch the exception in makemigrations.py. Is the exception coming from loader.check_consistent_history(connection) there?
comment:6 by , 9 years ago
For each connection: loader.check_consistent_history calls recorder.MigrationRecorder.applied_migrations to get the set of migrations that have already been applied to the database on the other end of the connection. MigrationRecorder.applied_migrations calls MigrationRecorder.ensure_schema so that it can query the table underlying MigrationRecorder.Migration. It is MigrationRecorder.ensure_schema that catches DatabaseError and raises MigrationSchemaMissing in its place.
Conceptually MigrationRecorder.applied_migrations is a read-only operation with respect to the database, but nevertheless it does attempt write operations as an implementation side-effect.
I'm inexperienced in Django's code, so I'll lift catch the exception higher up the call stack if you think that's best.
My thoughts at catching the MigrationSchemaMissing exception within applied_migrations was along the following lines.
The existing usages of applied_migrations are:
django.db.migrations.executor.MigrationsExecutor.check_replacements(self): doesn't handleMigrationSchemaMissing, copes withapplied_migrationsreturning an empty setdjango.db.migrations.loader.MigrationLoader.build_graph(self): this code explicitly treats no connection as being equivalent toapplied_migrationsreturning an empty setdjango.db.migrations.loader.MigrationLoader.check_consistent_history(self, connection): uses the result ofapplied_migrationsin a for loop, so copes withapplied_migrationsreturning an empty set
MigrationSchemaMissing is only raised in MigrationRecorder.ensure_schema.
MigrationSchemaMissing is only handled in django.core.management.core.management.base.BaseCommand.check_migrations, where it logs a message to stdout and then skips checking the migrations. However check_migrations doesn't directly or indirectly call applied_migrations in the try suite.
MigrationSchemaMissing is a subclass of DatabaseError, which has too many usages to consider.
Considering the existing usages above, and possible future usages, my thinking for catching MigrationSchemaMissing inside applied_migrations is:
- not being able to create
django_migrationsis not exceptional. I think my use-case andBaseCommand.check_migrationsproves that MigrationSchemaMissingis indirectly raised inapplied_migrationsbecause of the implementation side-effect, not because of the semantics ofapplied_migrations. I think it is leaking an exception in a non-exceptional circumstance.- I don't think a callee would usefully want to distinguish between a database with no migrations, where empty set returned, from a sitatuation of the
django_migrationstable failed to be created, whereMigrationSchemaMissingis raised. - requiring a callee to have to consider
MigrationSchemaMissingbeing raise is onerous, when returning an empty set would most likely have the same effect in the callee's code.
comment:7 by , 9 years ago
I think I've missed the obvious.
applied_migrations is calling ensure_schema just to avoid a DatabaseError when it calls Migration.objects.values_list. For me the call to ensure_schema has unwanted the side-effect of trying to create the django_migrations table, which is causing my problem.
Maybe it would better to not call ensure_schema at all, but instead catch the DatabaseError that is going to happen if there is no django_migrations table and then return an empty set.
MigrationRecorder.record_applied and MigrationRecorder.record_unapplied both call ensure_schema themselves. This would mean that the django_migrations table would be created at the point when a migration is actually to being applied. At that point, if the django_migrations table cannot be created, that is an exceptional circumstance.
comment:8 by , 9 years ago
I'm wrong about django.core.management.core.management.base.BaseCommand.check_migrations not directly or indirectly calling applied_migrations in the try suite.
Catching MigrationSchemaMissing in applied_migrations causes admin_scripts.tests.ManageRunserver.test_readonly_database to fail. This is because check_migrations no longer outputs "Not checking migrations" and instead outputs the list of missing migrations.
Is this new behaviour of BaseCommand.check_migrations better?
comment:9 by , 9 years ago
I've withdrawn the pull request. I'll submit a new pull request leaves MigrationRecorder as is and catches the exception higher up.
comment:15 by , 9 years ago
#27110 breaks this fix in the case where a database is present in DATABASES but absent from DATABASE_ROUTERS.
In spite of #27110 testing for DATABASE_ROUTERS, makemigrations still using the connections in django.db.utils.ConnectionHandler (ie as populated from settings.DATABASES) rather than the routers in DATABASE_ROUTERS for the set of databases for which it attempts to prepare to migrate.
This simplified example should result in makemigrations failing:
DATABASES = {
'default': { ... }
'read_only': { ... }
}
Class MyDefaultRouter():
class Meta:
DATABASE_ROUTERS = ['MyDefaultRouter',]
As long as we explicitly specify our database by employing the .objects.using() syntax, the database need not be present in DATABASE_ROUTERS
class MyReadOnlyModel(models.Model):
class Meta
queryset = MyReadOnlyModel.objects.using('read_only').all()
so at line 109 of makemigrations the call to loader.check_consistent_history(connection) on our read-only database, and will again raise the applied_migrations / ensure_schema traceback
Traceback (most recent call last):
File "manage.py", line 11, in <module>
execute_from_command_line(sys.argv)
File "venv/lib/python2.7/site-packages/django/core/management/__init__.py", line 367, in execute_from_command_line
utility.execute()
File "venv/lib/python2.7/site-packages/django/core/management/__init__.py", line 359, in execute
self.fetch_command(subcommand).run_from_argv(self.argv)
File "venv/lib/python2.7/site-packages/django/core/management/base.py", line 294, in run_from_argv
self.execute(*args, **cmd_options)
File "venv/lib/python2.7/site-packages/django/core/management/base.py", line 345, in execute
output = self.handle(*args, **options)
File "venv/lib/python2.7/site-packages/django/core/management/commands/makemigrations.py", line 109, in handle
loader.check_consistent_history(connection)
File "venv/lib/python2.7/site-packages/django/db/migrations/loader.py", line 276, in check_consistent_history
applied = recorder.applied_migrations()
File "venv/lib/python2.7/site-packages/django/db/migrations/recorder.py", line 65, in applied_migrations
self.ensure_schema()
File "venv/lib/python2.7/site-packages/django/db/migrations/recorder.py", line 59, in ensure_schema
raise MigrationSchemaMissing("Unable to create the django_migrations table (%s)" % exc)
django.db.migrations.exceptions.MigrationSchemaMissing: Unable to create the django_migrations table ((1142, "CREATE command denied to user 'readonly_user'@'192.168.1.1' for table 'django_migrations'"))
comment:17 by , 9 years ago
Replying to Tim Graham:
You need to add an
allow_migrate()method to your router.
OK, I can see that now.
Still, it's unfortunate that we need to identify
read-only databases in this manner, which if I understand correctly is like this:
class MyRouter():
def allow_migrate(self, db):
if db in ['read_only0', 'read_only1', 'another_read_only_database_with_a_very_long_name']:
return False
Something like this might be fun:
DATABASES = {
'default' : {
'HOST': 'localhost',
'NAME': 'database0' },
'read_only': {
'NAME': 'database1',
'HOST': 'localhost',
'OPTIONS':
{'read_only': True,}
}
}
Then,
not DATABASES['default'].get('OPTIONS').get('read_only')
will return True,
and
not DATABASES['read_only'].get('OPTIONS').get('read_only')
will return False,
so back in makemigrations we can check:
# Non-default databases are only checked if database routers used.
aliases_to_check = connections if settings.DATABASE_ROUTERS else [DEFAULT_DB_ALIAS]
for alias in sorted(aliases_to_check):
connection = connections[alias]
if (connection.settings_dict['ENGINE'] != 'django.db.backends.dummy' and any(
# At least one model must be migrated to the database.
router.allow_migrate(connection.alias, app_label, model_name=model._meta.object_name)
for app_label in consistency_check_labels
for model in apps.get_app_config(app_label).get_models()
# The database must not be read-only
and not connections.settings_dict.get('OPTIONS').get('read_only')
)):
loader.check_consistent_history(connection)
On further thought, the use of the term read_only is loaded and maybe OPTIONSmigratable would be preferable
Anyway, it might be fun.
comment:18 by , 9 years ago
I haven't used routers much myself, but I think the current structure provides enough flexibility that adding on "shortcuts" like that would only further complicate things.
Traceback of MigrationSchemaMissing