feat(osm): Phase 6 — proximity materialized views + refresh cron

* `mv_park_nearest_poi` — for each IndustrialPark, the 3 nearest POI of
  six priority categories (HOSPITAL/BANK/GAS/BUS/METRO/POLICE) within
  5km. Refreshed weekly. Pre-aggregated 6,513 rows from the live
  catalog so the KCN sidebar can render in <50ms instead of running
  ST_Distance for every page hit.
* `mv_poi_density_by_province` — count of POI per (province, category)
  for analytics heatmaps.
* `OsmSyncService.refreshMaterializedViews()` calls
  `REFRESH MATERIALIZED VIEW CONCURRENTLY` so reads aren't blocked.
* New cron entry `weeklyRefreshViews` (Sun 04:00 ICT) and admin
  endpoint `POST /admin/osm/refresh-views` for on-demand refresh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-05-01 12:44:57 +07:00
parent a9770a5f93
commit 884a8d2a63
4 changed files with 80 additions and 0 deletions

View File

@@ -77,4 +77,19 @@ export class OsmSyncCronService {
await this.osmSync.run({ layer: 'industrial-parks', chunk, wait: true });
}
}
/** Refresh proximity / density mat views every Sunday after the weekly
* ward sync had time to settle. Always runs (cheap, no Overpass). */
@Cron('0 4 * * 0', { timeZone: 'Asia/Ho_Chi_Minh' })
async weeklyRefreshViews(): Promise<void> {
try {
await this.osmSync.refreshMaterializedViews();
} catch (err) {
this.logger.error(
`Refresh views failed: ${err instanceof Error ? err.message : err}`,
err instanceof Error ? err.stack : undefined,
'OsmSyncCronService',
);
}
}
}

View File

@@ -78,6 +78,22 @@ export class OsmSyncService {
private readonly logger: LoggerService,
) {}
/**
* Refresh the proximity / density materialized views. Called by the
* cron and from the admin "Refresh views" button. Runs concurrently
* (`CONCURRENTLY`) so reads aren't blocked.
*/
async refreshMaterializedViews(): Promise<void> {
this.logger.log('Refreshing materialized views', 'OsmSyncService');
await this.prisma.$executeRawUnsafe(
`REFRESH MATERIALIZED VIEW CONCURRENTLY "mv_park_nearest_poi"`,
);
await this.prisma.$executeRawUnsafe(
`REFRESH MATERIALIZED VIEW "mv_poi_density_by_province"`,
);
this.logger.log('Materialized views refreshed', 'OsmSyncService');
}
/** Look up a sync layer by its (layer, category) tuple. */
findLayer(layer: string, category?: string | null): OsmSyncLayerDef | undefined {
return SYNC_LAYERS.find(

View File

@@ -61,4 +61,14 @@ export class OsmSyncController {
new TriggerOsmSyncCommand(dto.layer, dto.category, dto.chunk),
);
}
@ApiOperation({
summary: 'Refresh proximity materialized views',
description: 'Recomputes mv_park_nearest_poi + mv_poi_density_by_province.',
})
@Post('refresh-views')
async refreshViews(): Promise<{ ok: true }> {
await this.osmSync.refreshMaterializedViews();
return { ok: true };
}
}