@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 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'
@@ -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 (config.scheduler && !this.#context.build) {
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
- if (!this.#applications.has(application)) {
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 workerUrl
2888
+ let workerStartResult
2816
2889
  if (config.startTimeout > 0) {
2817
- workerUrl = await executeWithTimeout(sendViaITC(worker, 'start'), config.startTimeout)
2890
+ workerStartResult = await executeWithTimeout(sendViaITC(worker, 'start'), config.startTimeout)
2818
2891
 
2819
- if (workerUrl === kTimeout) {
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
- workerUrl = await sendViaITC(worker, 'start')
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(configForNewWorker, applicationConfig, workersCount, applicationId, newIndex, false, 0, newWorker, true)
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.validateCronSchedulers(schedulerConfig)
12
- }
10
+ this.jobs = new Map()
11
+ this.started = false
13
12
 
14
- validateCronSchedulers (schedulerConfig) {
15
13
  for (const config of schedulerConfig) {
16
- // Skip disabled schedulers
17
- if (config.enabled === false) {
18
- continue
19
- }
14
+ this.addJob({ ...config, source: 'config' })
15
+ }
16
+ }
20
17
 
21
- // Validate cron expression
22
- const validation = validateCronExpression(config.cron)
23
- if (!validation.valid) {
24
- throw new Error(`Invalid cron expression "${config.cron}" for scheduler "${config.name}"`)
25
- }
18
+ addJob (config, executor) {
19
+ // Skip disabled schedulers
20
+ if (config.enabled === false) {
21
+ return null
22
+ }
26
23
 
27
- // Set defaults for optional fields
28
- const job = {
29
- ...config,
30
- headers: config.headers || {},
31
- body: config.body || {},
32
- maxRetries: config.maxRetries || 3
33
- }
34
- this.jobsConfig.push(job)
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
- for (const job of this.jobsConfig) {
40
- this.logger.info(`Configuring scheduler "${job.name}" with cron "${job.cron}"`)
41
- const cronJob = CronJob.from({
42
- cronTime: job.cron,
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
- for (const job of this.cronJobs) {
59
- await job.stop()
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
- const headers = {
78
- 'x-retry-attempt': attempt + 1,
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
- throw new Error(`HTTP error ${response.statusCode}`)
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
- return application.entrypoint ? controller.capability.getUrl() : null
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()
@@ -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.62.2",
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/db": "3.62.2",
43
- "@platformatic/gateway": "3.62.2",
44
- "@platformatic/node": "3.62.2",
45
- "@platformatic/service": "3.62.2",
46
- "@platformatic/composer": "3.62.2",
47
- "@platformatic/sql-graphql": "3.62.2",
48
- "@platformatic/sql-mapper": "3.62.2",
49
- "@platformatic/wattpm-pprof-capture": "3.62.2"
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.62.2",
81
- "@platformatic/foundation": "3.62.2",
82
- "@platformatic/itc": "3.62.2",
83
- "@platformatic/globals": "3.62.2",
84
- "@platformatic/generators": "3.62.2",
85
- "@platformatic/metrics": "3.62.2",
86
- "@platformatic/telemetry": "3.62.2"
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.62.2.json",
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",