diff --git a/adr/20260323-hints-process-directive.md b/adr/20260323-hints-process-directive.md new file mode 100644 index 0000000000..504234b61b --- /dev/null +++ b/adr/20260323-hints-process-directive.md @@ -0,0 +1,180 @@ +# `hints` process directive for executor-specific scheduling hints + +- Authors: Rob Syme +- Status: accepted +- Deciders: Paolo Di Tommaso, Ben Sherman, Rob Syme +- Date: 2026-03-23 +- Tags: directive, executor, scheduling + +## Summary + +Introduce a `hints` process directive for executor-specific scheduling hints that don't map to existing directives. + +## Problem Statement + +Many executors can be configured in various ways on a per-task basis. For example: + +- AWS Batch jobs can use *consumable resources* to limit concurrent job execution based on non-standard resources such as software license seats. + +- Google Batch jobs can specify a *provisioning model* to control the use of spot vs on-demand VMs on a per-task basis. + +- Seqera Scheduler supports a variety of resource and scheduling settings, including spot/on-demand provisioning. + +These settings can be exposed by Nextflow as executor-specific config options, such as `google.batch.spot`, but config options are applied globally. In order to apply a setting to specific processes or tasks, it must be exposed as a process directive. + +Process directives in Nextflow aim to provide a common vocabulary for executing tasks in many different environments. Directives such as `cpus`, `memory`, and `time` have broadly the same meaning across most executors, making it easier for users to write portable pipelines. + +At the same time, many executors have custom settings not shared by other executors, and it is not practical to create a new process directive for every new setting. There are over 40 [process directives](https://docs.seqera.io/nextflow/reference/process#directives) at the time of writing, and every new directive adds cognitive load when a user is trying to find the right directive for a given situation. + +There exist a few generic process directives already: + +- The `clusterOptions` directive can be used to specify command-line arguments, primarily for HPC schedulers +- The `ext` directive supports arbitrary key-values, but is designed primarily to customize the task script (e.g. tool arguments), not executor behavior +- The `resourceLabels` directive also supports arbitrary key-values, but is intended for tagging and tracking resources, not controlling them + +A new directive is needed to support executor-specific settings at a per-task level in a structured manner, without bloating the process directives for every new custom setting. + +## Goals + +- Provide a way to apply executor-specific settings to individual processes or tasks + +- Avoid the proliferation of narrow, executor-specific directives (e.g. `consumableResources`, `schedulingPolicy`, etc.) + +- Provide a single extension point that executors can consume selectively + +- Allow settings to be specified as key-values, providing validation where possible + +## Non-goals + +- Replacing existing directives (`cpus`, `memory`, `accelerator`, `queue`) — those remain the right place for standard resources + +## Decision + +Introduce a `hints` process directive with namespaced keys. Executors consume the hints they understand and silently ignore the rest. + +## Core Capabilities + +### Syntax + +The `hints` directive accepts a map of key-value pairs: + +```groovy +// process definition +process runDragen { + cpus 4 + memory '16 GB' + hints consumableResources: ['my-dragen-license': 1, 'other-license': 2] + + script: + """ + dragen --ref-dir /ref ... + """ +} +``` + +```groovy +// process config +process { + withName: 'runDragen' { + hints = [ + consumableResources: ['my-dragen-license': 1, 'other-license': 2] + ] + } +} +``` + +Keys are strings. Values may be any raw data type: strings, numbers, booleans, lists, or maps. Executors are responsible for defining which hints they recognize and what value type each hint expects. + +In the above example, the `consumableResources` hint is given as a map of resource name to quantity. The AWS Batch executor supplies it to each job request using `ConsumableResourceProperties`. + +### Namespacing + +Keys can use dot-separated scopes to namespace settings as needed: + +```groovy +hints consumableResources: ['my-dragen-license': 1] +hints 'scheduling.priority': 10 +hints 'scheduling.provisioningModel': 'spot' +``` + +Keys can be routed to specific executors by prefixing with the executor name and a slash (`/`): + +```groovy +hints 'awsbatch/consumableResources': ['my-dragen-license': 1] +hints 'seqera/scheduling.provisioningModel': 'spot' +hints 'k8s/nodeSelector': 'gpu=true' +``` + +The executor prefix gives pipeline developers the ability to target specific executors and have assurance that it won't accidentally apply to other executors (e.g. if another executor adds support for the same hint in the future). + +### Validation + +Nextflow should validate hints to the best of its ability, to catch errors such as typos: + +- **Prefixed hints** can be validated against the set of hints declared by the corresponding executor. Unrecognized hints should be reported as errors. + +- **Unprefixed hints** can be validated against the union of hints declared by all executors. Since unprefixed hints might be supported by executors that aren't currently loaded, unrecognized hints should be reported as warnings. + +### Multiple hint resolution + +The `hints` directive uses *replacement semantics* when specified multiple times, meaning that each `hints` setting completely replaces any previous settings: + +```groovy +process { + // generic hint + hints = [provisioningModel: 'spot'] + + // specific hint replaces generic hint + withLabel: 'dragen' { + hints = [consumableResources: ['my-dragen-license': 1]] + } +} +``` + +Within a process definition, the `hints` directive uses *accumulation semantics*, meaning that subsequent `hints` directives are accumulated: + +```groovy +process runDragen { + // multiple separate hints + hints provisioningModel: 'spot' + hints consumableResources: ['my-dragen-license': 1, 'other-license': 2] + + // equivalent to... + hints ( + provisioningModel: 'spot', + consumableResources: ['my-dragen-license': 1, 'other-license': 2] + ) + + // ... +} +``` + +This behavior is consistent with other directives such as `pod` and `resourceLabels`. In practice, this means that a given `hints` setting should specify all relevant hints for the given context. + +For example, the `withLabel` selector above should also specify the `provisioningModel` hint if the intention is to preserve that hint for the selected processes: + +```groovy +process { + hints = [provisioningModel: 'spot'] + + withLabel: 'dragen' { + hints = [provisioningModel: 'spot', consumableResources: ['my-dragen-license': 1]] + } +} +``` + +While this approach may lead to duplication, it gives users and developers more control over which hints are applied in a given context. + +### Initial hint catalog + +The following hints should be supported initially: + +| Hint name | Value type | Executors | Use case | +|--|--|--|--| +| `consumableResources` | `Map` | AWS Batch | License-aware scheduling ([#5917](https://github.com/nextflow-io/nextflow/issues/5917)) | +| `scheduling.priority` | `Integer` | AWS Batch | Job scheduling priority ([#6998](https://github.com/nextflow-io/nextflow/issues/6998)) | +| `scheduling.provisioningModel` | `String` | Google Batch | Spot VM scheduling ([#3530](https://github.com/nextflow-io/nextflow/issues/3530)) | + +## Links + +- [Community issue](https://github.com/nextflow-io/nextflow/issues/5917) diff --git a/docs/executor.md b/docs/executor.md index c62f0e21f3..ef9a1606da 100644 --- a/docs/executor.md +++ b/docs/executor.md @@ -33,6 +33,14 @@ Resource requests and other job characteristics can be controlled via the follow - {ref}`process-resourcelabels` - {ref}`process-time` +The following {ref}`hints ` are supported: + +- `consumableResources`: Specify [AWS Batch consumable resources](https://docs.aws.amazon.com/batch/latest/userguide/resource-aware-scheduling.html) as a list of name-value pairs. For example: + + ```nextflow + hints consumableResources: ['my-license-a': 1, 'my-license-b': 2] + ``` + See {ref}`aws-batch` for more information. (azurebatch-executor)= @@ -441,6 +449,37 @@ Resource requests and other job characteristics can be controlled via the follow - {ref}`process-memory` - {ref}`process-time` +The following {ref}`hints ` are supported: + +- `machineRequirement.capacityMode` +- `machineRequirement.diskAllocation` +- `machineRequirement.diskEncrypted` +- `machineRequirement.diskIops` +- `machineRequirement.diskMountPath` +- `machineRequirement.diskSize` +- `machineRequirement.diskThroughputMiBps` +- `machineRequirement.diskType` +- `machineRequirement.machineTypes` +- `machineRequirement.maxSpotAttempts` +- `machineRequirement.provisioning` + +Each hint overrides the corresponding field of the `seqera.executor.machineRequirement` config scope on a per-process basis. Keys may be used as-is or with the `seqera/` prefix to restrict them to this executor. + +For example, to override the provisioning mode for a single process: + +```nextflow +process hello { + hints 'seqera/machineRequirement.provisioning': 'spotFirst' + + script: + """ + your_command --here + """ +} +``` + +See {ref}`config-seqera` for the full config reference. + ### Disk support When the {ref}`process-disk` directive is specified, the Seqera executor provisions storage for the task container. There are two disk allocation strategies: diff --git a/docs/reference/config.md b/docs/reference/config.md index 6519a7764d..c4a3fd56d8 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -1403,6 +1403,12 @@ The `seqera.executor` scope configures the Seqera scheduler service for the {ref The following settings are available: +`seqera.executor.autoLabels` +: When `true`, automatically adds workflow metadata labels to the session with the `nextflow.io/` prefix (default: `false`). The following labels are added: `projectName`, `userName`, `runName`, `sessionId`, `resume`, `revision`, `commitId`, `repository`, `manifestName`, `runtimeVersion`. A `seqera.io/runId` label is also added, computed as a SipHash of the session ID and run name. + +`seqera.executor.computeEnvId` +: The Seqera Platform compute environment ID. When specified, the scheduler resolves the compute environment directly by this ID instead of inferring a suitable compute environment. Used as a fallback when the workflow launch does not include a compute environment reference. + `seqera.executor.endpoint` : The Seqera scheduler service endpoint URL (required). @@ -1412,41 +1418,35 @@ The following settings are available: `seqera.executor.region` : The cloud region for task execution. -`seqera.executor.computeEnvId` -: The Seqera Platform compute environment ID. When specified, the scheduler resolves the compute environment directly by this ID instead of inferring a suitable compute environment. Used as a fallback when the workflow launch does not include a compute environment reference. - -`seqera.executor.autoLabels` -: When `true`, automatically adds workflow metadata labels to the session with the `nextflow.io/` prefix (default: `false`). The following labels are added: `projectName`, `userName`, `runName`, `sessionId`, `resume`, `revision`, `commitId`, `repository`, `manifestName`, `runtimeVersion`. A `seqera.io/runId` label is also added, computed as a SipHash of the session ID and run name. - -`seqera.executor.machineRequirement.provisioning` -: The instance provisioning mode. Can be `'spot'`, `'ondemand'`, or `'spotFirst'`. - -`seqera.executor.machineRequirement.maxSpotAttempts` -: The maximum number of spot retry attempts before falling back to on-demand. Only used when `provisioning` is `'spot'` or `'spotFirst'`. - -`seqera.executor.machineRequirement.machineFamilies` -: List of acceptable EC2 instance families, e.g. `['m5', 'c5', 'r5']`. +`seqera.executor.taskEnvironment` +: 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. For example: `taskEnvironment = [MY_VAR: 'value']`. `seqera.executor.machineRequirement.diskAllocation` : The disk allocation strategy. Can be `'task'` (default) for per-task EBS volumes, or `'node'` for per-node instance storage. When using `'node'` allocation, EBS-specific options (`diskType`, `diskIops`, `diskThroughputMiBps`, `diskEncrypted`) are not applicable. -`seqera.executor.machineRequirement.diskType` -: The EBS volume type for task scratch disk. Supported types: `'ebs/gp3'` (default), `'ebs/gp2'`, `'ebs/io1'`, `'ebs/io2'`, `'ebs/st1'`, `'ebs/sc1'`. Only applicable when `diskAllocation` is `'task'`. - -`seqera.executor.machineRequirement.diskThroughputMiBps` -: The throughput in MiB/s for gp3 volumes (125-1000). Default: `325` (Fusion recommended). Only applicable when `diskAllocation` is `'task'`. +`seqera.executor.machineRequirement.diskEncrypted` +: Enable KMS encryption for the EBS volume (default: `false`). Only applicable when `diskAllocation` is `'task'`. `seqera.executor.machineRequirement.diskIops` : The IOPS for io1/io2/gp3 volumes. Required for io1/io2 volume types. Only applicable when `diskAllocation` is `'task'`. -`seqera.executor.machineRequirement.diskEncrypted` -: Enable KMS encryption for the EBS volume (default: `false`). Only applicable when `diskAllocation` is `'task'`. - `seqera.executor.machineRequirement.diskMountPath` : The container path where the disk is mounted (default: `'/tmp'`). Applicable to all disk allocation strategies. -`seqera.executor.taskEnvironment` -: 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. For example: `taskEnvironment = [MY_VAR: 'value']`. +`seqera.executor.machineRequirement.diskThroughputMiBps` +: The throughput in MiB/s for gp3 volumes (125-1000). Default: `325` (Fusion recommended). Only applicable when `diskAllocation` is `'task'`. + +`seqera.executor.machineRequirement.diskType` +: The EBS volume type for task scratch disk. Supported types: `'ebs/gp3'` (default), `'ebs/gp2'`, `'ebs/io1'`, `'ebs/io2'`, `'ebs/st1'`, `'ebs/sc1'`. Only applicable when `diskAllocation` is `'task'`. + +`seqera.executor.machineRequirement.machineTypes` +: List of acceptable EC2 instance families. For example, `['m5', 'c5', 'r5']`. + +`seqera.executor.machineRequirement.maxSpotAttempts` +: The maximum number of spot retry attempts before falling back to on-demand. Only used when `provisioning` is `'spot'` or `'spotFirst'`. + +`seqera.executor.machineRequirement.provisioning` +: The instance provisioning mode. Can be `'spot'`, `'ondemand'`, or `'spotFirst'`. `seqera.executor.retryPolicy.delay` : The initial delay when a failing HTTP request is retried (default: `'450ms'`). diff --git a/docs/reference/process.md b/docs/reference/process.md index e3e4d6a24d..4032aa1e0d 100644 --- a/docs/reference/process.md +++ b/docs/reference/process.md @@ -840,6 +840,37 @@ The above example produces: [4, D] ``` +(process-hints)= + +### hints + +The `hints` directive specifies executor-specific hints as key-value pairs. Each executor uses the hints it recognizes and ignores the rest. Hint values can be any raw value (i.e., numbers, strings, booleans, lists, and maps). + +Unprefixed keys are available to **every** executor. Any executor that recognizes the key consumes it. Prefixing a key with an executor name (e.g., `awsbatch/...`) restricts the hint to that executor only. For example: + +```nextflow +process hello { + hints consumableResources: ['my-license': 1] + + script: + """ + your_command --here + """ +} +``` + +To restrict a hint to a single executor, prefix the key with the executor name: + +```nextflow + hints 'awsbatch/consumableResources': ['my-license': 1] +``` + +When the same hint is provided both unprefixed and with a matching executor prefix, the prefixed form takes precedence for that executor. + +Calling `hints` multiple times in a process definition accumulates entries, with later calls overwriting entries for the same key. Setting `hints` via configuration (e.g., in `nextflow.config`) replaces the entire map. + +See {ref}`executor-page` to see which hints are recognized by each executor. + (process-label)= ### label diff --git a/modules/nextflow/src/main/groovy/nextflow/processor/HintDefs.groovy b/modules/nextflow/src/main/groovy/nextflow/processor/HintDefs.groovy new file mode 100644 index 0000000000..a3e72d6a7b --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/processor/HintDefs.groovy @@ -0,0 +1,79 @@ +/* + * 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 nextflow.processor + +import groovy.transform.CompileStatic + +/** + * Validates the shape of the {@code hints} process directive. + * + * The core is intentionally agnostic about which hint keys are supported: + * each executor validates the keys it recognizes (prefixed with its own + * namespace, e.g. {@code awsbatch/...}, {@code seqera/...}). This class + * only enforces that the map conforms to {@code Map} with + * raw data type values. + * + * @author Paolo Di Tommaso + */ +@CompileStatic +class HintDefs { + + /** + * Validates the hint map structure. Does not check whether keys are + * recognized — that is the responsibility of each executor. + * + * Rules: + *
    + *
  • keys must be non-empty
  • + *
  • keys may contain at most one {@code /} separating the optional + * executor namespace from the hint name
  • + *
  • values must be a raw data type (String, Number, Boolean, List, + * Map) or {@code null}
  • + *
+ * + * @param hints the hint map to validate (may be {@code null}) + * @throws IllegalArgumentException if the map is malformed + */ + static void validateHints(Map hints) { + if( !hints ) + return + + for( final entry : hints.entrySet() ) { + final key = entry.key + final value = entry.value + + if( !key ) + throw new IllegalArgumentException("Process hint key cannot be null or empty") + + if( key.count('/') > 1 ) + throw new IllegalArgumentException("Invalid hint key '${key}': expected 'name' or 'executor/name'") + + if( !isValidHintValue(value) ) + throw new IllegalArgumentException("Invalid hint value for key '${key}': expected String, Number, Boolean, List, or Map, got ${value.getClass().getName()}") + } + } + + private static boolean isValidHintValue(Object value) { + return value == null + || value instanceof CharSequence + || value instanceof Number + || value instanceof Boolean + || value instanceof List + || value instanceof Map + } + +} diff --git a/modules/nextflow/src/main/groovy/nextflow/processor/TaskConfig.groovy b/modules/nextflow/src/main/groovy/nextflow/processor/TaskConfig.groovy index 27bdd95f98..1f79ac1c3b 100644 --- a/modules/nextflow/src/main/groovy/nextflow/processor/TaskConfig.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/processor/TaskConfig.groovy @@ -529,6 +529,10 @@ class TaskConfig extends LazyMap implements Cloneable { return CmdLineOptionMap.emptyOption() } + Map getHints() { + return get('hints') as Map ?: Collections.emptyMap() + } + Map getResourceLabels() { return get('resourceLabels') as Map ?: Collections.emptyMap() } diff --git a/modules/nextflow/src/main/groovy/nextflow/script/ProcessConfig.groovy b/modules/nextflow/src/main/groovy/nextflow/script/ProcessConfig.groovy index 81acfe7132..a56feccced 100644 --- a/modules/nextflow/src/main/groovy/nextflow/script/ProcessConfig.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/script/ProcessConfig.groovy @@ -173,6 +173,10 @@ class ProcessConfig implements Map, Cloneable { HashMode.of(configProperties.cache) ?: HashMode.DEFAULT() } + Map getHints() { + (configProperties.get('hints') ?: Collections.emptyMap()) as Map + } + Map getResourceLabels() { (configProperties.get('resourceLabels') ?: Collections.emptyMap()) as Map } diff --git a/modules/nextflow/src/main/groovy/nextflow/script/dsl/ProcessBuilder.groovy b/modules/nextflow/src/main/groovy/nextflow/script/dsl/ProcessBuilder.groovy index 2f63c30d62..6c353b8374 100644 --- a/modules/nextflow/src/main/groovy/nextflow/script/dsl/ProcessBuilder.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/script/dsl/ProcessBuilder.groovy @@ -25,6 +25,7 @@ import nextflow.exception.IllegalDirectiveException import nextflow.exception.ScriptRuntimeException import nextflow.processor.ConfigList import nextflow.processor.ErrorStrategy +import nextflow.processor.HintDefs import nextflow.script.BaseScript import nextflow.script.BodyDef import nextflow.script.ProcessConfig @@ -59,6 +60,7 @@ class ProcessBuilder { 'executor', 'ext', 'fair', + 'hints', 'label', 'machineType', 'maxErrors', @@ -224,6 +226,26 @@ class ProcessBuilder { config.put('errorStrategy', strategy) } + /** + * Implements the {@code hints} directive. + * + * This directive can be specified (invoked) multiple times in + * the process definition. Multiple calls accumulate entries. + * + * @param map + */ + void hints(Map map) { + if( !map ) return + HintDefs.validateHints(map) + + def allHints = (Map)config.get('hints') + if( !allHints ) { + allHints = [:] + } + allHints += map + config.put('hints', allHints) + } + /** * Implements the {@code label} directive. * diff --git a/modules/nextflow/src/test/groovy/nextflow/processor/HintDefsTest.groovy b/modules/nextflow/src/test/groovy/nextflow/processor/HintDefsTest.groovy new file mode 100644 index 0000000000..733d041278 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/processor/HintDefsTest.groovy @@ -0,0 +1,80 @@ +/* + * 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 nextflow.processor + +import spock.lang.Specification + +/** + * Tests for {@link HintDefs} + * + * @author Paolo Di Tommaso + */ +class HintDefsTest extends Specification { + + def 'should accept valid hints'() { + when: + HintDefs.validateHints([ + consumableResources: ['my-license': 1], + 'awsbatch/consumableResources': ['a': 1, 'b': 2], + 'seqera/machineRequirement.diskEncrypted': true, + 'seqera/machineRequirement.machineTypes': ['m5', 'm6i'], + 'seqera/machineRequirement.priority': 10, + 'seqera/machineRequirement.provisioning': 'spot', + ]) + then: + noExceptionThrown() + } + + def 'should accept null and empty maps'() { + when: + HintDefs.validateHints(null) + HintDefs.validateHints([:]) + then: + noExceptionThrown() + } + + def 'should accept null hint value'() { + when: + HintDefs.validateHints([consumableResources: null]) + then: + noExceptionThrown() + } + + def 'should reject closure value'() { + when: + HintDefs.validateHints([consumableResources: { 'x' }]) + then: + thrown(IllegalArgumentException) + } + + def 'should reject empty key'() { + when: + HintDefs.validateHints(['': 'x']) + then: + def e = thrown(IllegalArgumentException) + e.message.contains("null or empty") + } + + def 'should reject multi-segment key'() { + when: + HintDefs.validateHints(['a/b/c': 'x']) + then: + def e = thrown(IllegalArgumentException) + e.message.contains("a/b/c") + } + +} diff --git a/modules/nextflow/src/test/groovy/nextflow/processor/TaskConfigTest.groovy b/modules/nextflow/src/test/groovy/nextflow/processor/TaskConfigTest.groovy index 5703eecab4..afd01afb64 100644 --- a/modules/nextflow/src/test/groovy/nextflow/processor/TaskConfigTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/processor/TaskConfigTest.groovy @@ -622,6 +622,49 @@ class TaskConfigTest extends Specification { config.getResourceLabelsAsString() == 'region=eu-west-1,organization=A,user=this,team=that' } + def 'should configure hints options'() { + given: + def script = Mock(BaseScript) + + when: + def process = new ProcessConfig(script) + def dsl = new ProcessBuilder(process) + dsl.hints( 'seqera/machineRequirement.arch': 'arm64', consumableResources: ['my-license': 1] ) + + then: + process.get('hints') == ['seqera/machineRequirement.arch': 'arm64', consumableResources: ['my-license': 1]] + + when: + def config = process.createTaskConfig() + then: + config.getHints() == ['seqera/machineRequirement.arch': 'arm64', consumableResources: ['my-license': 1]] + } + + def 'should return empty map when no hints set'() { + when: + def config = new TaskConfig([:]) + then: + config.getHints() == [:] + } + + def 'should replace hints via config override'() { + given: + def script = Mock(BaseScript) + + when: 'set hints in process definition' + def process = new ProcessConfig(script) + def dsl = new ProcessBuilder(process) + dsl.hints( 'seqera/machineRequirement.arch': 'arm64', consumableResources: ['my-license': 1] ) + then: + process.getHints() == ['seqera/machineRequirement.arch': 'arm64', consumableResources: ['my-license': 1]] + + when: 'config override replaces the entire map' + def config = process.createTaskConfig() + config.put('hints', ['scheduling.priority': 5]) + then: + config.getHints() == ['scheduling.priority': 5] + } + def 'should report error on negative cpus' () { when: def config = new TaskConfig([cpus:-1]) diff --git a/modules/nextflow/src/test/groovy/nextflow/script/dsl/ProcessBuilderTest.groovy b/modules/nextflow/src/test/groovy/nextflow/script/dsl/ProcessBuilderTest.groovy index a25a8144a0..9767bc2f96 100644 --- a/modules/nextflow/src/test/groovy/nextflow/script/dsl/ProcessBuilderTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/script/dsl/ProcessBuilderTest.groovy @@ -171,6 +171,50 @@ class ProcessBuilderTest extends Specification { } + def 'should apply hints config' () { + given: + def builder = createBuilder() + def config = builder.getConfig() + expect: + config.getHints() == [:] + + when: + builder.hints 'seqera/machineRequirement.arch': 'arm64' + then: + config.getHints() == ['seqera/machineRequirement.arch': 'arm64'] + + when: + builder.hints 'seqera/machineRequirement.provisioning': 'spot', 'seqera/machineRequirement.maxSpotAttempts': '3' + then: + config.getHints() == ['seqera/machineRequirement.arch': 'arm64', 'seqera/machineRequirement.provisioning': 'spot', 'seqera/machineRequirement.maxSpotAttempts': '3'] + + when: 'duplicate key overwrites' + builder.hints 'seqera/machineRequirement.arch': 'x86_64' + then: + config.getHints() == ['seqera/machineRequirement.arch': 'x86_64', 'seqera/machineRequirement.provisioning': 'spot', 'seqera/machineRequirement.maxSpotAttempts': '3'] + } + + def 'should reject closure hint values' () { + given: + def builder = createBuilder() + + when: + builder.hints 'seqera/machineRequirement.provisioning': { 'spot' } + then: + thrown(IllegalArgumentException) + } + + def 'should accept number and boolean hint values' () { + given: + def builder = createBuilder() + + when: + builder.hints 'seqera/machineRequirement.maxSpotAttempts': 3 + builder.hints 'seqera/machineRequirement.diskEncrypted': true + then: + noExceptionThrown() + } + def 'should check a valid label' () { expect: diff --git a/modules/nf-lang/src/main/java/nextflow/script/dsl/ProcessDsl.java b/modules/nf-lang/src/main/java/nextflow/script/dsl/ProcessDsl.java index a75a875b3a..d296abc3a5 100644 --- a/modules/nf-lang/src/main/java/nextflow/script/dsl/ProcessDsl.java +++ b/modules/nf-lang/src/main/java/nextflow/script/dsl/ProcessDsl.java @@ -193,6 +193,13 @@ void disk( """) void fair(Boolean value); + @Description(""" + The `hints` directive specifies executor-specific hints as key-value pairs. Keys may be namespaced with an `executor/` prefix to target a specific executor. + + [Read more](https://nextflow.io/docs/latest/reference/process.html#hints) + """) + void hints(Map value); + @Description(""" The `label` directive allows you to annotate a process with a mnemonic identifier of your choice. diff --git a/modules/nf-lang/src/main/java/nextflow/script/types/TaskConfig.java b/modules/nf-lang/src/main/java/nextflow/script/types/TaskConfig.java index 4e5e8dc8db..5db812e75d 100644 --- a/modules/nf-lang/src/main/java/nextflow/script/types/TaskConfig.java +++ b/modules/nf-lang/src/main/java/nextflow/script/types/TaskConfig.java @@ -224,6 +224,14 @@ public interface TaskConfig { """) boolean getFair(); + @Constant("hints") + @Description(""" + The `hints` directive specifies executor-specific hints as key-value pairs. Keys may be namespaced with an `executor/` prefix to target a specific executor. + + [Read more](https://nextflow.io/docs/latest/reference/process.html#hints) + """) + Map getHints(); + @Constant("label") @Description(""" The `label` directive allows you to annotate a process with a mnemonic identifier of your choice. diff --git a/plugins/nf-amazon/src/main/nextflow/cloud/aws/batch/AwsBatchTaskHandler.groovy b/plugins/nf-amazon/src/main/nextflow/cloud/aws/batch/AwsBatchTaskHandler.groovy index 3d6cb2810d..6cbc6d7c2b 100644 --- a/plugins/nf-amazon/src/main/nextflow/cloud/aws/batch/AwsBatchTaskHandler.groovy +++ b/plugins/nf-amazon/src/main/nextflow/cloud/aws/batch/AwsBatchTaskHandler.groovy @@ -55,6 +55,8 @@ import software.amazon.awssdk.services.batch.model.AssignPublicIp import software.amazon.awssdk.services.batch.model.AttemptContainerDetail import software.amazon.awssdk.services.batch.model.BatchException import software.amazon.awssdk.services.batch.model.ClientException +import software.amazon.awssdk.services.batch.model.ConsumableResourceProperties +import software.amazon.awssdk.services.batch.model.ConsumableResourceRequirement import software.amazon.awssdk.services.batch.model.ContainerOverrides import software.amazon.awssdk.services.batch.model.DescribeJobDefinitionsRequest import software.amazon.awssdk.services.batch.model.DescribeJobDefinitionsResponse @@ -623,12 +625,20 @@ class AwsBatchTaskHandler extends TaskHandler implements BatchHandler KNOWN_HINTS = Set.of('consumableResources') + private static final String SUPPORTED_HINTS_MSG = + KNOWN_HINTS.collect { HINT_PREFIX + it }.sort().join(', ') + + @CompileStatic + protected ConsumableResourceProperties getConsumableResources(Map hints) { + if( !hints ) + return null + warnUnknownHints(hints) + final raw = hints.get(HINT_PREFIX + 'consumableResources') ?: hints.get('consumableResources') + if( !raw ) + return null + if( !(raw instanceof Map) ) + throw new IllegalArgumentException("Invalid 'consumableResources' hint: expected a map of resource name to quantity") + final resourceMap = (Map)raw + final List resourceList = new ArrayList<>() + for( Map.Entry entry : resourceMap.entrySet() ) { + final resourceName = entry.key?.toString() + if( !resourceName ) + throw new IllegalArgumentException("Invalid 'consumableResources' hint: resource name cannot be empty") + final value = entry.value + if( !(value instanceof Number) ) + throw new IllegalArgumentException("Invalid 'consumableResources' hint entry '${resourceName}': quantity must be a number") + resourceList.add( ConsumableResourceRequirement.builder() + .consumableResource(resourceName) + .quantity(((Number)value).longValue()) + .build() ) + } + if( !resourceList ) + return null + return ConsumableResourceProperties.builder() + .consumableResourceList(resourceList) + .build() + } + + @CompileStatic + protected void warnUnknownHints(Map hints) { + for( final key : hints.keySet() ) { + if( !key?.startsWith(HINT_PREFIX) ) + continue + if( !KNOWN_HINTS.contains(key.substring(HINT_PREFIX.length())) ) + log.warn1("Unknown AWS Batch hint: '${key}' -- supported keys are: ${SUPPORTED_HINTS_MSG}") + } + } + /** * Look for a Batch job definition in ACTIVE status for the given name and NF job definition ID * diff --git a/plugins/nf-amazon/src/main/nextflow/cloud/aws/batch/model/RegisterJobDefinitionModel.groovy b/plugins/nf-amazon/src/main/nextflow/cloud/aws/batch/model/RegisterJobDefinitionModel.groovy index 2ef91878d6..30f386821a 100644 --- a/plugins/nf-amazon/src/main/nextflow/cloud/aws/batch/model/RegisterJobDefinitionModel.groovy +++ b/plugins/nf-amazon/src/main/nextflow/cloud/aws/batch/model/RegisterJobDefinitionModel.groovy @@ -18,6 +18,7 @@ package nextflow.cloud.aws.batch.model import groovy.transform.CompileStatic +import software.amazon.awssdk.services.batch.model.ConsumableResourceProperties import software.amazon.awssdk.services.batch.model.JobDefinitionType import software.amazon.awssdk.services.batch.model.PlatformCapability import software.amazon.awssdk.services.batch.model.RegisterJobDefinitionRequest @@ -45,6 +46,8 @@ class RegisterJobDefinitionModel { private Map tags + private ConsumableResourceProperties consumableResourceProperties + RegisterJobDefinitionModel jobDefinitionName(String value) { this.jobDefinitionName = value return this @@ -82,6 +85,11 @@ class RegisterJobDefinitionModel { return this } + RegisterJobDefinitionModel consumableResourceProperties(ConsumableResourceProperties value) { + this.consumableResourceProperties = value + return this + } + String getJobDefinitionName() { return jobDefinitionName } @@ -106,6 +114,10 @@ class RegisterJobDefinitionModel { return tags } + ConsumableResourceProperties getConsumableResourceProperties() { + return consumableResourceProperties + } + RegisterJobDefinitionRequest toBatchRequest() { final builder = RegisterJobDefinitionRequest.builder() @@ -117,6 +129,8 @@ class RegisterJobDefinitionModel { builder.platformCapabilities(platformCapabilities) if (containerProperties) builder.containerProperties(containerProperties.toBatchContainerProperties()) + if (consumableResourceProperties) + builder.consumableResourceProperties(consumableResourceProperties) if (parameters) builder.parameters(parameters) if (tags) @@ -134,6 +148,7 @@ class RegisterJobDefinitionModel { ", containerProperties=" + containerProperties + ", parameters=" + parameters + ", tags=" + tags + + ", consumableResourceProperties=" + consumableResourceProperties + '}'; } } diff --git a/plugins/nf-amazon/src/test/nextflow/cloud/aws/batch/AwsBatchTaskHandlerTest.groovy b/plugins/nf-amazon/src/test/nextflow/cloud/aws/batch/AwsBatchTaskHandlerTest.groovy index d5b9bee22f..85a79d9be9 100644 --- a/plugins/nf-amazon/src/test/nextflow/cloud/aws/batch/AwsBatchTaskHandlerTest.groovy +++ b/plugins/nf-amazon/src/test/nextflow/cloud/aws/batch/AwsBatchTaskHandlerTest.groovy @@ -598,6 +598,87 @@ class AwsBatchTaskHandlerTest extends Specification { result.containerProperties.mountPoints[0].readOnly() result.containerProperties.volumes[0].host().sourcePath() == '/home/conda' result.containerProperties.volumes[0].name() == 'aws-cli' + result.consumableResourceProperties == null + } + + def 'should create a job definition with consumable resources from hints' () { + given: 'hints set through the real DSL → ProcessConfig → TaskConfig path' + def process = new ProcessConfig(Mock(BaseScript)) + new nextflow.script.dsl.ProcessBuilder(process).hints( + 'awsbatch/consumableResources': ['license-a': 1, 'license-b': 2], + foo: 'bar' + ) + def taskConfig = process.createTaskConfig() + and: + def IMAGE = 'foo/bar:1.0' + def JOB_NAME = 'nf-foo-bar-1-0' + def task = Mock(TaskRun) { + getContainer() >> IMAGE + getConfig() >> taskConfig + } + def handler = Spy(AwsBatchTaskHandler) { + getTask() >> task + fusionEnabled() >> false + } + handler.@executor = Mock(AwsBatchExecutor) + + when: + def result = handler.makeJobDefRequest(task) + then: + 1 * handler.normalizeJobDefinitionName(IMAGE) >> JOB_NAME + 1 * handler.getAwsOptions() >> new AwsOptions() + result.consumableResourceProperties != null + result.consumableResourceProperties.consumableResourceList().size() == 2 + result.consumableResourceProperties.consumableResourceList()[0].consumableResource() == 'license-a' + result.consumableResourceProperties.consumableResourceList()[0].quantity() == 1 + result.consumableResourceProperties.consumableResourceList()[1].consumableResource() == 'license-b' + result.consumableResourceProperties.consumableResourceList()[1].quantity() == 2 + } + + def 'should apply awsbatch/-prefixed consumableResources over unprefixed' () { + given: + def handler = new AwsBatchTaskHandler() + + when: + def result = handler.getConsumableResources([ + consumableResources: ['legacy': 9], + 'awsbatch/consumableResources': ['license-a': 1, 'license-b': 2], + ]) + then: + result.consumableResourceList().size() == 2 + result.consumableResourceList()[0].consumableResource() == 'license-a' + result.consumableResourceList()[0].quantity() == 1 + result.consumableResourceList()[1].consumableResource() == 'license-b' + result.consumableResourceList()[1].quantity() == 2 + } + + def 'should return null when hints empty or no consumableResources' () { + given: + def handler = new AwsBatchTaskHandler() + + expect: + handler.getConsumableResources(null) == null + handler.getConsumableResources([:]) == null + handler.getConsumableResources([foo: 'bar']) == null + handler.getConsumableResources([consumableResources: null]) == null + handler.getConsumableResources([consumableResources: [:]]) == null + } + + def 'should fail with a clear error on invalid consumableResources value' () { + given: + def handler = new AwsBatchTaskHandler() + + when: 'value is a string instead of a map' + handler.getConsumableResources([consumableResources: 'bad-string']) + then: + def e = thrown(IllegalArgumentException) + e.message.contains("expected a map") + + when: 'quantity is not a number' + handler.getConsumableResources([consumableResources: ['license-a': 'not-a-number']]) + then: + def e2 = thrown(IllegalArgumentException) + e2.message.contains("quantity must be a number") } def 'should create a fargate job definition' () { diff --git a/plugins/nf-seqera/src/main/io/seqera/executor/SeqeraTaskHandler.groovy b/plugins/nf-seqera/src/main/io/seqera/executor/SeqeraTaskHandler.groovy index 6fd85ae211..cc22778677 100644 --- a/plugins/nf-seqera/src/main/io/seqera/executor/SeqeraTaskHandler.groovy +++ b/plugins/nf-seqera/src/main/io/seqera/executor/SeqeraTaskHandler.groovy @@ -31,6 +31,7 @@ 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 @@ -114,8 +115,13 @@ class SeqeraTaskHandler extends TaskHandler implements FusionAwareTask { resourceReq.acceleratorName(accelerator.type) } // build machine requirement merging config settings with task arch, disk, and snapshot settings - final machineReq = SchemaMapperUtil.toMachineRequirement( + // 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() diff --git a/plugins/nf-seqera/src/main/io/seqera/util/HintHelper.groovy b/plugins/nf-seqera/src/main/io/seqera/util/HintHelper.groovy new file mode 100644 index 0000000000..85fd29b202 --- /dev/null +++ b/plugins/nf-seqera/src/main/io/seqera/util/HintHelper.groovy @@ -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 + */ +@CompileStatic +class HintHelper { + + static final String PREFIX = 'seqera/' + static final String MR_PREFIX = 'machineRequirement.' + + private static final List MR_FIELDS = Collections.unmodifiableList( + MachineRequirementOpts.declaredFields + .findAll { Field f -> f.isAnnotationPresent(ConfigOption) } + .collect { Field f -> f.setAccessible(true); f } as List + ) + + static final Set 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. + * + *

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).

+ * + *

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.

+ * + * @param hints the full hints map from task config + * @return a map of known hint names (no prefix) to values + */ + static Map extractSeqeraHints(Map hints) { + if( !hints ) + return Collections.emptyMap() + + final unprefixed = new LinkedHashMap() + final prefixed = new LinkedHashMap() + for( Map.Entry 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 hints) { + final seqeraHints = extractSeqeraHints(hints) + if( !seqeraHints ) + return baseOpts + + final Map merged = new LinkedHashMap<>() + for( final field : MR_FIELDS ) { + final value = field.get(baseOpts) + if( value != null ) + merged.put(field.name, value) + } + + for( Map.Entry 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) + } + +} diff --git a/plugins/nf-seqera/src/test/io/seqera/executor/SeqeraTaskHandlerTest.groovy b/plugins/nf-seqera/src/test/io/seqera/executor/SeqeraTaskHandlerTest.groovy index 1773ac8ae6..7aec5840a1 100644 --- a/plugins/nf-seqera/src/test/io/seqera/executor/SeqeraTaskHandlerTest.groovy +++ b/plugins/nf-seqera/src/test/io/seqera/executor/SeqeraTaskHandlerTest.groovy @@ -18,11 +18,13 @@ package io.seqera.executor import com.google.common.hash.HashCode import io.seqera.config.ExecutorOpts +import io.seqera.config.MachineRequirementOpts import io.seqera.sched.api.schema.v1a1.DescribeTaskResponse import io.seqera.sched.api.schema.v1a1.GetTaskLogsResponse import io.seqera.sched.api.schema.v1a1.MachineInfo import io.seqera.sched.api.schema.v1a1.NextflowTask import io.seqera.sched.api.schema.v1a1.PriceModel as SchedPriceModel +import io.seqera.sched.api.schema.v1a1.ProvisioningModel import io.seqera.sched.api.schema.v1a1.ResourceLimit import io.seqera.sched.api.schema.v1a1.ResourceRequirement import io.seqera.sched.api.schema.v1a1.Task @@ -860,6 +862,77 @@ class SeqeraTaskHandlerTest extends Specification { captured.getLabels() == [region: 'us-east-1'] } + def 'submit overlays seqera hints onto config-scope machine requirement'() { + given: + Task captured = null + def handler = createSubmitHandler( + hints: hints, + baseMachineReq: new MachineRequirementOpts([provisioning: 'ondemand']), + onSubmit: { captured = it }, + ) + + when: + handler.submit() + then: + captured.getMachineRequirement().getProvisioning() == expected + + where: + hints | expected + ['seqera/machineRequirement.provisioning': 'spotFirst'] | ProvisioningModel.SPOT_FIRST + ['machineRequirement.provisioning': 'spotFirst'] | ProvisioningModel.SPOT_FIRST + ['awsbatch/consumableResources': 'license-a=1'] | ProvisioningModel.ONDEMAND + } + + def 'submit fails on unknown seqera/-prefixed hint'() { + given: + def handler = createSubmitHandler(hints: ['seqera/machineRequirement.bogus': 'x']) + + when: + handler.submit() + then: + def e = thrown(IllegalArgumentException) + e.message.contains('seqera/machineRequirement.bogus') + } + + private SeqeraTaskHandler createSubmitHandler(Map args) { + final hints = args.hints as Map ?: [:] + final baseMachineReq = args.baseMachineReq as MachineRequirementOpts + final Closure onSubmit = args.onSubmit as Closure ?: {} + + def taskConfig = Mock(TaskConfig) { + getCpus() >> 1 + getResourceLabels() >> [:] + getResourceLimit(_) >> null + getHints() >> hints + } + def taskRun = Mock(TaskRun) { + getConfig() >> taskConfig + getWorkDir() >> Paths.get('/work/ab/cd1234') + getWorkDirStr() >> '/work/ab/cd1234' + getContainer() >> 'ubuntu:latest' + getId() >> TaskId.of(1) + getHash() >> HashCode.fromInt(1) + lazyName() >> 'sample_task' + } + def executor = Mock(SeqeraExecutor) { + getClient() >> Mock(SchedClient) + getBatchSubmitter() >> Mock(SeqeraBatchSubmitter) { + submit(_, _) >> { a -> onSubmit.call(a[1] as Task) } + } + getSeqeraConfig() >> Mock(ExecutorOpts) { + getMachineRequirement() >> baseMachineReq + } + getRunResourceLabels() >> [:] + } + return Spy(new SeqeraTaskHandler(taskRun, executor)) { + fusionEnabled() >> true + fusionSubmitCli() >> ['/bin/sh', '-c', 'true'] + fusionLauncher() >> Mock(nextflow.fusion.FusionScriptLauncher) { + fusionEnv() >> [:] + } + } + } + def 'submit leaves Task.labels unset when the task labels equal the run baseline'() { given: Task captured = null diff --git a/plugins/nf-seqera/src/test/io/seqera/util/HintHelperTest.groovy b/plugins/nf-seqera/src/test/io/seqera/util/HintHelperTest.groovy new file mode 100644 index 0000000000..26efe36798 --- /dev/null +++ b/plugins/nf-seqera/src/test/io/seqera/util/HintHelperTest.groovy @@ -0,0 +1,269 @@ +/* + * 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 io.seqera.config.MachineRequirementOpts +import nextflow.util.MemoryUnit +import spock.lang.Specification + +/** + * Tests for {@link HintHelper} + * + * @author Paolo Di Tommaso + */ +class HintHelperTest extends Specification { + + def 'should return base opts when no hints'() { + given: + def base = new MachineRequirementOpts([provisioning: 'spot']) + + when: + def result = HintHelper.overlayHints(base, [:]) + then: + result.provisioning == 'spot' + } + + def 'should return base opts when no seqera hints'() { + given: + def base = new MachineRequirementOpts([provisioning: 'spot']) + + when: + def result = HintHelper.overlayHints(base, [consumableResources: 'my-license']) + then: + result.provisioning == 'spot' + } + + def 'should overlay provisioning hint'() { + given: + def base = new MachineRequirementOpts([provisioning: 'ondemand']) + + when: + def result = HintHelper.overlayHints(base, ['seqera/machineRequirement.provisioning': 'spotFirst']) + then: + result.provisioning == 'spotFirst' + } + + def 'should overlay maxSpotAttempts hint'() { + given: + def base = new MachineRequirementOpts([:]) + + when: + def result = HintHelper.overlayHints(base, ['seqera/machineRequirement.maxSpotAttempts': 3]) + then: + result.maxSpotAttempts == 3 + } + + def 'should overlay machineTypes as list'() { + given: + def base = new MachineRequirementOpts([:]) + + when: + def result = HintHelper.overlayHints(base, ['seqera/machineRequirement.machineTypes': ['m5', 'm5a', 'm6i']]) + then: + result.machineTypes == ['m5', 'm5a', 'm6i'] + } + + def 'should overlay diskType hint'() { + given: + def base = new MachineRequirementOpts([:]) + + when: + def result = HintHelper.overlayHints(base, ['seqera/machineRequirement.diskType': 'ebs/gp3']) + then: + result.diskType == 'ebs/gp3' + } + + def 'should overlay diskThroughputMiBps hint'() { + given: + def base = new MachineRequirementOpts([:]) + + when: + def result = HintHelper.overlayHints(base, ['seqera/machineRequirement.diskThroughputMiBps': 500]) + then: + result.diskThroughputMiBps == 500 + } + + def 'should overlay diskIops hint'() { + given: + def base = new MachineRequirementOpts([:]) + + when: + def result = HintHelper.overlayHints(base, ['seqera/machineRequirement.diskIops': 10000]) + then: + result.diskIops == 10000 + } + + def 'should overlay diskEncrypted hint as boolean'() { + given: + def base = new MachineRequirementOpts([:]) + + when: + def result = HintHelper.overlayHints(base, ['seqera/machineRequirement.diskEncrypted': true]) + then: + result.diskEncrypted == true + } + + def 'should overlay diskAllocation hint'() { + given: + def base = new MachineRequirementOpts([:]) + + when: + def result = HintHelper.overlayHints(base, ['seqera/machineRequirement.diskAllocation': 'node']) + then: + result.diskAllocation == 'node' + } + + def 'should overlay diskMountPath hint'() { + given: + def base = new MachineRequirementOpts([:]) + + when: + def result = HintHelper.overlayHints(base, ['seqera/machineRequirement.diskMountPath': '/data']) + then: + result.diskMountPath == '/data' + } + + def 'should overlay diskSize hint'() { + given: + def base = new MachineRequirementOpts([:]) + + when: + def result = HintHelper.overlayHints(base, ['seqera/machineRequirement.diskSize': '100.GB']) + then: + result.diskSize == MemoryUnit.of('100.GB') + } + + def 'should overlay capacityMode hint'() { + given: + def base = new MachineRequirementOpts([:]) + + when: + def result = HintHelper.overlayHints(base, ['seqera/machineRequirement.capacityMode': 'asg']) + then: + result.capacityMode == 'asg' + } + + def 'should overlay multiple hints at once'() { + given: + def base = new MachineRequirementOpts([provisioning: 'ondemand']) + + when: + def result = HintHelper.overlayHints(base, [ + 'seqera/machineRequirement.provisioning': 'spotFirst', + 'seqera/machineRequirement.maxSpotAttempts': 3, + 'seqera/machineRequirement.diskType': 'ebs/gp3' + ]) + then: + result.provisioning == 'spotFirst' + result.maxSpotAttempts == 3 + result.diskType == 'ebs/gp3' + } + + def 'should preserve base values not overridden by hints'() { + given: + def base = new MachineRequirementOpts([provisioning: 'spot', diskType: 'ebs/gp3', diskMountPath: '/data']) + + when: + def result = HintHelper.overlayHints(base, ['seqera/machineRequirement.diskType': 'ebs/io1']) + then: + result.provisioning == 'spot' + result.diskType == 'ebs/io1' + result.diskMountPath == '/data' + } + + def 'should derive known keys from MachineRequirementOpts declared fields'() { + expect: 'KNOWN_KEYS covers every declared field of MachineRequirementOpts' + HintHelper.KNOWN_KEYS.size() > 0 + for( final field : MachineRequirementOpts.declaredFields ) { + if( field.synthetic || java.lang.reflect.Modifier.isStatic(field.modifiers) || field.name.startsWith('$') || field.name == 'metaClass' ) + continue + assert HintHelper.KNOWN_KEYS.contains("machineRequirement.${field.name}".toString()) + } + } + + def 'should error on unknown seqera hint'() { + when: + HintHelper.extractSeqeraHints(['seqera/machineRequirement.unknownField': 'value']) + then: + def e = thrown(IllegalArgumentException) + e.message.contains('Unknown Seqera Platform hint') + e.message.contains('seqera/machineRequirement.unknownField') + } + + def 'should ignore non-seqera hints in extraction'() { + when: + def result = HintHelper.extractSeqeraHints([ + consumableResources: 'my-license', + 'k8s/scheduling.nodeSelector': 'gpu=true', + 'seqera/machineRequirement.provisioning': 'spot' + ]) + then: + result.size() == 1 + result['machineRequirement.provisioning'] == 'spot' + } + + def 'should handle null hints map'() { + when: + def result = HintHelper.extractSeqeraHints(null) + then: + result.isEmpty() + } + + def 'should accept unprefixed known keys'() { + when: + def result = HintHelper.extractSeqeraHints([ + 'machineRequirement.provisioning': 'spot', + 'machineRequirement.diskType': 'ebs/gp3', + ]) + then: + result['machineRequirement.provisioning'] == 'spot' + result['machineRequirement.diskType'] == 'ebs/gp3' + } + + def 'should give prefixed form precedence over unprefixed'() { + when: + def result = HintHelper.extractSeqeraHints([ + 'machineRequirement.provisioning': 'ondemand', + 'seqera/machineRequirement.provisioning': 'spotFirst', + ]) + then: + result['machineRequirement.provisioning'] == 'spotFirst' + } + + def 'should overlay unprefixed hint onto base opts'() { + given: + def base = new MachineRequirementOpts([provisioning: 'ondemand']) + + when: + def result = HintHelper.overlayHints(base, ['machineRequirement.provisioning': 'spotFirst']) + then: + result.provisioning == 'spotFirst' + } + + def 'should ignore unknown unprefixed keys'() { + when: + def result = HintHelper.extractSeqeraHints([ + consumableResources: 'license-a=1', + somethingElse: 'x', + 'machineRequirement.provisioning': 'spot', + ]) + then: + result.size() == 1 + result['machineRequirement.provisioning'] == 'spot' + } + +}