add nextflow d30e48d
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]'
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user