feat(search): GOO-221 cursor/keyset pagination for SavedSearch alert listeners

All four alert code paths that previously loaded the entire SavedSearch
table into memory are replaced with bounded batch iteration backed by
the idx_savedsearch_alert_enabled partial index (merged in GOO-118).

Batch size is 500 rows; order-by is createdAt ASC, which matches the
index definition so the planner uses it for both the WHERE clause and
the cursor predicate.

Changed files:
- saved-search-alert.handler.ts: keyset loop on createdAt with
  alertEnabled=true, ALERT_BATCH_SIZE=500
- saved-search-alert-cron.service.ts: same pagination loop, removes
  the early-return on empty set (loop exits naturally on first empty page)
- residential-events.listener.ts: ResidentialPriceDropListener and
  ResidentialNewListingInProjectListener both paginated; select now
  includes createdAt to advance the cursor; shared ALERT_BATCH_SIZE

Tests:
- saved-search-alert.handler.spec.ts: adds createdAt to mock rows, adds
  3-page pagination test and orderBy/take assertion
- residential-events.listener.spec.ts: adds createdAt to mock rows, adds
  501-row pagination test verifying cursor advance on second call (9
  existing tests all pass)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-24 12:58:16 +07:00
parent be47c26031
commit 9af9e1d84a
7 changed files with 269 additions and 96 deletions

View File

@@ -65,12 +65,14 @@ describe('ResidentialPriceDropListener', () => {
userId: 'user-1',
name: 'Quận 7 căn hộ',
filters: { city: 'Hồ Chí Minh', district: 'Quận 7', priceMax: 3_000_000_000 },
createdAt: new Date('2026-01-01T00:00:00Z'),
},
{
id: 'ss-2',
userId: 'user-2',
name: 'Quận 1',
filters: { district: 'Quận 1' },
createdAt: new Date('2026-01-02T00:00:00Z'),
},
]);
@@ -101,7 +103,7 @@ describe('ResidentialPriceDropListener', () => {
it('skips saved searches owned by the listing seller', async () => {
prisma.listing.findUnique.mockResolvedValue(listing);
prisma.savedSearch.findMany.mockResolvedValue([
{ id: 'ss-self', userId: 'seller-1', name: 'mine', filters: {} },
{ id: 'ss-self', userId: 'seller-1', name: 'mine', filters: {}, createdAt: new Date('2026-01-01T00:00:00Z') },
]);
const event = new ListingPriceChangedEvent('listing-1', 2_500_000_000n, 2_000_000_000n);
@@ -117,6 +119,38 @@ describe('ResidentialPriceDropListener', () => {
await expect(listener.handle(event)).resolves.not.toThrow();
expect(logger.warn).toHaveBeenCalled();
});
it('paginates across batches and emits to all matching users', async () => {
prisma.listing.findUnique.mockResolvedValue(listing);
const BATCH = 500;
const makeRow = (n: number) => ({
id: `ss-${n}`,
userId: `user-${n}`,
name: `Search ${n}`,
// All match: district + city
filters: { city: 'Hồ Chí Minh', district: 'Quận 7' },
createdAt: new Date(Date.now() + n * 1000),
});
const page1 = Array.from({ length: BATCH }, (_, i) => makeRow(i));
const page2 = [makeRow(BATCH)];
prisma.savedSearch.findMany
.mockResolvedValueOnce(page1)
.mockResolvedValueOnce(page2)
.mockResolvedValue([]);
const event = new ListingPriceChangedEvent('listing-1', 2_500_000_000n, 2_000_000_000n);
await listener.handle(event);
expect(prisma.savedSearch.findMany).toHaveBeenCalledTimes(2);
expect(gateway.emitResidentialEvent).toHaveBeenCalledTimes(BATCH + 1);
// Second call must use cursor from last row of first batch
const secondCall = prisma.savedSearch.findMany.mock.calls[1][0];
expect(secondCall.where.createdAt?.gt).toEqual(page1[BATCH - 1]!.createdAt);
});
});
describe('ResidentialNewListingInProjectListener', () => {
@@ -149,9 +183,9 @@ describe('ResidentialNewListingInProjectListener', () => {
},
});
prisma.savedSearch.findMany.mockResolvedValue([
{ id: 'ss-tracker', userId: 'user-10', name: 'VGP', filters: { projectId: 'project-vgp' } },
{ id: 'ss-other', userId: 'user-11', name: 'khác', filters: { projectId: 'project-other' } },
{ id: 'ss-no-project', userId: 'user-12', name: 'no-project', filters: {} },
{ id: 'ss-tracker', userId: 'user-10', name: 'VGP', filters: { projectId: 'project-vgp' }, createdAt: new Date('2026-01-01T00:00:00Z') },
{ id: 'ss-other', userId: 'user-11', name: 'khác', filters: { projectId: 'project-other' }, createdAt: new Date('2026-01-02T00:00:00Z') },
{ id: 'ss-no-project', userId: 'user-12', name: 'no-project', filters: {}, createdAt: new Date('2026-01-03T00:00:00Z') },
]);
const event = new ListingApprovedEvent('listing-9', 'admin-1');

View File

@@ -7,6 +7,9 @@ import { NotificationsGateway } from '../../presentation/gateways/notifications.
const CONTEXT = 'ResidentialEventsListener';
/** Rows processed per cursor-page. Aligns with idx_savedsearch_alert_enabled batch size. */
const ALERT_BATCH_SIZE = 500;
/**
* Shape of the `filters` JSON column on `SavedSearch`. Matches fields
* consumed by the saved-search alert matcher. Anything else is ignored.
@@ -63,31 +66,47 @@ export class ResidentialPriceDropListener
});
if (!listing || !listing.property) return;
const savedSearches = await this.prisma.savedSearch.findMany({
where: { alertEnabled: true },
select: { id: true, userId: true, name: true, filters: true },
});
let matchCount = 0;
for (const search of savedSearches) {
if (search.userId === listing.sellerId) continue;
let cursor: Date | undefined;
const filters = normalizeFilters(search.filters);
if (!matchesFilters(listing, listing.property, filters)) continue;
this.gateway.emitResidentialEvent(search.userId, 'residential:price-drop', {
listingId: listing.id,
savedSearchId: search.id,
savedSearchName: search.name,
title: listing.property.title,
oldPrice: event.oldPrice.toString(),
newPrice: event.newPrice.toString(),
district: listing.property.district,
city: listing.property.city,
occurredAt: event.occurredAt.toISOString(),
// Stream alert-enabled saved searches in bounded batches (keyset on createdAt).
// idx_savedsearch_alert_enabled covers WHERE alertEnabled = true ORDER BY createdAt.
do {
const batch = await this.prisma.savedSearch.findMany({
where: {
alertEnabled: true,
...(cursor ? { createdAt: { gt: cursor } } : {}),
},
select: { id: true, userId: true, name: true, filters: true, createdAt: true },
orderBy: { createdAt: 'asc' },
take: ALERT_BATCH_SIZE,
});
matchCount++;
}
if (batch.length === 0) break;
for (const search of batch) {
if (search.userId === listing.sellerId) continue;
const filters = normalizeFilters(search.filters);
if (!matchesFilters(listing, listing.property, filters)) continue;
this.gateway.emitResidentialEvent(search.userId, 'residential:price-drop', {
listingId: listing.id,
savedSearchId: search.id,
savedSearchName: search.name,
title: listing.property.title,
oldPrice: event.oldPrice.toString(),
newPrice: event.newPrice.toString(),
district: listing.property.district,
city: listing.property.city,
occurredAt: event.occurredAt.toISOString(),
});
matchCount++;
}
cursor = batch[batch.length - 1]!.createdAt;
if (batch.length < ALERT_BATCH_SIZE) break;
} while (true);
if (matchCount > 0) {
this.logger.log(
@@ -126,35 +145,51 @@ export class ResidentialNewListingInProjectListener
const projectId = listing.property.projectDevelopmentId;
const savedSearches = await this.prisma.savedSearch.findMany({
where: { alertEnabled: true },
select: { id: true, userId: true, name: true, filters: true },
});
let matchCount = 0;
for (const search of savedSearches) {
if (search.userId === listing.sellerId) continue;
let cursor: Date | undefined;
const filters = normalizeFilters(search.filters);
if (filters.projectId !== projectId) continue;
this.gateway.emitResidentialEvent(
search.userId,
'residential:new-listing-in-project',
{
listingId: listing.id,
projectId,
savedSearchId: search.id,
savedSearchName: search.name,
title: listing.property.title,
price: listing.priceVND.toString(),
district: listing.property.district,
city: listing.property.city,
occurredAt: event.occurredAt.toISOString(),
// Stream alert-enabled saved searches in bounded batches (keyset on createdAt).
// idx_savedsearch_alert_enabled covers WHERE alertEnabled = true ORDER BY createdAt.
do {
const batch = await this.prisma.savedSearch.findMany({
where: {
alertEnabled: true,
...(cursor ? { createdAt: { gt: cursor } } : {}),
},
);
matchCount++;
}
select: { id: true, userId: true, name: true, filters: true, createdAt: true },
orderBy: { createdAt: 'asc' },
take: ALERT_BATCH_SIZE,
});
if (batch.length === 0) break;
for (const search of batch) {
if (search.userId === listing.sellerId) continue;
const filters = normalizeFilters(search.filters);
if (filters.projectId !== projectId) continue;
this.gateway.emitResidentialEvent(
search.userId,
'residential:new-listing-in-project',
{
listingId: listing.id,
projectId,
savedSearchId: search.id,
savedSearchName: search.name,
title: listing.property.title,
price: listing.priceVND.toString(),
district: listing.property.district,
city: listing.property.city,
occurredAt: event.occurredAt.toISOString(),
},
);
matchCount++;
}
cursor = batch[batch.length - 1]!.createdAt;
if (batch.length < ALERT_BATCH_SIZE) break;
} while (true);
if (matchCount > 0) {
this.logger.log(