@platformatic/runtime 3.62.2 → 3.63.0-alpha.2
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/index.d.ts +51 -0
- package/lib/errors.js +11 -0
- package/lib/management-api.js +17 -0
- package/lib/management-handlers.js +20 -0
- package/lib/runtime.js +99 -14
- package/lib/scheduler.js +192 -60
- package/lib/worker/itc.js +26 -1
- package/lib/worker/management.js +16 -0
- package/package.json +16 -16
- package/schema.json +1 -1
package/index.d.ts
CHANGED
|
@@ -58,6 +58,8 @@ export namespace errors {
|
|
|
58
58
|
export const ReservedITCHandlerNameError: (name: string) => FastifyError
|
|
59
59
|
export const DuplicateITCHandlerNameError: (name: string) => FastifyError
|
|
60
60
|
export const LastProfileTimeoutError: (id: string) => FastifyError
|
|
61
|
+
export const SchedulerJobNotFoundError: (name: string) => FastifyError
|
|
62
|
+
export const DuplicateSchedulerJobError: (name: string) => FastifyError
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
export namespace symbols {
|
|
@@ -109,6 +111,45 @@ export interface ApplicationDetails {
|
|
|
109
111
|
url?: string | null
|
|
110
112
|
}
|
|
111
113
|
|
|
114
|
+
export interface SchedulerJobBase {
|
|
115
|
+
name: string
|
|
116
|
+
cron: string
|
|
117
|
+
paused: boolean
|
|
118
|
+
maxRetries: number
|
|
119
|
+
lastExecutedAt?: string | null
|
|
120
|
+
lastStatus?: 'success' | 'failed' | null
|
|
121
|
+
nextRunAt?: string | null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface ConfiguredSchedulerJob extends SchedulerJobBase {
|
|
125
|
+
source: 'config'
|
|
126
|
+
callbackUrl: string
|
|
127
|
+
method: string
|
|
128
|
+
headers?: Record<string, string>
|
|
129
|
+
body?: string | Record<string, unknown>
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface ApplicationSchedulerJob extends SchedulerJobBase {
|
|
133
|
+
source: 'application'
|
|
134
|
+
applicationId: string
|
|
135
|
+
scheduleId: string
|
|
136
|
+
tasks: string[]
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export type SchedulerJob = ConfiguredSchedulerJob | ApplicationSchedulerJob
|
|
140
|
+
|
|
141
|
+
export interface SchedulerRunResult {
|
|
142
|
+
name: string
|
|
143
|
+
success: boolean
|
|
144
|
+
executedAt: string
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface ApplicationSchedule {
|
|
148
|
+
id: string
|
|
149
|
+
cron: string
|
|
150
|
+
tasks: string[]
|
|
151
|
+
}
|
|
152
|
+
|
|
112
153
|
export interface RuntimeMetadata {
|
|
113
154
|
pid: number
|
|
114
155
|
cwd: string
|
|
@@ -139,6 +180,10 @@ export declare class ManagementClient {
|
|
|
139
180
|
getApplicationOpenapiSchema (id: string): Promise<unknown>
|
|
140
181
|
getApplicationGraphqlSchema (id: string): Promise<unknown>
|
|
141
182
|
getMetrics (format?: string): Promise<{ metrics: unknown }>
|
|
183
|
+
getScheduler (): Promise<SchedulerJob[]>
|
|
184
|
+
pauseSchedulerJob (name: string): Promise<SchedulerJob>
|
|
185
|
+
resumeSchedulerJob (name: string): Promise<SchedulerJob>
|
|
186
|
+
runSchedulerJob (name: string): Promise<SchedulerRunResult>
|
|
142
187
|
startApplication (id: string): Promise<void>
|
|
143
188
|
stopApplication (id: string): Promise<void>
|
|
144
189
|
restartApplication (id: string): Promise<void>
|
|
@@ -198,6 +243,12 @@ export declare class Runtime extends EventEmitter {
|
|
|
198
243
|
startApplicationProfiling (id: string, options?: Record<string, unknown>, ensureStarted?: boolean): Promise<void>
|
|
199
244
|
stopApplicationProfiling (id: string, options?: Record<string, unknown>, ensureStarted?: boolean): Promise<Buffer>
|
|
200
245
|
getApplicationLastProfile (id: string, options?: Record<string, unknown>, ensureStarted?: boolean): Promise<{ profile: Buffer, timestamp: number | null, preserved: boolean }>
|
|
246
|
+
getScheduler (): SchedulerJob[]
|
|
247
|
+
pauseSchedulerJob (name: string): Promise<SchedulerJob>
|
|
248
|
+
resumeSchedulerJob (name: string): Promise<SchedulerJob>
|
|
249
|
+
runSchedulerJob (name: string): Promise<SchedulerRunResult>
|
|
250
|
+
getApplicationScheduledTasks (id: string): Promise<ApplicationSchedule[]>
|
|
251
|
+
runApplicationScheduledTasks (id: string, scheduleId: string, scheduledTime: number): Promise<unknown>
|
|
201
252
|
}
|
|
202
253
|
|
|
203
254
|
export function wrapInRuntimeConfig (
|
package/lib/errors.js
CHANGED
|
@@ -140,6 +140,17 @@ export const MessagingError = createError(
|
|
|
140
140
|
'Cannot send a message to application "%s": %s'
|
|
141
141
|
)
|
|
142
142
|
|
|
143
|
+
export const SchedulerJobNotFoundError = createError(
|
|
144
|
+
`${ERROR_PREFIX}_SCHEDULER_JOB_NOT_FOUND`,
|
|
145
|
+
'Scheduler "%s" not found',
|
|
146
|
+
404
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
export const DuplicateSchedulerJobError = createError(
|
|
150
|
+
`${ERROR_PREFIX}_DUPLICATE_SCHEDULER_JOB`,
|
|
151
|
+
'Scheduler "%s" is already registered'
|
|
152
|
+
)
|
|
153
|
+
|
|
143
154
|
export const MissingPprofCapture = createError(
|
|
144
155
|
`${ERROR_PREFIX}_MISSING_PPROF_CAPTURE`,
|
|
145
156
|
'Please install @platformatic/wattpm-pprof-capture'
|
package/lib/management-api.js
CHANGED
|
@@ -79,6 +79,23 @@ export async function managementApiPlugin (app, opts) {
|
|
|
79
79
|
await runtime.restart(applications)
|
|
80
80
|
})
|
|
81
81
|
|
|
82
|
+
app.get('/scheduler', async () => {
|
|
83
|
+
const jobs = await runtime.getScheduler()
|
|
84
|
+
return { jobs }
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
app.post('/scheduler/:name/pause', async request => {
|
|
88
|
+
return runtime.pauseSchedulerJob(request.params.name)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
app.post('/scheduler/:name/resume', async request => {
|
|
92
|
+
return runtime.resumeSchedulerJob(request.params.name)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
app.post('/scheduler/:name/run', async request => {
|
|
96
|
+
return runtime.runSchedulerJob(request.params.name)
|
|
97
|
+
})
|
|
98
|
+
|
|
82
99
|
app.get('/applications', async () => {
|
|
83
100
|
return runtime.getApplications()
|
|
84
101
|
})
|
|
@@ -19,6 +19,10 @@ const allOperations = [
|
|
|
19
19
|
'getApplicationOpenapiSchema',
|
|
20
20
|
'getApplicationGraphqlSchema',
|
|
21
21
|
'getMetrics',
|
|
22
|
+
'getScheduler',
|
|
23
|
+
'pauseSchedulerJob',
|
|
24
|
+
'resumeSchedulerJob',
|
|
25
|
+
'runSchedulerJob',
|
|
22
26
|
'startApplication',
|
|
23
27
|
'stopApplication',
|
|
24
28
|
'restartApplication',
|
|
@@ -96,6 +100,10 @@ export function createManagementHandlers (runtime, allowedOperations) {
|
|
|
96
100
|
return runtime.getMetrics(format)
|
|
97
101
|
})
|
|
98
102
|
|
|
103
|
+
register('getScheduler', async () => {
|
|
104
|
+
return runtime.getScheduler()
|
|
105
|
+
})
|
|
106
|
+
|
|
99
107
|
// Write operations
|
|
100
108
|
register('startApplication', async ({ id }) => {
|
|
101
109
|
await runtime.startApplication(id)
|
|
@@ -113,6 +121,18 @@ export function createManagementHandlers (runtime, allowedOperations) {
|
|
|
113
121
|
return runtime.restart(applications)
|
|
114
122
|
})
|
|
115
123
|
|
|
124
|
+
register('pauseSchedulerJob', async ({ name }) => {
|
|
125
|
+
return runtime.pauseSchedulerJob(name)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
register('resumeSchedulerJob', async ({ name }) => {
|
|
129
|
+
return runtime.resumeSchedulerJob(name)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
register('runSchedulerJob', async ({ name }) => {
|
|
133
|
+
return runtime.runSchedulerJob(name)
|
|
134
|
+
})
|
|
135
|
+
|
|
116
136
|
register('addApplications', async ({ applications, start }) => {
|
|
117
137
|
const config = runtime.getRuntimeConfig(true)
|
|
118
138
|
|
package/lib/runtime.js
CHANGED
|
@@ -88,6 +88,7 @@ const kInspectorOptions = Symbol('plt.runtime.worker.inspectorOptions')
|
|
|
88
88
|
const kHeapCheckCounter = Symbol('plt.runtime.worker.heapCheckCounter')
|
|
89
89
|
const kLastHeapStats = Symbol('plt.runtime.worker.lastHeapStats')
|
|
90
90
|
const kProfilingELUGates = Symbol('plt.runtime.worker.profilingELUGates')
|
|
91
|
+
const kWorkerScheduledTasks = Symbol('plt.runtime.worker.scheduledTasks')
|
|
91
92
|
const kHealthITCTimeoutMs = 5000
|
|
92
93
|
const kProfilingELUHysteresis = 0.1
|
|
93
94
|
const kLastProfileTimeoutMs = 10000
|
|
@@ -338,8 +339,8 @@ export class Runtime extends EventEmitter {
|
|
|
338
339
|
await this.addApplications(this.#config.applications)
|
|
339
340
|
await this.#setDispatcher(config.undici)
|
|
340
341
|
|
|
341
|
-
if (
|
|
342
|
-
this.#scheduler = startScheduler(config.scheduler, this.#dispatcher, logger)
|
|
342
|
+
if (!this.#context.build) {
|
|
343
|
+
this.#scheduler = startScheduler(config.scheduler ?? [], this.#dispatcher, logger)
|
|
343
344
|
}
|
|
344
345
|
|
|
345
346
|
this.#updateStatus('init')
|
|
@@ -651,13 +652,7 @@ export class Runtime extends EventEmitter {
|
|
|
651
652
|
|
|
652
653
|
const removed = []
|
|
653
654
|
for (const application of applications) {
|
|
654
|
-
|
|
655
|
-
throw new ApplicationNotFoundError(application, this.getApplicationsIds().join(', '))
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
// Use allowUnloaded so that applications without a live worker
|
|
659
|
-
// (stopped or crashed with restartOnError: 0) can still be removed.
|
|
660
|
-
const details = await this.getApplicationDetails(application, true)
|
|
655
|
+
const details = await this.getApplicationDetails(application)
|
|
661
656
|
details.status = 'removed'
|
|
662
657
|
removed.push(details)
|
|
663
658
|
}
|
|
@@ -666,6 +661,7 @@ export class Runtime extends EventEmitter {
|
|
|
666
661
|
|
|
667
662
|
for (const application of applications) {
|
|
668
663
|
this.#dynamicWorkersScaler?.remove(application)
|
|
664
|
+
await this.#scheduler?.removeApplicationJobs(application)
|
|
669
665
|
this.#applications.delete(application)
|
|
670
666
|
this.#applicationRestartCounts.delete(application)
|
|
671
667
|
}
|
|
@@ -774,6 +770,8 @@ export class Runtime extends EventEmitter {
|
|
|
774
770
|
await this.#startWorker(config, applicationConfig, workers, id, i, silent)
|
|
775
771
|
}
|
|
776
772
|
|
|
773
|
+
await this.#registerApplicationSchedulerJobs(id)
|
|
774
|
+
|
|
777
775
|
this.emitAndNotify('application:started', id)
|
|
778
776
|
}
|
|
779
777
|
|
|
@@ -786,6 +784,7 @@ export class Runtime extends EventEmitter {
|
|
|
786
784
|
const workersCount = workersIds.length
|
|
787
785
|
|
|
788
786
|
this.emitAndNotify('application:stopping', id)
|
|
787
|
+
await this.#scheduler?.stopApplicationJobs(id)
|
|
789
788
|
|
|
790
789
|
if (typeof workersCount === 'number') {
|
|
791
790
|
const stopInvocations = []
|
|
@@ -839,6 +838,7 @@ export class Runtime extends EventEmitter {
|
|
|
839
838
|
await this.#replaceWorker(config, applicationConfig, workersCount, id, workerIndex, worker, true)
|
|
840
839
|
}
|
|
841
840
|
|
|
841
|
+
await this.#registerApplicationSchedulerJobs(id)
|
|
842
842
|
this.#incrementApplicationRestartCount(id)
|
|
843
843
|
this.emitAndNotify('application:restarted', id)
|
|
844
844
|
} finally {
|
|
@@ -1785,6 +1785,79 @@ export class Runtime extends EventEmitter {
|
|
|
1785
1785
|
return sendViaITC(application, 'getApplicationGraphQLSchema')
|
|
1786
1786
|
}
|
|
1787
1787
|
|
|
1788
|
+
async getApplicationScheduledTasks (id) {
|
|
1789
|
+
const application = await this.#getApplicationById(id, true)
|
|
1790
|
+
|
|
1791
|
+
return sendViaITC(application, 'getApplicationScheduledTasks')
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
async runApplicationScheduledTasks (id, scheduleId, scheduledTime) {
|
|
1795
|
+
const application = await this.#getApplicationById(id, true)
|
|
1796
|
+
|
|
1797
|
+
return sendViaITC(application, 'runApplicationScheduledTasks', { scheduleId, scheduledTime })
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
getScheduler () {
|
|
1801
|
+
return this.#scheduler?.getJobs() ?? []
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
pauseSchedulerJob (name) {
|
|
1805
|
+
return this.#schedulerOrThrow().pauseJob(name)
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
resumeSchedulerJob (name) {
|
|
1809
|
+
return this.#schedulerOrThrow().resumeJob(name)
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
runSchedulerJob (name) {
|
|
1813
|
+
return this.#schedulerOrThrow().runJob(name)
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
#schedulerOrThrow () {
|
|
1817
|
+
if (!this.#scheduler) {
|
|
1818
|
+
throw new Error('The scheduler is not configured')
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
return this.#scheduler
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
async #registerApplicationSchedulerJobs (id) {
|
|
1825
|
+
if (!this.#scheduler) {
|
|
1826
|
+
return
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
const pausedJobs = new Set(
|
|
1830
|
+
this.#scheduler
|
|
1831
|
+
.getJobs()
|
|
1832
|
+
.filter(job => job.applicationId === id && job.paused)
|
|
1833
|
+
.map(job => job.name)
|
|
1834
|
+
)
|
|
1835
|
+
|
|
1836
|
+
await this.#scheduler.removeApplicationJobs(id)
|
|
1837
|
+
|
|
1838
|
+
const workerId = this.#workers.getKeys(id)[0]
|
|
1839
|
+
const schedules = this.#workers.get(workerId)?.[kWorkerScheduledTasks] ?? []
|
|
1840
|
+
for (const schedule of schedules) {
|
|
1841
|
+
const name = `${id}:${schedule.id}`
|
|
1842
|
+
this.#scheduler.addJob(
|
|
1843
|
+
{
|
|
1844
|
+
name,
|
|
1845
|
+
cron: schedule.cron,
|
|
1846
|
+
source: 'application',
|
|
1847
|
+
applicationId: id,
|
|
1848
|
+
scheduleId: schedule.id,
|
|
1849
|
+
tasks: schedule.tasks,
|
|
1850
|
+
maxRetries: 3
|
|
1851
|
+
},
|
|
1852
|
+
({ scheduledTime }) => this.runApplicationScheduledTasks(id, schedule.id, scheduledTime)
|
|
1853
|
+
)
|
|
1854
|
+
|
|
1855
|
+
if (pausedJobs.has(name)) {
|
|
1856
|
+
await this.#scheduler.pauseJob(name)
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1788
1861
|
async getWorkers (includeRaw = false) {
|
|
1789
1862
|
const status = {}
|
|
1790
1863
|
|
|
@@ -2812,22 +2885,24 @@ export class Runtime extends EventEmitter {
|
|
|
2812
2885
|
this.emitAndNotify('application:worker:starting', eventPayload)
|
|
2813
2886
|
|
|
2814
2887
|
try {
|
|
2815
|
-
let
|
|
2888
|
+
let workerStartResult
|
|
2816
2889
|
if (config.startTimeout > 0) {
|
|
2817
|
-
|
|
2890
|
+
workerStartResult = await executeWithTimeout(sendViaITC(worker, 'start'), config.startTimeout)
|
|
2818
2891
|
|
|
2819
|
-
if (
|
|
2892
|
+
if (workerStartResult === kTimeout) {
|
|
2820
2893
|
this.emitAndNotify('application:worker:startTimeout', eventPayload)
|
|
2821
2894
|
this.logger.error(`The ${label} failed to start in ${config.startTimeout}ms. Forcefully killing the thread.`)
|
|
2822
2895
|
worker.terminate()
|
|
2823
2896
|
throw new ApplicationStartTimeoutError(id, config.startTimeout)
|
|
2824
2897
|
}
|
|
2825
2898
|
} else {
|
|
2826
|
-
|
|
2899
|
+
workerStartResult = await sendViaITC(worker, 'start')
|
|
2827
2900
|
}
|
|
2828
2901
|
|
|
2829
2902
|
await this.#avoidOutOfOrderThreadLogs()
|
|
2830
2903
|
|
|
2904
|
+
const { url: workerUrl, scheduledTasks } = workerStartResult
|
|
2905
|
+
worker[kWorkerScheduledTasks] = scheduledTasks
|
|
2831
2906
|
if (workerUrl) {
|
|
2832
2907
|
this.#url = workerUrl
|
|
2833
2908
|
|
|
@@ -3144,7 +3219,17 @@ export class Runtime extends EventEmitter {
|
|
|
3144
3219
|
this.#workers.set(newWorkerId, newWorker)
|
|
3145
3220
|
|
|
3146
3221
|
// Add the worker to the mesh
|
|
3147
|
-
await this.#startWorker(
|
|
3222
|
+
await this.#startWorker(
|
|
3223
|
+
configForNewWorker,
|
|
3224
|
+
applicationConfig,
|
|
3225
|
+
workersCount,
|
|
3226
|
+
applicationId,
|
|
3227
|
+
newIndex,
|
|
3228
|
+
false,
|
|
3229
|
+
0,
|
|
3230
|
+
newWorker,
|
|
3231
|
+
true
|
|
3232
|
+
)
|
|
3148
3233
|
|
|
3149
3234
|
// Make sure the runtime hasn't been stopped in the meanwhile
|
|
3150
3235
|
if (this.#status !== 'started') {
|
package/lib/scheduler.js
CHANGED
|
@@ -1,68 +1,176 @@
|
|
|
1
1
|
import { CronJob, validateCronExpression } from 'cron'
|
|
2
2
|
import { setTimeout } from 'node:timers/promises'
|
|
3
3
|
import { request } from 'undici'
|
|
4
|
+
import { DuplicateSchedulerJobError, SchedulerJobNotFoundError } from './errors.js'
|
|
4
5
|
|
|
5
6
|
export class SchedulerService {
|
|
6
7
|
constructor (schedulerConfig, dispatcher, logger) {
|
|
7
8
|
this.logger = logger
|
|
8
|
-
this.jobsConfig = []
|
|
9
|
-
this.cronJobs = []
|
|
10
9
|
this.dispatcher = dispatcher
|
|
11
|
-
this.
|
|
12
|
-
|
|
10
|
+
this.jobs = new Map()
|
|
11
|
+
this.started = false
|
|
13
12
|
|
|
14
|
-
validateCronSchedulers (schedulerConfig) {
|
|
15
13
|
for (const config of schedulerConfig) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
14
|
+
this.addJob({ ...config, source: 'config' })
|
|
15
|
+
}
|
|
16
|
+
}
|
|
20
17
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
18
|
+
addJob (config, executor) {
|
|
19
|
+
// Skip disabled schedulers
|
|
20
|
+
if (config.enabled === false) {
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
26
23
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
24
|
+
// Validate cron expression
|
|
25
|
+
const validation = validateCronExpression(config.cron)
|
|
26
|
+
if (!validation.valid) {
|
|
27
|
+
throw new Error(`Invalid cron expression "${config.cron}" for scheduler "${config.name}"`)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Set defaults for optional fields
|
|
31
|
+
const job = {
|
|
32
|
+
...config,
|
|
33
|
+
headers: config.headers || {},
|
|
34
|
+
body: config.body || {},
|
|
35
|
+
maxRetries: config.maxRetries ?? 3,
|
|
36
|
+
source: config.source ?? 'application',
|
|
37
|
+
paused: false,
|
|
38
|
+
lastExecutedAt: null,
|
|
39
|
+
lastStatus: null,
|
|
40
|
+
cronJob: null,
|
|
41
|
+
executor
|
|
35
42
|
}
|
|
43
|
+
|
|
44
|
+
if (this.jobs.has(job.name)) {
|
|
45
|
+
throw new DuplicateSchedulerJobError(job.name)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
this.jobs.set(job.name, job)
|
|
49
|
+
|
|
50
|
+
if (this.started) {
|
|
51
|
+
this.#startJob(job)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return this.#serializeJob(job)
|
|
36
55
|
}
|
|
37
56
|
|
|
38
57
|
start () {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
onTick: async () => {
|
|
44
|
-
this.logger.info(`Executing scheduler "${job.name}"`)
|
|
45
|
-
// This cannot throw, the try/catch is inside
|
|
46
|
-
await this.executeCallback(job)
|
|
47
|
-
},
|
|
48
|
-
start: true,
|
|
49
|
-
timeZone: 'UTC',
|
|
50
|
-
waitForCompletion: true
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
this.cronJobs.push(cronJob)
|
|
58
|
+
this.started = true
|
|
59
|
+
|
|
60
|
+
for (const job of this.jobs.values()) {
|
|
61
|
+
this.#startJob(job)
|
|
54
62
|
}
|
|
55
63
|
}
|
|
56
64
|
|
|
57
65
|
async stop () {
|
|
58
|
-
|
|
59
|
-
|
|
66
|
+
this.started = false
|
|
67
|
+
|
|
68
|
+
for (const job of this.jobs.values()) {
|
|
69
|
+
await this.#stopJob(job)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
getJobs () {
|
|
74
|
+
return Array.from(this.jobs.values(), job => this.#serializeJob(job))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Stops the local cron trigger for a job without removing its configuration.
|
|
78
|
+
// Used when an external coordinator (e.g. ICC) takes over the scheduling, so
|
|
79
|
+
// that multiple instances of the same runtime do not execute the job concurrently.
|
|
80
|
+
async pauseJob (name) {
|
|
81
|
+
const job = this.#getJobOrThrow(name)
|
|
82
|
+
|
|
83
|
+
if (job.paused) {
|
|
84
|
+
return this.#serializeJob(job)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
job.paused = true
|
|
88
|
+
await this.#stopJob(job)
|
|
89
|
+
this.logger.info(`Scheduler "${name}" paused`)
|
|
90
|
+
return this.#serializeJob(job)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async resumeJob (name) {
|
|
94
|
+
const job = this.#getJobOrThrow(name)
|
|
95
|
+
|
|
96
|
+
if (!job.paused) {
|
|
97
|
+
return this.#serializeJob(job)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
job.paused = false
|
|
101
|
+
|
|
102
|
+
if (this.started) {
|
|
103
|
+
this.#startJob(job)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
this.logger.info(`Scheduler "${name}" resumed`)
|
|
107
|
+
return this.#serializeJob(job)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async removeApplicationJobs (applicationId) {
|
|
111
|
+
for (const [name, job] of this.jobs) {
|
|
112
|
+
if (job.applicationId === applicationId) {
|
|
113
|
+
await this.#stopJob(job)
|
|
114
|
+
this.jobs.delete(name)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async stopApplicationJobs (applicationId) {
|
|
120
|
+
for (const job of this.jobs.values()) {
|
|
121
|
+
if (job.applicationId === applicationId) {
|
|
122
|
+
await this.#stopJob(job)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Executes a job immediately, regardless of its cron schedule or paused state.
|
|
128
|
+
// Used by an external coordinator to trigger the execution in this instance.
|
|
129
|
+
async runJob (name) {
|
|
130
|
+
const job = this.#getJobOrThrow(name)
|
|
131
|
+
return this.executeCallback(job)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
#getJobOrThrow (name) {
|
|
135
|
+
const job = this.jobs.get(name)
|
|
136
|
+
if (!job) {
|
|
137
|
+
throw new SchedulerJobNotFoundError(name)
|
|
138
|
+
}
|
|
139
|
+
return job
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
#startJob (job) {
|
|
143
|
+
if (job.cronJob || job.paused) {
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
this.logger.info(`Configuring scheduler "${job.name}" with cron "${job.cron}"`)
|
|
148
|
+
job.cronJob = CronJob.from({
|
|
149
|
+
cronTime: job.cron,
|
|
150
|
+
onTick: async () => {
|
|
151
|
+
this.logger.info(`Executing scheduler "${job.name}"`)
|
|
152
|
+
// This cannot throw, the try/catch is inside
|
|
153
|
+
await this.executeCallback(job)
|
|
154
|
+
},
|
|
155
|
+
start: true,
|
|
156
|
+
timeZone: 'UTC',
|
|
157
|
+
waitForCompletion: true
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async #stopJob (job) {
|
|
162
|
+
if (!job.cronJob) {
|
|
163
|
+
return
|
|
60
164
|
}
|
|
165
|
+
|
|
166
|
+
await job.cronJob.stop()
|
|
167
|
+
job.cronJob = null
|
|
61
168
|
}
|
|
62
169
|
|
|
63
170
|
async executeCallback (scheduler) {
|
|
64
171
|
let attempt = 0
|
|
65
172
|
let success = false
|
|
173
|
+
const scheduledTime = Date.now()
|
|
66
174
|
|
|
67
175
|
while (!success && attempt < scheduler.maxRetries) {
|
|
68
176
|
try {
|
|
@@ -74,29 +182,32 @@ export class SchedulerService {
|
|
|
74
182
|
)
|
|
75
183
|
await setTimeout(delay)
|
|
76
184
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
...scheduler.headers
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const bodyString = typeof scheduler.body === 'string' ? scheduler.body : JSON.stringify(scheduler.body)
|
|
83
|
-
const response = await request(scheduler.callbackUrl, {
|
|
84
|
-
method: scheduler.method,
|
|
85
|
-
headers,
|
|
86
|
-
body: bodyString,
|
|
87
|
-
dispatcher: this.dispatcher
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
// Consumes the body, but we are not interested in the body content,
|
|
91
|
-
// we don't save it anywere, so we just dump it
|
|
92
|
-
await response.body.dump()
|
|
93
|
-
|
|
94
|
-
if (response.statusCode >= 200 && response.statusCode < 300) {
|
|
95
|
-
this.logger.info(`Scheduler "${scheduler.name}" executed successfully`)
|
|
96
|
-
success = true
|
|
185
|
+
if (scheduler.executor) {
|
|
186
|
+
await scheduler.executor({ scheduledTime })
|
|
97
187
|
} else {
|
|
98
|
-
|
|
188
|
+
const headers = {
|
|
189
|
+
'x-retry-attempt': attempt + 1,
|
|
190
|
+
...scheduler.headers
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const bodyString = typeof scheduler.body === 'string' ? scheduler.body : JSON.stringify(scheduler.body)
|
|
194
|
+
const response = await request(scheduler.callbackUrl, {
|
|
195
|
+
method: scheduler.method,
|
|
196
|
+
headers,
|
|
197
|
+
body: bodyString,
|
|
198
|
+
dispatcher: this.dispatcher
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
// Consume the body to release the connection.
|
|
202
|
+
await response.body.dump()
|
|
203
|
+
|
|
204
|
+
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
205
|
+
throw new Error(`HTTP error ${response.statusCode}`)
|
|
206
|
+
}
|
|
99
207
|
}
|
|
208
|
+
|
|
209
|
+
this.logger.info(`Scheduler "${scheduler.name}" executed successfully`)
|
|
210
|
+
success = true
|
|
100
211
|
} catch (error) {
|
|
101
212
|
this.logger.error(
|
|
102
213
|
`Error executing scheduler "${scheduler.name}" (attempt ${attempt + 1}/${scheduler.maxRetries}):`,
|
|
@@ -106,9 +217,30 @@ export class SchedulerService {
|
|
|
106
217
|
}
|
|
107
218
|
}
|
|
108
219
|
|
|
220
|
+
scheduler.lastExecutedAt = new Date().toISOString()
|
|
221
|
+
scheduler.lastStatus = success ? 'success' : 'failed'
|
|
222
|
+
|
|
109
223
|
if (!success) {
|
|
110
224
|
this.logger.error(`Scheduler "${scheduler.name}" failed after ${scheduler.maxRetries} attempts`)
|
|
111
225
|
}
|
|
226
|
+
|
|
227
|
+
return { name: scheduler.name, success, executedAt: scheduler.lastExecutedAt }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
#serializeJob (job) {
|
|
231
|
+
const { cronJob, executor, ...serializable } = job
|
|
232
|
+
let nextRunAt = null
|
|
233
|
+
|
|
234
|
+
if (cronJob) {
|
|
235
|
+
try {
|
|
236
|
+
nextRunAt = cronJob.nextDate().toJSDate().toISOString()
|
|
237
|
+
/* c8 ignore next 3 */
|
|
238
|
+
} catch {
|
|
239
|
+
nextRunAt = null
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { ...serializable, nextRunAt }
|
|
112
244
|
}
|
|
113
245
|
}
|
|
114
246
|
|
package/lib/worker/itc.js
CHANGED
|
@@ -195,7 +195,16 @@ export async function setupITC (controller, application, dispatcher, sharedConte
|
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
dispatcher.replaceServer(await controller.capability.getDispatchTarget())
|
|
198
|
-
|
|
198
|
+
|
|
199
|
+
const scheduledTasks =
|
|
200
|
+
typeof controller.capability.getScheduledTasks === 'function'
|
|
201
|
+
? await controller.capability.getScheduledTasks()
|
|
202
|
+
: []
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
url: application.entrypoint ? controller.capability.getUrl() : null,
|
|
206
|
+
scheduledTasks
|
|
207
|
+
}
|
|
199
208
|
},
|
|
200
209
|
|
|
201
210
|
async stop ({ force, dependents }) {
|
|
@@ -286,6 +295,22 @@ export async function setupITC (controller, application, dispatcher, sharedConte
|
|
|
286
295
|
}
|
|
287
296
|
},
|
|
288
297
|
|
|
298
|
+
async getApplicationScheduledTasks () {
|
|
299
|
+
if (typeof controller.capability.getScheduledTasks !== 'function') {
|
|
300
|
+
return []
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return controller.capability.getScheduledTasks()
|
|
304
|
+
},
|
|
305
|
+
|
|
306
|
+
async runApplicationScheduledTasks ({ scheduleId, scheduledTime }) {
|
|
307
|
+
if (typeof controller.capability.runScheduledTasks !== 'function') {
|
|
308
|
+
throw new Error(`Application "${application.id}" does not support scheduled task execution`)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return controller.capability.runScheduledTasks(scheduleId, scheduledTime)
|
|
312
|
+
},
|
|
313
|
+
|
|
289
314
|
async getApplicationMeta () {
|
|
290
315
|
try {
|
|
291
316
|
return await controller.capability.getMeta()
|
package/lib/worker/management.js
CHANGED
|
@@ -70,6 +70,22 @@ export class ManagementClient {
|
|
|
70
70
|
return this.#send('getMetrics', { format })
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
getScheduler () {
|
|
74
|
+
return this.#send('getScheduler')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
pauseSchedulerJob (name) {
|
|
78
|
+
return this.#send('pauseSchedulerJob', { name })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
resumeSchedulerJob (name) {
|
|
82
|
+
return this.#send('resumeSchedulerJob', { name })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
runSchedulerJob (name) {
|
|
86
|
+
return this.#send('runSchedulerJob', { name })
|
|
87
|
+
}
|
|
88
|
+
|
|
73
89
|
startApplication (id) {
|
|
74
90
|
return this.#send('startApplication', { id })
|
|
75
91
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@platformatic/runtime",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.63.0-alpha.2",
|
|
4
4
|
"description": "Platformatic runtime",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -39,14 +39,14 @@
|
|
|
39
39
|
"typescript": "^5.9.3",
|
|
40
40
|
"undici-oidc-interceptor": "^0.9.0",
|
|
41
41
|
"why-is-node-running": "^2.2.2",
|
|
42
|
-
"@platformatic/
|
|
43
|
-
"@platformatic/
|
|
44
|
-
"@platformatic/
|
|
45
|
-
"@platformatic/
|
|
46
|
-
"@platformatic/
|
|
47
|
-
"@platformatic/
|
|
48
|
-
"@platformatic/
|
|
49
|
-
"@platformatic/
|
|
42
|
+
"@platformatic/composer": "3.63.0-alpha.2",
|
|
43
|
+
"@platformatic/db": "3.63.0-alpha.2",
|
|
44
|
+
"@platformatic/gateway": "3.63.0-alpha.2",
|
|
45
|
+
"@platformatic/node": "3.63.0-alpha.2",
|
|
46
|
+
"@platformatic/sql-graphql": "3.63.0-alpha.2",
|
|
47
|
+
"@platformatic/service": "3.63.0-alpha.2",
|
|
48
|
+
"@platformatic/wattpm-pprof-capture": "3.63.0-alpha.2",
|
|
49
|
+
"@platformatic/sql-mapper": "3.63.0-alpha.2"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
52
|
"@fastify/accepts": "^5.0.0",
|
|
@@ -77,13 +77,13 @@
|
|
|
77
77
|
"undici": "^7.27.2",
|
|
78
78
|
"undici-thread-interceptor": "^1.3.1",
|
|
79
79
|
"ws": "^8.16.0",
|
|
80
|
-
"@platformatic/basic": "3.
|
|
81
|
-
"@platformatic/foundation": "3.
|
|
82
|
-
"@platformatic/
|
|
83
|
-
"@platformatic/globals": "3.
|
|
84
|
-
"@platformatic/
|
|
85
|
-
"@platformatic/
|
|
86
|
-
"@platformatic/
|
|
80
|
+
"@platformatic/basic": "3.63.0-alpha.2",
|
|
81
|
+
"@platformatic/foundation": "3.63.0-alpha.2",
|
|
82
|
+
"@platformatic/generators": "3.63.0-alpha.2",
|
|
83
|
+
"@platformatic/globals": "3.63.0-alpha.2",
|
|
84
|
+
"@platformatic/metrics": "3.63.0-alpha.2",
|
|
85
|
+
"@platformatic/telemetry": "3.63.0-alpha.2",
|
|
86
|
+
"@platformatic/itc": "3.63.0-alpha.2"
|
|
87
87
|
},
|
|
88
88
|
"engines": {
|
|
89
89
|
"node": ">=22.19.0"
|
package/schema.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://schemas.platformatic.dev/@platformatic/runtime/3.
|
|
2
|
+
"$id": "https://schemas.platformatic.dev/@platformatic/runtime/3.63.0-alpha.2.json",
|
|
3
3
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
4
4
|
"title": "Platformatic Runtime Config",
|
|
5
5
|
"type": "object",
|