chi0sk
github.com/chi0sk

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()
Important: Always call 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:

  1. StartSessionAsync() - Acquire lease and load data
  2. Read/modify profile.Data
  3. Automatic saves via write coalescing
  4. 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:

  1. Check expiration: If expired, steal immediately
  2. MessagingService challenge: Ping the owner to check if alive
  3. Response timeout: If no response in 2 seconds, assume dead and steal
  4. 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

  1. Canonical Serialization: Data is serialized deterministically (sorted keys)
  2. FNV-1a Hash: 64-bit hash computed from canonical representation
  3. Storage: Hash stored alongside data in integrity.hash
  4. Verification: On load, hash is recomputed and compared
  5. Corruption Detection: Mismatch triggers OnCorruption hook 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 OnLoad hook
-- 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 OnSave hook
  • Cancels any pending coalesced write
Note: You rarely need to call Save() manually. ChronicleStore automatically saves via write coalescing when you modify data, and performs a final save on Release().
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
Critical: Always call Release() when done with a profile. Failure to release causes:
  • 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
Auto-Fallback: If DataStore is unavailable (e.g., Studio without API access), ChronicleStore automatically falls back to simulation mode with default settings.

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

  1. After 30 seconds, circuit enters half-open state
  2. Next request is allowed through
  3. If successful, circuit closes
  4. 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

  1. When you modify profile.Data, profile is marked dirty
  2. A save is scheduled after 2 seconds (coalesce window)
  3. Additional modifications reset the timer
  4. If 30 seconds pass without save (max delay), forced save occurs
  5. 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
Note: Write coalescing is transparent. You don't need to do anything special - just modify data and ChronicleStore handles batching automatically.

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:

  1. Profile:Save() renews the lease as part of the same UpdateAsync
  2. 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

  1. Server A tries to acquire a lease held by Server B
  2. Lease is fresh (not expired), so Server A sends a challenge via MessagingService
  3. Server B (if alive) responds within 2 seconds
  4. If response received, Server A fails immediately (no retries)
  5. 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
Note: MessagingService integration is automatic and requires no configuration. It's disabled in simulation mode.

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:

  1. Go to Game Settings in Studio
  2. Navigate to Security tab
  3. Enable "Enable Studio Access to API Services"
  4. 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

ChronicleStore is created and maintained by sam (@chi0sk)

Documentation last updated: February 2026