refactor(server): move weather out of services/, give its cache sweep a lifecycle (#1835)

Finishes be-weather-airports-fold. The README has listed weather as the
reference implementation since the migration started, while 513 lines of it sat
in the legacy layer.

The file is nest/weather/weather.impl.ts now, and WeatherService owns what it
could not own before: the cache sweep. That sweep was a bare setInterval at
module scope, so every process that so much as imported the file started a
five-minute timer and nothing ever stopped it — there was nothing to call. It is
startCacheCleanup/stopCacheCleanup now, driven by onModuleInit/onModuleDestroy,
and the handle is unref-ed so a cache sweep can never hold the process open.

The old test file says as much: its second line is "Prevent the module-level
setInterval from running during tests", with a vi.useFakeTimers() to do it. That
workaround is no longer what keeps the timer quiet.

The cache itself stays module state, deliberately. The MCP weather tools call
getWeather directly from outside the container, and a cache is only worth having
if every caller sees the same one. Hoisting it into the class would mean handing
the registrar the container instance to get exactly what a module singleton
already provides — the same trade the place-photo cache resolved the other way,
because there the scheduler needed the instance anyway.

Coverage is the reason this PR carries eleven new tests rather than none. Code
in services/ is ungated; the moment it lands in src/nest it counts against the
80% branch threshold, and weather.impl arrived at 75%. The gap was in paths
worth having: a failing archive lookup with and without an upstream reason, a
far-future date whose climate rows are all null, an expired entry being re-
fetched, and the sweep evicting one on its own. Plus the lifecycle itself —
start is idempotent, stop is idempotent, the handle is unref-ed. 79% now.

services/ is down to 11 files, from 44.
This commit is contained in:
Maurice
2026-08-07 21:47:21 +02:00
committed by GitHub
parent 9c46f31c57
commit a7d789fdd5
11 changed files with 237 additions and 20 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { findByIata, searchAirports } from '../../nest/airports/airports.data';
import { getWeather, getDetailedWeather } from '../../services/weatherService';
import { getWeather, getDetailedWeather } from '../../nest/weather/weather.impl';
import {
TOOL_ANNOTATIONS_READONLY,
ok,
@@ -3,7 +3,7 @@ import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { readEnv } from '../../../app-config';
import { RealtimeService } from '../../realtime/realtime.service';
import { isUpdateConflict } from '../../common/conflictResult';
import { getWeather } from '../../../services/weatherService';
import { getWeather } from '../../weather/weather.impl';
import { BLOCKED_EXTENSIONS, filesDir } from '../../files/files.constants';
import { TripMembershipService } from '../../trip-membership/trip-membership.service';
import { NotificationsService } from '../../notifications/notifications.service';
@@ -2,7 +2,7 @@ import { Controller, Get, HttpException, Query, UseGuards } from '@nestjs/common
import type { WeatherResult } from '@trek/shared';
import { WeatherService } from './weather.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { ApiError } from '../../services/weatherService';
import { ApiError } from './weather.impl';
/**
* /api/weather — first migrated leaf module (the pilot).
@@ -100,7 +100,8 @@ const CACHE_MAX_ENTRIES = 1000;
const CACHE_PRUNE_TARGET = 500;
const CACHE_CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes
setInterval(() => {
/** Drop expired entries, then trim the cache back if it is still over the cap. */
function pruneCache(): void {
const now = Date.now();
for (const [key, entry] of weatherCache) {
if (now > entry.expiresAt) weatherCache.delete(key);
@@ -110,7 +111,31 @@ setInterval(() => {
const toDelete = entries.slice(0, entries.length - CACHE_PRUNE_TARGET);
toDelete.forEach(([key]) => weatherCache.delete(key));
}
}, CACHE_CLEANUP_INTERVAL);
}
let cleanupTimer: NodeJS.Timeout | null = null;
/**
* Start the cache sweep. WeatherService calls this from onModuleInit.
*
* It used to be a bare setInterval at module scope, which meant every process
* that so much as imported this file — including every test worker — started a
* five-minute timer it never stopped. Nothing cleaned it up because there was
* nothing to call.
*/
export function startCacheCleanup(): void {
if (cleanupTimer) return;
cleanupTimer = setInterval(pruneCache, CACHE_CLEANUP_INTERVAL);
// Never hold the process open for a cache sweep.
cleanupTimer.unref?.();
}
/** Stop the sweep (onModuleDestroy). Idempotent. */
export function stopCacheCleanup(): void {
if (!cleanupTimer) return;
clearInterval(cleanupTimer);
cleanupTimer = null;
}
const TTL_FORECAST_MS = 60 * 60 * 1000; // 1 hour
const TTL_CURRENT_MS = 15 * 60 * 1000; // 15 minutes
+22 -8
View File
@@ -1,16 +1,30 @@
import { Injectable } from '@nestjs/common';
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import type { WeatherResult } from '@trek/shared';
import { getWeather, getDetailedWeather } from '../../services/weatherService';
import { getWeather, getDetailedWeather, startCacheCleanup, stopCacheCleanup } from './weather.impl';
/**
* Thin Nest wrapper around the existing weather service. It delegates to the
* exact same `getWeather` / `getDetailedWeather` functions the legacy route and
* the MCP tools use, so behaviour — including the shared in-memory cache and the
* Open-Meteo calls — is identical. No logic is duplicated; the upstream service
* stays the single source of truth (still consumed by the MCP weather tools).
* The weather domain's container face, and the owner of the cache sweep's
* lifecycle.
*
* The Open-Meteo calls and the response shaping live in weather.impl.ts, which
* also holds the process-wide cache. That cache stays module state on purpose:
* the MCP weather tools call getWeather directly, from outside the container,
* and a cache is only worth having if every caller sees the same one. Moving it
* into this class would mean handing the registrar the container instance to
* achieve exactly what a module singleton already gives.
*
* What did have to move is the sweep timer — see startCacheCleanup.
*/
@Injectable()
export class WeatherService {
export class WeatherService implements OnModuleInit, OnModuleDestroy {
onModuleInit(): void {
startCacheCleanup();
}
onModuleDestroy(): void {
stopCacheCleanup();
}
get(lat: string, lng: string, date: string | undefined, lang: string): Promise<WeatherResult> {
return getWeather(lat, lng, date, lang) as Promise<WeatherResult>;
}