fix(meeting-document): hold quota for in-flight generation claims
This commit is contained in:
parent
f4724ddf53
commit
b35676c75c
5 changed files with 994 additions and 179 deletions
|
|
@ -0,0 +1,252 @@
|
|||
-- Meeting document generation: count in-flight claims against the allowance.
|
||||
--
|
||||
-- claim_meeting_document_generation_v1 used to read daily_usage without any
|
||||
-- lock or reservation, while usage was charged only by
|
||||
-- commit_meeting_document_generation_v1 after the provider call. N parallel
|
||||
-- requests with fresh idempotency keys therefore all passed the claim and each
|
||||
-- paid for a 4096-token Anthropic call (Opus on paid tiers) before commit
|
||||
-- rejected N-1 of them with generation_quota_exceeded.
|
||||
--
|
||||
-- A request row in status 'processing' now holds one unit of the allowance:
|
||||
-- * claim takes the same per-user/per-feature advisory lock as commit and
|
||||
-- rejects when recorded usage plus in-flight claims would exceed the base
|
||||
-- limit plus remaining overage credits;
|
||||
-- * fail_meeting_document_generation_v1 releases the unit (status -> failed);
|
||||
-- * commit converts it into recorded usage (status -> succeeded).
|
||||
-- A crashed worker cannot hold a unit forever: in-flight rows stop counting
|
||||
-- after a 10 minute lease, well beyond the 60 s provider timeout.
|
||||
-- Replaying an existing idempotency key skips the quota check, since it never
|
||||
-- starts new provider work.
|
||||
--
|
||||
-- Also: `INSERT ... ON CONFLICT DO NOTHING RETURNING true INTO inserted` leaves
|
||||
-- `inserted` NULL on a replay, so the old payload carried "claimed": null and
|
||||
-- the edge function rejected it as an invalid shape (500) instead of returning
|
||||
-- the idempotent result. The payload now always carries a boolean.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS meeting_document_generation_in_flight_idx
|
||||
ON public.meeting_document_generation_requests(user_id, quota_feature, created_at)
|
||||
WHERE status = 'processing';
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.claim_meeting_document_generation_v1(
|
||||
p_actor_id uuid,
|
||||
p_idempotency_key uuid,
|
||||
p_meeting_id uuid,
|
||||
p_template_id uuid,
|
||||
p_title text,
|
||||
p_model text
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog, public, auth, extensions
|
||||
AS $$
|
||||
DECLARE
|
||||
meeting_row public.meetings;
|
||||
template_row public.user_templates;
|
||||
transcript_value text;
|
||||
transcript_digest text;
|
||||
request_digest text;
|
||||
request_row public.meeting_document_generation_requests;
|
||||
inserted boolean := false;
|
||||
tier_value text := 'free';
|
||||
overage_value integer := 0;
|
||||
quota_feature_value text;
|
||||
quota_limit_value integer;
|
||||
quota_period_value text;
|
||||
current_usage bigint := 0;
|
||||
in_flight bigint := 0;
|
||||
is_replay boolean := false;
|
||||
safe_title text := trim(p_title);
|
||||
BEGIN
|
||||
IF p_actor_id IS NULL OR p_idempotency_key IS NULL OR p_meeting_id IS NULL OR p_template_id IS NULL THEN
|
||||
RAISE EXCEPTION 'generation_identifiers_required' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
IF char_length(safe_title) NOT BETWEEN 1 AND 160 THEN
|
||||
RAISE EXCEPTION 'invalid_document_title' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
IF p_model NOT IN ('claude-haiku-4-5-20251001', 'claude-sonnet-4-6', 'claude-opus-4-6') THEN
|
||||
RAISE EXCEPTION 'invalid_generation_model' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
SELECT * INTO meeting_row FROM public.meetings WHERE id = p_meeting_id;
|
||||
IF meeting_row.id IS NULL THEN
|
||||
RAISE EXCEPTION 'meeting_not_found' USING ERRCODE = 'P0002';
|
||||
END IF;
|
||||
IF meeting_row.user_id <> p_actor_id AND NOT (
|
||||
meeting_row.team_id IS NOT NULL
|
||||
AND (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.teams
|
||||
WHERE id = meeting_row.team_id AND owner_id = p_actor_id
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.team_members
|
||||
WHERE team_id = meeting_row.team_id
|
||||
AND user_id = p_actor_id
|
||||
AND role IN ('owner', 'admin')
|
||||
)
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'meeting_generation_forbidden' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
SELECT * INTO template_row
|
||||
FROM public.user_templates
|
||||
WHERE id = p_template_id
|
||||
AND user_id = p_actor_id
|
||||
AND template_kind = 'meeting_document';
|
||||
IF template_row.id IS NULL THEN
|
||||
RAISE EXCEPTION 'meeting_template_not_found' USING ERRCODE = 'P0002';
|
||||
END IF;
|
||||
|
||||
SELECT nullif(string_agg(
|
||||
CASE WHEN nullif(trim(transcript.speaker), '') IS NULL
|
||||
THEN transcript.text
|
||||
ELSE trim(transcript.speaker) || ': ' || transcript.text
|
||||
END,
|
||||
E'\n' ORDER BY transcript.segment_index
|
||||
), '')
|
||||
INTO transcript_value
|
||||
FROM public.transcripts AS transcript
|
||||
WHERE transcript.meeting_id = meeting_row.id;
|
||||
|
||||
transcript_value := coalesce(
|
||||
transcript_value,
|
||||
nullif(trim(meeting_row.edited_transcript), ''),
|
||||
nullif(trim(meeting_row.raw_transcript), '')
|
||||
);
|
||||
IF transcript_value IS NULL THEN
|
||||
RAISE EXCEPTION 'meeting_transcript_required' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
IF char_length(transcript_value) > 48000 THEN
|
||||
RAISE EXCEPTION 'meeting_transcript_too_large' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
SELECT coalesce(subscription.tier, 'free'), coalesce(subscription.overage_credits, 0)
|
||||
INTO tier_value, overage_value
|
||||
FROM public.subscriptions AS subscription
|
||||
WHERE subscription.user_id = p_actor_id;
|
||||
tier_value := coalesce(tier_value, 'free');
|
||||
overage_value := coalesce(overage_value, 0);
|
||||
|
||||
IF tier_value = 'free' AND p_model <> 'claude-haiku-4-5-20251001' THEN
|
||||
RAISE EXCEPTION 'generation_model_not_allowed' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
quota_feature_value := CASE
|
||||
WHEN p_model LIKE '%sonnet%' THEN 'llm_sonnet'
|
||||
WHEN p_model LIKE '%opus%' THEN 'llm_opus'
|
||||
ELSE 'llm_haiku'
|
||||
END;
|
||||
quota_limit_value := CASE
|
||||
WHEN tier_value = 'free' AND quota_feature_value = 'llm_haiku' THEN 250
|
||||
WHEN tier_value = 'free' THEN 0
|
||||
WHEN tier_value = 'pro' AND quota_feature_value = 'llm_haiku' THEN 1500
|
||||
WHEN tier_value = 'pro' AND quota_feature_value = 'llm_sonnet' THEN 300
|
||||
WHEN tier_value = 'pro' AND quota_feature_value = 'llm_opus' THEN 50
|
||||
WHEN tier_value = 'pro_plus' AND quota_feature_value = 'llm_haiku' THEN -1
|
||||
WHEN tier_value = 'pro_plus' AND quota_feature_value = 'llm_sonnet' THEN 1500
|
||||
WHEN tier_value = 'pro_plus' AND quota_feature_value = 'llm_opus' THEN 300
|
||||
WHEN tier_value = 'team' AND quota_feature_value = 'llm_haiku' THEN -1
|
||||
WHEN tier_value = 'team' AND quota_feature_value = 'llm_sonnet' THEN 3000
|
||||
WHEN tier_value = 'team' AND quota_feature_value = 'llm_opus' THEN 600
|
||||
WHEN tier_value = 'enterprise' THEN -1
|
||||
ELSE 0
|
||||
END;
|
||||
quota_period_value := CASE WHEN tier_value = 'free' THEN 'weekly' ELSE 'daily' END;
|
||||
IF quota_limit_value = 0 THEN
|
||||
RAISE EXCEPTION 'generation_quota_exceeded' USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
|
||||
IF quota_limit_value > 0 THEN
|
||||
-- Same lock key as commit_meeting_document_generation_v1, so claims and
|
||||
-- commits for one user and feature see each other's in-flight rows.
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended(p_actor_id::text || ':' || quota_feature_value, 0));
|
||||
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM public.meeting_document_generation_requests
|
||||
WHERE user_id = p_actor_id AND idempotency_key = p_idempotency_key
|
||||
) INTO is_replay;
|
||||
|
||||
IF NOT is_replay THEN
|
||||
SELECT coalesce(sum(usage.count), 0)
|
||||
INTO current_usage
|
||||
FROM public.daily_usage AS usage
|
||||
WHERE usage.user_id = p_actor_id
|
||||
AND usage.feature = quota_feature_value
|
||||
AND usage.date >= CASE quota_period_value
|
||||
WHEN 'weekly' THEN current_date - 6
|
||||
ELSE current_date
|
||||
END;
|
||||
|
||||
SELECT count(*)
|
||||
INTO in_flight
|
||||
FROM public.meeting_document_generation_requests AS pending
|
||||
WHERE pending.user_id = p_actor_id
|
||||
AND pending.quota_feature = quota_feature_value
|
||||
AND pending.status = 'processing'
|
||||
AND pending.created_at > now() - interval '10 minutes';
|
||||
|
||||
-- Remaining allowance = unused base units + overage credits. With no
|
||||
-- in-flight claims this is exactly the previous rule
|
||||
-- (usage >= limit AND overage <= 0).
|
||||
IF in_flight >= greatest(quota_limit_value - current_usage, 0) + greatest(overage_value, 0) THEN
|
||||
RAISE EXCEPTION 'generation_quota_exceeded' USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
transcript_digest := encode(extensions.digest(transcript_value, 'sha256'), 'hex');
|
||||
request_digest := encode(extensions.digest(
|
||||
jsonb_build_object(
|
||||
'meeting_id', meeting_row.id,
|
||||
'template_id', template_row.id,
|
||||
'template_revision', template_row.revision,
|
||||
'transcript_hash', transcript_digest,
|
||||
'title', safe_title,
|
||||
'model', p_model
|
||||
)::text,
|
||||
'sha256'
|
||||
), 'hex');
|
||||
|
||||
INSERT INTO public.meeting_document_generation_requests(
|
||||
user_id, idempotency_key, meeting_id, template_id, request_hash,
|
||||
document_title, model, quota_feature, quota_limit, quota_period,
|
||||
template_revision, template_type, transcript_hash, status
|
||||
)
|
||||
VALUES (
|
||||
p_actor_id, p_idempotency_key, meeting_row.id, template_row.id, request_digest,
|
||||
safe_title, p_model, quota_feature_value, quota_limit_value, quota_period_value,
|
||||
template_row.revision, template_row.template_type, transcript_digest, 'processing'
|
||||
)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING true INTO inserted;
|
||||
|
||||
SELECT * INTO request_row
|
||||
FROM public.meeting_document_generation_requests
|
||||
WHERE user_id = p_actor_id AND idempotency_key = p_idempotency_key;
|
||||
|
||||
IF NOT coalesce(inserted, false) THEN
|
||||
IF request_row.request_hash <> request_digest THEN
|
||||
RAISE EXCEPTION 'generation_idempotency_conflict' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'claimed', coalesce(inserted, false),
|
||||
'status', request_row.status,
|
||||
'documentId', request_row.document_id,
|
||||
'meetingTitle', coalesce(meeting_row.title, 'Meeting'),
|
||||
'documentTitle', request_row.document_title,
|
||||
'templateType', request_row.template_type,
|
||||
'systemPrompt', CASE WHEN coalesce(inserted, false) THEN template_row.system_prompt ELSE NULL END,
|
||||
'transcript', CASE WHEN coalesce(inserted, false) THEN transcript_value ELSE NULL END,
|
||||
'model', request_row.model
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.claim_meeting_document_generation_v1(uuid, uuid, uuid, uuid, text, text)
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.claim_meeting_document_generation_v1(uuid, uuid, uuid, uuid, text, text)
|
||||
TO service_role;
|
||||
Loading…
Add table
Add a link
Reference in a new issue