fix(classes): atomic signup/cancel under per-session advisory lock (D11)
Deferred deeper-audit P1. signUpForClass (count->decide->insert) and cancelMySignup (cancel->recount->promote) were non-atomic: concurrent signups at the capacity boundary over-enrolled and concurrent cancels double-promoted waitlisters (dynamic capacity can't be a DB constraint). Migration 045 adds class_signup_tx/class_cancel_tx (SECURITY DEFINER, pg_advisory_xact_lock per session) doing the decision+write atomically; actions call the RPCs and keep all pre-checks. Removed the now-unused effectiveCapacity/computeSignupStatus/pickPromotion imports + isUniqueViolation (pure rules still live + tested in classes-logic). 528 tests; build clean.
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
-- =============================================================================
|
||||
-- 045: Class signup/cancel concurrency (P1, deeper-audit deferred)
|
||||
-- =============================================================================
|
||||
-- signUpForClass did count-registered -> decide -> insert and cancelMySignup
|
||||
-- did cancel -> recount -> promote, both non-atomic: concurrent signups at
|
||||
-- the capacity boundary over-enrolled, and concurrent cancels double-promoted
|
||||
-- waitlisters. Capacity is dynamic (session OR class), so a constraint can't
|
||||
-- express it. These functions serialize all signup/cancel for a session with
|
||||
-- a per-session advisory xact lock and do the decision + write atomically.
|
||||
--
|
||||
-- The pure lib/classes-logic.ts (computeSignupStatus / pickPromotion) remains
|
||||
-- the documented rule and is unit-tested; the SQL below MUST match it and is
|
||||
-- the runtime authority. Idempotent: CREATE OR REPLACE.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.class_signup_tx(
|
||||
p_session_id uuid, p_space_id uuid, p_member_id uuid
|
||||
) RETURNS TABLE(signup_id uuid, signup_status text, err text)
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
|
||||
DECLARE
|
||||
v_cap int; v_reg int; v_status text; v_id uuid;
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock(hashtext(p_session_id::text));
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM class_signups
|
||||
WHERE session_id = p_session_id AND member_id = p_member_id
|
||||
AND space_id = p_space_id AND status <> 'cancelled'
|
||||
) THEN
|
||||
RETURN QUERY SELECT NULL::uuid, NULL::text, 'already'::text; RETURN;
|
||||
END IF;
|
||||
|
||||
SELECT COALESCE(s.capacity, c.capacity) INTO v_cap
|
||||
FROM class_sessions s JOIN classes c ON c.id = s.class_id
|
||||
WHERE s.id = p_session_id AND s.space_id = p_space_id;
|
||||
IF NOT FOUND THEN
|
||||
RETURN QUERY SELECT NULL::uuid, NULL::text, 'no_session'::text; RETURN;
|
||||
END IF;
|
||||
|
||||
SELECT count(*) INTO v_reg FROM class_signups
|
||||
WHERE session_id = p_session_id AND status = 'registered';
|
||||
|
||||
v_status := CASE WHEN v_cap IS NULL OR v_reg < v_cap
|
||||
THEN 'registered' ELSE 'waitlisted' END;
|
||||
|
||||
INSERT INTO class_signups (session_id, space_id, member_id, status)
|
||||
VALUES (p_session_id, p_space_id, p_member_id, v_status)
|
||||
RETURNING id INTO v_id;
|
||||
|
||||
RETURN QUERY SELECT v_id, v_status, NULL::text;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.class_cancel_tx(
|
||||
p_session_id uuid, p_space_id uuid, p_member_id uuid
|
||||
) RETURNS TABLE(cancelled_id uuid, promoted_id uuid, err text)
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
|
||||
DECLARE
|
||||
v_sid uuid; v_status text; v_cap int; v_reg int; v_prom uuid;
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock(hashtext(p_session_id::text));
|
||||
|
||||
SELECT id, status INTO v_sid, v_status FROM class_signups
|
||||
WHERE session_id = p_session_id AND member_id = p_member_id
|
||||
AND space_id = p_space_id AND status <> 'cancelled'
|
||||
LIMIT 1;
|
||||
IF NOT FOUND THEN
|
||||
RETURN QUERY SELECT NULL::uuid, NULL::uuid, 'not_signed_up'::text; RETURN;
|
||||
END IF;
|
||||
|
||||
UPDATE class_signups SET status = 'cancelled' WHERE id = v_sid;
|
||||
|
||||
v_prom := NULL;
|
||||
IF v_status = 'registered' THEN
|
||||
SELECT COALESCE(s.capacity, c.capacity) INTO v_cap
|
||||
FROM class_sessions s JOIN classes c ON c.id = s.class_id
|
||||
WHERE s.id = p_session_id AND s.space_id = p_space_id;
|
||||
|
||||
SELECT count(*) INTO v_reg FROM class_signups
|
||||
WHERE session_id = p_session_id AND status = 'registered';
|
||||
|
||||
IF v_cap IS NULL OR v_reg < v_cap THEN
|
||||
SELECT id INTO v_prom FROM class_signups
|
||||
WHERE session_id = p_session_id AND space_id = p_space_id
|
||||
AND status = 'waitlisted'
|
||||
ORDER BY signed_up_at ASC LIMIT 1;
|
||||
IF v_prom IS NOT NULL THEN
|
||||
UPDATE class_signups SET status = 'registered' WHERE id = v_prom;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN QUERY SELECT v_sid, v_prom, NULL::text;
|
||||
END;
|
||||
$$;
|
||||
@@ -2243,6 +2243,79 @@ INSERT INTO public.space_role_permissions (space_id, subject, permission)
|
||||
SELECT id, 'board', 'classes.instruct' FROM public.spaces
|
||||
ON CONFLICT (space_id, subject, permission) DO NOTHING;
|
||||
|
||||
-- Atomic signup/cancel (equivalent to scripts/045). Per-session advisory
|
||||
-- xact lock serializes the capacity decision + write so concurrent signups
|
||||
-- can't over-enroll and concurrent cancels can't double-promote. Mirrors the
|
||||
-- pure lib/classes-logic rule; this SQL is the runtime authority.
|
||||
CREATE OR REPLACE FUNCTION public.class_signup_tx(
|
||||
p_session_id uuid, p_space_id uuid, p_member_id uuid
|
||||
) RETURNS TABLE(signup_id uuid, signup_status text, err text)
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
|
||||
DECLARE
|
||||
v_cap int; v_reg int; v_status text; v_id uuid;
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock(hashtext(p_session_id::text));
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM class_signups
|
||||
WHERE session_id = p_session_id AND member_id = p_member_id
|
||||
AND space_id = p_space_id AND status <> 'cancelled'
|
||||
) THEN
|
||||
RETURN QUERY SELECT NULL::uuid, NULL::text, 'already'::text; RETURN;
|
||||
END IF;
|
||||
SELECT COALESCE(s.capacity, c.capacity) INTO v_cap
|
||||
FROM class_sessions s JOIN classes c ON c.id = s.class_id
|
||||
WHERE s.id = p_session_id AND s.space_id = p_space_id;
|
||||
IF NOT FOUND THEN
|
||||
RETURN QUERY SELECT NULL::uuid, NULL::text, 'no_session'::text; RETURN;
|
||||
END IF;
|
||||
SELECT count(*) INTO v_reg FROM class_signups
|
||||
WHERE session_id = p_session_id AND status = 'registered';
|
||||
v_status := CASE WHEN v_cap IS NULL OR v_reg < v_cap
|
||||
THEN 'registered' ELSE 'waitlisted' END;
|
||||
INSERT INTO class_signups (session_id, space_id, member_id, status)
|
||||
VALUES (p_session_id, p_space_id, p_member_id, v_status)
|
||||
RETURNING id INTO v_id;
|
||||
RETURN QUERY SELECT v_id, v_status, NULL::text;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.class_cancel_tx(
|
||||
p_session_id uuid, p_space_id uuid, p_member_id uuid
|
||||
) RETURNS TABLE(cancelled_id uuid, promoted_id uuid, err text)
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
|
||||
DECLARE
|
||||
v_sid uuid; v_status text; v_cap int; v_reg int; v_prom uuid;
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock(hashtext(p_session_id::text));
|
||||
SELECT id, status INTO v_sid, v_status FROM class_signups
|
||||
WHERE session_id = p_session_id AND member_id = p_member_id
|
||||
AND space_id = p_space_id AND status <> 'cancelled'
|
||||
LIMIT 1;
|
||||
IF NOT FOUND THEN
|
||||
RETURN QUERY SELECT NULL::uuid, NULL::uuid, 'not_signed_up'::text; RETURN;
|
||||
END IF;
|
||||
UPDATE class_signups SET status = 'cancelled' WHERE id = v_sid;
|
||||
v_prom := NULL;
|
||||
IF v_status = 'registered' THEN
|
||||
SELECT COALESCE(s.capacity, c.capacity) INTO v_cap
|
||||
FROM class_sessions s JOIN classes c ON c.id = s.class_id
|
||||
WHERE s.id = p_session_id AND s.space_id = p_space_id;
|
||||
SELECT count(*) INTO v_reg FROM class_signups
|
||||
WHERE session_id = p_session_id AND status = 'registered';
|
||||
IF v_cap IS NULL OR v_reg < v_cap THEN
|
||||
SELECT id INTO v_prom FROM class_signups
|
||||
WHERE session_id = p_session_id AND space_id = p_space_id
|
||||
AND status = 'waitlisted'
|
||||
ORDER BY signed_up_at ASC LIMIT 1;
|
||||
IF v_prom IS NOT NULL THEN
|
||||
UPDATE class_signups SET status = 'registered' WHERE id = v_prom;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN QUERY SELECT v_sid, v_prom, NULL::text;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 20. Equipment registry + reservations
|
||||
|
||||
Reference in New Issue
Block a user