Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions src/throttle/deploy.nevermore.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"targets": {
"test": {
"universeId": 9716264427,
"placeId": 120455070031007,
"project": "test/default.project.json",
"scriptTemplate": "test/scripts/Server/ServerMain.server.lua"
}
}
}
2 changes: 2 additions & 0 deletions src/throttle/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
],
"dependencies": {
"@quenty/loader": "workspace:*",
"@quenty/nevermore-test-runner": "workspace:*",
"@quentystudios/jest-lua": "3.10.0-quenty.2",
"@quenty/typeutils": "workspace:*"
},
"devDependencies": {
Expand Down
112 changes: 86 additions & 26 deletions src/throttle/src/Shared/ThrottledFunction.lua
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
--!strict
--[=[
Throttles execution of a functon. Does both leading, and following
Throttles execution of a function with configurable leading and trailing behavior.
@class ThrottledFunction
]=]

--[=[
@interface ThrottleConfig
.leading boolean? -- If true, will dispatch immediately after creating this ThrottledFunction.
.trailing boolean? -- If true, will dispatch after the timeout with the latest-called args.
.leadingFirstTimeOnly boolean? -- If true, will dispatch immediately after creating this ThrottledFunction, but from then on, will begin the <timeout> window upon manual call
and delay dispatch until <timeout> seconds have passed (with latest-called args).
@within ThrottledFunction
]=]
export type ThrottleConfig = {
leading: boolean?,
trailing: boolean?,
Expand All @@ -25,14 +33,23 @@ export type ThrottledFunction<T...> = typeof(setmetatable(
_callLeading: boolean,
_callTrailing: boolean,
_callLeadingFirstTime: boolean?,
_delayedDispatchThread: thread?,
},
{} :: typeof({ __index = ThrottledFunction })
))

--[=[
@function new
@within ThrottledFunction
@param timeoutInSeconds number -- The (minimum) time in seconds to wait between each function dispatch; the "cooldown".
@param func function -- The actual function whose calls will be throttled.
@param config? ThrottleConfig -- The configuration for how throttling will behave.
@return ThrottledFunction<T...>
]=]
function ThrottledFunction.new<T...>(
timeoutInSeconds: number,
func: Func<T...>,
config: ThrottleConfig
config: ThrottleConfig?
): ThrottledFunction<T...>
local self: ThrottledFunction<T...> = setmetatable({} :: any, ThrottledFunction)

Expand All @@ -44,51 +61,84 @@ function ThrottledFunction.new<T...>(

self._callLeading = true
self._callTrailing = true
self._delayedDispatchThread = nil

self:_configureOrError(config)
self:_configureOrError(config or {
leading = true,
trailing = true,
})

return self
end

--[=[
If leading = true, will enable Call() dispatching immediately after creating this ThrottledFunction.
Else, will have to wait <timeout> seconds before it dispatches with the latest-called args.

If trailing = true, will dispatch after the timeout with the latest-called args.
Else, will not automatically dispatch, and must manually call again after <timeout> seconds.

If leadingFirstTimeOnly = true, will enable Call() dispatching immediately after creating this
ThrottledFunction, but from then on, will begin the <timeout> window upon manual call
and delay dispatch until <timeout> seconds have passed (with latest-called args).

@function Call
@within ThrottledFunction
]=]
function ThrottledFunction.Call<T...>(self: ThrottledFunction<T...>, ...: T...)
local now = os.clock()

if self._trailingValue then
-- Update the next value to be dispatched
-- If it's not nil, we're likely in the middle of the cooldown window
-- so all we can do is update the trailing value, waiting for the delayed dispatch to reset it to nil.
self._trailingValue = table.pack(...)
elseif self._nextCallTimeStamp <= tick() then
return
end

if self._nextCallTimeStamp <= now then
-- We're outside the cooldown window
if self._callLeading or self._callLeadingFirstTime then
self._callLeadingFirstTime = false
-- Dispatch immediately
self._nextCallTimeStamp = tick() + self._timeout
self._callLeadingFirstTime = false
self._nextCallTimeStamp = now + self._timeout
self._func(...)
elseif self._callTrailing then
-- Schedule for trailing at exactly timeout
self._trailingValue = table.pack(...)
task.delay(self._timeout, function()
if self.Destroy then
self:_dispatch()
end
end)
-- Leading is disabled, but trailing is enabled; schedule for trailing.
self:_scheduleTrailing(self._timeout, ...)
else
error("[ThrottledFunction.Cleanup] - Trailing and leading are both disabled")
error("[ThrottledFunction.Call] - Trailing and leading are both disabled")
end
elseif self._callLeading or self._callTrailing or self._callLeadingFirstTime then
self._callLeadingFirstTime = false
-- As long as either leading or trailing are set to true, we are good
local remainingTime = self._nextCallTimeStamp - tick()
self._trailingValue = table.pack(...)
return
end

task.delay(remainingTime, function()
if self.Destroy then
self:_dispatch()
end
end)
if self._callTrailing then
-- We have no trailing value; it was dispatched a bit ago, or we just created this ThrottledFunction.
-- We're inside the cooldown window, so it's not dispatched/created that far ago. (we can't dispatch immediately.)
-- We should supply a trailing value, without immediately dispatching.
self._callLeadingFirstTime = false
local remainingTime = math.max(0, self._nextCallTimeStamp - now)
self:_scheduleTrailing(remainingTime, ...)
end
-- But if we don't have trailing, best to ignore the call (the args are dropped.)
end

ThrottledFunction.__call = ThrottledFunction.Call

function ThrottledFunction._scheduleTrailing<T...>(self: ThrottledFunction<T...>, delayTime: number, ...: T...)
self._trailingValue = table.pack(...)
if self._delayedDispatchThread then
task.cancel(self._delayedDispatchThread)
end
self._delayedDispatchThread = task.delay(delayTime, function()
if self.Destroy then
self:_dispatch()
end
end)
end

function ThrottledFunction._dispatch<T...>(self: ThrottledFunction<T...>)
self._nextCallTimeStamp = tick() + self._timeout
self._nextCallTimeStamp = os.clock() + self._timeout
self._delayedDispatchThread = nil

local trailingValue = self._trailingValue
if trailingValue then
Expand Down Expand Up @@ -122,10 +172,20 @@ function ThrottledFunction._configureOrError<T...>(self: ThrottledFunction<T...>
assert(self._callLeading or self._callTrailing, "Cannot configure both leading and trailing disabled")
end

--[=[
Cancels any pending trailing calls.

@function Destroy
@within ThrottledFunction
]=]
function ThrottledFunction.Destroy<T...>(self: ThrottledFunction<T...>)
local private: any = self
private._trailingValue = nil
private._func = nil
if private._delayedDispatchThread then
task.cancel(private._delayedDispatchThread)
end
private._delayedDispatchThread = nil
setmetatable(private, nil)
end

Expand Down
151 changes: 151 additions & 0 deletions src/throttle/src/Shared/ThrottledFunction.spec.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
--!strict
--[[
@class ThrottledFunction.spec.lua
]]

local require = (require :: any)(
game:GetService("ServerScriptService"):FindFirstChild("LoaderUtils", true).Parent
).bootstrapStory(script) :: typeof(require(script.Parent.loader).load(script))

local Jest = require("Jest")
local ThrottledFunction = require("ThrottledFunction")

local describe = Jest.Globals.describe
local expect = Jest.Globals.expect
local it = Jest.Globals.it
local jest = Jest.Globals.jest

local TIMEOUT = 1
local TIMEOUT_MS = TIMEOUT * 1000

local function recordCalls<T...>()
local calls = {}

local function callback(...: T...)
table.insert(calls, table.pack(...))
end

return calls, callback
end

describe("ThrottledFunction", function()
it("should drop cooldown calls when trailing is disabled", function()
jest.useFakeTimers()

local calls, callback = recordCalls()
local throttled = ThrottledFunction.new(TIMEOUT, callback, {
leading = true,
trailing = false,
})

throttled:Call("first")
throttled:Call("too fast, drop me")

jest.advanceTimersByTime(TIMEOUT_MS)

expect(#calls).toEqual(1)
expect(calls[1][1]).toEqual("first")

throttled:Destroy()
jest.useRealTimers()
end)

it("should dispatch the latest trailing call with all arguments", function()
jest.useFakeTimers()

local calls, callback = recordCalls()
local throttled = ThrottledFunction.new(TIMEOUT, callback, {
leading = true,
trailing = true,
})

throttled:Call("first")
throttled:Call("second but will be overwritten by the next call...")
throttled:Call("third", nil, "fourth")
jest.advanceTimersByTime(TIMEOUT_MS)

expect(#calls).toEqual(2)
expect(calls[1][1]).toEqual("first")
expect(calls[2].n).toEqual(3)
expect(calls[2][1]).toEqual("third")
expect(calls[2][2]).toEqual(nil)
expect(calls[2][3]).toEqual("fourth")

throttled:Destroy()
jest.useRealTimers()
end)

it("should delay trailing-only calls and keep the latest arguments", function()
jest.useFakeTimers()

local calls, callback = recordCalls()
local throttled = ThrottledFunction.new(TIMEOUT, callback, {
leading = false,
trailing = true,
})

throttled:Call("first but will be overwritten by the next call...")
throttled:Call("second, final and dispatched")

expect(#calls).toEqual(0)

jest.advanceTimersByTime(TIMEOUT_MS)

expect(#calls).toEqual(1)
expect(calls[1][1]).toEqual("second, final and dispatched")

throttled:Destroy()
jest.useRealTimers()
end)

it("should cancel pending trailing calls when destroyed, not calling after destroyed", function()
jest.useFakeTimers()

local calls, callback = recordCalls()
local throttled = ThrottledFunction.new(TIMEOUT, callback, {
leading = false,
trailing = true,
})

throttled:Call("first but will be destroyed and thus discarded")
throttled:Destroy()
jest.advanceTimersByTime(TIMEOUT_MS)

expect(#calls).toEqual(0)

jest.useRealTimers()
end)

it("should reject if leading and trailing are both false", function()
local _, callback = recordCalls()

expect(function()
ThrottledFunction.new(TIMEOUT, callback, {
leading = false,
trailing = false,
})
end).toThrow()

expect(function()
ThrottledFunction.new(
TIMEOUT,
callback,
{
leading = true,
trailing = true,
notAConfigKey = true,
} :: any
)
end).toThrow()

expect(function()
ThrottledFunction.new(
TIMEOUT,
callback,
{
leading = "yes",
} :: any
)
end).toThrow()
end)
end)
3 changes: 3 additions & 0 deletions src/throttle/src/jest.config.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
return {
testMatch = { "**/*.spec" },
}
17 changes: 17 additions & 0 deletions src/throttle/test/default.project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "ThrottleTest",
"tree": {
"$className": "DataModel",
"ServerScriptService": {
"$properties": {
"LoadStringEnabled": true
},
"throttle": {
"$path": ".."
},
"Script": {
"$path": "scripts/Server"
}
}
}
}
12 changes: 12 additions & 0 deletions src/throttle/test/scripts/Server/ServerMain.server.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
--!nonstrict
local ServerScriptService = game:GetService("ServerScriptService")

local root = ServerScriptService.throttle
local loader = root:FindFirstChild("LoaderUtils", true).Parent
local require = require(loader).bootstrapGame(root)

local NevermoreTestRunnerUtils = require("NevermoreTestRunnerUtils")

if NevermoreTestRunnerUtils.runTestsIfNeededAsync(root) then
return
end
Loading