-
Notifications
You must be signed in to change notification settings - Fork 633
UN-3445 [FIX] PG queue — indexed claim state removes high-concurrency scan-past #2159
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c946b35
e6d1635
aa87445
be3d23f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| # Generated by Django 4.2.30 on 2026-07-09 10:40 | ||
|
|
||
| from django.db import migrations, models | ||
|
|
||
|
|
||
| class Migration(migrations.Migration): | ||
| dependencies = [ | ||
| ("pg_queue", "0013_pgorchestrationclaim_organization_id"), | ||
| ] | ||
|
|
||
| operations = [ | ||
| migrations.RemoveIndex( | ||
| model_name="pgqueuemessage", | ||
| name="pg_queue_message_dequeue_idx", | ||
| ), | ||
| migrations.AddField( | ||
| model_name="pgqueuemessage", | ||
| name="state", | ||
| field=models.TextField(default="ready"), | ||
| ), | ||
| # Django's AddField backfills every existing row to 'ready' then DROPs the | ||
| # DB default (it manages defaults at the ORM layer). Re-add a DB-level | ||
| # default so the schema is self-protecting: a raw INSERT that omits `state` | ||
| # still lands a valid 'ready' row rather than a NOT-NULL violation. | ||
| # (The ORM always sends state='ready' via the field default, so this guards | ||
| # only raw SQL; insert_message_sql() also sets it explicitly — belt + | ||
| # suspenders.) Metadata-only; touches no row data. | ||
| migrations.RunSQL( | ||
| sql="ALTER TABLE pg_queue_message ALTER COLUMN state SET DEFAULT 'ready'", | ||
| reverse_sql="ALTER TABLE pg_queue_message ALTER COLUMN state DROP DEFAULT", | ||
| ), | ||
| # Deploy-safety backfill: the new claim keys off `state` and ignores `vt`, | ||
| # but AddField set EVERY pre-existing row (including claimed-but-unacked | ||
| # in-flight rows, which carry a future `vt`) to 'ready'. Without this, a | ||
| # rolling deploy over a non-empty queue would make a still-processing | ||
| # message instantly re-claimable → one-time double processing (absorbed by | ||
| # the idempotency stack, but avoidable). Land genuinely in-flight rows as | ||
| # 'claimed' so they stay invisible until their lease expires. No-op at the | ||
| # first enablement (the queue table is empty while the flag is off). | ||
| migrations.RunSQL( | ||
| sql="UPDATE pg_queue_message SET state = 'claimed' WHERE vt > now()", | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [HIGH — test gap] The deploy-safety backfill has zero test coverage, and a regression would silently reintroduce fleet-wide double-processing. This Suggested test (Django |
||
| reverse_sql=migrations.RunSQL.noop, | ||
| ), | ||
| migrations.AddIndex( | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [MED — lock during migration] Indexes are built non-concurrently and the CHECK constraint is validated under a full-table lock. Django Fix (if zero-downtime over a backlog matters): split into a non-atomic migration using |
||
| model_name="pgqueuemessage", | ||
| index=models.Index( | ||
| models.F("queue_name"), | ||
| models.OrderBy(models.F("priority"), descending=True), | ||
| models.F("msg_id"), | ||
| condition=models.Q(("state", "ready")), | ||
| name="pg_queue_message_claim_idx", | ||
| ), | ||
| ), | ||
| migrations.AddIndex( | ||
| model_name="pgqueuemessage", | ||
| index=models.Index( | ||
| models.F("msg_id"), | ||
| condition=models.Q(("state", "claimed")), | ||
| name="pg_queue_message_claimed_idx", | ||
| ), | ||
| ), | ||
| migrations.AddConstraint( | ||
| model_name="pgqueuemessage", | ||
| constraint=models.CheckConstraint( | ||
| check=models.Q(("state__in", ["ready", "claimed"])), | ||
| name="pg_queue_message_state_valid", | ||
| ), | ||
| ), | ||
| # NOTE (UN-3445): performance tuning for the state-machine churn | ||
| # (fillfactor + aggressive per-table autovacuum) is deliberately KEPT OUT of | ||
| # this migration. Those are core-Postgres reloptions, but they are (a) | ||
| # environment-specific tuning that belongs in the ops/deploy layer, not app | ||
| # schema, and (b) potentially restricted by a locked-down managed role | ||
| # (Cloud SQL / RDS / Azure), where a failed `ALTER TABLE SET (autovacuum_*)` | ||
| # would abort the whole migration. The fix itself (state column + partial | ||
| # indexes) is pure portable DDL and needs none of it. If a soak shows bloat, | ||
| # apply the tuning per environment via ops (see the PR/runbook), not here. | ||
| ] | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| """Regression guard for migration 0014's deploy-safety in-flight backfill. | ||
|
|
||
| The one line ``UPDATE pg_queue_message SET state='claimed' WHERE vt > now()`` is | ||
| what stops a rolling deploy over a non-empty queue from re-marking every in-flight | ||
| (claimed-but-unacked, future-``vt``) row as ``ready`` → instant re-claim → | ||
| fleet-wide one-time double processing (UN-3445 PR review, HIGH). Its correctness | ||
| rests on an ordering invariant nothing else locks down: ``AddField`` first backfills | ||
| ALL rows to ``ready``, and only this later ``RunSQL`` re-classifies the genuinely | ||
| in-flight ones. A refactor that drops/reorders the step, or inverts the ``vt > now()`` | ||
| predicate, would silently reintroduce the exact bug this PR prevents — and every | ||
| state-machine test would still pass (they don't exercise the migration step). | ||
|
|
||
| This structurally pins the operation (present, correct predicate, ordered after the | ||
| ``AddField``). The *behavioural* half — that ``vt > now()`` actually selects the | ||
| in-flight rows against real Postgres — is | ||
| ``test_pg_queue_client.py::test_inflight_backfill_sql_classifies_by_vt`` (the backend | ||
| has no pytest-django / migration-executor harness, so a full-migration run isn't | ||
| cheap here; these two together catch removal, reordering, and predicate inversion). | ||
| """ | ||
|
|
||
| from importlib import import_module | ||
|
|
||
| from django.db import migrations | ||
|
|
||
| _MIGRATION = import_module( | ||
| "pg_queue.migrations." | ||
| "0014_remove_pgqueuemessage_pg_queue_message_dequeue_idx_and_more" | ||
| ) | ||
|
|
||
|
|
||
| def _first_index(ops, predicate): | ||
| return next((i for i, op in enumerate(ops) if predicate(op)), -1) | ||
|
|
||
|
|
||
| def test_inflight_backfill_present_correct_and_ordered_after_addfield(): | ||
| ops = _MIGRATION.Migration.operations | ||
|
|
||
| add_state = _first_index( | ||
| ops, | ||
| lambda o: isinstance(o, migrations.AddField) and o.name == "state", | ||
| ) | ||
| backfill = _first_index( | ||
| ops, | ||
| lambda o: isinstance(o, migrations.RunSQL) | ||
| and "state = 'claimed'" in str(o.sql) | ||
| and "vt > now()" in str(o.sql), | ||
| ) | ||
|
|
||
| # Present: the backfill exists with the exact re-classification predicate. | ||
| assert add_state >= 0, "0014 must add the `state` field" | ||
| assert backfill >= 0, ( | ||
| "0014 must backfill in-flight rows with " | ||
| "`UPDATE ... SET state='claimed' WHERE vt > now()` — removing/inverting it " | ||
| "reintroduces fleet-wide double-delivery on a rolling deploy" | ||
| ) | ||
| # Ordered: AddField sets every row 'ready' first; the backfill must run AFTER | ||
| # it to re-classify the genuinely in-flight rows (reorder = no-op backfill). | ||
| assert backfill > add_state, "the in-flight backfill must run after AddField(state)" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[MED — deploy safety]
AddField(default="ready")backfills all pre-existing rows toready, including in-flight (future-vt) ones → transient double-delivery on deploy.The new claim ignores
vtand keys offstateonly. Any message that was claimed-but-unacked under the old design (futurevt) is backfilled toreadyand becomes immediately re-claimable, so during a rolling deploy a still-processing message can be picked up by another consumer → one-time double processing. It's absorbed by the at-least-once/idempotent contract, but it's real deploy-time behavior.Fix: either document that the queue must be drained / consumers quiesced before applying 0014, or backfill defensively so genuinely in-flight rows land
claimed(e.g. a follow-upRunSQLsettingstate='claimed' WHERE vt > now()).