Skip to content

Commit

Permalink
Added Cron Jobs subsystem.
Browse files Browse the repository at this point in the history
  • Loading branch information
rizen committed Jul 4, 2024
1 parent 61ea55a commit 54a9bbb
Show file tree
Hide file tree
Showing 17 changed files with 426 additions and 0 deletions.
1 change: 1 addition & 0 deletions app/composables/ving/useAdminLinks.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
export default () => {
const links = computed(() => {
const out = [
{ label: 'Cron Jobs', to: '/cronjob', icon: 'ph:clock' },
{ label: 'System Wide Alert', to: '/system-wide-alert', icon: 'ph:megaphone' },
{ label: 'Users', to: '/user/admin', icon: 'ph:users' },
];
Expand Down
67 changes: 67 additions & 0 deletions app/pages/cronjob/[id]/edit.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<template>
<Title>Edit {{cronjob.props?.id}}</Title>
<PanelFrame :title="'Edit '+cronjob.props?.id" section="Cron Jobs">
<template #left>
<PanelNav :links="links" />
</template>
<template #content>
<FieldsetNav v-if="cronjob.props">
<FieldsetItem name="Properties">

<FormInput name="schedule" type="text" v-model="cronjob.props.schedule" required label="Schedule" @change="cronjob.save('schedule')" class="mb-4" />
<FormInput name="handler" type="select" :options="cronjob.options?.handler" v-model="cronjob.props.handler" required label="Handler" @change="cronjob.save('handler')" class="mb-4" />
<FormInput name="params" type="textarea" v-model="computedParams" label="Params" @change="cronjob.save('params')" class="mb-4" />
<FormInput name="enabled" type="select" :options="cronjob.options?.enabled" v-model="cronjob.props.enabled" label="Enabled" @change="cronjob.save('enabled')" class="mb-4" />
<FormInput name="note" type="textarea" v-model="cronjob.props.note" label="Note" @change="cronjob.save('note')" class="mb-4" />
</FieldsetItem>

<FieldsetItem name="Statistics">

<div class="mb-4"><b>Id</b>: {{cronjob.props?.id}} <CopyToClipboard :text="cronjob.props?.id" size="xs" /></div>

<div class="mb-4"><b>Created At</b>: {{formatDateTime(cronjob.props.createdAt)}}</div>

<div class="mb-4"><b>Updated At</b>: {{formatDateTime(cronjob.props.updatedAt)}}</div>

</FieldsetItem>

<FieldsetItem name="Actions">
<Button @mousedown="cronjob.delete()" severity="danger" class="mr-2 mb-2" title="Delete" alt="Delete Cron Job"><Icon name="ph:trash" class="mr-1"/> Delete</Button>
</FieldsetItem>
</FieldsetNav>
</template>
</PanelFrame>
</template>

<script setup>
definePageMeta({
middleware: ['auth', 'admin']
});
const route = useRoute();
const notify = useNotify();
const id = route.params.id.toString();
const cronjob = useVingRecord({
id,
fetchApi: `/api/${useRestVersion()}/cronjob/${id}`,
createApi: `/api/${useRestVersion()}/cronjob`,
query: { includeMeta: true, includeOptions: true },
onUpdate() {
notify.success('Updated Cron Job.');
},
async onDelete() {
await navigateTo('/cronjob');
},
});
await cronjob.fetch()
const computedParams = computed({
get() {
return JSON.stringify(cronjob.props.params);
},
set(value) {
cronjob.props.params = JSON.parse(value);
}
});
onBeforeRouteLeave(() => cronjob.dispose());
const links = useAdminLinks();
</script>
63 changes: 63 additions & 0 deletions app/pages/cronjob/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<template>
<Title>Cron Jobs</Title>
<PanelFrame title="Cron Jobs">
<template #left>
<PanelNav :links="links" />
</template>
<template #content>
<PanelZone title="Existing Cron Jobs">
<DataTable :value="cronjobs.records" stripedRows @sort="(e) => cronjobs.sortDataTable(e)">

<Column field="props.schedule" header="Schedule" sortable></Column>
<Column field="props.handler" header="Handler" sortable></Column>
<Column field="props.params" header="Params" sortable></Column>
<Column field="props.enabled" header="Enabled" sortable>
<template #body="slotProps">
{{ enum2label(slotProps.data.props.enabled, cronjobs.propsOptions.enabled) }}
</template>
</Column>
<Column header="Manage">
<template #body="slotProps">
<ManageButton severity="primary" :items="[
{ icon:'ph:pencil', label:'Edit', to:`/cronjob/${slotProps.data.props.id}/edit`},
{ icon:'ph:trash', label:'Delete', action:slotProps.data.delete}
]" />
</template>
</Column>
</DataTable>
<Pager :kind="cronjobs" />
</PanelZone>
<PanelZone title="Create Cron Job">
<Form :send="() => cronjobs.create()">

<FormInput name="schedule" type="text" v-model="cronjobs.new.schedule" required label="Schedule" class="mb-4" />
<FormInput name="handler" type="select" v-model="cronjobs.new.handler" :options="cronjobs.propsOptions?.handler" required label="Handler" class="mb-4" />
<div>
<Button type="submit" class="w-auto" severity="success">
<Icon name="ph:plus" class="mr-1"/> Create Cron Job
</Button>
</div>
</Form>
</PanelZone>
</template>
</PanelFrame>
</template>

<script setup>
definePageMeta({
middleware: ['auth', 'admin']
});
const cronjobs = useVingKind({
listApi: `/api/${useRestVersion()}/cronjob`,
createApi: `/api/${useRestVersion()}/cronjob`,
query: { includeMeta: true, sortBy: 'schedule', sortOrder: 'asc' },
newDefaults: { schedule: '* * * * *', handler: 'Test', enabled: true },
});
await Promise.all([
cronjobs.search(),
cronjobs.fetchPropsOptions(),
]);
onBeforeRouteLeave(() => cronjobs.dispose());
const links = useAdminLinks();
</script>
12 changes: 12 additions & 0 deletions server/api/v1/cronjob/[id]/index.delete.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { useKind } from '#ving/record/utils.mjs';
import { obtainSession, describeParams } from '#ving/utils/rest.mjs';
import {defineEventHandler, getRouterParams} from 'h3';
export default defineEventHandler(async (event) => {
const cronjobs = await useKind('CronJob');
const { id } = getRouterParams(event);
const cronjob = await cronjobs.findOrDie(id);
const session = obtainSession(event);
await cronjob.canEdit(session);
await cronjob.delete();
return cronjob.describe(describeParams(event, session));
});
9 changes: 9 additions & 0 deletions server/api/v1/cronjob/[id]/index.get.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { useKind } from '#ving/record/utils.mjs';
import { describeParams } from '#ving/utils/rest.mjs';
import {defineEventHandler, getRouterParams} from 'h3';
export default defineEventHandler(async (event) => {
const cronjobs = await useKind('CronJob');
const { id } = getRouterParams(event);
const cronjob = await cronjobs.findOrDie(id);
return cronjob.describe(describeParams(event));
});
12 changes: 12 additions & 0 deletions server/api/v1/cronjob/[id]/index.put.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { useKind } from '#ving/record/utils.mjs';
import { describeParams, obtainSession, getBody } from '#ving/utils/rest.mjs';
import {defineEventHandler, getRouterParams} from 'h3';
export default defineEventHandler(async (event) => {
const cronjobs = await useKind('CronJob');
const { id } = getRouterParams(event);
const cronjob = await cronjobs.findOrDie(id);
const session = obtainSession(event);
await cronjob.canEdit(session);
await cronjob.updateAndVerify(await getBody(event), session);
return cronjob.describe(describeParams(event, session));
});
7 changes: 7 additions & 0 deletions server/api/v1/cronjob/index.get.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { useKind } from '#ving/record/utils.mjs';
import { describeListParams, describeListWhere } from '#ving/utils/rest.mjs';
import {defineEventHandler} from 'h3';
export default defineEventHandler(async (event) => {
const cronjobs = await useKind('CronJob');
return await cronjobs.describeList(describeListParams(event), describeListWhere(event, cronjobs.describeListFilter()));
});
9 changes: 9 additions & 0 deletions server/api/v1/cronjob/index.post.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { useKind } from '#ving/record/utils.mjs';
import { describeParams, getBody, obtainSessionIfRole } from '#ving/utils/rest.mjs';
import {defineEventHandler} from 'h3';
export default defineEventHandler(async (event) => {
const cronjobs = await useKind('CronJob');
const session = obtainSessionIfRole(event, 'verifiedEmail');
const cronjob = await cronjobs.createAndVerify(await getBody(event), session);
return cronjob.describe(describeParams(event, session));
});
7 changes: 7 additions & 0 deletions server/api/v1/cronjob/options.get.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { useKind } from '#ving/record/utils.mjs';
import { describeParams } from '#ving/utils/rest.mjs';
import {defineEventHandler} from 'h3';
export default defineEventHandler(async (event) => {
const cronjobs = await useKind('CronJob');
return cronjobs.mint().propOptions(describeParams(event), true);
});
3 changes: 3 additions & 0 deletions ving/docs/change-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ outline: deep
### 2024-07-04
* Added `options` param to ving schema props.
* Fixed generating pathing for pages.
* Added Cron Jobs subsystem.
* NOTE: Run a database migration to add the new cronjob table.
* Fixed: Can't have duplicate cron specs in redis. #175

### 2024-07-01
* Added verifiedEmail field to the User CLI.
Expand Down
47 changes: 47 additions & 0 deletions ving/docs/rest/CronJob.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
outline: deep
---
# CronJob
Run background [jobs](../subsystems/jobs) on a set schedule.

## Filters

| Prop | Queryable | Qualifier | Range |
| --- | --- | --- | --- |
| createdAt | No | No | Yes |
| updatedAt | No | No | Yes |
| schedule | No | Yes | No |
| handler | No | Yes | No |

## Endpoints

### List

```
GET /api/v1/cronjob
```

### Create
```
POST /api/v1/cronjob
```

### Read
```
GET /api/v1/cronjob/:id
```

### Update
```
PUT /api/v1/cronjob/:id
```

### Delete
```
DELETE /api/v1/cronjob/:id
```

### Options
```
GET /api/v1/cronjob/options
```
4 changes: 4 additions & 0 deletions ving/docs/subsystems/jobs.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,7 @@ To create a new job run:
./ving.mjs jobs -n MyNewHandler
```

## Cron Jobs
You can set up jobs with a `cron` style schedule via the javascript API or the [CLI](cli). However, 2 jobs cannot have the same schedule, and you cannot set them up via the REST API. To get around this you can use the `CronJob` VingRecord which is accessible via the [REST API](../rest/CronJob) or using the built-in Admin UI.

This system works by storing the configuration of your repeating jobs in the `CronJob` VingRecord. When the `CronJob` handler is run it will look for any `CronJob` records that have the same schedule and execute them. When there are no more scheduled jobs that run at that schedule, the CronJob handler will automatically remove the schedule from BullMQ.
20 changes: 20 additions & 0 deletions ving/drizzle/schema/CronJob.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { boolean, mysqlEnum, mysqlTable, timestamp, datetime, uniqueIndex, unique, char, varchar, text, int, bigint, json, mediumText, foreignKey } from '#ving/drizzle/orm.mjs';



export const CronJobTable = mysqlTable('cronjobs',
{
id: bigint('id', {mode:'number', unsigned: true}).notNull().autoincrement().primaryKey(),
createdAt: timestamp('createdAt').defaultNow().notNull(),
updatedAt: timestamp('updatedAt').defaultNow().notNull().onUpdateNow(),
schedule: varchar('schedule', { length: 60 }).notNull().default('* * * * *'),
handler: varchar('handler', { length: 60 }).notNull().default('Test'),
params: json('params').notNull().default({}),
enabled: boolean('enabled').notNull().default(true),
note: text('note').notNull()
},
(table) => ({

})
);

27 changes: 27 additions & 0 deletions ving/jobs/handlers/CronJob.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import ving from '#ving/index.mjs';
import { getJobsForHandler, killJob } from "#ving/jobs/queue.mjs";

/**
* This handler executes all cron jobs at the same schedule in the CronJob VingRecord.
* @param {Object} A `BullMQ` job.
* @returns {boolean} `true`
*/
export default async function (job) {
ving.log('jobs').info(`Running CronJobs at schedule ${JSON.stringify(job.data.schedule)}`);
const cronJobs = await ving.useKind('CronJob');
const records = await cronJobs.findMany({ schedule: job.data.schedule });
if (records.length == 0) {
ving.log('jobs').info(`No CronJobs found at schedule ${JSON.stringify(job.data.schedule)}. Removing schedule.`);
const jobs = await getJobsForHandler('CronJob');
for (const rjob of jobs) {
if (job.data.schedule == rjob.data.schedule)
await killJob(rjob.id);
}
}
else {
for (const record of records) {
await record.queueJob();
}
}
return true;
}
66 changes: 66 additions & 0 deletions ving/record/records/CronJob.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { VingRecord, VingKind, enum2options } from "#ving/record/VingRecord.mjs";
import { getHandlerNames, addJob } from "#ving/jobs/queue.mjs";

/** Management of individual CronJobs. This is needed, because BullMQ won't allow multiple jobs with the
* same schedule to run as cron jobs. So this will execute the `CronJob` handler, which will in turn execute
* any number of cron jobs at that same schedule. This also gives a nice administrative UI for managing
* cron jobs.
* @class
*/
export class CronJobRecord extends VingRecord {
// add custom Record code here

/**
* Used with the VingSchema to generate the options for the job handler name.
* @returns {Array} An array of options for the job handler names
* @example
* const options = await cronJob.handlerOptions()
*/
async handlerOptions() {
const handlerNames = getHandlerNames();
const filteredHandlerNames = handlerNames.filter((h) => h != 'CronJob');
return enum2options(filteredHandlerNames, filteredHandlerNames);
}

/**
* Inserts the current record into the database then adds a BullMQ job to execute the `CronJob` handler.
* @example
* await cronJob.insert()
*/
async insert() {
await super.insert();
await addJob('CronJob', { schedule: this.get('schedule') }, { cron: this.get('schedule') });
}

/**
* Updates the current record in the database then adds a BullMQ job to execute the `CronJob` handler.
* @example
* await cronJob.update()
*/
async update() {
await super.update();
await addJob('CronJob', { schedule: this.get('schedule') }, { cron: this.get('schedule') });
}

// don't need a delete, because the `CronJob` handler will delete the job when it is done if there are no
// scheduled jobs with that schedule.

/**
* Queues a job for this record.
* @example
* await cronJob.queueJob()
*/
async queueJob() {
if (this.get('enabled') == true)
await addJob(this.get('handler'), this.get('params'));
}
}

/** Management of all CronJobs.
* @class
*/
export class CronJobKind extends VingKind {
// add custom Kind code here


}
Loading

0 comments on commit 54a9bbb

Please sign in to comment.