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'
312import { 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'
417import { createLogger } from '../factories/logger-factory'
18+ import { IRunnable } from '../@types/base'
519import { shutdownMetricsTelemetry } from '../telemetry/metrics'
620
721const 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+
933export 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