add nextflow d30e48d

This commit is contained in:
2026-04-29 23:01:54 +02:00
parent d0b12d668d
commit 97cc9058d3
2840 changed files with 730250 additions and 0 deletions

View File

@@ -0,0 +1,205 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.seqera.config
import groovy.transform.CompileStatic
import nextflow.config.spec.ConfigOption
import nextflow.config.spec.ConfigScope
import nextflow.script.dsl.Description
import nextflow.util.Duration
/**
* Configuration for the Seqera executor.
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@Description("""
The `seqera.executor` scope provides configuration for the Seqera compute executor.
""")
@CompileStatic
class ExecutorOpts implements ConfigScope {
static final Set<String> VALID_AUTO_LABELS = Collections.unmodifiableSet(new LinkedHashSet<>([
'projectName', 'userName', 'runName', 'sessionId', 'resume',
'revision', 'commitId', 'repository', 'manifestName',
'runtimeVersion', 'workflowId', 'workspaceId', 'computeEnvId'
]))
final RetryOpts retryPolicy
@ConfigOption
@Description("""
The Seqera scheduler service endpoint URL.
""")
final String endpoint
@ConfigOption
@Description("""
The compute backend provider type (e.g. `aws`, `local`).
When specified, used together with region to select the matching compute environment.
""")
final String provider
@ConfigOption
@Description("""
The AWS region for task execution (default: `eu-central-1`).
""")
final String region
@ConfigOption
@Description("""
The EC2 key pair name for SSH access to instances.
""")
final String keyPairName
@ConfigOption
@Description("""
The interval for batching task submissions (default: `1 sec`).
""")
final Duration batchFlushInterval
@Description("""
Machine/infrastructure requirements for session tasks.
""")
final MachineRequirementOpts machineRequirement
@ConfigOption
@Description("""
Automatically attach workflow metadata labels (with the `nextflow.io/` and
`seqera.io/platform/` prefixes) to the session. Accepts:
- `true`: include all available metadata labels
- `false` (default): disable
- a list or comma-separated string of short names: e.g.
`['runName', 'projectName']` or `'runName,projectName'`
Valid names: `projectName`, `userName`, `runName`, `sessionId`, `resume`,
`revision`, `commitId`, `repository`, `manifestName`, `runtimeVersion`,
`workflowId`, `workspaceId`, `computeEnvId`.
""")
final Set<String> autoLabels
@ConfigOption
@Description("""
The resource prediction model to use for estimating task resource requirements
based on historical execution metrics. Supported values: `qr/v1`, `qr/v2` (quantile regression).
When not set, no resource estimation is applied.
""")
final String predictionModel
@ConfigOption
@Description("""
Custom environment variables to apply to all tasks submitted by the Seqera executor.
These are merged with the Fusion environment variables, with Fusion variables taking precedence.
""")
final Map<String, String> taskEnvironment
@ConfigOption
@Description("""
The Seqera Platform compute environment ID. When specified, the scheduler resolves
the compute environment directly by this ID instead of listing all workspace CEs.
Used as a fallback when the workflow launch does not include a CE reference.
""")
final String computeEnvId
/* required by config scope -- do not remove */
ExecutorOpts() {}
ExecutorOpts(Map opts) {
this.retryPolicy = new RetryOpts(opts.retryPolicy as Map ?: Map.of())
this.endpoint = opts.endpoint as String
if (!endpoint)
throw new IllegalArgumentException("Missing Seqera endpoint - make sure to specify 'seqera.executor.endpoint' settings")
this.provider = opts.provider as String
this.region = opts.region as String
this.keyPairName = opts.keyPairName as String
this.batchFlushInterval = opts.batchFlushInterval
? Duration.of(opts.batchFlushInterval as String)
: Duration.of('1 sec')
// machine requirement settings
this.machineRequirement = new MachineRequirementOpts(opts.machineRequirement as Map ?: Map.of())
this.autoLabels = parseAutoLabels(opts.get('autoLabels'))
// prediction model
this.predictionModel = opts.predictionModel as String ?: null
// custom task environment variables
this.taskEnvironment = opts.taskEnvironment as Map<String, String>
// compute environment ID
this.computeEnvId = opts.computeEnvId as String
}
RetryOpts retryOpts() {
this.retryPolicy
}
String getEndpoint() {
return endpoint
}
String getProvider() {
return provider
}
String getRegion() {
return region
}
String getKeyPairName() {
return keyPairName
}
Duration getBatchFlushInterval() {
return batchFlushInterval
}
MachineRequirementOpts getMachineRequirement() {
return machineRequirement
}
Set<String> getAutoLabels() {
return autoLabels
}
protected static Set<String> parseAutoLabels(Object value) {
if( value == null || value == false )
return Collections.<String>emptySet()
if( value == true )
return VALID_AUTO_LABELS
List<String> raw
if( value instanceof CharSequence )
raw = value.toString().tokenize(',').collect { String s -> s.trim() }.findAll { String s -> s }
else if( value instanceof List )
raw = ((List) value).collect { it?.toString()?.trim() }.findAll { String s -> s } as List<String>
else
throw new IllegalArgumentException("Invalid 'seqera.executor.autoLabels' value '${value}' - expected true, false, a list, or a comma-separated string")
final invalid = raw.findAll { String s -> !(s in VALID_AUTO_LABELS) }
if( invalid )
throw new IllegalArgumentException("Invalid 'seqera.executor.autoLabels' name(s) ${invalid} - valid names are: ${VALID_AUTO_LABELS.join(', ')}")
return Collections.unmodifiableSet(new LinkedHashSet<>(raw))
}
String getPredictionModel() {
return predictionModel
}
Map<String, String> getTaskEnvironment() {
return taskEnvironment
}
String getComputeEnvId() {
return computeEnvId
}
}

View File

@@ -0,0 +1,171 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.seqera.config
import groovy.transform.CompileStatic
import nextflow.config.spec.ConfigOption
import nextflow.config.spec.ConfigScope
import nextflow.script.dsl.Description
import nextflow.util.MemoryUnit
/**
* Machine/infrastructure requirements configuration options.
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@CompileStatic
class MachineRequirementOpts implements ConfigScope {
@ConfigOption
@Description("""
The instance provisioning mode: `spot`, `ondemand`, or `spotFirst`.
""")
final String provisioning
@ConfigOption
@Description("""
Maximum number of spot retry attempts before falling back to on-demand.
Only used when provisioning is `spot` or `spotFirst`.
""")
final Integer maxSpotAttempts
@ConfigOption
@Description("""
List of acceptable machine type patterns. Supports exact types (e.g., `t3.small`),
family prefixes (e.g., `m5` matches all m5 sizes), and glob wildcards (e.g., `t*.small`).
""")
final List<String> machineTypes
@ConfigOption
@Description("""
The EBS volume type for task scratch disk (e.g., `ebs/gp3`, `ebs/io1`).
Default: `ebs/gp3`.
""")
final String diskType
@ConfigOption
@Description("""
The throughput in MiB/s for gp3 volumes (125-1000).
Default: 325 (Fusion recommended).
""")
final Integer diskThroughputMiBps
@ConfigOption
@Description("""
The IOPS for io1/io2/gp3 volumes. Required for io1/io2.
""")
final Integer diskIops
@ConfigOption
@Description("""
Enable KMS encryption for the EBS volume.
Default: false.
""")
final Boolean diskEncrypted
@ConfigOption
@Description("""
The disk allocation strategy: `task` or `node`.
- `task`: Per-task EBS volume created at task launch (default)
- `node`: Per-node instance storage attached at cluster level
""")
final String diskAllocation
@ConfigOption
@Description("""
The container path where the disk is mounted (e.g., `/data`).
Default: `/tmp`.
""")
final String diskMountPath
@ConfigOption
@Description("""
The disk size for session-level storage (e.g., `100.GB`).
""")
final MemoryUnit diskSize
@ConfigOption
@Description("""
The ECS capacity provider mode: `managed` (default) or `asg`.
- `managed`: ECS Managed Instances
- `asg`: Auto Scaling Group-backed capacity provider
""")
final String capacityMode
/* required by config scope -- do not remove */
MachineRequirementOpts() {}
MachineRequirementOpts(Map opts) {
this.provisioning = opts.provisioning as String
this.maxSpotAttempts = opts.maxSpotAttempts as Integer
this.machineTypes = (opts.machineTypes ?: opts.machineFamilies) as List<String>
this.diskType = opts.diskType as String
this.diskThroughputMiBps = opts.diskThroughputMiBps as Integer
this.diskIops = opts.diskIops as Integer
this.diskEncrypted = opts.diskEncrypted as Boolean
this.diskAllocation = opts.diskAllocation as String
this.diskMountPath = opts.diskMountPath as String
this.diskSize = opts.diskSize instanceof MemoryUnit
? opts.diskSize as MemoryUnit
: (opts.diskSize ? MemoryUnit.of(opts.diskSize as String) : null)
this.capacityMode = opts.capacityMode as String
}
String getProvisioning() {
return provisioning
}
Integer getMaxSpotAttempts() {
return maxSpotAttempts
}
List<String> getMachineTypes() {
return machineTypes
}
String getDiskType() {
return diskType
}
Integer getDiskThroughputMiBps() {
return diskThroughputMiBps
}
Integer getDiskIops() {
return diskIops
}
Boolean getDiskEncrypted() {
return diskEncrypted
}
String getDiskAllocation() {
return diskAllocation
}
String getDiskMountPath() {
return diskMountPath
}
MemoryUnit getDiskSize() {
return diskSize
}
String getCapacityMode() {
return capacityMode
}
}

View File

@@ -0,0 +1,92 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.seqera.config
import groovy.transform.CompileStatic
import groovy.transform.ToString
import io.seqera.util.retry.Retryable
import nextflow.config.spec.ConfigOption
import nextflow.config.spec.ConfigScope
import nextflow.script.dsl.Description
import nextflow.util.Duration
/**
* Model retry options for Seqera scheduler HTTP requests.
* Implements {@link Retryable.Config} for integration with lib-retry.
*/
@ToString(includeNames = true, includePackage = false)
@CompileStatic
class RetryOpts implements ConfigScope, Retryable.Config {
@ConfigOption
@Description("""
The initial delay when a failing HTTP request is retried (default: `450ms`).
""")
Duration delay = Duration.of('450ms')
@ConfigOption
@Description("""
The max delay when a failing HTTP request is retried (default: `90s`).
""")
Duration maxDelay = Duration.of('90s')
@ConfigOption
@Description("""
The maximum number of retry attempts (default: `10`).
""")
int maxAttempts = 10
@ConfigOption
@Description("""
The jitter factor for randomizing retry delays (default: `0.25`).
""")
double jitter = 0.25
@ConfigOption
@Description("""
The multiplier for exponential backoff (default: `2.0`).
""")
double multiplier = 2.0d
RetryOpts() {
this(Collections.emptyMap())
}
RetryOpts(Map config) {
if( config.delay )
delay = config.delay as Duration
if( config.maxDelay )
maxDelay = config.maxDelay as Duration
if( config.maxAttempts )
maxAttempts = config.maxAttempts as int
if( config.jitter )
jitter = config.jitter as double
if( config.multiplier )
multiplier = config.multiplier as double
}
// Methods required by Retryable.Config interface
@Override
java.time.Duration getDelayAsDuration() {
return java.time.Duration.ofMillis(delay.toMillis())
}
@Override
java.time.Duration getMaxDelayAsDuration() {
return java.time.Duration.ofMillis(maxDelay.toMillis())
}
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.seqera.config
import groovy.transform.CompileStatic
import nextflow.config.spec.ConfigScope
import nextflow.config.spec.ScopeName
import nextflow.script.dsl.Description
/**
* Top-level configuration scope for Seqera settings.
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@ScopeName("seqera")
@Description("""
The `seqera` scope provides configuration for Seqera services.
""")
@CompileStatic
class SeqeraConfig implements ConfigScope {
@Description("""
Configuration for the Seqera compute executor.
""")
final ExecutorOpts executor
/* required by config scope -- do not remove */
SeqeraConfig() {}
SeqeraConfig(Map opts) {
this.executor = opts.executor
? new ExecutorOpts(opts.executor as Map)
: null
}
ExecutorOpts getExecutor() {
return executor
}
}

View File

@@ -0,0 +1,143 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.seqera.executor
import java.nio.file.FileVisitResult
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.SimpleFileVisitor
import java.nio.file.attribute.BasicFileAttributes
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
import io.seqera.sched.api.schema.v1a1.InputFilesMetrics
import nextflow.file.FileHolder
import nextflow.processor.TaskRun
/**
* Computes input files metrics for a task.
* Follows symlinks and recursively computes directory sizes.
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@Slf4j
@CompileStatic
class InputFilesProfiler {
/**
* Compute input files metrics for a task.
*
* @param task The task to compute metrics for
* @return InputFilesMetrics or null if no input files
*/
static InputFilesMetrics compute(TaskRun task) {
final files = task?.inputFiles
if( !files || files.isEmpty() )
return null
return compute0(files)
}
/**
* Compute input files metrics from a list of file holders.
*
* @param files List of FileHolder objects
* @return InputFilesMetrics or null if list is empty
*/
static InputFilesMetrics compute(List<FileHolder> files) {
if( !files || files.isEmpty() )
return null
return compute0(files)
}
private static InputFilesMetrics compute0(List<FileHolder> files) {
int totalCount = 0
long totalBytes = 0
long maxFileBytes = Long.MIN_VALUE
long minFileBytes = Long.MAX_VALUE
for( FileHolder fh : files ) {
final long[] result = getFileStats(fh.storePath)
final count = (int) result[0]
final size = result[1]
totalCount += count
totalBytes += size
if( size > maxFileBytes )
maxFileBytes = size
if( size < minFileBytes )
minFileBytes = size
}
return new InputFilesMetrics()
.count(totalCount)
.totalBytes(totalBytes)
.maxFileBytes(maxFileBytes)
.minFileBytes(minFileBytes)
}
/**
* Get file stats for a path: file count and total size.
* For regular files, count is 1. For directories, count is the number of files within.
*
* @param path The path to measure
* @return A two-element array: [fileCount, totalSize]
*/
private static long[] getFileStats(Path path) {
if( path == null )
return new long[]{0, 0}
try {
if( Files.isDirectory(path) ) {
return computeDirStats(path)
}
// Files.size() follows symlinks by default
return new long[]{1, Files.size(path)}
}
catch( Exception e ) {
log.warn "Unable to determine size for input file: ${path} - ${e.message}"
return new long[]{1, 0}
}
}
/**
* Recursively compute file count and total size of a directory.
*
* @param dir The directory path
* @return A two-element array: [fileCount, totalSize]
*/
private static long[] computeDirStats(Path dir) {
final long[] result = [0L, 0L] // [count, size]
Files.walkFileTree(dir, new SimpleFileVisitor<Path>() {
@Override
FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
result[0]++
result[1] += attrs.size()
return FileVisitResult.CONTINUE
}
@Override
FileVisitResult visitFileFailed(Path file, IOException exc) {
log.warn "Unable to access file during size computation: ${file} - ${exc.message}"
return FileVisitResult.CONTINUE
}
})
return result
}
}

View File

@@ -0,0 +1,171 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.seqera.executor
import com.google.common.hash.Hashing
import groovy.transform.CompileStatic
import nextflow.NextflowMeta
import nextflow.script.WorkflowMetadata
/**
* Helper class to manage run labels.
*
* Builds the labels map from workflow metadata ({@code nextflow.io/*}),
* scheduler metadata ({@code seqera:sched:*}), and user-configured labels.
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@CompileStatic
class Labels {
static final Set<String> ALL_AUTO_LABELS = Collections.unmodifiableSet(new LinkedHashSet<>([
'projectName', 'userName', 'runName', 'sessionId', 'resume',
'revision', 'commitId', 'repository', 'manifestName',
'runtimeVersion', 'workflowId', 'workspaceId', 'computeEnvId'
]))
private final Map<String,String> entries = new LinkedHashMap<>(20)
/**
* Add all {@code nextflow.io/*} and {@code seqera.io/platform/*} labels
* derived from workflow metadata.
*/
Labels withWorkflowMetadata(WorkflowMetadata workflow) {
return withWorkflowMetadata(workflow, ALL_AUTO_LABELS)
}
/**
* Add workflow metadata labels filtered by the {@code include} set of
* short names (e.g. {@code 'runName'}). Unknown names are ignored; the
* caller is expected to validate membership upstream.
*/
Labels withWorkflowMetadata(WorkflowMetadata workflow, Set<String> include) {
if( !include ) return this
if( include.contains('projectName') && workflow.projectName )
entries.put('nextflow.io/projectName', workflow.projectName)
if( include.contains('userName') && workflow.userName )
entries.put('nextflow.io/userName', workflow.userName)
if( include.contains('runName') && workflow.runName )
entries.put('nextflow.io/runName', workflow.runName)
if( include.contains('sessionId') && workflow.sessionId )
entries.put('nextflow.io/sessionId', workflow.sessionId.toString())
if( include.contains('resume') )
entries.put('nextflow.io/resume', String.valueOf(workflow.resume))
if( include.contains('revision') && workflow.revision )
entries.put('nextflow.io/revision', workflow.revision)
if( include.contains('commitId') && workflow.commitId )
entries.put('nextflow.io/commitId', workflow.commitId)
if( include.contains('repository') && workflow.repository )
entries.put('nextflow.io/repository', workflow.repository)
if( include.contains('manifestName') && workflow.manifest?.name )
entries.put('nextflow.io/manifestName', workflow.manifest.name)
if( include.contains('runtimeVersion') && NextflowMeta.instance.version )
entries.put('nextflow.io/runtimeVersion', NextflowMeta.instance.version.toString())
if( include.contains('workflowId') && workflow.platform?.workflowId )
entries.put('seqera.io/platform/workflowId', workflow.platform.workflowId)
if( include.contains('workspaceId') && workflow.platform?.workspace?.id )
entries.put('seqera.io/platform/workspaceId', workflow.platform.workspace.id)
if( include.contains('computeEnvId') && workflow.platform?.computeEnv?.id )
entries.put('seqera.io/platform/computeEnvId', workflow.platform.computeEnv.id)
return this
}
/**
* Add {@code seqera:sched:*} scheduler labels
*/
Labels withSchedRunId(String runId) {
if( runId )
entries.put('seqera:sched:runId', runId)
return this
}
Labels withSchedClusterId(String clusterId) {
if( clusterId )
entries.put('seqera:sched:clusterId', clusterId)
return this
}
/**
* Add config-level {@code process.resourceLabels}. Values are coerced to
* string via {@link String#valueOf} to satisfy the scheduler API typing.
*/
Labels withProcessResourceLabels(Map<String,?> map) {
if( !map ) return this
for( Map.Entry<String,?> entry : map.entrySet() )
entries.put(entry.key.toString(), String.valueOf(entry.value))
return this
}
/**
* @return all labels as an unmodifiable map
*/
Map<String,String> getEntries() {
return Collections.unmodifiableMap(entries)
}
/**
* Compute a run identifier as SipHash of sessionId + runName
*/
protected static String runId(String sessionId, String runName) {
return Hashing
.sipHash24()
.newHasher()
.putUnencodedChars(sessionId)
.putUnencodedChars(runName)
.hash()
.toString()
}
/**
* Coerce arbitrary map values to strings via {@link String#valueOf}.
* Returns an empty map for null/empty input. Throws
* {@link IllegalArgumentException} when the value is not a {@link Map},
* to surface a clear error when {@code process.resourceLabels} is
* misconfigured (e.g. as a list).
*/
static Map<String,String> toStringMap(Object value) {
if( value == null )
return Collections.<String,String>emptyMap()
if( value !instanceof Map )
throw new IllegalArgumentException("Invalid value for 'resourceLabels' directive - expected a map of key/value pairs, got '${value.getClass().getName()}'")
final map = (Map<?,?>) value
if( map.isEmpty() )
return Collections.<String,String>emptyMap()
final result = new LinkedHashMap<String,String>(map.size())
for( Map.Entry<?,?> entry : map.entrySet() )
result.put(entry.key.toString(), String.valueOf(entry.value))
return result
}
/**
* Return the entries of {@code task} that are missing from {@code run}
* or have a different value. Returns {@code null} if the resulting
* map would be empty (so callers can omit the field).
*/
static Map<String,String> delta(Map<String,String> task, Map<String,String> run) {
if( !task ) return null
final result = new LinkedHashMap<String,String>()
for( Map.Entry<String,String> entry : task.entrySet() ) {
final k = entry.key
final v = entry.value
if( run == null || !run.containsKey(k) || run.get(k) != v )
result.put(k, v)
}
return result.isEmpty() ? null : result
}
}

View File

@@ -0,0 +1,317 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.seqera.executor
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ExecutorService
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
import groovy.transform.CompileStatic
import groovy.transform.TupleConstructor
import groovy.util.logging.Slf4j
import io.seqera.sched.api.schema.v1a1.InputFilesMetrics
import io.seqera.sched.api.schema.v1a1.Task
import io.seqera.sched.client.SchedClient
import nextflow.SysEnv
import nextflow.util.Duration
import nextflow.util.ThreadPoolBuilder
import nextflow.util.Threads
/**
* Batches task submissions to the Seqera scheduler API.
*
* @author Lorenzo Fontana <fontanalorenz@gmail.com>
*/
@Slf4j
@CompileStatic
class SeqeraBatchSubmitter {
/** Maximum tasks per API call */
static final int TASKS_PER_REQUEST = SysEnv.getInteger('NXF_SEQERA_TASK_PER_REQUEST', 100)
/** Default flush interval */
static final Duration REQUEST_INTERVAL = SysEnv.get('NXF_SEQERA_REQUEST_INTERVAL', '1 sec') as Duration
/** Keep-alive interval - send empty submission to maintain run */
static final Duration KEEP_ALIVE_INTERVAL = SysEnv.get('NXF_SEQERA_KEEP_ALIVE_INTERVAL', '60 sec') as Duration
/** Timeout for waiting on metrics computation */
static final Duration METRICS_TIMEOUT = SysEnv.get('NXF_SEQERA_METRICS_TIMEOUT', '30 sec') as Duration
/**
* Holds a task handler, its prepared Task object, and async metrics computation
*/
@TupleConstructor
static class PendingTask {
SeqeraTaskHandler handler
Task task
CompletableFuture<InputFilesMetrics> metricsFuture
}
private final SchedClient client
private final String runId
private final Duration requestInterval
private final Duration keepAliveInterval
private final Closure onError
private final LinkedBlockingQueue<PendingTask> pendingQueue = new LinkedBlockingQueue<>()
private Thread sender
private volatile boolean completed = false
/** Executor pool for async input file metrics computation */
private final ExecutorService metricsExecutor
SeqeraBatchSubmitter(SchedClient client, String runId) {
this(client, runId, REQUEST_INTERVAL, KEEP_ALIVE_INTERVAL)
}
SeqeraBatchSubmitter(SchedClient client, String runId, Duration requestInterval) {
this(client, runId, requestInterval, KEEP_ALIVE_INTERVAL)
}
SeqeraBatchSubmitter(SchedClient client, String runId, Duration requestInterval, Duration keepAliveInterval, Closure onError=null) {
this.client = client
this.runId = runId
this.requestInterval = requestInterval
this.keepAliveInterval = keepAliveInterval
this.onError = onError
// Create a thread pool for metrics computation
this.metricsExecutor = new ThreadPoolBuilder()
.withName('seqera-metrics')
.withMinSize(0)
.withMaxSize(10)
.withKeepAliveTime(60_000L)
.withAllowCoreThreadTimeout(true)
.build()
}
/**
* Start the sender thread that processes the batch queue
*/
void start() {
log.debug "[SEQERA] Starting batch submitter - interval=${requestInterval}"
this.sender = Threads.start('Seqera-batch-submitter', this.&sendTasks0)
}
/**
* Enqueue a task for batch submission.
* Starts async computation of input files metrics immediately.
*/
void submit(SeqeraTaskHandler handler, Task task) {
if (completed) {
throw new IllegalStateException("Batch submitter has been shutdown")
}
// Start async metrics computation
final taskRun = handler.task
final metricsFuture = CompletableFuture.supplyAsync(
()-> InputFilesProfiler.compute(taskRun),
metricsExecutor
)
pendingQueue.add(new PendingTask(handler, task, metricsFuture))
}
/**
* Signal completion and wait for sender thread to finish
*/
void shutdown() {
log.debug "[SEQERA] Shutting down batch submitter"
completed = true
if (sender) {
sender.join()
}
// Shutdown metrics executor
metricsExecutor.shutdown()
try {
if (!metricsExecutor.awaitTermination(30, TimeUnit.SECONDS)) {
metricsExecutor.shutdownNow()
}
}
catch (InterruptedException e) {
metricsExecutor.shutdownNow()
Thread.currentThread().interrupt()
}
log.debug "[SEQERA] Batch submitter shutdown complete"
}
/**
* Sender thread loop
*/
protected void sendTasks0(dummy) {
final List<PendingTask> batch = new ArrayList<>(TASKS_PER_REQUEST)
long previous = System.currentTimeMillis()
final long period = requestInterval.millis
final long delay = period / 10 as long
try {
while (!completed || !pendingQueue.isEmpty()) {
// Poll with timeout
final PendingTask pending = pendingQueue.poll(delay, TimeUnit.MILLISECONDS)
if (pending) {
// Start the batch timer when first task arrives
if (batch.isEmpty()) {
previous = System.currentTimeMillis()
}
batch.add(pending)
}
// Check if we should flush
final now = System.currentTimeMillis()
final delta = now - previous
if (!batch.isEmpty()) {
// Flush if: time elapsed OR batch full OR shutting down
if (delta > period || batch.size() >= TASKS_PER_REQUEST || completed) {
flushBatch(batch)
previous = System.currentTimeMillis()
batch.clear()
}
}
else if (delta > keepAliveInterval.millis) {
// Keep-alive: send empty submission to maintain run
try {
log.debug "[SEQERA] Sending keep-alive for run ${runId}"
client.createTasks(runId, Collections.emptyList())
}
catch (Exception e) {
log.warn "[SEQERA] Keep-alive failed: ${e.message}"
// Don't crash the thread for keep-alive failures
}
// Always update timestamp to avoid rapid retry on failure
previous = System.currentTimeMillis()
}
}
// Final flush of any remaining tasks
if (!batch.isEmpty()) {
flushBatch(batch)
}
}
catch (Throwable e) {
log.error "[SEQERA] Fatal error in batch submitter thread", e
// Convert Throwable to Exception for handler API
final Exception exception = e instanceof Exception ? (Exception) e : new RuntimeException(e)
// Fail any tasks in the current batch
for (PendingTask pending : batch) {
try {
pending.handler.onBatchSubmitFailure(exception)
}
catch (Exception ex) {
log.warn "[SEQERA] Error failing batch task", ex
}
}
// Drain and fail any remaining pending tasks
drainAndFailPendingTasks(exception)
// Invoke error callback to abort run
if (onError) {
try {
onError.call(e)
}
catch (Throwable t) {
log.warn "[SEQERA] Error in failure callback", t
}
}
}
}
/**
* Drain the pending queue and fail all tasks with the given error
*/
private void drainAndFailPendingTasks(Exception cause) {
PendingTask pending
while ((pending = pendingQueue.poll()) != null) {
try {
pending.handler.onBatchSubmitFailure(cause)
}
catch (Exception e) {
log.warn "[SEQERA] Error failing pending task", e
}
}
}
/**
* Submit a batch of tasks to the scheduler API
*/
protected void flushBatch(List<PendingTask> batch) {
log.debug "[SEQERA] Submitting batch of ${batch.size()} tasks"
try {
// Resolve async metrics for all tasks in batch
resolveMetrics(batch)
// Extract Task objects for API call
final List<Task> tasks = batch.collect { it.task }
// Submit batch to API
final response = client.createTasks(runId, tasks)
final List<String> taskIds = response.getTaskIds()
// Validate response
if (taskIds.size() != batch.size()) {
throw new IllegalStateException("Seqera Scheduler API returned ${taskIds.size()} task IDs but submitted ${batch.size()} tasks")
}
// Map task IDs back to handlers
for (int i = 0; i < batch.size(); i++) {
final handler = batch[i].handler
final taskId = taskIds[i]
handler.setBatchTaskId(taskId)
}
log.debug "[SEQERA] Batch submission complete: ${taskIds.size()} tasks submitted"
} catch (Exception e) {
log.error "[SEQERA] Batch submission failed for ${batch.size()} tasks", e
// Propagate failure to all handlers in this batch
for (PendingTask pending : batch) {
try {
pending.handler.onBatchSubmitFailure(e)
} catch (Exception ex) {
log.warn "[SEQERA] Error handling batch failure for task", ex
}
}
}
}
/**
* Wait for and attach metrics to all tasks in the batch.
* Uses timeout to avoid blocking indefinitely on slow computations.
*/
private void resolveMetrics(List<PendingTask> batch) {
final timeout = METRICS_TIMEOUT.millis
for (PendingTask pending : batch) {
try {
final metrics = pending.metricsFuture.get(timeout, TimeUnit.MILLISECONDS)
if (metrics) {
pending.task.inputFiles(metrics)
log.debug "[SEQERA] Task `${pending.handler.task.name}` input files metrics: ${metrics}"
}
}
catch (TimeoutException e) {
log.warn "[SEQERA] Timeout computing input files metrics for task: ${pending.handler.task.name}"
pending.metricsFuture.cancel(true)
}
catch (Exception e) {
log.warn "[SEQERA] Failed to compute input files metrics for task: ${pending.handler.task.name} - ${e.message}"
}
}
}
}

View File

@@ -0,0 +1,239 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.seqera.executor
import groovy.transform.CompileStatic
import groovy.transform.PackageScope
import groovy.util.logging.Slf4j
import io.seqera.config.SeqeraConfig
import io.seqera.config.ExecutorOpts
import io.seqera.util.SchemaMapperUtil
import io.seqera.sched.api.schema.v1a1.CreateRunRequest
import io.seqera.sched.api.schema.v1a1.PipelineSpec
import io.seqera.sched.api.schema.v1a1.PredictionModel
import io.seqera.sched.client.SchedClient
import io.seqera.sched.api.schema.v1a1.TerminateRunRequest
import io.seqera.sched.client.SchedClientConfig
import nextflow.exception.AbortOperationException
import nextflow.executor.Executor
import nextflow.fusion.FusionHelper
import nextflow.platform.PlatformHelper
import nextflow.processor.TaskHandler
import nextflow.processor.TaskMonitor
import nextflow.processor.TaskPollingMonitor
import nextflow.processor.TaskRun
import nextflow.SysEnv
import nextflow.util.Duration
import nextflow.util.ServiceName
import org.pf4j.ExtensionPoint
/**
* Nextflow executor that delegates task execution to the Seqera scheduler API.
*
* <p>This executor creates a run on the Seqera scheduler, submits tasks in batches
* via {@link SeqeraBatchSubmitter}, and monitors their lifecycle through the scheduler API.
* It requires Fusion file system to be enabled and all processes to specify a container image.
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@Slf4j
@ServiceName(SEQERA)
@CompileStatic
class SeqeraExecutor extends Executor implements ExtensionPoint {
public static final String SEQERA = 'seqera'
private static final String DEFAULT_FUSION_VERSION = '2.6'
private ExecutorOpts seqeraConfig
private SchedClient client
private volatile String runId
private volatile Map<String,String> runResourceLabels = Collections.<String,String>emptyMap()
private SeqeraBatchSubmitter batchSubmitter
@Override
protected void register() {
applyFusionDefaults()
createClient()
}
protected void applyFusionDefaults() {
final fusionConfig = session.config.fusion as Map
if( fusionConfig!=null && !fusionConfig.containerConfigUrl ) {
fusionConfig.put('targetVersion', DEFAULT_FUSION_VERSION)
}
}
@Override
void shutdown() {
// Flush any pending batch jobs before terminating run
session.error
batchSubmitter?.shutdown()
terminateRun()
}
protected void createClient() {
final seqera = new SeqeraConfig(session.config.seqera as Map ?: Collections.<String,Object>emptyMap())
this.seqeraConfig = seqera.executor
if (!seqeraConfig)
throw new IllegalArgumentException("Missing Seqera executor configuration - make sure to specify 'seqera.executor' settings")
// Get access token and refresh token from tower config (shares authentication with Platform)
def towerConfig = session.config.tower as Map ?: Collections.emptyMap()
def accessToken = PlatformHelper.getAccessToken(towerConfig, SysEnv.get())
def refreshToken = PlatformHelper.getRefreshToken(towerConfig, SysEnv.get())
def platformUrl = PlatformHelper.getEndpoint(towerConfig, SysEnv.get())
def clientConfig = SchedClientConfig.builder()
.endpoint(seqeraConfig.endpoint)
.platformUrl(platformUrl)
.accessToken(accessToken)
.refreshToken(refreshToken)
.retryConfig(seqeraConfig.retryOpts())
.build()
this.client = new SchedClient(clientConfig)
}
protected void createRun() {
final towerConfig = session.config.tower as Map ?: Collections.emptyMap()
final workflowId = session.workflowMetadata?.platform?.workflowId
final workflowUrl = session.workflowMetadata?.platform?.workflowUrl
final workspaceId = PlatformHelper.getWorkspaceId(towerConfig, SysEnv.get()) as Long
final computeEnvId = PlatformHelper.getComputeEnvId(towerConfig, SysEnv.get()) ?: seqeraConfig.computeEnvId
computeRunResourceLabels()
final labels = new Labels()
if( seqeraConfig.autoLabels )
labels.withWorkflowMetadata(session.workflowMetadata, seqeraConfig.autoLabels)
labels.withProcessResourceLabels(runResourceLabels)
final predictionModel = seqeraConfig.predictionModel ? PredictionModel.fromValue(seqeraConfig.predictionModel) : null
final pipeline = new PipelineSpec()
.workflowId(workflowId)
.workflowUrl(workflowUrl)
.workDir(session.workDir?.toUriString())
final request = new CreateRunRequest()
.provider(seqeraConfig.provider)
.region(seqeraConfig.region)
.name(session.runName)
.machineRequirement(SchemaMapperUtil.toMachineRequirement(seqeraConfig.machineRequirement))
.labels(labels.entries)
.workspaceId(workspaceId)
.pipeline(pipeline)
.predictionModel(predictionModel)
.computeEnvId(computeEnvId)
log.debug "[SEQERA] Creating run: ${request}"
final response = client.createRun(request)
this.runId = response.getRunId()
log.debug "[SEQERA] Run created id: ${runId}; workflowId: '${workflowId}'; workflowUrl: '${workflowUrl}'"
// Initialize and start batch submitter with error callback to abort on fatal errors
this.batchSubmitter = new SeqeraBatchSubmitter(
client,
runId,
seqeraConfig.batchFlushInterval,
SeqeraBatchSubmitter.KEEP_ALIVE_INTERVAL,
{ Throwable t -> session.abort(t) }
)
this.batchSubmitter.start()
}
protected void terminateRun() {
if (!runId) {
return
}
final stopReason = truncate(session.fault?.report, 10_000)
log.debug "[SEQERA] Terminating run: ${runId}; stopReason: ${stopReason}"
client.terminateRun(runId, new TerminateRunRequest().stopReason(stopReason))
log.debug "[SEQERA] Run terminated"
}
@Override
protected TaskMonitor createTaskMonitor() {
TaskPollingMonitor.create(session, config, name, 1000, Duration.of('10 sec'))
}
@Override
TaskHandler createTaskHandler(TaskRun task) {
return new SeqeraTaskHandler(task, this)
}
/**
* @return {@code true} whenever the containerization is managed by the executor itself
*/
boolean isContainerNative() {
return true
}
@Override
boolean isFusionEnabled() {
final enabled = FusionHelper.isFusionEnabled(session)
if (!enabled)
throw new AbortOperationException("Seqera executor requires the use of Fusion file system")
return true
}
/**
* Lazily creates the run on first access, ensuring workflowId and labels
* are available (they are set by TowerClient.onFlowCreate before tasks are submitted).
*/
void ensureRunCreated() {
if (runId) return
synchronized (this) {
if (runId) return
createRun()
}
}
SchedClient getClient() {
return client
}
String getRunId() {
return runId
}
Map<String,String> getRunResourceLabels() {
return Collections.unmodifiableMap(runResourceLabels)
}
@PackageScope
void computeRunResourceLabels() {
final processMap = session.config.process as Map
final value = processMap?.get('resourceLabels')
if( value instanceof Closure ) {
log.debug "Skipping run-level process.resourceLabels: dynamic (closure) values are only resolved per-task"
this.runResourceLabels = Collections.<String,String>emptyMap()
return
}
this.runResourceLabels = Labels.toStringMap(value)
}
SeqeraBatchSubmitter getBatchSubmitter() {
return batchSubmitter
}
ExecutorOpts getSeqeraConfig() {
return seqeraConfig
}
protected static String truncate(String value, int maxLen) {
if (!value || value.length() <= maxLen)
return value
return value.take(maxLen) + '\n.. [TRUNCATED]'
}
}

View File

@@ -0,0 +1,432 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.seqera.executor
import java.nio.file.Path
import groovy.transform.CompileStatic
import groovy.transform.PackageScope
import groovy.util.logging.Slf4j
import io.seqera.executor.Labels
import io.seqera.sched.api.schema.v1a1.AcceleratorType
import io.seqera.sched.api.schema.v1a1.GetTaskLogsResponse
import io.seqera.sched.api.schema.v1a1.NextflowTask
import io.seqera.sched.api.schema.v1a1.ResourceLimit
import io.seqera.sched.api.schema.v1a1.ResourceRequirement
import io.seqera.sched.api.schema.v1a1.Task
import io.seqera.sched.api.schema.v1a1.TaskState as SchedTaskState
import io.seqera.sched.api.schema.v1a1.TaskStatus as SchedTaskStatus
import io.seqera.sched.client.SchedClient
import io.seqera.util.HintHelper
import io.seqera.util.SchemaMapperUtil
import nextflow.cloud.types.CloudMachineInfo
import nextflow.exception.ProcessException
import nextflow.exception.ProcessUnrecoverableException
import nextflow.util.Duration
import nextflow.util.MemoryUnit
import nextflow.fusion.FusionAwareTask
import nextflow.processor.TaskHandler
import nextflow.processor.TaskRun
import nextflow.processor.TaskStatus
import nextflow.trace.TraceRecord
/**
* Task handler for the Seqera scheduler executor.
*
* <p>Manages the lifecycle of a single task submitted to the Seqera scheduler,
* including submission via batch submitter, status polling, completion handling,
* and trace record enrichment with machine info and spot interruption metadata.
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@Slf4j
@CompileStatic
class SeqeraTaskHandler extends TaskHandler implements FusionAwareTask {
private SchedClient client
private SeqeraExecutor executor
private Path exitFile
private Path outputFile
private Path errorFile
private volatile String taskId
/**
* Cached task state from last describeTask call, used for trace record metadata
*/
private volatile SchedTaskState cachedTaskState
/**
* Cached machine info extracted from task attempts
*/
private volatile CloudMachineInfo machineInfo
SeqeraTaskHandler(TaskRun task, SeqeraExecutor executor) {
super(task)
this.client = executor.getClient()
this.executor = executor
// those files are access via NF runtime, keep based on CloudStoragePath
this.outputFile = task.workDir.resolve(TaskRun.CMD_OUTFILE)
this.errorFile = task.workDir.resolve(TaskRun.CMD_ERRFILE)
this.exitFile = task.workDir.resolve(TaskRun.CMD_EXIT)
}
@Override
void prepareLauncher() {
assert fusionEnabled()
final launcher = fusionLauncher()
launcher.build()
}
@Override
void submit() {
executor.ensureRunCreated()
int cpuShares = (task.config.getCpus() ?: 1) * 1024
int memoryMiB = task.config.getMemory() ? (int) (task.config.getMemory().toBytes() / (1024 * 1024)) : 1024
final resourceReq = new ResourceRequirement()
.cpuShares(cpuShares)
.memoryMiB(memoryMiB)
// add accelerator settings if defined
final accelerator = task.config.getAccelerator()
if( accelerator ) {
// number of accelerators requested, fallback to limit if request is not specified
resourceReq.acceleratorCount(accelerator.request ?: accelerator.limit)
// accelerator type is GPU by default (most common in scientific computing)
resourceReq.acceleratorType(AcceleratorType.GPU)
// specific accelerator model name e.g. "nvidia-tesla-v100", "nvidia-a10g"
if( accelerator.type )
resourceReq.acceleratorName(accelerator.type)
}
// build machine requirement merging config settings with task arch, disk, and snapshot settings
// overlay any seqera/machineRequirement.* hints on top of config-scope values (hints win)
final baseMachineOpts = HintHelper.overlayHints(
executor.getSeqeraConfig().machineRequirement,
task.config.getHints()
)
final machineReq = SchemaMapperUtil.toMachineRequirement(
baseMachineOpts,
task.getContainerPlatform(),
task.config.getDisk(),
fusionConfig().snapshotsEnabled()
)
// build resource limit from process resourceLimits directive (upper bound for OOM retry scaling)
final resourceLim = toResourceLimit()
// validate container - Seqera executor requires all processes to specify a container image
final container = task.getContainer()
if( !container )
throw new ProcessUnrecoverableException("Process `${task.lazyName()}` failed because the container image was not specified -- the Seqera executor requires all processes define a container image")
// build the scheduler task with all required attributes
final schedTask = new Task()
.name(task.lazyName()) // process name for identification
.image(container) // container image to run
.command(fusionSubmitCli()) // fusion-based command launcher
.environment(getTaskEnvironment()) // fusion + user-configured environment variables
.resourceRequirement(resourceReq) // cpu, memory, accelerators
.resourceLimit(resourceLim) // resource upper bounds for OOM retry
.machineRequirement(machineReq) // machine type and disk requirements
.nextflow(new NextflowTask()
.taskId(task.id?.intValue())
.hash(task.hash?.toString())
.workDir(task.getWorkDirStr()))
// attach per-task resource labels delta (over run-level baseline)
final taskLabels = Labels.toStringMap(task.config.getResourceLabels())
final delta = Labels.delta(taskLabels, executor.runResourceLabels)
if( delta )
schedTask.labels(delta)
log.debug "[SEQERA] Enqueueing task for batch submission: ${schedTask}"
// Enqueue for batch submission - status will be set by setBatchTaskId callback
executor.getBatchSubmitter().submit(this, schedTask)
}
/**
* Build the task environment by merging user-configured environment variables
* with Fusion environment variables. Fusion variables take precedence.
*/
protected Map<String, String> getTaskEnvironment() {
final configEnv = executor.getSeqeraConfig()?.taskEnvironment
final fusionEnv = fusionLauncher().fusionEnv()
if( !configEnv )
return fusionEnv
final result = new LinkedHashMap<String, String>(configEnv)
result.putAll(fusionEnv)
return result
}
/**
* Called by batch submitter after successful batch submission
*/
void setBatchTaskId(String taskId) {
this.taskId = taskId
this.status = TaskStatus.SUBMITTED
log.debug "[SEQERA] Process `${task.lazyName()}` submitted > taskId=$taskId; work-dir=${task.getWorkDirStr()}"
}
/**
* Called by batch submitter when batch submission fails
*/
void onBatchSubmitFailure(Exception cause) {
log.debug "[SEQERA] Batch submission failed for task ${task.lazyName()}: ${cause.message}"
task.error = cause
this.status = TaskStatus.COMPLETED
}
/**
* Build a {@link ResourceLimit} from the process {@code resourceLimits} directive.
* Returns {@code null} if no resource limits are defined.
*/
protected ResourceLimit toResourceLimit() {
final memoryLimit = task.config.getResourceLimit('memory') as MemoryUnit
final cpusLimit = task.config.getResourceLimit('cpus') as Integer
if( !memoryLimit && !cpusLimit )
return null
final result = new ResourceLimit()
if( memoryLimit )
result.memoryMiB((int)(memoryLimit.toBytes() / (1024 * 1024)))
if( cpusLimit )
result.cpuShares(cpusLimit * 1024)
return result
}
protected SchedTaskStatus schedTaskStatus() {
cachedTaskState = client.describeTask(taskId).getTaskState()
return cachedTaskState.getStatus()
}
@Override
boolean checkIfRunning() {
if (isSubmitted()) {
final schedStatus = schedTaskStatus()
log.debug "[SEQERA] checkIfRunning taskId=${taskId}; status=${schedStatus}"
if (isRunningOrTerminated(schedStatus)) {
status = TaskStatus.RUNNING
return true
}
}
return false
}
@Override
boolean checkIfCompleted() {
// Handle batch submission failure - task error was set but never reached RUNNING state
if (task.error && isCompleted()) {
return true
}
if (!isRunning())
return false
final schedStatus = schedTaskStatus()
log.debug "[SEQERA] checkIfCompleted status=${schedStatus}"
if (isTerminated(schedStatus)) {
log.debug "[SEQERA] Process `${task.lazyName()}` - terminated taskId=$taskId; status=$schedStatus"
// finalize the task
task.exitStatus = readExitFile()
if (isFailed(schedStatus)) {
// When no exit code available, get the error message from task state
if (task.exitStatus == Integer.MAX_VALUE) {
final errorMessage = cachedTaskState?.getErrorMessage() ?: "Task failed for unknown reason"
task.error = new ProcessException(errorMessage)
}
final logs = getTaskLogs(taskId)
task.stdout = logs?.stdout ?: outputFile
task.stderr = logs?.stderr ?: errorFile
} else {
task.stdout = outputFile
task.stderr = errorFile
}
status = TaskStatus.COMPLETED
return true
}
return false
}
protected boolean isRunningOrTerminated(SchedTaskStatus status) {
return status == SchedTaskStatus.RUNNING || isTerminated(status)
}
protected boolean isTerminated(SchedTaskStatus status) {
return status in [SchedTaskStatus.SUCCEEDED, SchedTaskStatus.FAILED, SchedTaskStatus.CANCELLED]
}
protected boolean isFailed(SchedTaskStatus status) {
return status == SchedTaskStatus.FAILED
}
protected GetTaskLogsResponse getTaskLogs(String taskId) {
return client.getTaskLogs(taskId)
}
@Override
protected void killTask() {
if( !taskId ) {
log.trace "[SEQERA] Skip cancel - taskId not yet assigned"
return
}
log.debug "[SEQERA] Cancel taskId=${taskId}"
try {
client.cancelTask(taskId)
}
catch (Throwable t) {
log.warn "[SEQERA] Failed to cancel task ${taskId}", t
}
}
@PackageScope
Integer readExitFile() {
try {
final result = exitFile.text as Integer
log.trace "[SEQERA] Read exit file for taskId $taskId; exit=${result}"
return result
}
catch (Exception e) {
log.debug "[SEQERA] Cannot read exit status for task: `${task.lazyName()}` - ${e.message}"
// return MAX_VALUE to signal it was unable to retrieve the exit code
return Integer.MAX_VALUE
}
}
/**
* Get machine info for the task execution from the last task attempt.
* The machine info is cached after first retrieval.
*
* @return CloudMachineInfo containing instance type, zone, and price model, or null if not available
*/
protected CloudMachineInfo getMachineInfo() {
if (machineInfo)
return machineInfo
if (!cachedTaskState)
return null
try {
final attempts = cachedTaskState.getAttempts()
if (!attempts || attempts.isEmpty())
return null
final lastAttempt = attempts.get(attempts.size() - 1)
final lastInfo = lastAttempt.getMachineInfo()
if (!lastInfo)
return null
// Convert Sched API MachineInfo to Nextflow CloudMachineInfo
machineInfo = new CloudMachineInfo(
type: lastInfo.getType(),
zone: lastInfo.getZone(),
priceModel: SchemaMapperUtil.toPriceModel(lastInfo.getPriceModel())
)
log.trace "[SEQERA] taskId=$taskId => machineInfo=$machineInfo"
return machineInfo
}
catch (Exception e) {
log.debug "[SEQERA] Unable to get machine info for taskId=$taskId - ${e.message}"
return null
}
}
/**
* Get the number of spot interruptions for this task.
* This is calculated server-side from task attempts with spot-related stop reasons.
*
* @return the count of spot interruptions, or null if not completed or not available
*/
protected Integer getNumSpotInterruptions() {
if (!taskId || !isCompleted())
return null
if (!cachedTaskState)
return null
return cachedTaskState.getNumSpotInterruptions()
}
/**
* Get the log stream identifier for this task.
*
* @return the log stream ID, or null if not available
*/
protected String getLogStreamId() {
return cachedTaskState?.getLogStreamId()
}
/**
* Get the native backend ID for this task (ECS task ARN or Docker container ID).
*
* @return the native ID from the last task attempt, or null if not available
*/
protected String getNativeId() {
return cachedTaskState?.getId()
}
/**
* Get the allocated resources for this task from the last task attempt.
* Falls back to the resource requirement from the task state if no attempts exist.
*
* @return a map of allocated resource fields, or null if not available
*/
protected Map<String,Object> getResourceAllocation() {
if (!cachedTaskState)
return null
def resources = null
final attempts = cachedTaskState.getAttempts()
if (attempts && !attempts.isEmpty()) {
resources = attempts.get(attempts.size() - 1).getResources()
}
if (!resources) {
resources = cachedTaskState.getResourceAllocation()
}
if (!resources)
return null
final result = new LinkedHashMap<String,Object>()
if (resources.getCpuShares() != null)
result.put('cpuShares', resources.getCpuShares())
if (resources.getMemoryMiB() != null)
result.put('memoryMiB', resources.getMemoryMiB())
if (resources.getAcceleratorCount() != null)
result.put('acceleratorCount', resources.getAcceleratorCount())
if (resources.getAcceleratorType() != null)
result.put('acceleratorType', resources.getAcceleratorType().toString())
if (resources.getAcceleratorName() != null)
result.put('acceleratorName', resources.getAcceleratorName())
if (resources.getTime() != null)
result.put('time', resources.getTime())
return result.isEmpty() ? null : result
}
protected Long getGrantedTime() {
final String time = cachedTaskState?.getResourceAllocation()?.getTime()
return time != null ? Duration.of(time).toMillis() : task.config.getTime()?.toMillis()
}
/**
* Get the trace record for this task, including machine info and spot interruptions metadata.
*
* @return the trace record with additional metadata fields
*/
@Override
TraceRecord getTraceRecord() {
final result = super.getTraceRecord()
result.put('native_id', getNativeId())
result.machineInfo = getMachineInfo()
result.numSpotInterruptions = getNumSpotInterruptions()
result.logStreamId = getLogStreamId()
result.resourceAllocation = getResourceAllocation()
// Override executor name to include cloud backend for cost tracking
result.executorName = "${SeqeraExecutor.SEQERA}/aws"
return result
}
}

View File

@@ -0,0 +1,34 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.seqera.plugin
import groovy.transform.CompileStatic
import nextflow.plugin.BasePlugin
import org.pf4j.PluginWrapper
/**
* Seqera plugin entry point
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@CompileStatic
class SeqeraPlugin extends BasePlugin {
SeqeraPlugin(PluginWrapper wrapper) {
super(wrapper)
}
}

View File

@@ -0,0 +1,127 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.seqera.util
import java.lang.reflect.Field
import groovy.transform.CompileStatic
import io.seqera.config.MachineRequirementOpts
import nextflow.config.spec.ConfigOption
/**
* Helper for processing {@code seqera/machineRequirement.*} hints from the
* {@code hints} process directive and overlaying them onto
* {@link MachineRequirementOpts} config-scope values.
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@CompileStatic
class HintHelper {
static final String PREFIX = 'seqera/'
static final String MR_PREFIX = 'machineRequirement.'
private static final List<Field> MR_FIELDS = Collections.unmodifiableList(
MachineRequirementOpts.declaredFields
.findAll { Field f -> f.isAnnotationPresent(ConfigOption) }
.collect { Field f -> f.setAccessible(true); f } as List<Field>
)
static final Set<String> KNOWN_KEYS = Collections.unmodifiableSet(
MR_FIELDS.collect { MR_PREFIX + it.name }.toSet()
)
private static final String SUPPORTED_KEYS_MSG =
KNOWN_KEYS.collect { PREFIX + it }.sort().join(', ')
/**
* Extract hints consumed by the Seqera executor and validate them.
*
* <p>Both {@code seqera/}-prefixed and unprefixed keys that match one of the
* {@link #KNOWN_KEYS} are returned with the prefix (if any) stripped. When the
* same logical key appears both prefixed and unprefixed, the prefixed form
* wins (executor-targeted hints override the general form).</p>
*
* <p>Foreign-namespaced keys (e.g. {@code awsbatch/...}) and unprefixed keys
* that are not recognized are ignored — they may be targeted at another
* executor. Unrecognized {@code seqera/}-prefixed keys raise an error, since
* they were explicitly targeted at this executor.</p>
*
* @param hints the full hints map from task config
* @return a map of known hint names (no prefix) to values
*/
static Map<String, Object> extractSeqeraHints(Map<String, Object> hints) {
if( !hints )
return Collections.emptyMap()
final unprefixed = new LinkedHashMap<String, Object>()
final prefixed = new LinkedHashMap<String, Object>()
for( Map.Entry<String, Object> entry : hints.entrySet() ) {
final key = entry.key
if( !key )
continue
if( key.startsWith(PREFIX) ) {
final stripped = key.substring(PREFIX.length())
if( !KNOWN_KEYS.contains(stripped) )
throw new IllegalArgumentException("Unknown Seqera Platform hint: '${key}' — supported keys are: ${SUPPORTED_KEYS_MSG}")
prefixed.put(stripped, entry.value)
}
else if( !key.contains('/') && KNOWN_KEYS.contains(key) ) {
unprefixed.put(key, entry.value)
}
}
unprefixed.putAll(prefixed)
return unprefixed
}
/**
* Overlay {@code machineRequirement.*} hints onto existing config-scope
* {@link MachineRequirementOpts}. Hint values take precedence over
* config-scope values.
*
* @param baseOpts the config-scope machine requirement options
* @param hints the full hints map from task config
* @return a new {@link MachineRequirementOpts} with hints overlaid
*/
static MachineRequirementOpts overlayHints(MachineRequirementOpts baseOpts, Map<String, Object> hints) {
final seqeraHints = extractSeqeraHints(hints)
if( !seqeraHints )
return baseOpts
final Map<String, Object> merged = new LinkedHashMap<>()
for( final field : MR_FIELDS ) {
final value = field.get(baseOpts)
if( value != null )
merged.put(field.name, value)
}
for( Map.Entry<String, Object> entry : seqeraHints.entrySet() ) {
final fieldName = entry.key.substring(MR_PREFIX.length())
final value = entry.value
if( value == null ) {
merged.remove(fieldName)
continue
}
merged.put(fieldName, value)
}
return new MachineRequirementOpts(merged)
}
}

View File

@@ -0,0 +1,288 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.seqera.util
import groovy.transform.CompileStatic
import io.seqera.config.MachineRequirementOpts
import io.seqera.sched.api.schema.v1a1.DiskAllocation
import io.seqera.sched.api.schema.v1a1.DiskRequirement
import io.seqera.sched.api.schema.v1a1.EcsCapacityMode
import io.seqera.sched.api.schema.v1a1.MachineRequirement
import io.seqera.sched.api.schema.v1a1.PriceModel as SchedPriceModel
import io.seqera.sched.api.schema.v1a1.ProvisioningModel
import nextflow.cloud.types.PriceModel
import nextflow.fusion.FusionConfig
import nextflow.util.MemoryUnit
/**
* Utility class to map Nextflow config objects to Sched API schema objects.
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@CompileStatic
class SchemaMapperUtil {
/** Default EBS volume type - gp3 provides good balance of price and performance */
static final String DEFAULT_DISK_TYPE = 'ebs/gp3'
/** Default throughput in MiB/s - Fusion recommended setting for optimal I/O */
static final int DEFAULT_DISK_THROUGHPUT_MIBPS = 325
/** Supported EBS volume types */
static final Set<String> SUPPORTED_DISK_TYPES = Set.of(
'ebs/gp3', // General purpose SSD (default)
'ebs/gp2', // General purpose SSD (legacy)
'ebs/io1', // Provisioned IOPS SSD
'ebs/io2', // Provisioned IOPS SSD (higher durability)
'ebs/st1', // Throughput optimized HDD
'ebs/sc1' // Cold HDD
)
/**
* Maps MachineRequirementOpts to MachineRequirement API object.
*
* @param opts the config options (can be null)
* @return the MachineRequirement API object, or null if opts is null or has no settings
*/
static MachineRequirement toMachineRequirement(MachineRequirementOpts opts) {
if (!opts)
return null
final diskReq = toDiskRequirement(opts.diskSize, opts)
final capacityMode = toEcsCapacityMode(opts.capacityMode)
if (!opts.provisioning && !opts.maxSpotAttempts && !opts.machineTypes && !diskReq && !capacityMode)
return null
new MachineRequirement()
.provisioning(toProvisioningModel(opts.provisioning))
.maxSpotAttempts(opts.maxSpotAttempts)
.machineTypes(opts.machineTypes)
.disk(diskReq)
.capacityMode(capacityMode)
}
/**
* Maps MachineRequirementOpts to MachineRequirement API object, merging with task arch.
*
* @param opts the config options (can be null)
* @param taskArch the task container platform/arch (can be null)
* @return the MachineRequirement API object, or null if no settings
*/
static MachineRequirement toMachineRequirement(MachineRequirementOpts opts, String taskArch) {
return toMachineRequirement(opts, taskArch, null, false)
}
/**
* Maps MachineRequirementOpts to MachineRequirement API object, merging with task arch, disk, and snapshots.
*
* @param opts the config options (can be null)
* @param taskArch the task container platform/arch (can be null)
* @param diskSize the disk size from task config (can be null)
* @param snapshotEnabled whether Fusion snapshots are enabled
* @return the MachineRequirement API object, or null if no settings
*/
static MachineRequirement toMachineRequirement(MachineRequirementOpts opts, String taskArch, MemoryUnit diskSize, boolean snapshotEnabled) {
final arch = taskArch
final provisioning = opts?.provisioning
final maxSpotAttempts = opts?.maxSpotAttempts
?: (snapshotEnabled ? FusionConfig.DEFAULT_SNAPSHOT_MAX_SPOT_ATTEMPTS : null)
final machineTypes = opts?.machineTypes
// task disk overrides config disk
final effectiveDiskSize = diskSize ?: opts?.diskSize
final diskReq = toDiskRequirement(effectiveDiskSize, opts)
final capacityMode = toEcsCapacityMode(opts?.capacityMode)
// return null if no settings
if (!arch && !provisioning && !maxSpotAttempts && !machineTypes && !diskReq && !snapshotEnabled && !capacityMode)
return null
new MachineRequirement()
.arch(arch)
.provisioning(toProvisioningModel(provisioning))
.maxSpotAttempts(maxSpotAttempts)
.machineTypes(machineTypes)
.disk(diskReq)
.snapshotEnabled(snapshotEnabled ? Boolean.TRUE : null)
.capacityMode(capacityMode)
}
/**
* Maps a disk size to DiskRequirement API object.
* Uses config options if provided, otherwise defaults to Fusion recommended settings:
* EBS gp3 volume with 325 MiB/s throughput.
*
* For 'node' allocation (default), only sizeGiB and mountPath are applicable.
* For 'task' allocation, all EBS options can be specified.
*
* @param diskSize the disk size (can be null)
* @param opts the machine requirement options with disk settings (can be null)
* @return the DiskRequirement API object, or null if diskSize is null or zero
*/
static DiskRequirement toDiskRequirement(MemoryUnit diskSize, MachineRequirementOpts opts=null) {
final allocation = toDiskAllocation(opts?.diskAllocation)
// For NVMe allocation, disk size is instance-determined — no size required
if (allocation == DiskAllocation.NVME) {
validateNvmeAllocationOpts(opts)
final DiskRequirement req = new DiskRequirement()
req.sizeGiB(diskSize ? diskSize.toGiga() as Integer : 0)
req.allocation(allocation)
req.mountPath(opts?.diskMountPath)
return req
}
if (!diskSize || diskSize.toGiga() <= 0)
return null
final effectiveAllocation = allocation ?: DiskAllocation.NODE
// For 'node' allocation (default), only size and mountPath are valid
if (effectiveAllocation == DiskAllocation.NODE) {
validateNodeAllocationOpts(opts)
final DiskRequirement req = new DiskRequirement()
req.sizeGiB(diskSize.toGiga() as Integer)
req.allocation(effectiveAllocation)
req.mountPath(opts?.diskMountPath)
return req
}
// For 'task' allocation, apply EBS-specific options
final type = opts?.diskType ?: DEFAULT_DISK_TYPE
// Validate disk type is supported
if (!SUPPORTED_DISK_TYPES.contains(type)) {
throw new IllegalArgumentException("Invalid disk type: ${type}. Supported types: ${SUPPORTED_DISK_TYPES.join(', ')}")
}
final throughput = opts?.diskThroughputMiBps ?: DEFAULT_DISK_THROUGHPUT_MIBPS
final iops = opts?.diskIops
final encrypted = opts?.diskEncrypted ?: false
final DiskRequirement req = new DiskRequirement()
req.sizeGiB(diskSize.toGiga() as Integer)
req.volumeType(type)
req.encrypted(encrypted)
req.allocation(allocation)
// Only set throughput for gp3 volumes
if (type == DEFAULT_DISK_TYPE) {
req.throughputMiBps(throughput)
}
// Set IOPS if provided
if (iops) {
req.iops(iops)
}
req.mountPath(opts?.diskMountPath)
return req
}
/**
* Validates that no EBS-specific options are set when using 'node' allocation.
* Node allocation uses instance storage, not EBS volumes.
*
* @param opts the machine requirement options
* @throws IllegalArgumentException if EBS-specific options are set with node allocation
*/
private static void validateNodeAllocationOpts(MachineRequirementOpts opts) {
if (!opts)
return
final List<String> invalidOpts = []
if (opts.diskType)
invalidOpts.add('diskType')
if (opts.diskThroughputMiBps)
invalidOpts.add('diskThroughputMiBps')
if (opts.diskIops)
invalidOpts.add('diskIops')
if (opts.diskEncrypted)
invalidOpts.add('diskEncrypted')
if (invalidOpts) {
throw new IllegalArgumentException(
"The following options are not valid with 'node' disk allocation: ${invalidOpts.join(', ')}. " +
"Node allocation uses instance storage; only disk size is applicable."
)
}
}
/**
* Validates that no EBS-specific options are set when using 'nvme' allocation.
* NVMe uses instance store disks, not EBS volumes.
*/
private static void validateNvmeAllocationOpts(MachineRequirementOpts opts) {
if (!opts)
return
final List<String> invalidOpts = []
if (opts.diskType)
invalidOpts.add('diskType')
if (opts.diskThroughputMiBps)
invalidOpts.add('diskThroughputMiBps')
if (opts.diskIops)
invalidOpts.add('diskIops')
if (opts.diskEncrypted)
invalidOpts.add('diskEncrypted')
if (invalidOpts) {
throw new IllegalArgumentException(
"The following options are not valid with 'nvme' disk allocation: ${invalidOpts.join(', ')}. " +
"NVMe uses instance store disks; EBS options are not applicable."
)
}
}
/**
* Maps a disk allocation string to DiskAllocation enum.
*
* @param value the disk allocation string (task, node)
* @return the DiskAllocation enum value, or null if value is null
*/
static DiskAllocation toDiskAllocation(String value) {
value ? DiskAllocation.fromValue(value) : null
}
/**
* Maps a capacity mode string to EcsCapacityMode enum.
*
* @param value the capacity mode string (managed, asg)
* @return the EcsCapacityMode enum value, or null if value is null
*/
static EcsCapacityMode toEcsCapacityMode(String value) {
value ? EcsCapacityMode.fromValue(value) : null
}
/**
* Maps a provisioning string to ProvisioningModel enum.
*
* @param value the provisioning string (spot, ondemand, spotFirst)
* @return the ProvisioningModel enum value, or null if value is null
*/
static ProvisioningModel toProvisioningModel(String value) {
value ? ProvisioningModel.fromValue(value) : null
}
/**
* Maps Sched API PriceModel to Nextflow PriceModel.
*
* @param schedPriceModel the Sched API price model
* @return the Nextflow PriceModel, or null if input is null or unknown
*/
static PriceModel toPriceModel(SchedPriceModel schedPriceModel) {
if (schedPriceModel == null)
return null
switch (schedPriceModel) {
case SchedPriceModel.SPOT:
return PriceModel.spot
case SchedPriceModel.STANDARD:
return PriceModel.standard
default:
return null
}
}
}