-
Notifications
You must be signed in to change notification settings - Fork 1.5k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
## Summary & Motivation The use of Lodash's `throttle` caused incorrect promise handling due to its behavior: > "Subsequent calls to the throttled function return the **result of the last func invocation**." > ([Lodash docs](https://lodash.com/docs/#throttle)) This led to promises resolving with stale results from previous requests. To fix this, I replaced `throttle` with a custom `throttleLatest` implementation that ensures the promise returned corresponds to the current request, not a previous one. Additionally, I resolved a potential issue with worker listeners not being cleaned up properly, ensuring correct promise resolution. ## How I Tested These Changes - Added Jest tests for `throttleLatest` with the required behavior. - Verified that the asset graph renders correctly on initial load in the UI. ## Changelog [ui] Fixed an issue that would sometimes cause the asset graph to fail to render on initial load.
- Loading branch information
Showing
9 changed files
with
241 additions
and
25 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
136 changes: 136 additions & 0 deletions
136
js_modules/dagster-ui/packages/ui-core/src/asset-graph/__tests__/throttleLatest.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,136 @@ | ||
import {throttleLatest} from '../throttleLatest'; | ||
|
||
jest.useFakeTimers(); | ||
|
||
describe('throttleLatest', () => { | ||
let mockFunction: jest.Mock<Promise<string>, [number]>; | ||
let throttledFunction: (arg: number) => Promise<string>; | ||
|
||
beforeEach(() => { | ||
jest.clearAllMocks(); | ||
mockFunction = jest.fn((arg: number) => { | ||
return Promise.resolve(`Result: ${arg}`); | ||
}); | ||
throttledFunction = throttleLatest(mockFunction, 2000); | ||
}); | ||
|
||
it('should execute the first call immediately', async () => { | ||
const promise = throttledFunction(1); | ||
expect(mockFunction).toHaveBeenCalledWith(1); | ||
|
||
await expect(promise).resolves.toBe('Result: 1'); | ||
}); | ||
|
||
it('should throttle subsequent calls within wait time and reject previous promises', async () => { | ||
const promise1 = throttledFunction(1); | ||
const promise2 = throttledFunction(2); | ||
|
||
await expect(promise1).rejects.toThrow('Throttled: A new call has been made.'); | ||
|
||
expect(mockFunction).toHaveBeenCalledTimes(1); | ||
|
||
jest.runAllTimers(); | ||
|
||
await expect(promise2).resolves.toBe('Result: 2'); | ||
}); | ||
|
||
it('should allow a new call after the wait time', async () => { | ||
const promise1 = throttledFunction(1); | ||
|
||
jest.advanceTimersByTime(1000); | ||
|
||
const promise2 = throttledFunction(2); | ||
|
||
await expect(promise1).rejects.toThrow('Throttled: A new call has been made.'); | ||
|
||
jest.advanceTimersByTime(1000); | ||
|
||
await expect(promise2).resolves.toBe('Result: 2'); | ||
|
||
const promise3 = throttledFunction(3); | ||
|
||
await jest.runAllTimers(); | ||
|
||
await expect(promise3).resolves.toBe('Result: 3'); | ||
|
||
expect(mockFunction).toHaveBeenCalledTimes(3); | ||
expect(mockFunction).toHaveBeenNthCalledWith(3, 3); | ||
}); | ||
|
||
it('should handle multiple rapid calls correctly', async () => { | ||
const promise1 = throttledFunction(1); | ||
await Promise.resolve(); | ||
|
||
throttledFunction(2); | ||
|
||
const promise3 = throttledFunction(3); | ||
|
||
await jest.runAllTimers(); | ||
|
||
expect(mockFunction).toHaveBeenNthCalledWith(1, 1); | ||
expect(mockFunction).toHaveBeenCalledTimes(2); | ||
expect(mockFunction).toHaveBeenNthCalledWith(2, 3); | ||
await expect(promise1).resolves.toBe('Result: 1'); | ||
await expect(promise3).resolves.toBe('Result: 3'); | ||
}); | ||
|
||
it('should reject the previous active promise when a new call is made before it resolves', async () => { | ||
// Modify mockFunction to return a promise that doesn't resolve immediately | ||
mockFunction.mockImplementationOnce((arg: number) => { | ||
return new Promise((resolve) => { | ||
setTimeout(() => resolve(`Result: ${arg}`), 5000); | ||
}); | ||
}); | ||
|
||
const promise1 = throttledFunction(1); | ||
|
||
// After 100ms, make a new call | ||
jest.advanceTimersByTime(100); | ||
const promise2 = throttledFunction(2); | ||
|
||
// The first promise should be rejected | ||
await expect(promise1).rejects.toThrow('Throttled: A new call has been made.'); | ||
|
||
// The second promise is scheduled to execute after the remaining time (2000 - 100 = 1900ms) | ||
jest.advanceTimersByTime(1900); | ||
|
||
// Now, the second call should resolve | ||
await expect(promise2).resolves.toBe('Result: 2'); | ||
}); | ||
|
||
it('should handle function rejection correctly', async () => { | ||
mockFunction.mockImplementationOnce(() => { | ||
return Promise.reject(new Error('Function failed')); | ||
}); | ||
|
||
const promise1 = throttledFunction(1); | ||
jest.runAllTimers(); | ||
|
||
await expect(promise1).rejects.toThrow('Function failed'); | ||
}); | ||
|
||
it('should not reject promises if no new call is made within wait time', async () => { | ||
const promise1 = throttledFunction(1); | ||
|
||
// No subsequent calls | ||
jest.runAllTimers(); | ||
|
||
await expect(promise1).resolves.toBe('Result: 1'); | ||
}); | ||
|
||
it('should handle multiple sequential calls with enough time between them', async () => { | ||
const promise1 = throttledFunction(1); | ||
jest.runAllTimers(); | ||
await expect(promise1).resolves.toBe('Result: 1'); | ||
|
||
const promise2 = throttledFunction(2); | ||
jest.runAllTimers(); | ||
await expect(promise2).resolves.toBe('Result: 2'); | ||
|
||
const promise3 = throttledFunction(3); | ||
jest.runAllTimers(); | ||
await expect(promise3).resolves.toBe('Result: 3'); | ||
|
||
expect(mockFunction).toHaveBeenCalledTimes(3); | ||
}); | ||
}); |
56 changes: 56 additions & 0 deletions
56
js_modules/dagster-ui/packages/ui-core/src/asset-graph/throttleLatest.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,56 @@ | ||
export type ThrottledFunction<T extends (...args: any[]) => Promise<any>> = ( | ||
...args: Parameters<T> | ||
) => ReturnType<T>; | ||
|
||
export function throttleLatest<T extends (...args: any[]) => Promise<any>>( | ||
func: T, | ||
wait: number, | ||
): ThrottledFunction<T> { | ||
let timeout: NodeJS.Timeout | null = null; | ||
let lastCallTime: number = 0; | ||
let activeReject: ((reason?: any) => void) | null = null; | ||
|
||
return function (...args: Parameters<T>): ReturnType<T> { | ||
const now = Date.now(); | ||
|
||
return new Promise((resolve, reject) => { | ||
// If a call is already active, reject its promise | ||
if (activeReject) { | ||
activeReject(new Error('Throttled: A new call has been made.')); | ||
activeReject = null; | ||
} | ||
|
||
const execute = () => { | ||
lastCallTime = Date.now(); | ||
activeReject = reject; | ||
|
||
func(...args) | ||
.then((result) => { | ||
resolve(result); | ||
activeReject = null; | ||
}) | ||
.catch((error) => { | ||
reject(error); | ||
activeReject = null; | ||
}); | ||
}; | ||
|
||
const remaining = wait - (now - lastCallTime); | ||
if (remaining <= 0) { | ||
if (timeout) { | ||
clearTimeout(timeout); | ||
timeout = null; | ||
} | ||
execute(); | ||
} else { | ||
if (timeout) { | ||
clearTimeout(timeout); | ||
} | ||
timeout = setTimeout(() => { | ||
execute(); | ||
timeout = null; | ||
}, remaining); | ||
} | ||
}) as ReturnType<T>; | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
4eba622
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Deploy preview for dagit-core-storybook ready!
✅ Preview
https://dagit-core-storybook-70yt3vzpc-elementl.vercel.app
Built with commit 4eba622.
This pull request is being automatically deployed with vercel-action