Skip to content

Commit d4e2f8b

Browse files
committed
Use exitStatusAwaiter for Grid too
1 parent 9ba89cf commit d4e2f8b

9 files changed

Lines changed: 188 additions & 95 deletions

File tree

modules/nextflow/src/main/groovy/nextflow/executor/ExecutorConfig.groovy

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ class ExecutorConfig implements ConfigScope {
193193
}
194194

195195
Duration getExitReadTimeout(String execName) {
196-
getExecConfigProp(execName, 'exitReadTimeout', null) as Duration
196+
getExecConfigProp(execName, 'exitReadTimeout', exitReadTimeout) as Duration
197197
}
198198

199199
Duration getMonitorDumpInterval(String execName) {

modules/nextflow/src/main/groovy/nextflow/executor/ExitStatusAwaiter.groovy

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ class ExitStatusAwaiter {
4747
this.timeoutMillis = timeout.toMillis()
4848
}
4949

50+
/** Clears accumulated missing/empty timestamps so the next read() starts fresh. */
51+
void reset() {
52+
missingSinceMillis = 0
53+
emptySinceMillis = 0
54+
}
55+
5056
/**
5157
* @param exitFile The path to the task's {@code .exitcode} file
5258
* @return

modules/nextflow/src/main/groovy/nextflow/executor/GridTaskHandler.groovy

Lines changed: 37 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import dev.failsafe.function.CheckedSupplier
3232
import groovy.transform.CompileStatic
3333
import groovy.transform.Memoized
3434
import groovy.util.logging.Slf4j
35+
import nextflow.Global
3536
import nextflow.exception.ProcessException
3637
import nextflow.exception.ProcessFailedException
3738
import nextflow.exception.ProcessNonZeroExitStatusException
@@ -83,7 +84,9 @@ class GridTaskHandler extends TaskHandler implements FusionAwareTask {
8384
BatchCleanup batch
8485

8586
@TestOnly
86-
protected GridTaskHandler() {}
87+
protected GridTaskHandler() {
88+
this.exitAwaiter = new ExitStatusAwaiter(Duration.of('270sec'))
89+
}
8790

8891
GridTaskHandler( TaskRun task, AbstractGridExecutor executor ) {
8992
super(task)
@@ -96,6 +99,7 @@ class GridTaskHandler extends TaskHandler implements FusionAwareTask {
9699
this.wrapperFile = task.workDir.resolve(TaskRun.CMD_RUN)
97100
final duration = executor.config.getExitReadTimeout(executor.name)
98101
this.exitStatusReadTimeoutMillis = duration.toMillis()
102+
this.exitAwaiter = new ExitStatusAwaiter(duration)
99103
this.queue = task.config?.queue
100104
this.sanityCheckInterval = duration
101105
}
@@ -300,9 +304,7 @@ class GridTaskHandler extends TaskHandler implements FusionAwareTask {
300304

301305
private long exitTimestampMillis0 = System.currentTimeMillis()
302306

303-
private long exitTimestampMillis1
304-
305-
private long exitTimestampMillis2
307+
private ExitStatusAwaiter exitAwaiter
306308

307309
/**
308310
* When a process terminated save its exit status into the file defined by #exitFile
@@ -312,17 +314,6 @@ class GridTaskHandler extends TaskHandler implements FusionAwareTask {
312314
*/
313315
protected Integer readExitStatus() {
314316

315-
String workDirList = null
316-
if( exitTimestampMillis1 && FileHelper.workDirIsSharedFS ) {
317-
/*
318-
* When the file is in a NFS folder in order to avoid false negative
319-
* list the content of the parent path to force refresh of NFS metadata
320-
* http://stackoverflow.com/questions/3833127/alternative-to-file-exists-in-java
321-
* http://superuser.com/questions/422061/how-to-determine-whether-a-directory-is-on-an-nfs-mounted-drive
322-
*/
323-
workDirList = FileHelper.listDirectory(task.workDir)
324-
}
325-
326317
/*
327318
* when the file does not exist return null, to force the monitor to continue to wait
328319
*/
@@ -334,86 +325,52 @@ class GridTaskHandler extends TaskHandler implements FusionAwareTask {
334325
else
335326
log.trace "JobId `$jobId` exit file: ${exitFile.toUriString()} - lastModified: ${exitAttrs?.lastModifiedTime()} - size: ${exitAttrs?.size()}"
336327
}
337-
// -- fetch the job status before return a result
338-
final active = executor.checkActiveStatus(jobId, queue)
339328

340-
// --
329+
// -- grace period: don't query the scheduler until enough time has elapsed since job submission
341330
def elapsed = System.currentTimeMillis() - startedMillis
342-
if( elapsed < executor.queueInterval.toMillis() * 2.5 ) {
331+
if( executor.queueInterval && elapsed < executor.queueInterval.toMillis() * 2.5 ) {
332+
exitAwaiter.reset()
343333
return null
344334
}
345335

346-
// -- if the job is active, this means that it is still running and thus the exit file cannot exist
347-
// returns null to continue to wait
348-
if( active ) {
349-
// make sure to reset exit time if the task is active -- see #927
350-
exitTimestampMillis1 = 0
336+
// -- fetch the job status before returning a result
337+
// -- if the job is active, it is still running and the exit file absence is expected
338+
if( executor.checkActiveStatus(jobId, queue) ) {
339+
// make sure to reset the awaiter if the task is still active -- see #927
340+
exitAwaiter.reset()
351341
return null
352342
}
353343

354-
// -- if the job is not active, something is going wrong
355-
// * before returning an error code make (due to NFS latency) the file status could be in a incoherent state
356-
if( !exitTimestampMillis1 ) {
357-
log.trace "Exit file does not exist for and the job is not running for task: $this -- Try to wait before kill it"
358-
exitTimestampMillis1 = System.currentTimeMillis()
344+
// -- job is not active and exit file is missing: force NFS metadata refresh and delegate
345+
// timeout tracking to the shared awaiter (mirrors ExitStatusAwaiter two-phase logic)
346+
String workDirList = null
347+
if( Global.session && FileHelper.workDirIsSharedFS ) {
348+
/*
349+
* When the file is in a NFS folder, list the parent path to force refresh of NFS metadata
350+
* before the awaiter re-reads the attributes, avoiding false-negative cache hits.
351+
* http://stackoverflow.com/questions/3833127/alternative-to-file-exists-in-java
352+
* http://superuser.com/questions/422061/how-to-determine-whether-a-directory-is-on-an-nfs-mounted-drive
353+
*/
354+
workDirList = FileHelper.listDirectory(task.workDir)
359355
}
360356

361-
def delta = System.currentTimeMillis() - exitTimestampMillis1
362-
if( delta < exitStatusReadTimeoutMillis ) {
363-
return null
357+
final result = exitAwaiter.read(exitFile)
358+
if( result == Integer.MAX_VALUE ) {
359+
def errMessage = []
360+
errMessage << "Failed to get exit status for process ${this} -- exitStatusReadTimeoutMillis: $exitStatusReadTimeoutMillis"
361+
errMessage << "Current queue status:"
362+
errMessage << executor.dumpQueueStatus()?.indent('> ')
363+
errMessage << "Content of workDir: ${task.workDir}"
364+
errMessage << workDirList?.indent('> ')
365+
log.debug errMessage.join('\n')
364366
}
365-
366-
def errMessage = []
367-
errMessage << "Failed to get exit status for process ${this} -- exitStatusReadTimeoutMillis: $exitStatusReadTimeoutMillis; delta: $delta"
368-
// -- dump current queue stats
369-
errMessage << "Current queue status:"
370-
errMessage << executor.dumpQueueStatus()?.indent('> ')
371-
// -- dump directory listing
372-
errMessage << "Content of workDir: ${task.workDir}"
373-
errMessage << workDirList?.indent('> ')
374-
log.debug errMessage.join('\n')
375-
376-
return Integer.MAX_VALUE
367+
return result
377368
}
378369

379370
/*
380-
* read the exit file, it should contain the executed process exit status
371+
* file is present: delegate content parsing and empty-file retry to the shared awaiter
381372
*/
382-
def status = exitFile.text?.trim()
383-
if( status ) {
384-
try {
385-
return status.toInteger()
386-
}
387-
catch( Exception e ) {
388-
log.warn "Unable to parse process exit file: ${exitFile.toUriString()} -- bad value: '$status'"
389-
return Integer.MAX_VALUE
390-
}
391-
}
392-
393-
else {
394-
/*
395-
* Since working with NFS it may happen that the file exists BUT it is empty due to network latencies,
396-
* before returning an invalid exit code, wait some seconds.
397-
*
398-
* More in detail:
399-
* 1) the very first time that arrive here initialize the 'exitTimestampMillis' to the current timestamp
400-
* 2) when the file is empty but less than 5 seconds are spent from the first check, return null
401-
* this will force the monitor to continue to wait for job termination
402-
* 3) if more than 5 seconds are spent, and the file is empty return MAX_INT as an invalid exit status
403-
*
404-
*/
405-
if( !exitTimestampMillis2 ) {
406-
log.debug "File is returning empty content: $this -- Try to wait a while... and pray."
407-
exitTimestampMillis2 = System.currentTimeMillis()
408-
}
409-
410-
def delta = System.currentTimeMillis() - exitTimestampMillis2
411-
if( delta < exitStatusReadTimeoutMillis ) {
412-
return null
413-
}
414-
log.warn "Unable to read command status from: ${exitFile.toUriString()} after $delta ms"
415-
return -1
416-
}
373+
return exitAwaiter.read(exitFile)
417374
}
418375

419376
@Override

modules/nextflow/src/test/groovy/nextflow/executor/GridExecutorTest.groovy

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import java.nio.file.Files
2020
import nextflow.processor.TaskConfig
2121
import nextflow.processor.TaskRun
2222
import nextflow.processor.TaskStatus
23+
import nextflow.util.Duration
2324
import spock.lang.Specification
2425
/**
2526
*
@@ -121,25 +122,24 @@ class GridExecutorTest extends Specification {
121122
def executor = Mock(AbstractGridExecutor) {
122123
getConfig() >> new ExecutorConfig([:])
123124
}
124-
executor.checkActiveStatus(_) >> { return true }
125125

126126
when:
127127
def handler = new GridTaskHandler(task, executor)
128128
handler.status = TaskStatus.RUNNING
129129
handler.exitFile.text = ''
130-
handler.exitStatusReadTimeoutMillis = 1000
130+
// inject a fast-timeout awaiter so the test doesn't need to wait 270 seconds
131+
handler.@exitAwaiter = new ExitStatusAwaiter(Duration.of('1sec'))
131132

132133
then:
133-
// the first try return false
134+
// the first try return false (within timeout window)
134135
!handler.checkIfCompleted()
135-
// wait more the timeout defined by the property 'exitStatusReadTimeoutMillis'
136+
// wait past the 1-second timeout
136137
sleep 1_500
137138
// now 'checkIfCompleted' returns true
138139
handler.checkIfCompleted()
139140
handler.status == TaskStatus.COMPLETED
140-
// but the 'exitStatus' is-1 to signal the '.exitcode' file was empty
141-
// and allow the task to be retried
142-
handler.task.exitStatus == -1
141+
// exit status is Integer.MAX_VALUE when the .exitcode file was persistently empty
142+
handler.task.exitStatus == Integer.MAX_VALUE
143143

144144
}
145145

modules/nextflow/src/test/groovy/nextflow/executor/GridTaskHandlerTest.groovy

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package nextflow.executor
1818

19+
import java.nio.file.Files
1920
import java.nio.file.Path
2021
import java.nio.file.Paths
2122

@@ -27,6 +28,7 @@ import nextflow.processor.TaskBean
2728
import nextflow.processor.TaskConfig
2829
import nextflow.processor.TaskProcessor
2930
import nextflow.processor.TaskRun
31+
import nextflow.util.Duration
3032
import spock.lang.Specification
3133
import test.TestHelper
3234
/**
@@ -134,6 +136,128 @@ class GridTaskHandlerTest extends Specification {
134136
'''.stripIndent(true)
135137
}
136138

139+
def 'should defer readExitStatus while job is active (awaiter is reset each cycle)'() {
140+
given:
141+
def workDir = Files.createTempDirectory('nf-grid-test')
142+
def task = Mock(TaskRun) {
143+
getName() >> 'foo'
144+
getWorkDir() >> workDir
145+
getConfig() >> Mock(TaskConfig) { getQueue() >> 'normal' }
146+
}
147+
def exec = Mock(AbstractGridExecutor) {
148+
getConfig() >> new ExecutorConfig([:])
149+
getName() >> 'test'
150+
checkActiveStatus(_, _) >> true
151+
}
152+
def handler = new GridTaskHandler(task, exec)
153+
// startedMillis stays at 0 so elapsed >> queueInterval (null → grace period skipped)
154+
handler.@exitAwaiter = new ExitStatusAwaiter(Duration.of('100ms'))
155+
156+
when: 'job is still active and exit file is absent'
157+
def r1 = handler.readExitStatus()
158+
then:
159+
r1 == null
160+
161+
when: 'called again after more time — awaiter should have been reset, so still no timeout'
162+
sleep(150)
163+
def r2 = handler.readExitStatus()
164+
then:
165+
r2 == null // reset() means the 100 ms clock never accumulated
166+
167+
cleanup:
168+
workDir.toFile().deleteDir()
169+
}
170+
171+
def 'should defer readExitStatus when job inactive but exit file not yet visible'() {
172+
given:
173+
def workDir = Files.createTempDirectory('nf-grid-test')
174+
def task = Mock(TaskRun) {
175+
getName() >> 'foo'
176+
getWorkDir() >> workDir
177+
getConfig() >> Mock(TaskConfig) { getQueue() >> 'normal' }
178+
}
179+
def exec = Mock(AbstractGridExecutor) {
180+
getConfig() >> new ExecutorConfig([:])
181+
getName() >> 'test'
182+
checkActiveStatus(_, _) >> false
183+
}
184+
def handler = new GridTaskHandler(task, exec)
185+
handler.@exitAwaiter = new ExitStatusAwaiter(Duration.of('2sec'))
186+
187+
when: 'exit file still absent within timeout window'
188+
def result = handler.readExitStatus()
189+
190+
then:
191+
result == null
192+
193+
cleanup:
194+
workDir.toFile().deleteDir()
195+
}
196+
197+
def 'should return MAX_VALUE when inactive job exit file stays absent past timeout'() {
198+
given:
199+
def workDir = Files.createTempDirectory('nf-grid-test')
200+
def task = Mock(TaskRun) {
201+
getName() >> 'foo'
202+
getWorkDir() >> workDir
203+
getConfig() >> Mock(TaskConfig) { getQueue() >> 'normal' }
204+
}
205+
def exec = Mock(AbstractGridExecutor) {
206+
getConfig() >> new ExecutorConfig([:])
207+
getName() >> 'test'
208+
checkActiveStatus(_, _) >> false
209+
dumpQueueStatus() >> null
210+
}
211+
def handler = new GridTaskHandler(task, exec)
212+
handler.@exitAwaiter = new ExitStatusAwaiter(Duration.of('100ms'))
213+
214+
when: 'first call starts the missing-file clock'
215+
def r1 = handler.readExitStatus()
216+
then:
217+
r1 == null
218+
219+
when: 'second call after timeout has elapsed'
220+
sleep(150)
221+
def r2 = handler.readExitStatus()
222+
then:
223+
r2 == Integer.MAX_VALUE
224+
225+
cleanup:
226+
workDir.toFile().deleteDir()
227+
}
228+
229+
def 'should parse exit code once file appears after initial absence'() {
230+
given:
231+
def workDir = Files.createTempDirectory('nf-grid-test')
232+
def exitFile = workDir.resolve(TaskRun.CMD_EXIT)
233+
def task = Mock(TaskRun) {
234+
getName() >> 'foo'
235+
getWorkDir() >> workDir
236+
getConfig() >> Mock(TaskConfig) { getQueue() >> 'normal' }
237+
}
238+
def exec = Mock(AbstractGridExecutor) {
239+
getConfig() >> new ExecutorConfig([:])
240+
getName() >> 'test'
241+
checkActiveStatus(_, _) >> false
242+
}
243+
def handler = new GridTaskHandler(task, exec)
244+
handler.@exitAwaiter = new ExitStatusAwaiter(Duration.of('2sec'))
245+
246+
when: 'exit file is absent on first poll'
247+
def r1 = handler.readExitStatus()
248+
then:
249+
r1 == null
250+
251+
when: 'exit file appears with exit code 0'
252+
exitFile.text = '0'
253+
def r2 = handler.readExitStatus()
254+
then:
255+
r2 == 0
256+
257+
cleanup:
258+
workDir.toFile().deleteDir()
259+
}
260+
137261
def 'should create launch command' () {
138262
given:
139263
def exec = Spy(GridTaskHandler)

0 commit comments

Comments
 (0)