Skip to content

Commit 98dd386

Browse files
committed
Let a plugin supply the task hasher and the cache resolution strategy
Two extension points in nextflow.processor, both with defaults that preserve today's behaviour exactly: - TaskHasherFactory creates the TaskHasher for a task, or abstains. With no extension registered, or all abstaining, TaskProcessor does `new TaskHasher(task)` as before. Core's TaskHasher is untouched. - TaskCacheStrategy decides how a task is resolved against the cache, through the TaskResolver primitives the processor implements. DefaultTaskCacheStrategy is master's checkCachedOrLaunchTask loop moved verbatim -- the tries fold, the lock, mkdirs, submitTask -- so a run with no extension takes the same path it takes today. checkCachedOrLaunchTask becomes the dispatch. Supporting changes: - CacheFactory may resolve its own work directory and write it back to the session, so Session.init creates the cache before reading workDir. Everything below -- the work dir creation, the observers, the WorkflowMetadata snapshot -- must see the effective value, or workflow.workDir names a directory no task uses. A throw after that point now closes the cache rather than leaking it. - CacheStore.updateEntry, a default method, so a store composed of several members can route an update back to the member that served the read. - CacheDB.dispatchWrite, an internal refactor so a failed async cache write is logged with its cause instead of being swallowed. - FileHelper.getTaskHashFromPath tolerates an optional `-N` suffix on the work directory leaf. nf-lineage resolves every task input to its producing task by parsing that path (LinObserver.manageFileInParam -> getSourceReference); a cache that lays the attempts of one task out side by side keeps the two-level <2hex>/<30hex> hierarchy and appends the attempt to the leaf. Without this the parser returns null for those inputs and lineage records them as bare DataPaths, losing the edge to the producing task and paying a content checksum in its place. The parser is already lenient by design -- a non-2-char bucket and an unparseable hash both return null -- so this is one more accepted leaf shape, and hex never contains a dash, so it stays unambiguous. Assisted-by: Claude Opus 5 (1M context) Signed-off-by: jorgee <jorge.ejarque@seqera.io>
1 parent 56c5c1d commit 98dd386

15 files changed

Lines changed: 1100 additions & 65 deletions

File tree

modules/nextflow/src/main/groovy/nextflow/Session.groovy

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,14 @@ class Session implements ISession {
371371

372372
CacheDB getCache() { cache }
373373

374+
/**
375+
* Open this run's cache. A separate method so a test can substitute a factory that writes back
376+
* into the session the way a real one may — see {@link CacheFactory#newInstance}.
377+
*/
378+
protected CacheDB createCache() {
379+
return CacheFactory.create(uniqueId, runName).open()
380+
}
381+
374382
/**
375383
* Creates a new session using the configuration properties provided
376384
*
@@ -461,6 +469,30 @@ class Session implements ISession {
461469
*/
462470
Session init( ScriptFile scriptFile, List<String> args=null, Map<String,?> cliParams=null, Map<String,?> configParams=null ) {
463471

472+
// -- create the cache FIRST, and do not read `workDir` above this line: a factory may
473+
// resolve a work dir of its own and write it back here (see CacheFactory.newInstance),
474+
// and everything below -- the work dir creation, the observers, the WorkflowMetadata
475+
// snapshot -- must see the effective value, otherwise `workflow.workDir` reports a
476+
// directory the tasks never use. SessionTest locks this ordering.
477+
cache = createCache()
478+
try {
479+
return init0(scriptFile, args, cliParams, configParams)
480+
}
481+
catch( Throwable t ) {
482+
// Everything below createCache() can throw -- the work dir may be unwritable, an observer
483+
// factory may abort -- and ScriptRunner calls init() OUTSIDE the try that later closes the
484+
// session, so an abort here would leave the cache open. For the default cache that means a
485+
// LevelDB index already truncated by open() and never closed; for a plugin cache it means
486+
// whatever that cache holds. Close it and let the original failure propagate.
487+
try { cache?.close() }
488+
catch( Exception e ) { log.debug "Unable to close the cache after a failed session init -- ${e.message}" }
489+
cache = null
490+
throw t
491+
}
492+
}
493+
494+
private Session init0( ScriptFile scriptFile, List<String> args, Map<String,?> cliParams, Map<String,?> configParams ) {
495+
464496
if(!workDir.mkdirs())
465497
throw new AbortOperationException("Cannot create work-dir '${FilesEx.toUriString(workDir)}' -- Make sure you have write permissions or specify a different directory by using the `-w` command line option")
466498
log.debug "Work-dir: ${workDir.toUriString()} [${FileHelper.getPathFsType(workDir)}]"
@@ -492,8 +524,6 @@ class Session implements ISession {
492524
binding.setParams( (Map)config.params )
493525
binding.setArgs( new ScriptRunner.ArgsList(args) )
494526

495-
cache = CacheFactory.create(uniqueId,runName).open()
496-
497527
return this
498528
}
499529

modules/nextflow/src/main/groovy/nextflow/cache/CacheDB.groovy

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

1717
package nextflow.cache
1818

19-
2019
import com.google.common.hash.HashCode
2120
import groovy.transform.CompileStatic
2221
import groovy.transform.PackageScope
@@ -85,10 +84,45 @@ class CacheDB implements Closeable {
8584
final record = (List)KryoHelper.deserialize(payload)
8685
TraceRecord trace = TraceRecord.deserialize( (byte[])record[0] )
8786
TaskContext ctx = record[1]!=null && processor!=null ? TaskContext.deserialize(processor, (byte[])record[1]) : null
88-
8987
return new TaskEntry(trace,ctx)
9088
}
9189

90+
/**
91+
* Dispatch an asynchronous cache write on the {@link #writer} agent, logging any failure
92+
* instead of letting the agent swallow it. A dropped entry / index write
93+
* would otherwise leave a successfully executed task non-resumable with no trace: the run still
94+
* succeeds, but the task re-executes on the next resume. Surfacing the error makes that visible
95+
* (and is the natural hook for a future retry at the store level).
96+
*
97+
* @param what short description of the write for the log message (typically the task hash)
98+
* @param action the store mutation to run on the writer thread
99+
*/
100+
protected void dispatchWrite(String what, Closure action) {
101+
writer.send {
102+
try {
103+
action.call()
104+
}
105+
catch( Throwable e ) {
106+
log.warn("Unable to persist cache record for ${what} -- the task will re-execute on the next resume", e)
107+
}
108+
}
109+
}
110+
111+
/**
112+
* Bump the reference count of an entry that already exists, which also refreshes the stored
113+
* object's last-modified stamp.
114+
*
115+
* <b>The count is advisory for a shared store.</b> This is a read-modify-write with no
116+
* compare-and-set behind {@link CacheStore}, so two runs resuming the same task can read the same
117+
* value and write the same increment, losing one. That is harmless today only because neither
118+
* consumer of the count is reachable for a shared cache: {@link #removeTaskEntry} — the only
119+
* decrement, and the only path that can delete on reaching zero — is called from
120+
* {@code Session.cleanup}, which returns before opening the cache for a non-{@code file:} work
121+
* dir, and from {@code CmdClean}, which a shared cache's {@code CacheDB} may refuse by
122+
* overriding it; and a cross-run cache ages entries by the object's last-modified time, not by
123+
* the count. Eviction tooling that reads the count instead would need a genuinely atomic update
124+
* here.
125+
*/
92126
void incTaskEntry( HashCode hash ) {
93127
final payload = store.getEntry(hash)
94128
if( !payload ) {
@@ -99,11 +133,15 @@ class CacheDB implements Closeable {
99133
final record = (List)KryoHelper.deserialize(payload)
100134
// third record contains the reference count for this record
101135
record[2] = ((Integer)record[2]) +1
102-
// save it again
103-
store.putEntry(hash, KryoHelper.serialize(record))
136+
// save it again -- an update of the record just read, not a new entry (see updateEntry)
137+
store.updateEntry(hash, KryoHelper.serialize(record))
104138

105139
}
106140

141+
/**
142+
* Decrement the reference count, deleting the entry when it reaches zero. Callers must not invoke
143+
* this on a shared cache — see the advisory-count note on {@link #incTaskEntry}.
144+
*/
107145
boolean removeTaskEntry( HashCode hash ) {
108146
final payload = store.getEntry(hash)
109147
if( !payload ) {
@@ -114,9 +152,9 @@ class CacheDB implements Closeable {
114152
final record = (List)KryoHelper.deserialize(payload)
115153
// third record contains the reference count for this record
116154
def count = record[2] = ((Integer)record[2]) -1
117-
// save or delete
155+
// save or delete -- as in incTaskEntry, saving is an update of the record just read
118156
if( count > 0 ) {
119-
store.putEntry(hash, KryoHelper.serialize(record))
157+
store.updateEntry(hash, KryoHelper.serialize(record))
120158
return false
121159
}
122160
else {
@@ -153,18 +191,18 @@ class CacheDB implements Closeable {
153191
}
154192

155193
void putTaskAsync( TaskHandler handler, TraceRecord trace ) {
156-
writer.send { writeTaskEntry0(handler, trace) }
194+
dispatchWrite("task entry ${handler.task.hash}") { writeTaskEntry0(handler, trace) }
157195
}
158196

159197
void cacheTaskAsync( TaskHandler handler ) {
160-
writer.send {
198+
dispatchWrite("cached task ${handler.task.hash}") {
161199
writeTaskIndex0(handler,true)
162200
incTaskEntry(handler.task.hash)
163201
}
164202
}
165203

166204
void putIndexAsync(TaskHandler handler ) {
167-
writer.send { writeTaskIndex0(handler) }
205+
dispatchWrite("task index ${handler.task.hash}") { writeTaskIndex0(handler) }
168206
}
169207

170208
@PackageScope

modules/nextflow/src/main/groovy/nextflow/cache/CacheFactory.groovy

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,20 @@ import org.pf4j.ExtensionPoint
3232
@CompileStatic
3333
abstract class CacheFactory implements ExtensionPoint {
3434

35+
/**
36+
* Build the cache instance.
37+
*
38+
* <p>A factory MAY write back into the current session while resolving its cache — a
39+
* content-addressable cache whose work directory <b>is</b> the cache assigns
40+
* {@code session.workDir}, and turns {@code resumeMode} on because its hits are keyed by task
41+
* hash rather than by session id. This is why {@code Session.init} creates the cache before it
42+
* reads {@code workDir}: everything downstream of that point (the work-dir creation, the
43+
* observers, the {@code WorkflowMetadata} snapshot) must see the effective value, or
44+
* {@code workflow.workDir} reports a directory the tasks never use.
45+
*
46+
* <p>{@code SessionTest} locks that ordering, so a reorder fails a test rather than silently
47+
* producing a run whose reported work dir is not the one in use.
48+
*/
3549
protected abstract CacheDB newInstance(UUID uniqueId, String runName, Path home=null)
3650

3751
static CacheDB create(UUID uniqueId, String runName, Path home=null) {

modules/nextflow/src/main/groovy/nextflow/cache/CacheStore.groovy

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,18 @@ interface CacheStore {
4141
void putEntry(HashCode key, byte[] value)
4242
void deleteEntry(HashCode key)
4343

44+
/**
45+
* Update an entry that already exists in this store, i.e. the read-modify-write of the
46+
* reference count / last-used stamp in {@link CacheDB#incTaskEntry}, whose value is a record
47+
* just read back through {@link #getEntry}.
48+
*
49+
* This is deliberately distinct from {@link #putEntry}, which stores a <b>new</b> entry: a
50+
* composite store has to send an update to the member that served the read — and may have to
51+
* drop it when that member is read-only — while a new entry must always go to the writable
52+
* one. Defaults to {@link #putEntry}, which is the correct behaviour for a single store.
53+
*/
54+
default void updateEntry(HashCode key, byte[] value) { putEntry(key, value) }
55+
4456
void writeIndex(HashCode key, boolean cached)
4557
Iterator<Index> iterateIndex()
4658
void deleteIndex()
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/*
2+
* Copyright 2013-2026, Seqera Labs
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package nextflow.processor
18+
19+
import java.nio.file.Path
20+
21+
import com.google.common.hash.HashCode
22+
import groovy.transform.CompileStatic
23+
import groovy.util.logging.Slf4j
24+
import nextflow.Session
25+
import nextflow.file.FileHelper
26+
import nextflow.util.HashBuilder
27+
import nextflow.util.LockManager
28+
29+
/**
30+
* The default {@link TaskCacheStrategy}: the per-run, local resolution loop as it has always been in
31+
* {@code TaskProcessor.checkCachedOrLaunchTask}, relocated here unchanged.
32+
*
33+
* <p>The task hash is folded with the attempt number ({@code tries}) into the per-attempt hash that
34+
* names the work directory and keys the cache entry. For each attempt the entry is looked up and, when
35+
* resuming, the task is resumed from it; an attempt whose work directory already exists -- a previous
36+
* failure, or an identical task instance of this run -- bumps to the next one; the first free work
37+
* directory is created under an in-process lock and the task is launched there.
38+
*
39+
* <p>This strategy is not a plugin extension: it is the fallback {@link TaskProcessor} uses when no
40+
* registered {@link TaskCacheStrategy} applies to the session, so {@link #isEnabled} is always true.
41+
*
42+
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
43+
*/
44+
@Slf4j
45+
@CompileStatic
46+
class DefaultTaskCacheStrategy implements TaskCacheStrategy {
47+
48+
private static LockManager lockManager = new LockManager()
49+
50+
@Override
51+
boolean isEnabled(Session session) { return true }
52+
53+
@Override
54+
void resolve(TaskRun task, HashCode hash, boolean shouldTryCache, TaskResolver resolver) {
55+
56+
int tries = task.failCount +1
57+
while( true ) {
58+
hash = HashBuilder.defaultHasher().putBytes(hash.asBytes()).putInt(tries).hash()
59+
60+
Path resumeDir = null
61+
boolean exists = false
62+
try {
63+
final entry = resolver.entry(hash)
64+
resumeDir = entry ? FileHelper.asPath(entry.trace.getWorkDir()) : null
65+
if( resumeDir )
66+
exists = resumeDir.exists()
67+
68+
log.trace "[${task.lazyName()}] Cacheable folder=${resumeDir?.toUriString()} -- exists=$exists; try=$tries; shouldTryCache=$shouldTryCache; entry=$entry"
69+
final cached = shouldTryCache && exists && entry.trace.isCompleted() && resolver.resume(task, hash, resumeDir, entry)
70+
if( cached )
71+
break
72+
}
73+
catch (Throwable t) {
74+
log.warn1("[${task.lazyName()}] Unable to resume cached task -- See log file for details", causedBy: t)
75+
}
76+
77+
if( exists ) {
78+
tries++
79+
continue
80+
}
81+
82+
final lock = lockManager.acquire(hash)
83+
final workDir = resolver.workDirFor(hash)
84+
try {
85+
if( resumeDir != workDir )
86+
exists = workDir.exists()
87+
if( exists ) {
88+
tries++
89+
continue
90+
}
91+
else if( !workDir.mkdirs() )
92+
throw new IOException("Unable to create directory=$workDir -- check file system permissions")
93+
}
94+
finally {
95+
lock.release()
96+
}
97+
98+
// submit task for execution
99+
resolver.launch( task, hash, workDir )
100+
break
101+
}
102+
103+
}
104+
105+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
* Copyright 2013-2026, Seqera Labs
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package nextflow.processor
18+
19+
import com.google.common.hash.HashCode
20+
import nextflow.Session
21+
import org.pf4j.ExtensionPoint
22+
23+
/**
24+
* Plugin extension point deciding how a task is resolved against the cache: resumed from a previous
25+
* execution, or given a work directory and launched.
26+
*
27+
* <p>{@link TaskProcessor} resolves the registered strategies once, in {@link nextflow.plugin.Priority}
28+
* order, and uses the first one that {@link #isEnabled applies} to the session; with none registered,
29+
* or none applying, the {@link DefaultTaskCacheStrategy} is used, so a run without such a plugin
30+
* resolves its tasks exactly as before.
31+
*
32+
* <p>A strategy is expected to abstain (return {@code false} from {@link #isEnabled}) whenever its cache
33+
* is not the one in use for the session: the plugin registry is process-wide, while the choice of
34+
* strategy belongs to the run.
35+
*
36+
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
37+
*/
38+
interface TaskCacheStrategy extends ExtensionPoint {
39+
40+
/**
41+
* Whether this strategy applies to the run; the highest-priority applicable one wins, else the
42+
* default.
43+
*
44+
* @param session The session of the run.
45+
* @return {@code true} to take over the resolution of every task of the run.
46+
*/
47+
boolean isEnabled(Session session)
48+
49+
/**
50+
* Resolve {@code task}, whose hash is {@code hash}: end by calling exactly one of
51+
* {@link TaskResolver#resume resolver.resume} (returning {@code true}) or
52+
* {@link TaskResolver#launch resolver.launch}.
53+
*
54+
* @param task The task to resolve.
55+
* @param hash The task hash, as computed by its {@link TaskHasher}.
56+
* @param tryCache Whether a cached execution may be resumed; {@code false} on a retry, and when
57+
* the run is not resuming.
58+
* @param resolver The processor's primitives to resume or launch the task with.
59+
*/
60+
void resolve(TaskRun task, HashCode hash, boolean tryCache, TaskResolver resolver)
61+
62+
}

0 commit comments

Comments
 (0)