Skip to content

Commit fd7f56a

Browse files
feat(dvm): dispatch pending DVM jobs to worker processes (#734)
* feat(dvm): dispatch pending DVM jobs to worker processes Signed-off-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com> * fix(cli): guard worker stdin writes against closed streams Signed-off-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com> * fix(dvm): prevent dispatch starvation and shutdown job leaks Signed-off-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com> * fix(dvm): set default job timeout to 1 minute Signed-off-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com> --------- Signed-off-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
1 parent f0aab15 commit fd7f56a

10 files changed

Lines changed: 922 additions & 29 deletions

File tree

.changeset/dvm-job-dispatch.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"nostream": minor
3+
---
4+
5+
feat(dvm): dispatch pending DVM jobs to worker processes and publish kind 6000-6999 results back

src/@types/repositories.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,5 +81,5 @@ export interface IDvmJobRepository {
8181
updateStatus(
8282
job: Pick<DvmJob, 'id' | 'status'> & Partial<Pick<DvmJob, 'resultEventId' | 'error'>>,
8383
): Promise<DvmJob | undefined>
84-
findPendingJobs(limit?: number): Promise<DvmJob[]>
84+
findPendingJobs(limit?: number, kinds?: number[]): Promise<DvmJob[]>
8585
}

src/app/dvm-orchestrator-worker.ts

Lines changed: 275 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,48 @@
1-
import { path } from 'ramda'
2-
import { IRunnable } from '../@types/base'
1+
import { andThen, otherwise, path, pipe } from 'ramda'
2+
3+
import {
4+
broadcastEvent,
5+
getPublicKey,
6+
getRelayPrivateKey,
7+
identifyEvent,
8+
signEvent,
9+
toNostrEvent,
10+
} from '../utils/event'
11+
import { DvmJob, DvmJobStatus } from '../@types/dvm'
312
import { DvmWorker, Settings } from '../@types/settings'
13+
import { Event, UnidentifiedEvent } from '../@types/event'
14+
import { EventKinds, EventTags } from '../constants/base'
15+
import { IDvmJobRepository, IEventRepository } from '../@types/repositories'
16+
import { spawnWorkerProcess, WorkerProcessHandle, WorkerSpawnErrorReason } from '../cli/utils/process'
417
import { createLogger } from '../factories/logger-factory'
18+
import { IRunnable } from '../@types/base'
519
import { shutdownMetricsTelemetry } from '../telemetry/metrics'
620

721
const logger = createLogger('dvm-orchestrator-worker')
822

23+
const POLL_INTERVAL_MS = 2000
24+
const DEFAULT_JOB_TIMEOUT_MS = 60000
25+
const DISPATCH_BATCH_SIZE = 10
26+
27+
type PendingJob = {
28+
job: DvmJob
29+
requestEvent: Event
30+
timer: NodeJS.Timeout
31+
}
32+
933
export class DvmOrchestratorWorker implements IRunnable {
1034
private config: DvmWorker | undefined
35+
private interval: NodeJS.Timeout | undefined
36+
private isRunning = false
37+
private closing = false
38+
private worker: WorkerProcessHandle | undefined
39+
private readonly pending = new Map<string, PendingJob>()
1140

1241
public constructor(
1342
private readonly process: NodeJS.Process,
1443
private readonly settings: () => Settings,
44+
private readonly dvmJobRepository: IDvmJobRepository,
45+
private readonly eventRepository: IEventRepository,
1546
) {
1647
this.process
1748
.on('SIGINT', this.onExit.bind(this))
@@ -33,6 +64,221 @@ export class DvmOrchestratorWorker implements IRunnable {
3364
}
3465

3566
logger.info('dvm-orchestrator worker started for command: %s', this.config.command)
67+
68+
this.ensureWorkerProcess()
69+
70+
this.interval = setInterval(async () => {
71+
if (this.isRunning) {
72+
logger('skipping scheduled dispatch because previous run is still in progress')
73+
return
74+
}
75+
76+
this.isRunning = true
77+
try {
78+
await this.dispatchNextJob()
79+
} catch (error) {
80+
this.onError(error as Error)
81+
} finally {
82+
this.isRunning = false
83+
}
84+
}, POLL_INTERVAL_MS)
85+
}
86+
87+
// One long-lived worker process per configured dvm.workers[i], multiplexing
88+
// every in-flight job over a single newline-delimited-JSON stdin/stdout pipe
89+
// (per issue #731 — process.ts's one-shot spawn helpers buffer all output
90+
// into a single string, which doesn't work once more than one job can be
91+
// in flight against the same worker at a time).
92+
private ensureWorkerProcess(): void {
93+
if (this.worker || !this.config || this.closing) {
94+
return
95+
}
96+
97+
const worker = spawnWorkerProcess(this.config.command, this.config.args ?? [])
98+
worker.onMessage((message) => this.handleWorkerMessage(message))
99+
worker.onExit((code, signal) => this.handleWorkerExit(code, signal))
100+
worker.onSpawnError((reason) => this.handleWorkerSpawnError(reason))
101+
this.worker = worker
102+
}
103+
104+
private async dispatchNextJob(): Promise<void> {
105+
if (!this.config) {
106+
return
107+
}
108+
109+
// Lazily respawn if the worker process died since the last tick.
110+
this.ensureWorkerProcess()
111+
if (!this.worker) {
112+
return
113+
}
114+
115+
// findPendingJobs() returns both SUBMITTED and PICKED_UP jobs (oldest first);
116+
// assignWorker() only succeeds against SUBMITTED ones. Walk a batch instead of
117+
// just the oldest candidate so an already-picked-up job at the head can't
118+
// starve out later SUBMITTED jobs behind it.
119+
const candidates = await this.dvmJobRepository.findPendingJobs(DISPATCH_BATCH_SIZE, this.config.kinds)
120+
121+
let job: DvmJob | undefined
122+
for (const candidate of candidates) {
123+
if (await this.dvmJobRepository.assignWorker(candidate.id, this.workerIndex())) {
124+
job = candidate
125+
break
126+
}
127+
}
128+
129+
if (!job) {
130+
// Lost the race on every candidate in this batch — try again next tick.
131+
return
132+
}
133+
134+
logger('picked up job %s (kind %d)', job.id, job.kind)
135+
136+
const [row] = await this.eventRepository.findByFilters([{ ids: [job.id] }])
137+
if (!row) {
138+
await this.failJob(job.id, 'source event not found')
139+
return
140+
}
141+
142+
const requestEvent = toNostrEvent(row)
143+
const timeoutMs = this.config.timeoutMs ?? DEFAULT_JOB_TIMEOUT_MS
144+
const timer = setTimeout(() => this.handleJobTimeout(job.id), timeoutMs)
145+
this.pending.set(job.id, { job, requestEvent, timer })
146+
147+
const sent = this.worker.send({
148+
id: requestEvent.id,
149+
kind: requestEvent.kind,
150+
pubkey: requestEvent.pubkey,
151+
tags: requestEvent.tags,
152+
content: requestEvent.content,
153+
})
154+
155+
if (!sent) {
156+
clearTimeout(timer)
157+
this.pending.delete(job.id)
158+
await this.failJob(job.id, 'unable to send job to worker process')
159+
}
160+
}
161+
162+
// The worker replies on the same pipe with { id: <job id>, content: <string> },
163+
// correlated back to the job that's still pending — replies for jobs we no
164+
// longer track (already timed out, or from a previous worker instance) are
165+
// dropped rather than treated as an error.
166+
private handleWorkerMessage(message: unknown): void {
167+
const jobId = (message as { id?: unknown } | null)?.id
168+
if (typeof jobId !== 'string') {
169+
logger.error('ignoring malformed worker message (missing id): %o', message)
170+
return
171+
}
172+
173+
const pending = this.pending.get(jobId)
174+
if (!pending) {
175+
return
176+
}
177+
178+
clearTimeout(pending.timer)
179+
this.pending.delete(jobId)
180+
181+
const rawContent = (message as { content?: unknown }).content
182+
const content = typeof rawContent === 'string' ? rawContent : JSON.stringify(message)
183+
184+
void this.publishResult(pending.job, pending.requestEvent, content)
185+
}
186+
187+
private handleJobTimeout(jobId: string): void {
188+
const pending = this.pending.get(jobId)
189+
if (!pending) {
190+
return
191+
}
192+
193+
this.pending.delete(jobId)
194+
void this.failJob(jobId, 'worker timeout', true)
195+
196+
// Soft Node-level guard per issue #731: kill the worker process on timeout.
197+
// Any other jobs still in flight on it fail as a side effect of the exit
198+
// handler below; ensureWorkerProcess() respawns on the next dispatch tick.
199+
this.worker?.kill()
200+
}
201+
202+
private handleWorkerExit(code: number | null, signal: NodeJS.Signals | null): void {
203+
logger.error('dvm worker process exited (code=%s, signal=%s)', code, signal)
204+
this.worker = undefined
205+
206+
for (const [jobId, pending] of this.pending) {
207+
clearTimeout(pending.timer)
208+
void this.failJob(jobId, `worker exited (code=${code ?? 'null'}, signal=${signal ?? 'null'})`)
209+
}
210+
this.pending.clear()
211+
}
212+
213+
private handleWorkerSpawnError(reason: WorkerSpawnErrorReason): void {
214+
logger.error('unable to spawn dvm worker process: %s', reason)
215+
this.worker = undefined
216+
}
217+
218+
private workerIndex(): number {
219+
return Number(this.process.env.DVM_WORKER_INDEX)
220+
}
221+
222+
private async failJob(jobId: string, error: string, timedOut = false): Promise<void> {
223+
logger.error('job %s failed: %s', jobId, error)
224+
try {
225+
await this.dvmJobRepository.updateStatus({
226+
id: jobId,
227+
status: timedOut ? DvmJobStatus.TIMED_OUT : DvmJobStatus.FAILED,
228+
error,
229+
})
230+
} catch (updateError) {
231+
logger.error('unable to update failed job %s: %o', jobId, updateError)
232+
}
233+
}
234+
235+
// Relay-authored, matching the same self-signing pattern used for invoice
236+
// notifications (payments-service.ts) and NIP-89 authoring: the settings
237+
// schema has no per-worker signing key, so the relay's own derived keypair
238+
// is the DVM identity for every locally-bridged worker.
239+
private async publishResult(job: DvmJob, requestEvent: Event, content: string): Promise<void> {
240+
const currentSettings = this.settings()
241+
const relayPrivkey = getRelayPrivateKey(currentSettings.info.relay_url)
242+
const relayPubkey = getPublicKey(relayPrivkey)
243+
244+
const unsignedEvent: UnidentifiedEvent = {
245+
pubkey: relayPubkey,
246+
kind: (requestEvent.kind + 1000) as EventKinds,
247+
created_at: Math.floor(Date.now() / 1000),
248+
content,
249+
tags: [
250+
[EventTags.Event, requestEvent.id],
251+
[EventTags.Pubkey, requestEvent.pubkey],
252+
],
253+
}
254+
255+
const persistEvent = async (event: Event) => {
256+
await this.eventRepository.create(event)
257+
return event
258+
}
259+
260+
const markCompleted = async (event: Event) => {
261+
await this.dvmJobRepository.updateStatus({
262+
id: job.id,
263+
status: DvmJobStatus.COMPLETED,
264+
resultEventId: event.id,
265+
})
266+
return event
267+
}
268+
269+
const logPublishError = async (error: Error) => {
270+
logger.error('unable to publish result for job %s: %o', job.id, error)
271+
await this.failJob(job.id, `unable to publish result: ${error.message}`)
272+
}
273+
274+
await pipe(
275+
identifyEvent,
276+
andThen(signEvent(relayPrivkey)),
277+
andThen(persistEvent),
278+
andThen(broadcastEvent),
279+
andThen(markCompleted),
280+
otherwise(logPublishError),
281+
)(unsignedEvent)
36282
}
37283

38284
private onError(error: Error) {
@@ -51,8 +297,33 @@ export class DvmOrchestratorWorker implements IRunnable {
51297

52298
public close(callback?: () => void) {
53299
logger('closing')
54-
if (typeof callback === 'function') {
55-
callback()
300+
this.closing = true
301+
if (this.interval) {
302+
clearInterval(this.interval)
303+
}
304+
305+
const invokeCallback = () => {
306+
if (typeof callback === 'function') {
307+
callback()
308+
}
309+
}
310+
311+
if (this.pending.size === 0) {
312+
this.worker?.kill()
313+
invokeCallback()
314+
return
56315
}
316+
317+
// Fail every still-in-flight job before killing the worker: clearing
318+
// `this.pending` first (as before) meant handleWorkerExit() found nothing
319+
// to fail, leaving those jobs stuck at PICKED_UP in the DB forever.
320+
const failures = Array.from(this.pending.entries()).map(([jobId, pending]) => {
321+
clearTimeout(pending.timer)
322+
return this.failJob(jobId, 'worker shutting down')
323+
})
324+
this.pending.clear()
325+
this.worker?.kill()
326+
327+
void Promise.all(failures).finally(invokeCallback)
57328
}
58329
}

0 commit comments

Comments
 (0)