ChronicleStore
A high-level, production-ready DataStore wrapper for Roblox, inspired by ProfileStore with enterprise-grade reliability features.
Session-Based
Automatic lease management prevents data conflicts across servers
Data Integrity
Built-in hash verification detects corruption automatically
Circuit Breaker
Intelligent failure handling prevents cascading errors
Write Optimization
Automatic write coalescing reduces DataStore calls
Why ChronicleStore?
ChronicleStore provides production-ready features out of the box:
- Reliability: Exponential backoff, circuit breakers, and automatic retries
- Data Safety: FNV-1a hash verification and corruption detection
- Performance: Write coalescing, lease renewal, and optimized DataStore calls
- Developer Experience: Comprehensive hooks, simulation mode for testing, and detailed observability
- Battle-Tested: Handles edge cases like lease conflicts, server crashes, and DataStore outages
Getting Started
Installation
Copy the ChronicleStore module into your game's ReplicatedStorage or ServerScriptService.
Basic Usage
local ChronicleStore = require(path.to.ChronicleStore)
-- Create a store
local PlayerStore = ChronicleStore.new("PlayerData", {
Template = {
Coins = 0,
Level = 1,
Inventory = {},
Settings = {
Music = true,
SFX = true,
}
}
})
-- Load a player's profile
local profile = PlayerStore:StartSessionAsync("Player_" .. player.UserId)
if profile then
-- Access data
print("Coins:", profile.Data.Coins)
-- Modify data
profile.Data.Coins = profile.Data.Coins + 100
-- Save changes
profile:Save()
-- Release when done (e.g., when player leaves)
profile:Release()
else
warn("Failed to load profile")
player:Kick("Data loading failed")
end
Quick Example: Player Data Management
local Players = game:GetService("Players")
local ChronicleStore = require(game.ServerScriptService.ChronicleStore)
local PlayerProfiles = {}
local PlayerStore = ChronicleStore.new("PlayerData", {
Template = {
Coins = 0,
Level = 1,
Experience = 0,
Inventory = {},
},
Hooks = {
OnLoad = function(key, data, stats)
print("Loaded profile:", key, "in", stats.latency_ms, "ms")
end,
OnSave = function(key, data, stats)
print("Saved profile:", key)
end,
}
})
Players.PlayerAdded:Connect(function(player)
local profile = PlayerStore:StartSessionAsync(
"Player_" .. player.UserId,
function()
return not player:IsDescendantOf(Players)
end
)
if not profile then
player:Kick("Failed to load data")
return
end
PlayerProfiles[player] = profile
-- Give player their coins
local leaderstats = Instance.new("Folder")
leaderstats.Name = "leaderstats"
local coins = Instance.new("IntValue")
coins.Name = "Coins"
coins.Value = profile.Data.Coins
coins.Parent = leaderstats
leaderstats.Parent = player
end)
Players.PlayerRemoving:Connect(function(player)
local profile = PlayerProfiles[player]
if profile then
profile:Release()
PlayerProfiles[player] = nil
end
end)
Core Concepts
Profiles
A Profile represents a single player's (or entity's) data. Each profile contains:
Data- The actual user data (what you read and modify)Metadata- System information (key, session ID, generation, commit index, timestamps)- Methods -
Save(),Release(),IsActive()
Release() when you're done with a profile. This ensures data is saved and the lease is released for other servers.
Sessions
A Session is the active period during which a server has exclusive access to a profile. Sessions are managed automatically through leases.
Session lifecycle:
StartSessionAsync()- Acquire lease and load data- Read/modify
profile.Data - Automatic saves via write coalescing
Release()- Final save and release lease
Leases
A Lease is a time-limited exclusive lock on a profile. Leases prevent multiple servers from editing the same data simultaneously.
Lease Properties
- Duration: 60 seconds (configurable via
LEASE_DURATION) - Renewal: Automatic renewal when less than 50 seconds remain
- Owner: Identified by server JobId and unique session ID
- Generation: Increments on each lease transfer to detect conflicts
Lease Conflict Resolution
When a server tries to acquire a lease held by another server:
- Check expiration: If expired, steal immediately
- MessagingService challenge: Ping the owner to check if alive
- Response timeout: If no response in 2 seconds, assume dead and steal
- Active owner: If owner responds, fail immediately (don't retry)
-- Lease conflict hook example
local store = ChronicleStore.new("Data", {
Template = {},
Hooks = {
OnLeaseConflict = function(key, resolution)
if resolution == "active_owner_confirmed" then
warn("Another server owns this profile:", key)
elseif resolution == "expired_lease_steal" then
print("Acquired expired lease:", key)
elseif resolution == "messaging_timeout_steal" then
print("Previous owner didn't respond, acquired lease:", key)
end
end,
}
})
Data Integrity
ChronicleStore uses FNV-1a hashing to verify data hasn't been corrupted. Every save computes a hash, and every load verifies it.
How It Works
- Canonical Serialization: Data is serialized deterministically (sorted keys)
- FNV-1a Hash: 64-bit hash computed from canonical representation
- Storage: Hash stored alongside data in
integrity.hash - Verification: On load, hash is recomputed and compared
- Corruption Detection: Mismatch triggers
OnCorruptionhook and fails load
-- Corruption detection hook
Hooks = {
OnCorruption = function(key, corruptionType, details)
warn("DATA CORRUPTION DETECTED:", key)
warn("Type:", corruptionType)
warn("Details:", HttpService:JSONEncode(details))
-- Alert monitoring system, send to analytics, etc.
end,
}
API Reference
ChronicleStore.new()
ChronicleStore.new(storeName: string, config: Config) → ChronicleStore
Creates a new ChronicleStore instance.
Parameters
| Parameter | Type | Description |
|---|---|---|
| storeName | string | Name of the DataStore (used with DataStoreService:GetDataStore) |
| config | Config | Configuration object (see Configuration section) |
Returns
A new ChronicleStore instance.
local PlayerStore = ChronicleStore.new("PlayerData", {
Template = {
Coins = 0,
Level = 1,
},
Hooks = {
OnLoad = function(key, data, stats)
print("Loaded:", key)
end,
},
SimulationMode = false,
})
StartSessionAsync()
ChronicleStore:StartSessionAsync(key: string, cancelCondition: (() → boolean)?) → Profile?
Loads a profile with exclusive access. Acquires a lease, loads data, and returns a Profile object.
Parameters
| Parameter | Type | Description |
|---|---|---|
| key | string | Unique identifier for the profile (e.g., "Player_123456789") |
| cancelCondition | (() → boolean)? | Optional function that returns true to abort loading (e.g., player left) |
Returns
A Profile object if successful, nil if failed or cancelled.
Behavior
- Attempts to acquire a lease (with retry and backoff)
- Loads data from DataStore
- Verifies data integrity via hash
- Merges with template to add missing fields
- Schedules automatic lease renewal
- Calls
OnLoadhook
-- Basic usage
local profile = PlayerStore:StartSessionAsync("Player_" .. player.UserId)
-- With cancel condition (player left during load)
local profile = PlayerStore:StartSessionAsync(
"Player_" .. player.UserId,
function()
return not player:IsDescendantOf(Players)
end
)
if not profile then
warn("Failed to load profile")
return
end
Profile:Save()
Profile:Save() → boolean
Manually saves the profile to DataStore.
Returns
true if save succeeded, false if failed.
Behavior
- Computes integrity hash of current data
- Validates lease ownership and generation
- Increments commit index
- Renews lease as part of save (piggyback optimization)
- Calls
OnSavehook - Cancels any pending coalesced write
profile.Data.Coins = profile.Data.Coins + 100
-- Manual save (optional, auto-save will happen anyway)
local success = profile:Save()
if not success then
warn("Save failed!")
end
Profile:Release()
Profile:Release() → ()
Releases the profile, performs final save, and frees the lease.
Behavior
- Cancels scheduled lease renewal
- Cancels pending coalesced writes
- Performs final save if data is dirty
- Releases the lease (marks as expired in DataStore)
- Marks profile as inactive
- Removes from active sessions
- Lease not freed until expiration (60 seconds)
- Other servers blocked from loading
- Memory leaks (profile stays in ActiveSessions)
Players.PlayerRemoving:Connect(function(player)
local profile = PlayerProfiles[player]
if profile then
profile:Release() -- Always release!
PlayerProfiles[player] = nil
end
end)
Profile:IsActive()
Profile:IsActive() → boolean
Checks if the profile is still active (not released).
Returns
true if active, false if released.
if profile:IsActive() then
profile.Data.Coins = profile.Data.Coins + 10
else
warn("Cannot modify inactive profile")
end
Additional Methods
IsDataStoreAvailable()
ChronicleStore:IsDataStoreAvailable() → boolean
Returns true if DataStore is available or simulation mode is enabled.
GetStatus()
ChronicleStore:GetStatus() → {enabled: boolean, mode: string}
Returns current status: {enabled = true, mode = "production"} or {enabled = false, mode = "unavailable"} or {enabled = true, mode = "simulation"}.
Configuration
Template
The Template defines the default structure for new profiles and provides default values for missing fields.
Template = {
Coins = 0,
Level = 1,
Experience = 0,
Inventory = {},
Settings = {
Music = true,
SFX = true,
GraphicsQuality = "Medium",
},
Stats = {
PlayTime = 0,
Kills = 0,
Deaths = 0,
},
}
Template Reconciliation
When a profile is loaded, ChronicleStore performs a deep merge with the template:
- Missing fields from template are added to loaded data
- Existing fields in loaded data are preserved
- Nested tables are merged recursively
This allows you to safely add new fields to your data structure without breaking existing saves.
Hooks
Hooks are callbacks that let you observe and react to lifecycle events.
OnLoad
OnLoad: (key: string, data: any, stats: Stats) → ()
Called after a profile is successfully loaded.
OnLoad = function(key, data, stats)
print("Profile loaded:", key)
print("Latency:", stats.latency_ms, "ms")
print("DataStore calls:", stats.datastore_calls)
print("Retries:", stats.retry_count)
-- Send to analytics
AnalyticsService:RecordEvent("ProfileLoaded", {
Key = key,
LoadTime = stats.latency_ms,
})
end
OnSave
OnSave: (key: string, data: any, stats: Stats) → ()
Called when a profile is saved (manual or automatic).
OnSave = function(key, data, stats)
print("Profile saved:", key)
-- Backup to external database, log to analytics, etc.
end
OnCorruption
OnCorruption: (key: string, corruptionType: string, details: any) → ()
Called when data corruption is detected (hash mismatch).
OnCorruption = function(key, corruptionType, details)
warn("CRITICAL: Data corruption detected for", key)
warn("Corruption type:", corruptionType)
-- Send alert to monitoring system
MonitoringService:Alert("DataCorruption", {
Key = key,
Type = corruptionType,
Details = details,
})
end
OnLeaseConflict
OnLeaseConflict: (key: string, resolution: string) → ()
Called when a lease conflict occurs. Resolution can be:
"expired_lease_steal"- Acquired an expired lease"messaging_timeout_steal"- Previous owner didn't respond"active_owner_confirmed"- Another server owns the profile
OnLeaseConflict = function(key, resolution)
if resolution == "active_owner_confirmed" then
warn("Conflict: Another server actively owns", key)
else
print("Acquired lease via", resolution, "for", key)
end
end
OnCircuitOpen
OnCircuitOpen: (reason: string, stats: CircuitStats) → ()
Called when the circuit breaker opens (too many failures).
OnCircuitOpen = function(reason, stats)
warn("Circuit breaker OPEN:", reason)
warn("Error rate:", stats.error_rate)
warn("Consecutive failures:", stats.consecutive_failures)
-- Alert ops team
AlertService:Send("DataStore circuit breaker opened!")
end
Simulation Mode
Simulation mode allows testing without DataStore access (useful in Studio with API access disabled).
local TestStore = ChronicleStore.new("TestData", {
Template = { Coins = 0 },
SimulationMode = true,
SimulationConfig = {
FailureRate = 0.1, -- 10% of operations fail
LatencyMs = 100, -- 100ms simulated latency
CorruptionRate = 0.01, -- 1% chance of corruption
LeaseContention = true, -- Simulate lease conflicts
MessagingFailure = false, -- Simulate messaging failures
},
})
SimulationConfig Options
| Option | Type | Description |
|---|---|---|
| FailureRate | number | Probability (0-1) that operations fail |
| LatencyMs | number | Simulated delay in milliseconds for each operation |
| CorruptionRate | number | Probability (0-1) of data corruption injection |
| LeaseContention | boolean | Whether to simulate lease conflicts |
| MessagingFailure | boolean | Whether to simulate MessagingService failures |
Advanced Features
Circuit Breaker
The circuit breaker prevents cascading failures by temporarily blocking requests when error rates are high.
States
- Closed: Normal operation, all requests allowed
- Open: Too many failures, requests fail fast for 30 seconds
- Half-Open: Testing if service recovered, limited requests allowed
Triggering Conditions
Circuit opens when:
- 5+ consecutive failures, OR
- Error rate exceeds 50% in a 60-second window
Recovery
- After 30 seconds, circuit enters half-open state
- Next request is allowed through
- If successful, circuit closes
- If fails, circuit reopens with doubled timeout (up to 300s max)
-- Monitor circuit breaker
Hooks = {
OnCircuitOpen = function(reason, stats)
warn("Circuit breaker opened!")
warn("Consecutive failures:", stats.consecutive_failures)
warn("Error rate:", stats.error_rate)
-- Disable non-critical features
game.ReplicatedStorage.CircuitOpen.Value = true
-- Alert monitoring
HttpService:PostAsync(WEBHOOK_URL, {
message = "DataStore circuit breaker opened",
stats = stats,
})
end,
}
Write Coalescing
Write coalescing batches multiple data modifications into fewer DataStore writes, reducing API calls and improving performance.
How It Works
- When you modify
profile.Data, profile is marked dirty - A save is scheduled after 2 seconds (coalesce window)
- Additional modifications reset the timer
- If 30 seconds pass without save (max delay), forced save occurs
- Manual
Save()cancels scheduled save
Benefits
- Reduces DataStore calls (saves money, avoids throttling)
- Batches rapid changes (e.g., incrementing coins in a loop)
- Still ensures data persists within reasonable time
-- These modifications will be batched into 1 save
profile.Data.Coins = profile.Data.Coins + 10
profile.Data.Coins = profile.Data.Coins + 5
profile.Data.Level = profile.Data.Level + 1
profile.Data.Experience = 500
-- After 2 seconds of no changes, 1 save occurs
-- OR call profile:Save() to save immediately
Automatic Lease Renewal
Leases expire after 60 seconds. ChronicleStore automatically renews them while a profile is active.
Renewal Strategy
- When less than 50 seconds remain, renewal is scheduled
- Renewal updates the lease expiration time
- If a save occurs before renewal, lease is renewed as part of save (piggyback optimization)
- If renewal fails, session may be invalidated
Renewal Piggybacking
To minimize DataStore calls, lease renewal is piggybacked onto saves whenever possible:
- Profile:Save() renews the lease as part of the same UpdateAsync
- Scheduled renewal only happens if no save occurs within threshold
-- Long-running session (e.g., player online for hours)
-- Lease is automatically renewed every ~50 seconds
-- You don't need to do anything!
local profile = store:StartSessionAsync("Player_123")
-- ... player plays for 3 hours ...
-- Lease renewed automatically ~216 times
profile:Release()
MessagingService Integration
ChronicleStore uses MessagingService to detect if a lease owner is still alive, enabling faster conflict resolution.
Challenge-Response Protocol
- Server A tries to acquire a lease held by Server B
- Lease is fresh (not expired), so Server A sends a challenge via MessagingService
- Server B (if alive) responds within 2 seconds
- If response received, Server A fails immediately (no retries)
- If no response, Server A assumes Server B crashed and steals the lease
Benefits
- Faster lease acquisition when previous owner crashed
- Prevents unnecessary 60-second wait for lease expiration
- Graceful handling of server crashes
Best Practices
Always Release Profiles
Use PlayerRemoving to ensure profiles are released:
Players.PlayerRemoving:Connect(function(player)
local profile = PlayerProfiles[player]
if profile then
profile:Release()
PlayerProfiles[player] = nil
end
end)
Handle Load Failures
Always check if StartSessionAsync returns nil:
local profile = store:StartSessionAsync("Player_" .. player.UserId)
if not profile then
player:Kick("Failed to load your data. Please rejoin.")
return
end
Use Cancel Conditions
Prevent loading data for players who already left:
local profile = store:StartSessionAsync(
"Player_" .. player.UserId,
function()
-- Cancel if player left during load
return not player:IsDescendantOf(Players)
end
)
if not profile then
return -- Player left or load failed
end
Leverage Hooks for Observability
Use hooks to monitor performance and detect issues:
Hooks = {
OnLoad = function(key, data, stats)
if stats.latency_ms > 1000 then
warn("Slow load detected:", key, stats.latency_ms, "ms")
end
if stats.retry_count > 0 then
warn("Load required retries:", key, stats.retry_count)
end
end,
OnCircuitOpen = function(reason, stats)
-- Alert ops team immediately
AlertService:Send("DataStore issues detected!")
end,
OnCorruption = function(key, type, details)
-- Critical alert - data loss possible
AlertService:SendCritical("Data corruption: " .. key)
end,
}
Don't Modify Data After Release
Check IsActive() before modifications in async contexts:
-- In an async function (e.g., after a wait)
task.wait(5)
if profile:IsActive() then
profile.Data.Coins = profile.Data.Coins + 100
else
warn("Profile was released, cannot modify")
end
Use Simulation Mode for Testing
Test your game logic without DataStore access:
local RunService = game:GetService("RunService")
local store = ChronicleStore.new("PlayerData", {
Template = { Coins = 0 },
SimulationMode = RunService:IsStudio(), -- Auto sim in Studio
SimulationConfig = {
LatencyMs = 50,
FailureRate = 0.05, -- 5% failure for testing
},
})
Global Shutdown Handling
ChronicleStore automatically saves all profiles on server shutdown via game:BindToClose. No action needed, but you can observe it:
-- ChronicleStore handles this automatically!
-- All active profiles are saved and released on shutdown
Structure Keys Consistently
Use a consistent key format:
-- Good: Consistent format
local key = "Player_" .. player.UserId
-- Bad: Inconsistent formats
local key1 = player.UserId
local key2 = "User" .. player.UserId
local key3 = player.Name -- Never use names!
Examples
Complete Player Data System
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ChronicleStore = require(game.ServerScriptService.ChronicleStore)
local PlayerProfiles = {}
local ProfileLoadedEvent = ReplicatedStorage:WaitForChild("ProfileLoaded")
local PlayerStore = ChronicleStore.new("PlayerData", {
Template = {
Coins = 100,
Level = 1,
Experience = 0,
Inventory = {},
Settings = {
Music = true,
SFX = true,
},
Stats = {
PlayTime = 0,
SessionCount = 0,
},
},
Hooks = {
OnLoad = function(key, data, stats)
print(string.format("[ChronicleStore] Loaded %s in %.0fms", key, stats.latency_ms))
end,
OnSave = function(key, data)
print("[ChronicleStore] Saved", key)
end,
OnCorruption = function(key, type, details)
warn("DATA CORRUPTION:", key, type)
end,
},
})
Players.PlayerAdded:Connect(function(player)
local profile = PlayerStore:StartSessionAsync(
"Player_" .. player.UserId,
function()
return not player:IsDescendantOf(Players)
end
)
if not profile then
player:Kick("Failed to load your data. Please rejoin.")
return
end
PlayerProfiles[player] = profile
-- Increment session count
profile.Data.Stats.SessionCount += 1
-- Setup leaderstats
local leaderstats = Instance.new("Folder")
leaderstats.Name = "leaderstats"
local coins = Instance.new("IntValue")
coins.Name = "Coins"
coins.Value = profile.Data.Coins
coins.Parent = leaderstats
local level = Instance.new("IntValue")
level.Name = "Level"
level.Value = profile.Data.Level
level.Parent = leaderstats
leaderstats.Parent = player
-- Sync leaderstats to profile
coins.Changed:Connect(function()
if profile:IsActive() then
profile.Data.Coins = coins.Value
end
end)
level.Changed:Connect(function()
if profile:IsActive() then
profile.Data.Level = level.Value
end
end)
-- Track playtime
task.spawn(function()
while profile:IsActive() and player:IsDescendantOf(Players) do
task.wait(60) -- Every minute
if profile:IsActive() then
profile.Data.Stats.PlayTime += 1
end
end
end)
-- Notify client
ProfileLoadedEvent:FireClient(player)
end)
Players.PlayerRemoving:Connect(function(player)
local profile = PlayerProfiles[player]
if profile then
profile:Release()
PlayerProfiles[player] = nil
end
end)
-- Helper function for other scripts
local function GetProfile(player)
return PlayerProfiles[player]
end
return {
GetProfile = GetProfile,
}
Currency System with Validation
local ProfileService = require(script.Parent.ProfileService)
local function AddCoins(player, amount)
local profile = ProfileService.GetProfile(player)
if not profile or not profile:IsActive() then
return false
end
if amount < 0 then
warn("Cannot add negative coins:", amount)
return false
end
profile.Data.Coins = profile.Data.Coins + amount
return true
end
local function RemoveCoins(player, amount)
local profile = ProfileService.GetProfile(player)
if not profile or not profile:IsActive() then
return false
end
if amount < 0 then
warn("Cannot remove negative coins:", amount)
return false
end
if profile.Data.Coins < amount then
return false -- Insufficient funds
end
profile.Data.Coins = profile.Data.Coins - amount
return true
end
local function PurchaseItem(player, itemName, cost)
if not RemoveCoins(player, cost) then
return false, "Insufficient coins"
end
local profile = ProfileService.GetProfile(player)
table.insert(profile.Data.Inventory, itemName)
return true, "Purchase successful"
end
return {
AddCoins = AddCoins,
RemoveCoins = RemoveCoins,
PurchaseItem = PurchaseItem,
}
Testing with Simulation Mode
local ChronicleStore = require(game.ServerScriptService.ChronicleStore)
-- Create a test store with aggressive failure simulation
local TestStore = ChronicleStore.new("TestData", {
Template = {
TestValue = 0,
},
SimulationMode = true,
SimulationConfig = {
FailureRate = 0.3, -- 30% operations fail
LatencyMs = 200, -- 200ms delay
CorruptionRate = 0.05, -- 5% corruption
LeaseContention = true,
},
Hooks = {
OnLoad = function(key, data, stats)
print("Load successful after", stats.retry_count, "retries")
end,
OnCircuitOpen = function(reason, stats)
warn("Circuit opened! Error rate:", stats.error_rate)
end,
},
})
-- Test load/save cycle
local function runTest()
print("Starting test...")
local profile = TestStore:StartSessionAsync("Test_123")
if not profile then
warn("Failed to load (expected with high failure rate)")
return
end
print("Loaded! Current value:", profile.Data.TestValue)
-- Modify data
for i = 1, 10 do
profile.Data.TestValue += 1
task.wait(0.1)
end
-- Save
local success = profile:Save()
print("Save result:", success)
-- Release
profile:Release()
print("Test complete!")
end
runTest()
Troubleshooting
Profile Won't Load
Symptom: StartSessionAsync returns nil
Possible Causes:
- DataStore unavailable (Studio without API access enabled)
- Another server holds the lease
- Circuit breaker is open
- Data corruption detected
- Cancel condition returned true
Solutions:
-- Check DataStore availability
local status = store:GetStatus()
print("Store status:", status.mode) -- "production", "simulation", or "unavailable"
-- Enable simulation mode for testing
local store = ChronicleStore.new("Data", {
Template = {},
SimulationMode = true, -- Bypass DataStore
})
-- Add OnLeaseConflict hook to diagnose lease issues
Hooks = {
OnLeaseConflict = function(key, resolution)
warn("Lease conflict:", key, resolution)
end,
}
Save Failures
Symptom: Profile:Save() returns false
Possible Causes:
- Profile already released
- Lease expired or stolen
- DataStore throttling
- Circuit breaker open
Solutions:
-- Check if profile is active
if not profile:IsActive() then
warn("Cannot save inactive profile")
return
end
-- Add OnSave hook to diagnose
Hooks = {
OnSave = function(key, data, stats)
print("Save stats:", stats)
end,
}
-- Monitor circuit breaker
Hooks = {
OnCircuitOpen = function(reason, stats)
warn("Circuit open - saves will fail fast!")
end,
}
Memory Leaks
Symptom: Server memory usage grows over time
Cause: Profiles not released
Solution:
-- ALWAYS release profiles in PlayerRemoving
Players.PlayerRemoving:Connect(function(player)
local profile = PlayerProfiles[player]
if profile then
profile:Release() -- Critical!
PlayerProfiles[player] = nil
end
end)
-- Check for leaked profiles
local function checkLeaks()
local count = 0
for player, profile in pairs(PlayerProfiles) do
if not player:IsDescendantOf(Players) then
warn("Leaked profile detected:", player.Name)
profile:Release()
PlayerProfiles[player] = nil
count += 1
end
end
if count > 0 then
warn("Cleaned up", count, "leaked profiles")
end
end
-- Run periodically
while true do
task.wait(300) -- Every 5 minutes
checkLeaks()
end
High DataStore Call Usage
Symptom: Too many DataStore calls, hitting throttling limits
Solutions:
- Let write coalescing work (don't call Save() manually unless needed)
- Increase WRITE_COALESCE_WINDOW constant
- Use simulation mode for testing
-- DON'T do this (forces immediate saves)
profile.Data.Coins += 1
profile:Save() -- Bad!
profile.Data.Coins += 1
profile:Save() -- Bad!
-- DO this (let coalescing work)
profile.Data.Coins += 1
profile.Data.Coins += 1
-- Automatic save after 2 seconds batches both changes
Studio API Access Disabled
Symptom: "DataStore unavailable" warnings in Studio
Solution:
- Go to Game Settings in Studio
- Navigate to Security tab
- Enable "Enable Studio Access to API Services"
- Restart server
Or use simulation mode:
local RunService = game:GetService("RunService")
local store = ChronicleStore.new("Data", {
Template = {},
SimulationMode = RunService:IsStudio(), -- Auto simulation in Studio
})
Getting Help
If you encounter issues not covered here:
- Check the observability logs (printed to output with
[ChronicleStore]prefix) - Enable all hooks to capture detailed diagnostics
- Test with simulation mode to isolate DataStore vs logic issues
- Review the source code - it's heavily commented (for my standards (。_。))
- Contact @rituals._ on discord for support