-
-
Notifications
You must be signed in to change notification settings - Fork 186
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Generalize polling abstraction #3636
Conversation
packages/polling-controller/src/StaticIntervalPollingController.ts
Outdated
Show resolved
Hide resolved
} | ||
|
||
stopBlockTrackingPolling(key: string) { | ||
const [networkClientId] = key.split(':'); |
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.
do you think we should have a helper to get us from pollingKey back to networkClient and options?
Main question is if |
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.
I think there are plenty of opportunities for improving upon the architecture here — separating the pieces more and perhaps deciding on a better name that encapsulates the commonalities between the two classes. But that of course would take more time. In the meantime, I'm curious whether we ought to drop the strategy pattern and stick with abstract methods that get overridden in subclasses, since we seem to be following that pattern already. I tried to make some suggestions below.
packages/polling-controller/src/BlockTrackerPollingController.ts
Outdated
Show resolved
Hide resolved
}); | ||
} | ||
|
||
abstract getNetworkClientById( |
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.
Is this something that can be defined in the abstract PollingController? It seems like something that would be common to both.
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.
This one is specific to the BlockTrackerPollingController so it wouldn't be great to require it on the StaticInterval interface
packages/polling-controller/src/BlockTrackerPollingController.ts
Outdated
Show resolved
Hide resolved
packages/polling-controller/src/StaticIntervalPollingController.ts
Outdated
Show resolved
Hide resolved
I'm not sure I see the benefit of having multiple controllers for each possible interval mechanism as that sounds like it would increase the complexity for the child controllers to adopt or switch between them, or even provide their own custom interval mechanism in rare cases. Would an alternate architecture be to maintain a single The different interval strategies could be defined in classes inside the same package but with names such as The fundamental logic and API and flow remain the same, so it feels natural to encapsulate all that in a single Would that also simplify the upgrade process for all the controllers already using the |
I will add changelog entries when/if we decide to roll with this approach. |
packages/polling-controller/src/BlockTrackerPollingController.ts
Outdated
Show resolved
Hide resolved
networkClientId, | ||
options, | ||
); | ||
networkClient.blockTracker.addListener('latest', updateOnNewBlock); |
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.
does it make sense to also call updateOnNewBlock with the current block number that blockTracker returns here?
…to StaticIntervalPollingController
229f64a
to
5ccf0d3
Compare
export function AbstractPollingControllerBaseMixin<TBase extends Constructor>( | ||
Base: TBase, | ||
) { | ||
abstract class AbstractPollingControllerBase extends Base { |
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.
Could we explicitly write an interface which captures the polling behavior, and this abstract class then implements? Would make integration less coupled to the partial implementation.
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.
Done here 997c806
) { | ||
#activeListeners: Record<string, (options: Json) => Promise<void>> = {}; | ||
|
||
abstract _getNetworkClientById( |
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.
Why is this abstract
for BlockTrackerPollingController but not for StaticIntervalPollingController? How do we envision this being used?
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.
This method is used just below on line 45 to retrieve the networkClient blockTracker. The StaticIntervalPollingController does not require the networkClient. The reason I don't have this as a constructor arg is because safely adding a required param to the constructor for mixins is tricky and generally not recommended since the children will have diverse constructor signatures
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.
I see, okay.
this.#pollingTokenSets.set(key, pollingTokenSet); | ||
|
||
if (pollingTokenSet.size === 1) { | ||
// Start new polling only if it's the first token for this key |
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.
Nit: This comment repeats what the code already says:
// Start new polling only if it's the first token for this key |
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.
done here: 823aaa1
clock.restore(); | ||
}); | ||
|
||
describe('Polling on block times', () => { |
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.
What is the purpose of this extra describe
? I don't see another one in this file. It seems that this could be merged into the outer describe
.
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.
Yep!
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.
done here: 823aaa1
}; | ||
|
||
let getNetworkClientByIdStub: jest.Mock; | ||
class MyGasFeeController extends BlockTrackerPollingController<any, any, any> { |
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.
Does it matter that this is a gas fee controller? Perhaps ChildBlockTrackerPollingController
would be a more suitable name.
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.
fixed here: 823aaa1
} | ||
|
||
private start(interval: number) { | ||
setInterval(() => { |
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.
Does this test block tracker actually need to emit blocks on an interval? It doesn't seem like the class being tested cares about that — it just assumes that if it listens to latest
for a block tracker, at some point that event will be emitted. Can we call emit
manually in the test instead? It seems that this would remove the need to keep track of so many timers and simplify the math required to understand each test.
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.
yeah this makes more sense!
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.
Modified the tests to use this pattern here: 823aaa1
if (keyToDelete) { | ||
// TODO figure out why this is necessary | ||
const nonNullKey = keyToDelete; | ||
this._stopPollingByPollingTokenSetId(nonNullKey); | ||
this.#pollingTokenSets.delete(nonNullKey); | ||
this.#callbacks.get(nonNullKey)?.forEach((callback) => { | ||
// for some reason this typescript can't tell that keyToDelete is not null here | ||
callback(nonNullKey); | ||
}); | ||
this.#callbacks.get(nonNullKey)?.clear(); | ||
} |
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.
This happens because you're using forEach
to iterate through the callbacks, and you're passing a function to forEach
. Functions are, by their nature, not guaranteed to be run immediately, so keyToDelete
could have changed back to null
before it was called, so TypeScript can't ensure that it's not null
. To fix this you can use a for...of
loop:
if (keyToDelete) { | |
// TODO figure out why this is necessary | |
const nonNullKey = keyToDelete; | |
this._stopPollingByPollingTokenSetId(nonNullKey); | |
this.#pollingTokenSets.delete(nonNullKey); | |
this.#callbacks.get(nonNullKey)?.forEach((callback) => { | |
// for some reason this typescript can't tell that keyToDelete is not null here | |
callback(nonNullKey); | |
}); | |
this.#callbacks.get(nonNullKey)?.clear(); | |
} | |
if (keyToDelete) { | |
this._stopPollingByPollingTokenSetId(keyToDelete); | |
this.#pollingTokenSets.delete(keyToDelete); | |
const callbacks = this.#callbacks.get(keyToDelete); | |
if (callbacks) { | |
for (const callback of callbacks) { | |
// eslint-disable-next-line n/callback-return | |
callback(keyToDelete); | |
} | |
callbacks.clear(); | |
} | |
} |
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.
gotcha! Thanks for clearing that up!
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.
done here: 823aaa1
options: Json = {}, | ||
) { | ||
const key = getKey(networkClientId, options); | ||
const callbacks = this.#callbacks.get(key) || new Set<typeof callback>(); |
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.
Nit: ??
would be safer:
const callbacks = this.#callbacks.get(key) || new Set<typeof callback>(); | |
const callbacks = this.#callbacks.get(key) ?? new Set<typeof callback>(); |
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.
done here: 823aaa1
const pollToken = random(); | ||
const key = getKey(networkClientId, options); | ||
const pollingTokenSet = | ||
this.#pollingTokenSets.get(key) || new Set<string>(); |
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.
Nit: ??
would be safer:
this.#pollingTokenSets.get(key) || new Set<string>(); | |
this.#pollingTokenSets.get(key) ?? new Set<string>(); |
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.
done here: 823aaa1
return executePollMock; | ||
}; | ||
|
||
class MyGasFeeController extends StaticIntervalPollingController< |
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.
Does it matter that this is a gas fee controller? Perhaps ChildStaticIntervalPollingController
would be a more suitable name.
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.
done here: 823aaa1
expect(controller._executePoll).toHaveBeenCalledTimes(2); | ||
controller.stopAllPolling(); | ||
}); | ||
it('should call _executePoll immediately and on interval if polling', async () => { |
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.
Hmm, that doesn't seem to be what this test is testing. It seems that _executePoll
is called immediately regardless of whether polling was started, yes? If so, then I'm curious whether this test can be merged with the previous one?
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.
Yep seems redundant!
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.
done here: 823aaa1
await advanceTime({ clock, duration: TICK_TIME * 2 }); | ||
expect(controller._executePoll).toHaveBeenCalledTimes(3); | ||
}); | ||
it('should call _executePoll immediately once and continue calling _executePoll on interval when start is called again with the same networkClientId', async () => { |
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.
Nit: We know we are testing startPollingByNetworkClientId
so we don't need to repeat the name of the method in the test:
it('should call _executePoll immediately once and continue calling _executePoll on interval when start is called again with the same networkClientId', async () => { | |
it('should call _executePoll immediately once and continue calling _executePoll on interval when called again with the same networkClientId', async () => { |
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.
done here: 823aaa1
it('should error if no pollingToken is passed', () => { | ||
controller.startPollingByNetworkClientId('mainnet'); | ||
expect(() => { | ||
controller.stopPollingByPollingToken(undefined as unknown as any); |
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.
Instead of using any
here (which doesn't say anything as to why this is necessary) what do you think about using @ts-expect-error
:
controller.stopPollingByPollingToken(undefined as unknown as any); | |
// @ts-expect-error We're intentionally passing no polling token | |
controller.stopPollingByPollingToken(); |
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.
done here: 823aaa1
controller.stopAllPolling(); | ||
}); | ||
|
||
it('should publish "pollingComplete" when stop is called', async () => { |
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.
What are your thoughts on moving this test to a different describe for onPollingCompleteByNetworkClientId
?
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.
yep makes sense!
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.
done here: 823aaa1
> { | ||
_executePoll = createExecutePollMock(); | ||
|
||
_intervalLength = TICK_TIME; |
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.
There is no property called _intervalLength
, so this has no effect. You can test this by changing TICK_TIME
to something else like 500. Thoughts on overriding getIntervalLength
instead?
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.
yeah whoops this got left in by mistake. Why not just call the setIntervalLength
?
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.
Yeah, that would work too!
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.
done here: 823aaa1
{ | ||
readonly #intervalIds: Record<PollingTokenSetId, NodeJS.Timeout> = {}; | ||
|
||
intervalLength: number | undefined = 1000; |
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.
Since this property is currently public, you have two ways of accessing this property. This seems unnecessary. Thoughts on using #intervalLength
instead of intervalLength
?
(Note: if this is the way it was before, then fine, we can change this in another PR, but I thought I'd call it out anyway)
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.
done here: 823aaa1
} | ||
this._startPollingByNetworkClientId(networkClientId, options); | ||
}, | ||
existingInterval ? this.intervalLength : 0, |
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.
Thoughts on using getIntervalLength()
so that when you mock this method in the tests it'll work across the board?
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.
could do that... or just call setIntervalLength
in the tests where you want o modify the default no?
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.
Yes, that would work too.
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.
done here: 823aaa1
a9ea7e6
to
823aaa1
Compare
14dde1c
to
071f73b
Compare
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.
Looks good! Thanks for your patience on this.
WIPReady for review: I will add changelog entries when/if we decide to roll with this approach.Alternative approach to refactoring the PollingController per this feedback on PollingTracker enhancement work
CHANGELOG ENTRIES
@metamask/polling-controller
Removed
PollingController
,PollingControllerV1
andPollingControllerOnly
are all removed and replaced byStaticIntervalPollingController
,StaticIntervalPollingControllerV1
andStaticIntervalPollingControllerOnly
Added
BlockTrackerPollingController
,BlockTrackerPollingControllerV1
andBlockTrackerPollingControllerOnly
have been added and can be used by subclasses to poll with a blockTracker using the same API as theStaticIntervalPollingController
versions - the only exception is the requirement to implement the abstract method_getNetworkClientById
on subclasses.