feat(metadata): cache metadata provider answers

Repeated searches and refreshes asked every provider again, which is slow and runs into provider
rate limits. Keep each provider's answer in Postgres, shared by all users, and reuse it until an
admin-set lifetime runs out. Search screens mark saved results and offer a fresh search, and
explicit refreshes stay live, falling back to a saved answer only when a provider fails.

Empty answers are only kept for API providers that report failures reliably, and a cache-only
request never calls a provider in cooldown. Book Dock's manual refetch stays live-only because
the dock has nowhere to report a fallback. Released iOS builds keep working: every new request
field is optional and newer clients gate it on the metadata-search-cache server feature.
This commit is contained in:
neon
2026-10-10 22:26:26 +00:00
parent c91648eaac
commit 7bb51a7a0b
200 changed files with 35972 additions and 566 deletions
+2
View File
@@ -13,6 +13,8 @@ export const SERVER_FEATURES = [
"bookmark-edit",
"notebook-hub",
"book-request-pairs",
/** Metadata searches (book and author) accept `cache=refresh` and mark candidates answered from the cache. */
"metadata-search-cache",
] as const;
export type ServerFeature = (typeof SERVER_FEATURES)[number];
+4
View File
@@ -1,4 +1,5 @@
import type { BooksPage } from "./book";
import type { MetadataCandidateCacheInfo } from "./metadata-cache";
import type { MergeStrategy } from "./metadata-preferences";
export type AuthorSummary = {
@@ -82,6 +83,9 @@ export type AuthorMetadataCandidate = {
influences?: string[];
};
/** An author candidate as a search response or stream sends it; `cache` only when the cache answered. */
export type AuthorMetadataSearchCandidateEvent = AuthorMetadataCandidate & { cache?: MetadataCandidateCacheInfo };
export type AuthorMetadataProviderInfo = {
key: AuthorMetadataProviderKey;
label: string;
+1
View File
@@ -26,6 +26,7 @@ export * from "./library";
export * from "./format-priority";
export * from "./maintenance";
export * from "./metadata-fetch";
export * from "./metadata-cache";
export * from "./pattern-resolver";
export * from "./query";
export * from "./jump-buckets";
+130
View File
@@ -0,0 +1,130 @@
/** Which provider subsystem a cache entry belongs to. Book and author registries share some keys. */
export const METADATA_CACHE_NAMESPACES = ["book", "author"] as const;
export type MetadataCacheNamespace = (typeof METADATA_CACHE_NAMESPACES)[number];
/** The cache policies a client may request. Every other policy is chosen by the server. */
export const METADATA_CACHE_REQUEST_POLICIES = ["default", "refresh"] as const;
export type MetadataCacheRequestPolicy = (typeof METADATA_CACHE_REQUEST_POLICIES)[number];
export const METADATA_CACHE_STATUSES = ["results", "empty", "found", "not_found"] as const;
export type MetadataCacheEntryStatus = (typeof METADATA_CACHE_STATUSES)[number];
export interface MetadataCacheProviderOverride {
namespace: MetadataCacheNamespace;
provider: string;
/** False turns caching off for this provider only. Absent inherits the global switch. */
enabled?: boolean;
resultsTtlSeconds?: number;
emptyTtlSeconds?: number;
}
export interface MetadataCacheConfig {
enabled: boolean;
resultsTtlSeconds: number;
/** Zero never caches empty answers. Only applies to providers that report failures reliably. */
emptyTtlSeconds: number;
/** How long past its TTL an entry may still answer for a provider that failed. Zero disables. */
staleGraceSeconds: number;
maxStorageMb: number;
providerOverrides: MetadataCacheProviderOverride[];
}
const HOUR = 60 * 60;
const DAY = 24 * HOUR;
export const METADATA_CACHE_LIMITS = {
resultsTtlSeconds: { min: HOUR, max: 365 * DAY },
emptyTtlSeconds: { min: 0, max: 30 * DAY },
staleGraceSeconds: { min: 0, max: 90 * DAY },
maxStorageMb: { min: 50, max: 10240 },
maxProviderOverrides: 64,
} as const;
export const DEFAULT_METADATA_CACHE_CONFIG: MetadataCacheConfig = {
enabled: true,
resultsTtlSeconds: 7 * DAY,
emptyTtlSeconds: 7 * DAY,
staleGraceSeconds: 7 * DAY,
maxStorageMb: 512,
providerOverrides: [],
};
/** A provider as the cache settings see it: whether it caches at all and whether empty answers are kept. */
export interface MetadataCacheProviderInfo {
namespace: MetadataCacheNamespace;
provider: string;
label: string;
cacheable: boolean;
cachesEmptyResults: boolean;
}
export interface MetadataCacheConfigResponse {
config: MetadataCacheConfig;
providers: MetadataCacheProviderInfo[];
}
export interface MetadataCacheProviderStats {
namespace: MetadataCacheNamespace;
provider: string;
entries: number;
emptyEntries: number;
payloadBytes: number;
}
export interface MetadataCacheCounters {
hits: number;
misses: number;
fallbacks: number;
liveCalls: number;
writes: number;
skippedWrites: number;
writeFailures: number;
readFailures: number;
uncacheable: Record<string, number>;
}
export interface MetadataCacheStats {
entries: number;
payloadBytes: number;
relationBytes: number;
maxStorageBytes: number;
oldestFetchedAt: string | null;
providers: MetadataCacheProviderStats[];
/** Since the server started. */
counters: MetadataCacheCounters;
sweepRunning: boolean;
lastSweepAt: string | null;
}
export interface MetadataCacheClearRequest {
namespace?: MetadataCacheNamespace;
provider?: string;
/** Only empty answers (`empty` and `not_found`). */
emptyOnly?: boolean;
}
export interface MetadataCacheClearResponse {
/** Rows removed now. A partial clear may keep removing rows in the background. */
deleted: number;
pending: boolean;
}
/** Where a candidate came from when the cache answered for its provider. */
export interface MetadataCandidateCacheInfo {
fetchedAt: string;
/** True when the entry is past its TTL and answered only because the provider failed. */
stale: boolean;
}
/** A provider the cache answered for during a fetch, instead of or after a live call. */
export interface MetadataCacheServedProvider {
provider: string;
source: "cache" | "fallback";
stale: boolean;
cachedAt: string;
}
/** Added to single-item refresh responses so a client can say which providers were not live. */
export interface MetadataCacheUsage {
fallbackProviders: MetadataCacheServedProvider[];
}
+9
View File
@@ -1,5 +1,6 @@
import type { AudiobookChapter, NarratorRef } from "./audiobook";
import type { CoverMedium } from "./book";
import type { MetadataCacheServedProvider, MetadataCandidateCacheInfo } from "./metadata-cache";
import type { SeriesIndex } from "./series-index";
export const MetadataProviderKey = {
@@ -126,6 +127,12 @@ export interface MetadataCandidate {
communityRatingCount?: number;
}
/**
* A candidate as a search stream sends it. `cache` is present only when the metadata cache answered
* for the candidate's provider; it is never part of the candidate itself.
*/
export type MetadataSearchCandidateEvent = MetadataCandidate & { cache?: MetadataCandidateCacheInfo };
export interface MetadataProviderInfo {
key: MetadataProviderKey;
label: string;
@@ -172,6 +179,8 @@ export interface MetadataFetchDiagnostics {
resolvedFieldCount: number;
/** One entry per cover slot the fetch applied to; a slot outside the book's media is absent. */
coverSlots?: Partial<Record<CoverMedium, MetadataFetchCoverSlotDiagnostics>>;
/** Providers the metadata cache answered for. Absent on servers without the cache. */
cacheServedProviders?: MetadataCacheServedProvider[];
}
export interface ProviderThrottleRuntimeState {