@platformatic/runtime 3.63.0 → 3.64.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/index.d.ts CHANGED
@@ -9,7 +9,7 @@ import { EventEmitter } from 'node:events'
9
9
  import { Logger } from 'pino'
10
10
  import { PlatformaticRuntimeConfig } from './config.js'
11
11
 
12
- export type RuntimeConfiguration = Promise<Configuration<PlatformaticRuntimeConfig>>
12
+ export type RuntimeConfiguration = Configuration<PlatformaticRuntimeConfig>
13
13
 
14
14
  export type ApplicationCommandContext = {
15
15
  colorette: typeof colorette
@@ -72,6 +72,8 @@ export namespace errors {
72
72
  ) => FastifyError
73
73
  export const ExtensionHealthRoutesUnavailableError: () => FastifyError
74
74
  export const LastProfileTimeoutError: (id: string) => FastifyError
75
+ export const SchedulerJobNotFoundError: (name: string) => FastifyError
76
+ export const DuplicateSchedulerJobError: (name: string) => FastifyError
75
77
  }
76
78
 
77
79
  export namespace symbols {
@@ -145,6 +147,45 @@ export interface RuntimeSharedContextUpdateOptions extends SharedContextUpdateOp
145
147
  context?: object
146
148
  }
147
149
 
150
+ export interface SchedulerJobBase {
151
+ name: string
152
+ cron: string
153
+ paused: boolean
154
+ maxRetries: number
155
+ lastExecutedAt?: string | null
156
+ lastStatus?: 'success' | 'failed' | null
157
+ nextRunAt?: string | null
158
+ }
159
+
160
+ export interface ConfiguredSchedulerJob extends SchedulerJobBase {
161
+ source: 'config'
162
+ callbackUrl: string
163
+ method: string
164
+ headers?: Record<string, string>
165
+ body?: string | Record<string, unknown>
166
+ }
167
+
168
+ export interface ApplicationSchedulerJob extends SchedulerJobBase {
169
+ source: 'application'
170
+ applicationId: string
171
+ scheduleId: string
172
+ tasks: string[]
173
+ }
174
+
175
+ export type SchedulerJob = ConfiguredSchedulerJob | ApplicationSchedulerJob
176
+
177
+ export interface SchedulerRunResult {
178
+ name: string
179
+ success: boolean
180
+ executedAt: string
181
+ }
182
+
183
+ export interface ApplicationSchedule {
184
+ id: string
185
+ cron: string
186
+ tasks: string[]
187
+ }
188
+
148
189
  export interface RuntimeMetadata {
149
190
  pid: number
150
191
  cwd: string
@@ -175,6 +216,10 @@ export declare class ManagementClient {
175
216
  getApplicationOpenapiSchema (id: string): Promise<unknown>
176
217
  getApplicationGraphqlSchema (id: string): Promise<unknown>
177
218
  getMetrics (format?: string): Promise<{ metrics: unknown }>
219
+ getSchedulerJobs (): Promise<SchedulerJob[]>
220
+ pauseSchedulerJob (name: string): Promise<SchedulerJob>
221
+ resumeSchedulerJob (name: string): Promise<SchedulerJob>
222
+ runSchedulerJob (name: string): Promise<SchedulerRunResult>
178
223
  startApplication (id: string): Promise<void>
179
224
  stopApplication (id: string): Promise<void>
180
225
  restartApplication (id: string): Promise<void>
@@ -286,6 +331,11 @@ export declare class Runtime extends EventEmitter {
286
331
  getRuntimeStatus (): string
287
332
  getRuntimeMetadata (): Promise<RuntimeMetadata>
288
333
  getRuntimeEnv (): Record<string, string>
334
+ // `includeMeta: true` returns the full merged Configuration object (which
335
+ // carries the kMetadata symbol key: `{ root, path, env, module }`), not a
336
+ // plain Record; the argument-invariant overload below covers every other
337
+ // call.
338
+ getRuntimeConfig (includeMeta: true): RuntimeConfiguration
289
339
  getRuntimeConfig (includeMeta?: boolean): Record<string, unknown>
290
340
  getApplicationsIds (): string[]
291
341
  /**
@@ -339,9 +389,23 @@ export declare class Runtime extends EventEmitter {
339
389
  restartApplication (id: string): Promise<void>
340
390
  addApplications (applications: unknown[], start?: boolean): Promise<ApplicationDetails[]>
341
391
  removeApplications (applications: string[], silent?: boolean): Promise<ApplicationDetails[]>
392
+ startApplicationProfiling (id: string, options: Record<string, unknown> & { allWorkers: true }, ensureStarted?: boolean): Promise<{ workers: number[] }>
342
393
  startApplicationProfiling (id: string, options?: Record<string, unknown>, ensureStarted?: boolean): Promise<void>
394
+ stopApplicationProfiling (id: string, options: Record<string, unknown> & { allWorkers: true }, ensureStarted?: boolean): Promise<Array<{ workerIndex: number, profile: Buffer }>>
395
+ stopApplicationProfiling (id: string, options: Record<string, unknown> & { includeSampleCount: true }, ensureStarted?: boolean): Promise<{ profile: Buffer, sampleCount: number }>
343
396
  stopApplicationProfiling (id: string, options?: Record<string, unknown>, ensureStarted?: boolean): Promise<Buffer>
344
- getApplicationLastProfile (id: string, options?: Record<string, unknown>, ensureStarted?: boolean): Promise<{ profile: Buffer, timestamp: number | null, preserved: boolean }>
397
+ getApplicationLastProfile (id: string, options?: Record<string, unknown>, ensureStarted?: boolean): Promise<{ profile: Buffer, timestamp: number | null, sampleCount: number | null, preserved: boolean }>
398
+ // Patches one application's resolved configuration through the same
399
+ // per-application JSON-Patch mechanism the runtime already applies during
400
+ // create()/start().
401
+ setApplicationConfigPatch (applicationId: string, patch: Array<Record<string, unknown>>): void
402
+ removeApplicationConfigPatch (applicationId: string): void
403
+ getSchedulerJobs (): SchedulerJob[]
404
+ pauseSchedulerJob (name: string): Promise<SchedulerJob>
405
+ resumeSchedulerJob (name: string): Promise<SchedulerJob>
406
+ runSchedulerJob (name: string): Promise<SchedulerRunResult>
407
+ getApplicationScheduledTasks (id: string): Promise<ApplicationSchedule[]>
408
+ runApplicationScheduledTasks (id: string, scheduleId: string, scheduledTime: number): Promise<unknown>
345
409
  }
346
410
 
347
411
  export function wrapInRuntimeConfig (
@@ -357,13 +421,27 @@ export declare function loadConfiguration (
357
421
  context?: ConfigurationOptions
358
422
  ): Promise<RuntimeConfiguration>
359
423
 
424
+ // create() additionally accepts `setupSignals`, a Runtime-only option (it is
425
+ // never read outside this package) that controls whether SIGUSR2/close-with-grace
426
+ // handlers are installed.
427
+ export interface RuntimeCreateContext extends ConfigurationOptions<PlatformaticRuntimeConfig> {
428
+ setupSignals?: boolean
429
+ }
430
+
360
431
  export function create (
361
432
  root: string,
362
433
  source?: string | PlatformaticRuntimeConfig,
363
- context?: ConfigurationOptions
434
+ context?: RuntimeCreateContext
364
435
  ): Promise<Runtime>
365
436
 
366
- export declare function prepareApplication (config: RuntimeConfiguration, application: object): object
437
+ // The runtime's actual per-application preparation (worker defaults, paths,
438
+ // telemetry hooks, capability resolution) also takes the resolved worker
439
+ // count as a third argument, and is itself async.
440
+ export declare function prepareApplication (
441
+ config: RuntimeConfiguration,
442
+ application: object,
443
+ workers?: PlatformaticRuntimeConfig['workers']
444
+ ): Promise<object>
367
445
 
368
446
  export declare function transform (
369
447
  config: RuntimeConfiguration,
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.getSchedulerJobs()
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
  })
@@ -203,8 +220,8 @@ export async function managementApiPlugin (app, opts) {
203
220
  app.log.debug('start profiling', { id })
204
221
 
205
222
  const options = request.body || {}
206
- await runtime.startApplicationProfiling(id, options)
207
- reply.code(200).send({})
223
+ const result = await runtime.startApplicationProfiling(id, options)
224
+ reply.code(200).send(result ?? {})
208
225
  })
209
226
 
210
227
  app.post('/applications/:id/pprof/stop', async (request, reply) => {
@@ -213,6 +230,19 @@ export async function managementApiPlugin (app, opts) {
213
230
 
214
231
  const options = request.body || {}
215
232
  const profileData = await runtime.stopApplicationProfiling(id, options)
233
+
234
+ // With allWorkers there is one profile per worker: return them as JSON
235
+ // with base64 encoded payloads since a single binary body cannot carry
236
+ // multiple profiles.
237
+ if (options.allWorkers && Array.isArray(profileData)) {
238
+ const profiles = profileData.map(({ workerIndex, profile }) => ({
239
+ workerIndex,
240
+ profile: Buffer.from(profile).toString('base64')
241
+ }))
242
+
243
+ return { profiles }
244
+ }
245
+
216
246
  reply.type('application/octet-stream').code(200).send(profileData)
217
247
  })
218
248
 
@@ -19,6 +19,10 @@ const allOperations = [
19
19
  'getApplicationOpenapiSchema',
20
20
  'getApplicationGraphqlSchema',
21
21
  'getMetrics',
22
+ 'getSchedulerJobs',
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('getSchedulerJobs', async () => {
104
+ return runtime.getSchedulerJobs()
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
@@ -93,6 +93,7 @@ const kInspectorOptions = Symbol('plt.runtime.worker.inspectorOptions')
93
93
  const kHeapCheckCounter = Symbol('plt.runtime.worker.heapCheckCounter')
94
94
  const kLastHeapStats = Symbol('plt.runtime.worker.lastHeapStats')
95
95
  const kProfilingELUGates = Symbol('plt.runtime.worker.profilingELUGates')
96
+ const kWorkerScheduledTasks = Symbol('plt.runtime.worker.scheduledTasks')
96
97
  const kHealthITCTimeoutMs = 5000
97
98
  const kProfilingELUHysteresis = 0.1
98
99
  const kLastProfileTimeoutMs = 10000
@@ -116,6 +117,10 @@ const kApplicationRestartsMetricHelp = 'Total number of restarts triggered by th
116
117
 
117
118
  const MAX_LISTENERS_COUNT = 100
118
119
 
120
+ function hasWorkerIndex (applicationId) {
121
+ return /^.+:\d+$/.test(applicationId)
122
+ }
123
+
119
124
  function parseOrigins (origins) {
120
125
  if (!origins) return undefined
121
126
 
@@ -352,8 +357,8 @@ export class Runtime extends EventEmitter {
352
357
  await this.addApplications(this.#config.applications)
353
358
  await this.#setDispatcher(config.undici)
354
359
 
355
- if (config.scheduler && !this.#context.build) {
356
- this.#scheduler = startScheduler(config.scheduler, this.#dispatcher, logger)
360
+ if (!this.#context.build) {
361
+ this.#scheduler = startScheduler(config.scheduler ?? [], this.#dispatcher, logger)
357
362
  }
358
363
 
359
364
  this.#updateStatus('init')
@@ -708,6 +713,7 @@ export class Runtime extends EventEmitter {
708
713
 
709
714
  for (const application of applications) {
710
715
  this.#dynamicWorkersScaler?.remove(application)
716
+ await this.#scheduler?.removeApplicationJobs(application)
711
717
  this.#applications.delete(application)
712
718
  this.#applicationRestartCounts.delete(application)
713
719
  }
@@ -816,6 +822,8 @@ export class Runtime extends EventEmitter {
816
822
  await this.#startWorker(config, applicationConfig, workers, id, i, silent)
817
823
  }
818
824
 
825
+ await this.#registerApplicationSchedulerJobs(id)
826
+
819
827
  this.emitAndNotify('application:started', id)
820
828
  await this.#dynamicWorkersScaler?.applyPendingUpdate(id)
821
829
  }
@@ -829,6 +837,7 @@ export class Runtime extends EventEmitter {
829
837
  const workersCount = workersIds.length
830
838
 
831
839
  this.emitAndNotify('application:stopping', id)
840
+ await this.#scheduler?.stopApplicationJobs(id)
832
841
 
833
842
  if (typeof workersCount === 'number') {
834
843
  const stopInvocations = []
@@ -882,6 +891,7 @@ export class Runtime extends EventEmitter {
882
891
  await this.#replaceWorker(config, applicationConfig, workersCount, id, workerIndex, worker, true)
883
892
  }
884
893
 
894
+ await this.#registerApplicationSchedulerJobs(id)
885
895
  this.#incrementApplicationRestartCount(id)
886
896
  this.emitAndNotify('application:restarted', id)
887
897
  } finally {
@@ -907,17 +917,68 @@ export class Runtime extends EventEmitter {
907
917
  }
908
918
 
909
919
  async startApplicationProfiling (id, options = {}, ensureStarted = true) {
910
- const service = await this.#getApplicationById(id, ensureStarted)
911
920
  this.#validatePprofCapturePreload()
912
921
 
913
- return sendViaITC(service, 'startProfiling', options)
922
+ const { allWorkers, ...profilingOptions } = options
923
+
924
+ if (!allWorkers || hasWorkerIndex(id)) {
925
+ const service = await this.#getApplicationWorkerForProfiling(id, ensureStarted)
926
+ return sendViaITC(service, 'startProfiling', profilingOptions)
927
+ }
928
+
929
+ const started = []
930
+ const alreadyProfiling = []
931
+ let firstError
932
+
933
+ for (const { workerIndex, worker } of await this.#getApplicationWorkersForProfiling(id, ensureStarted)) {
934
+ try {
935
+ await sendViaITC(worker, 'startProfiling', profilingOptions)
936
+ started.push(workerIndex)
937
+ } catch (error) {
938
+ // A worker which is already being profiled is considered covered, but
939
+ // if no other worker could be started the error is still reported.
940
+ if (error.code === 'PLT_PPROF_PROFILING_ALREADY_STARTED') {
941
+ alreadyProfiling.push(workerIndex)
942
+ }
943
+
944
+ firstError ??= error
945
+ }
946
+ }
947
+
948
+ if (started.length === 0 && firstError) {
949
+ throw firstError
950
+ }
951
+
952
+ return { workers: started.concat(alreadyProfiling).sort((a, b) => a - b) }
914
953
  }
915
954
 
916
955
  async stopApplicationProfiling (id, options = {}, ensureStarted = true) {
917
- const service = await this.#getApplicationById(id, ensureStarted)
918
956
  this.#validatePprofCapturePreload()
919
957
 
920
- return sendViaITC(service, 'stopProfiling', options)
958
+ const { allWorkers, ...profilingOptions } = options
959
+
960
+ if (!allWorkers || hasWorkerIndex(id)) {
961
+ const service = await this.#getApplicationWorkerForProfiling(id, ensureStarted)
962
+ return sendViaITC(service, 'stopProfiling', profilingOptions)
963
+ }
964
+
965
+ const profiles = []
966
+ let firstError
967
+
968
+ for (const { workerIndex, worker } of await this.#getApplicationWorkersForProfiling(id, ensureStarted)) {
969
+ try {
970
+ const profile = await sendViaITC(worker, 'stopProfiling', profilingOptions)
971
+ profiles.push({ workerIndex, profile })
972
+ } catch (error) {
973
+ firstError ??= error
974
+ }
975
+ }
976
+
977
+ if (profiles.length === 0 && firstError) {
978
+ throw firstError
979
+ }
980
+
981
+ return profiles
921
982
  }
922
983
 
923
984
  async getApplicationLastProfile (id, options = {}, ensureStarted = true) {
@@ -935,7 +996,11 @@ export class Runtime extends EventEmitter {
935
996
  // surface as an unhandled rejection.
936
997
  const pull = (async () => {
937
998
  const service = await this.#getApplicationById(id, ensureStarted)
938
- const result = await sendViaITC(service, 'getLastProfile', { ...options, includeTimestamp: true })
999
+ const result = await sendViaITC(service, 'getLastProfile', {
1000
+ ...options,
1001
+ includeTimestamp: true,
1002
+ includeSampleCount: true
1003
+ })
939
1004
  return { service, result }
940
1005
  })()
941
1006
  pull.catch(() => {})
@@ -947,7 +1012,9 @@ export class Runtime extends EventEmitter {
947
1012
 
948
1013
  // An older capture module which does not support includeTimestamp
949
1014
  // returns the raw profile.
950
- const value = result instanceof Uint8Array ? { profile: result, timestamp: null } : result
1015
+ const value = result instanceof Uint8Array
1016
+ ? { profile: result, timestamp: null, sampleCount: null }
1017
+ : { sampleCount: null, ...result }
951
1018
 
952
1019
  // A strictly newer live window supersedes the preserved overload
953
1020
  // profile: prune it so the preserved copy naturally expires once the
@@ -979,7 +1046,12 @@ export class Runtime extends EventEmitter {
979
1046
  if (preserved) {
980
1047
  // The preserved flag lets consumers distinguish post-mortem evidence
981
1048
  // from a live window and judge it together with the timestamp.
982
- return { profile: preserved.profile, timestamp: preserved.timestamp, preserved: true }
1049
+ return {
1050
+ profile: preserved.profile,
1051
+ timestamp: preserved.timestamp,
1052
+ sampleCount: preserved.sampleCount,
1053
+ preserved: true
1054
+ }
983
1055
  }
984
1056
 
985
1057
  throw error
@@ -1888,17 +1960,27 @@ export class Runtime extends EventEmitter {
1888
1960
  }
1889
1961
 
1890
1962
  async getApplicationMeta (id) {
1891
- const application = await this.#getApplicationById(id)
1963
+ const hasWorkerId = /^.+:\d+$/.test(id)
1964
+ const attempts = hasWorkerId ? 1 : Math.max(1, this.#workers.getKeys(id).length)
1892
1965
 
1893
- try {
1894
- return await sendViaITC(application, 'getApplicationMeta')
1895
- } catch (e) {
1896
- // The application exports no meta, return an empty object
1897
- if (e.code === 'PLT_ITC_HANDLER_NOT_FOUND') {
1898
- return {}
1899
- }
1966
+ for (let attempt = 0; attempt < attempts; attempt++) {
1967
+ const application = await this.#getApplicationById(id)
1900
1968
 
1901
- throw e
1969
+ try {
1970
+ return await sendViaITC(application, 'getApplicationMeta')
1971
+ } catch (e) {
1972
+ // The application exports no meta, return an empty object
1973
+ if (e.code === 'PLT_ITC_HANDLER_NOT_FOUND') {
1974
+ return {}
1975
+ }
1976
+
1977
+ // A parallel restart can stop the selected worker while metadata is
1978
+ // being retrieved. Retry another worker unless one was requested
1979
+ // explicitly or every worker available at the start has been tried.
1980
+ if (e.code !== 'PLT_RUNTIME_APPLICATION_WORKER_EXIT' || attempt === attempts - 1) {
1981
+ throw e
1982
+ }
1983
+ }
1902
1984
  }
1903
1985
  }
1904
1986
 
@@ -1973,6 +2055,79 @@ export class Runtime extends EventEmitter {
1973
2055
  return sendViaITC(application, 'getApplicationGraphQLSchema')
1974
2056
  }
1975
2057
 
2058
+ async getApplicationScheduledTasks (id) {
2059
+ const application = await this.#getApplicationById(id, true)
2060
+
2061
+ return sendViaITC(application, 'getApplicationScheduledTasks')
2062
+ }
2063
+
2064
+ async runApplicationScheduledTasks (id, scheduleId, scheduledTime) {
2065
+ const application = await this.#getApplicationById(id, true)
2066
+
2067
+ return sendViaITC(application, 'runApplicationScheduledTasks', { scheduleId, scheduledTime })
2068
+ }
2069
+
2070
+ getSchedulerJobs () {
2071
+ return this.#scheduler?.getJobs() ?? []
2072
+ }
2073
+
2074
+ pauseSchedulerJob (name) {
2075
+ return this.#schedulerOrThrow().pauseJob(name)
2076
+ }
2077
+
2078
+ resumeSchedulerJob (name) {
2079
+ return this.#schedulerOrThrow().resumeJob(name)
2080
+ }
2081
+
2082
+ runSchedulerJob (name) {
2083
+ return this.#schedulerOrThrow().runJob(name)
2084
+ }
2085
+
2086
+ #schedulerOrThrow () {
2087
+ if (!this.#scheduler) {
2088
+ throw new Error('The scheduler is not configured')
2089
+ }
2090
+
2091
+ return this.#scheduler
2092
+ }
2093
+
2094
+ async #registerApplicationSchedulerJobs (id) {
2095
+ if (!this.#scheduler) {
2096
+ return
2097
+ }
2098
+
2099
+ const pausedJobs = new Set(
2100
+ this.#scheduler
2101
+ .getJobs()
2102
+ .filter(job => job.applicationId === id && job.paused)
2103
+ .map(job => job.name)
2104
+ )
2105
+
2106
+ await this.#scheduler.removeApplicationJobs(id)
2107
+
2108
+ const workerId = this.#workers.getKeys(id)[0]
2109
+ const schedules = this.#workers.get(workerId)?.[kWorkerScheduledTasks] ?? []
2110
+ for (const schedule of schedules) {
2111
+ const name = `${id}:${schedule.id}`
2112
+ this.#scheduler.addJob(
2113
+ {
2114
+ name,
2115
+ cron: schedule.cron,
2116
+ source: 'application',
2117
+ applicationId: id,
2118
+ scheduleId: schedule.id,
2119
+ tasks: schedule.tasks,
2120
+ maxRetries: 3
2121
+ },
2122
+ ({ scheduledTime }) => this.runApplicationScheduledTasks(id, schedule.id, scheduledTime)
2123
+ )
2124
+
2125
+ if (pausedJobs.has(name)) {
2126
+ await this.#scheduler.pauseJob(name)
2127
+ }
2128
+ }
2129
+ }
2130
+
1976
2131
  async getWorkers (includeRaw = false) {
1977
2132
  const status = {}
1978
2133
 
@@ -2414,7 +2569,7 @@ export class Runtime extends EventEmitter {
2414
2569
  // The event only carries metadata: the profile can be retrieved on demand
2415
2570
  // via getApplicationLastProfile. We use emit instead of emitAndNotify since
2416
2571
  // other workers are not interested in this event.
2417
- worker[kITC].on('profile:captured', ({ type, timestamp }) => {
2572
+ worker[kITC].on('profile:captured', ({ type, timestamp, sampleCount }) => {
2418
2573
  // A strictly newer completed window supersedes the preserved overload
2419
2574
  // profile: once the worker is past the overload and producing windows
2420
2575
  // again, its old evidence must not be served anymore.
@@ -2430,7 +2585,8 @@ export class Runtime extends EventEmitter {
2430
2585
  application: applicationId,
2431
2586
  worker: index,
2432
2587
  type,
2433
- timestamp
2588
+ timestamp,
2589
+ sampleCount: sampleCount ?? null
2434
2590
  })
2435
2591
  })
2436
2592
 
@@ -2492,8 +2648,12 @@ export class Runtime extends EventEmitter {
2492
2648
  // the worker can be retrieved (see getApplicationLastProfile) even while
2493
2649
  // the worker event loop is blocked, and it survives the worker being
2494
2650
  // replaced by the health checks.
2495
- worker[kITC].on('profile:overload', ({ type, timestamp, profile }) => {
2496
- this.#lastOverloadProfiles.set(`${workerId}:${type}`, { profile, timestamp })
2651
+ worker[kITC].on('profile:overload', ({ type, timestamp, profile, sampleCount }) => {
2652
+ this.#lastOverloadProfiles.set(`${workerId}:${type}`, {
2653
+ profile,
2654
+ timestamp,
2655
+ sampleCount: sampleCount ?? null
2656
+ })
2497
2657
  })
2498
2658
 
2499
2659
  // Preserved overload profiles outlive their worker only for a grace
@@ -3000,22 +3160,24 @@ export class Runtime extends EventEmitter {
3000
3160
  this.emitAndNotify('application:worker:starting', eventPayload)
3001
3161
 
3002
3162
  try {
3003
- let workerUrl
3163
+ let workerStartResult
3004
3164
  if (config.startTimeout > 0) {
3005
- workerUrl = await executeWithTimeout(sendViaITC(worker, 'start'), config.startTimeout)
3165
+ workerStartResult = await executeWithTimeout(sendViaITC(worker, 'start'), config.startTimeout)
3006
3166
 
3007
- if (workerUrl === kTimeout) {
3167
+ if (workerStartResult === kTimeout) {
3008
3168
  this.emitAndNotify('application:worker:startTimeout', eventPayload)
3009
3169
  this.logger.error(`The ${label} failed to start in ${config.startTimeout}ms. Forcefully killing the thread.`)
3010
3170
  worker.terminate()
3011
3171
  throw new ApplicationStartTimeoutError(id, config.startTimeout)
3012
3172
  }
3013
3173
  } else {
3014
- workerUrl = await sendViaITC(worker, 'start')
3174
+ workerStartResult = await sendViaITC(worker, 'start')
3015
3175
  }
3016
3176
 
3017
3177
  await this.#avoidOutOfOrderThreadLogs()
3018
3178
 
3179
+ const { url: workerUrl, scheduledTasks } = workerStartResult
3180
+ worker[kWorkerScheduledTasks] = scheduledTasks
3019
3181
  if (workerUrl) {
3020
3182
  this.#url = workerUrl
3021
3183
 
@@ -3338,7 +3500,17 @@ export class Runtime extends EventEmitter {
3338
3500
  this.#workers.set(newWorkerId, newWorker)
3339
3501
 
3340
3502
  // Add the worker to the mesh
3341
- await this.#startWorker(configForNewWorker, applicationConfig, workersCount, applicationId, newIndex, false, 0, newWorker, true)
3503
+ await this.#startWorker(
3504
+ configForNewWorker,
3505
+ applicationConfig,
3506
+ workersCount,
3507
+ applicationId,
3508
+ newIndex,
3509
+ false,
3510
+ 0,
3511
+ newWorker,
3512
+ true
3513
+ )
3342
3514
 
3343
3515
  // Make sure the runtime hasn't been stopped in the meanwhile
3344
3516
  if (this.#status !== 'started') {
@@ -3388,6 +3560,37 @@ export class Runtime extends EventEmitter {
3388
3560
  return this.#getWorkerByIdOrNext(applicationId, workerId, ensureStarted, mustExist)
3389
3561
  }
3390
3562
 
3563
+ // Profiling start and stop must address the same worker: resolve an id
3564
+ // without an explicit worker index to the first worker deterministically,
3565
+ // as the round-robin used by #getApplicationById would rotate to a
3566
+ // different worker between the two calls.
3567
+ async #getApplicationWorkerForProfiling (applicationId, ensureStarted) {
3568
+ if (hasWorkerIndex(applicationId)) {
3569
+ return this.#getApplicationById(applicationId, ensureStarted)
3570
+ }
3571
+
3572
+ if (!this.#applications.has(applicationId)) {
3573
+ throw new ApplicationNotFoundError(applicationId, this.getApplicationsIds().join(', '))
3574
+ }
3575
+
3576
+ const [firstWorker] = this.#workers.getKeys(applicationId)
3577
+ return this.#getWorkerByIdOrNext(applicationId, firstWorker?.split(':')[1], ensureStarted)
3578
+ }
3579
+
3580
+ async #getApplicationWorkersForProfiling (applicationId, ensureStarted) {
3581
+ if (!this.#applications.has(applicationId)) {
3582
+ throw new ApplicationNotFoundError(applicationId, this.getApplicationsIds().join(', '))
3583
+ }
3584
+
3585
+ const workers = []
3586
+ for (const key of this.#workers.getKeys(applicationId)) {
3587
+ const workerIndex = parseInt(key.split(':')[1], 10)
3588
+ workers.push({ workerIndex, worker: await this.#getWorkerByIdOrNext(applicationId, workerIndex, ensureStarted) })
3589
+ }
3590
+
3591
+ return workers
3592
+ }
3593
+
3391
3594
  // This method can work in two modes: when workerId is provided, it will return the specific worker
3392
3595
  // otherwise it will return the next available worker for the application.
3393
3596
  async #getWorkerByIdOrNext (applicationId, workerId, ensureStarted = false, mustExist = true) {
@@ -83,7 +83,7 @@ export class ScalingAlgorithm {
83
83
 
84
84
  const newWorkersCount = appMinWorkers - workersCount
85
85
  totalWorkersCount += newWorkersCount
86
- totalAvailableMemory += newWorkersCount * avgHeapUsed
86
+ totalAvailableMemory -= newWorkersCount * avgHeapUsed
87
87
  continue
88
88
  }
89
89
 
@@ -96,7 +96,7 @@ export class ScalingAlgorithm {
96
96
 
97
97
  const removedWorkersCount = workersCount - appMaxWorkers
98
98
  totalWorkersCount -= removedWorkersCount
99
- totalAvailableMemory -= removedWorkersCount * avgHeapUsed
99
+ totalAvailableMemory += removedWorkersCount * avgHeapUsed
100
100
  continue
101
101
  }
102
102
 
@@ -111,7 +111,7 @@ export class ScalingAlgorithm {
111
111
 
112
112
  const removedWorkersCount = 1
113
113
  totalWorkersCount -= removedWorkersCount
114
- totalAvailableMemory -= removedWorkersCount * avgHeapUsed
114
+ totalAvailableMemory += removedWorkersCount * avgHeapUsed
115
115
  }
116
116
  }
117
117
  }
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
@@ -205,7 +205,16 @@ export async function setupITC (controller, application, dispatcher, sharedConte
205
205
  }
206
206
 
207
207
  dispatcher.replaceServer(await controller.capability.getDispatchTarget())
208
- return application.entrypoint ? controller.capability.getUrl() : null
208
+
209
+ const scheduledTasks =
210
+ typeof controller.capability.getScheduledTasks === 'function'
211
+ ? await controller.capability.getScheduledTasks()
212
+ : []
213
+
214
+ return {
215
+ url: application.entrypoint ? controller.capability.getUrl() : null,
216
+ scheduledTasks
217
+ }
209
218
  },
210
219
 
211
220
  async stop ({ force, dependents }) {
@@ -305,6 +314,22 @@ export async function setupITC (controller, application, dispatcher, sharedConte
305
314
  }
306
315
  },
307
316
 
317
+ async getApplicationScheduledTasks () {
318
+ if (typeof controller.capability.getScheduledTasks !== 'function') {
319
+ return []
320
+ }
321
+
322
+ return controller.capability.getScheduledTasks()
323
+ },
324
+
325
+ async runApplicationScheduledTasks ({ scheduleId, scheduledTime }) {
326
+ if (typeof controller.capability.runScheduledTasks !== 'function') {
327
+ throw new Error(`Application "${application.id}" does not support scheduled task execution`)
328
+ }
329
+
330
+ return controller.capability.runScheduledTasks(scheduleId, scheduledTime)
331
+ },
332
+
308
333
  async getApplicationMeta () {
309
334
  try {
310
335
  return await controller.capability.getMeta()
@@ -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.64.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/db": "3.64.0",
43
+ "@platformatic/composer": "3.64.0",
44
+ "@platformatic/gateway": "3.64.0",
45
+ "@platformatic/service": "3.64.0",
46
+ "@platformatic/node": "3.64.0",
47
+ "@platformatic/sql-graphql": "3.64.0",
48
+ "@platformatic/wattpm-pprof-capture": "3.64.0",
49
+ "@platformatic/sql-mapper": "3.64.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/foundation": "3.64.0",
81
+ "@platformatic/globals": "3.64.0",
82
+ "@platformatic/generators": "3.64.0",
83
+ "@platformatic/itc": "3.64.0",
84
+ "@platformatic/basic": "3.64.0",
85
+ "@platformatic/metrics": "3.64.0",
86
+ "@platformatic/telemetry": "3.64.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.64.0.json",
3
3
  "$schema": "http://json-schema.org/draft-07/schema#",
4
4
  "title": "Platformatic Runtime Config",
5
5
  "type": "object",