Files
HealthLog/src/lib/environment/request-budget.ts
T
Marc-André Bombeck d81a6ddf25 feat(environment): fill air quality for every past day with entries
A background job fetches air quality, pollen and UV for every earlier
local day with a reading, mood entry, dose, symptom or workout: after the
update (boot and nightly discovery), when the home is set and when air
quality is switched on. A day with a weather row gets its air quality; a
day without one gets both, at the covering location period or else at the
home, also before the home was set. Days the source does not reach (before
2013 in Europe, before August 2022 elsewhere) stay empty and are not
counted.

Pending days are grouped by coarse location and cut into ranges of up to
90 days, joining days at most 14 apart, newest first. A run holds itself to
300 of the account's 400 daily calls and half of each instance window,
stores its progress on the user (migration 0391) and sends a follow-up while
it makes progress; a budget stop waits for the next discovery. The
Location & weather card shows the progress.

The air-quality client now asks pollen and dust from 2021 and skips the
request outside Europe before CAMS global starts.
2026-10-09 09:43:01 +02:00

210 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The instance-wide Open-Meteo request budget (v1.42, #615).
*
* The hosted Open-Meteo APIs are free for non-commercial use within 600 calls
* a minute, 5 000 an hour and 10 000 a day, counted per client address, so
* per instance. A "call" is weighted: a request for more than 10 variables
* or more than two weeks counts as several, `max(1, variables / 10) ×
* max(1, days / 14)` (open-meteo.com/en/pricing). One account's full
* backfill of weather and air quality is around 150 calls; a large instance
* filling hundreds of accounts in one night would run into the daily limit
* and every request after it would fail.
*
* So every request to the archive, the geocoder and the air-quality API asks
* this budget first, with its weight, and is not sent when the budget says
* no. The limits sit below the free ones (500 / 4 000 / 8 000) to leave room
* for clock skew between our windows and theirs. A refused request is not an
* error: the nightly job stops, reports `budget_blocked`, and the next night
* continues where it stopped, because what is missing is exactly what the
* gap fill looks for.
*
* The counter lives in Postgres (`rate_limits`, the table every rate limit
* here uses), so the web process and the worker share it, and the three
* windows are checked and charged together under one advisory lock: a
* request is admitted only when all three have room, and then charged to
* all three. Weights are stored in hundredths of a call, because the column
* is an integer and a 90-day air-quality chunk weighs about 10.9 calls.
*
* An operator running their own Open-Meteo instance (`OPENMETEO_*_URL`) is
* not bound by the hosted limits, but the budget still applies: it is cheap,
* and it keeps a misconfigured instance from being hammered.
*/
import { prisma } from "@/lib/db";
/** One budget window. */
export interface BudgetWindow {
/** Bucket key suffix, also the window's name. */
name: "minute" | "hour" | "day" | "account-day";
windowMs: number;
/** Calls admitted per window (whole calls). */
limit: number;
}
export const OPEN_METEO_BUDGET_WINDOWS: readonly BudgetWindow[] = [
{ name: "minute", windowMs: 60_000, limit: 500 },
{ name: "hour", windowMs: 3_600_000, limit: 4_000 },
{ name: "day", windowMs: 86_400_000, limit: 8_000 },
];
/**
* The share of the daily budget one account may use. The instance-wide
* windows alone let a single account drain the day for everyone (repeated
* backfills of a two-year range), so a request made for an account is also
* charged to that account's own daily bucket. One twentieth of the day is
* 400 calls: more than two full backfills of weather and air quality, and
* still leaves the rest of the day for nineteen other accounts.
*/
export const OPEN_METEO_ACCOUNT_DAY_SHARE = 0.05;
const DAY_LIMIT =
OPEN_METEO_BUDGET_WINDOWS.find((w) => w.name === "day")?.limit ?? 0;
/** The per-account daily window, charged only when a request names an account. */
export const OPEN_METEO_ACCOUNT_DAY_WINDOW: BudgetWindow = {
name: "account-day",
windowMs: 86_400_000,
limit: Math.floor(DAY_LIMIT * OPEN_METEO_ACCOUNT_DAY_SHARE),
};
const BUCKET_PREFIX = "open-meteo-budget";
const LOCK_KEY = "open-meteo-budget";
/** Hundredths of a call: the unit the buckets count in. */
const CENTI = 100;
/**
* The weight of one request in calls, by the hosted pricing rule: more than
* 10 variables or more than 14 days counts proportionally more.
*/
export function openMeteoCallWeight(variables: number, days: number): number {
return Math.max(1, variables / 10) * Math.max(1, days / 14);
}
/** A bucket as stored: count in hundredths, and when its window resets. */
export interface BudgetBucket {
count: number;
resetAt: Date;
}
/**
* Pure admission decision: given the stored buckets (keyed by window name,
* absent when never written), whether `weightCenti` fits in every window, and
* the buckets as they must be written when it does. An expired bucket starts
* over at the request's weight. Exported for tests.
*/
export function admitWeight(
buckets: ReadonlyMap<BudgetWindow["name"], BudgetBucket>,
weightCenti: number,
now: Date,
windows: readonly BudgetWindow[] = OPEN_METEO_BUDGET_WINDOWS,
):
| { admitted: true; next: Map<BudgetWindow["name"], BudgetBucket> }
| { admitted: false; window: BudgetWindow["name"] } {
const next = new Map<BudgetWindow["name"], BudgetBucket>();
for (const w of windows) {
const current = buckets.get(w.name);
const live = current && current.resetAt.getTime() > now.getTime();
const count = (live ? current.count : 0) + weightCenti;
if (count > w.limit * CENTI) return { admitted: false, window: w.name };
next.set(w.name, {
count,
resetAt: live ? current.resetAt : new Date(now.getTime() + w.windowMs),
});
}
return { admitted: true, next };
}
/**
* Ask the budget for one request of `weight` calls. Returns true and charges
* every window when it fits, false (charging nothing) when any window is
* full. With `accountId`, the account's own daily share is checked and
* charged as well (`OPEN_METEO_ACCOUNT_DAY_SHARE`); requests made for no
* particular account (the geocoder) are bound by the instance windows only.
*/
export async function reserveOpenMeteoCalls(
weight: number,
accountId?: string,
): Promise<boolean> {
const weightCenti = Math.max(1, Math.ceil(weight * CENTI));
const windows: readonly BudgetWindow[] = accountId
? [...OPEN_METEO_BUDGET_WINDOWS, OPEN_METEO_ACCOUNT_DAY_WINDOW]
: OPEN_METEO_BUDGET_WINDOWS;
const keyOf = (name: BudgetWindow["name"]) =>
name === "account-day"
? `${BUCKET_PREFIX}:account:${accountId}:day`
: `${BUCKET_PREFIX}:${name}`;
const nameOfKey = new Map(windows.map((w) => [keyOf(w.name), w.name]));
return prisma.$transaction(async (tx) => {
// `pg_advisory_xact_lock` returns void, which the client cannot
// deserialize as a column; selecting FROM it yields a plain row.
await tx.$queryRaw`
SELECT 1 AS locked
FROM pg_advisory_xact_lock(hashtextextended(${LOCK_KEY}, 0))
`;
const rows = await tx.rateLimit.findMany({
where: { key: { in: [...nameOfKey.keys()] } },
select: { key: true, count: true, resetAt: true },
});
const buckets = new Map<BudgetWindow["name"], BudgetBucket>();
for (const row of rows) {
const name = nameOfKey.get(row.key);
if (name) buckets.set(name, { count: row.count, resetAt: row.resetAt });
}
const decision = admitWeight(buckets, weightCenti, new Date(), windows);
if (!decision.admitted) return false;
for (const [name, bucket] of decision.next) {
const key = keyOf(name);
await tx.rateLimit.upsert({
where: { key },
create: { key, count: bucket.count, resetAt: bucket.resetAt },
update: { count: bucket.count, resetAt: bucket.resetAt },
});
}
return true;
});
}
/** Thrown by a client whose request the budget refused. Not a failure. */
export class OpenMeteoBudgetExhaustedError extends Error {
constructor() {
super("open-meteo request budget exhausted");
this.name = "OpenMeteoBudgetExhaustedError";
}
}
/** Calls already charged to each live window, by window name. */
export type BudgetUsage = Partial<Record<BudgetWindow["name"], number>>;
/**
* Read how much of each window is in use, in calls, without charging
* anything. A window never written or already reset reads as absent (zero).
* With `accountId` the account's daily share is read as well. Used by the
* air-quality history backfill, which holds itself under a ceiling of its own
* so the nightly fetch and on-demand requests keep their room.
*/
export async function readOpenMeteoBudgetUsage(
accountId?: string,
now: Date = new Date(),
): Promise<BudgetUsage> {
const keys = new Map<string, BudgetWindow["name"]>(
OPEN_METEO_BUDGET_WINDOWS.map((w) => [
`${BUCKET_PREFIX}:${w.name}`,
w.name,
]),
);
if (accountId) {
keys.set(`${BUCKET_PREFIX}:account:${accountId}:day`, "account-day");
}
const rows = await prisma.rateLimit.findMany({
where: { key: { in: [...keys.keys()] } },
select: { key: true, count: true, resetAt: true },
});
const usage: BudgetUsage = {};
for (const row of rows) {
const name = keys.get(row.key);
if (!name || row.resetAt.getTime() <= now.getTime()) continue;
usage[name] = row.count / CENTI;
}
return usage;
}