@live-change/cron-service 0.9.162

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/config.js ADDED
@@ -0,0 +1,15 @@
1
+ import definition from './definition.js'
2
+
3
+ const {
4
+ adminRoles = ['admin', 'owner'],
5
+ ownerTypes = ['user_User'],
6
+ topicTypes = ['topic_Topic']
7
+ } = definition.config
8
+
9
+ const config = {
10
+ adminRoles,
11
+ ownerTypes,
12
+ topicTypes
13
+ }
14
+
15
+ export default config
package/definition.js ADDED
@@ -0,0 +1,13 @@
1
+ import App from '@live-change/framework'
2
+ const app = App.app()
3
+
4
+ import relationsPlugin from '@live-change/relations-plugin'
5
+ import accessControlService from '@live-change/access-control-service'
6
+ import taskService from '@live-change/task-service'
7
+
8
+ const definition = app.createServiceDefinition({
9
+ name: "cron",
10
+ use: [ relationsPlugin, accessControlService, taskService ]
11
+ })
12
+
13
+ export default definition
package/index.js ADDED
@@ -0,0 +1,11 @@
1
+ import App from '@live-change/framework'
2
+ const app = App.app()
3
+
4
+ import definition from './definition.js'
5
+
6
+ import "./interval.js"
7
+ import "./schedule.js"
8
+ import "./run.js"
9
+
10
+
11
+ export default definition
package/interval.js ADDED
@@ -0,0 +1,163 @@
1
+ import App from '@live-change/framework'
2
+ const app = App.app()
3
+
4
+ import definition from './definition.js'
5
+ import config from './config.js'
6
+ import { triggerType } from './run.js'
7
+
8
+ export const Interval = definition.model({
9
+ name: "Interval",
10
+ propertyOfAny: {
11
+ to: ['owner', 'topic'],
12
+ ownerTypes: config.ownerTypes,
13
+ topicTypes: config.topicTypes,
14
+ writeAccessControl: {
15
+ roles: config.adminRoles
16
+ },
17
+ readAccessControl: {
18
+ roles: config.adminRoles
19
+ },
20
+ readAllAccess: ['admin'],
21
+ },
22
+ properties: {
23
+ description: {
24
+ type: String,
25
+ description: "Description of the interval"
26
+ },
27
+ interval: {
28
+ type: Number, // milliseconds
29
+ description: "Interval between triggers in milliseconds"
30
+ },
31
+ wait: {
32
+ type: Number, // milliseconds
33
+ description: "Wait for previous trigger to finish before planning next trigger"
34
+ },
35
+ trigger: triggerType
36
+ }
37
+ })
38
+
39
+ async function processInterval({ id, interval, wait, trigger }) {
40
+ if(wait) await waitForTasks('cron_Interval', id)
41
+ const nextTimestamp = Date.now() + interval
42
+ const nextTime = new Date(nextTimestamp)
43
+ await triggerService({
44
+ service: 'timer',
45
+ type: 'createTimer',
46
+ }, {
47
+ timer: {
48
+ id: 'cron_Interval_' + id,
49
+ timestamp: nextTimestamp,
50
+ time: nextTime,
51
+ service: definition.name,
52
+ causeType: 'cron_Interval',
53
+ cause: id,
54
+ trigger: {
55
+ type: 'runInterval',
56
+ data: {
57
+ interval: id
58
+ }
59
+ }
60
+ }
61
+ })
62
+ }
63
+
64
+
65
+ definition.trigger({
66
+ name: 'runInterval',
67
+ properties: {
68
+ interval: {
69
+ type: Interval,
70
+ }
71
+ },
72
+ execute: async ({ interval }, { service, trigger, triggerService }, emit) => {
73
+ const intervalData = await Interval.get(interval)
74
+ if(!intervalData) return /// interval was deleted
75
+ if(intervalData.wait) {
76
+ await runTrigger(intervalData.trigger, {
77
+ trigger,
78
+ triggerService,
79
+ jobType: 'cron_Interval',
80
+ job: interval,
81
+ timeout: Number.isInteger(intervalData.wait) ? intervalData.wait : undefined
82
+ })
83
+ await processInterval(intervalData)
84
+ } else {
85
+ await processInterval(intervalData) // no wait, process immediately
86
+ try {
87
+ await doRunTrigger(intervalData.trigger, {
88
+ trigger,
89
+ triggerService,
90
+ jobType: 'cron_Interval',
91
+ job: interval
92
+ })
93
+ } catch(error) {
94
+ console.error("ERROR RUNNING INTERVAL TRIGGER", error)
95
+ }
96
+ }
97
+ }
98
+ })
99
+
100
+
101
+ definition.trigger({
102
+ name: 'changeCron_Interval',
103
+ properties: {
104
+ object: {
105
+ type: Interval,
106
+ validation: ['nonEmpty'],
107
+ },
108
+ data: {
109
+ type: Object,
110
+ },
111
+ oldData: {
112
+ type: Object,
113
+ }
114
+ },
115
+ execute: async ({ object, data, oldData }, { service, trigger, triggerService }, emit) => {
116
+ if(oldData) { // clear old version
117
+ await triggerService({
118
+ service: 'timer',
119
+ type: 'cancelTimerIfExists',
120
+ data: {
121
+ timer: 'cron_Interval_' + object.id
122
+ }
123
+ })
124
+ if(!data) { // full cleanup
125
+ await triggerService({
126
+ service: 'timer',
127
+ type: 'cancelTimerIfExists',
128
+ data: {
129
+ timer: 'cron_run_timeout_' + object.id
130
+ }
131
+ })
132
+ RunState.delete(App.encodeIdentifier(['cron_Interval', object.id]))
133
+ }
134
+ }
135
+ if(data) { // setup new version
136
+ await processInterval({
137
+ id: object,
138
+ ...data``
139
+ })
140
+ }
141
+ }
142
+ })
143
+
144
+ const Timer = definition.foreignModel('timer', 'Timer')
145
+
146
+ definition.afterStart(async (service) => {
147
+ const position = ''
148
+ const bucketSize = 128
149
+ let bucket = []
150
+ do {
151
+ bucket = await Interval.rangeGet({
152
+ gt: position,
153
+ limit: bucketSize
154
+ })
155
+ for(const interval of bucket) {
156
+ const existingTimer = await Timer.get('cron_Interval_' + interval.id)
157
+ if(!existingTimer) {
158
+ console.error("INTERVAL", interval, "HAS NO TIMER, REPROCESSING")
159
+ await processInterval(interval)
160
+ }
161
+ }
162
+ } while(bucket.length === bucketSize)
163
+ })
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@live-change/cron-service",
3
+ "version": "0.9.162",
4
+ "description": "",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "test": "NODE_ENV=test tape tests/*"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/live-change/live-change-stack.git"
12
+ },
13
+ "license": "MIT",
14
+ "bugs": {
15
+ "url": "https://github.com/live-change/live-change-stack/issues"
16
+ },
17
+ "homepage": "https://github.com/live-change/live-change-stack",
18
+ "author": {
19
+ "email": "michal@laszczewski.pl",
20
+ "name": "Michał Łaszczewski",
21
+ "url": "https://www.viamage.com/"
22
+ },
23
+ "type": "module",
24
+ "dependencies": {
25
+ "@live-change/framework": "^0.9.162"
26
+ },
27
+ "gitHead": "59cd1485ca6d76bd87a6634a92d6e661e5803d5e"
28
+ }
package/run.js ADDED
@@ -0,0 +1,204 @@
1
+ import App from '@live-change/framework'
2
+ const app = App.app()
3
+
4
+ import definition from './definition.js'
5
+
6
+ const Task = definition.foreignModel('task', 'Task')
7
+
8
+ export const RunState = definition.model({
9
+ name: "RunState",
10
+ propertyOfAny: {
11
+ to: ['job'],
12
+ jobTypes: ['cron_Interval', 'cron_Schedule']
13
+ },
14
+ properties: {
15
+ state: {
16
+ type: String,
17
+ enum: ['running', 'waiting']
18
+ },
19
+ tasks: {
20
+ type: Array,
21
+ of: {
22
+ type: Task
23
+ }
24
+ },
25
+ startedAt: {
26
+ type: Date
27
+ },
28
+ returnTask: {
29
+ type: Boolean
30
+ }
31
+ },
32
+ indexes: {
33
+ byTasks: {
34
+ multi: true,
35
+ property: ['tasks']
36
+ }
37
+ }
38
+ })
39
+
40
+
41
+ export const triggerType = {
42
+ description: "Trigger to schedule",
43
+ input: 'triggerEditor',
44
+ type: Object,
45
+ properties: {
46
+ name: {
47
+ description: "Trigger name",
48
+ type: String,
49
+ },
50
+ service: {
51
+ description: "Service holding the trigger, or null for triggering any service",
52
+ type: String,
53
+ },
54
+ properties: {
55
+ description: "Properties to pass to the trigger",
56
+ type: Object,
57
+ }
58
+ }
59
+ }
60
+
61
+ export async function doRunTrigger(triggerInfo, { trigger, triggerService, jobType, job }) {
62
+ if(triggerInfo.service) {
63
+ return await triggerService({
64
+ type: triggerInfo.name,
65
+ service: triggerInfo.service,
66
+ causeType: jobType, cause: job
67
+ }, triggerInfo.properties, true)
68
+ } else {
69
+ return await trigger({
70
+ type: triggerInfo.name,
71
+ causeType: jobType, cause: job
72
+ }, triggerInfo.properties)
73
+ }
74
+ }
75
+
76
+ export async function runTrigger(triggerInfo, { trigger, triggerService, jobType, job, timeout }) {
77
+ const id = App.encodeIdentifier([jobType, job])
78
+ await RunState.create({
79
+ id,
80
+ jobType, job,
81
+ startedAt: new Date(),
82
+ state: 'running'
83
+ })
84
+ if(timeout) {
85
+ const timestamp = Date.now() + timeout
86
+ const time = new Date(timestamp)
87
+ await triggerService({
88
+ service: 'timer',
89
+ type: 'createTimer'
90
+ }, {
91
+ timer: {
92
+ id: 'cron_run_timeout_' + id,
93
+ timestamp, time,
94
+ causeType: jobType, cause: job,
95
+ service: definition.name,
96
+ trigger: {
97
+ type: 'runTimeout',
98
+ data: {
99
+ jobType, job
100
+ }
101
+ }
102
+ }
103
+ })
104
+ }
105
+ let triggerResults = []
106
+ try {
107
+ triggerResults = await doRunTrigger(triggerInfo, { trigger, triggerService, jobType, job })
108
+ } catch(error) {
109
+ console.error("ERROR RUNNING TRIGGER", error)
110
+ /// Ignore error, it will be handled by the task service
111
+ }
112
+ if(triggerInfo.returnTask) {
113
+ const tasks = await Promise.all(triggerResults.map((result) => Task.get(result)))
114
+ .filter(task => task !== undefined)
115
+ .filter(task => task.state !== 'done' && task.state !== 'failed')
116
+ if(tasks.length > 0) {
117
+ await RunState.update(id, {
118
+ state: 'waiting',
119
+ tasks: tasks.map(task => task.id)
120
+ })
121
+ /// Double check to avoid race condition
122
+ /* const runningTasks = await Promise.all(tasks.map(task => Task.get(task.id))).filter(task => task.state !== 'done' && task.state !== 'failed')
123
+ if(runningTasks.length === 0) {
124
+ await RunState.delete(id)
125
+ return 'done'
126
+ } */
127
+ /// There are still running tasks, wait for them
128
+ return 'waiting'
129
+ }
130
+ }
131
+ await RunState.delete(id)
132
+ if(timeout) {
133
+ await triggerService({
134
+ service: 'timer',
135
+ type: 'cancelTimer'
136
+ }, {
137
+ timer: 'cron_run_timeout_' + id,
138
+ })
139
+ }
140
+ return 'done'
141
+ }
142
+
143
+ definition.trigger({
144
+ name: 'runTimeout',
145
+ properties: {
146
+ jobType: {
147
+ type: String
148
+ },
149
+ job: {
150
+ type: String
151
+ }
152
+ },
153
+ execute: async ({ jobType, job }, { trigger, triggerService }) => {
154
+ const id = App.encodeIdentifier([jobType, job])
155
+ await RunState.delete(id)
156
+ return 'done'
157
+ }
158
+ })
159
+
160
+ export async function waitForTasks(jobType, job) {
161
+ const runState = App.encodeIdentifier([jobType, job])
162
+ return new Promise((resolve, reject) => {
163
+ let done = false
164
+ const taskObservations = new Map()
165
+ function addTaskObservation(taskId) {
166
+ const observable = Task.observable(taskId)
167
+ if(!observable) return
168
+ const observer = {
169
+ set: (value) => {
170
+ if(!value) return updateTasks()
171
+ if(value.state === 'done' || value.state === 'failed') updateTasks()
172
+ }
173
+ }
174
+ taskObservations.set(taskId, { observable, observer })
175
+ observable.observe(observer)
176
+ }
177
+ async function updateTasks() {
178
+ if(done) return
179
+ const runningTasks = taskObservations.values()
180
+ .filter(observation => observation.observable.getValue().state !== 'done' && observation.observable.getValue().state !== 'failed')
181
+ if(runningTasks.length === 0) {
182
+ await RunState.delete(runState)
183
+ finish()
184
+ }
185
+ }
186
+ const runStateObservable = RunState.observable(runState)
187
+ const runStateObserver = {
188
+ set: (value) => {
189
+ if(!value) finish()
190
+ if(value.tasks) {
191
+ for(const taskId of value.tasks) {
192
+ addTaskObservation(taskId)
193
+ }
194
+ }
195
+ }
196
+ }
197
+ function finish() {
198
+ if(done) return
199
+ done = true
200
+ runStateObservable.unobserve(runStateObserver)
201
+ resolve()
202
+ }
203
+ })
204
+ }
package/schedule.js ADDED
@@ -0,0 +1,176 @@
1
+ import App from '@live-change/framework'
2
+ const app = App.app()
3
+
4
+ import definition from './definition.js'
5
+ import config from './config.js'
6
+ import { triggerType } from './run.js'
7
+
8
+ export const Schedule = definition.model({
9
+ name: "Schedule",
10
+ propertyOfAny: {
11
+ to: ['owner', 'topic'],
12
+ ownerTypes: config.ownerTypes,
13
+ topicTypes: config.topicTypes,
14
+ writeAccessControl: {
15
+ roles: config.adminRoles
16
+ },
17
+ readAccessControl: {
18
+ roles: config.adminRoles
19
+ },
20
+ readAllAccess: ['admin'],
21
+ },
22
+ properties: {
23
+ description: {
24
+ type: String
25
+ },
26
+ minute: {
27
+ type: Number // NaN for every minute
28
+ },
29
+ hour: {
30
+ type: Number, // NaN for every hour
31
+ },
32
+ day: {
33
+ type: Number, // NaN for every day
34
+ },
35
+ dayOfWeek: {
36
+ type: Number, // NaN for every day of week
37
+ },
38
+ month: {
39
+ type: Number, // NaN for every month
40
+ },
41
+ trigger: triggerType
42
+ }
43
+ })
44
+
45
+ function getNextTime(schedule) {
46
+ const now = new Date()
47
+ let time = now
48
+ if(Number.isInteger(schedule.minute)) {
49
+ if(time.getMinutes() >= schedule.minute) { // next hour
50
+ time = new Date(time.getFullYear(), time.getMonth(), time.getDate(), time.getHours() + 1, schedule.minute, 0)
51
+ } else {
52
+ time = new Date(time.getFullYear(), time.getMonth(), time.getDate(), time.getHours(), schedule.minute, 0)
53
+ }
54
+ }
55
+ if(Number.isInteger(schedule.hour)) {
56
+ if(time.getHours() > schedule.hour) { // next day
57
+ time = new Date(time.getFullYear(), time.getMonth(), time.getDate() + 1, schedule.hour, schedule.minute || 0, 0)
58
+ } else {
59
+ time = new Date(time.getFullYear(), time.getMonth(), time.getDate(), schedule.hour, schedule.minute || 0, 0)
60
+ }
61
+ }
62
+
63
+ function isDayOk() {
64
+ if(Number.isInteger(schedule.month) && time.getMonth()+1 !== schedule.month) return false
65
+ if(Number.isInteger(schedule.day) && time.getDate() !== schedule.day) return false
66
+ if(Number.isInteger(schedule.dayOfWeek) && time.getDay()+1 !== schedule.dayOfWeek) return false
67
+ return true
68
+ }
69
+ while(!isDayOk()) {
70
+ time = new Date(time.getTime() + 24 * 60 * 60 * 1000)
71
+ }
72
+ return time
73
+ }
74
+
75
+ async function processSchedule({ id, minute, hour, day, dayOfWeek, month, trigger }) {
76
+ const nextTime = getNextTime(schedule)
77
+ const nextTimestamp = nextTime.getTime()
78
+ await triggerService({
79
+ service: 'timer',
80
+ type: 'createTimer',
81
+ }, {
82
+ timer: {
83
+ id: 'cron_Schedule_' + id,
84
+ timestamp: nextTimestamp,
85
+ time: nextTime,
86
+ service: definition.name,
87
+ causeType: 'cron_Schedule',
88
+ cause: id,
89
+ trigger: {
90
+ type: 'runSchedule',
91
+ data: {
92
+ schedule: id
93
+ }
94
+ }
95
+ }
96
+ })
97
+ }
98
+
99
+ definition.trigger({
100
+ name: 'runSchedule',
101
+ properties: {
102
+ schedule: {
103
+ type: Schedule,
104
+ }
105
+ },
106
+ execute: async ({ schedule }, { service, trigger, triggerService }, emit) => {
107
+ const scheduleData = await Schedule.get(schedule)
108
+ if(!scheduleData) return /// schedule was deleted
109
+ await processSchedule(scheduleData) // no wait, process immediately
110
+ try {
111
+ await doRunTrigger(scheduleData.trigger, {
112
+ trigger,
113
+ triggerService,
114
+ jobType: 'cron_Schedule',
115
+ job: schedule
116
+ })
117
+ } catch(error) {
118
+ console.error("ERROR RUNNING SCHEDULE TRIGGER", error)
119
+ }
120
+
121
+ }
122
+ })
123
+
124
+ definition.trigger({
125
+ name: 'changeCron_Schedule',
126
+ properties: {
127
+ object: {
128
+ type: Schedule,
129
+ validation: ['nonEmpty'],
130
+ },
131
+ data: {
132
+ type: Object,
133
+ },
134
+ oldData: {
135
+ type: Object,
136
+ }
137
+ },
138
+ execute: async ({ object, data, oldData }, { service, trigger, triggerService }, emit) => {
139
+ if(oldData) {
140
+ await triggerService({
141
+ service: 'timer',
142
+ type: 'cancelTimerIfExists',
143
+ data: {
144
+ timer: 'cron_Schedule_' + object.id
145
+ }
146
+ })
147
+ }
148
+ if(data) {
149
+ await processSchedule({
150
+ id: object,
151
+ ...data
152
+ })
153
+ }
154
+ }
155
+ })
156
+
157
+ const Timer = definition.foreignModel('timer', 'Timer')
158
+
159
+ definition.afterStart(async (service) => {
160
+ const position = ''
161
+ const bucketSize = 128
162
+ let bucket = []
163
+ do {
164
+ bucket = await Schedule.rangeGet({
165
+ gt: position,
166
+ limit: bucketSize
167
+ })
168
+ } while(bucket.length === bucketSize)
169
+ for(const schedule of bucket) {
170
+ const existingTimer = await Timer.get('cron_Schedule_' + schedule.id)
171
+ if(!existingTimer) {
172
+ console.error("SCHEDULE", schedule, "HAS NO TIMER, REPROCESSING")
173
+ await processSchedule(schedule)
174
+ }
175
+ }
176
+ })