Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/instrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ export async function register() {
// Runtime DSN consistency only. Sourcemap upload credentials are build-time
// and are gated in next.config.ts — not re-checked here.
requireSentryEnv();

// Warm rag_aliases so the first post-boot search skips the cold-cache DB RTT.
// Non-blocking: failures are swallowed inside warmEnabledRagAliasCache.
const { warmEnabledRagAliasCache } = await import("@/lib/rag/rag-retrieval-variants");
void warmEnabledRagAliasCache();
}

export { captureRequestError as onRequestError };
89 changes: 87 additions & 2 deletions src/lib/api-rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,88 @@ type InMemoryRateLimitWindow = {
resetAtMs: number;
};

/**
* Short-lived negative cache for subjects already limited by durable storage.
* Never caches allow decisions — that would under-count across instances.
* Only skips RPC RTT while a recent durable consume already returned limited=true.
*/
type DurableRateLimitDenyCacheEntry = {
limit: number;
remaining: number;
retryAfterSeconds: number;
resetAtMs: number;
};

type GlobalWithRateLimitFallback = typeof globalThis & {
__clinicalKbInMemoryApiRateLimits?: Map<string, InMemoryRateLimitWindow>;
__clinicalKbDurableApiRateLimitDenyCache?: Map<string, DurableRateLimitDenyCacheEntry>;
};

const inMemoryApiRateLimits = ((globalThis as GlobalWithRateLimitFallback).__clinicalKbInMemoryApiRateLimits ??=
new Map<string, InMemoryRateLimitWindow>());

const durableApiRateLimitDenyCache = ((
globalThis as GlobalWithRateLimitFallback
).__clinicalKbDurableApiRateLimitDenyCache ??= new Map<string, DurableRateLimitDenyCacheEntry>());

function durableDenyCacheKey(identity: string, bucket: ApiRateLimitBucket) {
return `${identity}:${bucket}`;
}

function durableDenyCacheEnabled() {
// Vitest workers share process-global Maps across unrelated route suites; keep the
// cache off unless a focused unit test explicitly opts in.
if (process.env.VITEST === "true" && process.env.ALLOW_DURABLE_RATE_LIMIT_DENY_CACHE_IN_TESTS !== "1") {
return false;
}
return true;
}

function tryReadDurableRateLimitDenyCache(identity: string, bucket: ApiRateLimitBucket): ApiRateLimitResult | null {
if (!durableDenyCacheEnabled()) return null;
const key = durableDenyCacheKey(identity, bucket);
const entry = durableApiRateLimitDenyCache.get(key);
if (!entry) return null;
const now = Date.now();
if (now >= entry.resetAtMs) {
durableApiRateLimitDenyCache.delete(key);
return null;
}
return {
limited: true,
limit: entry.limit,
remaining: entry.remaining,
retryAfterSeconds: Math.max(1, Math.ceil((entry.resetAtMs - now) / 1000)),
resetAt: new Date(entry.resetAtMs).toISOString(),
};
}

function rememberDurableRateLimitDenyCache(identity: string, bucket: ApiRateLimitBucket, result: ApiRateLimitResult) {
if (!durableDenyCacheEnabled()) return;
const key = durableDenyCacheKey(identity, bucket);
if (!result.limited) {
durableApiRateLimitDenyCache.delete(key);
return;
}
const resetAtMs = Date.parse(result.resetAt);
if (!Number.isFinite(resetAtMs) || resetAtMs <= Date.now()) return;
durableApiRateLimitDenyCache.set(key, {
limit: result.limit,
remaining: result.remaining,
retryAfterSeconds: result.retryAfterSeconds,
resetAtMs,
});
}

/** Test helper: clear durable deny-cache entries between cases. */
export function resetDurableRateLimitDenyCacheForTests() {
durableApiRateLimitDenyCache.clear();
}

/** @deprecated Use resetDurableRateLimitDenyCacheForTests — name kept for older test imports. */
export function resetDurableRateLimitLeasesForTests() {
resetDurableRateLimitDenyCacheForTests();
}
export class ApiRateLimitUnavailableError extends PublicApiError {
constructor() {
super("Rate limit check is temporarily unavailable.", 503, { code: "rate_limit_unavailable" });
Expand All @@ -139,6 +214,9 @@ export async function consumeApiRateLimit(args: {
const defaults = apiRateLimitDefaults[args.bucket];
const limit = args.limit ?? defaults.limit;
const windowSeconds = args.windowSeconds ?? defaults.windowSeconds;
const denied = tryReadDurableRateLimitDenyCache(args.ownerId, args.bucket);
if (denied) return denied;

const { data, error } = await args.supabase.rpc("consume_api_rate_limit", {
p_owner_id: args.ownerId,
p_bucket: args.bucket,
Expand Down Expand Up @@ -168,13 +246,15 @@ export async function consumeApiRateLimit(args: {
throw new ApiRateLimitUnavailableError();
}

return {
const result: ApiRateLimitResult = {
limited: row.limited,
limit: Number(row.limit_value ?? limit),
remaining: Number(row.remaining ?? 0),
retryAfterSeconds: Math.max(1, Number(row.retry_after_seconds ?? windowSeconds)),
resetAt: String(row.reset_at ?? new Date(Date.now() + windowSeconds * 1000).toISOString()),
};
rememberDurableRateLimitDenyCache(args.ownerId, args.bucket, result);
return result;
}

/**
Expand Down Expand Up @@ -219,6 +299,9 @@ export async function consumeSubjectApiRateLimit(args: {
const limit = args.limit ?? defaults.limit;
const windowSeconds = args.windowSeconds ?? defaults.windowSeconds;
const consumeAnonymousLimit = async (subjectKey: string, requestedLimit: number, requestedWindowSeconds: number) => {
const denied = tryReadDurableRateLimitDenyCache(subjectKey, args.bucket);
if (denied) return denied;

const { data, error } = await args.supabase.rpc("consume_api_subject_rate_limit", {
p_subject_key: subjectKey,
p_bucket: args.bucket,
Expand Down Expand Up @@ -256,13 +339,15 @@ export async function consumeSubjectApiRateLimit(args: {
throw new ApiRateLimitUnavailableError();
}

return {
const result = {
limited: row.limited,
limit: Number(row.limit_value ?? requestedLimit),
remaining: Number(row.remaining ?? 0),
retryAfterSeconds: Math.max(1, Number(row.retry_after_seconds ?? requestedWindowSeconds)),
resetAt: String(row.reset_at ?? new Date(Date.now() + requestedWindowSeconds * 1000).toISOString()),
} satisfies ApiRateLimitResult;
rememberDurableRateLimitDenyCache(subjectKey, args.bucket, result);
return result;
};

if (args.bucket !== "answer" && args.bucket !== "document_upload") {
Expand Down
20 changes: 19 additions & 1 deletion src/lib/rag/rag-retrieval-variants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import type { ClinicalQueryAnalysis, RagQueryClass, SearchResult } from "@/lib/t

const maxRetrievalQueryVariants = 4;
export const maxTextRpcQueryVariants = 3;
const ragAliasCacheTtlMs = 60_000;
/** Aliases change rarely; longer TTL cuts cold-instance DB RTT without ranking impact. */
const ragAliasCacheTtlMs = 300_000;
const maxRagAliasCacheEntries = 256;
const maxRagAliasesPerScope = 200;
const maxRagAliasExpansions = 12;
Expand Down Expand Up @@ -112,6 +113,23 @@ export function shouldApplyUnsupportedSearchShortCircuit(
return aliasExpansions.length === 0 && shouldShortCircuitUnsupportedSearch(query, analysis);
}

/**
* Warm the global (owner_id IS NULL) rag_aliases cache at server startup so the
* first search after a deploy does not pay the cold-cache DB RTT.
* Failures are swallowed — warmup must never block boot.
*/
export async function warmEnabledRagAliasCache(
supabase: ReturnType<typeof createAdminClient> = createAdminClient(),
): Promise<void> {
try {
await fetchEnabledRagAliases(supabase, undefined, { includePublic: true });
} catch (error) {
console.warn("rag_aliases cache warmup failed; first request will retry.", {
message: error instanceof Error ? error.message : String(error),
});
}
}

/** Fetch enabled rag aliases. */
export async function fetchEnabledRagAliases(
supabase: ReturnType<typeof createAdminClient>,
Expand Down
4 changes: 2 additions & 2 deletions supabase/drift-manifest.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"generated_at": "2026-07-27T18:49:27.107Z",
"generated_at": "2026-07-31T14:44:06.210Z",
"generator": "scripts/generate-drift-manifest.ts",
"postgres_image": "supabase/postgres:17.6.1.127@sha256:be60aee15997daca475b710b734bc6bfe52cd544dcd7e9fd2ff58210b6747d83",
"schema_sha256": "0255ea652fe8334afeca899246568964c44bb6eb1153884053ff6867bf818fb0",
"schema_sha256": "d45841d7de9b2e4834134204e1919fcbbf239540f9f0003d555739e15f806dd8",
"replay_seconds": 17,
"snapshot": {
"views": [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
-- DB query performance: upsert rate-limit consume + searchable document_images index.
-- Applying to hosted Supabase remains an explicitly approved operator action.

set search_path = public, extensions, pg_temp;

-- Faster consume path: single INSERT ... ON CONFLICT instead of update/insert retry loop.
create or replace function public.consume_api_subject_rate_limit(
p_subject_key text,
p_bucket text,
p_limit integer,
p_window_seconds integer
)
returns table (
limited boolean,
limit_value integer,
remaining integer,
retry_after_seconds integer,
reset_at timestamptz
)
language plpgsql
security definer
set search_path = public, extensions, pg_temp
as $$
declare
v_now timestamptz := now();
v_window_start timestamptz := v_now;
v_count integer;
v_reset_at timestamptz;
begin
if p_subject_key is null or btrim(p_subject_key) = '' then
raise exception 'subject_key is required';
end if;
if p_bucket is null or btrim(p_bucket) = '' then
raise exception 'bucket is required';
end if;
if p_limit < 1 then
raise exception 'limit must be positive';
end if;
if p_window_seconds < 1 then
raise exception 'window must be positive';
end if;

insert into public.api_rate_limit_subjects(subject_key, bucket, window_start, request_count, updated_at)
values (p_subject_key, p_bucket, v_window_start, 1, v_now)
on conflict (subject_key, bucket) do update
set
window_start = case
when public.api_rate_limit_subjects.window_start + make_interval(secs => p_window_seconds) <= v_now
then excluded.window_start
else public.api_rate_limit_subjects.window_start
end,
request_count = case
when public.api_rate_limit_subjects.window_start + make_interval(secs => p_window_seconds) <= v_now
then 1
else public.api_rate_limit_subjects.request_count + 1
end,
updated_at = v_now
returning request_count, window_start + make_interval(secs => p_window_seconds)
into v_count, v_reset_at;

return query
select
v_count > p_limit as limited,
p_limit as limit_value,
greatest(p_limit - v_count, 0) as remaining,
greatest(1, ceiling(extract(epoch from (v_reset_at - v_now)))::integer) as retry_after_seconds,
v_reset_at as reset_at;
end;
$$;

revoke execute on function public.consume_api_subject_rate_limit(text, text, integer, integer) from public, anon, authenticated;
grant execute on function public.consume_api_subject_rate_limit(text, text, integer, integer) to service_role;

create or replace function public.consume_api_rate_limit(
p_owner_id uuid,
p_bucket text,
p_limit integer,
p_window_seconds integer
)
returns table (
limited boolean,
limit_value integer,
remaining integer,
retry_after_seconds integer,
reset_at timestamptz
)
language plpgsql
security definer
set search_path = public, extensions, pg_temp
as $$
declare
v_now timestamptz := now();
v_window_start timestamptz := v_now;
v_count integer;
v_reset_at timestamptz;
begin
if p_owner_id is null then
raise exception 'owner_id is required';
end if;
if p_bucket is null or btrim(p_bucket) = '' then
raise exception 'bucket is required';
end if;
if p_limit < 1 then
raise exception 'limit must be positive';
end if;
if p_window_seconds < 1 then
raise exception 'window must be positive';
end if;

insert into public.api_rate_limits(owner_id, bucket, window_start, request_count, updated_at)
values (p_owner_id, p_bucket, v_window_start, 1, v_now)
on conflict (owner_id, bucket) do update
set
window_start = case
when public.api_rate_limits.window_start + make_interval(secs => p_window_seconds) <= v_now
then excluded.window_start
else public.api_rate_limits.window_start
end,
request_count = case
when public.api_rate_limits.window_start + make_interval(secs => p_window_seconds) <= v_now
then 1
else public.api_rate_limits.request_count + 1
end,
updated_at = v_now
returning request_count, window_start + make_interval(secs => p_window_seconds)
into v_count, v_reset_at;

return query
select
v_count > p_limit as limited,
p_limit as limit_value,
greatest(p_limit - v_count, 0) as remaining,
greatest(1, ceiling(extract(epoch from (v_reset_at - v_now)))::integer) as retry_after_seconds,
v_reset_at as reset_at;
end;
$$;

revoke execute on function public.consume_api_rate_limit(uuid, text, integer, integer) from public, anon, authenticated;
grant execute on function public.consume_api_rate_limit(uuid, text, integer, integer) to service_role;

-- Matches attachPageVisualEvidence: searchable + document_id + page_number, ordered by relevance.
create index if not exists document_images_searchable_doc_page_relevance_idx
on public.document_images (document_id, page_number, clinical_relevance_score desc nulls last)
where searchable is true
and image_type is distinct from 'logo_decorative';
Loading
Loading