Skip to content
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

Merged
merged 29 commits into from
Dec 12, 2023
Merged

Generalize polling abstraction #3636

merged 29 commits into from
Dec 12, 2023

Conversation

adonesky1
Copy link
Contributor

@adonesky1 adonesky1 commented Dec 7, 2023

WIP
Ready 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


  • BREAKING: PollingController, PollingControllerV1 and PollingControllerOnly are all removed and replaced by StaticIntervalPollingController, StaticIntervalPollingControllerV1 and StaticIntervalPollingControllerOnly



Added


  • BlockTrackerPollingController, BlockTrackerPollingControllerV1 and BlockTrackerPollingControllerOnly have been added and can be used by subclasses to poll with a blockTracker using the same API as the StaticIntervalPollingController versions - the only exception is the requirement to implement the abstract method _getNetworkClientById on subclasses.

}

stopBlockTrackingPolling(key: string) {
const [networkClientId] = key.split(':');
Copy link
Contributor

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?

@jiexi
Copy link
Contributor

jiexi commented Dec 7, 2023

Main question is if PollingStrategy is necessary or not. Otherwise looking great

Copy link
Contributor

@mcmire mcmire left a 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.

});
}

abstract getNetworkClientById(
Copy link
Contributor

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.

Copy link
Contributor Author

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

@matthewwalsh0
Copy link
Member

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 PollingController with a fixed API that defaults to static intervals but provides a setIntervalStrategy(intervalStrategy: IntervalStrategy) that can be called only if we need to switch from the default?

The different interval strategies could be defined in classes inside the same package but with names such as StaticIntervalStrategy and BlockTrackerIntervalStrategy.

The fundamental logic and API and flow remain the same, so it feels natural to encapsulate all that in a single PollingController, we just want a way to alternate the interval types but in an extensible way.

Would that also simplify the upgrade process for all the controllers already using the PollingController?

@adonesky1 adonesky1 marked this pull request as ready for review December 8, 2023 20:44
@adonesky1 adonesky1 requested a review from a team as a code owner December 8, 2023 20:44
@adonesky1
Copy link
Contributor Author

I will add changelog entries when/if we decide to roll with this approach.

networkClientId,
options,
);
networkClient.blockTracker.addListener('latest', updateOnNewBlock);
Copy link
Contributor

@jiexi jiexi Dec 8, 2023

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?

@adonesky1 adonesky1 force-pushed the generalize-polling-abstraction branch from 229f64a to 5ccf0d3 Compare December 8, 2023 21:49
export function AbstractPollingControllerBaseMixin<TBase extends Constructor>(
Base: TBase,
) {
abstract class AbstractPollingControllerBase extends Base {
Copy link
Contributor

@legobeat legobeat Dec 9, 2023

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.

Copy link
Contributor Author

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(
Copy link
Contributor

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?

Copy link
Contributor Author

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

Copy link
Contributor

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
Copy link
Contributor

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:

Suggested change
// Start new polling only if it's the first token for this key

Copy link
Contributor Author

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', () => {
Copy link
Contributor

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep!

Copy link
Contributor Author

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> {
Copy link
Contributor

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.

Copy link
Contributor Author

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(() => {
Copy link
Contributor

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.

Copy link
Contributor Author

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!

Copy link
Contributor Author

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

Comment on lines 113 to 123
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();
}
Copy link
Contributor

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:

Suggested change
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();
}
}

Copy link
Contributor Author

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!

Copy link
Contributor Author

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>();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: ?? would be safer:

Suggested change
const callbacks = this.#callbacks.get(key) || new Set<typeof callback>();
const callbacks = this.#callbacks.get(key) ?? new Set<typeof callback>();

Copy link
Contributor Author

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>();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: ?? would be safer:

Suggested change
this.#pollingTokenSets.get(key) || new Set<string>();
this.#pollingTokenSets.get(key) ?? new Set<string>();

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done here: 823aaa1

packages/assets-controllers/src/CurrencyRateController.ts Outdated Show resolved Hide resolved
return executePollMock;
};

class MyGasFeeController extends StaticIntervalPollingController<
Copy link
Contributor

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.

Copy link
Contributor Author

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 () => {
Copy link
Contributor

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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep seems redundant!

Copy link
Contributor Author

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 () => {
Copy link
Contributor

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:

Suggested change
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 () => {

Copy link
Contributor Author

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);
Copy link
Contributor

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:

Suggested change
controller.stopPollingByPollingToken(undefined as unknown as any);
// @ts-expect-error We're intentionally passing no polling token
controller.stopPollingByPollingToken();

Copy link
Contributor Author

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 () => {
Copy link
Contributor

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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep makes sense!

Copy link
Contributor Author

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;
Copy link
Contributor

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?

Copy link
Contributor Author

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?

Copy link
Contributor

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!

Copy link
Contributor Author

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;
Copy link
Contributor

@mcmire mcmire Dec 11, 2023

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)

Copy link
Contributor Author

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,
Copy link
Contributor

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?

Copy link
Contributor Author

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?

Copy link
Contributor

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done here: 823aaa1

@adonesky1 adonesky1 force-pushed the generalize-polling-abstraction branch from 14dde1c to 071f73b Compare December 11, 2023 23:09
Copy link
Contributor

@mcmire mcmire left a 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.

@adonesky1 adonesky1 merged commit 42d0b32 into main Dec 12, 2023
136 checks passed
@adonesky1 adonesky1 deleted the generalize-polling-abstraction branch December 12, 2023 21:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants