diff --git a/.dockerignore b/.dockerignore index d28b308..09bb957 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,6 +2,7 @@ * # Explicitly include the bits we want # TODO: find a good way to keep this in sync +!gunicorn.conf.py !handler/*.py !handler/chat/*.py !handler/management/*.py diff --git a/gunicorn.conf.py b/gunicorn.conf.py new file mode 100644 index 0000000..d3bb7ad --- /dev/null +++ b/gunicorn.conf.py @@ -0,0 +1,27 @@ +# We intentionally do NOT run with --preload. `simone.urls` starts a +# singleton Cron background thread (and opens a DB connection) as an import +# side effect (see wsgi.py's `cron.start()`), and --preload would run that +# in the gunicorn master before workers are forked. fork() then duplicates +# whatever the Cron thread has in flight (its DB connection, mid-transaction +# or not) into the worker, which can leave things like table locks stuck +# open with nothing left to ever commit/release them -- every request that +# then needs the same rows hangs forever with nothing logged, until the +# client (Slack, ~3s) gives up. Without --preload, each worker loads the app +# (and starts its own Cron thread) fully independently, post-fork, so there +# is nothing shared to corrupt. +# +# This relies on running a single worker: script/run doesn't pass --workers, +# so gunicorn defaults to one. If that's ever increased, Cron would start +# once per worker and tick (and message Slack) that many times over. + + +def worker_abort(worker): + ''' + Called when the arbiter SIGABRTs a worker for failing to heartbeat + within --timeout. Dump every thread's stack so a hang shows up in the + logs instead of just a silent restart. + ''' + import faulthandler + import sys + + faulthandler.dump_traceback(file=sys.stderr) diff --git a/handler_about/migrations/0004_replace_index_together.py b/handler_about/migrations/0004_replace_index_together.py new file mode 100644 index 0000000..29b201e --- /dev/null +++ b/handler_about/migrations/0004_replace_index_together.py @@ -0,0 +1,67 @@ +from django.db import migrations, models + +# Django 5.1 removed the `index_together` Meta option, so the model now +# declares this same composite index via `Meta.indexes` instead. The name +# below is the one Django's schema editor deterministically assigned to the +# old index_together-based index (a hash of the table + column names, so +# it's the same on every backend) when it was created back in +# 0002_add_workspace_fk. On SQLite it no longer actually exists — the +# 0003_make_workspace_nonnull AlterField rebuilt the table, and SQLite's +# table-rebuild only restores indexes tracked via Meta.indexes, silently +# dropping the index_together one — but on MySQL it's still present, so we +# look for it by name and drop it if we find it. +LEGACY_INDEX_NAME = ( + 'handler_about_fact_workspace_id_user_id_created_at_cff0afa0_idx' +) +NEW_INDEX_NAME = 'handler_abo_workspa_4d097e_idx' + + +def drop_legacy_index(apps, schema_editor): + Fact = apps.get_model('handler_about', 'Fact') + table = Fact._meta.db_table + with schema_editor.connection.cursor() as cursor: + constraints = schema_editor.connection.introspection.get_constraints( + cursor, table + ) + if LEGACY_INDEX_NAME in constraints: + schema_editor.remove_index( + Fact, + models.Index( + fields=['workspace', 'user_id', 'created_at'], + name=LEGACY_INDEX_NAME, + ), + ) + + +class Migration(migrations.Migration): + + dependencies = [('handler_about', '0003_make_workspace_nonnull')] + + operations = [ + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.AlterIndexTogether( + name='fact', index_together=set() + ), + migrations.AddIndex( + model_name='fact', + index=models.Index( + fields=['workspace', 'user_id', 'created_at'], + name=NEW_INDEX_NAME, + ), + ), + ], + database_operations=[ + migrations.RunPython( + drop_legacy_index, migrations.RunPython.noop + ), + migrations.AddIndex( + model_name='fact', + index=models.Index( + fields=['workspace', 'user_id', 'created_at'], + name=NEW_INDEX_NAME, + ), + ), + ], + ) + ] diff --git a/handler_about/models.py b/handler_about/models.py index ba94c42..e440940 100644 --- a/handler_about/models.py +++ b/handler_about/models.py @@ -13,6 +13,6 @@ def __str__(self): return f'{self.id} - {self.value}' class Meta: - index_together = (('workspace', 'user_id', 'created_at'),) + indexes = [models.Index(fields=['workspace', 'user_id', 'created_at'])] unique_together = (('workspace', 'user_id', 'value'),) ordering = ('user_id', 'created_at') diff --git a/pyproject.toml b/pyproject.toml index d38836b..c947253 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ packages = [] name = "simone" version = "0.0.1" dependencies = [ - "Django>=4.0.8,<5.0", + "Django>=4.0.8,<6.0", "cron-validator>=1.0.3", "cryptography>=43.0.0", "gunicorn>=20.1.0", diff --git a/requirements.txt b/requirements.txt index 3942e21..40602c3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ charset-normalizer==3.4.7 click==8.4.2 cron-validator==1.0.8 cryptography==49.0.0 -django==4.2.30 +django==5.2.16 django-debug-toolbar==6.3.0 exceptiongroup==1.3.1; python_version=='3.10' gunicorn==26.0.0 diff --git a/script/run b/script/run index b79fad6..d6e4122 100755 --- a/script/run +++ b/script/run @@ -4,4 +4,4 @@ set -e ./manage.py collectstatic --no-input ./manage.py migrate --no-input -gunicorn simone.wsgi --bind 0.0.0.0:6444 --graceful-timeout 5 --preload --threads 4 +gunicorn simone.wsgi --config gunicorn.conf.py --bind 0.0.0.0:6444 --graceful-timeout 5 --threads 4