mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
feat(ai): durable provider-health ledger and credential-expired surfacing
Promote the volatile per-worker last-working cache to a Postgres provider-health ledger so the fallback chain shares one health signal across every instance, modelled on the atomic-upsert rate limiter. The ledger holds a durable negative cache: an auth-class failure (401/403) benches that provider for a cooldown instead of re-burning the dead round-trip on every generation; a sustained hard failure backs off briefly. The local model is never benched — it stays the guaranteed floor of the chain. Every ledger interaction fails open, so a DB error falls back to today's chain walk exactly. When the user's primary provider fails auth-class, the Coach emits a distinct credential_expired frame that deep-links to reconnect rather than a generic "try again later". Adds the provider_health table (migration 0112) and the errorCredentialExpired copy across all locales.
This commit is contained in:
1 parent
6db3bef413
commit
fbd45f3044
15 files changed
+949
-3
No files matched your search
@@ -0,0 +1,43 @@
|
||||
-- v1.11.0 W1 — durable, multi-instance provider-health ledger.
|
||||
--
|
||||
-- Promotes the volatile per-worker last-working cache in
|
||||
-- `provider-runner.ts` to Postgres, modelled on the atomic-upsert
|
||||
-- rate-limiter table so every worker shares one health signal. One row
|
||||
-- per (user, provider chain entry):
|
||||
-- - the runner records each generation outcome (ok / hard_failed /
|
||||
-- auth_failed) and reads the row to deprioritise or skip a provider
|
||||
-- inside its backoff window instead of re-burning a dead round-trip;
|
||||
-- - an auth-class failure (401/403) sets `last_result = 'auth_failed'`
|
||||
-- plus a `next_retry_at` cooldown — a durable negative cache that
|
||||
-- also drives the proactive `credential_expired` surfacing.
|
||||
--
|
||||
-- Purely additive: one new table, no enum changes, no backfill. Until a
|
||||
-- provider fails the table is empty and behaviour matches today.
|
||||
-- Reversibility: forward-only; dropping the table loses only the cache,
|
||||
-- and the runner repopulates it on the next outcome.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "provider_health" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"provider_type" TEXT NOT NULL,
|
||||
"last_result" TEXT NOT NULL,
|
||||
"last_status" INTEGER,
|
||||
"consecutive_failures" INTEGER NOT NULL DEFAULT 0,
|
||||
"last_ok_at" TIMESTAMP(3),
|
||||
"last_failure_at" TIMESTAMP(3),
|
||||
"next_retry_at" TIMESTAMP(3),
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "provider_health_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "provider_health_user_id_provider_type_key"
|
||||
ON "provider_health" ("user_id", "provider_type");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "provider_health_user_id_last_result_idx"
|
||||
ON "provider_health" ("user_id", "last_result");
|
||||
|
||||
ALTER TABLE "provider_health"
|
||||
ADD CONSTRAINT "provider_health_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -424,6 +424,10 @@ model User {
|
||||
// scoped, time-limited, revocable read view of the health record to a
|
||||
// clinician holding the raw `hls_` token (stored only as an HMAC hash).
|
||||
clinicianShareLinks ClinicianShareLink[]
|
||||
// v1.11.0 W1 — durable per-provider AI-health ledger (one row per
|
||||
// provider chain entry) so a known-bad credential is negatively cached
|
||||
// across workers instead of re-burning a 401 every generation.
|
||||
providerHealth ProviderHealth[]
|
||||
|
||||
// v1.7.0 profile — optional patient-identity fields surfaced on the
|
||||
// health-record export cover (PDF) and the FHIR `Patient` resource.
|
||||
@@ -2387,6 +2391,41 @@ model RateLimit {
|
||||
@@map("rate_limits")
|
||||
}
|
||||
|
||||
/// v1.11.0 W1 — durable, multi-instance provider-health ledger.
|
||||
///
|
||||
/// Promotes the volatile per-worker `lastWorkingCache`
|
||||
/// (`provider-runner.ts`) to Postgres, modelled on the atomic-upsert
|
||||
/// rate-limiter pattern so every worker shares one signal. One row per
|
||||
/// (user, providerType): the runner records each outcome and reads the
|
||||
/// row to (a) deprioritise / skip a provider inside its backoff window
|
||||
/// instead of re-burning a dead round-trip every call, and (b) hold a
|
||||
/// durable negative-cache for an auth-class failure (401/403 =
|
||||
/// credential dead) far longer than the old 1h in-memory TTL, surfacing
|
||||
/// it rather than silently retrying.
|
||||
///
|
||||
/// `nextRetryAt` is the backoff gate: while `lastResult = "auth_failed"`
|
||||
/// and NOW() < `nextRetryAt`, the provider is skipped (negative cache).
|
||||
/// On a success the row clears to `ok` with a null `nextRetryAt`.
|
||||
/// Server-internal: never a Measurement, never ingested from a client.
|
||||
model ProviderHealth {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
providerType String @map("provider_type") // ProviderChainType tag
|
||||
lastResult String @map("last_result") // "ok" | "hard_failed" | "auth_failed"
|
||||
lastStatus Int? @map("last_status") // HTTP status of the last failure; null on ok/network
|
||||
consecutiveFailures Int @default(0) @map("consecutive_failures")
|
||||
lastOkAt DateTime? @map("last_ok_at")
|
||||
lastFailureAt DateTime? @map("last_failure_at")
|
||||
nextRetryAt DateTime? @map("next_retry_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, providerType])
|
||||
@@index([userId, lastResult])
|
||||
@@map("provider_health")
|
||||
}
|
||||
|
||||
// ─── Idempotency Keys (mobile-client replay protection) ──
|
||||
|
||||
/// Caches the response of a POST/PUT/PATCH/DELETE request keyed by
|
||||
|
||||
Reference in new issue
Block a user