Skip to content

Commit 25824a8

Browse files
authored
Merge pull request #688 from Quenty/users/euvinkeel/throttledfunction-trailing
fix: fixed `trailing = false` bug + added tests
2 parents ab12ae1 + 27216d0 commit 25824a8

8 files changed

Lines changed: 287 additions & 26 deletions

File tree

pnpm-lock.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/throttle/deploy.nevermore.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"targets": {
3+
"test": {
4+
"universeId": 9716264427,
5+
"placeId": 120455070031007,
6+
"project": "test/default.project.json",
7+
"scriptTemplate": "test/scripts/Server/ServerMain.server.lua"
8+
}
9+
}
10+
}

src/throttle/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
],
3131
"dependencies": {
3232
"@quenty/loader": "workspace:*",
33+
"@quenty/nevermore-test-runner": "workspace:*",
34+
"@quentystudios/jest-lua": "3.10.0-quenty.2",
3335
"@quenty/typeutils": "workspace:*"
3436
},
3537
"devDependencies": {

src/throttle/src/Shared/ThrottledFunction.lua

Lines changed: 86 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
--!strict
22
--[=[
3-
Throttles execution of a functon. Does both leading, and following
3+
Throttles execution of a function with configurable leading and trailing behavior.
44
@class ThrottledFunction
55
]=]
66

7+
--[=[
8+
@interface ThrottleConfig
9+
.leading boolean? -- If true, will dispatch immediately after creating this ThrottledFunction.
10+
.trailing boolean? -- If true, will dispatch after the timeout with the latest-called args.
11+
.leadingFirstTimeOnly boolean? -- If true, will dispatch immediately after creating this ThrottledFunction, but from then on, will begin the <timeout> window upon manual call
12+
and delay dispatch until <timeout> seconds have passed (with latest-called args).
13+
@within ThrottledFunction
14+
]=]
715
export type ThrottleConfig = {
816
leading: boolean?,
917
trailing: boolean?,
@@ -25,14 +33,23 @@ export type ThrottledFunction<T...> = typeof(setmetatable(
2533
_callLeading: boolean,
2634
_callTrailing: boolean,
2735
_callLeadingFirstTime: boolean?,
36+
_delayedDispatchThread: thread?,
2837
},
2938
{} :: typeof({ __index = ThrottledFunction })
3039
))
3140

41+
--[=[
42+
@function new
43+
@within ThrottledFunction
44+
@param timeoutInSeconds number -- The (minimum) time in seconds to wait between each function dispatch; the "cooldown".
45+
@param func function -- The actual function whose calls will be throttled.
46+
@param config? ThrottleConfig -- The configuration for how throttling will behave.
47+
@return ThrottledFunction<T...>
48+
]=]
3249
function ThrottledFunction.new<T...>(
3350
timeoutInSeconds: number,
3451
func: Func<T...>,
35-
config: ThrottleConfig
52+
config: ThrottleConfig?
3653
): ThrottledFunction<T...>
3754
local self: ThrottledFunction<T...> = setmetatable({} :: any, ThrottledFunction)
3855

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

4562
self._callLeading = true
4663
self._callTrailing = true
64+
self._delayedDispatchThread = nil
4765

48-
self:_configureOrError(config)
66+
self:_configureOrError(config or {
67+
leading = true,
68+
trailing = true,
69+
})
4970

5071
return self
5172
end
5273

74+
--[=[
75+
If leading = true, will enable Call() dispatching immediately after creating this ThrottledFunction.
76+
Else, will have to wait <timeout> seconds before it dispatches with the latest-called args.
77+
78+
If trailing = true, will dispatch after the timeout with the latest-called args.
79+
Else, will not automatically dispatch, and must manually call again after <timeout> seconds.
80+
81+
If leadingFirstTimeOnly = true, will enable Call() dispatching immediately after creating this
82+
ThrottledFunction, but from then on, will begin the <timeout> window upon manual call
83+
and delay dispatch until <timeout> seconds have passed (with latest-called args).
84+
85+
@function Call
86+
@within ThrottledFunction
87+
]=]
5388
function ThrottledFunction.Call<T...>(self: ThrottledFunction<T...>, ...: T...)
89+
local now = os.clock()
90+
5491
if self._trailingValue then
55-
-- Update the next value to be dispatched
92+
-- If it's not nil, we're likely in the middle of the cooldown window
93+
-- so all we can do is update the trailing value, waiting for the delayed dispatch to reset it to nil.
5694
self._trailingValue = table.pack(...)
57-
elseif self._nextCallTimeStamp <= tick() then
95+
return
96+
end
97+
98+
if self._nextCallTimeStamp <= now then
99+
-- We're outside the cooldown window
58100
if self._callLeading or self._callLeadingFirstTime then
59-
self._callLeadingFirstTime = false
60101
-- Dispatch immediately
61-
self._nextCallTimeStamp = tick() + self._timeout
102+
self._callLeadingFirstTime = false
103+
self._nextCallTimeStamp = now + self._timeout
62104
self._func(...)
63105
elseif self._callTrailing then
64-
-- Schedule for trailing at exactly timeout
65-
self._trailingValue = table.pack(...)
66-
task.delay(self._timeout, function()
67-
if self.Destroy then
68-
self:_dispatch()
69-
end
70-
end)
106+
-- Leading is disabled, but trailing is enabled; schedule for trailing.
107+
self:_scheduleTrailing(self._timeout, ...)
71108
else
72-
error("[ThrottledFunction.Cleanup] - Trailing and leading are both disabled")
109+
error("[ThrottledFunction.Call] - Trailing and leading are both disabled")
73110
end
74-
elseif self._callLeading or self._callTrailing or self._callLeadingFirstTime then
75-
self._callLeadingFirstTime = false
76-
-- As long as either leading or trailing are set to true, we are good
77-
local remainingTime = self._nextCallTimeStamp - tick()
78-
self._trailingValue = table.pack(...)
111+
return
112+
end
79113

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

88125
ThrottledFunction.__call = ThrottledFunction.Call
89126

127+
function ThrottledFunction._scheduleTrailing<T...>(self: ThrottledFunction<T...>, delayTime: number, ...: T...)
128+
self._trailingValue = table.pack(...)
129+
if self._delayedDispatchThread then
130+
task.cancel(self._delayedDispatchThread)
131+
end
132+
self._delayedDispatchThread = task.delay(delayTime, function()
133+
if self.Destroy then
134+
self:_dispatch()
135+
end
136+
end)
137+
end
138+
90139
function ThrottledFunction._dispatch<T...>(self: ThrottledFunction<T...>)
91-
self._nextCallTimeStamp = tick() + self._timeout
140+
self._nextCallTimeStamp = os.clock() + self._timeout
141+
self._delayedDispatchThread = nil
92142

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

175+
--[=[
176+
Cancels any pending trailing calls.
177+
178+
@function Destroy
179+
@within ThrottledFunction
180+
]=]
125181
function ThrottledFunction.Destroy<T...>(self: ThrottledFunction<T...>)
126182
local private: any = self
127183
private._trailingValue = nil
128184
private._func = nil
185+
if private._delayedDispatchThread then
186+
task.cancel(private._delayedDispatchThread)
187+
end
188+
private._delayedDispatchThread = nil
129189
setmetatable(private, nil)
130190
end
131191

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
--!strict
2+
--[[
3+
@class ThrottledFunction.spec.lua
4+
]]
5+
6+
local require = (require :: any)(
7+
game:GetService("ServerScriptService"):FindFirstChild("LoaderUtils", true).Parent
8+
).bootstrapStory(script) :: typeof(require(script.Parent.loader).load(script))
9+
10+
local Jest = require("Jest")
11+
local ThrottledFunction = require("ThrottledFunction")
12+
13+
local describe = Jest.Globals.describe
14+
local expect = Jest.Globals.expect
15+
local it = Jest.Globals.it
16+
local jest = Jest.Globals.jest
17+
18+
local TIMEOUT = 1
19+
local TIMEOUT_MS = TIMEOUT * 1000
20+
21+
local function recordCalls<T...>()
22+
local calls = {}
23+
24+
local function callback(...: T...)
25+
table.insert(calls, table.pack(...))
26+
end
27+
28+
return calls, callback
29+
end
30+
31+
describe("ThrottledFunction", function()
32+
it("should drop cooldown calls when trailing is disabled", function()
33+
jest.useFakeTimers()
34+
35+
local calls, callback = recordCalls()
36+
local throttled = ThrottledFunction.new(TIMEOUT, callback, {
37+
leading = true,
38+
trailing = false,
39+
})
40+
41+
throttled:Call("first")
42+
throttled:Call("too fast, drop me")
43+
44+
jest.advanceTimersByTime(TIMEOUT_MS)
45+
46+
expect(#calls).toEqual(1)
47+
expect(calls[1][1]).toEqual("first")
48+
49+
throttled:Destroy()
50+
jest.useRealTimers()
51+
end)
52+
53+
it("should dispatch the latest trailing call with all arguments", function()
54+
jest.useFakeTimers()
55+
56+
local calls, callback = recordCalls()
57+
local throttled = ThrottledFunction.new(TIMEOUT, callback, {
58+
leading = true,
59+
trailing = true,
60+
})
61+
62+
throttled:Call("first")
63+
throttled:Call("second but will be overwritten by the next call...")
64+
throttled:Call("third", nil, "fourth")
65+
jest.advanceTimersByTime(TIMEOUT_MS)
66+
67+
expect(#calls).toEqual(2)
68+
expect(calls[1][1]).toEqual("first")
69+
expect(calls[2].n).toEqual(3)
70+
expect(calls[2][1]).toEqual("third")
71+
expect(calls[2][2]).toEqual(nil)
72+
expect(calls[2][3]).toEqual("fourth")
73+
74+
throttled:Destroy()
75+
jest.useRealTimers()
76+
end)
77+
78+
it("should delay trailing-only calls and keep the latest arguments", function()
79+
jest.useFakeTimers()
80+
81+
local calls, callback = recordCalls()
82+
local throttled = ThrottledFunction.new(TIMEOUT, callback, {
83+
leading = false,
84+
trailing = true,
85+
})
86+
87+
throttled:Call("first but will be overwritten by the next call...")
88+
throttled:Call("second, final and dispatched")
89+
90+
expect(#calls).toEqual(0)
91+
92+
jest.advanceTimersByTime(TIMEOUT_MS)
93+
94+
expect(#calls).toEqual(1)
95+
expect(calls[1][1]).toEqual("second, final and dispatched")
96+
97+
throttled:Destroy()
98+
jest.useRealTimers()
99+
end)
100+
101+
it("should cancel pending trailing calls when destroyed, not calling after destroyed", function()
102+
jest.useFakeTimers()
103+
104+
local calls, callback = recordCalls()
105+
local throttled = ThrottledFunction.new(TIMEOUT, callback, {
106+
leading = false,
107+
trailing = true,
108+
})
109+
110+
throttled:Call("first but will be destroyed and thus discarded")
111+
throttled:Destroy()
112+
jest.advanceTimersByTime(TIMEOUT_MS)
113+
114+
expect(#calls).toEqual(0)
115+
116+
jest.useRealTimers()
117+
end)
118+
119+
it("should reject if leading and trailing are both false", function()
120+
local _, callback = recordCalls()
121+
122+
expect(function()
123+
ThrottledFunction.new(TIMEOUT, callback, {
124+
leading = false,
125+
trailing = false,
126+
})
127+
end).toThrow()
128+
129+
expect(function()
130+
ThrottledFunction.new(
131+
TIMEOUT,
132+
callback,
133+
{
134+
leading = true,
135+
trailing = true,
136+
notAConfigKey = true,
137+
} :: any
138+
)
139+
end).toThrow()
140+
141+
expect(function()
142+
ThrottledFunction.new(
143+
TIMEOUT,
144+
callback,
145+
{
146+
leading = "yes",
147+
} :: any
148+
)
149+
end).toThrow()
150+
end)
151+
end)

src/throttle/src/jest.config.lua

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
return {
2+
testMatch = { "**/*.spec" },
3+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"name": "ThrottleTest",
3+
"tree": {
4+
"$className": "DataModel",
5+
"ServerScriptService": {
6+
"$properties": {
7+
"LoadStringEnabled": true
8+
},
9+
"throttle": {
10+
"$path": ".."
11+
},
12+
"Script": {
13+
"$path": "scripts/Server"
14+
}
15+
}
16+
}
17+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
--!nonstrict
2+
local ServerScriptService = game:GetService("ServerScriptService")
3+
4+
local root = ServerScriptService.throttle
5+
local loader = root:FindFirstChild("LoaderUtils", true).Parent
6+
local require = require(loader).bootstrapGame(root)
7+
8+
local NevermoreTestRunnerUtils = require("NevermoreTestRunnerUtils")
9+
10+
if NevermoreTestRunnerUtils.runTestsIfNeededAsync(root) then
11+
return
12+
end

0 commit comments

Comments
 (0)