-- ============================================================ -- Migration 014: Row-Based Entry Lock Table -- Date: 2026-02-18 -- Purpose: Replace session-based advisory locks with durable row locks. -- ============================================================ CREATE TABLE IF NOT EXISTS entry_locks ( profile_id uuid NOT NULL, symbol text NOT NULL, symbol_normalized text GENERATED ALWAYS AS (lower(symbol)) STORED NOT NULL, owner text NOT NULL, acquired_at timestamptz NOT NULL DEFAULT now(), expires_at timestamptz NOT NULL, PRIMARY KEY (profile_id, symbol_normalized) ); CREATE INDEX IF NOT EXISTS entry_locks_expires_idx ON entry_locks (expires_at); ALTER TABLE entry_locks ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS "Allow entry lock owner" ON entry_locks; CREATE POLICY "Allow entry lock owner" ON entry_locks FOR ALL USING (auth.uid() = profile_id) WITH CHECK (auth.uid() = profile_id); DROP POLICY IF EXISTS "Allow service role entry lock access" ON entry_locks; CREATE POLICY "Allow service role entry lock access" ON entry_locks FOR ALL USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role'); CREATE OR REPLACE FUNCTION fn_try_acquire_entry_lock_row( p_profile_id uuid, p_symbol text, p_owner text, p_ttl_seconds integer DEFAULT 30 ) RETURNS boolean AS $$ DECLARE now_ts timestamptz := now(); ttl integer := GREATEST(COALESCE(p_ttl_seconds, 30), 1); expires timestamptz := now_ts + make_interval(secs := ttl); BEGIN INSERT INTO entry_locks (profile_id, symbol, owner, acquired_at, expires_at) VALUES (p_profile_id, p_symbol, p_owner, now_ts, expires) ON CONFLICT (profile_id, symbol_normalized) WHERE entry_locks.expires_at <= now_ts DO UPDATE SET owner = EXCLUDED.owner, acquired_at = EXCLUDED.acquired_at, expires_at = EXCLUDED.expires_at; RETURN TRUE; EXCEPTION WHEN unique_violation THEN RETURN FALSE; END; $$ LANGUAGE plpgsql VOLATILE; CREATE OR REPLACE FUNCTION fn_release_entry_lock_row( p_profile_id uuid, p_symbol text, p_owner text ) RETURNS boolean AS $$ DECLARE deleted_count integer; BEGIN DELETE FROM entry_locks WHERE profile_id = p_profile_id AND symbol_normalized = lower(p_symbol) AND owner = p_owner; GET DIAGNOSTICS deleted_count = ROW_COUNT; RETURN deleted_count > 0; END; $$ LANGUAGE plpgsql VOLATILE;