@platformatic/runtime 3.63.0 → 3.65.0

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/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
 
@@ -24,7 +24,7 @@ import { resolve } from 'node:path'
24
24
  import { getActiveResourcesInfo } from 'node:process'
25
25
  import { workerData } from 'node:worker_threads'
26
26
  import { getGlobalDispatcher, setGlobalDispatcher } from 'undici'
27
- import { ApplicationAlreadyStartedError, RuntimeNotStartedError } from '../errors.js'
27
+ import { ApplicationAlreadyStartedError, exitCodes, RuntimeNotStartedError } from '../errors.js'
28
28
  import { getApplicationUrl } from '../utils.js'
29
29
  import { markAsPlatformaticDispatcher, refreshGlobalDispatcher } from './interceptors.js'
30
30
 
@@ -43,7 +43,7 @@ function handleUnhandled (app, event, listeners, timeout, err, ...args) {
43
43
  logger.error({ err: ensureLoggableError(err) }, `The ${label} threw an ${event} event.`)
44
44
 
45
45
  // Give some time to the listeners, logger and ITC notifications to land before shutting down
46
- setTimeout(() => process.exit(1), timeout)
46
+ setTimeout(() => process.exit(exitCodes.PROCESS_UNHANDLED_ERROR), timeout)
47
47
 
48
48
  for (const listener of listeners) {
49
49
  try {
package/lib/worker/itc.js CHANGED
@@ -167,6 +167,10 @@ export async function setupITC (controller, application, dispatcher, sharedConte
167
167
  }
168
168
  })
169
169
 
170
+ // ITC handlers run concurrently, so later start/stop requests must wait for
171
+ // the actual in-flight start operation rather than a controller event.
172
+ let controllerStartPromise
173
+
170
174
  const itc = new ITC({
171
175
  name: controller.applicationConfig.id + '-worker',
172
176
  port: parentPort,
@@ -175,14 +179,15 @@ export async function setupITC (controller, application, dispatcher, sharedConte
175
179
  const status = controller.getStatus()
176
180
 
177
181
  if (status === 'starting') {
178
- await once(controller, 'start')
182
+ await controllerStartPromise
179
183
  } else {
180
184
  // This gives a chance to a capability to perform custom logic
181
185
  const events = getEvents()
182
186
  events.emit('start')
183
187
 
184
188
  try {
185
- await controller.start()
189
+ controllerStartPromise = controller.start()
190
+ await controllerStartPromise
186
191
  } catch (e) {
187
192
  await controller.stop(true)
188
193
 
@@ -205,7 +210,16 @@ export async function setupITC (controller, application, dispatcher, sharedConte
205
210
  }
206
211
 
207
212
  dispatcher.replaceServer(await controller.capability.getDispatchTarget())
208
- return application.entrypoint ? controller.capability.getUrl() : null
213
+
214
+ const scheduledTasks =
215
+ typeof controller.capability.getScheduledTasks === 'function'
216
+ ? await controller.capability.getScheduledTasks()
217
+ : []
218
+
219
+ return {
220
+ url: application.entrypoint ? controller.capability.getUrl() : null,
221
+ scheduledTasks
222
+ }
209
223
  },
210
224
 
211
225
  async stop ({ force, dependents }) {
@@ -213,7 +227,7 @@ export async function setupITC (controller, application, dispatcher, sharedConte
213
227
  const status = controller.getStatus()
214
228
 
215
229
  if (!force && status === 'starting') {
216
- await once(controller, 'start')
230
+ await controllerStartPromise
217
231
  }
218
232
 
219
233
  if (force || status.startsWith('start')) {
@@ -305,6 +319,22 @@ export async function setupITC (controller, application, dispatcher, sharedConte
305
319
  }
306
320
  },
307
321
 
322
+ async getApplicationScheduledTasks () {
323
+ if (typeof controller.capability.getScheduledTasks !== 'function') {
324
+ return []
325
+ }
326
+
327
+ return controller.capability.getScheduledTasks()
328
+ },
329
+
330
+ async runApplicationScheduledTasks ({ scheduleId, scheduledTime }) {
331
+ if (typeof controller.capability.runScheduledTasks !== 'function') {
332
+ throw new Error(`Application "${application.id}" does not support scheduled task execution`)
333
+ }
334
+
335
+ return controller.capability.runScheduledTasks(scheduleId, scheduledTime)
336
+ },
337
+
308
338
  async getApplicationMeta () {
309
339
  try {
310
340
  return await controller.capability.getMeta()
@@ -11,12 +11,14 @@ import { addPinoInstrumentation } from '@platformatic/telemetry'
11
11
  import { Buffer } from 'node:buffer'
12
12
  import { subscribe } from 'node:diagnostics_channel'
13
13
  import { EventEmitter } from 'node:events'
14
+ import { readFile } from 'node:fs/promises'
14
15
  import { ServerResponse } from 'node:http'
15
16
  import inspector from 'node:inspector'
16
17
  import { hostname } from 'node:os'
17
18
  import { join, resolve } from 'node:path'
18
19
  import { setDefaultHighWaterMark } from 'node:stream'
19
20
  import { pathToFileURL } from 'node:url'
21
+ import { parseEnv } from 'node:util'
20
22
  import { threadId, workerData } from 'node:worker_threads'
21
23
  import pino from 'pino'
22
24
  import { install as installUndiciGlobals } from 'undici'
@@ -240,8 +242,21 @@ async function main () {
240
242
  const logger = getLogger()
241
243
  logger.debug({ envfile }, 'Loading envfile...')
242
244
 
245
+ // Note that process.loadEnvFile is not used here as it never overrides an already defined
246
+ // variable. The worker environment is seeded from the environment the runtime resolved, which
247
+ // might contain values coming from an env file of the runtime. Those are only defaults, so the
248
+ // env file of this application, which is more specific, is allowed to override them. Real
249
+ // environment variables are never overridden.
250
+ const envFileFallbackKeys = new Set(workerData.envFileFallbackKeys ?? [])
251
+
243
252
  try {
244
- process.loadEnvFile(envfile)
253
+ const applicationEnv = parseEnv(await readFile(envfile, 'utf-8'))
254
+
255
+ for (const [key, value] of Object.entries(applicationEnv)) {
256
+ if (!(key in process.env) || envFileFallbackKeys.has(key)) {
257
+ process.env[key] = value
258
+ }
259
+ }
245
260
  } catch {
246
261
  // Ignore if the file doesn't exist, similar to dotenv behavior
247
262
  }
@@ -70,6 +70,22 @@ export class ManagementClient {
70
70
  return this.#send('getMetrics', { format })
71
71
  }
72
72
 
73
+ getSchedulerJobs () {
74
+ return this.#send('getSchedulerJobs')
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.63.0",
3
+ "version": "3.65.0",
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/composer": "3.63.0",
43
- "@platformatic/db": "3.63.0",
44
- "@platformatic/gateway": "3.63.0",
45
- "@platformatic/service": "3.63.0",
46
- "@platformatic/sql-graphql": "3.63.0",
47
- "@platformatic/sql-mapper": "3.63.0",
48
- "@platformatic/node": "3.63.0",
49
- "@platformatic/wattpm-pprof-capture": "3.63.0"
42
+ "@platformatic/composer": "3.65.0",
43
+ "@platformatic/gateway": "3.65.0",
44
+ "@platformatic/node": "3.65.0",
45
+ "@platformatic/service": "3.65.0",
46
+ "@platformatic/db": "3.65.0",
47
+ "@platformatic/sql-graphql": "3.65.0",
48
+ "@platformatic/sql-mapper": "3.65.0",
49
+ "@platformatic/wattpm-pprof-capture": "3.65.0"
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.63.0",
81
- "@platformatic/generators": "3.63.0",
82
- "@platformatic/foundation": "3.63.0",
83
- "@platformatic/globals": "3.63.0",
84
- "@platformatic/metrics": "3.63.0",
85
- "@platformatic/telemetry": "3.63.0",
86
- "@platformatic/itc": "3.63.0"
80
+ "@platformatic/basic": "3.65.0",
81
+ "@platformatic/generators": "3.65.0",
82
+ "@platformatic/globals": "3.65.0",
83
+ "@platformatic/itc": "3.65.0",
84
+ "@platformatic/telemetry": "3.65.0",
85
+ "@platformatic/metrics": "3.65.0",
86
+ "@platformatic/foundation": "3.65.0"
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.63.0.json",
2
+ "$id": "https://schemas.platformatic.dev/@platformatic/runtime/3.65.0.json",
3
3
  "$schema": "http://json-schema.org/draft-07/schema#",
4
4
  "title": "Platformatic Runtime Config",
5
5
  "type": "object",