feat: T4b/T4c wave 8 - weather history, phenology, honey chain, Telegram base
- Weather history from Open-Meteo behind an adapter: worker loop phenology-weather with backoff, Retry-After, User-Agent, chunked writes and forecast-to-archive correction - Phenology: growing degree sum per DWD, phase from reports, GTS and calendar, calibration from previous years, daily job step, SeasonService reads the stored state - Honey chain: prefill for extractions and batches (proposals only), plausibility and withdrawal warnings, Extraction lock, chain events and batch.created webhook from HoneyBatchRecorded - Telegram base: schema, NotificationDelivery outbox, hardened bot client, startup check without crash, no-network-outside-loops guard - ARCHITECTURE.md: sections 11.11-11.13, assumptions 42-46, env table Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
22382d53a2
commit
1971b2d9c4
90 files changed
+8431
-155
No files matched your search
@@ -6,6 +6,8 @@ export interface PollingLoopOptions {
|
||||
intervalMs: number;
|
||||
tick: () => Promise<unknown>;
|
||||
logger: Logger;
|
||||
/** Wait before the first tick after start() (default: none). */
|
||||
initialDelayMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,7 +31,7 @@ export class PollingLoop {
|
||||
// schedules the next one, so ticks never overlap across stop()/start().
|
||||
// The first tick after start() runs immediately, also in that case.
|
||||
if (this.running) this.rerunRequested = true;
|
||||
else this.schedule(0);
|
||||
else this.schedule(this.options.initialDelayMs ?? 0);
|
||||
}
|
||||
|
||||
wake(): void {
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WeatherHistoryModule } from '../weather/history/weather-history.module';
|
||||
import { WorkerRuntime } from './worker-runtime.service';
|
||||
|
||||
/**
|
||||
* The worker loops. Imported by WorkerModule (dist/worker.js) and
|
||||
* AllInOneModule ("all"), never by AppModule: API pods run no background
|
||||
* loops. Its dependencies come from the global Prisma, DomainEvents,
|
||||
* CalendarSync and Webhooks modules of the importing root module.
|
||||
* CalendarSync and Webhooks modules of the importing root module; the weather
|
||||
* source comes with WeatherHistoryModule.
|
||||
*/
|
||||
@Module({
|
||||
imports: [WeatherHistoryModule],
|
||||
providers: [WorkerRuntime],
|
||||
exports: [WorkerRuntime],
|
||||
})
|
||||
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
DRAIN_MAX_ROUNDS,
|
||||
} from '../domain-events/domain-event-dispatcher';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
import type { WeatherHistoryService } from '../weather/history/weather-history.service';
|
||||
import {
|
||||
WEATHER_FIRST_TICK_DELAY_MS,
|
||||
WEATHER_HISTORY_POLL_MS,
|
||||
} from '../weather/history/weather-history.config';
|
||||
import type { WebhookDeliveryWorker } from '../webhooks/webhook-delivery.worker';
|
||||
import { PgListener, PgListenerOptions } from './pg-listener';
|
||||
import { HEALTH_STALE_MS } from './worker-health';
|
||||
@@ -64,6 +69,7 @@ describe('WorkerRuntime', () => {
|
||||
processOutbox: jest.Mock<Promise<void>, [AbortSignal?]>;
|
||||
};
|
||||
let webhookDeliveries: FakeWebhookDeliveries;
|
||||
let weatherHistory: { syncOnce: jest.Mock };
|
||||
let runtime: WorkerRuntime;
|
||||
let logError: jest.SpyInstance;
|
||||
|
||||
@@ -93,6 +99,11 @@ describe('WorkerRuntime', () => {
|
||||
.mockResolvedValue(undefined),
|
||||
};
|
||||
webhookDeliveries = new FakeWebhookDeliveries();
|
||||
weatherHistory = {
|
||||
syncOnce: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ points: 0, daysWritten: 0, failedPoints: 0 }),
|
||||
};
|
||||
logError = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
@@ -102,6 +113,7 @@ describe('WorkerRuntime', () => {
|
||||
dispatcher as unknown as DomainEventDispatcher,
|
||||
calendarSync as unknown as CalendarSyncService,
|
||||
webhookDeliveries as unknown as WebhookDeliveryWorker,
|
||||
weatherHistory as unknown as WeatherHistoryService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -122,6 +134,7 @@ describe('WorkerRuntime', () => {
|
||||
expect(CALENDAR_OUTBOX_POLL_MS).toBe(60_000);
|
||||
expect(WEBHOOK_DELIVERY_POLL_MS).toBe(5_000);
|
||||
expect(HEALTH_QUERY_TIMEOUT_MS).toBe(5_000);
|
||||
expect(WEATHER_HISTORY_POLL_MS).toBe(3_600_000);
|
||||
});
|
||||
|
||||
it('starts the listener and every loop at bootstrap', async () => {
|
||||
@@ -381,4 +394,37 @@ describe('WorkerRuntime', () => {
|
||||
await shutdown;
|
||||
expect(stopped).toBe(true);
|
||||
});
|
||||
|
||||
it('runs the loop without a wake channel and stops on shutdown', async () => {
|
||||
await bootstrap();
|
||||
|
||||
// The phenology-weather loop has no channel: nothing to LISTEN to.
|
||||
expect(listenerOptions().channels).toEqual([
|
||||
'domain_event',
|
||||
'calendar_sync',
|
||||
'webhook_delivery',
|
||||
]);
|
||||
// The first pass waits 10 s: not in the startup burst of the other loops.
|
||||
expect(weatherHistory.syncOnce).not.toHaveBeenCalled();
|
||||
await jest.advanceTimersByTimeAsync(WEATHER_FIRST_TICK_DELAY_MS - 1);
|
||||
expect(weatherHistory.syncOnce).not.toHaveBeenCalled();
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(weatherHistory.syncOnce).toHaveBeenCalledTimes(1);
|
||||
const [today, signal] = weatherHistory.syncOnce.mock.calls[0] as [
|
||||
string,
|
||||
AbortSignal,
|
||||
];
|
||||
expect(today).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(signal.aborted).toBe(false);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(WEATHER_HISTORY_POLL_MS - 1);
|
||||
expect(weatherHistory.syncOnce).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(weatherHistory.syncOnce).toHaveBeenCalledTimes(2);
|
||||
|
||||
await runtime.onModuleDestroy();
|
||||
expect(signal.aborted).toBe(true);
|
||||
await jest.advanceTimersByTimeAsync(WEATHER_HISTORY_POLL_MS * 2);
|
||||
expect(weatherHistory.syncOnce).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
|
||||
import { berlinToday } from '../common/dates';
|
||||
import { loggableError } from '../common/loggable-error';
|
||||
import { withTimeout } from '../common/with-timeout';
|
||||
import { WAKE_CHANNELS, WakeChannel } from '../common/worker-wakeup';
|
||||
@@ -13,6 +14,11 @@ import {
|
||||
DRAIN_MAX_ROUNDS,
|
||||
} from '../domain-events/domain-event-dispatcher';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
WEATHER_FIRST_TICK_DELAY_MS,
|
||||
WEATHER_HISTORY_POLL_MS,
|
||||
} from '../weather/history/weather-history.config';
|
||||
import { WeatherHistoryService } from '../weather/history/weather-history.service';
|
||||
import { WebhookDeliveryWorker } from '../webhooks/webhook-delivery.worker';
|
||||
import { PgListener } from './pg-listener';
|
||||
import { PollingLoop } from './polling-loop';
|
||||
@@ -24,11 +30,16 @@ export const WEBHOOK_DELIVERY_POLL_MS = 5_000;
|
||||
/** The health check's SELECT 1 must answer within this. */
|
||||
export const HEALTH_QUERY_TIMEOUT_MS = 5_000;
|
||||
|
||||
/** One background loop: its poll interval and the channel that wakes it. */
|
||||
/**
|
||||
* One background loop: its poll interval and, if an outbox feeds it, the
|
||||
* channel that wakes it early. Loops without a channel only poll.
|
||||
*/
|
||||
interface WorkerLoop {
|
||||
name: string;
|
||||
intervalMs: number;
|
||||
channel: WakeChannel;
|
||||
channel?: WakeChannel;
|
||||
/** Wait before the first tick after the worker started. */
|
||||
initialDelayMs?: number;
|
||||
tick: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
@@ -50,7 +61,8 @@ function databaseUrl(): string {
|
||||
@Injectable()
|
||||
export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
|
||||
private readonly logger = new Logger(WorkerRuntime.name);
|
||||
private readonly loops: ReadonlyMap<WakeChannel, PollingLoop>;
|
||||
private readonly loops: ReadonlyMap<string, PollingLoop>;
|
||||
private readonly loopsByChannel: ReadonlyMap<WakeChannel, PollingLoop>;
|
||||
private listener: PgListener | undefined;
|
||||
/** Aborted at shutdown: running ticks claim no further work. */
|
||||
private readonly stopping = new AbortController();
|
||||
@@ -64,12 +76,25 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
|
||||
private readonly dispatcher: DomainEventDispatcher,
|
||||
private readonly calendarSync: CalendarSyncService,
|
||||
private readonly webhookDeliveries: WebhookDeliveryWorker,
|
||||
private readonly weatherHistory: WeatherHistoryService,
|
||||
) {
|
||||
const loops = this.workerLoops().map((definition) => ({
|
||||
definition,
|
||||
loop: new PollingLoop({
|
||||
name: definition.name,
|
||||
intervalMs: definition.intervalMs,
|
||||
tick: definition.tick,
|
||||
initialDelayMs: definition.initialDelayMs,
|
||||
logger: this.logger,
|
||||
}),
|
||||
}));
|
||||
this.loops = new Map(
|
||||
this.workerLoops().map(({ channel, name, intervalMs, tick }) => [
|
||||
channel,
|
||||
new PollingLoop({ name, intervalMs, tick, logger: this.logger }),
|
||||
]),
|
||||
loops.map(({ definition, loop }) => [definition.name, loop]),
|
||||
);
|
||||
this.loopsByChannel = new Map(
|
||||
loops.flatMap(({ definition, loop }) =>
|
||||
definition.channel ? [[definition.channel, loop] as const] : [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -94,6 +119,15 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
|
||||
channel: WAKE_CHANNELS.webhookDeliveries,
|
||||
tick: () => this.deliverWebhooks(),
|
||||
},
|
||||
{
|
||||
// Daily mean temperatures for the phenology; hourly, no outbox.
|
||||
name: 'phenology-weather',
|
||||
intervalMs: WEATHER_HISTORY_POLL_MS,
|
||||
// Not in the startup burst of the other loops.
|
||||
initialDelayMs: WEATHER_FIRST_TICK_DELAY_MS,
|
||||
tick: () =>
|
||||
this.weatherHistory.syncOnce(berlinToday(), this.stopping.signal),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -101,8 +135,8 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
|
||||
this.progressAt = performance.now();
|
||||
this.listener = new PgListener({
|
||||
connectionString: databaseUrl(),
|
||||
channels: [...this.loops.keys()],
|
||||
onNotify: (channel) => this.loops.get(channel)?.wake(),
|
||||
channels: [...this.loopsByChannel.keys()],
|
||||
onNotify: (channel) => this.loopsByChannel.get(channel)?.wake(),
|
||||
logger: this.logger,
|
||||
});
|
||||
// LISTEN first: work committed from now on wakes a loop, and work
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CalendarSyncModule } from '../calendar-sync/calendar-sync.module';
|
||||
import { DomainEventsModule } from '../domain-events/domain-events.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { PhenologyModule } from '../phenology/phenology.module';
|
||||
import { RemindersModule } from '../reminders/reminders.module';
|
||||
import { SuggestionsModule } from '../suggestions/suggestions.module';
|
||||
import { TasksModule } from '../tasks/tasks.module';
|
||||
@@ -37,6 +38,7 @@ import { WorkerRuntimeModule } from './worker-runtime.module';
|
||||
AutomationModule,
|
||||
SuggestionsModule,
|
||||
AutopilotModule,
|
||||
PhenologyModule,
|
||||
WithdrawalModule,
|
||||
RemindersModule,
|
||||
WorkerRuntimeModule,
|
||||
|
||||
Reference in new issue
Block a user