· 5 months ago · Apr 23, 2025, 09:10 AM
1--[[
2MAD STUDIO (by loleris)
3
4-[ProfileStore]---------------------------------------
5
6 Periodic DataStore saving solution with session locking
7
8 WARNINGS FOR "Profile.Data" VALUES:
9 ! Do not create numeric tables with gaps - attempting to store such tables will result in an error.
10 ! Do not create mixed tables (some values indexed by number and others by a string key)
11 - only numerically indexed data will be stored.
12 ! Do not index tables by anything other than numbers and strings.
13 ! Do not reference Roblox Instances
14 ! Do not reference userdata (Vector3, Color3, CFrame...) - Serialize userdata before referencing
15 ! Do not reference functions
16
17 Members:
18
19 ProfileStore.IsClosing [bool]
20 -- Set to true after a game:BindToClose() trigger
21
22 ProfileStore.IsCriticalState [bool]
23 -- Set to true when ProfileStore experiences too many consecutive errors
24
25 ProfileStore.OnError [Signal] (message, store_name, profile_key)
26 -- Most ProfileStore errors will be caught and passed to this signal
27
28 ProfileStore.OnOverwrite [Signal] (store_name, profile_key)
29 -- Triggered when a DataStore key was likely used to store data that wasn't
30 a ProfileStore profile or the ProfileStore structure was invalidly manually
31 altered for that DataStore key
32
33 ProfileStore.OnCriticalToggle [Signal] (is_critical)
34 -- Triggered when ProfileStore experiences too many consecutive errors
35
36 ProfileStore.DataStoreState [string] ("NotReady", "NoInternet", "NoAccess", "Access")
37 -- This value resembles ProfileStore's access to the DataStore; The value starts
38 as "NotReady" and will eventually change to one of the other 3 possible values.
39
40 Functions:
41
42 ProfileStore.New(store_name, template?) --> [ProfileStore]
43 store_name [string] -- DataStore name
44 template [table] or nil -- Profiles will default to given table (hard-copy) when no data was saved previously
45
46 ProfileStore.SetConstant(name, value)
47 name [string]
48 value [number]
49
50 Members [ProfileStore]:
51
52 ProfileStore.Mock [ProfileStore]
53 -- Reflection of ProfileStore methods, but the methods will now query a mock
54 DataStore with no relation to the real DataStore
55
56 ProfileStore.Name [string]
57
58 Methods [ProfileStore]:
59
60 ProfileStore:StartSessionAsync(profile_key, params?) --> [Profile] or nil
61 profile_key [string] -- DataStore key
62 params nil or [table]: -- Custom params; E.g. {Steal = true}
63 {
64 Steal = true, -- Pass this to disregard an existing session lock
65 Cancel = fn() -> (boolean), -- Pass this to create a request cancel condition.
66 -- If the cancel function returns true, ProfileStore will stop trying to
67 -- start the session and return nil
68 }
69
70 ProfileStore:MessageAsync(profile_key, message) --> is_success [bool]
71 profile_key [string] -- DataStore key
72 message [table] -- Data to be messaged to the profile
73
74 ProfileStore:GetAsync(profile_key, version?) --> [Profile] or nil
75 -- Reads a profile without starting a session - will not autosave
76 profile_key [string] -- DataStore key
77 version nil or [string] -- DataStore key version
78
79 ProfileStore:VersionQuery(profile_key, sort_direction?, min_date?, max_date?) --> [VersionQuery]
80 profile_key [string]
81 sort_direction nil or [Enum.SortDirection]
82 min_date nil or [DateTime]
83 max_date nil or [DateTime]
84
85 ProfileStore:RemoveAsync(profile_key) --> is_success [bool]
86 -- Completely removes profile data from the DataStore / mock DataStore with no way to recover it.
87
88 Methods [VersionQuery]:
89
90 VersionQuery:NextAsync() --> [Profile] or nil -- (Yields)
91 -- Returned profile is similar to profiles returned by ProfileStore:GetAsync()
92
93 Members [Profile]:
94
95 Profile.Data [table]
96 -- When the profile is active changes to this table are guaranteed to be saved
97 Profile.LastSavedData [table] (Read-only)
98 -- Last snapshot of "Profile.Data" that has been successfully saved to the DataStore;
99 Useful for proper developer product purchase receipt handling
100
101 Profile.FirstSessionTime [number] (Read-only)
102 -- os.time() timestamp of the first profile session
103
104 Profile.SessionLoadCount [number] (Read-only) -- Amount of times a session was started for this profile
105
106 Profile.Session [table] (Read-only) {PlaceId = number, JobId = string} / nil
107 -- Set to a table if this profile is in use by a server; nil if released
108
109 Profile.RobloxMetaData [table] -- Writable table that gets saved automatically and once the profile is released
110 Profile.UserIds [table] -- (Read-only) -- {user_id [number], ...} -- User ids associated with this profile
111
112 Profile.KeyInfo [DataStoreKeyInfo] -- Changes before OnAfterSave signal
113
114 Profile.OnSave [Signal] ()
115 -- Triggered right before changes to Profile.Data are saved to the DataStore
116
117 Profile.OnLastSave [Signal] (reason [string]: "Manual", "External", "Shutdown")
118 -- Triggered right before changes to Profile.Data are saved to the DataStore
119 for the last time; A reason is provided for the last save:
120 - "Manual" - Profile:EndSession() was called
121 - "Shutdown" - The server that has ownership of this profile is shutting down
122 - "External" - Another server has started a session for this profile
123 Note that this event will not trigger for when a profile session is ended by
124 another server trying to take ownership of the session - this is impossible to
125 do without compromising on ProfileStore's speed.
126
127 Profile.OnSessionEnd [Signal] ()
128 -- Triggered when the profile session is terminated on this server
129
130 Profile.OnAfterSave [Signal] (last_saved_data)
131 -- Triggered after a successful save
132 last_saved_data [table] -- Profile.LastSavedData
133
134 Profile.ProfileStore [ProfileStore] -- ProfileStore object this profile belongs to
135 Profile.Key [string] -- DataStore key
136
137 Methods [Profile]:
138
139 Profile:IsActive() --> [bool] -- If "true" is returned, changes to Profile.Data are guaranteed to save;
140 This guarantee is only valid until code yields (e.g. task.wait() is used).
141
142 Profile:Reconcile() -- Fills in missing (nil) [string_key] = [value] pairs to the Profile.Data structure
143 from the "template" argument that was passed to "ProfileStore.New()"
144
145 Profile:EndSession() -- Call after the server has finished working with this profile
146 e.g., after the player leaves (Profile object will become inactive)
147
148 Profile:AddUserId(user_id) -- Associates user_id with profile (GDPR compliance)
149 user_id [number]
150
151 Profile:RemoveUserId(user_id) -- Removes user_id association with profile (safe function)
152 user_id [number]
153
154 Profile:MessageHandler(fn) -- Sets a message handler for this profile
155 fn [function] (message [table], processed [function]())
156 -- The handler function receives a message table and a callback function;
157 The callback function is to be called when a message has been processed
158 - this will discard the message from the profile message cache; If the
159 callback function is not called, other message handlers will also be triggered
160 with unprocessed message data.
161
162 Profile:Save() -- If the profile session is still active makes an UpdateAsync call
163 to the DataStore to immediately save profile data
164
165 Profile:SetAsync() -- Forcefully saves changes to the profile; Only for profiles
166 loaded with ProfileStore:GetAsync() or ProfileStore:VersionQuery()
167
168--]]
169
170local AUTO_SAVE_PERIOD = 300 -- (Seconds) Time between when changes to a profile are saved to the DataStore
171local LOAD_REPEAT_PERIOD = 10 -- (Seconds) Time between successive profile reads when handling a session conflict
172local FIRST_LOAD_REPEAT = 5 -- (Seconds) Time between first and second profile read when handling a session conflict
173local SESSION_STEAL = 40 -- (Seconds) Time until a session conflict is resolved with the waiting server stealing the session
174local ASSUME_DEAD = 630 -- (Seconds) If a profile hasn't had updates for this long, quickly assume an active session belongs to a crashed server
175local START_SESSION_TIMEOUT = 120 -- (Seconds) If a session can't be started for a profile for this long, stop repeating calls to the DataStore
176
177local CRITICAL_STATE_ERROR_COUNT = 5 -- Assume critical state if this many issues happen in a short amount of time
178local CRITICAL_STATE_ERROR_EXPIRE = 120 -- (Seconds) Individual issue expiration
179local CRITICAL_STATE_EXPIRE = 120 -- (Seconds) Critical state expiration
180
181local MAX_MESSAGE_QUEUE = 1000 -- Max messages saved in a profile that were sent using "ProfileStore:MessageAsync()"
182
183----- Dependencies -----
184
185-- local Util = require(game.ReplicatedStorage.Shared.Util)
186-- local Signal = Util.Signal
187
188local Signal do
189
190 local FreeRunnerThread
191
192 --[[
193 Yield-safe coroutine reusing by stravant;
194 Sources:
195 https://devforum.roblox.com/t/lua-signal-class-comparison-optimal-goodsignal-class/1387063
196 https://gist.github.com/stravant/b75a322e0919d60dde8a0316d1f09d2f
197 --]]
198
199 local function AcquireRunnerThreadAndCallEventHandler(fn, ...)
200 local acquired_runner_thread = FreeRunnerThread
201 FreeRunnerThread = nil
202 fn(...)
203 -- The handler finished running, this runner thread is free again.
204 FreeRunnerThread = acquired_runner_thread
205 end
206
207 local function RunEventHandlerInFreeThread(...)
208 AcquireRunnerThreadAndCallEventHandler(...)
209 while true do
210 AcquireRunnerThreadAndCallEventHandler(coroutine.yield())
211 end
212 end
213
214 local Connection = {}
215 Connection.__index = Connection
216
217 local SignalClass = {}
218 SignalClass.__index = SignalClass
219
220 function Connection:Disconnect()
221
222 if self.is_connected == false then
223 return
224 end
225
226 local signal = self.signal
227 self.is_connected = false
228 signal.listener_count -= 1
229
230 if signal.head == self then
231 signal.head = self.next
232 else
233 local prev = signal.head
234 while prev ~= nil and prev.next ~= self do
235 prev = prev.next
236 end
237 if prev ~= nil then
238 prev.next = self.next
239 end
240 end
241
242 end
243
244 function SignalClass.New()
245
246 local self = {
247 head = nil,
248 listener_count = 0,
249 }
250 setmetatable(self, SignalClass)
251
252 return self
253
254 end
255
256 function SignalClass:Connect(listener: (...any) -> ())
257
258 if type(listener) ~= "function" then
259 error(`[{script.Name}]: \"listener\" must be a function; Received {typeof(listener)}`)
260 end
261
262 local connection = {
263 listener = listener,
264 signal = self,
265 next = self.head,
266 is_connected = true,
267 }
268 setmetatable(connection, Connection)
269
270 self.head = connection
271 self.listener_count += 1
272
273 return connection
274
275 end
276
277 function SignalClass:GetListenerCount(): number
278 return self.listener_count
279 end
280
281 function SignalClass:Fire(...)
282 local item = self.head
283 while item ~= nil do
284 if item.is_connected == true then
285 if not FreeRunnerThread then
286 FreeRunnerThread = coroutine.create(RunEventHandlerInFreeThread)
287 end
288 task.spawn(FreeRunnerThread, item.listener, ...)
289 end
290 item = item.next
291 end
292 end
293
294 function SignalClass:Wait()
295 local co = coroutine.running()
296 local connection
297 connection = self:Connect(function(...)
298 connection:Disconnect()
299 task.spawn(co, ...)
300 end)
301 return coroutine.yield()
302 end
303
304 Signal = table.freeze({
305 New = SignalClass.New,
306 })
307
308end
309
310----- Private -----
311
312local ActiveSessionCheck = {} -- {[session_token] = profile, ...}
313local AutoSaveList = {} -- {profile, ...} -- Loaded profile table which will be circularly auto-saved
314local IssueQueue = {} -- {issue_time, ...}
315
316local DataStoreService = game:GetService("DataStoreService")
317local MessagingService = game:GetService("MessagingService")
318local HttpService = game:GetService("HttpService")
319local RunService = game:GetService("RunService")
320
321local PlaceId = game.PlaceId
322local JobId = game.JobId
323
324local AutoSaveIndex = 1 -- Next profile to auto save
325local LastAutoSave = os.clock()
326
327local LoadIndex = 0
328
329local ActiveProfileLoadJobs = 0 -- Number of active threads that are loading in profiles
330local ActiveProfileSaveJobs = 0 -- Number of active threads that are saving profiles
331
332local CriticalStateStart = 0 -- os.clock()
333
334local IsStudio = RunService:IsStudio()
335local DataStoreState: "NotReady" | "NoInternet" | "NoAccess" | "Access" = "NotReady"
336
337local MockStore = {}
338local UserMockStore = {}
339local MockFlag = false
340
341local OnError = Signal.New() -- (message, store_name, profile_key)
342local OnOverwrite = Signal.New() -- (store_name, profile_key)
343
344local UpdateQueue = { -- For stability sake, we won't do UpdateAsync calls for the same key until all previous calls finish
345 --[[
346 [session_token] = {
347 coroutine, ...
348 },
349 ...
350 --]]
351}
352
353local function WaitInUpdateQueue(session_token) --> next_in_queue()
354
355 local is_first = false
356
357 if UpdateQueue[session_token] == nil then
358 is_first = true
359 UpdateQueue[session_token] = {}
360 end
361
362 local queue = UpdateQueue[session_token]
363
364 if is_first == false then
365 table.insert(queue, coroutine.running())
366 coroutine.yield()
367 end
368
369 return function()
370 local next_co = table.remove(queue, 1)
371 if next_co ~= nil then
372 coroutine.resume(next_co)
373 else
374 UpdateQueue[session_token] = nil
375 end
376 end
377
378end
379
380local function SessionToken(store_name, profile_key, is_mock)
381
382 local session_token = "L_" -- Live
383
384 if is_mock == true then
385 session_token = "U_" -- User mock
386 elseif DataStoreState ~= "Access" then
387 session_token = "M_" -- Mock, cause no DataStore access
388 end
389
390 session_token ..= store_name .. "\0" .. profile_key
391
392 return session_token
393
394end
395
396local function DeepCopyTable(t)
397 local copy = {}
398 for key, value in pairs(t) do
399 if type(value) == "table" then
400 copy[key] = DeepCopyTable(value)
401 else
402 copy[key] = value
403 end
404 end
405 return copy
406end
407
408local function ReconcileTable(target, template)
409 for k, v in pairs(template) do
410 if type(k) == "string" then -- Only string keys will be reconciled
411 if target[k] == nil then
412 if type(v) == "table" then
413 target[k] = DeepCopyTable(v)
414 else
415 target[k] = v
416 end
417 elseif type(target[k]) == "table" and type(v) == "table" then
418 ReconcileTable(target[k], v)
419 end
420 end
421 end
422end
423
424local function RegisterError(error_message, store_name, profile_key) -- Called when a DataStore API call errors
425 warn(`[{script.Name}]: DataStore API error (STORE:{store_name}; KEY:{profile_key}) - {tostring(error_message)}`)
426 table.insert(IssueQueue, os.clock()) -- Adding issue time to queue
427 OnError:Fire(tostring(error_message), store_name, profile_key)
428end
429
430local function RegisterOverwrite(store_name, profile_key) -- Called when a corrupted profile is loaded
431 warn(`[{script.Name}]: Invalid profile was overwritten (STORE:{store_name}; KEY:{profile_key})`)
432 OnOverwrite:Fire(store_name, profile_key)
433end
434
435local function NewMockDataStoreKeyInfo(params)
436
437 local version_id_string = tostring(params.VersionId or 0)
438 local meta_data = params.MetaData or {}
439 local user_ids = params.UserIds or {}
440
441 return {
442 CreatedTime = params.CreatedTime,
443 UpdatedTime = params.UpdatedTime,
444 Version = string.rep("0", 16) .. "."
445 .. string.rep("0", 10 - string.len(version_id_string)) .. version_id_string
446 .. "." .. string.rep("0", 16) .. "." .. "01",
447
448 GetMetadata = function()
449 return DeepCopyTable(meta_data)
450 end,
451
452 GetUserIds = function()
453 return DeepCopyTable(user_ids)
454 end,
455 }
456
457end
458
459local function MockUpdateAsync(mock_data_store, profile_store_name, key, transform_function, is_get_call) --> loaded_data, key_info
460
461 local profile_store = mock_data_store[profile_store_name]
462
463 if profile_store == nil then
464 profile_store = {}
465 mock_data_store[profile_store_name] = profile_store
466 end
467
468 local epoch_time = math.floor(os.time() * 1000)
469 local mock_entry = profile_store[key]
470 local mock_entry_was_nil = false
471
472 if mock_entry == nil then
473 mock_entry_was_nil = true
474 if is_get_call ~= true then
475 mock_entry = {
476 Data = nil,
477 CreatedTime = epoch_time,
478 UpdatedTime = epoch_time,
479 VersionId = 0,
480 UserIds = {},
481 MetaData = {},
482 }
483 profile_store[key] = mock_entry
484 end
485 end
486
487 local mock_key_info = mock_entry_was_nil == false and NewMockDataStoreKeyInfo(mock_entry) or nil
488
489 local transform, user_ids, roblox_meta_data = transform_function(mock_entry and mock_entry.Data, mock_key_info)
490
491 if transform == nil then
492 return nil
493 else
494 if mock_entry ~= nil and is_get_call ~= true then
495 mock_entry.Data = DeepCopyTable(transform)
496 mock_entry.UserIds = DeepCopyTable(user_ids or {})
497 mock_entry.MetaData = DeepCopyTable(roblox_meta_data or {})
498 mock_entry.VersionId += 1
499 mock_entry.UpdatedTime = epoch_time
500 end
501
502 return DeepCopyTable(transform), mock_entry ~= nil and NewMockDataStoreKeyInfo(mock_entry) or nil
503 end
504
505end
506
507local function UpdateAsync(profile_store, profile_key, transform_params, is_user_mock, is_get_call, version) --> loaded_data, key_info
508 --transform_params = {
509 -- ExistingProfileHandle = function(latest_data),
510 -- MissingProfileHandle = function(latest_data),
511 -- EditProfile = function(latest_data),
512 --}
513
514 local loaded_data, key_info
515
516 local next_in_queue = WaitInUpdateQueue(SessionToken(profile_store.Name, profile_key, is_user_mock))
517
518 local success = true
519
520 local success, error_message = pcall(function()
521 local transform_function = function(latest_data)
522
523 local missing_profile = false
524 local overwritten = false
525 local global_updates = {0, {}}
526
527 if latest_data == nil then
528
529 missing_profile = true
530
531 elseif type(latest_data) ~= "table" then
532
533 missing_profile = true
534 overwritten = true
535
536 else
537
538 if type(latest_data.Data) == "table" and type(latest_data.MetaData) == "table" and type(latest_data.GlobalUpdates) == "table" then
539
540 -- Regular profile structure detected:
541
542 latest_data.WasOverwritten = false -- Must be set to false if set previously
543 global_updates = latest_data.GlobalUpdates
544
545 if transform_params.ExistingProfileHandle ~= nil then
546 transform_params.ExistingProfileHandle(latest_data)
547 end
548
549 elseif latest_data.Data == nil and latest_data.MetaData == nil and type(latest_data.GlobalUpdates) == "table" then
550
551 -- Regular structure not detected, but GlobalUpdate data exists:
552
553 latest_data.WasOverwritten = false -- Must be set to false if set previously
554 global_updates = latest_data.GlobalUpdates or global_updates
555 missing_profile = true
556
557 else
558
559 missing_profile = true
560 overwritten = true
561
562 end
563
564 end
565
566 -- Profile was not created or corrupted and no GlobalUpdate data exists:
567 if missing_profile == true then
568 latest_data = {
569 -- Data = nil,
570 -- MetaData = nil,
571 GlobalUpdates = global_updates,
572 }
573 if transform_params.MissingProfileHandle ~= nil then
574 transform_params.MissingProfileHandle(latest_data)
575 end
576 end
577
578 -- Editing profile:
579 if transform_params.EditProfile ~= nil then
580 transform_params.EditProfile(latest_data)
581 end
582
583 -- Invalid data handling (Silently override with empty profile)
584 if overwritten == true then
585 latest_data.WasOverwritten = true -- Temporary tag that will be removed on first save
586 end
587
588 return latest_data, latest_data.UserIds, latest_data.RobloxMetaData
589 end
590
591 if is_user_mock == true then -- Used when the profile is accessed through ProfileStore.Mock
592
593 loaded_data, key_info = MockUpdateAsync(UserMockStore, profile_store.Name, profile_key, transform_function, is_get_call)
594 task.wait() -- Simulate API call yield
595
596 elseif DataStoreState ~= "Access" then -- Used when API access is disabled
597
598 loaded_data, key_info = MockUpdateAsync(MockStore, profile_store.Name, profile_key, transform_function, is_get_call)
599 task.wait() -- Simulate API call yield
600
601 else
602
603 if is_get_call == true then
604
605 if version ~= nil then
606
607 local success, error_message = pcall(function()
608 loaded_data, key_info = profile_store.data_store:GetVersionAsync(profile_key, version)
609 end)
610
611 if success == false and type(error_message) == "string" and string.find(error_message, "not valid") ~= nil then
612 warn(`[{script.Name}]: Passed version argument is not valid; Traceback:\n` .. debug.traceback())
613 end
614
615 else
616
617 loaded_data, key_info = profile_store.data_store:GetAsync(profile_key)
618
619 end
620
621 loaded_data = transform_function(loaded_data)
622
623 else
624
625 loaded_data, key_info = profile_store.data_store:UpdateAsync(profile_key, transform_function)
626
627 end
628
629 end
630
631 end)
632
633 next_in_queue()
634
635 if success == true and type(loaded_data) == "table" then
636 -- Invalid data handling:
637 if loaded_data.WasOverwritten == true and is_get_call ~= true then
638 RegisterOverwrite(
639 profile_store.Name,
640 profile_key
641 )
642 end
643 -- Return loaded_data:
644 return loaded_data, key_info
645 else
646 -- Error handling:
647 RegisterError(
648 error_message or "Undefined error",
649 profile_store.Name,
650 profile_key
651 )
652 -- Return nothing:
653 return nil
654 end
655
656end
657
658local function IsThisSession(session_tag)
659 return session_tag[1] == PlaceId and session_tag[2] == JobId
660end
661
662local function ReadMockFlag(): boolean
663 local is_mock = MockFlag
664 MockFlag = false
665 return is_mock
666end
667
668local function WaitForStoreReady(profile_store)
669 while profile_store.is_ready == false do
670 task.wait()
671 end
672end
673
674local function AddProfileToAutoSave(profile)
675
676 ActiveSessionCheck[profile.session_token] = profile
677
678 -- Add at AutoSaveIndex and move AutoSaveIndex right:
679
680 table.insert(AutoSaveList, AutoSaveIndex, profile)
681
682 if #AutoSaveList > 1 then
683 AutoSaveIndex = AutoSaveIndex + 1
684 elseif #AutoSaveList == 1 then
685 -- First profile created - make sure it doesn't get immediately auto saved:
686 LastAutoSave = os.clock()
687 end
688
689end
690
691local function RemoveProfileFromAutoSave(profile)
692
693 ActiveSessionCheck[profile.session_token] = nil
694
695 local auto_save_index = table.find(AutoSaveList, profile)
696
697 if auto_save_index ~= nil then
698 table.remove(AutoSaveList, auto_save_index)
699 if auto_save_index < AutoSaveIndex then
700 AutoSaveIndex = AutoSaveIndex - 1 -- Table contents were moved left before AutoSaveIndex so move AutoSaveIndex left as well
701 end
702 if AutoSaveList[AutoSaveIndex] == nil then -- AutoSaveIndex was at the end of the AutoSaveList - reset to 1
703 AutoSaveIndex = 1
704 end
705 end
706
707end
708
709local function SaveProfileAsync(profile, is_ending_session, is_overwriting, last_save_reason)
710
711 if type(profile.Data) ~= "table" then
712 error(`[{script.Name}]: Developer code likely set "Profile.Data" to a non-table value! (STORE:{profile.ProfileStore.Name}; KEY:{profile.Key})`)
713 end
714
715 profile.OnSave:Fire()
716 if is_ending_session == true then
717 profile.OnLastSave:Fire(last_save_reason or "Manual")
718 end
719
720 if is_ending_session == true and is_overwriting ~= true then
721 if profile.roblox_message_subscription ~= nil then
722 profile.roblox_message_subscription:Disconnect()
723 end
724 RemoveProfileFromAutoSave(profile)
725 profile.OnSessionEnd:Fire()
726 end
727
728 ActiveProfileSaveJobs = ActiveProfileSaveJobs + 1
729
730 -- Compare "SessionLoadCount" when writing to profile to prevent a rare case of repeat last save when the profile is loaded on the same server again
731
732 local repeat_save_flag = true -- Released Profile save calls have to repeat until they succeed
733 local exp_backoff = 1
734
735 while repeat_save_flag == true do
736
737 if is_ending_session ~= true then
738 repeat_save_flag = false
739 end
740
741 local loaded_data, key_info = UpdateAsync(
742 profile.ProfileStore,
743 profile.Key,
744 {
745 ExistingProfileHandle = nil,
746 MissingProfileHandle = nil,
747 EditProfile = function(latest_data)
748
749 -- Check if this session still owns the profile:
750
751 local session_owns_profile = false
752
753 if is_overwriting ~= true then
754
755 local active_session = latest_data.MetaData.ActiveSession
756 local session_load_count = latest_data.MetaData.SessionLoadCount
757
758 if type(active_session) == "table" then
759 session_owns_profile = IsThisSession(active_session) and session_load_count == profile.load_index
760 end
761
762 else
763 session_owns_profile = true
764 end
765
766 -- We may only edit the profile if this server has ownership of the profile:
767
768 if session_owns_profile == true then
769
770 -- Clear processed updates (messages):
771
772 local locked_updates = profile.locked_global_updates -- [index] = true, ...
773 local active_updates = latest_data.GlobalUpdates[2]
774 -- ProfileService module format: {{update_id, version_id, update_locked, update_data}, ...}
775 -- ProfileStore module format: {{update_id, update_data}, ...}
776
777 if next(locked_updates) ~= nil then
778 local i = 1
779 while i <= #active_updates do
780 local update = active_updates[i]
781 if locked_updates[update[1]] == true then
782 table.remove(active_updates, i)
783 else
784 i += 1
785 end
786 end
787 end
788
789 -- Save profile data:
790
791 latest_data.Data = profile.Data
792 latest_data.RobloxMetaData = profile.RobloxMetaData
793 latest_data.UserIds = profile.UserIds
794
795 if is_overwriting ~= true then
796
797 latest_data.MetaData.LastUpdate = os.time()
798
799 if is_ending_session == true then
800 latest_data.MetaData.ActiveSession = nil
801 end
802
803 else
804
805 latest_data.MetaData.ActiveSession = nil
806 latest_data.MetaData.ForceLoadSession = nil
807
808 end
809
810 end
811
812 end,
813 },
814 profile.is_mock
815 )
816
817 if loaded_data ~= nil and key_info ~= nil then
818
819 if is_overwriting == true then
820 break
821 end
822
823 repeat_save_flag = false
824
825 local active_session = loaded_data.MetaData.ActiveSession
826 local session_load_count = loaded_data.MetaData.SessionLoadCount
827 local session_owns_profile = false
828
829 if type(active_session) == "table" then
830 session_owns_profile = IsThisSession(active_session) and session_load_count == profile.load_index
831 end
832
833 local force_load_session = loaded_data.MetaData.ForceLoadSession
834 local force_load_pending = false
835 if type(force_load_session) == "table" then
836 force_load_pending = not IsThisSession(force_load_session)
837 end
838
839 local is_active = profile:IsActive()
840
841 -- If another server is trying to start a session for this profile - end the session:
842
843 if force_load_pending == true and session_owns_profile == true then
844 if is_active == true then
845 SaveProfileAsync(profile, true, false, "External")
846 end
847 break
848 end
849
850 -- Clearing processed update list / Detecting new updates:
851
852 local locked_updates = profile.locked_global_updates -- [index] = true, ...
853 local received_updates = profile.received_global_updates -- [index] = true, ...
854 local active_updates = loaded_data.GlobalUpdates[2]
855
856 local new_updates = {} -- {}, ...
857 local still_pending = {} -- [index] = true, ...
858
859 for _, update in ipairs(active_updates) do
860 if locked_updates[update[1]] == true then
861 still_pending[update[1]] = true
862 elseif received_updates[update[1]] ~= true then
863 received_updates[update[1]] = true
864 table.insert(new_updates, update)
865 end
866 end
867
868 for index in pairs(locked_updates) do
869 if still_pending[index] ~= true then
870 locked_updates[index] = nil
871 end
872 end
873
874 -- Updating profile values:
875
876 profile.KeyInfo = key_info
877 profile.LastSavedData = loaded_data.Data
878 profile.global_updates = loaded_data.GlobalUpdates and loaded_data.GlobalUpdates[2] or {}
879
880 if session_owns_profile == true then
881 if is_active == true and is_ending_session ~= true then
882
883 -- Processing new global updates (messages):
884
885 for _, update in ipairs(new_updates) do
886
887 local index = update[1]
888 local update_data = update[#update] -- Backwards compatibility with ProfileService
889
890 for _, handler in ipairs(profile.message_handlers) do
891
892 local is_processed = false
893 local processed_callback = function()
894 is_processed = true
895 locked_updates[index] = true
896 end
897
898 local send_update_data = DeepCopyTable(update_data)
899
900 task.spawn(handler, send_update_data, processed_callback)
901
902 if is_processed == true then
903 break
904 end
905
906 end
907
908 end
909
910 end
911 else
912
913 if profile.roblox_message_subscription ~= nil then
914 profile.roblox_message_subscription:Disconnect()
915 end
916
917 if is_active == true then
918 RemoveProfileFromAutoSave(profile)
919 profile.OnSessionEnd:Fire()
920 end
921
922 end
923
924 profile.OnAfterSave:Fire(profile.LastSavedData)
925
926 elseif repeat_save_flag == true then
927
928 -- DataStore call likely resulted in an error; Repeat the DataStore call shortly
929 task.wait(exp_backoff)
930 exp_backoff = math.min(if last_save_reason == "Shutdown" then 8 else 20, exp_backoff * 2)
931
932 end
933
934 end
935
936 ActiveProfileSaveJobs = ActiveProfileSaveJobs - 1
937
938end
939
940----- Public -----
941
942--[[
943 Saved profile structure:
944
945 {
946 Data = {},
947
948 MetaData = {
949 ProfileCreateTime = 0,
950 SessionLoadCount = 0,
951 ActiveSession = {place_id, game_job_id, unique_session_id} / nil,
952 ForceLoadSession = {place_id, game_job_id} / nil,
953 LastUpdate = 0, -- os.time()
954 MetaTags = {}, -- Backwards compatibility with ProfileService
955 },
956
957 RobloxMetaData = {},
958 UserIds = {},
959
960 GlobalUpdates = {
961 update_index,
962 {
963 {update_index, data}, ...
964 },
965 },
966 }
967
968--]]
969
970export type JSONAcceptable = { JSONAcceptable } | { [string]: JSONAcceptable } | number | string | boolean | buffer
971
972export type Profile<T> = {
973 Data: T & JSONAcceptable,
974 LastSavedData: T & JSONAcceptable,
975 FirstSessionTime: number,
976 SessionLoadCount: number,
977 Session: {PlaceId: number, JobId: string}?,
978 RobloxMetaData: JSONAcceptable,
979 UserIds: {number},
980 KeyInfo: DataStoreKeyInfo,
981 OnSave: {Connect: (self: any, listener: () -> ()) -> ({Disconnect: (self: any) -> ()})},
982 OnLastSave: {Connect: (self: any, listener: (reason: "Manual" | "External" | "Shutdown") -> ()) -> ({Disconnect: (self: any) -> ()})},
983 OnSessionEnd: {Connect: (self: any, listener: () -> ()) -> ({Disconnect: (self: any) -> ()})},
984 OnAfterSave: {Connect: (self: any, listener: (last_saved_data: T & JSONAcceptable) -> ()) -> ({Disconnect: (self: any) -> ()})},
985 ProfileStore: JSONAcceptable,
986 Key: string,
987
988 IsActive: (self: any) -> (boolean),
989 Reconcile: (self: any) -> (),
990 EndSession: (self: any) -> (),
991 AddUserId: (self: any, user_id: number) -> (),
992 RemoveUserId: (self: any, user_id: number) -> (),
993 MessageHandler: (self: any, fn: (message: JSONAcceptable, processed: () -> ()) -> ()) -> (),
994 Save: (self: any) -> (),
995 SetAsync: (self: any) -> (),
996}
997
998export type VersionQuery<T> = {
999 NextAsync: (self: any) -> (Profile<T>?),
1000}
1001
1002type ProfileStoreStandard<T> = {
1003 Name: string,
1004 StartSessionAsync: (self: any, profile_key: string, params: {Steal: boolean?}) -> (Profile<T>?),
1005 MessageAsync: (self: any, profile_key: string, message: JSONAcceptable) -> (boolean),
1006 GetAsync: (self: any, profile_key: string, version: string?) -> (Profile<T>?),
1007 VersionQuery: (self: any, profile_key: string, sort_direction: Enum.SortDirection?, min_date: DateTime | number | nil, max_date: DateTime | number | nil) -> (VersionQuery<T>),
1008 RemoveAsync: (self: any, profile_key: string) -> (boolean),
1009}
1010
1011export type ProfileStore<T> = {
1012 Mock: ProfileStoreStandard<T>,
1013} & ProfileStoreStandard<T>
1014
1015type ConstantName = "AUTO_SAVE_PERIOD" | "LOAD_REPEAT_PERIOD" | "FIRST_LOAD_REPEAT" | "SESSION_STEAL"
1016| "ASSUME_DEAD" | "START_SESSION_TIMEOUT" | "CRITICAL_STATE_ERROR_COUNT" | "CRITICAL_STATE_ERROR_EXPIRE"
1017| "CRITICAL_STATE_EXPIRE" | "MAX_MESSAGE_QUEUE"
1018
1019export type ProfileStoreModule = {
1020 IsClosing: boolean,
1021 IsCriticalState: boolean,
1022 OnError: {Connect: (self: any, listener: (message: string, store_name: string, profile_key: string) -> ()) -> ({Disconnect: (self: any) -> ()})},
1023 OnOverwrite: {Connect: (self: any, listener: (store_name: string, profile_key: string) -> ()) -> ({Disconnect: (self: any) -> ()})},
1024 OnCriticalToggle: {Connect: (self: any, listener: (is_critical: boolean) -> ()) -> ({Disconnect: (self: any) -> ()})},
1025 DataStoreState: "NotReady" | "NoInternet" | "NoAccess" | "Access",
1026 New: <T>(store_name: string, template: (T & JSONAcceptable)?) -> (ProfileStore<T>),
1027 SetConstant: (name: ConstantName, value: number) -> ()
1028}
1029
1030local Profile = {}
1031Profile.__index = Profile
1032
1033function Profile.New(raw_data, key_info, profile_store, key, is_mock, session_token)
1034
1035 local data = raw_data.Data or {}
1036 local session = raw_data.MetaData and raw_data.MetaData.ActiveSession or nil
1037
1038 local global_updates = raw_data.GlobalUpdates and raw_data.GlobalUpdates[2] or {}
1039 local received_global_updates = {}
1040
1041 for _, update in ipairs(global_updates) do
1042 received_global_updates[update[1]] = true
1043 end
1044
1045 local self = {
1046
1047 Data = data,
1048 LastSavedData = DeepCopyTable(data),
1049
1050 FirstSessionTime = raw_data.MetaData and raw_data.MetaData.ProfileCreateTime or 0,
1051 SessionLoadCount = raw_data.MetaData and raw_data.MetaData.SessionLoadCount or 0,
1052 Session = session and {PlaceId = session[1], JobId = session[2]},
1053
1054 RobloxMetaData = raw_data.RobloxMetaData or {},
1055 UserIds = raw_data.UserIds or {},
1056 KeyInfo = key_info,
1057
1058 OnAfterSave = Signal.New(),
1059 OnSave = Signal.New(),
1060 OnLastSave = Signal.New(),
1061 OnSessionEnd = Signal.New(),
1062
1063 ProfileStore = profile_store,
1064 Key = key,
1065
1066 load_timestamp = os.clock(),
1067 is_mock = is_mock,
1068 session_token = session_token or "",
1069 load_index = raw_data.MetaData and raw_data.MetaData.SessionLoadCount or 0,
1070 locked_global_updates = {},
1071 received_global_updates = received_global_updates,
1072 message_handlers = {},
1073 global_updates = global_updates,
1074
1075 }
1076 setmetatable(self, Profile)
1077
1078 return self
1079
1080end
1081
1082function Profile:IsActive()
1083 return ActiveSessionCheck[self.session_token] == self
1084end
1085
1086function Profile:Reconcile()
1087 ReconcileTable(self.Data, self.ProfileStore.template)
1088end
1089
1090function Profile:EndSession()
1091 if self:IsActive() == true then
1092 task.spawn(SaveProfileAsync, self, true, nil, "Manual") -- Call save function in a new thread with release_from_session = true
1093 end
1094end
1095
1096function Profile:AddUserId(user_id) -- Associates user_id with profile (GDPR compliance)
1097
1098 if type(user_id) ~= "number" or user_id % 1 ~= 0 then
1099 warn(`[{script.Name}]: Invalid UserId argument for :AddUserId() ({tostring(user_id)}); Traceback:\n` .. debug.traceback())
1100 return
1101 end
1102
1103 if user_id < 0 and self.is_mock ~= true and DataStoreState == "Access" then
1104 return -- Avoid giving real Roblox APIs negative UserId's
1105 end
1106
1107 if table.find(self.UserIds, user_id) == nil then
1108 table.insert(self.UserIds, user_id)
1109 end
1110
1111end
1112
1113function Profile:RemoveUserId(user_id) -- Removes user_id association with profile (safe function)
1114
1115 if type(user_id) ~= "number" or user_id % 1 ~= 0 then
1116 warn(`[{script.Name}]: Invalid UserId argument for :RemoveUserId() ({tostring(user_id)}); Traceback:\n` .. debug.traceback())
1117 return
1118 end
1119
1120 local index = table.find(self.UserIds, user_id)
1121
1122 if index ~= nil then
1123 table.remove(self.UserIds, index)
1124 end
1125
1126end
1127
1128function Profile:SetAsync() -- Saves the profile to the DataStore and removes the session lock
1129
1130 if self.view_mode ~= true then
1131 error(`[{script.Name}]: :SetAsync() can only be used in view mode`)
1132 end
1133
1134 SaveProfileAsync(self, nil, true)
1135
1136end
1137
1138function Profile:MessageHandler(fn)
1139
1140 if type(fn) ~= "function" then
1141 error(`[{script.Name}]: fn argument is not a function`)
1142 end
1143
1144 if self.view_mode ~= true and self:IsActive() ~= true then
1145 return -- Don't process messages if the profile session was ended
1146 end
1147
1148 local locked_updates = self.locked_global_updates
1149 table.insert(self.message_handlers, fn)
1150
1151 for _, update in ipairs(self.global_updates) do
1152
1153 local index = update[1]
1154 local update_data = update[#update] -- Backwards compatibility with ProfileService
1155
1156 if locked_updates[index] ~= true then
1157
1158 local processed_callback = function()
1159 locked_updates[index] = true
1160 end
1161
1162 local send_update_data = DeepCopyTable(update_data)
1163
1164 task.spawn(fn, send_update_data, processed_callback)
1165
1166 end
1167
1168 end
1169
1170end
1171
1172function Profile:Save()
1173
1174 if self.view_mode == true then
1175 error(`[{script.Name}]: Can't save profile in view mode; Should you be calling :SetAsync() instead?`)
1176 end
1177
1178 if self:IsActive() == false then
1179 warn(`[{script.Name}]: Attempted saving an inactive profile (STORE:{self.ProfileStore.Name}; KEY:{self.Key});`
1180 .. ` Traceback:\n` .. debug.traceback())
1181 return
1182 end
1183
1184 -- Move the profile right behind the auto save index to delay the next auto save for it:
1185 RemoveProfileFromAutoSave(self)
1186 AddProfileToAutoSave(self)
1187
1188 -- Perform save in new thread:
1189 task.spawn(SaveProfileAsync, self)
1190
1191end
1192
1193local ProfileStore: ProfileStoreModule = {
1194
1195 IsClosing = false,
1196 IsCriticalState = false,
1197 OnError = OnError, -- (message, store_name, profile_key)
1198 OnOverwrite = OnOverwrite, -- (store_name, profile_key)
1199 OnCriticalToggle = Signal.New(), -- (is_critical)
1200 DataStoreState = "NotReady", -- ("NotReady", "NoInternet", "NoAccess", "Access")
1201
1202}
1203ProfileStore.__index = ProfileStore
1204
1205function ProfileStore.SetConstant(name, value)
1206
1207 if type(value) ~= "number" then
1208 error(`[{script.Name}]: Invalid value type`)
1209 end
1210
1211 if name == "AUTO_SAVE_PERIOD" then
1212 AUTO_SAVE_PERIOD = value
1213 elseif name == "LOAD_REPEAT_PERIOD" then
1214 LOAD_REPEAT_PERIOD = value
1215 elseif name == "FIRST_LOAD_REPEAT" then
1216 FIRST_LOAD_REPEAT = value
1217 elseif name == "SESSION_STEAL" then
1218 SESSION_STEAL = value
1219 elseif name == "ASSUME_DEAD" then
1220 ASSUME_DEAD = value
1221 elseif name == "START_SESSION_TIMEOUT" then
1222 START_SESSION_TIMEOUT = value
1223 elseif name == "CRITICAL_STATE_ERROR_COUNT" then
1224 CRITICAL_STATE_ERROR_COUNT = value
1225 elseif name == "CRITICAL_STATE_ERROR_EXPIRE" then
1226 CRITICAL_STATE_ERROR_EXPIRE = value
1227 elseif name == "CRITICAL_STATE_EXPIRE" then
1228 CRITICAL_STATE_EXPIRE = value
1229 elseif name == "MAX_MESSAGE_QUEUE" then
1230 MAX_MESSAGE_QUEUE = value
1231 else
1232 error(`[{script.Name}]: Invalid constant name was provided`)
1233 end
1234
1235end
1236
1237function ProfileStore.Test()
1238 return {
1239 ActiveSessionCheck = ActiveSessionCheck,
1240 AutoSaveList = AutoSaveList,
1241 ActiveProfileLoadJobs = ActiveProfileLoadJobs,
1242 ActiveProfileSaveJobs = ActiveProfileSaveJobs,
1243 MockStore = MockStore,
1244 UserMockStore = UserMockStore,
1245 UpdateQueue = UpdateQueue,
1246 }
1247end
1248
1249function ProfileStore.New(store_name, template)
1250
1251 template = template or {}
1252
1253 if type(store_name) ~= "string" then
1254 error(`[{script.Name}]: Invalid or missing "store_name"`)
1255 elseif string.len(store_name) == 0 then
1256 error(`[{script.Name}]: store_name cannot be an empty string`)
1257 elseif string.len(store_name) > 50 then
1258 error(`[{script.Name}]: store_name is too long`)
1259 end
1260
1261 if type(template) ~= "table" then
1262 error(`[{script.Name}]: Invalid template argument`)
1263 end
1264
1265 local self
1266 self = {
1267
1268 Mock = {
1269
1270 Name = store_name,
1271
1272 StartSessionAsync = function(_, profile_key)
1273 MockFlag = true
1274 return self:StartSessionAsync(profile_key)
1275 end,
1276 MessageAsync = function(_, profile_key, message)
1277 MockFlag = true
1278 return self:MessageAsync(profile_key, message)
1279 end,
1280 GetAsync = function(_, profile_key, version)
1281 MockFlag = true
1282 return self:GetAsync(profile_key, version)
1283 end,
1284 VersionQuery = function(_, profile_key, sort_direction, min_date, max_date)
1285 MockFlag = true
1286 return self:VersionQuery(profile_key, sort_direction, min_date, max_date)
1287 end,
1288 RemoveAsync = function(_, profile_key)
1289 MockFlag = true
1290 return self:RemoveAsync(profile_key)
1291 end
1292 },
1293
1294 Name = store_name,
1295
1296 template = template,
1297 data_store = nil,
1298 load_jobs = {},
1299 mock_load_jobs = {},
1300 is_ready = true,
1301
1302 }
1303 setmetatable(self, ProfileStore)
1304
1305 local options = Instance.new("DataStoreOptions")
1306 options:SetExperimentalFeatures({v2 = true})
1307
1308 if DataStoreState == "NotReady" then
1309
1310 -- The module is not sure whether DataStores are accessible yet:
1311
1312 self.is_ready = false
1313
1314 task.spawn(function()
1315
1316 repeat task.wait() until DataStoreState ~= "NotReady"
1317
1318 if DataStoreState == "Access" then
1319 self.data_store = DataStoreService:GetDataStore(store_name, nil, options)
1320 end
1321
1322 self.is_ready = true
1323
1324 end)
1325
1326 elseif DataStoreState == "Access" then
1327
1328 self.data_store = DataStoreService:GetDataStore(store_name, nil, options)
1329
1330 end
1331
1332 return self
1333
1334end
1335
1336local function RobloxMessageSubscription(profile, unique_session_id)
1337
1338 local last_roblox_message = 0
1339
1340 local roblox_message_subscription = MessagingService:SubscribeAsync("PS_" .. unique_session_id, function(message)
1341 if type(message.Data) == "table" and message.Data.LoadCount == profile.SessionLoadCount then
1342 -- High reaction rate, based on numPlayers × 10 DataStore budget as of writing
1343 if os.clock() - last_roblox_message > 6 then
1344 last_roblox_message = os.clock()
1345 if profile:IsActive() == true then
1346 if message.Data.EndSession == true then
1347 SaveProfileAsync(profile, true, false, "External")
1348 else
1349 profile:Save()
1350 end
1351 end
1352 end
1353 end
1354 end)
1355
1356 if profile:IsActive() == true then
1357 profile.roblox_message_subscription = roblox_message_subscription
1358 else
1359 roblox_message_subscription:Disconnect()
1360 end
1361
1362end
1363
1364function ProfileStore:StartSessionAsync(profile_key, params)
1365
1366 local is_mock = ReadMockFlag()
1367
1368 if type(profile_key) ~= "string" then
1369 error(`[{script.Name}]: profile_key must be a string`)
1370 elseif string.len(profile_key) == 0 then
1371 error(`[{script.Name}]: Invalid profile_key`)
1372 elseif string.len(profile_key) > 50 then
1373 error(`[{script.Name}]: profile_key is too long`)
1374 end
1375
1376 if params ~= nil and type(params) ~= "table" then
1377 error(`[{script.Name}]: Invalid params`)
1378 end
1379
1380 params = params or {}
1381
1382 if ProfileStore.IsClosing == true then
1383 return nil
1384 end
1385
1386 WaitForStoreReady(self)
1387
1388 local session_token = SessionToken(self.Name, profile_key, is_mock)
1389
1390 if ActiveSessionCheck[session_token] ~= nil then
1391 error(`[{script.Name}]: Profile (STORE:{self.Name}; KEY:{profile_key}) is already loaded in this session`)
1392 end
1393
1394 ActiveProfileLoadJobs = ActiveProfileLoadJobs + 1
1395
1396 local is_user_cancel = false
1397
1398 local function cancel_condition()
1399 if is_user_cancel == false then
1400 if params.Cancel ~= nil then
1401 is_user_cancel = params.Cancel() == true
1402 end
1403 return is_user_cancel
1404 end
1405 return true
1406 end
1407
1408 local user_steal = params.Steal == true
1409
1410 local force_load_steps = 0 -- Session conflict handling values
1411 local request_force_load = true
1412 local steal_session = false
1413
1414 local start = os.clock()
1415 local exp_backoff = 1
1416
1417 while ProfileStore.IsClosing == false and cancel_condition() == false do
1418
1419 -- Load profile:
1420
1421 -- SPECIAL CASE - If StartSessionAsync is called for the same key again before another StartSessionAsync finishes,
1422 -- grab the DataStore return for the new call. The early call will return nil. This is supposed to retain
1423 -- expected and efficient behavior in cases where a player would quickly rejoin the same server.
1424
1425 LoadIndex += 1
1426 local load_id = LoadIndex
1427 local profile_load_jobs = is_mock == true and self.mock_load_jobs or self.load_jobs
1428 local profile_load_job = profile_load_jobs[profile_key] -- {load_id, {loaded_data, key_info} or nil}
1429
1430 local loaded_data, key_info
1431 local unique_session_id = HttpService:GenerateGUID(false)
1432
1433 if profile_load_job ~= nil then
1434
1435 profile_load_job[1] = load_id -- Steal load job
1436 while profile_load_job[2] == nil do -- Wait for job to finish
1437 task.wait()
1438 end
1439 if profile_load_job[1] == load_id then -- Load job hasn't been double-stolen
1440 loaded_data, key_info = table.unpack(profile_load_job[2])
1441 profile_load_jobs[profile_key] = nil
1442 else
1443 ActiveProfileLoadJobs = ActiveProfileLoadJobs - 1
1444 return nil
1445 end
1446
1447 else
1448
1449 profile_load_job = {load_id, nil}
1450 profile_load_jobs[profile_key] = profile_load_job
1451
1452 profile_load_job[2] = table.pack(UpdateAsync(
1453 self,
1454 profile_key,
1455 {
1456 ExistingProfileHandle = function(latest_data)
1457
1458 if ProfileStore.IsClosing == true or cancel_condition() == true then
1459 return
1460 end
1461
1462 local active_session = latest_data.MetaData.ActiveSession
1463 local force_load_session = latest_data.MetaData.ForceLoadSession
1464
1465 if active_session == nil then
1466 latest_data.MetaData.ActiveSession = {PlaceId, JobId, unique_session_id}
1467 latest_data.MetaData.ForceLoadSession = nil
1468 elseif type(active_session) == "table" then
1469 if IsThisSession(active_session) == false then
1470 local last_update = latest_data.MetaData.LastUpdate
1471 if last_update ~= nil then
1472 if os.time() - last_update > ASSUME_DEAD then
1473 latest_data.MetaData.ActiveSession = {PlaceId, JobId, unique_session_id}
1474 latest_data.MetaData.ForceLoadSession = nil
1475 return
1476 end
1477 end
1478 if steal_session == true or user_steal == true then
1479 local force_load_interrupted = if force_load_session ~= nil then not IsThisSession(force_load_session) else true
1480 if force_load_interrupted == false or user_steal == true then
1481 latest_data.MetaData.ActiveSession = {PlaceId, JobId, unique_session_id}
1482 latest_data.MetaData.ForceLoadSession = nil
1483 end
1484 elseif request_force_load == true then
1485 latest_data.MetaData.ForceLoadSession = {PlaceId, JobId}
1486 end
1487 else
1488 latest_data.MetaData.ForceLoadSession = nil
1489 end
1490 end
1491
1492 end,
1493 MissingProfileHandle = function(latest_data)
1494
1495 local is_cancel = ProfileStore.IsClosing == true or cancel_condition() == true
1496
1497 latest_data.Data = DeepCopyTable(self.template)
1498 latest_data.MetaData = {
1499 ProfileCreateTime = os.time(),
1500 SessionLoadCount = 0,
1501 ActiveSession = if is_cancel == false then {PlaceId, JobId, unique_session_id} else nil,
1502 ForceLoadSession = nil,
1503 MetaTags = {}, -- Backwards compatibility with ProfileService
1504 }
1505
1506 end,
1507 EditProfile = function(latest_data)
1508
1509 if ProfileStore.IsClosing == true or cancel_condition() == true then
1510 return
1511 end
1512
1513 local active_session = latest_data.MetaData.ActiveSession
1514 if active_session ~= nil and IsThisSession(active_session) == true then
1515 latest_data.MetaData.SessionLoadCount = latest_data.MetaData.SessionLoadCount + 1
1516 latest_data.MetaData.LastUpdate = os.time()
1517 end
1518
1519 end,
1520 },
1521 is_mock
1522 ))
1523 if profile_load_job[1] == load_id then -- Load job hasn't been stolen
1524 loaded_data, key_info = table.unpack(profile_load_job[2])
1525 profile_load_jobs[profile_key] = nil
1526 else
1527 ActiveProfileLoadJobs = ActiveProfileLoadJobs - 1
1528 return nil -- Load job stolen
1529 end
1530 end
1531
1532 -- Handle load_data:
1533
1534 if loaded_data ~= nil and key_info ~= nil then
1535 local active_session = loaded_data.MetaData.ActiveSession
1536 if type(active_session) == "table" then
1537
1538 if IsThisSession(active_session) == true then
1539
1540 -- Profile is now taken by this session:
1541
1542 local profile = Profile.New(loaded_data, key_info, self, profile_key, is_mock, session_token)
1543 AddProfileToAutoSave(profile)
1544
1545 if is_mock ~= true and DataStoreState == "Access" then
1546
1547 -- Use MessagingService to quickly detect session conflicts and resolve them quickly:
1548 task.spawn(RobloxMessageSubscription, profile, unique_session_id) -- Blocking prevention
1549
1550 end
1551
1552 if ProfileStore.IsClosing == true or cancel_condition() == true then
1553 -- The server has initiated a shutdown by the time this profile was loaded
1554 SaveProfileAsync(profile, true) -- Release profile and yield until the DataStore call is finished
1555 profile = nil -- Don't return the profile object
1556 end
1557
1558 ActiveProfileLoadJobs = ActiveProfileLoadJobs - 1
1559 return profile
1560
1561 else
1562
1563 if ProfileStore.IsClosing == true or cancel_condition() == true then
1564 ActiveProfileLoadJobs = ActiveProfileLoadJobs - 1
1565 return nil
1566 end
1567
1568 -- Profile is taken by some other session:
1569
1570 local force_load_session = loaded_data.MetaData.ForceLoadSession
1571 local force_load_interrupted = if force_load_session ~= nil then not IsThisSession(force_load_session) else true
1572
1573 if force_load_interrupted == false then
1574
1575 if request_force_load == false then
1576 force_load_steps = force_load_steps + 1
1577 if force_load_steps >= math.ceil(SESSION_STEAL / LOAD_REPEAT_PERIOD) then
1578 steal_session = true
1579 end
1580 end
1581
1582 -- Request the remote server to end its session:
1583 if type(active_session[3]) == "string" then
1584 local session_load_count = loaded_data.MetaData.SessionLoadCount or 0
1585 task.spawn(MessagingService.PublishAsync, MessagingService, "PS_" .. active_session[3], {LoadCount = session_load_count, EndSession = true})
1586 end
1587
1588 -- Attempt to load the profile again after a delay
1589 local wait_until = os.clock() + if request_force_load == true then FIRST_LOAD_REPEAT else LOAD_REPEAT_PERIOD
1590 repeat task.wait() until os.clock() >= wait_until or ProfileStore.IsClosing == true
1591
1592 else
1593 -- Another session tried to load this profile:
1594 ActiveProfileLoadJobs = ActiveProfileLoadJobs - 1
1595 return nil
1596 end
1597
1598 request_force_load = false -- Only request a force load once
1599
1600 end
1601
1602 else
1603 ActiveProfileLoadJobs = ActiveProfileLoadJobs - 1
1604 return nil -- In this scenario it is likely that this server started shutting down
1605 end
1606 else
1607
1608 -- A DataStore call has likely ended in an error:
1609
1610 local default_timeout = false
1611
1612 if params.Cancel == nil then
1613 default_timeout = os.clock() - start >= START_SESSION_TIMEOUT
1614 end
1615
1616 if default_timeout == true or ProfileStore.IsClosing == true or cancel_condition() == true then
1617 ActiveProfileLoadJobs = ActiveProfileLoadJobs - 1
1618 return nil
1619 end
1620
1621 task.wait(exp_backoff) -- Repeat the call shortly
1622 exp_backoff = math.min(20, exp_backoff * 2)
1623
1624 end
1625
1626 end
1627
1628 ActiveProfileLoadJobs = ActiveProfileLoadJobs - 1
1629 return nil -- Game started shutting down or the request was cancelled - don't return the profile
1630
1631end
1632
1633function ProfileStore:MessageAsync(profile_key, message)
1634
1635 local is_mock = ReadMockFlag()
1636
1637 if type(profile_key) ~= "string" then
1638 error(`[{script.Name}]: profile_key must be a string`)
1639 elseif string.len(profile_key) == 0 then
1640 error(`[{script.Name}]: Invalid profile_key`)
1641 elseif string.len(profile_key) > 50 then
1642 error(`[{script.Name}]: profile_key is too long`)
1643 end
1644
1645 if type(message) ~= "table" then
1646 error(`[{script.Name}]: message must be a table`)
1647 end
1648
1649 if ProfileStore.IsClosing == true then
1650 return false
1651 end
1652
1653 WaitForStoreReady(self)
1654
1655 local exp_backoff = 1
1656
1657 while ProfileStore.IsClosing == false do
1658
1659 -- Updating profile:
1660
1661 local loaded_data = UpdateAsync(
1662 self,
1663 profile_key,
1664 {
1665 ExistingProfileHandle = nil,
1666 MissingProfileHandle = nil,
1667 EditProfile = function(latest_data)
1668
1669 local global_updates = latest_data.GlobalUpdates
1670 local update_list = global_updates[2]
1671 --{
1672 -- update_index,
1673 -- {
1674 -- {update_index, data}, ...
1675 -- },
1676 --},
1677
1678 global_updates[1] += 1
1679 table.insert(update_list, {global_updates[1], message})
1680
1681 -- Clearing queue if above limit:
1682
1683 while #update_list > MAX_MESSAGE_QUEUE do
1684 table.remove(update_list, 1)
1685 end
1686
1687 end,
1688 },
1689 is_mock
1690 )
1691
1692 if loaded_data ~= nil then
1693
1694 local session_token = SessionToken(self.Name, profile_key, is_mock)
1695
1696 local profile = ActiveSessionCheck[session_token]
1697
1698 if profile ~= nil then
1699
1700 -- The message was sent to a profile that is active in this server:
1701 profile:Save()
1702
1703 else
1704
1705 local meta_data = loaded_data.MetaData or {}
1706 local active_session = meta_data.ActiveSession
1707 local session_load_count = meta_data.SessionLoadCount or 0
1708
1709 if type(active_session) == "table" and type(active_session[3]) == "string" then
1710 -- Request the remote server to auto-save sooner and receive the message:
1711 task.spawn(MessagingService.PublishAsync, MessagingService, "PS_" .. active_session[3], {LoadCount = session_load_count})
1712 end
1713
1714 end
1715
1716 return true
1717
1718 else
1719
1720 task.wait(exp_backoff) -- A DataStore call has likely ended in an error - repeat the call shortly
1721 exp_backoff = math.min(20, exp_backoff * 2)
1722
1723 end
1724
1725 end
1726
1727 return false
1728
1729end
1730
1731function ProfileStore:GetAsync(profile_key, version)
1732
1733 local is_mock = ReadMockFlag()
1734
1735 if type(profile_key) ~= "string" then
1736 error(`[{script.Name}]: profile_key must be a string`)
1737 elseif string.len(profile_key) == 0 then
1738 error(`[{script.Name}]: Invalid profile_key`)
1739 elseif string.len(profile_key) > 50 then
1740 error(`[{script.Name}]: profile_key is too long`)
1741 end
1742
1743 if ProfileStore.IsClosing == true then
1744 return nil
1745 end
1746
1747 WaitForStoreReady(self)
1748
1749 if version ~= nil and (is_mock or DataStoreState ~= "Access") then
1750 return nil -- No version support in mock mode
1751 end
1752
1753 local exp_backoff = 1
1754
1755 while ProfileStore.IsClosing == false do
1756
1757 -- Load profile:
1758
1759 local loaded_data, key_info = UpdateAsync(
1760 self,
1761 profile_key,
1762 {
1763 ExistingProfileHandle = nil,
1764 MissingProfileHandle = function(latest_data)
1765
1766 latest_data.Data = DeepCopyTable(self.template)
1767 latest_data.MetaData = {
1768 ProfileCreateTime = os.time(),
1769 SessionLoadCount = 0,
1770 ActiveSession = nil,
1771 ForceLoadSession = nil,
1772 MetaTags = {}, -- Backwards compatibility with ProfileService
1773 }
1774
1775 end,
1776 EditProfile = nil,
1777 },
1778 is_mock,
1779 true, -- Use :GetAsync()
1780 version -- DataStore key version
1781 )
1782
1783 -- Handle load_data:
1784
1785 if loaded_data ~= nil then
1786
1787 if key_info == nil then
1788 return nil -- Load was successful, but the key was empty - return no profile object
1789 end
1790
1791 local profile = Profile.New(loaded_data, key_info, self, profile_key, is_mock)
1792 profile.view_mode = true
1793
1794 return profile
1795
1796 else
1797
1798 task.wait(exp_backoff) -- A DataStore call has likely ended in an error - repeat the call shortly
1799 exp_backoff = math.min(20, exp_backoff * 2)
1800
1801 end
1802
1803 end
1804
1805 return nil -- Game started shutting down - don't return the profile
1806
1807end
1808
1809function ProfileStore:RemoveAsync(profile_key)
1810
1811 local is_mock = ReadMockFlag()
1812
1813 if type(profile_key) ~= "string" or string.len(profile_key) == 0 then
1814 error(`[{script.Name}]: Invalid profile_key`)
1815 end
1816
1817 if ProfileStore.IsClosing == true then
1818 return false
1819 end
1820
1821 WaitForStoreReady(self)
1822
1823 local wipe_status = false
1824
1825 local next_in_queue = WaitInUpdateQueue(SessionToken(self.Name, profile_key, is_mock))
1826
1827 if is_mock == true then -- Used when the profile is accessed through ProfileStore.Mock
1828
1829 local mock_data_store = UserMockStore[self.Name]
1830
1831 if mock_data_store ~= nil then
1832 mock_data_store[profile_key] = nil
1833 if next(mock_data_store) == nil then
1834 UserMockStore[self.Name] = nil
1835 end
1836 end
1837
1838 wipe_status = true
1839 task.wait() -- Simulate API call yield
1840
1841 elseif DataStoreState ~= "Access" then -- Used when API access is disabled
1842
1843 local mock_data_store = MockStore[self.Name]
1844
1845 if mock_data_store ~= nil then
1846 mock_data_store[profile_key] = nil
1847 if next(mock_data_store) == nil then
1848 MockStore[self.Name] = nil
1849 end
1850 end
1851
1852 wipe_status = true
1853 task.wait() -- Simulate API call yield
1854
1855 else -- Live DataStore
1856
1857 wipe_status = pcall(function()
1858 self.data_store:RemoveAsync(profile_key)
1859 end)
1860
1861 end
1862
1863 next_in_queue()
1864
1865 return wipe_status
1866
1867end
1868
1869local ProfileVersionQuery = {}
1870ProfileVersionQuery.__index = ProfileVersionQuery
1871
1872function ProfileVersionQuery.New(profile_store, profile_key, sort_direction, min_date, max_date, is_mock)
1873
1874 local self = {
1875 profile_store = profile_store,
1876 profile_key = profile_key,
1877 sort_direction = sort_direction,
1878 min_date = min_date,
1879 max_date = max_date,
1880
1881 query_pages = nil,
1882 query_index = 0,
1883 query_failure = false,
1884
1885 is_query_yielded = false,
1886 query_queue = {},
1887
1888 is_mock = is_mock,
1889 }
1890 setmetatable(self, ProfileVersionQuery)
1891
1892 return self
1893
1894end
1895
1896function MoveVersionQueryQueue(self) -- Hidden ProfileVersionQuery method
1897 while #self.query_queue > 0 do
1898
1899 local queue_entry = table.remove(self.query_queue, 1)
1900
1901 task.spawn(queue_entry)
1902
1903 if self.is_query_yielded == true then
1904 break
1905 end
1906
1907 end
1908end
1909
1910local VersionQueryNextAsyncStackingFlag = false
1911local WarnAboutVersionQueryOnce = false
1912
1913function ProfileVersionQuery:NextAsync()
1914
1915 local is_stacking = VersionQueryNextAsyncStackingFlag == true
1916 VersionQueryNextAsyncStackingFlag = false
1917
1918 WaitForStoreReady(self.profile_store)
1919
1920 if ProfileStore.IsClosing == true then
1921 return nil -- Silently fail :NextAsync() requests
1922 end
1923
1924 if self.is_mock == true or DataStoreState ~= "Access" then
1925 if IsStudio == true and WarnAboutVersionQueryOnce == false then
1926 WarnAboutVersionQueryOnce = true
1927 warn(`[{script.Name}]: :VersionQuery() is not supported in mock mode!`)
1928 end
1929 return nil -- Silently fail :NextAsync() requests
1930 end
1931
1932 local profile
1933 local is_finished = false
1934
1935 local function query_job()
1936
1937 if self.query_failure == true then
1938 is_finished = true
1939 return
1940 end
1941
1942 -- First "next" call loads version pages:
1943
1944 if self.query_pages == nil then
1945
1946 self.is_query_yielded = true
1947
1948 task.spawn(function()
1949 VersionQueryNextAsyncStackingFlag = true
1950 profile = self:NextAsync()
1951 is_finished = true
1952 end)
1953
1954 local list_success, error_message = pcall(function()
1955 self.query_pages = self.profile_store.data_store:ListVersionsAsync(
1956 self.profile_key,
1957 self.sort_direction,
1958 self.min_date,
1959 self.max_date
1960 )
1961 self.query_index = 0
1962 end)
1963
1964 if list_success == false or self.query_pages == nil then
1965 warn(`[{script.Name}]: Version query fail - {tostring(error_message)}`)
1966 self.query_failure = true
1967 end
1968
1969 self.is_query_yielded = false
1970
1971 MoveVersionQueryQueue(self)
1972
1973 return
1974
1975 end
1976
1977 local current_page = self.query_pages:GetCurrentPage()
1978 local next_item = current_page[self.query_index + 1]
1979
1980 -- No more entries:
1981
1982 if self.query_pages.IsFinished == true and next_item == nil then
1983 is_finished = true
1984 return
1985 end
1986
1987 -- Load next page when this page is over:
1988
1989 if next_item == nil then
1990
1991 self.is_query_yielded = true
1992 task.spawn(function()
1993 VersionQueryNextAsyncStackingFlag = true
1994 profile = self:NextAsync()
1995 is_finished = true
1996 end)
1997
1998 local success, error_message = pcall(function()
1999 self.query_pages:AdvanceToNextPageAsync()
2000 self.query_index = 0
2001 end)
2002
2003 if success == false or #self.query_pages:GetCurrentPage() == 0 then
2004 self.query_failure = true
2005 end
2006
2007 self.is_query_yielded = false
2008 MoveVersionQueryQueue(self)
2009
2010 return
2011
2012 end
2013
2014 -- Next page item:
2015
2016 self.query_index += 1
2017 profile = self.profile_store:GetAsync(self.profile_key, next_item.Version)
2018 is_finished = true
2019
2020 end
2021
2022 if self.is_query_yielded == false then
2023 query_job()
2024 else
2025 if is_stacking == true then
2026 table.insert(self.query_queue, 1, query_job)
2027 else
2028 table.insert(self.query_queue, query_job)
2029 end
2030 end
2031
2032 while is_finished == false do
2033 task.wait()
2034 end
2035
2036 return profile
2037
2038end
2039
2040function ProfileStore:VersionQuery(profile_key, sort_direction, min_date, max_date)
2041
2042 local is_mock = ReadMockFlag()
2043
2044 if type(profile_key) ~= "string" or string.len(profile_key) == 0 then
2045 error(`[{script.Name}]: Invalid profile_key`)
2046 end
2047
2048 -- Type check:
2049
2050 if sort_direction ~= nil and (typeof(sort_direction) ~= "EnumItem"
2051 or sort_direction.EnumType ~= Enum.SortDirection) then
2052 error(`[{script.Name}]: Invalid sort_direction ({tostring(sort_direction)})`)
2053 end
2054
2055 if min_date ~= nil and typeof(min_date) ~= "DateTime" and typeof(min_date) ~= "number" then
2056 error(`[{script.Name}]: Invalid min_date ({tostring(min_date)})`)
2057 end
2058
2059 if max_date ~= nil and typeof(max_date) ~= "DateTime" and typeof(max_date) ~= "number" then
2060 error(`[{script.Name}]: Invalid max_date ({tostring(max_date)})`)
2061 end
2062
2063 min_date = typeof(min_date) == "DateTime" and min_date.UnixTimestampMillis or min_date
2064 max_date = typeof(max_date) == "DateTime" and max_date.UnixTimestampMillis or max_date
2065
2066 return ProfileVersionQuery.New(self, profile_key, sort_direction, min_date, max_date, is_mock)
2067
2068end
2069
2070-- DataStore API access check:
2071
2072if IsStudio == true then
2073
2074 task.spawn(function()
2075
2076 local new_state = "NoAccess"
2077
2078 local status, message = pcall(function()
2079 -- This will error if current instance has no Studio API access:
2080 DataStoreService:GetDataStore("____PS"):SetAsync("____PS", os.time())
2081 end)
2082
2083 local no_internet_access = status == false and string.find(message, "ConnectFail", 1, true) ~= nil
2084
2085 if no_internet_access == true then
2086 warn(`[{script.Name}]: No internet access - check your network connection`)
2087 end
2088
2089 if status == false and
2090 (string.find(message, "403", 1, true) ~= nil or -- Cannot write to DataStore from studio if API access is not enabled
2091 string.find(message, "must publish", 1, true) ~= nil or -- Game must be published to access live keys
2092 no_internet_access == true) then -- No internet access
2093
2094 new_state = if no_internet_access == true then "NoInternet" else "NoAccess"
2095 print(`[{script.Name}]: Roblox API services unavailable - data will not be saved`)
2096 else
2097 new_state = "Access"
2098 print(`[{script.Name}]: Roblox API services available - data will be saved`)
2099 end
2100
2101 DataStoreState = new_state
2102 ProfileStore.DataStoreState = new_state
2103
2104 end)
2105
2106else
2107
2108 DataStoreState = "Access"
2109 ProfileStore.DataStoreState = "Access"
2110
2111end
2112
2113-- Update loop:
2114
2115RunService.Heartbeat:Connect(function()
2116
2117 -- Auto saving:
2118
2119 local auto_save_list_length = #AutoSaveList
2120 if auto_save_list_length > 0 then
2121 local auto_save_index_speed = AUTO_SAVE_PERIOD / auto_save_list_length
2122 local os_clock = os.clock()
2123 while os_clock - LastAutoSave > auto_save_index_speed do
2124 LastAutoSave = LastAutoSave + auto_save_index_speed
2125 local profile = AutoSaveList[AutoSaveIndex]
2126 if os_clock - profile.load_timestamp < AUTO_SAVE_PERIOD / 2 then
2127 -- This profile is freshly loaded - auto saving immediately is not necessary:
2128 profile = nil
2129 for _ = 1, auto_save_list_length - 1 do
2130 -- Move auto save index to the right:
2131 AutoSaveIndex = AutoSaveIndex + 1
2132 if AutoSaveIndex > auto_save_list_length then
2133 AutoSaveIndex = 1
2134 end
2135 profile = AutoSaveList[AutoSaveIndex]
2136 if os_clock - profile.load_timestamp >= AUTO_SAVE_PERIOD / 2 then
2137 break
2138 else
2139 profile = nil
2140 end
2141 end
2142 end
2143 -- Move auto save index to the right:
2144 AutoSaveIndex = AutoSaveIndex + 1
2145 if AutoSaveIndex > auto_save_list_length then
2146 AutoSaveIndex = 1
2147 end
2148 -- Perform save call:
2149 if profile ~= nil then
2150 task.spawn(SaveProfileAsync, profile) -- Auto save profile in new thread
2151 end
2152 end
2153 end
2154
2155 -- Critical state handling:
2156
2157 if ProfileStore.IsCriticalState == false then
2158 if #IssueQueue >= CRITICAL_STATE_ERROR_COUNT then
2159 ProfileStore.IsCriticalState = true
2160 ProfileStore.OnCriticalToggle:Fire(true)
2161 CriticalStateStart = os.clock()
2162 warn(`[{script.Name}]: Entered critical state`)
2163 end
2164 else
2165 if #IssueQueue >= CRITICAL_STATE_ERROR_COUNT then
2166 CriticalStateStart = os.clock()
2167 elseif os.clock() - CriticalStateStart > CRITICAL_STATE_EXPIRE then
2168 ProfileStore.IsCriticalState = false
2169 ProfileStore.OnCriticalToggle:Fire(false)
2170 warn(`[{script.Name}]: Critical state ended`)
2171 end
2172 end
2173
2174 -- Issue queue:
2175
2176 while true do
2177 local issue_time = IssueQueue[1]
2178 if issue_time == nil then
2179 break
2180 elseif os.clock() - issue_time > CRITICAL_STATE_ERROR_EXPIRE then
2181 table.remove(IssueQueue, 1)
2182 else
2183 break
2184 end
2185 end
2186
2187end)
2188
2189-- Release all loaded profiles when the server is shutting down:
2190
2191task.spawn(function()
2192
2193 while DataStoreState == "NotReady" do
2194 task.wait()
2195 end
2196
2197 if DataStoreState ~= "Access" then
2198
2199 game:BindToClose(function()
2200 ProfileStore.IsClosing = true
2201 task.wait() -- Mock shutdown delay
2202 end)
2203
2204 return -- Don't wait for profiles to properly save in mock mode so studio could end the simulation faster
2205
2206 end
2207
2208 game:BindToClose(function()
2209
2210 ProfileStore.IsClosing = true
2211
2212 -- Release all active profiles:
2213 -- (Clone AutoSaveList to a new table because AutoSaveList changes when profiles are released)
2214
2215 local on_close_save_job_count = 0
2216 local active_profiles = {}
2217 for index, profile in ipairs(AutoSaveList) do
2218 active_profiles[index] = profile
2219 end
2220
2221 -- Release the profiles; Releasing profiles can trigger listeners that release other profiles, so check active state:
2222 for _, profile in ipairs(active_profiles) do
2223 if profile:IsActive() == true then
2224 on_close_save_job_count = on_close_save_job_count + 1
2225 task.spawn(function() -- Save profile on new thread
2226 SaveProfileAsync(profile, true, nil, "Shutdown")
2227 on_close_save_job_count = on_close_save_job_count - 1
2228 end)
2229 end
2230 end
2231
2232 -- Yield until all active profile jobs are finished:
2233 while on_close_save_job_count > 0 or ActiveProfileLoadJobs > 0 or ActiveProfileSaveJobs > 0 do
2234 task.wait()
2235 end
2236
2237 return -- We're done!
2238
2239 end)
2240
2241end)
2242
2243return ProfileStore