Webhooks: notification keys name rows, not announcements, so a re-announcement is dropped and a long list overflows the column #413
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#413
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found on 2026-09-29 in a read-only review of postulo at
95348908d, reading the notification keys the announcers build against the webhook queue that deduplicates on them and the column that stores them, and confirmed by runningannounce_due_reminderstwice around apostpone_reminder. Related to #240, which added the webhook notifier and its deduplication by "the notification's stable key"; this issue shows that three announcers build a key that is stable across announcements that are meant to be distinct, and that two of them build one with no upper bound.Checked again on 2026-10-01 against
mainat0a5cad587: still present. The code is now atnotifications/management/commands/send_due_reminders.py:90(thereminder:<pk>key) andapplications/views.py:771(the reminder edit form's call topostpone_reminder); the other locations have not moved.Three announcers build their key from row ids and nothing else:
reminder:<pk>,went_quiet:<ids>andposting_closing:<ids>. The webhook queue refuses a second delivery row for a key a connection has already seen and never prunes its rows, so when Postulo deliberately announces the same reminder, quiet application or closing listing a second time, email, Apprise and the browser get it and the webhook silently does not. The two batched keys also list every row of the pass, so they can exceed the 200 charactersWebhookDelivery.keyholds, which fails the insert on PostgreSQL.notifications/webhooks.py:60-74,enqueue: returnsNonewhenWebhookDelivery.objects.filter(connection=connection, key=notification.key).exists(), and otherwise stores the key unchanged. Nothing insrc/ever deletes aWebhookDeliveryrow, so a key is refused for the life of the connection.notifications/models.py:68:key = models.CharField(_("key"), max_length=200, blank=True, db_index=True).notifications/management/commands/send_due_reminders.py:88:key=f"reminder:{reminder.pk}".applications/quiet.py:144,_announcement:key="went_quiet:" + ",".join(str(row.pk) for row in applications).jobs/closing.py:149,_announcement:key="posting_closing:" + ",".join(str(row.pk) for row in postings).applications/services.py:582(postpone_reminderclearsnotified_at; used by Later, by the reminder edit form atapplications/views.py:725and by the API atapi/routers/reminders.py:87),applications/services.py:468(reschedule_interview, the same for the interview's reminder),applications/quiet.py:65-66("one that woke up and went quiet again is announced again") andjobs/closing.py:89-92("Announced again when the date moves").notifications/slow.py:169and:212(the event id) and:190(interview:{id}:{starts_at}).notifications/base.py:73-75("reminder:41is the same message however many times it is announced") and, in the wiki,Webhooks.md:55-57("the same thing announced twice carries the same key").1. A deliberate re-announcement reuses the old key and is dropped
What happens:
reminder:41is queued and delivered. The person presses Later, edits the due time, or reschedules the interview it belongs to;notified_atis cleared and the reminder is announced again at its new time.enqueuefinds the oldreminder:41row and writes nothing. In a probe on the snapshot,announce_due_reminders()returned(1, 1)both times andWebhookDelivery.objects.count()stayed at 1.closes_at. The next pass claims the listing again; if the id list is the same as last time (the usual case is one listing) the key is againposting_closing:<pk>and the message is not queued.applications/quiet.py:85treats it as fresh; alone in its pass, its key is againwent_quiet:<pk>and the message is not queued.In all three
WebhookNotifier.sendreturns normally, sonotifications/service.py:91-92records the connection as working and counts it as delivered.2. The joined ids can exceed 200 characters
What happens: a
went_quietkey holds 38 four-digit ids in 200 characters and aposting_closingkey 37; one row more is 205. A pass that large is plausible for quiet applications: the first pass after the scheduler is started on an instance already in use, or after a spreadsheet of past applications is imported, announces every quiet application of a person in one message. On PostgreSQLWebhookDelivery.objects.createraisesDataError;notifications/service.py:85-89logs it and writes it on the connection. The rows were stamped before sending (applications/quiet.py:93-99,jobs/closing.py:93-99), so the announcement is never tried again. SQLite does not enforce the length, so the test suite cannot see it.Why it matters: the webhook is the notifier an automation relies on, and it drops exactly the announcements that carry news: the ones the person caused by moving something, and the largest ones. The first failure leaves no trace (the connection shows last worked: now); the second leaves an error on the connection but loses the message for good, on PostgreSQL, which the project supports with its own Compose file (
docker/compose.postgres.yml). The key is also sent in the payload, and the wiki advises receivers to deduplicate on it, so a receiver following that advice would discard a second announcement even if Postulo sent it.Fix: make each key name one announcement and keep it bounded.
reminder:{pk}:{due_at.isoformat()}."posting_closing:" + sha256(",".join(f"{row.pk}@{row.closes_at.isoformat()}" for row in postings).encode()).hexdigest()[:40], and forwent_quietthe ids with thequiet_announced_atstamp just claimed (thenowalready passed to_announcement). The ids stay available indata. A retry of the same announcement keeps its key; a real re-announcement gets a new one.enqueue, refuse or hash any key longer than the column, so a later announcer cannot reintroduce the overflow.Notification.keycomment innotifications/base.py:73-79and the wiki'sWebhooks.md:55-57to say that a key names one announcement, not one object, and update the assertions that pin the old shape (tests/test_deadlines.py:365,tests/test_notifications.py:149).Done when
tests/test_webhooks.pyannounces a due reminder to a webhook connection, postpones it withpostpone_reminderto a time already past, announces again and expects twoWebhookDeliveryrows; it fails today.tests/test_deadlines.pymoves a listing'scloses_atafter its first announcement and expects a secondWebhookDeliveryrow, and one intests/test_quiet.pydoes the same for an application that goes quiet, wakes and goes quiet again; both fail today.tests/test_quiet.pyandtests/test_deadlines.pybuild the announcement for a hundred rows and expectlen(message.key)to be at mostWebhookDelivery._meta.get_field("key").max_length; both fail today.test_a_key_means_one_delivery_however_often_it_is_announcedintests/test_webhooks.pystill passes: one announcement notified twice is one row.