@open-mercato/queue 0.6.8-develop.7015.1.af90a2ddc7 → 0.6.8-develop.7019.1.f4c01c4b5c
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/dist/factory.js +2 -1
- package/dist/factory.js.map +2 -2
- package/dist/strategies/async.js +111 -4
- package/dist/strategies/async.js.map +3 -3
- package/dist/worker/runner.js +3 -1
- package/dist/worker/runner.js.map +2 -2
- package/package.json +3 -3
- package/src/__tests__/abandoned-job.test.ts +494 -0
- package/src/__tests__/bullmq-abandoned-reasons.test.ts +42 -0
- package/src/__tests__/worker-abandoned-job.test.ts +101 -0
- package/src/factory.ts +7 -1
- package/src/strategies/async.ts +206 -2
- package/src/types.ts +47 -0
- package/src/worker/runner.ts +4 -0
package/src/strategies/async.ts
CHANGED
|
@@ -36,8 +36,11 @@ interface BullQueueInterface<T> {
|
|
|
36
36
|
close: () => Promise<void>
|
|
37
37
|
getJobCounts: (...states: string[]) => Promise<Record<string, number>>
|
|
38
38
|
getJobs: (types: string[], start?: number, end?: number) => Promise<Array<{
|
|
39
|
+
id?: string
|
|
39
40
|
data?: T
|
|
41
|
+
failedReason?: string
|
|
40
42
|
remove: () => Promise<void>
|
|
43
|
+
updateData?: (data: T) => Promise<void>
|
|
41
44
|
}>>
|
|
42
45
|
}
|
|
43
46
|
|
|
@@ -66,6 +69,66 @@ type BullMQOtelModule = { BullMQOtel: new (tracerName: string) => object }
|
|
|
66
69
|
|
|
67
70
|
const REMOVABLE_JOB_STATES = ['waiting', 'delayed', 'prioritized', 'paused', 'waiting-children']
|
|
68
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The failures BullMQ records when it gives up on a job *before* handing it to the processor.
|
|
74
|
+
*
|
|
75
|
+
* Both are written as a `defa` (deferred failure) marker on the job, after which the next worker
|
|
76
|
+
* short-circuits in `Worker.processJob` via `getUnrecoverableErrorMessage` and fails the job without
|
|
77
|
+
* calling the handler. The first comes from the stalled-job script once a job's cumulative stall
|
|
78
|
+
* count passes `maxStalledCount`; the second from `maxStartedAttempts`.
|
|
79
|
+
*
|
|
80
|
+
* Matching the reason is what tells "the queue abandoned this" from "the handler ran and threw", and
|
|
81
|
+
* it is deliberately stateless: the alternative — tracking which jobs this process has entered — can
|
|
82
|
+
* only answer "did the handler run *here*", which is the wrong question the moment more than one
|
|
83
|
+
* worker is running. `bullmq-abandoned-reasons.test.ts` asserts these strings still exist in the
|
|
84
|
+
* installed BullMQ, so an upgrade that renames them fails loudly instead of silently disabling the
|
|
85
|
+
* callback.
|
|
86
|
+
*/
|
|
87
|
+
export const ABANDONED_JOB_REASONS = [
|
|
88
|
+
'job stalled more than allowable limit',
|
|
89
|
+
'job started more than allowable limit',
|
|
90
|
+
] as const
|
|
91
|
+
|
|
92
|
+
function isAbandonedJobReason(message: string): boolean {
|
|
93
|
+
return (ABANDONED_JOB_REASONS as readonly string[]).includes(message)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Metadata key written onto the stored job once `onJobAbandoned` has completed for it.
|
|
98
|
+
*
|
|
99
|
+
* BullMQ's failed set is the durable record of abandoned jobs (`removeOnFail` keeps them), so it
|
|
100
|
+
* doubles as the dead-letter queue for reports: the sweep re-delivers any abandoned job that does not
|
|
101
|
+
* carry this marker. The marker — not `job.remove()` — is the acknowledgement, so the failed job
|
|
102
|
+
* itself survives for diagnosis.
|
|
103
|
+
*/
|
|
104
|
+
const ABANDON_REPORT_ACK_KEY = 'abandonReportedAt'
|
|
105
|
+
// NOTE for anyone adding a retry action: the marker lives inside the job's own payload envelope, so a
|
|
106
|
+
// job retried from admin tooling carries it into its next life and a second abandonment of that job
|
|
107
|
+
// would never be reported. A retry path must clear `metadata.abandonReportedAt` when it re-enqueues.
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* How often a worker re-sweeps the failed set for unacknowledged abandoned jobs.
|
|
111
|
+
*
|
|
112
|
+
* Override with `QUEUE_ABANDONED_SWEEP_INTERVAL_MS` to trade recovery latency against Redis chatter.
|
|
113
|
+
*/
|
|
114
|
+
export const ABANDONED_JOB_SWEEP_INTERVAL_MS = 5 * 60 * 1000
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* How long `close()` waits for in-flight reports before giving up on them.
|
|
118
|
+
*
|
|
119
|
+
* Bounded on purpose: the hook reaches a database, and a shutdown during an infrastructure incident
|
|
120
|
+
* is exactly when that write can hang rather than fail. An unbounded wait would turn a graceful
|
|
121
|
+
* shutdown into a SIGKILL and skip the telemetry flush that follows it. Abandoning the wait is safe
|
|
122
|
+
* because delivery is at-least-once — an unacknowledged report is re-delivered by the next worker's
|
|
123
|
+
* start-up sweep, the same path that covers a process which died mid-report.
|
|
124
|
+
*/
|
|
125
|
+
export const ABANDONED_JOB_DRAIN_TIMEOUT_MS = 5000
|
|
126
|
+
|
|
127
|
+
function resolveSweepIntervalMs(): number {
|
|
128
|
+
const configured = Number.parseInt(process.env.QUEUE_ABANDONED_SWEEP_INTERVAL_MS ?? '', 10)
|
|
129
|
+
return Number.isFinite(configured) && configured > 0 ? configured : ABANDONED_JOB_SWEEP_INTERVAL_MS
|
|
130
|
+
}
|
|
131
|
+
|
|
69
132
|
function payloadMatchesScope(payload: unknown, scope: QueueJobScope): boolean {
|
|
70
133
|
if (!payload || typeof payload !== 'object') return false
|
|
71
134
|
const scopedPayload = payload as { tenantId?: unknown; organizationId?: unknown; jobType?: unknown }
|
|
@@ -129,11 +192,105 @@ export function createAsyncQueue<T = unknown>(
|
|
|
129
192
|
const attempts = options?.attempts ?? 3
|
|
130
193
|
const lockDuration = options?.lockDuration
|
|
131
194
|
const maxStalledCount = options?.maxStalledCount
|
|
195
|
+
const onJobAbandoned = options?.onJobAbandoned
|
|
132
196
|
const logger = packageLogger.child({ queue: name })
|
|
133
197
|
|
|
134
198
|
let bullQueue: BullQueueInterface<QueuedJob<T>> | null = null
|
|
135
199
|
let bullWorker: BullWorkerInterface | null = null
|
|
136
200
|
let bullmqModule: BullMQModule | null = null
|
|
201
|
+
let abandonedSweepTimer: ReturnType<typeof setInterval> | null = null
|
|
202
|
+
let closing = false
|
|
203
|
+
|
|
204
|
+
// In-flight `onJobAbandoned` calls. Detached from the caller that started them (the 'failed'
|
|
205
|
+
// listener or the sweep), so `close()` drains them rather than letting a deploy truncate a repair
|
|
206
|
+
// mid-write. The id set stops the two callers from double-reporting a job inside one process.
|
|
207
|
+
const pendingAbandonedReports = new Set<Promise<void>>()
|
|
208
|
+
const inFlightAbandonedJobIds = new Set<string>()
|
|
209
|
+
|
|
210
|
+
type AbandonedJobRecord = {
|
|
211
|
+
id?: string
|
|
212
|
+
data?: QueuedJob<T>
|
|
213
|
+
updateData?: (data: QueuedJob<T>) => Promise<void>
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// The acknowledgement that makes delivery at-least-once: written only after the callback returns,
|
|
217
|
+
// so a callback that threw or a process that died mid-report leaves the job unmarked and a later
|
|
218
|
+
// sweep retries it. Requires the driver to expose `updateData`; without it the report simply stays
|
|
219
|
+
// unacknowledged and repeats, which the idempotency contract permits.
|
|
220
|
+
async function acknowledgeAbandonedReport(job: AbandonedJobRecord): Promise<void> {
|
|
221
|
+
if (!job.data || typeof job.updateData !== 'function') return
|
|
222
|
+
await job.updateData({
|
|
223
|
+
...job.data,
|
|
224
|
+
metadata: { ...(job.data.metadata ?? {}), [ABANDON_REPORT_ACK_KEY]: new Date().toISOString() },
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function reportAbandonedJob(job: AbandonedJobRecord, reason: string): Promise<void> | null {
|
|
229
|
+
if (!onJobAbandoned) return null
|
|
230
|
+
const payload = job.data
|
|
231
|
+
if (!payload) return null
|
|
232
|
+
if (payload.metadata && payload.metadata[ABANDON_REPORT_ACK_KEY]) return null
|
|
233
|
+
if (inFlightAbandonedJobIds.has(payload.id)) return null
|
|
234
|
+
inFlightAbandonedJobIds.add(payload.id)
|
|
235
|
+
|
|
236
|
+
const jobId = job.id ?? null
|
|
237
|
+
logger.warn('Job abandoned by the queue without running its handler', { jobId, reason })
|
|
238
|
+
const report = (async () => {
|
|
239
|
+
try {
|
|
240
|
+
await onJobAbandoned(payload, { jobId, reason })
|
|
241
|
+
} catch (hookError) {
|
|
242
|
+
logger.error('onJobAbandoned handler threw; the report stays unacknowledged and the sweep will retry it', {
|
|
243
|
+
jobId,
|
|
244
|
+
err: hookError as Error,
|
|
245
|
+
})
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
await acknowledgeAbandonedReport(job)
|
|
250
|
+
} catch (ackError) {
|
|
251
|
+
logger.error('Failed to acknowledge an abandoned-job report; the sweep may repeat it', {
|
|
252
|
+
jobId,
|
|
253
|
+
err: ackError as Error,
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
})().finally(() => {
|
|
257
|
+
inFlightAbandonedJobIds.delete(payload.id)
|
|
258
|
+
})
|
|
259
|
+
pendingAbandonedReports.add(report)
|
|
260
|
+
void report.then(() => pendingAbandonedReports.delete(report))
|
|
261
|
+
return report
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// The failed set is this strategy's dead-letter queue for abandoned jobs. Enumerating it on worker
|
|
265
|
+
// start and on an interval, and re-delivering anything unacknowledged, is what upgrades the
|
|
266
|
+
// 'failed'-listener fast path from at-most-once to at-least-once: a report lost to a crash is
|
|
267
|
+
// simply still unmarked when the next sweep looks. Residual loss: `removeOnFail` caps the set, so
|
|
268
|
+
// a job evicted before any sweep sees it is gone for good.
|
|
269
|
+
async function sweepAbandonedJobs(): Promise<void> {
|
|
270
|
+
if (!onJobAbandoned || closing) return
|
|
271
|
+
try {
|
|
272
|
+
const queue = await getQueue()
|
|
273
|
+
const failedJobs = await queue.getJobs(['failed'], 0, -1)
|
|
274
|
+
// Re-checked after the awaits: a sweep already past its guard when `close()` ran would
|
|
275
|
+
// otherwise start a report the drain has stopped waiting for. The next start-up sweep
|
|
276
|
+
// re-delivers it, so stopping here loses nothing.
|
|
277
|
+
if (closing) return
|
|
278
|
+
for (const failedJob of failedJobs) {
|
|
279
|
+
// Re-checked every iteration for the same reason: a long fan-out must not outlive the drain.
|
|
280
|
+
if (closing) return
|
|
281
|
+
const reason = failedJob.failedReason ?? ''
|
|
282
|
+
if (!isAbandonedJobReason(reason)) continue
|
|
283
|
+
// Awaited one at a time. Each report opens a request container and writes to the database, and
|
|
284
|
+
// the worst case for this loop is the first worker start after the feature ships, on a
|
|
285
|
+
// deployment that has been accumulating abandoned jobs — the largest backlog, on the process
|
|
286
|
+
// least able to absorb it. The sweep is a recovery path with no latency requirement (five
|
|
287
|
+
// minutes late is its normal mode), so pacing costs nothing worth having.
|
|
288
|
+
await reportAbandonedJob(failedJob, reason)
|
|
289
|
+
}
|
|
290
|
+
} catch (sweepError) {
|
|
291
|
+
logger.error('Abandoned-job sweep failed', { err: sweepError as Error })
|
|
292
|
+
}
|
|
293
|
+
}
|
|
137
294
|
// Resolved once: a BullMQOtel instance (delegate async tracing to BullMQ) or
|
|
138
295
|
// undefined (use our own metadata._trace carrier instead). Memoized as the
|
|
139
296
|
// in-flight promise so concurrent first-time callers share one resolution.
|
|
@@ -255,9 +412,24 @@ export function createAsyncQueue<T = unknown>(
|
|
|
255
412
|
})
|
|
256
413
|
|
|
257
414
|
bullWorker.on('failed', (job, err) => {
|
|
258
|
-
const
|
|
415
|
+
const failedJob = job as AbandonedJobRecord | undefined
|
|
259
416
|
const error = err as Error
|
|
260
|
-
logger.error('Job failed', { jobId:
|
|
417
|
+
logger.error('Job failed', { jobId: failedJob?.id, err: error })
|
|
418
|
+
|
|
419
|
+
if (!onJobAbandoned) return
|
|
420
|
+
// Any other reason means a handler ran and threw. That failure is the handler's own and it has
|
|
421
|
+
// already had its chance to record it.
|
|
422
|
+
if (!isAbandonedJobReason(error?.message ?? '')) return
|
|
423
|
+
// No payload means the queue could not give us the job at all. There is nothing to hand the
|
|
424
|
+
// callback and nothing it could repair, so reporting could only ever be a false alarm — the
|
|
425
|
+
// 'Job failed' line above still records it.
|
|
426
|
+
if (!failedJob?.data) return
|
|
427
|
+
|
|
428
|
+
// The fast path: report the moment the abandonment is observed. `reportAbandonedJob` runs the
|
|
429
|
+
// callback detached with its own try/catch — this is an EventEmitter, where an unhandled
|
|
430
|
+
// rejection is fatal to the process — and acknowledges the job only afterwards, so a report
|
|
431
|
+
// lost here is retried by the sweep.
|
|
432
|
+
reportAbandonedJob(failedJob, error.message)
|
|
261
433
|
})
|
|
262
434
|
|
|
263
435
|
// A stalled job is redelivered under the same id while the previous worker
|
|
@@ -275,6 +447,17 @@ export function createAsyncQueue<T = unknown>(
|
|
|
275
447
|
logger.error('Worker error', { err: error })
|
|
276
448
|
})
|
|
277
449
|
|
|
450
|
+
if (onJobAbandoned) {
|
|
451
|
+
// Sweep immediately so reports lost to a previous process's crash are re-delivered as soon as
|
|
452
|
+
// a worker is back, then keep re-sweeping for anything the fast path loses while running.
|
|
453
|
+
// The timer is unref'd so it never holds the process open.
|
|
454
|
+
void sweepAbandonedJobs()
|
|
455
|
+
abandonedSweepTimer = setInterval(() => {
|
|
456
|
+
void sweepAbandonedJobs()
|
|
457
|
+
}, resolveSweepIntervalMs())
|
|
458
|
+
;(abandonedSweepTimer as unknown as { unref?: () => void }).unref?.()
|
|
459
|
+
}
|
|
460
|
+
|
|
278
461
|
logger.info('Worker started', { concurrency })
|
|
279
462
|
|
|
280
463
|
// For async strategy, return a sentinel result indicating worker mode
|
|
@@ -311,10 +494,31 @@ export function createAsyncQueue<T = unknown>(
|
|
|
311
494
|
}
|
|
312
495
|
|
|
313
496
|
async function close(): Promise<void> {
|
|
497
|
+
closing = true
|
|
498
|
+
if (abandonedSweepTimer) {
|
|
499
|
+
clearInterval(abandonedSweepTimer)
|
|
500
|
+
abandonedSweepTimer = null
|
|
501
|
+
}
|
|
314
502
|
if (bullWorker) {
|
|
315
503
|
await bullWorker.close()
|
|
316
504
|
bullWorker = null
|
|
317
505
|
}
|
|
506
|
+
// Drain any abandonment report still in flight, so a deploy-time shutdown cannot cut off the very
|
|
507
|
+
// repair the callback exists to perform. Bounded: the hook writes to a database, and a shutdown
|
|
508
|
+
// during an incident is exactly when that write can hang instead of failing. Giving up costs
|
|
509
|
+
// nothing permanent — an unacknowledged report is re-delivered by the next start-up sweep.
|
|
510
|
+
if (pendingAbandonedReports.size) {
|
|
511
|
+
const drained = Promise.all([...pendingAbandonedReports]).then(() => true)
|
|
512
|
+
const expired = new Promise<boolean>((resolve) => {
|
|
513
|
+
const timer = setTimeout(() => resolve(false), ABANDONED_JOB_DRAIN_TIMEOUT_MS)
|
|
514
|
+
;(timer as unknown as { unref?: () => void }).unref?.()
|
|
515
|
+
})
|
|
516
|
+
if (!(await Promise.race([drained, expired]))) {
|
|
517
|
+
logger.warn('Abandoned-job reports still in flight at shutdown; the sweep will retry them', {
|
|
518
|
+
pending: pendingAbandonedReports.size,
|
|
519
|
+
})
|
|
520
|
+
}
|
|
521
|
+
}
|
|
318
522
|
if (bullQueue) {
|
|
319
523
|
await bullQueue.close()
|
|
320
524
|
bullQueue = null
|
package/src/types.ts
CHANGED
|
@@ -99,6 +99,43 @@ export type AsyncQueueOptions = {
|
|
|
99
99
|
lockDuration?: number
|
|
100
100
|
/** Number of stalled-job recoveries BullMQ permits before failing a job. Defaults to 1. */
|
|
101
101
|
maxStalledCount?: number
|
|
102
|
+
/**
|
|
103
|
+
* Called when the queue permanently gives up on a job WITHOUT its handler having run.
|
|
104
|
+
*
|
|
105
|
+
* Why this cannot be left to the handler: a queue may destroy a job before ever calling the
|
|
106
|
+
* processor — the usual cause is a job redelivered once too often, past `maxStalledCount`. The
|
|
107
|
+
* handler then never runs, never throws and never learns, and any state it created on enqueue (a
|
|
108
|
+
* run row, a progress record) is orphaned in whatever "in progress" state it was left in, with
|
|
109
|
+
* nothing to correct it.
|
|
110
|
+
*
|
|
111
|
+
* Contract:
|
|
112
|
+
* - Fires **only** for a job the queue abandoned before its handler ran. It is deliberately not a
|
|
113
|
+
* general "job failed" hook: a handler that ran and threw owns its own outcome and has already
|
|
114
|
+
* had the chance to record it. Reporting both would double-report and would hide the difference
|
|
115
|
+
* between "the work failed" and "the work never started".
|
|
116
|
+
* - **At-least-once, where the backend allows it.** The strategy reports as soon as it observes
|
|
117
|
+
* the abandonment, and also sweeps the backend's dead-job records — on worker start, then
|
|
118
|
+
* periodically — for reports that were never acknowledged. A report is acknowledged only after
|
|
119
|
+
* this callback returns, so a callback that threw or a process that died mid-report is retried
|
|
120
|
+
* by a later sweep. The callback MUST therefore be idempotent, and MUST tolerate a payload it
|
|
121
|
+
* does not recognise. Residual loss is still possible when the backend evicts its dead-job
|
|
122
|
+
* records before any sweep sees them; state that absolutely must never be stranded should also
|
|
123
|
+
* have a staleness check of its own at the domain level.
|
|
124
|
+
* - Not every strategy can implement it — the local strategy runs handlers in-process, so no queue
|
|
125
|
+
* outlives a handler to abandon its job.
|
|
126
|
+
*
|
|
127
|
+
* Backend specifics (which failures count as abandonment, and how they are detected) belong to the
|
|
128
|
+
* strategy; see `strategies/async.ts`.
|
|
129
|
+
*/
|
|
130
|
+
onJobAbandoned?: (payload: unknown, info: AbandonedJobInfo) => void | Promise<void>
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** What the queue can say about a job it gave up on. */
|
|
134
|
+
export type AbandonedJobInfo = {
|
|
135
|
+
/** The queue driver's own job id, or null when it did not supply one. */
|
|
136
|
+
jobId: string | null
|
|
137
|
+
/** The failure the queue recorded, e.g. 'job stalled more than allowable limit'. */
|
|
138
|
+
reason: string
|
|
102
139
|
}
|
|
103
140
|
|
|
104
141
|
/**
|
|
@@ -262,6 +299,14 @@ export type WorkerMeta = {
|
|
|
262
299
|
lockDuration?: number
|
|
263
300
|
/** Number of stalled-job recoveries BullMQ permits before failing a job. */
|
|
264
301
|
maxStalledCount?: number
|
|
302
|
+
/**
|
|
303
|
+
* Called when the queue abandons one of this worker's jobs without running the handler.
|
|
304
|
+
*
|
|
305
|
+
* Declared here rather than on the producing queue because the worker is the side that must hear
|
|
306
|
+
* it: the event being reported is a worker restart, and the process that restarts never
|
|
307
|
+
* constructs the enqueueing queue. See `AsyncQueueOptions.onJobAbandoned` for the contract.
|
|
308
|
+
*/
|
|
309
|
+
onJobAbandoned?: AsyncQueueOptions['onJobAbandoned']
|
|
265
310
|
}
|
|
266
311
|
|
|
267
312
|
/**
|
|
@@ -281,4 +326,6 @@ export type WorkerDescriptor<T = unknown> = {
|
|
|
281
326
|
lockDuration?: number
|
|
282
327
|
/** Number of stalled-job recoveries BullMQ permits before failing a job. */
|
|
283
328
|
maxStalledCount?: number
|
|
329
|
+
/** Called when the queue abandons one of this worker's jobs without running the handler. */
|
|
330
|
+
onJobAbandoned?: AsyncQueueOptions['onJobAbandoned']
|
|
284
331
|
}
|
package/src/worker/runner.ts
CHANGED
|
@@ -24,6 +24,8 @@ export type WorkerRunnerOptions<T = unknown> = {
|
|
|
24
24
|
lockDuration?: number
|
|
25
25
|
/** Number of stalled-job recoveries BullMQ permits before failing a job. */
|
|
26
26
|
maxStalledCount?: number
|
|
27
|
+
/** Called when the queue abandons a job without running the handler. */
|
|
28
|
+
onJobAbandoned?: AsyncQueueOptions['onJobAbandoned']
|
|
27
29
|
/** Whether to set up graceful shutdown handlers */
|
|
28
30
|
gracefulShutdown?: boolean
|
|
29
31
|
/** If true, don't block - return immediately after starting processing (for multi-queue mode) */
|
|
@@ -151,6 +153,7 @@ export async function runWorker<T = unknown>(
|
|
|
151
153
|
concurrency = 1,
|
|
152
154
|
lockDuration,
|
|
153
155
|
maxStalledCount,
|
|
156
|
+
onJobAbandoned,
|
|
154
157
|
gracefulShutdown = true,
|
|
155
158
|
background = false,
|
|
156
159
|
strategy: strategyOption,
|
|
@@ -176,6 +179,7 @@ export async function runWorker<T = unknown>(
|
|
|
176
179
|
concurrency,
|
|
177
180
|
lockDuration,
|
|
178
181
|
maxStalledCount,
|
|
182
|
+
onJobAbandoned,
|
|
179
183
|
})
|
|
180
184
|
|
|
181
185
|
// Set up graceful shutdown
|