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

chore: Refactor cronjob controller #3029

Open
wants to merge 18 commits into
base: main
Choose a base branch
from
Open
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
8 changes: 4 additions & 4 deletions packages/snaps-controllers/coverage.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"branches": 93.31,
"functions": 97.05,
"lines": 98.25,
"statements": 97.98
"branches": 93.32,
"functions": 97.59,
"lines": 98.35,
"statements": 98.08
}
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ describe('CronjobController', () => {

expect(() =>
cronjobController.scheduleBackgroundEvent(backgroundEvent),
).toThrow('Cannot schedule an event in the past.');
).toThrow('Cannot schedule execution in the past.');

expect(cronjobController.state.events).toStrictEqual({});

Expand Down
134 changes: 86 additions & 48 deletions packages/snaps-controllers/src/cronjob/CronjobController.ts
Copy link
Member

Choose a reason for hiding this comment

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

I haven't reviewed this in detail. but generally when I proposed we refactor the CronjobController, the main goal I had in mind was to unify the logic used to manage background events and cronjobs.

We currently treat these as entirely separate entities, but I don't think they need to be. My general thought was that we should investigate treating them as one entity with the only variation being non-recurring versus recurring cronjobs (or background events - no strong opinion on the name).

I had intended for the referenced ticket to be to investigate ways to achieve this.

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 PR unifies some logic, but I inherently found it hard to make a mental model of both being the same thing because the term non-recurring cronjob in of itself is an oxymoron. Maybe there's a better term to encompass both things. One divergence is that we fetch the cron jobs every time from the manifest, maybe if we can fetch once and unify the state then some of the other logic itself can be refactored. We can keep the original ticket open, I think this is an improvement nonetheless.

Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,26 @@ export class CronjobController extends BaseController<
this._handleEventSnapUpdated = this._handleEventSnapUpdated.bind(this);
this._handleSnapDisabledEvent = this._handleSnapDisabledEvent.bind(this);
this._handleSnapEnabledEvent = this._handleSnapEnabledEvent.bind(this);

// Subscribe to Snap events
/* eslint-disable @typescript-eslint/unbound-method */
this.#initializeEventListeners();

// Register action handlers
this.#initializeActionHandlers();

this.dailyCheckIn().catch((error) => {
/* istanbul ignore next */
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added ignore statement here (and below) as this scenario is pretty difficult to replicate in our test environment. I don't think it's worth the effort to try and cover these.

logError(error);
});

this.#rescheduleBackgroundEvents(Object.values(this.state.events));
}

/**
* Initialize event listeners.
*/
#initializeEventListeners() {
/* eslint-disable @typescript-eslint/unbound-method */
this.messagingSystem.subscribe(
'SnapController:snapInstalled',
this._handleSnapRegisterEvent,
Expand All @@ -180,7 +197,12 @@ export class CronjobController extends BaseController<
this._handleEventSnapUpdated,
);
/* eslint-enable @typescript-eslint/unbound-method */
}

/**
* Initialize action handlers.
*/
#initializeActionHandlers() {
this.messagingSystem.registerActionHandler(
`${controllerName}:scheduleBackgroundEvent`,
(...args) => this.scheduleBackgroundEvent(...args),
Expand All @@ -195,12 +217,64 @@ export class CronjobController extends BaseController<
`${controllerName}:getBackgroundEvents`,
(...args) => this.getBackgroundEvents(...args),
);
}

this.dailyCheckIn().catch((error) => {
logError(error);
/**
* Execute a request.
*
* @param snapId - ID of a Snap.
* @param request - Request to be executed.
*/
async #executeRequest(snapId: SnapId, request: unknown) {
await this.messagingSystem
.call('SnapController:handleRequest', {
snapId,
origin: '',
handler: HandlerType.OnCronjob,
request: request as Record<string, unknown>,
})
.catch((error) => {
/* istanbul ignore next */
logError(error);
});
}

/**
* Setup a timer.
*
* @param id - ID of a timer.
* @param ms - Time in milliseconds.
* @param snapId - ID of a Snap.
* @param onComplete - Callback function to be executed when the timer completes.
*/
#setupTimer(id: string, ms: number, snapId: SnapId, onComplete: () => void) {
if (ms <= 0) {
throw new Error('Cannot schedule execution in the past.');
}

const timer = new Timer(ms);
timer.start(() => {
onComplete();
this.#timers.delete(id);
this.#snapIds.delete(id);
});

this.#rescheduleBackgroundEvents(Object.values(this.state.events));
this.#timers.set(id, timer);
this.#snapIds.set(id, snapId);
}

/**
* Cleanup a timer.
*
* @param id - ID of a timer.
*/
#cleanupTimer(id: string) {
const timer = this.#timers.get(id);
if (timer) {
Copy link
Member

Choose a reason for hiding this comment

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

Should this throw an error if the timer doesn't exist?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm I don't think so, this function is only called when cancelling a background event.

timer.cancel();
this.#timers.delete(id);
this.#snapIds.delete(id);
}
}

/**
Expand Down Expand Up @@ -273,23 +347,15 @@ export class CronjobController extends BaseController<
return;
}

const timer = new Timer(ms);
timer.start(() => {
this.#executeCronjob(job).catch((error) => {
// TODO: Decide how to handle errors.
logError(error);
});

this.#timers.delete(job.id);
this.#setupTimer(job.id, ms, job.snapId, () => {
// TODO: Decide how to handle errors.
this.#executeCronjob(job).catch(logError);
this.#schedule(job);
});

if (!this.state.jobs[job.id]?.lastRun) {
this.#updateJobLastRunState(job.id, 0); // 0 for init, never ran actually
}

this.#timers.set(job.id, timer);
this.#snapIds.set(job.id, job.snapId);
}

/**
Expand All @@ -299,12 +365,7 @@ export class CronjobController extends BaseController<
*/
async #executeCronjob(job: Cronjob) {
this.#updateJobLastRunState(job.id, Date.now());
await this.messagingSystem.call('SnapController:handleRequest', {
snapId: job.snapId,
origin: '',
handler: HandlerType.OnCronjob,
request: job.request,
});
await this.#executeRequest(job.snapId, job.request);
}

/**
Expand Down Expand Up @@ -358,10 +419,7 @@ export class CronjobController extends BaseController<
'Only the origin that scheduled this event can cancel it.',
);

const timer = this.#timers.get(id);
timer?.cancel();
this.#timers.delete(id);
this.#snapIds.delete(id);
this.#cleanupTimer(id);
this.update((state) => {
delete state.events[id];
});
Expand All @@ -377,32 +435,12 @@ export class CronjobController extends BaseController<
const now = new Date();
const ms = date.getTime() - now.getTime();

if (ms <= 0) {
throw new Error('Cannot schedule an event in the past.');
}

const timer = new Timer(ms);
timer.start(() => {
this.messagingSystem
.call('SnapController:handleRequest', {
snapId: event.snapId,
origin: '',
handler: HandlerType.OnCronjob,
request: event.request,
})
.catch((error) => {
logError(error);
});

this.#timers.delete(event.id);
this.#snapIds.delete(event.id);
this.#setupTimer(event.id, ms, event.snapId, () => {
this.#executeRequest(event.snapId, event.request).catch(logError);
this.update((state) => {
delete state.events[event.id];
});
});

this.#timers.set(event.id, timer);
this.#snapIds.set(event.id, event.snapId);
}

/**
Expand Down Expand Up @@ -493,7 +531,7 @@ export class CronjobController extends BaseController<
this.#dailyTimer = new Timer(DAILY_TIMEOUT);
this.#dailyTimer.start(() => {
this.dailyCheckIn().catch((error) => {
// TODO: Decide how to handle errors.
/* istanbul ignore next */
logError(error);
});
});
Expand Down
Loading