Design a notification pipeline
Picture
A saved reminder might become email, push, or both. Preferences and channel capabilities decide which jobs should exist.
Predict
Does one dedup key per event distinguish email from push for two different users?
Walk through the small example
No, include recipient and channel. Identity must match the individual delivery being protected.
Mental model and runnable Python model
Separate event ingestion, preference/template resolution, delivery-job creation, channel workers, and provider adapters. Store stable delivery IDs and bounded retry status. Use an outbox for reliable creation from a business event where needed. Rate-limit per provider and tenant, respect quiet hours and unsubscribe policy, and dead-letter persistent failures with an operational path. End-to-end exactly-once delivery may be impossible if a provider offers no idempotency or status query after an uncertain timeout; document the duplicate-versus-loss tradeoff.
Requirements and data
Define transactional versus promotional notifications, channels, latency, quiet hours, preferences, and retention. Store template version, recipient, channel, event ID, delivery ID, status, attempts, and next attempt time without unnecessarily retaining sensitive content.
Pipeline
Ingest durable events, resolve current policy and template, create deduplicated jobs, and route them to channel workers. Workers recheck relevant preferences, call provider adapters with idempotency where supported, and persist the outcome or uncertain state.
Retries and limits
Use bounded exponential backoff with jitter, provider-specific rate limits, and deadlines. Separate invalid recipients from transient provider failures. Dead-lettered work needs inspection and safe replay; it is not a place to silently abandon the user’s promise.
Tradeoffs and observation
Measure queue age, attempts, provider acceptance, delivery reports, and opt-out violations separately. Acceptance is not necessarily inbox delivery. For providers without idempotency or status lookup, state the unavoidable uncertain-outcome policy.
What the function promises: Planner creates unique local (event,user,channel) jobs for currently permitted channels. It does not send messages or implement durable scheduling.
class Planner:
def __init__(self):
self.jobs = set()
def plan(self, event, user, channels, permitted):
created = []
for channel in dict.fromkeys(channels):
key = event, user, channel
if channel in permitted and key not in self.jobs:
self.jobs.add(key)
created.append(key)
return created
planner = Planner()
assert planner.plan("e1", "Ana", ["email","push"], {"email"}) == [("e1","Ana","email")]
assert planner.plan("e1", "Ana", ["email"], {"email"}) == []
assert planner.plan("e1", "Bo", ["email"], {"email"}) == [("e1","Bo","email")]
Work and storage: Expected O(requested channels) planning and O(retained delivery IDs) storage.
When to recognize it: Queue work by channel, pace calls, retry suitable failures, and record provider responses. A timeout may occur after the provider accepted the message.
Check before applying it: Delivery identity and provider guarantees determine whether duplicate suppression is actually achievable.
Change one rule
A provider times out after accepting an email but has no idempotency support. Can the client guarantee both no duplicates and no losses?
See what changes
Not generally under that uncertainty. Use provider status lookup if available or choose and document a bounded retry/reconciliation policy appropriate to the notification.
Connect
Let work wait without losing its identity: Each queued delivery needs retries, deduplication, and a failure policy.
Design a chat service: Distinguish notification acceptance from durable chat-message storage.