fix(meeting-document): hold quota for in-flight generation claims

This commit is contained in:
Yun Chan 2026-09-28 00:53:47 +09:00
parent f4724ddf53
commit b35676c75c
5 changed files with 994 additions and 179 deletions

View file

@ -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;