Skip to content

Fix email/notification toggle not persisting on unsubscribe - #6

Open
rtb-12 wants to merge 2 commits into
masterfrom
fix/subscription-sync-idempotent-return
Open

Fix email/notification toggle not persisting on unsubscribe#6
rtb-12 wants to merge 2 commits into
masterfrom
fix/subscription-sync-idempotent-return

Conversation

@rtb-12

@rtb-12 rtb-12 commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Problem

Users who toggled email (or notification) subscriptions off kept receiving them.

Subscription.save() and Subscription.delete() returned all(res) over the Redis SADD/SREM return codes. Redis returns 0 for the idempotent case - member already present on SADD, already absent on SREM - which is not a failure, but all() treats any 0 as failure.

Downstream, UserSubscription.subscribe()/unsubscribe() do:

redis_result = redis_subscription.delete()
if not redis_result:
    return False          # <-- bailed out here
... .delete()             # DB UserSubscription row never deleted

So whenever the person wasn't in (or already out of) every leaf set - common with partial or collapsed subscriptions, or when subscribing to a category whose leaves the person already partly holds - save()/delete() returned False and the DB row was left un-created / un-deleted. Redis and the DB then drifted. Because a category shows as subscribed in the UI when the person has a row on it or any ancestor, this could also leave a person receiving mail with no DB row to toggle off.

Fix

pipe.execute() already raises on a real broker/transaction error, so reaching the return means every command ran. Return True unconditionally and let exceptions signal genuine failures. (redisdb/subscription.py, both save() and delete().)

Also included: drift reconciliation

There is no mechanism that rebuilds the Redis sets from the UserSubscription table, so drift that already accumulated is permanent. New management command reconcilesubscriptions flushes the categories:subscription:<action>:* keys and replays save() over the DB rows, making Redis a pure function of the DB. Run it once after deploy:

django-admin reconcilesubscriptions            # all actions
django-admin reconcilesubscriptions --dry-run  # preview

Verification

Run against the assembled app (needs Redis + DB + swapper, so not a unit test in this isolated service repo):

python manage.py shell < verify_subscription_sync.py   # from a working tree copy

Asserts subscribe -> in both stores, unsubscribe -> empty in both, and the idempotent repeat stays empty. Note: I have not yet run this - containers were down. Please run before merge.

Out of scope (separate work)

  • send_only_to_subscribed_users defaults to False in omniport-service-emails / omniport-service-notifications (different repos). The non-targeted send path already always respects subscriptions; the flag only affects explicitly-targeted sends where reaching exactly those recipients may be intended. Flipping the default is a behavior decision for those services, not a silent change here.
  • (Retracted) An earlier draft flagged a "collapse-to-parent" bug in utils/get_subscription.py. On review it is not a defect: the frontend posts the full tree state, so the parent slug is present and is correctly unsubscribed when a child is unchecked.

🤖 Generated with Claude Code

rtb-12 and others added 2 commits July 12, 2026 19:06
Subscription.save()/delete() returned all(res) over the SADD/SREM
return codes. Redis returns 0 for the idempotent case (member already
present on SADD, already absent on SREM), which is not a failure, but
all() treated any 0 as failure.

As a result UserSubscription.unsubscribe() saw a falsy result from
delete() and returned early, never deleting the DB row. Redis and the
DB then drifted: a person who toggled emails off was removed from the
Redis send-set but kept their DB subscription row, and any resync that
rebuilds Redis from the DB re-added them, so they kept receiving mail
after opting out (mirror bug on subscribe()).

pipe.execute() raises on a real broker/transaction error, so reaching
the return means every command ran. Return True unconditionally and let
exceptions signal genuine failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There is no mechanism that rebuilds the Redis subscription sets from the
UserSubscription table, so any drift between the two stores is permanent.
This command flushes the 'categories:subscription:<action>:*' keys and
replays save() over the DB rows, making Redis a pure function of the DB.

Run it once after deploying the unsubscribe fix to clear the drift that
already accumulated. Supports --action to scope to one action and
--dry-run to preview.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant