fix(poller): stop infinite re-enqueue of unsendable notifications
In shadow/dry-run/kill-switch modes, when the target has no token/recipient/
chat, or when not in the canary cohort, the handlers returned without marking
the notification, so the poller (pendingFilter on notified:{$ne:true})
re-selected the same batch every cycle forever (observed: fcm:500 every 10s).
Add a service-owned per-channel marker processedBy.<channel>, set in every
terminal 'won't/can't send' branch, and exclude marked docs in pendingFilter.
Unlike the shared notified/emailNotified/telegramNotified flags, processedBy is
invisible to the old system, so it never suppresses a real send in prod shadow.
- types.ts: NotificationDoc.processedBy?: Partial<Record<Channel, Date>>
- poller.ts: pendingFilter excludes processedBy.<channel>
- handlers.ts: markProcessed() in no-token/no-recipient/no-chat, decideSend=false,
dead-token and telegram-blocked branches
- fake-repo: dotted-path support in matching and $set/$unset
- tests: regression test + processedBy assertions (86 passing)
This commit is contained in:
parent
3596f7abed
commit
6d1ba439a2
6 changed files with 115 additions and 15 deletions
|
|
@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest';
|
|||
import type { Repos } from '../src/db.js';
|
||||
import type { Config } from '../src/config.js';
|
||||
import { processEmailJob, processFcmJob, type Deps } from '../src/handlers.js';
|
||||
import { pendingFilter } from '../src/poller.js';
|
||||
import { aNotif, aUser, fakeFcm, fakeMailer, freshRepos, silentLog, testCfg } from './helpers.js';
|
||||
|
||||
let repos: Repos;
|
||||
|
|
@ -60,8 +61,31 @@ describe('FCM handler', () => {
|
|||
|
||||
expect(fcm.calls).toHaveLength(0);
|
||||
const after = await repos.notifications.findOne({ _id: notif._id });
|
||||
expect(after?.notified).toBeUndefined();
|
||||
expect(await repos.sends.countDocuments({})).toBe(0); // no reservation in dry-run
|
||||
expect(after?.notified).toBeUndefined(); // never touches the old system's flag
|
||||
expect(after?.processedBy?.fcm).toBeInstanceOf(Date); // but marks it processed…
|
||||
expect(await repos.sends.countDocuments({})).toBe(0); // …with no reservation in dry-run
|
||||
});
|
||||
|
||||
it('does not re-enqueue a doc already processed in shadow mode', async () => {
|
||||
// Regression for the infinite fcm:500/cycle re-enqueue: in shadow the handler
|
||||
// marks processedBy so the poller filter no longer selects the doc.
|
||||
const notif = aNotif();
|
||||
await repos.notifications.insertOne(notif);
|
||||
await repos.users.insertOne(aUser());
|
||||
const fcm = fakeFcm({ ok: true, messageId: 'x' });
|
||||
const shadow = { ...baseCfg, mode: 'shadow' as const };
|
||||
|
||||
// Before: the poller would select this pending mobile doc.
|
||||
expect(await repos.notifications.countDocuments(pendingFilter('fcm'))).toBe(1);
|
||||
|
||||
await processFcmJob(deps({ cfg: shadow, fcm }), notif._id.toHexString());
|
||||
|
||||
expect(fcm.calls).toHaveLength(0); // nothing sent in shadow
|
||||
const after = await repos.notifications.findOne({ _id: notif._id });
|
||||
expect(after?.notified).toBeUndefined(); // old system's flag untouched
|
||||
expect(after?.processedBy?.fcm).toBeInstanceOf(Date);
|
||||
// After: the poller no longer selects it -> no re-enqueue.
|
||||
expect(await repos.notifications.countDocuments(pendingFilter('fcm'))).toBe(0);
|
||||
});
|
||||
|
||||
it('kill switch stops sending', async () => {
|
||||
|
|
@ -133,6 +157,7 @@ describe('FCM handler', () => {
|
|||
expect(fcm.calls).toHaveLength(0);
|
||||
const after = await repos.notifications.findOne({ _id: notif._id });
|
||||
expect(after?.notified).toBeUndefined();
|
||||
expect(after?.processedBy?.fcm).toBeInstanceOf(Date); // terminal: won't be re-enqueued
|
||||
});
|
||||
|
||||
it('enforces the per-user/day limit', async () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue