d3ro-voice/server/supabase/migrations/20260928020800_llm_quota_reservations.sql

296 lines
10 KiB
PL/PgSQL

-- llm-proxy: reserve LLM quota before calling the provider.
--
-- llm-proxy used to run a read-only checkQuota (a SELECT on daily_usage),
-- call Anthropic, and only then charge with consume_quota. Nothing counted
-- requests that were still in flight, so N parallel requests from one user all
-- passed the pre-check and each paid for a full Anthropic generation before
-- consume_quota rejected every request past the allowance and threw the paid
-- reply away (the same flaw 20260928000037 fixed for meeting documents).
--
-- reserve_llm_quota now takes one unit up front, under the same
-- per-user/per-feature advisory lock that consume_quota uses, and records it
-- in daily_usage immediately, so a concurrent request (or a consume_quota
-- call for the same feature) sees it. The edge function calls the provider
-- only while it holds a reservation, then settles it:
-- * finalize_llm_quota(id, true) -> 'completed' (the unit stays spent)
-- * finalize_llm_quota(id, false) -> 'released' (daily_usage -1, and the
-- overage credit is returned when the unit came from overage)
-- A crashed worker cannot hold a unit forever: reservations whose 10 minute
-- lease has expired are released by the next reserve call for that
-- user/feature, well beyond the proxy's 45 s time-to-first-byte and 180 s
-- stream deadlines.
--
-- p_base_limit / p_period come from PLAN_QUOTA (packages/core plan catalog),
-- exactly like consume_quota. Only service_role may call these functions.
-- Apply this migration before deploying the llm-proxy that calls it.
CREATE TABLE IF NOT EXISTS public.llm_quota_reservations (
id uuid PRIMARY KEY,
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
feature text NOT NULL CHECK (feature IN ('llm_haiku', 'llm_sonnet', 'llm_opus')),
usage_date date NOT NULL DEFAULT CURRENT_DATE,
consumed_from text NOT NULL CHECK (consumed_from IN ('base', 'overage', 'unlimited')),
status text NOT NULL DEFAULT 'reserved' CHECK (status IN ('reserved', 'completed', 'released')),
tier text NOT NULL,
quota_period text NOT NULL CHECK (quota_period IN ('daily', 'weekly')),
quota_limit integer NOT NULL,
current_count integer NOT NULL,
overage_after integer NOT NULL,
lease_expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
finalized_at timestamptz,
release_reason text
);
ALTER TABLE public.llm_quota_reservations ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON TABLE public.llm_quota_reservations FROM PUBLIC, anon, authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.llm_quota_reservations TO service_role;
CREATE INDEX IF NOT EXISTS idx_llm_quota_reservations_reclaim
ON public.llm_quota_reservations(user_id, feature, lease_expires_at)
WHERE status = 'reserved';
CREATE OR REPLACE FUNCTION public.reserve_llm_quota(
p_user_id uuid,
p_reservation_id uuid,
p_feature text,
p_base_limit integer,
p_period text
) RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
existing public.llm_quota_reservations%ROWTYPE;
expired public.llm_quota_reservations%ROWTYPE;
subscription_tier text := 'free';
overage integer := 0;
new_overage integer;
current_count integer := 0;
consumed_from text;
BEGIN
IF p_user_id IS NULL
OR p_reservation_id IS NULL
OR p_feature IS NULL
OR p_feature NOT IN ('llm_haiku', 'llm_sonnet', 'llm_opus')
OR p_base_limit IS NULL
OR p_base_limit < -1
OR p_period IS NULL
OR p_period NOT IN ('daily', 'weekly') THEN
RAISE EXCEPTION 'invalid_llm_quota_reservation' USING ERRCODE = '22023';
END IF;
-- Same lock key as consume_quota so both paths serialise per user/feature.
PERFORM pg_advisory_xact_lock(hashtextextended(p_user_id::text || ':' || p_feature, 20260928));
SELECT * INTO existing
FROM public.llm_quota_reservations
WHERE id = p_reservation_id
FOR UPDATE;
IF FOUND THEN
IF existing.user_id <> p_user_id OR existing.feature <> p_feature THEN
RAISE EXCEPTION 'llm_quota_reservation_conflict' USING ERRCODE = 'PT409';
END IF;
RETURN jsonb_build_object(
'allowed', existing.status IN ('reserved', 'completed'),
'reservation_id', existing.id,
'status', existing.status,
'current', existing.current_count,
'limit', existing.quota_limit,
'period', existing.quota_period,
'tier', existing.tier,
'overage_credits', existing.overage_after,
'consumed_from', existing.consumed_from
);
END IF;
-- Reclaim crashed requests before calculating the next allowance.
FOR expired IN
SELECT *
FROM public.llm_quota_reservations
WHERE user_id = p_user_id
AND feature = p_feature
AND status = 'reserved'
AND lease_expires_at <= now()
FOR UPDATE
LOOP
UPDATE public.daily_usage
SET count = greatest(count - 1, 0)
WHERE user_id = expired.user_id
AND date = expired.usage_date
AND feature = expired.feature;
IF expired.consumed_from = 'overage' THEN
UPDATE public.subscriptions
SET overage_credits = overage_credits + 1,
updated_at = now()
WHERE user_id = expired.user_id;
END IF;
UPDATE public.llm_quota_reservations
SET status = 'released', finalized_at = now(), release_reason = 'lease_expired'
WHERE id = expired.id;
END LOOP;
SELECT coalesce(tier, 'free'), coalesce(overage_credits, 0)
INTO subscription_tier, overage
FROM public.subscriptions
WHERE user_id = p_user_id
FOR UPDATE;
IF NOT FOUND THEN
subscription_tier := 'free';
overage := 0;
END IF;
-- Not available: never spend credits on a model the tier does not include.
IF p_base_limit = 0 THEN
RETURN jsonb_build_object(
'allowed', false,
'reservation_id', NULL,
'status', 'denied',
'current', 0,
'limit', 0,
'period', p_period,
'tier', subscription_tier,
'overage_credits', overage,
'consumed_from', 'none'
);
END IF;
-- daily_usage already includes units held by in-flight reservations.
SELECT coalesce(sum(count), 0)::integer INTO current_count
FROM public.daily_usage
WHERE user_id = p_user_id
AND feature = p_feature
AND date >= CASE WHEN p_period = 'weekly' THEN CURRENT_DATE - 6 ELSE CURRENT_DATE END
AND date <= CURRENT_DATE;
IF p_base_limit = -1 THEN
consumed_from := 'unlimited';
ELSIF current_count < p_base_limit THEN
consumed_from := 'base';
ELSE
UPDATE public.subscriptions
SET overage_credits = overage_credits - 1,
updated_at = now()
WHERE user_id = p_user_id
AND overage_credits > 0
RETURNING overage_credits INTO new_overage;
IF NOT FOUND THEN
RETURN jsonb_build_object(
'allowed', false,
'reservation_id', NULL,
'status', 'denied',
'current', current_count,
'limit', p_base_limit,
'period', p_period,
'tier', subscription_tier,
'overage_credits', 0,
'consumed_from', 'none'
);
END IF;
overage := new_overage;
consumed_from := 'overage';
END IF;
INSERT INTO public.daily_usage(user_id, date, feature, count)
VALUES (p_user_id, CURRENT_DATE, p_feature, 1)
ON CONFLICT (user_id, date, feature)
DO UPDATE SET count = public.daily_usage.count + 1;
current_count := current_count + 1;
INSERT INTO public.llm_quota_reservations(
id, user_id, feature, consumed_from, tier, quota_period, quota_limit,
current_count, overage_after, lease_expires_at
) VALUES (
p_reservation_id, p_user_id, p_feature, consumed_from, subscription_tier, p_period, p_base_limit,
current_count, overage, now() + interval '10 minutes'
);
RETURN jsonb_build_object(
'allowed', true,
'reservation_id', p_reservation_id,
'status', 'reserved',
'current', current_count,
'limit', p_base_limit,
'period', p_period,
'tier', subscription_tier,
'overage_credits', overage,
'consumed_from', consumed_from
);
END;
$$;
CREATE OR REPLACE FUNCTION public.finalize_llm_quota(
p_reservation_id uuid,
p_succeeded boolean
) RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
reservation public.llm_quota_reservations%ROWTYPE;
final_status text;
BEGIN
IF p_reservation_id IS NULL OR p_succeeded IS NULL THEN
RAISE EXCEPTION 'invalid_llm_quota_finalize' USING ERRCODE = '22023';
END IF;
SELECT * INTO reservation
FROM public.llm_quota_reservations
WHERE id = p_reservation_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'llm_quota_reservation_not_found' USING ERRCODE = 'P0002';
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended(reservation.user_id::text || ':' || reservation.feature, 20260928));
SELECT * INTO reservation
FROM public.llm_quota_reservations
WHERE id = p_reservation_id
FOR UPDATE;
IF reservation.status <> 'reserved' THEN
RETURN jsonb_build_object('reservation_id', reservation.id, 'status', reservation.status);
END IF;
IF p_succeeded THEN
final_status := 'completed';
ELSE
UPDATE public.daily_usage
SET count = greatest(count - 1, 0)
WHERE user_id = reservation.user_id
AND date = reservation.usage_date
AND feature = reservation.feature;
IF reservation.consumed_from = 'overage' THEN
UPDATE public.subscriptions
SET overage_credits = overage_credits + 1,
updated_at = now()
WHERE user_id = reservation.user_id;
END IF;
final_status := 'released';
END IF;
UPDATE public.llm_quota_reservations
SET status = final_status,
finalized_at = now(),
release_reason = CASE WHEN p_succeeded THEN NULL ELSE 'provider_failed' END
WHERE id = reservation.id;
RETURN jsonb_build_object('reservation_id', reservation.id, 'status', final_status);
END;
$$;
REVOKE ALL ON FUNCTION public.reserve_llm_quota(uuid, uuid, text, integer, text) FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.finalize_llm_quota(uuid, boolean) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.reserve_llm_quota(uuid, uuid, text, integer, text) TO service_role;
GRANT EXECUTE ON FUNCTION public.finalize_llm_quota(uuid, boolean) TO service_role;
COMMENT ON TABLE public.llm_quota_reservations IS
'Service-only leases that reserve LLM quota before the Anthropic call in llm-proxy and refund failed or expired generations.';