Skip to content

Commit

Permalink
feat(timed-events): add cron based timed events (#21)
Browse files Browse the repository at this point in the history
* feat: add database-stored cronjobs

* feat: add events to change handlers at fixed times

* chore: move old fixed cronjobs to seeder

* feat: add skipping next occurrence of a timed event

* fix: register timed events on system start

* chore: clean code
  • Loading branch information
Yoronex authored Jan 5, 2025
1 parent 100aa57 commit e37622e
Show file tree
Hide file tree
Showing 14 changed files with 468 additions and 33 deletions.
30 changes: 0 additions & 30 deletions src/cron.ts

This file was deleted.

2 changes: 2 additions & 0 deletions src/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Entities as AuthEntities } from './modules/auth/entities';
import { Entities as AuditEntities } from './modules/audit/entities';
import { Entities as SpotifyEntities } from './modules/spotify/entities';
import { Entities as LightsEntities } from './modules/lights/entities';
import { Entities as TimedEventsEntities } from './modules/timed-events/entities';

const dataSource = new DataSource({
host: process.env.TYPEORM_HOST,
Expand All @@ -22,6 +23,7 @@ const dataSource = new DataSource({
},
entities: [
ServerSetting,
...TimedEventsEntities,
...BaseEntities,
...AuthEntities,
...AuditEntities,
Expand Down
5 changes: 5 additions & 0 deletions src/helpers/security-groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface ISecurityGroups {
sudosos: ISecuritySections;
serverSettings: ISecuritySections;
orders: ISecuritySections;
timedEvents: ISecuritySections;
}

/**
Expand Down Expand Up @@ -138,6 +139,10 @@ export const securityGroups = {
base: allSecuritySubscriberGroups,
privileged: baseSecurityGroups,
},
timedEvents: {
base: allSecurityGroups,
privileged: [SecurityGroup.ADMIN],
},
};

// Since object above cannot be type directly, we cast it here
Expand Down
5 changes: 2 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ import initBackofficeSynchronizer from './modules/backoffice/synchronizer';
import { SocketioNamespaces } from './socketio-namespaces';
import SocketConnectionManager from './modules/root/socket-connection-manager';
import { FeatureFlagManager, ServerSettingsStore } from './modules/server-settings';
import registerCronJobs from './cron';
import EmitterStore from './modules/events/emitter-store';
// do not remove; used for extending existing types
import Types from './types';
import { OrderManager } from './modules/orders';
import TimedEventsService from './modules/timed-events/timed-events-service';

async function createApp(): Promise<void> {
// Fix for production issue where a Docker volume overwrites the contents of a folder instead of merging them
Expand All @@ -40,6 +40,7 @@ async function createApp(): Promise<void> {

await ServerSettingsStore.getInstance().initialize();
const featureFlagManager = new FeatureFlagManager();
await TimedEventsService.getInstance().registerAllDatabaseEvents();

const app = await createHttp();
const httpServer = createServer(app);
Expand Down Expand Up @@ -82,8 +83,6 @@ async function createApp(): Promise<void> {

initBackofficeSynchronizer(io.of('/backoffice'), emitterStore);

registerCronJobs();

const port = process.env.PORT || 3000;
httpServer.listen(port, () => logger.info(`Listening at http://localhost:${port}`));
}
Expand Down
163 changes: 163 additions & 0 deletions src/modules/timed-events/cron-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import cron, { ScheduledTask } from 'node-cron';
import HandlerService from '../root/handler-service';
import logger from '../../logger';
import { TimedEvent } from './entities';
import { AuditService } from '../audit';
import HandlerManager from '../root/handler-manager';
import RootAudioService from '../root/root-audio-service';
import RootLightsService from '../root/root-lights-service';
import RootScreenService from '../root/root-screen-service';

export class CronExpressionError extends Error {}

export default class CronManager {
private cronTasks: Map<number, ScheduledTask>;

constructor(private eventIsSkipped: (event: TimedEvent) => Promise<void>) {
this.cronTasks = new Map();
}

/**
* Schedule an event
* @param timedEvent
*/
registerEvent(timedEvent: TimedEvent) {
if (!cron.validate(timedEvent.cronExpression)) {
throw new CronExpressionError('Invalid cron expression');
}

// Remove the old event first if it already exists
this.removeEvent(timedEvent);

const task = cron.schedule(timedEvent.cronExpression, this.handleEvent.bind(this, timedEvent));
this.cronTasks.set(timedEvent.id, task);
}

/**
* Unregister an event, such that it will not be scheduled anymore
* @param timedEvent
*/
removeEvent(timedEvent: TimedEvent) {
if (this.cronTasks.has(timedEvent.id)) {
const task = this.cronTasks.get(timedEvent.id)!;
task.stop();
this.cronTasks.delete(timedEvent.id);
}
}

/**
* Callbacks when an event should be executed
* @param event
*/
async handleEvent(event: TimedEvent): Promise<void> {
const spec = event.eventSpec;
try {
if (event.skipNext) {
logger.info(`Timed event "${event.id}": skip.`);
event.skipNext = false;
await this.eventIsSkipped(event);

return;
}

switch (spec.type) {
case 'system-reset':
logger.audit(
{ id: 'cron', name: 'Scheduled Task', roles: [] },
'Reset system state to default.',
);
await new HandlerService().resetToDefaults();
break;

case 'clean-audit-logs':
logger.audit(
{ id: 'cron', name: 'Scheduled Task', roles: [] },
`Remove audit logs older than ${process.env.AUDIT_LOGS_MAX_AGE_DAYS} days.`,
);
await new AuditService().removeExpiredAuditLogs();
break;

case 'switch-handler-audio':
const audio = await new RootAudioService().getSingleAudio(spec.params.id);
if (audio == null) {
logger.error(`Timed event "${event.id}": audio with ID "${spec.params.id}" not found.`);
return;
}

const foundAudio = HandlerManager.getInstance().registerHandler(
audio,
spec.params.handler,
);
if (!foundAudio) {
logger.error(
`Timed event "${event.id}": audio handler with name "${spec.params.handler}" not found.`,
);
}

logger.audit(
{ id: 'cron', name: 'Scheduled Task', roles: [] },
`Change "${audio.name}" (id: ${audio.id}) audio handler to "${spec.params.handler}".`,
);

return;

case 'switch-handler-lights':
const lights = await new RootLightsService().getSingleLightsGroup(spec.params.id);
if (lights == null) {
logger.error(
`Timed event "${event.id}": lightsGroup with ID "${spec.params.id}" not found.`,
);
return;
}

const foundLights = HandlerManager.getInstance().registerHandler(
lights,
spec.params.handler,
);
if (!foundLights) {
logger.error(
`Timed event "${event.id}": lightsGroup handler with name "${spec.params.handler}" not found.`,
);
}

logger.audit(
{ id: 'cron', name: 'Scheduled Task', roles: [] },
`Change "${lights.name}" (id: ${lights.id}) lights handler to "${spec.params.handler}".`,
);

return;

case 'switch-handler-screen':
const screen = await new RootScreenService().getSingleScreen(spec.params.id);
if (screen == null) {
logger.error(
`Timed event "${event.id}": screen with ID "${spec.params.id}" not found.`,
);
return;
}

const foundScreen = HandlerManager.getInstance().registerHandler(
screen,
spec.params.handler,
);
if (!foundScreen) {
logger.error(
`Timed event "${event.id}": screen handler with name "${spec.params.handler}" not found.`,
);
}

logger.audit(
{ id: 'cron', name: 'Scheduled Task', roles: [] },
`Change "${screen.name}" (id: ${screen.id}) screen handler to "${spec.params.handler}".`,
);

return;

default:
logger.error(`Timed event "${event.id}": unknown type "${(spec as any).type}".`);
}
} catch (error: unknown) {
logger.error(`Timed event "${event.id}": ${error}`);
}
}
}
5 changes: 5 additions & 0 deletions src/modules/timed-events/entities/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import TimedEvent from './timed-event';

export { default as TimedEvent } from './timed-event';

export const Entities = [TimedEvent];
28 changes: 28 additions & 0 deletions src/modules/timed-events/entities/timed-event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import BaseEntity from '../../root/entities/base-entity';
import { Column, Entity } from 'typeorm';
import EventSpec from '../event-spec';

@Entity()
export default class TimedEvent extends BaseEntity {
@Column()
public cronExpression: string;

@Column({
type: 'varchar',
transformer: {
from(value: string): EventSpec {
return JSON.parse(value);
},
to(value: EventSpec): string {
return JSON.stringify(value);
},
},
})
public eventSpec: EventSpec;

/**
* Whether the next time this should fire, it should be skipped instead
*/
@Column({ default: false })
public skipNext: boolean;
}
16 changes: 16 additions & 0 deletions src/modules/timed-events/event-spec/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import TimedEventReset from './timed-event-reset';
import TimedEventCleanAuditLogs from './timed-event-clean-audit-logs';
import {
TimedEventSwitchHandlerAudio,
TimedEventSwitchHandlerLights,
TimedEventSwitchHandlerScreen,
} from './timed-event-switch-handler';

type EventSpec =
| TimedEventReset
| TimedEventCleanAuditLogs
| TimedEventSwitchHandlerAudio
| TimedEventSwitchHandlerLights
| TimedEventSwitchHandlerScreen;

export default EventSpec;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
type TimedEventCleanAuditLogs = {
type: 'clean-audit-logs';
};

export default TimedEventCleanAuditLogs;
5 changes: 5 additions & 0 deletions src/modules/timed-events/event-spec/timed-event-reset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
type TimedEventReset = {
type: 'system-reset';
};

export default TimedEventReset;
19 changes: 19 additions & 0 deletions src/modules/timed-events/event-spec/timed-event-switch-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
interface SwitchHandlerParams {
id: number;
handler: string;
}

export type TimedEventSwitchHandlerAudio = {
type: 'switch-handler-audio';
params: SwitchHandlerParams;
};

export type TimedEventSwitchHandlerLights = {
type: 'switch-handler-lights';
params: SwitchHandlerParams;
};

export type TimedEventSwitchHandlerScreen = {
type: 'switch-handler-screen';
params: SwitchHandlerParams;
};
Loading

0 comments on commit e37622e

Please sign in to comment.