@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 +82 -4
- package/lib/errors.js +11 -0
- package/lib/management-api.js +32 -2
- package/lib/management-handlers.js +20 -0
- package/lib/runtime.js +230 -27
- package/lib/scaling-algorithm.js +3 -3
- 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
|
@@ -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 =
|
|
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?:
|
|
434
|
+
context?: RuntimeCreateContext
|
|
364
435
|
): Promise<Runtime>
|
|
365
436
|
|
|
366
|
-
|
|
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'
|
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.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 (
|
|
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
|
-
|
|
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
|
-
|
|
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', {
|
|
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
|
|
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 {
|
|
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
|
|
1963
|
+
const hasWorkerId = /^.+:\d+$/.test(id)
|
|
1964
|
+
const attempts = hasWorkerId ? 1 : Math.max(1, this.#workers.getKeys(id).length)
|
|
1892
1965
|
|
|
1893
|
-
|
|
1894
|
-
|
|
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
|
-
|
|
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}`, {
|
|
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
|
|
3163
|
+
let workerStartResult
|
|
3004
3164
|
if (config.startTimeout > 0) {
|
|
3005
|
-
|
|
3165
|
+
workerStartResult = await executeWithTimeout(sendViaITC(worker, 'start'), config.startTimeout)
|
|
3006
3166
|
|
|
3007
|
-
if (
|
|
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
|
-
|
|
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(
|
|
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) {
|
package/lib/scaling-algorithm.js
CHANGED
|
@@ -83,7 +83,7 @@ export class ScalingAlgorithm {
|
|
|
83
83
|
|
|
84
84
|
const newWorkersCount = appMinWorkers - workersCount
|
|
85
85
|
totalWorkersCount += newWorkersCount
|
|
86
|
-
totalAvailableMemory
|
|
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
|
|
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
|
|
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.
|
|
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
|
@@ -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
|
-
|
|
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()
|
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
|
+
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.
|
|
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/
|
|
43
|
-
"@platformatic/
|
|
44
|
-
"@platformatic/gateway": "3.
|
|
45
|
-
"@platformatic/service": "3.
|
|
46
|
-
"@platformatic/
|
|
47
|
-
"@platformatic/sql-
|
|
48
|
-
"@platformatic/
|
|
49
|
-
"@platformatic/
|
|
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/
|
|
81
|
-
"@platformatic/
|
|
82
|
-
"@platformatic/
|
|
83
|
-
"@platformatic/
|
|
84
|
-
"@platformatic/
|
|
85
|
-
"@platformatic/
|
|
86
|
-
"@platformatic/
|
|
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.
|
|
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",
|