Discussion on implementation of Scheduled Experience Notification

Some facts we know:

  • Experience notifications can only be sent within a game experience in server side.
  • The current API does not support scheduled notification.

We want to schedule some notifications for daily rewards, maybe the function call looks like this:

ScheduleDailyRewardNotification(player, 1, os.time() + 1*24*60*60) -- day '1' reward, one day later
...

Suppose there are a bunch of such notifications scheduled in the past already, we want to process a bunch of them up to now os.time(). So we naturally want to sort by the scheduled time.

In the following, I choose to use the OrderedDataStore.

local scheduledDataStore = DataStoreService:GetOrderedDataStore("ScheduledNotificationDailyReward")
local ascending = true
local pageSize = 1
local minValue = nil
local maxValue = os.time()
local pages = scheduledDataStore:GetSortedAsync(ascending, pageSize, minValue, maxValue)
while true do
  for _, item in pages:GetCurrentPage() do
    if not item or not item.key or not item.value then
      -- in case it is processed and removed by other servers
      continue
    end
    local success, removedValue = scheduledDataStore:RemoveAsync(item.key)
    if success and removedValue then
      local playerUserId, day = ParseDailyRewardFromKey(item.key)
      CreateNotificationDailyReward(playerUserId, day)
    end
  end
  if pages.IsFinished then break end
  pages:AdvanceToNextPageAsync()
end

Notice that OrderedDataStore can only store a number as value, and it cannot have meta-data. we will shove other data into the key. so we can now implement ScheduleDailyRewardNotification as follow:

function ScheduleDailyRewardNotification(player, day, scheduledTime)
  local key = player.UserId .. "/" .. day
  scheduledDataStore:SetAsync(key, scheduledTime)
end

And the corresponding ParseDailyRewardFromKey:

function ParseDailyRewardFromKey(key)
  local chunks = key:split("/")
  local playerUserId = chunks[1]
  local day = chunks[2]
  return playerUserId, day
end

This theoretically works. but I am not sure how it will behave when multiple servers do the consuming at the same time, whether they would remove and consume the data in sequence and not repeat each other

1 Like

To tackle the potential repeating problem, another choice of implementation is with MemoryStoreQueue (with priority). According to document, its ReadAsync method

returns items from the queue but makes them invisible to other ReadAsync calls for the period of the invisibility timeout.

its AddAsync method usage is:

AddAsync(value: any, expirationNumber: number, priority: number?)

We can implement the ScheduleDailyRewardNotification as follow:

function ScheduleDailyRewardNotification(player, day, scheduledTime)
  -- retain one day more than the scheduledTime, hopefully any server during that day will consume it
  local expirationNumber = scheduledTime + 24 * 60 * 60 
  local value = { playerUserId = player.UserId, day = day }
  scheduledMemoryQueue:AddAsync(value, expirationNumber, scheduledTime)
end

And we process the queue similar to the example in ReadAsync

Notice we don’t have to compress and parse all data into the key anymore, it is another advantage of using MemoryStoreQueue.

However, the memory store has a limit:

64KB + 1KB * [number of users]. The quota applies on the experience level instead of the server level.

And from the wordings, it seems it only count CCU as the [number of users]. So, it means the quota does not increase if players leave the game and do not come back.

Each queue also has a limit:

  • Maximum number of items: 1,000,000
  • Maximum total size (including keys for sorted map): 100 MB

It means if we only look at the number of items, even if we use a separate queue for each daily rewards, we can only serve up to 1M players. And from the 100MB limit, each of those items can only have 104 bytes of data. These mean MemoryStoreQueue is not suitable for the job.