Milestones
Records once-per-session moments such as a loaded profile, so a subscriber that attaches late still receives every one that already happened.
- Realm
- Server
- Require
ServerScriptService.ChloeKernelServer .Milestones - Since
- v0.8.0
- Source
- Milestones.luau
A Bus topic is fire-once. A service that subscribes to Kernel.ProfileLoaded after a player’s profile
loaded has missed that player for good, and whether it missed depends on boot order, join timing and load latency. That
produced a whole class of intermittent cold-boot bugs: stations built at template size, boards never seeded, work never
resumed. Milestones fixes it for moments that happen once per session. Milestones.publish records
the moment on the session and then publishes the Bus topic. Milestones.on subscribes and then replays the recorded
moment for every live session, so a late subscriber receives everything that already happened, once, and everything
after. The framework publishes DataDriver profile loads, Passes lookups and
Policy loads this way.
How it works
Section titled “How it works”publish
Section titled “publish”Milestones.publish(kernel, name, session, ...):
- When
sessionis a table, storestable.pack(...)atsession.Milestones[name], creatingsession.Milestoneson first use. A later publish of the same name for the same session replaces the record. - Publishes
nameon the Bus with(session, ...). Ordinary Bus subscribers receive it as usual, topic first.
Milestones.on(kernel, name, fn):
- Subscribes to
nameon the Bus first, so a publication that happens during the replay is not missed. - Walks
kernel.Sessions. For every session that has not ended and has a record forname, it callsfn(session, ...)with the recorded arguments on its own thread throughtask.spawn. - Returns the Bus connection. Disconnecting it stops live deliveries.
The subscription remembers, per session, which record it last delivered (weak keys, so ended sessions are not kept). A live delivery of a record it already replayed is skipped, and so is a replay of a record it already received live. Each publication reaches each subscriber once, including one published from inside a replay handler while the walk is still running. A second publication for the same session is a new record and is delivered again.
fn receives (session, ...). Unlike a Bus handler, it gets no topic argument.
Framework milestones
Section titled “Framework milestones”| Topic | Published by | Arguments |
|---|---|---|
Kernel.ProfileLoaded |
DataDriver, once the profile is set on session.Profile |
session, profile |
Commerce.PassesResolved |
Passes, when every join lookup for the session finished | session |
Policy.Loaded |
Policy, when the player’s PolicyService rules loaded | session, policy |
Service registration
Section titled “Service registration”kernel:registerService publishes Kernel.ServiceRegistered(name) after each successful registration (0.8.0). That is
a plain Bus topic with no session to record it on, so Milestones.on does not replay it. Registration finishes before
any service’s init runs, so code inside a service reads the registry directly: kernel.Services.Definitions lists
every registered service. See Services.
Per-player setup that needs saved data:
--!strict-- VisitService: counts visits and stamps the last one on each profile.
local ServerScriptService = game:GetService("ServerScriptService")
local Milestones = require(ServerScriptService.ChloeKernelServer.Milestones)
local VisitService = { Name = "VisitService", Dependencies = { "DataService" },}
function VisitService.init(self: any, kernel: any) self.Kernel = kernelend
function VisitService.start(self: any) -- Also runs for every player whose profile loaded before this line self.Connection = Milestones.on(self.Kernel, "Kernel.ProfileLoaded", function(session, profile) profile.Data.Visits += 1 session:bind(function() -- Runs before the DataDriver's final save, which is bound with bindLast local Current = session.Profile if Current then Current.Data.LastVisit = os.time() end end) end)end
function VisitService.stop(self: any) if self.Connection then self.Connection:disconnect() endend
return VisitServiceVisits and LastVisit must be in the profile’s Defaults. The leave-time write lands in the final save because
DataDriver binds that save with session:bindLast, which runs after every ordinary binding.
A milestone of your own, for a moment that happens once per session:
local ServerScriptService = game:GetService("ServerScriptService")
local Milestones = require(ServerScriptService.ChloeKernelServer.Milestones)
-- PlotService, when it hands a player their plotlocal function assignPlot(session: any, plot: Model) session.Data.Plot = plot Milestones.publish(kernel, "Plots.Assigned", session, plot)end
-- Any other service, whenever it startsMilestones.on(kernel, "Plots.Assigned", function(session, plot: Model) local Sign = plot:FindFirstChild("OwnerSign", true) if Sign and Sign:IsA("TextLabel") then Sign.Text = session.Player.DisplayName endend)API reference
Section titled “API reference”| Member | Description |
|---|---|
Milestones.publish(kernel, name: string, session, ...) |
Records ... on session.Milestones[name] when session is a table, then publishes name on the Bus with (session, ...). |
Milestones.on(kernel, name: string, fn: (session, ...any) -> ()): Connection |
Subscribes, then replays the recorded moment for every live session. Delivers each publication once. Returns the Bus connection. |
session.Milestones |
{ [name]: packed arguments }, the moments recorded for this session. |
Require it from ServerScriptService.ChloeKernelServer.Milestones. It is server only because sessions are.
Gotchas
Section titled “Gotchas”- A milestone needs a session. A server-wide moment (a round started, a map loaded) has nothing to be recorded on; keep that state in the owning service and let late readers ask it.
- The record holds its arguments for the session’s life. For
Kernel.ProfileLoadedthat is the profile, which the session references anyway. - Replays skip sessions that have ended. A session that ends while its replay runs is the handler’s problem, as with any
session callback: check
session.Activeafter a yield.