Skip to content
Draft
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
6 changes: 6 additions & 0 deletions packages/snaps-controllers/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- **BREAKING:** Add `setEventDate` to `CronjobControllerStateManager` ([#4107](https://github.com/MetaMask/snaps/pull/4107))
- `CronjobController` now persists a rescheduled event's next execution date through `setEventDate(id, date)` rather than passing the entire state to `set`. Rescheduling is the controller's most frequent write and changes only this field, so clients may now store dates separately and merge them back in `getInitialState`.
- Implementers of `CronjobControllerStateManager` must add the method. Delegating to `set` with the date applied preserves existing behaviour.

## [21.1.0]

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,38 @@ const MOCK_VERSION = '1.0.0' as SemVerVersion;
/**
* Get a mock state manager for the `CronjobController`.
*
* @returns A state manager object with `get` and `set` methods.
* @returns A state manager object with `getInitialState`, `set` and
* `setEventDate` methods.
*/
function getMockStateManager(): CronjobControllerStateManager {
let state: CronjobControllerState | undefined;

// Dates are stored apart from the rest of the state, mirroring how a client
// is expected to implement this, and merged back on read.
const dates = new Map<string, string>();

return {
getInitialState: () => state,
getInitialState: () => {
if (!state) {
return undefined;
}

return {
...state,
events: Object.fromEntries(
Object.entries(state.events).map(([id, event]) => [
id,
{ ...event, date: dates.get(id) ?? event.date },
]),
),
};
},
set: (newState) => {
state = newState;
},
setEventDate: (id, date) => {
dates.set(id, date);
},
};
}

Expand Down Expand Up @@ -469,6 +491,59 @@ describe('CronjobController', () => {
cronjobController.destroy();
});

it('persists a reschedule through `setEventDate`, without rewriting all state', async () => {
const rootMessenger = getRootCronjobControllerMessenger();
const controllerMessenger =
getRestrictedCronjobControllerMessenger(rootMessenger);

const handleRequest = jest.fn().mockResolvedValue(undefined);
rootMessenger.registerActionHandler(
'SnapController:handleRequest',
handleRequest,
);

const stateManager = getMockStateManager();
const set = jest.spyOn(stateManager, 'set');
const setEventDate = jest.spyOn(stateManager, 'setEventDate');

const cronjobController = new CronjobController({
messenger: controllerMessenger,
stateManager,
state: {
events: {
[`cronjob-${MOCK_SNAP_ID}-0`]: {
id: `cronjob-${MOCK_SNAP_ID}-0`,
snapId: MOCK_SNAP_ID,
date: new Date('2022-01-01T00:00Z').toISOString(),
scheduledAt: new Date('2022-01-01T00:00Z').toISOString(),
schedule: 'PT25H',
recurring: true,
request: {
method: 'exampleMethod',
params: ['p1'],
},
},
},
},
});

cronjobController.init();

await new Promise((resolve) => originalProcessNextTick(resolve));
expect(handleRequest).toHaveBeenCalledTimes(1);

// Firing the event reschedules it, which is the write this change is
// about: one date, not the whole event map.
expect(setEventDate).toHaveBeenCalledWith(
`cronjob-${MOCK_SNAP_ID}-0`,
expect.any(String),
);

expect(set).not.toHaveBeenCalled();

cronjobController.destroy();
});

it('handles the `snapInstalled` event', () => {
const rootMessenger = getRootCronjobControllerMessenger();
const controllerMessenger =
Expand Down
18 changes: 16 additions & 2 deletions packages/snaps-controllers/src/cronjob/CronjobController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,20 @@ export const DAILY_TIMEOUT = inMilliseconds(24, Duration.Hour);

export type CronjobControllerStateManager = {
set(state: CronjobControllerState): void;

/**
* Persist a single event's next execution date.
*
* Rescheduling is by far the most frequent write this controller makes — a
* snap with a `PT30S` schedule reschedules every thirty seconds — and it
* changes one field. Routing it here lets an implementation store dates
* separately instead of re-serialising every event on each tick.
*
* @param id - The ID of the event.
* @param date - The next execution date, as an ISO 8601 string.
*/
setEventDate(id: string, date: string): void;

getInitialState(): CronjobControllerState | undefined;
};

Expand Down Expand Up @@ -389,11 +403,11 @@ export class CronjobController extends BaseController<
}

const date = getExecutionDate(event.schedule);
const { nextState } = this.update((state) => {
this.update((state) => {
state.events[event.id].date = date;
});

this.#stateManager.set(nextState);
this.#stateManager.setEventDate(event.id, date);

this.#startTimer({
...event,
Expand Down
Loading