@open-mercato/queue 0.6.7-develop.6862.1.c11a64ce0a → 0.6.7
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/.turbo/turbo-build.log +1 -1
- package/dist/factory.js +1 -4
- package/dist/factory.js.map +2 -2
- package/dist/pending-probe.js +2 -4
- package/dist/pending-probe.js.map +2 -2
- package/dist/strategies/async.js +10 -50
- package/dist/strategies/async.js.map +2 -2
- package/dist/strategies/local.js +8 -15
- package/dist/strategies/local.js.map +2 -2
- package/dist/worker/runner.js +1 -18
- package/dist/worker/runner.js.map +2 -2
- package/jest.config.cjs +0 -1
- package/package.json +6 -12
- package/src/__tests__/async.strategy.test.ts +5 -62
- package/src/__tests__/factory.test.ts +1 -5
- package/src/__tests__/pending-probe.test.ts +1 -40
- package/src/factory.ts +1 -4
- package/src/pending-probe.ts +2 -4
- package/src/strategies/async.ts +13 -83
- package/src/strategies/local.ts +10 -24
- package/src/types.ts +0 -16
- package/src/worker/runner.ts +0 -32
- package/dist/tracing.js +0 -27
- package/dist/tracing.js.map +0 -7
- package/src/__tests__/async.telemetry.test.ts +0 -123
- package/src/__tests__/tracing.test.ts +0 -123
- package/src/__tests__/worker-shutdown-telemetry.test.ts +0 -80
- package/src/tracing.ts +0 -58
package/src/worker/runner.ts
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
import { createQueue } from '../factory'
|
|
2
2
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
3
|
-
import {
|
|
4
|
-
getTelemetryRuntime,
|
|
5
|
-
isTelemetryBackendEnabled,
|
|
6
|
-
} from '@open-mercato/shared/lib/telemetry/runtime'
|
|
7
3
|
import type { Queue, JobHandler, AsyncQueueOptions, QueueStrategyType } from '../types'
|
|
8
4
|
|
|
9
5
|
const logger = createLogger('queue').child({ component: 'worker' })
|
|
@@ -20,10 +16,6 @@ export type WorkerRunnerOptions<T = unknown> = {
|
|
|
20
16
|
connection?: AsyncQueueOptions['connection']
|
|
21
17
|
/** Number of concurrent jobs to process */
|
|
22
18
|
concurrency?: number
|
|
23
|
-
/** How long a job lock is held before the job counts as stalled, in ms. */
|
|
24
|
-
lockDuration?: number
|
|
25
|
-
/** Number of stalled-job recoveries BullMQ permits before failing a job. */
|
|
26
|
-
maxStalledCount?: number
|
|
27
19
|
/** Whether to set up graceful shutdown handlers */
|
|
28
20
|
gracefulShutdown?: boolean
|
|
29
21
|
/** If true, don't block - return immediately after starting processing (for multi-queue mode) */
|
|
@@ -75,17 +67,6 @@ function registerShutdownHandlers(): void {
|
|
|
75
67
|
unregisterShutdownHandlers(sigtermHandler, sigintHandler)
|
|
76
68
|
shutdownInProgress = false
|
|
77
69
|
|
|
78
|
-
// Flush buffered spans/logs before the process dies. A worker never returns
|
|
79
|
-
// from run(), so bin.ts's post-run shutdownTelemetry() is unreachable on this
|
|
80
|
-
// path — without this, the BatchSpanProcessor's ~5s tail is dropped on every
|
|
81
|
-
// restart/redeploy. Idempotent and a no-op when telemetry is off; a flush
|
|
82
|
-
// failure must not turn a clean shutdown into a failed one.
|
|
83
|
-
try {
|
|
84
|
-
await getTelemetryRuntime()?.shutdown()
|
|
85
|
-
} catch (error) {
|
|
86
|
-
logger.error('Error flushing telemetry during shutdown', { err: error })
|
|
87
|
-
}
|
|
88
|
-
|
|
89
70
|
if (!hasError) {
|
|
90
71
|
logger.info('Worker closed successfully')
|
|
91
72
|
}
|
|
@@ -149,22 +130,11 @@ export async function runWorker<T = unknown>(
|
|
|
149
130
|
handler,
|
|
150
131
|
connection,
|
|
151
132
|
concurrency = 1,
|
|
152
|
-
lockDuration,
|
|
153
|
-
maxStalledCount,
|
|
154
133
|
gracefulShutdown = true,
|
|
155
134
|
background = false,
|
|
156
135
|
strategy: strategyOption,
|
|
157
136
|
} = options
|
|
158
137
|
|
|
159
|
-
// Worker processes don't run Next's instrumentation hook, so initialize
|
|
160
|
-
// telemetry here — this is the single bootstrap every standalone worker passes
|
|
161
|
-
// through. Import the telemetry package only for an explicit enabled backend;
|
|
162
|
-
// with the default/unset backend the worker never evaluates the package.
|
|
163
|
-
if (!getTelemetryRuntime() && isTelemetryBackendEnabled()) {
|
|
164
|
-
const { initTelemetry } = await import('@open-mercato/telemetry')
|
|
165
|
-
await initTelemetry()
|
|
166
|
-
}
|
|
167
|
-
|
|
168
138
|
// Determine queue strategy from option, env var, or default to 'local'
|
|
169
139
|
const strategy: QueueStrategyType = strategyOption
|
|
170
140
|
?? (process.env.QUEUE_STRATEGY === 'async' ? 'async' : 'local')
|
|
@@ -174,8 +144,6 @@ export async function runWorker<T = unknown>(
|
|
|
174
144
|
const queue = createQueue<T>(queueName, strategy, {
|
|
175
145
|
connection,
|
|
176
146
|
concurrency,
|
|
177
|
-
lockDuration,
|
|
178
|
-
maxStalledCount,
|
|
179
147
|
})
|
|
180
148
|
|
|
181
149
|
// Set up graceful shutdown
|
package/dist/tracing.js
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import { getTelemetryRuntime } from "@open-mercato/shared/lib/telemetry/runtime";
|
|
2
|
-
const TRACE_META_KEY = "_trace";
|
|
3
|
-
function attachTraceMetadata(metadata) {
|
|
4
|
-
const carrier = getTelemetryRuntime()?.captureTraceContext() ?? {};
|
|
5
|
-
if (Object.keys(carrier).length === 0) return metadata;
|
|
6
|
-
return { ...metadata ?? {}, [TRACE_META_KEY]: carrier };
|
|
7
|
-
}
|
|
8
|
-
function runJobInTrace(queueName, metadata, fn) {
|
|
9
|
-
const runtime = getTelemetryRuntime();
|
|
10
|
-
if (!runtime) return fn();
|
|
11
|
-
return runtime.continueTrace(
|
|
12
|
-
readTraceCarrier(metadata),
|
|
13
|
-
`queue.${queueName}`,
|
|
14
|
-
fn,
|
|
15
|
-
{ kind: "consumer" }
|
|
16
|
-
);
|
|
17
|
-
}
|
|
18
|
-
function readTraceCarrier(metadata) {
|
|
19
|
-
const raw = metadata?.[TRACE_META_KEY];
|
|
20
|
-
if (!raw || typeof raw !== "object") return void 0;
|
|
21
|
-
return raw;
|
|
22
|
-
}
|
|
23
|
-
export {
|
|
24
|
-
attachTraceMetadata,
|
|
25
|
-
runJobInTrace
|
|
26
|
-
};
|
|
27
|
-
//# sourceMappingURL=tracing.js.map
|
package/dist/tracing.js.map
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 3,
|
|
3
|
-
"sources": ["../src/tracing.ts"],
|
|
4
|
-
"sourcesContent": ["import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'\n\n/**\n * Distributed-trace propagation across the enqueue \u2192 worker boundary.\n *\n * The W3C trace carrier rides on the job's `metadata._trace` (a first-class\n * metadata channel, not the user payload). Both halves are automatic \u2014 the\n * strategies call `attachTraceMetadata` at enqueue and `runJobInTrace` at\n * dispatch \u2014 so a worker joins the enqueuing request's trace with no per-worker\n * code. Everything here is a cheap no-op when telemetry is off.\n *\n * This also covers anything that rides the queue: persistent event subscribers\n * (the event bus enqueues) and outbound webhook delivery (queued) become part of\n * the originating request's trace for free.\n */\nconst TRACE_META_KEY = '_trace'\n\n/**\n * Attach the active trace context to a job's metadata. Returns `metadata`\n * unchanged when telemetry is off (no active span \u2192 empty carrier), so jobs stay\n * clean unless tracing is active.\n */\nexport function attachTraceMetadata(\n metadata: Record<string, unknown> | undefined,\n): Record<string, unknown> | undefined {\n const carrier = getTelemetryRuntime()?.captureTraceContext() ?? {}\n if (Object.keys(carrier).length === 0) return metadata\n return { ...(metadata ?? {}), [TRACE_META_KEY]: carrier }\n}\n\n/**\n * Run a job handler inside a span (`queue.<queueName>`) that continues the\n * producer's trace from the carrier on `metadata`. With no carrier (or telemetry\n * off) it runs `fn` under a fresh root span \u2014 and a no-op when off. The span\n * ends when `fn` settles (sync or async).\n */\nexport function runJobInTrace<T>(\n queueName: string,\n metadata: Record<string, unknown> | undefined,\n fn: () => T,\n): T {\n const runtime = getTelemetryRuntime()\n if (!runtime) return fn()\n return runtime.continueTrace(\n readTraceCarrier(metadata),\n `queue.${queueName}`,\n fn,\n { kind: 'consumer' },\n )\n}\n\nfunction readTraceCarrier(\n metadata: Record<string, unknown> | undefined,\n): Record<string, string> | undefined {\n const raw = metadata?.[TRACE_META_KEY]\n if (!raw || typeof raw !== 'object') return undefined\n return raw as Record<string, string>\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,2BAA2B;AAepC,MAAM,iBAAiB;AAOhB,SAAS,oBACd,UACqC;AACrC,QAAM,UAAU,oBAAoB,GAAG,oBAAoB,KAAK,CAAC;AACjE,MAAI,OAAO,KAAK,OAAO,EAAE,WAAW,EAAG,QAAO;AAC9C,SAAO,EAAE,GAAI,YAAY,CAAC,GAAI,CAAC,cAAc,GAAG,QAAQ;AAC1D;AAQO,SAAS,cACd,WACA,UACA,IACG;AACH,QAAM,UAAU,oBAAoB;AACpC,MAAI,CAAC,QAAS,QAAO,GAAG;AACxB,SAAO,QAAQ;AAAA,IACb,iBAAiB,QAAQ;AAAA,IACzB,SAAS,SAAS;AAAA,IAClB;AAAA,IACA,EAAE,MAAM,WAAW;AAAA,EACrB;AACF;AAEA,SAAS,iBACP,UACoC;AACpC,QAAM,MAAM,WAAW,cAAc;AACrC,MAAI,CAAC,OAAO,OAAO,QAAQ,SAAU,QAAO;AAC5C,SAAO;AACT;",
|
|
6
|
-
"names": []
|
|
7
|
-
}
|
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
// An OTLP backend must be active for the async strategy to delegate tracing to
|
|
2
|
-
// bullmq-otel. Set before any module reads the (memoized) telemetry env, and
|
|
3
|
-
// restored afterwards — process.env is shared across test files in the same
|
|
4
|
-
// jest worker, and a leaked 'otlp' backend breaks sibling telemetry tests.
|
|
5
|
-
const originalTelemetryBackend = process.env.TELEMETRY_BACKEND
|
|
6
|
-
process.env.TELEMETRY_BACKEND = 'otlp'
|
|
7
|
-
|
|
8
|
-
afterAll(() => {
|
|
9
|
-
if (originalTelemetryBackend === undefined) delete process.env.TELEMETRY_BACKEND
|
|
10
|
-
else process.env.TELEMETRY_BACKEND = originalTelemetryBackend
|
|
11
|
-
})
|
|
12
|
-
|
|
13
|
-
import { createQueue } from '../factory'
|
|
14
|
-
import { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'
|
|
15
|
-
import {
|
|
16
|
-
registerTelemetryRuntime,
|
|
17
|
-
resetTelemetryRuntime,
|
|
18
|
-
} from '@open-mercato/shared/lib/telemetry/runtime'
|
|
19
|
-
|
|
20
|
-
const queueCtor = jest.fn()
|
|
21
|
-
const workerCtor = jest.fn()
|
|
22
|
-
const queueAdd = jest.fn(async () => ({ id: 'bull-job-id' }))
|
|
23
|
-
|
|
24
|
-
jest.mock('@open-mercato/shared/lib/redis/connection', () => ({
|
|
25
|
-
getRedisUrlOrThrow: jest.fn(),
|
|
26
|
-
parseRedisUrl: jest.requireActual('@open-mercato/shared/lib/redis/connection').parseRedisUrl,
|
|
27
|
-
}))
|
|
28
|
-
|
|
29
|
-
jest.mock('bullmq', () => {
|
|
30
|
-
class MockQueue<T> {
|
|
31
|
-
constructor(name: string, opts: unknown) {
|
|
32
|
-
queueCtor(name, opts)
|
|
33
|
-
}
|
|
34
|
-
add = queueAdd as unknown as (name: string, data: T, opts?: unknown) => Promise<{ id?: string }>
|
|
35
|
-
close = jest.fn(async () => {})
|
|
36
|
-
obliterate = jest.fn(async () => {})
|
|
37
|
-
getJobCounts = jest.fn(async () => ({ waiting: 0, active: 0, completed: 0, failed: 0 }))
|
|
38
|
-
}
|
|
39
|
-
class MockWorker<T> {
|
|
40
|
-
constructor(
|
|
41
|
-
name: string,
|
|
42
|
-
_processor: (job: { id?: string; data: T; attemptsMade: number }) => Promise<void>,
|
|
43
|
-
opts: unknown,
|
|
44
|
-
) {
|
|
45
|
-
workerCtor(name, _processor, opts)
|
|
46
|
-
}
|
|
47
|
-
on = jest.fn()
|
|
48
|
-
close = jest.fn(async () => {})
|
|
49
|
-
}
|
|
50
|
-
return { Queue: MockQueue, Worker: MockWorker }
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
class MockBullMQOtel {
|
|
54
|
-
constructor(public readonly tracerName: string) {}
|
|
55
|
-
}
|
|
56
|
-
jest.mock('bullmq-otel', () => ({ BullMQOtel: MockBullMQOtel }))
|
|
57
|
-
|
|
58
|
-
describe('Queue - async strategy telemetry wiring', () => {
|
|
59
|
-
beforeEach(() => {
|
|
60
|
-
jest.clearAllMocks()
|
|
61
|
-
;(getRedisUrlOrThrow as jest.MockedFunction<typeof getRedisUrlOrThrow>).mockReturnValue(
|
|
62
|
-
'rediss://default:secret@example.com:6380/1',
|
|
63
|
-
)
|
|
64
|
-
registerTelemetryRuntime({
|
|
65
|
-
canUseGlobalTracePropagation: () => true,
|
|
66
|
-
captureTraceContext: () => ({}),
|
|
67
|
-
continueTrace: (_carrier, _name, fn) => fn(),
|
|
68
|
-
recordHttpDuration: () => {},
|
|
69
|
-
reportError: () => {},
|
|
70
|
-
shutdown: async () => {},
|
|
71
|
-
})
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
afterEach(() => {
|
|
75
|
-
resetTelemetryRuntime()
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
it('wires bullmq-otel into BOTH the queue and worker when they resolve concurrently', async () => {
|
|
79
|
-
const queue = createQueue<{ value: number }>('trace-queue', 'async', { concurrency: 3 })
|
|
80
|
-
|
|
81
|
-
// Resolve enqueue (Queue) and process (Worker) concurrently: both hit the
|
|
82
|
-
// shared telemetry resolution at once. The memoized in-flight promise must
|
|
83
|
-
// hand both the SAME bullmq-otel instance — never one with, one without.
|
|
84
|
-
await Promise.all([queue.enqueue({ value: 1 }), queue.process(async () => {})])
|
|
85
|
-
|
|
86
|
-
const queueOpts = queueCtor.mock.calls[0]?.[1] as { telemetry?: unknown }
|
|
87
|
-
const workerOpts = workerCtor.mock.calls[0]?.[2] as { telemetry?: unknown }
|
|
88
|
-
expect(queueOpts.telemetry).toBeInstanceOf(MockBullMQOtel)
|
|
89
|
-
expect(workerOpts.telemetry).toBeInstanceOf(MockBullMQOtel)
|
|
90
|
-
expect(queueOpts.telemetry).toBe(workerOpts.telemetry)
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
it('omits the metadata._trace carrier when bullmq-otel owns propagation', async () => {
|
|
94
|
-
const queue = createQueue<{ value: number }>('trace-queue', 'async')
|
|
95
|
-
|
|
96
|
-
await queue.enqueue({ value: 42 })
|
|
97
|
-
|
|
98
|
-
const jobData = queueAdd.mock.calls[0]?.[1] as Record<string, unknown>
|
|
99
|
-
expect(jobData).not.toHaveProperty('metadata')
|
|
100
|
-
})
|
|
101
|
-
|
|
102
|
-
it('uses the dedicated carrier when global propagation is not explicitly trusted', async () => {
|
|
103
|
-
resetTelemetryRuntime()
|
|
104
|
-
registerTelemetryRuntime({
|
|
105
|
-
canUseGlobalTracePropagation: () => false,
|
|
106
|
-
captureTraceContext: () => ({ traceparent: 'secure-carrier' }),
|
|
107
|
-
continueTrace: (_carrier, _name, fn) => fn(),
|
|
108
|
-
recordHttpDuration: () => {},
|
|
109
|
-
reportError: () => {},
|
|
110
|
-
shutdown: async () => {},
|
|
111
|
-
})
|
|
112
|
-
const queue = createQueue<{ value: number }>('secure-trace-queue', 'async')
|
|
113
|
-
|
|
114
|
-
await queue.enqueue({ value: 7 })
|
|
115
|
-
|
|
116
|
-
const queueOpts = queueCtor.mock.calls[0]?.[1] as { telemetry?: unknown }
|
|
117
|
-
const jobData = queueAdd.mock.calls[0]?.[1] as {
|
|
118
|
-
metadata?: { _trace?: { traceparent?: string } }
|
|
119
|
-
}
|
|
120
|
-
expect(queueOpts.telemetry).toBeUndefined()
|
|
121
|
-
expect(jobData.metadata?._trace?.traceparent).toBe('secure-carrier')
|
|
122
|
-
})
|
|
123
|
-
})
|
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs'
|
|
2
|
-
import os from 'node:os'
|
|
3
|
-
import path from 'node:path'
|
|
4
|
-
import { attachTraceMetadata, runJobInTrace } from '../tracing'
|
|
5
|
-
import { createLocalQueue } from '../strategies/local'
|
|
6
|
-
import { registerProvider, initTelemetry, shutdownTelemetry } from '@open-mercato/telemetry'
|
|
7
|
-
import type { LogRecord, MetricPoint, Span, SpanOptions, TelemetryProvider, TraceCarrier } from '@open-mercato/telemetry'
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Verifies the enqueue → worker trace handoff: the active context is captured
|
|
11
|
-
* onto job metadata at enqueue, and the worker continues that trace at dispatch.
|
|
12
|
-
* Uses a recording provider under the explicitly enabled console seam rather
|
|
13
|
-
* than the real OTLP SDK.
|
|
14
|
-
*/
|
|
15
|
-
const spanNames: string[] = []
|
|
16
|
-
const remoteCarriers: TraceCarrier[] = []
|
|
17
|
-
|
|
18
|
-
function noopSpan(): Span {
|
|
19
|
-
return { setAttribute() {}, setAttributes() {}, recordException() {}, setStatus() {}, end() {} }
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const recordingProvider: TelemetryProvider = {
|
|
23
|
-
name: 'console',
|
|
24
|
-
supports: ['traces'],
|
|
25
|
-
async start() {},
|
|
26
|
-
async shutdown() {},
|
|
27
|
-
runInSpan<T>(name: string, _o: SpanOptions, fn: (s: Span) => T): T {
|
|
28
|
-
spanNames.push(name)
|
|
29
|
-
return fn(noopSpan())
|
|
30
|
-
},
|
|
31
|
-
activeSpan: () => undefined,
|
|
32
|
-
activeTraceContext: () => undefined,
|
|
33
|
-
inject: (carrier) => {
|
|
34
|
-
carrier.traceparent = 'test-traceparent'
|
|
35
|
-
},
|
|
36
|
-
runInRemoteSpan<T>(carrier: TraceCarrier, name: string, _o: SpanOptions, fn: (s: Span) => T): T {
|
|
37
|
-
remoteCarriers.push(carrier)
|
|
38
|
-
spanNames.push(name)
|
|
39
|
-
return fn(noopSpan())
|
|
40
|
-
},
|
|
41
|
-
emitLog: (_r: LogRecord) => {},
|
|
42
|
-
recordMetric: (_p: MetricPoint) => {},
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
beforeAll(async () => {
|
|
46
|
-
process.env.TELEMETRY_BACKEND = 'console'
|
|
47
|
-
registerProvider(recordingProvider)
|
|
48
|
-
await initTelemetry()
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
afterAll(async () => {
|
|
52
|
-
await shutdownTelemetry()
|
|
53
|
-
delete process.env.TELEMETRY_BACKEND
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
describe('queue trace propagation', () => {
|
|
57
|
-
it('attaches the active trace carrier to job metadata at enqueue', () => {
|
|
58
|
-
const metadata = attachTraceMetadata(undefined)
|
|
59
|
-
expect(metadata).toEqual({ _trace: { traceparent: 'test-traceparent' } })
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
it('preserves existing metadata while attaching the trace carrier', () => {
|
|
63
|
-
const metadata = attachTraceMetadata({ foo: 'bar' })
|
|
64
|
-
expect(metadata).toMatchObject({ foo: 'bar', _trace: { traceparent: 'test-traceparent' } })
|
|
65
|
-
})
|
|
66
|
-
|
|
67
|
-
it('continues the producer trace from job metadata at dispatch', async () => {
|
|
68
|
-
const result = await runJobInTrace('orders-process', { _trace: { traceparent: 'tp-123' } }, () =>
|
|
69
|
-
Promise.resolve('done'),
|
|
70
|
-
)
|
|
71
|
-
expect(result).toBe('done')
|
|
72
|
-
expect(remoteCarriers).toContainEqual({ traceparent: 'tp-123' })
|
|
73
|
-
expect(spanNames).toContain('queue.orders-process')
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
it('runs jobs without a carrier under a fresh span (no crash)', async () => {
|
|
77
|
-
const result = await runJobInTrace('orders-process', undefined, () => Promise.resolve(42))
|
|
78
|
-
expect(result).toBe(42)
|
|
79
|
-
})
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* End-to-end through the REAL local strategy (file write + read + dispatch),
|
|
84
|
-
* proving the headline acceptance criterion: a queued job continues the
|
|
85
|
-
* enqueuing request's trace. This exercises the actual enqueue/dispatch wiring,
|
|
86
|
-
* not just the helpers above.
|
|
87
|
-
*/
|
|
88
|
-
describe('queue trace propagation (real local strategy)', () => {
|
|
89
|
-
let baseDir: string
|
|
90
|
-
|
|
91
|
-
beforeEach(() => {
|
|
92
|
-
baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'om-queue-trace-'))
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
afterEach(() => {
|
|
96
|
-
fs.rmSync(baseDir, { recursive: true, force: true })
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
it('persists the trace carrier on enqueue and continues it on dispatch', async () => {
|
|
100
|
-
const queue = createLocalQueue<{ orderId: string }>('orders-process', { baseDir })
|
|
101
|
-
|
|
102
|
-
await queue.enqueue({ orderId: 'o-1' })
|
|
103
|
-
|
|
104
|
-
// The carrier is written to the job's metadata — NOT the user payload.
|
|
105
|
-
const stored = JSON.parse(
|
|
106
|
-
fs.readFileSync(path.join(baseDir, 'orders-process', 'queue.json'), 'utf8'),
|
|
107
|
-
) as Array<{ payload: unknown; metadata?: Record<string, unknown> }>
|
|
108
|
-
expect(stored[0].metadata).toEqual({ _trace: { traceparent: 'test-traceparent' } })
|
|
109
|
-
expect(stored[0].payload).toEqual({ orderId: 'o-1' })
|
|
110
|
-
|
|
111
|
-
let handlerRan = false
|
|
112
|
-
await queue.process((job) => {
|
|
113
|
-
handlerRan = true
|
|
114
|
-
// The handler still sees only its payload; the carrier is invisible to it.
|
|
115
|
-
expect(job.payload).toEqual({ orderId: 'o-1' })
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
expect(handlerRan).toBe(true)
|
|
119
|
-
// The worker continued the producer's trace under a `queue.<name>` span.
|
|
120
|
-
expect(remoteCarriers).toContainEqual({ traceparent: 'test-traceparent' })
|
|
121
|
-
expect(spanNames).toContain('queue.orders-process')
|
|
122
|
-
})
|
|
123
|
-
})
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import path from 'node:path'
|
|
2
|
-
import fs from 'node:fs'
|
|
3
|
-
import os from 'node:os'
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Regression: the worker's graceful shutdown used to close the queues
|
|
7
|
-
* and call process.exit() without flushing telemetry. A worker never returns
|
|
8
|
-
* from run(), so the CLI's post-run shutdownTelemetry() is unreachable — the
|
|
9
|
-
* BatchSpanProcessor's buffered tail (~5s of spans/logs) was dropped on every
|
|
10
|
-
* restart/redeploy. The shutdown handler must flush BEFORE exiting.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import {
|
|
14
|
-
registerTelemetryRuntime,
|
|
15
|
-
resetTelemetryRuntime,
|
|
16
|
-
type TelemetryRuntime,
|
|
17
|
-
} from '@open-mercato/shared/lib/telemetry/runtime'
|
|
18
|
-
|
|
19
|
-
import { runWorker } from '../worker/runner'
|
|
20
|
-
|
|
21
|
-
const mockCallOrder: string[] = []
|
|
22
|
-
const runtime: TelemetryRuntime = {
|
|
23
|
-
canUseGlobalTracePropagation: () => false,
|
|
24
|
-
captureTraceContext: () => ({}),
|
|
25
|
-
continueTrace: (_carrier, _name, fn) => fn(),
|
|
26
|
-
recordHttpDuration: () => {},
|
|
27
|
-
reportError: () => {},
|
|
28
|
-
shutdown: jest.fn(async () => {
|
|
29
|
-
mockCallOrder.push('flush')
|
|
30
|
-
}),
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
describe('worker shutdown flushes telemetry', () => {
|
|
34
|
-
let tmpDir: string
|
|
35
|
-
let cwd: string
|
|
36
|
-
|
|
37
|
-
beforeEach(() => {
|
|
38
|
-
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'worker-shutdown-'))
|
|
39
|
-
cwd = process.cwd()
|
|
40
|
-
process.chdir(tmpDir)
|
|
41
|
-
mockCallOrder.length = 0
|
|
42
|
-
delete process.env.TELEMETRY_BACKEND
|
|
43
|
-
registerTelemetryRuntime(runtime)
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
afterEach(() => {
|
|
47
|
-
process.chdir(cwd)
|
|
48
|
-
resetTelemetryRuntime()
|
|
49
|
-
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
it('SIGTERM flushes telemetry before process.exit', async () => {
|
|
53
|
-
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
|
54
|
-
mockCallOrder.push(`exit:${code ?? 0}`)
|
|
55
|
-
return undefined as never
|
|
56
|
-
}) as never)
|
|
57
|
-
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {})
|
|
58
|
-
|
|
59
|
-
try {
|
|
60
|
-
await runWorker({
|
|
61
|
-
queueName: 'shutdown-flush-test',
|
|
62
|
-
handler: async () => {},
|
|
63
|
-
strategy: 'local',
|
|
64
|
-
background: true,
|
|
65
|
-
gracefulShutdown: true,
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
process.emit('SIGTERM')
|
|
69
|
-
// The shutdown handler is async (close → flush → exit); let it settle.
|
|
70
|
-
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
71
|
-
|
|
72
|
-
expect(mockCallOrder).toContain('flush')
|
|
73
|
-
expect(mockCallOrder).toContain('exit:0')
|
|
74
|
-
expect(mockCallOrder.indexOf('flush')).toBeLessThan(mockCallOrder.indexOf('exit:0'))
|
|
75
|
-
} finally {
|
|
76
|
-
exitSpy.mockRestore()
|
|
77
|
-
logSpy.mockRestore()
|
|
78
|
-
}
|
|
79
|
-
})
|
|
80
|
-
})
|
package/src/tracing.ts
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Distributed-trace propagation across the enqueue → worker boundary.
|
|
5
|
-
*
|
|
6
|
-
* The W3C trace carrier rides on the job's `metadata._trace` (a first-class
|
|
7
|
-
* metadata channel, not the user payload). Both halves are automatic — the
|
|
8
|
-
* strategies call `attachTraceMetadata` at enqueue and `runJobInTrace` at
|
|
9
|
-
* dispatch — so a worker joins the enqueuing request's trace with no per-worker
|
|
10
|
-
* code. Everything here is a cheap no-op when telemetry is off.
|
|
11
|
-
*
|
|
12
|
-
* This also covers anything that rides the queue: persistent event subscribers
|
|
13
|
-
* (the event bus enqueues) and outbound webhook delivery (queued) become part of
|
|
14
|
-
* the originating request's trace for free.
|
|
15
|
-
*/
|
|
16
|
-
const TRACE_META_KEY = '_trace'
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Attach the active trace context to a job's metadata. Returns `metadata`
|
|
20
|
-
* unchanged when telemetry is off (no active span → empty carrier), so jobs stay
|
|
21
|
-
* clean unless tracing is active.
|
|
22
|
-
*/
|
|
23
|
-
export function attachTraceMetadata(
|
|
24
|
-
metadata: Record<string, unknown> | undefined,
|
|
25
|
-
): Record<string, unknown> | undefined {
|
|
26
|
-
const carrier = getTelemetryRuntime()?.captureTraceContext() ?? {}
|
|
27
|
-
if (Object.keys(carrier).length === 0) return metadata
|
|
28
|
-
return { ...(metadata ?? {}), [TRACE_META_KEY]: carrier }
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Run a job handler inside a span (`queue.<queueName>`) that continues the
|
|
33
|
-
* producer's trace from the carrier on `metadata`. With no carrier (or telemetry
|
|
34
|
-
* off) it runs `fn` under a fresh root span — and a no-op when off. The span
|
|
35
|
-
* ends when `fn` settles (sync or async).
|
|
36
|
-
*/
|
|
37
|
-
export function runJobInTrace<T>(
|
|
38
|
-
queueName: string,
|
|
39
|
-
metadata: Record<string, unknown> | undefined,
|
|
40
|
-
fn: () => T,
|
|
41
|
-
): T {
|
|
42
|
-
const runtime = getTelemetryRuntime()
|
|
43
|
-
if (!runtime) return fn()
|
|
44
|
-
return runtime.continueTrace(
|
|
45
|
-
readTraceCarrier(metadata),
|
|
46
|
-
`queue.${queueName}`,
|
|
47
|
-
fn,
|
|
48
|
-
{ kind: 'consumer' },
|
|
49
|
-
)
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function readTraceCarrier(
|
|
53
|
-
metadata: Record<string, unknown> | undefined,
|
|
54
|
-
): Record<string, string> | undefined {
|
|
55
|
-
const raw = metadata?.[TRACE_META_KEY]
|
|
56
|
-
if (!raw || typeof raw !== 'object') return undefined
|
|
57
|
-
return raw as Record<string, string>
|
|
58
|
-
}
|