How to stop MemoryStore queue items paying out twice
A queue item that isn't removed in time becomes visible again and gets processed twice. How to record each item's id so gifts and prizes are only granted once.
What it looks like
A MemoryStoreQueue carries things that pay out, such as cross-server gifts, mail, trade proceeds or rewards from a live event. The server reads an item, grants it, then removes it, but the grant isn't recorded anywhere, so an item that's read again is granted again.
Luau
local items, id = queue:ReadAsync(10, false, 30) for _, gift in items do profile.Coins += gift end pcall(queue.RemoveAsync, queue, id)
How to detect it
- Find every
MemoryStoreService:GetQueueand everyReadAsyncloop, and what the server does with each item. - Roblox's queue guide explains that a read item is only hidden for the queue's invisibility timeout, 30 seconds by default. If
RemoveAsyncisn't called in time, or fails, the item becomes visible again and the next read, on any server, processes it again. - Look for anything that can delay or skip the remove: yields between the read and the remove, such as data store calls,
task.waitor web calls; aRemoveAsyncoutsidepcallor with its failure ignored; an error in the grant that stops the loop before the remove. - Check whether the grant is recorded. Each item should carry a unique id, and the player's saved data should remember which ids were granted, so a repeat is ignored.
- A queue per player, read only by the server that player is in, keeps items from being taken by servers that can't grant them. Check that items aren't removed while the player's data isn't loaded, or they're lost.
- Sorted maps used the same way, read and then removed in two steps, have the same problem.
UpdateAsyncon a sorted map changes a key in one step.
What an exploiter could do
They make the slow path happen on purpose. Sending many gifts at once, joining when the data store is busy, or leaving at the right moment can push processing past the timeout, and each repeat pays out again. Gifts, mail and trade proceeds can be duplicated, along with anything else the queue carries.
How to fix it
- Give every queued item a unique id when it's added, such as one from
HttpService:GenerateGUID. - Before granting, check the id against a list kept in the player's saved data, and record it in the same change that grants the item.
- Keep the work between
ReadAsyncandRemoveAsyncshort, with no yields you don't need, and set an invisibility timeout that covers it. - Call
RemoveAsyncinpcalland log failures. The recorded ids make a missed remove harmless.
Vulnerable example
Vulnerable
local MemoryStoreService = game:GetService("MemoryStoreService") local Players = game:GetService("Players") local PlayerData = require(script.Parent.PlayerData) local function collect(player: Player) local profile = PlayerData.waitFor(player) local queue = MemoryStoreService:GetQueue("Gifts_" .. player.UserId) while profile and player.Parent == Players do local ok, items, id = pcall(queue.ReadAsync, queue, 10, false, 30) if ok and #items > 0 then for _, gift in items do if typeof(gift) == "number" and gift % 1 == 0 and gift > 0 and gift <= 100000 then profile.Coins += gift end end pcall(queue.RemoveAsync, queue, id) elseif not ok then task.wait(5) end end end Players.PlayerAdded:Connect(collect)
Fixed example
Fixed
local MemoryStoreService = game:GetService("MemoryStoreService") local Players = game:GetService("Players") local PlayerData = require(script.Parent.PlayerData) local function apply(profile: PlayerData.Profile, gift: unknown) if typeof(gift) ~= "table" then return end local fields = gift :: { [string]: unknown } local giftId, coins = fields.id, fields.coins if typeof(giftId) ~= "string" or typeof(coins) ~= "number" then return end if coins % 1 ~= 0 or coins < 1 or coins > 100000 then return end local key = "Gift:" .. giftId if not profile.Claimed[key] then profile.Claimed[key] = true profile.Coins += coins end end local function collect(player: Player) local profile = PlayerData.waitFor(player) local queue = MemoryStoreService:GetQueue("Gifts_" .. player.UserId, 60) while profile and player.Parent == Players do local ok, items, id = pcall(queue.ReadAsync, queue, 10, false, 30) if ok and #items > 0 then for _, gift in items do apply(profile, gift) end local removed, err = pcall(queue.RemoveAsync, queue, id) if not removed then warn(`Gift queue remove failed for {player.UserId}: {err}`) end elseif not ok then task.wait(5) end end end Players.PlayerAdded:Connect(collect)
Common false positives
- Queues for matchmaking, where reading an entry twice only means a player is considered twice, don't pay out. Check what processing does.
- Items whose grant is already recorded by id in saved data, like the fixed example, are safe to read twice.
- A queue item that's removed before a slow step, with the slow step made safe to repeat, is fine.