-- cast_vote.lua -- Atomic vote casting with 7-ticket system -- -- Business Rules: -- 1. Each user has 7 distinct tickets (creative, visual, etc.) -- 2. Each ticket can only be assigned to ONE program -- 3. A user can only give ONE ticket to each program (no multi-ticket to same program) -- 4. Supports revoke: if user already used this ticket, revoke old vote first -- -- KEYS[1] = vote:user:{userId}:tickets (Hash) -- KEYS[2] = vote:user:{userId}:programs (Set) -- KEYS[3] = vote:count:{programId}:{ticketType} (String counter) -- KEYS[4] = vote:leaderboard:{ticketType} (Sorted Set) -- KEYS[5] = vote:program:{programId}:voters (Set) -- KEYS[6] = vote:sync:queue (List) -- KEYS[7] = vote:lock:{userId} (String) -- -- ARGV[1] = userId -- ARGV[2] = programId -- ARGV[3] = ticketType -- ARGV[4] = timestamp -- ARGV[5] = lockTtlMs local user_tickets_key = KEYS[1] local user_programs_key = KEYS[2] local count_key = KEYS[3] local leaderboard_key = KEYS[4] local program_voters_key = KEYS[5] local sync_queue_key = KEYS[6] local lock_key = KEYS[7] local user_id = ARGV[1] local program_id = ARGV[2] local ticket_type = ARGV[3] local timestamp = ARGV[4] local lock_ttl = tonumber(ARGV[5]) -- Step 1: Acquire distributed lock local lock_acquired = redis.call('SET', lock_key, timestamp, 'NX', 'PX', lock_ttl) if not lock_acquired then return cjson.encode({ success = false, error = 'LOCK_FAILED', message = 'Another vote operation in progress' }) end -- Step 2: Check if user already voted for this program with ANY ticket local already_voted_program = redis.call('SISMEMBER', user_programs_key, program_id) local current_ticket_program = redis.call('HGET', user_tickets_key, ticket_type) -- Case: User trying to vote same program with same ticket (no-op) if current_ticket_program == program_id then redis.call('DEL', lock_key) return cjson.encode({ success = true, message = 'Already voted for this program with this ticket', program_id = program_id, ticket_type = ticket_type, is_duplicate = true }) end -- Case: User already voted for this program with a DIFFERENT ticket if already_voted_program == 1 and current_ticket_program ~= program_id then redis.call('DEL', lock_key) return cjson.encode({ success = false, error = 'ALREADY_VOTED_PROGRAM', message = 'You already voted for this program with another ticket' }) end -- Step 3: If this ticket was used before, revoke the old vote local old_program_id = current_ticket_program local revoked = false if old_program_id and old_program_id ~= false then -- Decrement old program's count local old_count_key = 'vote:count:' .. old_program_id .. ':' .. ticket_type local old_leaderboard_key = 'vote:leaderboard:' .. ticket_type local old_voters_key = 'vote:program:' .. old_program_id .. ':voters' redis.call('DECR', old_count_key) redis.call('ZINCRBY', old_leaderboard_key, -1, old_program_id) redis.call('SREM', old_voters_key, user_id) redis.call('SREM', user_programs_key, old_program_id) revoked = true end -- Step 4: Cast the new vote -- 4a: Set the ticket assignment redis.call('HSET', user_tickets_key, ticket_type, program_id) -- 4b: Add program to user's voted programs redis.call('SADD', user_programs_key, program_id) -- 4c: Increment vote count local new_count = redis.call('INCR', count_key) -- 4d: Update leaderboard redis.call('ZINCRBY', leaderboard_key, 1, program_id) -- 4e: Add user to program's voters redis.call('SADD', program_voters_key, user_id) -- Step 5: Queue for MySQL sync local vote_record = cjson.encode({ action = 'cast', user_id = user_id, program_id = program_id, ticket_type = ticket_type, timestamp = timestamp, revoked_program = old_program_id or nil }) redis.call('RPUSH', sync_queue_key, vote_record) -- Step 6: Release lock redis.call('DEL', lock_key) return cjson.encode({ success = true, program_id = program_id, ticket_type = ticket_type, new_count = new_count, revoked = revoked, revoked_program = old_program_id or nil })