Roblox Data Rollback

You can write your topic however you want, but you need to answer these questions:

  1. What do you want to achieve? Keep it simple and clear!
    I want to roll back all my players data.
  2. What is the issue? Include screenshots / videos if possible!
    A hacker inflated my game’s economy and I want to roll back every players data before it happened.
  3. What solutions have you tried so far? Did you look for solutions on the Developer Hub?
    I tried searching the forums for data roll back methods.
    After that, you should include more details if you have any. Try to make your topic as descriptive as possible, so that it’s easier for people to help you!

Please do not ask people to write entire scripts or design entire systems for you. If you can’t answer the three questions above, you should probably pick a different category.

1 Like

Did you have backups made, or used something such as DataStore2 that make backups for you? If not - unlikely that you can.

2 Likes

I didn’t have any backups for data.

1 Like

Is there any solutions that might work? If not, I would be happy if you could show me how to backup data if problems persist.

1 Like

Check this out.

2 Likes

In future DataStore upset events: set your game to Private and send some type of notice as soon as possible that a maintenance and a rollback will be occurring due to a “recent exploit” to prevent confusion. Doing this step as soon as possible will prevent players from losing too much progress from the time the exploit happened to now.

Under the same universe in Roblox studio, you can use DataStore:ListKeysAsync() to get all of the saved keys and DataStore:ListVersionsAsync() to get all versions from the past 30 days for a specific key. You can then compare the DataStoreObjectVersionInfo.CreatedTime with the time the attack occured. Once you get the last safe DataStoreObjectVersionInfo.Version, use can fetch that version’s data with DataStore:GetVersionAsync() use DataStore:SetAsync() to override all data. Optionally, can also use DataStore:RemoveVersionAsync() to mark data after the exploit as deleted.

This process may take some time depending on the amount of players you have, and you will have to code your rollback to account for limits (non-playtest Studio command line does have higher limits). You can track your budgets with DataStoreService:GetRequestBudgetForRequestType(). I would recommend testing your written code in another place first to prevent any mistakes from happening during the backup. In the future, we could also be getting a new method called GetVersionAtTimeAsync to simplify the backup process.

You may also use the Cloud API instead of Studio. Using the Cloud API also allows you to utilize the beta snapshot feature, which is useful before doing any changes to DataStores (such as data rollbacks) or pushing critical game updates.

Edit:

Lastly, consider rewarding your players for the big inconvenience. Nothing is more frustrating having to wait for hours just to fix a huge exploit that shouldn’t have happened in the first place only to have your data be rolled back. Make it worth their while and reward them with in-game currencies or items more than or equal to the time (potentially) lost as a token of apology.

8 Likes

:smiley: Thanks! I’ll try to figure out how to use the resources you just sent! I also agree on the part about awarding the players for it.

You’re very welcome. If you haven’t already, I would highly recommend using jlwitthuhn/OpenCloudTools. It allows you to externally read and write to your DataStores, create and upload backups to/from disk, and much more useful tools:

Hi,

To rollback all players on a database, would one have to loop through every single key in a datastore, then for each key get the version via ListVersionsAsync, (do checks/comparisons), fetch the version via GetVersionAsync and apply it? Or is there a more efficient way to loop through DataStores for every player?

Furthermore, can you provide an example on how to get started & using Cloud API and this beta snapshot? And how does this beta snapshot work?

So with this, I can connect to my Roblox game’s Datastore and read/write to keys and et cetera, and back up the game’s Datastore to my workstation’s disk?

Yes, that is correct. This is the only way as of now.

The Cloud API are web requests, something you can do with any language with some type of Http package. They’ve linked examples in the documentation I’ve linked.

And yes, you can backup and restore your DataStores to disk with OpenCloudTools with a few clicks!

Edit:

The snapshot feature is in beta so it’s subject to change, but the documentation explains how it works better than I will.

@1vgot_terminated @JuniorMikezz

I decided to write up a Luau code you guys can copy and paste into the console. Change the settings at the bottom of the script before running and follow any instructions it has given you. Of course, feel free to edit as this code doesn’t currently include internal failures and automatic retries.

This should really only be used once or during rollbacks that requires specific timeframes, and any future rollbacks should be saved and uploaded via disk/cloud instead for fewer, safer, and battle-tested API calls.

--!strict

local RunService = game:GetService("RunService")

-- Only allow the script to run in the console
if RunService:IsEdit() == false or RunService:IsRunMode() == true then
	return
end

local DataStoreService = game:GetService("DataStoreService")

local function waitForRequestTypeBudget(dataStoreRequestType: Enum.DataStoreRequestType, budget: number, interval: number, verbose: boolean?): ()
	local hasWarned: boolean = false
	
	while DataStoreService:GetRequestBudgetForRequestType(dataStoreRequestType) < budget do
		if verbose == true and hasWarned == false then
			warn(`Waiting for budget '{dataStoreRequestType.Name}' ({DataStoreService:GetRequestBudgetForRequestType(dataStoreRequestType)} / {budget})`)
			hasWarned = true
		end
		
		task.wait(interval)
	end
end

local function fetchDataStoreKeysAsync(name: string, prefix: string?, pageSize: number?, cursor: string?, excludeDeleted: boolean?, verbose: boolean?): {string}
	if verbose == true then
		print(`Fetching keys from '{name}'`)
	end
	
	waitForRequestTypeBudget(Enum.DataStoreRequestType.ListAsync, 1, 1)
	
	local dataStore: DataStore = DataStoreService:GetDataStore(name)
	local dataStoreKeyPages: DataStoreKeyPages = dataStore:ListKeysAsync(prefix, pageSize, cursor, excludeDeleted)
	local dataStoreKeys: {string} = {}
	
	while true do
		for _: number, dataStoreKey: DataStoreKey in dataStoreKeyPages:GetCurrentPage() do
			table.insert(dataStoreKeys, dataStoreKey.KeyName)
			
			if verbose == true then
				print(`Fetched key path '{name}/{dataStoreKey.KeyName}'`)
			end
		end
		
		if dataStoreKeyPages.IsFinished == true then
			break
		end
		
		dataStoreKeyPages:AdvanceToNextPageAsync()
	end
	
	if verbose == true then
		print(`Successfully fetched keys from '{name}'`, dataStoreKeys)
	end
	
	return dataStoreKeys
end

local function fetchDataStoreVersionsAsync(name: string, key: string, sortDirection: Enum.SortDirection?, minDate: number?, maxDate: number?, pageSize: number?, verbose: boolean?): {DataStoreObjectVersionInfo}
	if verbose == true then
		print(`Fetching versions from path '{name}/{key}'`)
	end
	
	waitForRequestTypeBudget(Enum.DataStoreRequestType.ListAsync, 1, 1)
	
	local dataStore: DataStore = DataStoreService:GetDataStore(name)
	local dataStoreKeyVersionPages: DataStoreVersionPages = dataStore:ListVersionsAsync(key, sortDirection, minDate, maxDate, pageSize)
	local dataStoreVersions: {DataStoreObjectVersionInfo} = {}
	
	while true do
		for _: number, dataStoreObjectVersionInfo: DataStoreObjectVersionInfo in dataStoreKeyVersionPages:GetCurrentPage() do
			table.insert(dataStoreVersions, dataStoreObjectVersionInfo)
			
			if verbose == true then
				print(`Fetched version '{dataStoreObjectVersionInfo.Version}' from path '{name}/{key}'`)
			end
		end
		
		if dataStoreKeyVersionPages.IsFinished == true then
			break
		end
		
		dataStoreKeyVersionPages:AdvanceToNextPageAsync()
	end
	
	if verbose == true then
		print(`Successfully fetched versions from path '{name}/{key}'`, dataStoreVersions)
	end
	
	return dataStoreVersions
end

local function fetchVersionAsync(name: string, key: string, version: string, verbose: boolean?): (any, DataStoreKeyInfo)
	if verbose == true then
		print(`Fetching version from path '{name}/{key}'`)
	end
	
	waitForRequestTypeBudget(Enum.DataStoreRequestType.GetVersionAsync, 1, 1)
	
	local dataStore: DataStore = DataStoreService:GetDataStore(name)
	local value: any, dataStoreKeyInfo: DataStoreKeyInfo = dataStore:GetVersionAsync(key, version)
	
	if verbose == true then
		print(`Successfully fetched version from path '{name}'/{key}`, value, dataStoreKeyInfo)
	end
	
	return value, dataStoreKeyInfo
end

local rollbackThread: thread? = nil

local function tryRollback(name: string, beforeMillis: number, prefix: string?, editFunction: (data: any, keyInfo: DataStoreKeyInfo) -> DataStoreSetOptions?): ()
	if game:GetAttribute("OPERATION_ROLLBACK") == true then
		warn(
			[[A rollback has already been requested, is already running, or has previously crashed.
			
			To forcefully cleanup the previous operation, run this in the console and try again:
			game:SetAttribute("OPERATION_ROLLBACK", nil)
			game:SetAttribute("OPERATION_CONTINUE", nil)
			game:SetAttribute("OPERATION_CANCELLED_MESSAGE", nil)]]
		)
		
		return
	end
	
	rollbackThread = coroutine.running()
	game:SetAttribute("OPERATION_ROLLBACK", true)
	game:SetAttribute("OPERATION_CONTINUE", nil)
	game:SetAttribute("OPERATION_CANCELLED_MESSAGE", nil)
	
	local cancelConnection: RBXScriptConnection; cancelConnection = game:GetAttributeChangedSignal("OPERATION_ROLLBACK"):Connect(function(): ()
		if cancelConnection.Connected == true then
			if game:GetAttribute("OPERATION_ROLLBACK") ~= true and rollbackThread ~= nil then
				cancelConnection:Disconnect()
				pcall(task.cancel, rollbackThread)
				
				warn(`Rollback has been cancelled: {game:GetAttribute("OPERATION_CANCELLED_MESSAGE") or "user cancelled"}`)
				game:SetAttribute("OPERATION_ROLLBACK", nil)
				game:SetAttribute("OPERATION_CONTINUE", nil)
				game:SetAttribute("OPERATION_CANCELLED_MESSAGE", nil)
			end
		end
	end)
	
	warn(string.format(
		[[Requested rollback. Please confirm the following arguments before proceeding:
		
		DataStore Name: %s
		Before Time: %s
		Prefix: %s
		
		To cancel at anytime, run this in the console:
		game:SetAttribute("OPERATION_ROLLBACK", nil)
		
		To start the process, run this in the console:
		game:SetAttribute("OPERATION_CONTINUE", true)]],
		name, DateTime.fromUnixTimestampMillis(beforeMillis):ToIsoDate(), prefix or "nil")
	)
	
	while game:GetAttribute("OPERATION_CONTINUE") ~= true do
		warn('--------------------------------------------------')
		warn(`Waiting for user input...`)
		game:GetAttributeChangedSignal("OPERATION_CONTINUE"):Wait()
	end
	
	game:SetAttribute("OPERATION_CONTINUE", nil)
	warn('--------------------------------------------------')
	
	local dataStoreKeys: {string} = nil
	
	do
		local success: boolean, response: string? = pcall(function(): ()
			dataStoreKeys = fetchDataStoreKeysAsync(name)
		end)
		
		if success == false then
			game:SetAttribute("OPERATION_CANCELLED_MESSAGE", `failed to get DataStore keys ({response})`)
			game:SetAttribute("OPERATION_ROLLBACK", nil)
			
			return
		end
	end
	
	if #dataStoreKeys == 0 then
		game:SetAttribute("OPERATION_CANCELLED_MESSAGE", "no keys found")
		game:SetAttribute("OPERATION_ROLLBACK", nil)
		
		return
	end
	
	local latestSafeDataStoreVersions: {[string]: string} = {}
	local latestSafeDataStoreData: {
		[string]: {
			Value: any,
			DataStoreKeyInfo: DataStoreKeyInfo
		}
	} = {}
	
	local rollbackCount: number = 0
	
	for _: number, key: string in dataStoreKeys do
		for _: number, dataStoreObjectVersionInfo: DataStoreObjectVersionInfo in fetchDataStoreVersionsAsync(name, key, Enum.SortDirection.Descending, nil, beforeMillis, 1) do
			latestSafeDataStoreVersions[key] = dataStoreObjectVersionInfo.Version
		end
	end
	
	if next(latestSafeDataStoreVersions) == nil then
		game:SetAttribute("OPERATION_CANCELLED_MESSAGE", "no suitable versions found")
		game:SetAttribute("OPERATION_ROLLBACK", nil)

		return
	end
	
	for key: string, version: string in latestSafeDataStoreVersions do
		local value: any, dataStoreKeyInfo: DataStoreKeyInfo = nil, nil
		local success: boolean, response: string? = pcall(function(): ()
			value, dataStoreKeyInfo = fetchVersionAsync(name, key, version)
		end)

		if success == true then
			latestSafeDataStoreData[key] = {
				Value = value,
				DataStoreKeyInfo = dataStoreKeyInfo
			}

			rollbackCount += 1
		else
			warn(`ERROR: Failed to get version '{version}' for key '{key}' ({response})`)
		end
	end
	
	if next(latestSafeDataStoreData) == nil then
		game:SetAttribute("OPERATION_CANCELLED_MESSAGE", "no data found")
		game:SetAttribute("OPERATION_ROLLBACK", nil)

		return
	end
	
	for key: string, data: {Value: any, DataStoreKeyInfo: DataStoreKeyInfo} in latestSafeDataStoreData do
		print('--------------------------------------------------')
		print(`> Key: {key} | Last Updated: {DateTime.fromUnixTimestampMillis(data.DataStoreKeyInfo.UpdatedTime):ToIsoDate()} ({data.DataStoreKeyInfo.UpdatedTime}) | Data:`)
		print(data)
		print('--------------------------------------------------')
	end
	
	warn(string.format(
		[[You are about to rollback %d key(s) listed above.
		
		To cancel at anytime, run this in the console:
		game:SetAttribute("OPERATION_ROLLBACK", nil)
		
		To continue the process, run this in the console:
		game:SetAttribute("OPERATION_CONTINUE", true)]],
		rollbackCount
	))
	
	while game:GetAttribute("OPERATION_CONTINUE") ~= true do
		warn('--------------------------------------------------')
		warn(`Waiting for user input...`)
		game:GetAttributeChangedSignal("OPERATION_CONTINUE"):Wait()
	end
	
	game:SetAttribute("OPERATION_CONTINUE", nil)
	warn('--------------------------------------------------')
	
	local dataStore: DataStore = DataStoreService:GetDataStore(name)
	local successCount: number = 0
	local failedCount: number = 0
	
	for key: string, data: {Value: any, DataStoreKeyInfo: DataStoreKeyInfo} in latestSafeDataStoreData do
		local dataStoreSetOptions: DataStoreSetOptions? = if editFunction ~= nil then editFunction(data.Value, data.DataStoreKeyInfo) else nil
		waitForRequestTypeBudget(Enum.DataStoreRequestType.SetIncrementAsync, 1, 5)
		
		local success: boolean, response: string? = pcall(function(): ()
			dataStore:SetAsync(key, data.Value, data.DataStoreKeyInfo:GetUserIds(), dataStoreSetOptions)
		end)
		
		if success == true then
			print(`🟢 Successfully rolled back data for key '{key}'`)
			successCount += 1
		else
			warn(`🔴 Failed to rollback data for key '{key}' ({response})`)
			failedCount += 1
		end
	end
	
	warn('--------------------------------------------------')
	warn(`Rollback complete.`)
	warn(`Successful rollbacks: {successCount} / {rollbackCount}`)
	warn(`Failed rollbacks: {failedCount} / {rollbackCount}`)
	
	rollbackThread = nil
	game:SetAttribute("OPERATION_ROLLBACK", nil)
	game:SetAttribute("OPERATION_CONTINUE", nil)
	game:SetAttribute("OPERATION_CANCELLED_MESSAGE", nil)
end

-- The DataStore name you want to rollback
local DATA_STORE_NAME: string = "v0.0.0-dev1"

-- The before date you wish to revert to, if any.
-- Year, Month, Day, Hour, Minute, Second, Millisecond
local BEFORE_DATE: DateTime = DateTime.fromUniversalTime(2024, 11, 10, 0, 0, 0, 0)
local BEFORE_DATE_MILLISECONDS: number = BEFORE_DATE.UnixTimestampMillis

-- The prefix, if any, you want to use to grab keys.
-- Leave as an empty string to get all keys.
-- If you want to test it out using your or a test account,
-- you can put your specific key here to make sure
-- everything works before proceeding with the mass rollback.
local PREFIX: string = ""

-- The function to run to edit any data. Use it to manually
-- edit things such as session locks or reapplying metadata
local EDIT_FUNCTION: (data: any, keyInfo: DataStoreKeyInfo) -> DataStoreSetOptions? = function(data: any, keyInfo: DataStoreKeyInfo): DataStoreSetOptions?
	-- Unlock ProfileService session
	if data.Data ~= nil and typeof(data.MetaData) == "table" then
		--data.MetaData.ActiveSession = nil
		print(`Unlocked session for '{keyInfo.Version}'`)
	end
	
	-- Reconstruct metadata. UserIds are automatically applied again.
	local setOptions: DataStoreSetOptions = Instance.new("DataStoreSetOptions")
	setOptions:SetMetadata(keyInfo:GetMetadata())
	
	-- Pass the set options back for saving
	return setOptions
end

tryRollback(DATA_STORE_NAME, BEFORE_DATE_MILLISECONDS, PREFIX, EDIT_FUNCTION)

{77AEE546-555F-46C7-B11B-4264B3640115}

2 Likes

Much appreciated. Why do you recommend to use it ‘once’ and what is the difference with the second point you made about future rollbacks if you can elaborate on that.

Also, for rollback code execution, do you recommend to run it in console or put it in a script, press play and have it run that way?

I would recommend a bulk DataStore download before you push out critical game updates or anytime you do a large DataStore schema change that has not been 100% tested, simply because you have the data indefinitely that you can roll back as many times as you want with just SetAsync calls, skipping the entire version process again, similar to how online services have backups of their databases in case of a critical malfunction, with some even having automatic backups.

This does have it’s drawbacks, primarily storage issues depending on the total stored data and how much you are keeping. There is no correct solution, so tackle it however you wish.

I say “it should really only be used once” because the download/upload process is easier and less prone to developer mistakes over manually (re)writing a backup code anytime a failure/exploit happens, but you can keep doing this if you’d like, especially if you want finer filters on what data gets rolled back.

I would personally just run it into the console without pressing Play, simply because your budgets are higher. Although it is easier to hit Stop to cancel the ongoing process, I’ve coded it where you can stop it at anytime if you follow the instructions. It can also be better if you have other scripts that may interfere with the backup performance when pressing Play.

When you say download, I assume you mean backing it up (e.g. downloading to my disk), if so is this achieveable by the OpenClouds GitHub you sent, do they allow one to connect to their roblox’s game database and download the full database to their local machine?

When you say ‘download/saved and upload’ safer method can you give an example what tools / steps / how to achieve this ? Do you mean to download the full database, and upload what?

Correct. Their entire program is simply a clean and efficient UI for the DataStore (and more) Cloud API. Under the hood, they make the same API calls as if you were writing your own requests from scratch so you don’t have to. Additionally, you can upload what you have downloaded back to the DataStore (it’s the screenshot I posted!). You can also use this to transfer data from one universe to another. This is done by saving your database as a SQLite file with it’s structure you can then store on your disk or on a cloud storage service: OpenCloudTools/doc/bulk_download.md at master · jlwitthuhn/OpenCloudTools · GitHub

I would recommend reading up on their page for instructions it’s full capabilities if you have any more questions, as I’m simply just repeating information they provided.

Few questions:

So rollback process time depends on the amount of entries stored in a database (e.g. more players, longer it is)?

What is this ‘limits’ your talking about, can you elaborate?

And with this ‘budgets’, please also elaborate too?

Lastly, how will GetVersionAtTimeAsync simplify the process?

And can you provide an example of using the beta snapshot for rollback?

Furthermore, you seem to have experience with ‘the cloud’, if possible can you explain to me how this works and is incorporated in roblox development?