Files
hackerspace.sh/scripts/045_class_signup_concurrency.sql
Moheeb Zara 705f03a299 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.
2026-05-19 01:30:07 -07:00

96 lines
3.8 KiB
PL/PgSQL

-- =============================================================================
-- 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;
$$;