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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 20:00:46 +02:00
1 parent 22382d53a2
commit 1971b2d9c4
90 files changed
+8431 -155

No files matched your search

+3 -1
View File
@@ -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,